session-analysis.ts

  1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
  3//
  4// SPDX-License-Identifier: MIT
  5
  6import {
  7	getAgentDir,
  8	SettingsManager,
  9	type ExtensionAPI,
 10	type ExtensionContext,
 11	type SessionEntry,
 12} from "@earendil-works/pi-coding-agent";
 13import * as path from "node:path";
 14
 15/**
 16 * Model override sources for handoff extraction calls. Use provider/modelId
 17 * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is:
 18 * --handoff-model, PI_HANDOFF_MODEL, user settings handoffModel, session model.
 19 */
 20const HANDOFF_MODEL_FLAG = "handoff-model";
 21const HANDOFF_MODEL_ENV = "PI_HANDOFF_MODEL";
 22const HANDOFF_MODEL_SETTING = "handoffModel";
 23
 24export function registerHandoffModelFlag(pi: ExtensionAPI): void {
 25	pi.registerFlag(HANDOFF_MODEL_FLAG, {
 26		description: "Model for handoff extraction as provider/modelId",
 27		type: "string",
 28	});
 29}
 30
 31function nonEmptyString(value: string | undefined): string | undefined {
 32	const trimmed = value?.trim();
 33	return trimmed ? trimmed : undefined;
 34}
 35
 36function stringProperty(source: object, key: string): string | undefined {
 37	const descriptor = Object.getOwnPropertyDescriptor(source, key);
 38	if (!descriptor || !("value" in descriptor)) return undefined;
 39	return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined;
 40}
 41
 42function userSettingsHandoffModel(cwd: string): string | undefined {
 43	const settingsManager = SettingsManager.create(cwd, getAgentDir());
 44	return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_MODEL_SETTING);
 45}
 46
 47function configuredHandoffModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined {
 48	const flagValue = pi.getFlag(HANDOFF_MODEL_FLAG);
 49	const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined;
 50	return flagModel ?? nonEmptyString(process.env[HANDOFF_MODEL_ENV]) ?? userSettingsHandoffModel(ctx.cwd);
 51}
 52
 53function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined {
 54	const slashIdx = spec.indexOf("/");
 55	if (slashIdx <= 0 || slashIdx === spec.length - 1) return undefined;
 56	return {
 57		provider: spec.slice(0, slashIdx),
 58		modelId: spec.slice(slashIdx + 1),
 59	};
 60}
 61
 62/**
 63 * Build a candidate file set from two sources:
 64 *   1. Primary: actual tool calls (read, write, edit, create) in the session
 65 *   2. Secondary: file-like patterns in the conversation text (catches files
 66 *      that were discussed but never opened)
 67 */
 68export function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set<string> {
 69	const files = new Set<string>();
 70	const fileToolNames = new Set(["read", "write", "edit", "create"]);
 71
 72	// Primary: files from actual tool calls
 73	for (const entry of entries) {
 74		if (entry.type !== "message") continue;
 75		const msg = entry.message;
 76		if (msg.role !== "assistant") continue;
 77
 78		for (const block of msg.content) {
 79			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
 80			if (!fileToolNames.has(block.name)) continue;
 81
 82			const args = block.arguments as Record<string, unknown>;
 83			const filePath =
 84				typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined;
 85			if (!filePath) continue;
 86			if (filePath.endsWith("/SKILL.md")) continue;
 87
 88			files.add(filePath);
 89		}
 90	}
 91
 92	// Secondary: file-like patterns from conversation text.
 93	// Trailing lookahead so the boundary isn't consumed — otherwise adjacent
 94	// files separated by a single space (e.g. "file1.txt file2.txt") get skipped.
 95	const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm;
 96	for (const match of conversationText.matchAll(filePattern)) {
 97		const candidate = match[1];
 98		if (candidate && !candidate.startsWith(".") && candidate.length > 2) {
 99			files.add(candidate);
100		}
101	}
102
103	return files;
104}
105
106/**
107 * Extract skill names that were actually loaded during the conversation.
108 * Looks for read() tool calls targeting SKILL.md files and derives the
109 * skill name from the parent directory (the convention for pi skills).
110 */
111export function extractLoadedSkills(entries: SessionEntry[]): string[] {
112	const skills = new Set<string>();
113	for (const entry of entries) {
114		if (entry.type !== "message") continue;
115		const msg = entry.message;
116		if (msg.role !== "assistant") continue;
117
118		for (const block of msg.content) {
119			if (typeof block !== "object" || block === null || block.type !== "toolCall") continue;
120
121			// read() calls where the path ends in SKILL.md
122			if (block.name !== "read") continue;
123			const args = block.arguments as Record<string, unknown>;
124			const filePath = typeof args.path === "string" ? args.path : undefined;
125			if (!filePath?.endsWith("/SKILL.md")) continue;
126
127			// Skill name is the parent directory name:
128			//   .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld
129			const parent = path.basename(path.dirname(filePath));
130			if (parent && parent !== "skills") {
131				skills.add(parent);
132			}
133		}
134	}
135	return [...skills].sort();
136}
137
138/**
139 * Resolve the model to use for handoff extraction calls. Uses --handoff-model,
140 * PI_HANDOFF_MODEL, then handoffModel in user settings, falling back to the
141 * session model if no valid override is configured.
142 */
143export function resolveExtractionModel(
144	pi: ExtensionAPI,
145	ctx: {
146		cwd: string;
147		model: ExtensionContext["model"];
148		modelRegistry: ExtensionContext["modelRegistry"];
149	},
150): ExtensionContext["model"] {
151	const modelSpec = configuredHandoffModel(pi, ctx);
152	if (!modelSpec) return ctx.model;
153	const parsed = parseModelSpec(modelSpec);
154	if (!parsed) return ctx.model;
155	return ctx.modelRegistry.find(parsed.provider, parsed.modelId) ?? ctx.model;
156}