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}