// SPDX-FileCopyrightText: Amolith // SPDX-FileCopyrightText: Petr Baudis // // SPDX-License-Identifier: MIT import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { type ExtensionAPI, type ExtensionCommandContext, type ExtensionContext, getAgentDir, type SessionEntry, SettingsManager, } from "@earendil-works/pi-coding-agent"; import { pickModel } from "./model-utils.js"; /** * Model override sources for handoff extraction calls. Use provider/modelId * values (e.g. "anthropic/claude-haiku-4-5"). Precedence is: * --handoff-extraction-model, PI_HANDOFF_EXTRACTION_MODEL, user settings * handoffExtractionModel, session model. */ const HANDOFF_EXTRACTION_MODEL_FLAG = "handoff-extraction-model"; const HANDOFF_EXTRACTION_MODEL_ENV = "PI_HANDOFF_EXTRACTION_MODEL"; const HANDOFF_EXTRACTION_MODEL_SETTING = "handoffExtractionModel"; type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; interface JsonObject { [key: string]: JsonValue; } export function registerHandoffExtractionModelFlag(pi: ExtensionAPI): void { pi.registerFlag(HANDOFF_EXTRACTION_MODEL_FLAG, { description: "Model for handoff extraction as provider/modelId", type: "string", }); } export function registerHandoffExtractionModelCommand(pi: ExtensionAPI): void { pi.registerCommand("handoff:set-extraction-model", { description: "Choose the model used to extract handoff context", handler: async (args: string, ctx: ExtensionCommandContext) => { if (!ctx.hasUI || ctx.mode !== "tui") { ctx.ui.notify("/handoff:set-extraction-model requires interactive mode", "error"); return; } const currentModel = configuredExtractionModel(pi, ctx); const selectedModel = await pickModel(ctx, currentModel, nonEmptyString(args)); if (!selectedModel) { ctx.ui.notify("Handoff extraction model unchanged", "info"); return; } const modelSpec = `${selectedModel.provider}/${selectedModel.id}`; try { setUserSettingsHandoffExtractionModel(modelSpec); } catch (err) { const message = err instanceof Error ? err.message : String(err); ctx.ui.notify(`Failed to save handoff extraction model: ${message}`, "error"); return; } process.env[HANDOFF_EXTRACTION_MODEL_ENV] = modelSpec; if (configuredFlagHandoffExtractionModel(pi)) { ctx.ui.notify( `Saved handoff extraction model: ${modelSpec}. The --${HANDOFF_EXTRACTION_MODEL_FLAG} flag still takes precedence for this run.`, "warning", ); return; } ctx.ui.notify(`Handoff extraction model: ${modelSpec}`, "info"); }, }); } function nonEmptyString(value: string | undefined): string | undefined { const trimmed = value?.trim(); return trimmed ? trimmed : undefined; } function stringProperty(source: object, key: string): string | undefined { const descriptor = Object.getOwnPropertyDescriptor(source, key); if (!descriptor || !("value" in descriptor)) return undefined; return typeof descriptor.value === "string" ? nonEmptyString(descriptor.value) : undefined; } function userSettingsHandoffExtractionModel(cwd: string): string | undefined { const settingsManager = SettingsManager.create(cwd, getAgentDir()); return stringProperty(settingsManager.getGlobalSettings(), HANDOFF_EXTRACTION_MODEL_SETTING); } function configuredFlagHandoffExtractionModel(pi: ExtensionAPI): string | undefined { const flagValue = pi.getFlag(HANDOFF_EXTRACTION_MODEL_FLAG); const flagModel = typeof flagValue === "string" ? nonEmptyString(flagValue) : undefined; return flagModel; } function configuredHandoffExtractionModel(pi: ExtensionAPI, ctx: { cwd: string }): string | undefined { const flagModel = configuredFlagHandoffExtractionModel(pi); return ( flagModel ?? nonEmptyString(process.env[HANDOFF_EXTRACTION_MODEL_ENV]) ?? userSettingsHandoffExtractionModel(ctx.cwd) ); } function parseJsonObject(content: string, source: string): JsonObject { const parsed: JsonValue = JSON.parse(content); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error(`${source} must contain a JSON object`); } return parsed; } function readSettingsObject(settingsPath: string): JsonObject { if (!existsSync(settingsPath)) return {}; return parseJsonObject(readFileSync(settingsPath, "utf-8"), settingsPath); } function setUserSettingsHandoffExtractionModel(modelSpec: string): void { const settingsPath = path.join(getAgentDir(), "settings.json"); const settings = readSettingsObject(settingsPath); settings[HANDOFF_EXTRACTION_MODEL_SETTING] = modelSpec; mkdirSync(path.dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8"); } function parseModelSpec(spec: string): { provider: string; modelId: string } | undefined { const slashIdx = spec.indexOf("/"); if (slashIdx <= 0 || slashIdx === spec.length - 1) return undefined; return { provider: spec.slice(0, slashIdx), modelId: spec.slice(slashIdx + 1), }; } export function configuredExtractionModel( pi: ExtensionAPI, ctx: { cwd: string; modelRegistry: ExtensionContext["modelRegistry"]; }, ): ExtensionContext["model"] { const modelSpec = configuredHandoffExtractionModel(pi, ctx); if (!modelSpec) return undefined; const parsed = parseModelSpec(modelSpec); if (!parsed) return undefined; return ctx.modelRegistry.find(parsed.provider, parsed.modelId); } /** * Build a candidate file set from two sources: * 1. Primary: actual tool calls (read, write, edit, create) in the session * 2. Secondary: file-like patterns in the conversation text (catches files * that were discussed but never opened) */ export function extractCandidateFiles(entries: SessionEntry[], conversationText: string): Set { const files = new Set(); const fileToolNames = new Set(["read", "write", "edit", "create"]); // Primary: files from actual tool calls for (const entry of entries) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "assistant") continue; for (const block of msg.content) { if (typeof block !== "object" || block === null || block.type !== "toolCall") continue; if (!fileToolNames.has(block.name)) continue; const args = block.arguments as Record; const filePath = typeof args.path === "string" ? args.path : typeof args.file === "string" ? args.file : undefined; if (!filePath) continue; if (filePath.endsWith("/SKILL.md")) continue; files.add(filePath); } } // Secondary: file-like patterns from conversation text. // Trailing lookahead so the boundary isn't consumed — otherwise adjacent // files separated by a single space (e.g. "file1.txt file2.txt") get skipped. const filePattern = /(?:^|\s)([a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)(?=\s|$|[,;:)])/gm; for (const match of conversationText.matchAll(filePattern)) { const candidate = match[1]; if (candidate && !candidate.startsWith(".") && candidate.length > 2) { files.add(candidate); } } return files; } /** * Extract skill names that were actually loaded during the conversation. * Looks for read() tool calls targeting SKILL.md files and derives the * skill name from the parent directory (the convention for pi skills). */ export function extractLoadedSkills(entries: SessionEntry[]): string[] { const skills = new Set(); for (const entry of entries) { if (entry.type !== "message") continue; const msg = entry.message; if (msg.role !== "assistant") continue; for (const block of msg.content) { if (typeof block !== "object" || block === null || block.type !== "toolCall") continue; // read() calls where the path ends in SKILL.md if (block.name !== "read") continue; const args = block.arguments as Record; const filePath = typeof args.path === "string" ? args.path : undefined; if (!filePath?.endsWith("/SKILL.md")) continue; // Skill name is the parent directory name: // .../skills/backing-up-with-keld/SKILL.md → backing-up-with-keld const parent = path.basename(path.dirname(filePath)); if (parent && parent !== "skills") { skills.add(parent); } } } return [...skills].sort(); }