From 12ee4a26e6e71290c170883d23e90577572f06dd Mon Sep 17 00:00:00 2001 From: Amolith Date: Sun, 5 Apr 2026 21:27:32 -0600 Subject: [PATCH] handoff: Split into focused modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Break the 905-line single file into six modules along natural boundaries: - session-paths.ts: path utilities (getSessionsRoot, getFallbackSessionsRoot, normalizeSessionPath, sessionPathAllowed) - session-analysis.ts: extractCandidateFiles, extractLoadedSkills, resolveExtractionModel - handoff-extraction.ts: extraction prompts, tool schema, extractHandoffContext, assembleHandoffDraft - session-query-tool.ts: session_query tool registration - handoff-tool.ts: handoff tool registration - handoff-command.ts: /handoff command registration - index.ts: wiring, event handlers, shared state, countdown logic No behavioral changes. Each module has a narrow interface with the rest of the system — shared state is threaded through callbacks rather than module-level globals. --- packages/handoff/src/handoff-command.ts | 145 +++++ packages/handoff/src/handoff-extraction.ts | 160 +++++ packages/handoff/src/handoff-tool.ts | 145 +++++ packages/handoff/src/index.ts | 705 +-------------------- packages/handoff/src/session-analysis.ts | 106 ++++ packages/handoff/src/session-paths.ts | 40 ++ packages/handoff/src/session-query-tool.ts | 156 +++++ 7 files changed, 764 insertions(+), 693 deletions(-) create mode 100644 packages/handoff/src/handoff-command.ts create mode 100644 packages/handoff/src/handoff-extraction.ts create mode 100644 packages/handoff/src/handoff-tool.ts create mode 100644 packages/handoff/src/session-analysis.ts create mode 100644 packages/handoff/src/session-paths.ts create mode 100644 packages/handoff/src/session-query-tool.ts diff --git a/packages/handoff/src/handoff-command.ts b/packages/handoff/src/handoff-command.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbdad35d6559b9cb4bc423bc2f1b98098c61fbc3 --- /dev/null +++ b/packages/handoff/src/handoff-command.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import type { + ExtensionAPI, + ExtensionCommandContext, + ExtensionContext, + SessionEntry, +} from "@mariozechner/pi-coding-agent"; +import { BorderedLoader, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; +import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js"; +import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js"; + +export function registerHandoffCommand(pi: ExtensionAPI, startCountdown: (ctx: ExtensionContext) => void) { + pi.registerCommand("handoff", { + description: "Transfer context to a new session (-model provider/modelId)", + handler: async (args: string, ctx: ExtensionCommandContext) => { + if (!ctx.hasUI) { + ctx.ui.notify("/handoff requires interactive mode", "error"); + return; + } + + if (!ctx.model) { + ctx.ui.notify("No model selected", "error"); + return; + } + + // Parse optional -model flag from args + let remaining = args; + let newSessionModel: string | undefined; + + const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/); + if (modelMatch) { + newSessionModel = modelMatch[1]; + remaining = remaining.replace(modelMatch[0], " "); + } + + let goal = remaining.trim(); + if (!goal) { + const entered = await ctx.ui.input("handoff goal", "What should the new thread do?"); + if (!entered?.trim()) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + goal = entered.trim(); + } + + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + ctx.ui.notify("No conversation to hand off", "warning"); + return; + } + + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; + const loadedSkills = extractLoadedSkills(branch); + const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; + + const result = await ctx.ui.custom((tui, theme, _kb, done) => { + const loader = new BorderedLoader(tui, theme, "Extracting handoff context..."); + loader.onAbort = () => done(null); + + const run = async () => { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); + if (!auth.ok) { + throw new Error(`Failed to get API key: ${auth.error}`); + } + + return extractHandoffContext( + extractionModel, + auth.apiKey, + auth.headers, + conversationText, + goal, + candidateFiles, + loadedSkills, + loader.signal, + ); + }; + + run() + .then(done) + .catch((err) => { + console.error("handoff generation failed", err); + done(null); + }); + + return loader; + }); + + if (!result) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + + const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile); + + const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft); + if (editedPrompt === undefined) { + ctx.ui.notify("Handoff cancelled", "info"); + return; + } + + const next = await ctx.newSession({ + parentSession: currentSessionFile ?? undefined, + }); + + if (next.cancelled) { + ctx.ui.notify("New session cancelled", "info"); + return; + } + + // Apply -model if specified + if (newSessionModel) { + const slashIdx = newSessionModel.indexOf("/"); + if (slashIdx > 0) { + const provider = newSessionModel.slice(0, slashIdx); + const modelId = newSessionModel.slice(slashIdx + 1); + const model = ctx.modelRegistry.find(provider, modelId); + if (model) { + await pi.setModel(model); + } else { + ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning"); + } + } else { + ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning"); + } + } + + const newSessionFile = ctx.sessionManager.getSessionFile(); + ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info"); + + ctx.ui.setEditorText(editedPrompt); + startCountdown(ctx); + }, + }); +} diff --git a/packages/handoff/src/handoff-extraction.ts b/packages/handoff/src/handoff-extraction.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cc278fd7fd9e4b6202c33dd0f3c45d101ea7237 --- /dev/null +++ b/packages/handoff/src/handoff-extraction.ts @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import { complete, type Message, type Tool } from "@mariozechner/pi-ai"; +import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; + +const SYSTEM_PROMPT = `You're helping transfer context between coding sessions. The next session starts fresh with no memory of this conversation, so extract what matters. + +Consider these questions: +- What was just done or implemented? +- What decisions were made and why? +- What technical details were discovered (APIs, methods, patterns)? +- What constraints, limitations, or caveats were found? +- What patterns or approaches are being followed? +- What is still unfinished or unresolved? +- What open questions or risks are worth flagging? + +Rules: +- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries. +- Only include files from the provided candidate list. +- Only include skills from the provided loaded skills list. +- If something wasn't explicitly discussed, don't include it.`; + +/** + * Tool definition passed to the extraction LLM call. Models produce + * structured tool-call arguments far more reliably than free-form JSON + * in a text response, so we use a single tool instead of responseFormat. + */ +const HANDOFF_EXTRACTION_TOOL: Tool = { + name: "extract_handoff_context", + description: "Extract handoff context from the conversation for a new session.", + parameters: Type.Object({ + relevantFiles: Type.Array(Type.String(), { + description: "File paths relevant to continuing this work, chosen from the provided candidate list", + }), + skillsInUse: Type.Array(Type.String(), { + description: "Skills relevant to the goal, chosen from the provided list of loaded skills", + }), + context: Type.String({ + description: + "Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed", + }), + openItems: Type.Array(Type.String(), { + description: "Unfinished work, open questions, known risks, and things to watch out for", + }), + }), +}; + +export type HandoffExtraction = { + files: string[]; + skills: string[]; + context: string; + openItems: string[]; +}; + +/** + * Run the extraction LLM call and return structured context, or null if + * aborted or the model didn't produce a valid tool call. + */ +export async function extractHandoffContext( + model: NonNullable, + apiKey: string | undefined, + headers: Record | undefined, + conversationText: string, + goal: string, + candidateFiles: string[], + loadedSkills: string[], + signal?: AbortSignal, +): Promise { + const filesContext = + candidateFiles.length > 0 + ? `\n\n## Candidate Files\n\nThese files were touched or mentioned during the session. Return only the ones relevant to the goal.\n\n${candidateFiles.map((f) => `- ${f}`).join("\n")}` + : ""; + + const skillsContext = + loadedSkills.length > 0 + ? `\n\n## Skills Loaded During This Session\n\n${loadedSkills.map((s) => `- ${s}`).join("\n")}\n\nReturn only the skills from this list that are relevant to the goal.` + : ""; + + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + model, + { + systemPrompt: SYSTEM_PROMPT, + messages: [userMessage], + tools: [HANDOFF_EXTRACTION_TOOL], + }, + { apiKey, headers, signal }, + ); + + if (response.stopReason === "aborted") return null; + + const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context"); + + if (!toolCall || toolCall.type !== "toolCall") { + console.error("Model did not call extract_handoff_context:", response.content); + return null; + } + + const args = toolCall.arguments as Record; + + if ( + !Array.isArray(args.relevantFiles) || + !Array.isArray(args.skillsInUse) || + typeof args.context !== "string" || + !Array.isArray(args.openItems) + ) { + console.error("Unexpected tool call arguments shape:", args); + return null; + } + + const candidateSet = new Set(candidateFiles); + const loadedSkillSet = new Set(loadedSkills); + + return { + files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)), + skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)), + context: args.context as string, + openItems: (args.openItems as string[]).filter((item) => typeof item === "string"), + }; +} + +/** + * Assemble the handoff draft. The user's goal goes last so it has the + * most weight in the new session (recency bias). + */ +export function assembleHandoffDraft( + result: HandoffExtraction, + goal: string, + parentSessionFile: string | undefined, +): string { + const parentBlock = parentSessionFile + ? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n` + : ""; + + const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : ""; + + const skillsSection = + result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : ""; + + const contextSection = `## Context\n\n${result.context}\n\n`; + + const openItemsSection = + result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : ""; + + return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim(); +} diff --git a/packages/handoff/src/handoff-tool.ts b/packages/handoff/src/handoff-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7ad72bf189c1474583abd2d3ac78828bbc86737 --- /dev/null +++ b/packages/handoff/src/handoff-tool.ts @@ -0,0 +1,145 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; +import { convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import { extractCandidateFiles, extractLoadedSkills, resolveExtractionModel } from "./session-analysis.js"; +import { type HandoffExtraction, assembleHandoffDraft, extractHandoffContext } from "./handoff-extraction.js"; + +export type PendingHandoff = { + prompt: string; + parentSession: string | undefined; + newModel: string | undefined; +}; + +export function registerHandoffTool(pi: ExtensionAPI, setPendingHandoff: (h: PendingHandoff) => void) { + pi.registerTool({ + name: "handoff", + label: "Handoff", + description: + "Transfer context to a new session. Use when the user explicitly asks for a handoff or when the context window is nearly full. Provide a goal describing what the new session should focus on.", + parameters: Type.Object({ + goal: Type.String({ + description: "The goal/task for the new session", + }), + model: Type.Optional( + Type.String({ + description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')", + }), + ), + }), + async execute(_toolCallId, params, signal, _onUpdate, ctx) { + if (!ctx.model) { + return { + content: [{ type: "text" as const, text: "No model selected." }], + details: undefined, + }; + } + + const branch = ctx.sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + return { + content: [ + { + type: "text" as const, + text: "No conversation to hand off.", + }, + ], + details: undefined, + }; + } + + const llmMessages = convertToLlm(messages); + const conversationText = serializeConversation(llmMessages); + const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; + const loadedSkills = extractLoadedSkills(branch); + + const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); + if (!auth.ok) { + return { + content: [ + { + type: "text" as const, + text: `Failed to get API key: ${auth.error}`, + }, + ], + details: undefined, + }; + } + + let result: HandoffExtraction | null; + try { + result = await extractHandoffContext( + extractionModel, + auth.apiKey, + auth.headers, + conversationText, + params.goal, + candidateFiles, + loadedSkills, + signal, + ); + } catch (err) { + if (signal?.aborted) { + return { + content: [ + { + type: "text" as const, + text: "Handoff cancelled.", + }, + ], + details: undefined, + }; + } + return { + content: [ + { + type: "text" as const, + text: `Handoff extraction failed: ${String(err)}`, + }, + ], + details: undefined, + }; + } + + if (!result) { + return { + content: [ + { + type: "text" as const, + text: "Handoff extraction failed or was cancelled.", + }, + ], + details: undefined, + }; + } + + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile); + + setPendingHandoff({ + prompt, + parentSession: currentSessionFile, + newModel: params.model, + }); + + return { + content: [ + { + type: "text" as const, + text: "Handoff prepared. The session will switch after this turn completes.", + }, + ], + details: undefined, + }; + }, + }); +} diff --git a/packages/handoff/src/index.ts b/packages/handoff/src/index.ts index 7c770475aba1a35dbc880464acc2115619c74fde..97d0b9e1c2909a32ae594904c29e968cf10bcfdd 100644 --- a/packages/handoff/src/index.ts +++ b/packages/handoff/src/index.ts @@ -3,80 +3,16 @@ // // SPDX-License-Identifier: MIT -import { complete, type Message, type Tool } from "@mariozechner/pi-ai"; -import type { - ExtensionAPI, - ExtensionCommandContext, - ExtensionContext, - SessionEntry, -} from "@mariozechner/pi-coding-agent"; -import { BorderedLoader, SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { SessionManager } from "@mariozechner/pi-coding-agent"; import { Key, matchesKey } from "@mariozechner/pi-tui"; -import { Type } from "@sinclair/typebox"; -import * as fs from "node:fs"; -import * as os from "node:os"; -import * as path from "node:path"; +import { registerHandoffCommand } from "./handoff-command.js"; +import { registerHandoffTool, type PendingHandoff } from "./handoff-tool.js"; +import { registerSessionQueryTool } from "./session-query-tool.js"; const STATUS_KEY = "handoff"; const COUNTDOWN_SECONDS = 10; -/** - * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var - * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a - * different model for extraction than the session's current model. - */ -const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL; - -const SYSTEM_PROMPT = `You're helping transfer context between coding sessions. The next session starts fresh with no memory of this conversation, so extract what matters. - -Consider these questions: -- What was just done or implemented? -- What decisions were made and why? -- What technical details were discovered (APIs, methods, patterns)? -- What constraints, limitations, or caveats were found? -- What patterns or approaches are being followed? -- What is still unfinished or unresolved? -- What open questions or risks are worth flagging? - -Rules: -- Be concrete: prefer file paths, specific decisions, and actual commands over vague summaries. -- Only include files from the provided candidate list. -- Only include skills from the provided loaded skills list. -- If something wasn't explicitly discussed, don't include it.`; - -const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session. - -Rules: -- Use only facts from the provided conversation. -- Prefer concrete outputs: file paths, decisions, TODOs, errors. -- If not present, say explicitly: "Not found in provided session.". -- Keep answer concise.`; - -/** - * Tool definition passed to the extraction LLM call. Models produce - * structured tool-call arguments far more reliably than free-form JSON - * in a text response, so we use a single tool instead of responseFormat. - */ -const HANDOFF_EXTRACTION_TOOL: Tool = { - name: "extract_handoff_context", - description: "Extract handoff context from the conversation for a new session.", - parameters: Type.Object({ - relevantFiles: Type.Array(Type.String(), { - description: "File paths relevant to continuing this work, chosen from the provided candidate list", - }), - skillsInUse: Type.Array(Type.String(), { - description: "Skills relevant to the goal, chosen from the provided list of loaded skills", - }), - context: Type.String({ - description: - "Key context: what was done, decisions made, technical details discovered, constraints, and patterns being followed", - }), - openItems: Type.Array(Type.String(), { - description: "Unfinished work, open questions, known risks, and things to watch out for", - }), - }), -}; - type PendingAutoSubmit = { ctx: ExtensionContext; sessionFile: string | undefined; @@ -84,12 +20,6 @@ type PendingAutoSubmit = { unsubscribeInput: () => void; }; -type PendingHandoff = { - prompt: string; - parentSession: string | undefined; - newModel: string | undefined; -}; - function isEditableInput(data: string): boolean { if (!data) return false; if (data.length === 1) { @@ -112,236 +42,6 @@ function statusLine(ctx: ExtensionContext, seconds: number): string { return `${accent} ${hint}`; } -function getSessionsRoot(sessionFile: string | undefined): string | undefined { - if (!sessionFile) return undefined; - const normalized = sessionFile.replace(/\\/g, "/"); - const marker = "/sessions/"; - const idx = normalized.indexOf(marker); - if (idx === -1) { - return path.dirname(path.resolve(sessionFile)); - } - return normalized.slice(0, idx + marker.length - 1); -} - -function getFallbackSessionsRoot(): string | undefined { - const configuredDir = process.env.PI_CODING_AGENT_DIR; - const candidate = configuredDir - ? path.resolve(configuredDir, "sessions") - : path.resolve(os.homedir(), ".pi", "agent", "sessions"); - return fs.existsSync(candidate) ? candidate : undefined; -} - -function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string { - if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath); - if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath); - return path.resolve(sessionPath); -} - -function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean { - if (!sessionsRoot) return false; // fail closed when root unknown - const root = path.resolve(sessionsRoot); - const resolved = path.resolve(candidate); - return resolved === root || resolved.startsWith(`${root}${path.sep}`); -} - -/** - * 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) - */ -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). - */ -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(); -} - -/** - * Resolve the model to use for handoff extraction calls. Uses the - * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model. - */ -function resolveExtractionModel(ctx: { - model: ExtensionContext["model"]; - modelRegistry: ExtensionContext["modelRegistry"]; -}): ExtensionContext["model"] { - if (!HANDOFF_MODEL_OVERRIDE) return ctx.model; - const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/"); - if (slashIdx <= 0) return ctx.model; - const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx); - const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1); - return ctx.modelRegistry.find(provider, modelId) ?? ctx.model; -} - -type HandoffExtraction = { - files: string[]; - skills: string[]; - context: string; - openItems: string[]; -}; - -/** - * Run the extraction LLM call and return structured context, or null if - * aborted or the model didn't produce a valid tool call. - */ -async function extractHandoffContext( - model: NonNullable, - apiKey: string | undefined, - headers: Record | undefined, - conversationText: string, - goal: string, - candidateFiles: string[], - loadedSkills: string[], - signal?: AbortSignal, -): Promise { - const filesContext = - candidateFiles.length > 0 - ? `\n\n## Candidate Files\n\nThese files were touched or mentioned during the session. Return only the ones relevant to the goal.\n\n${candidateFiles.map((f) => `- ${f}`).join("\n")}` - : ""; - - const skillsContext = - loadedSkills.length > 0 - ? `\n\n## Skills Loaded During This Session\n\n${loadedSkills.map((s) => `- ${s}`).join("\n")}\n\nReturn only the skills from this list that are relevant to the goal.` - : ""; - - const userMessage: Message = { - role: "user", - content: [ - { - type: "text", - text: `## Conversation\n\n${conversationText}\n\n## Goal for Next Session\n\n${goal}${filesContext}${skillsContext}`, - }, - ], - timestamp: Date.now(), - }; - - const response = await complete( - model, - { - systemPrompt: SYSTEM_PROMPT, - messages: [userMessage], - tools: [HANDOFF_EXTRACTION_TOOL], - }, - { apiKey, headers, signal }, - ); - - if (response.stopReason === "aborted") return null; - - const toolCall = response.content.find((c) => c.type === "toolCall" && c.name === "extract_handoff_context"); - - if (!toolCall || toolCall.type !== "toolCall") { - console.error("Model did not call extract_handoff_context:", response.content); - return null; - } - - const args = toolCall.arguments as Record; - - if ( - !Array.isArray(args.relevantFiles) || - !Array.isArray(args.skillsInUse) || - typeof args.context !== "string" || - !Array.isArray(args.openItems) - ) { - console.error("Unexpected tool call arguments shape:", args); - return null; - } - - const candidateSet = new Set(candidateFiles); - const loadedSkillSet = new Set(loadedSkills); - - return { - files: (args.relevantFiles as string[]).filter((f) => typeof f === "string" && candidateSet.has(f)), - skills: (args.skillsInUse as string[]).filter((s) => typeof s === "string" && loadedSkillSet.has(s)), - context: args.context as string, - openItems: (args.openItems as string[]).filter((item) => typeof item === "string"), - }; -} - -/** - * Assemble the handoff draft. The user's goal goes last so it has the - * most weight in the new session (recency bias). - */ -function assembleHandoffDraft(result: HandoffExtraction, goal: string, parentSessionFile: string | undefined): string { - const parentBlock = parentSessionFile - ? `**Parent session:** \`${parentSessionFile}\`\n\nUse the \`session_query\` tool with this path if you need details from the prior thread.\n\n` - : ""; - - const filesSection = result.files.length > 0 ? `## Files\n\n${result.files.map((f) => `- ${f}`).join("\n")}\n\n` : ""; - - const skillsSection = - result.skills.length > 0 ? `## Skills in Use\n\n${result.skills.map((s) => `- ${s}`).join("\n")}\n\n` : ""; - - const contextSection = `## Context\n\n${result.context}\n\n`; - - const openItemsSection = - result.openItems.length > 0 ? `## Open Items\n\n${result.openItems.map((item) => `- ${item}`).join("\n")}\n\n` : ""; - - return `${parentBlock}${filesSection}${skillsSection}${contextSection}${openItemsSection}${goal}`.trim(); -} - export default function (pi: ExtensionAPI) { let pending: PendingAutoSubmit | null = null; let pendingHandoff: PendingHandoff | null = null; @@ -434,6 +134,8 @@ export default function (pi: ExtensionAPI) { }; }; + // --- Event handlers --- + pi.on("session_before_switch", (_event, ctx) => { if (pending) clearPending(ctx); }); @@ -512,394 +214,11 @@ export default function (pi: ExtensionAPI) { } }); - pi.registerTool({ - name: "session_query", - label: "Session Query", - description: - "Query a prior pi session file. Use when handoff prompt references a parent session and you need details.", - parameters: Type.Object({ - sessionPath: Type.String({ - description: - "Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)", - }), - question: Type.String({ description: "Question about that session" }), - }), - async execute(_toolCallId, params, signal, onUpdate, ctx) { - const currentSessionFile = ctx.sessionManager.getSessionFile(); - const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot(); - const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot); - - const error = (text: string) => ({ - content: [{ type: "text" as const, text }], - details: { error: true } as const, - }); - - const cancelled = () => ({ - content: [{ type: "text" as const, text: "Session query cancelled." }], - details: { cancelled: true } as const, - }); - - if (signal?.aborted) { - return cancelled(); - } - - if (!resolvedPath.endsWith(".jsonl")) { - return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`); - } - - if (!sessionPathAllowed(resolvedPath, sessionsRoot)) { - return error(`Session path outside allowed sessions directory: ${params.sessionPath}`); - } - - if (!fs.existsSync(resolvedPath)) { - return error(`Session file not found: ${resolvedPath}`); - } - - let fileStats: fs.Stats; - try { - fileStats = fs.statSync(resolvedPath); - } catch (err) { - return error(`Failed to stat session file: ${String(err)}`); - } - - if (!fileStats.isFile()) { - return error(`Session path is not a file: ${resolvedPath}`); - } - - onUpdate?.({ - content: [{ type: "text", text: `Querying: ${resolvedPath}` }], - details: { status: "loading", sessionPath: resolvedPath }, - }); - - let sessionManager: SessionManager; - try { - sessionManager = SessionManager.open(resolvedPath); - } catch (err) { - return error(`Failed to open session: ${String(err)}`); - } - - const branch = sessionManager.getBranch(); - const messages = branch - .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") - .map((entry) => entry.message); - - if (messages.length === 0) { - return { - content: [{ type: "text" as const, text: "Session has no messages." }], - details: { empty: true, sessionPath: resolvedPath }, - }; - } - - if (!ctx.model) { - return error("No model selected for session query."); - } - - const conversationText = serializeConversation(convertToLlm(messages)); - try { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); - if (!auth.ok) { - return error(`Failed to get API key: ${auth.error}`); - } - const userMessage: Message = { - role: "user", - content: [ - { - type: "text", - text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`, - }, - ], - timestamp: Date.now(), - }; - - const response = await complete( - ctx.model, - { systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] }, - { apiKey: auth.apiKey, headers: auth.headers, signal: signal as AbortSignal }, - ); - - if (response.stopReason === "aborted") { - return cancelled(); - } - - const answer = response.content - .filter((c): c is { type: "text"; text: string } => c.type === "text") - .map((c) => c.text) - .join("\n") - .trim(); - - return { - content: [{ type: "text" as const, text: answer || "No answer generated." }], - details: { - sessionPath: resolvedPath, - question: params.question, - messageCount: messages.length, - }, - }; - } catch (err) { - if (signal?.aborted) { - return cancelled(); - } - if (err instanceof Error && err.name === "AbortError") { - return cancelled(); - } - return error(`Session query failed: ${String(err)}`); - } - }, - }); - - pi.registerTool({ - name: "handoff", - label: "Handoff", - description: - "Transfer context to a new session. Use when the user explicitly asks for a handoff or when the context window is nearly full. Provide a goal describing what the new session should focus on.", - parameters: Type.Object({ - goal: Type.String({ - description: "The goal/task for the new session", - }), - model: Type.Optional( - Type.String({ - description: "Model for the new session as provider/modelId (e.g. 'anthropic/claude-haiku-4-5')", - }), - ), - }), - async execute(_toolCallId, params, signal, _onUpdate, ctx) { - if (!ctx.model) { - return { - content: [{ type: "text" as const, text: "No model selected." }], - details: undefined, - }; - } - - const branch = ctx.sessionManager.getBranch(); - const messages = branch - .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") - .map((entry) => entry.message); - - if (messages.length === 0) { - return { - content: [ - { - type: "text" as const, - text: "No conversation to hand off.", - }, - ], - details: undefined, - }; - } - - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; - const loadedSkills = extractLoadedSkills(branch); - - const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); - if (!auth.ok) { - return { - content: [ - { - type: "text" as const, - text: `Failed to get API key: ${auth.error}`, - }, - ], - details: undefined, - }; - } - - let result: HandoffExtraction | null; - try { - result = await extractHandoffContext( - extractionModel, - auth.apiKey, - auth.headers, - conversationText, - params.goal, - candidateFiles, - loadedSkills, - signal, - ); - } catch (err) { - if (signal?.aborted) { - return { - content: [ - { - type: "text" as const, - text: "Handoff cancelled.", - }, - ], - details: undefined, - }; - } - return { - content: [ - { - type: "text" as const, - text: `Handoff extraction failed: ${String(err)}`, - }, - ], - details: undefined, - }; - } - - if (!result) { - return { - content: [ - { - type: "text" as const, - text: "Handoff extraction failed or was cancelled.", - }, - ], - details: undefined, - }; - } - - const currentSessionFile = ctx.sessionManager.getSessionFile(); - const prompt = assembleHandoffDraft(result, params.goal, currentSessionFile); - - pendingHandoff = { - prompt, - parentSession: currentSessionFile, - newModel: params.model, - }; - - return { - content: [ - { - type: "text" as const, - text: "Handoff prepared. The session will switch after this turn completes.", - }, - ], - details: undefined, - }; - }, - }); - - pi.registerCommand("handoff", { - description: "Transfer context to a new session (-model provider/modelId)", - handler: async (args: string, ctx: ExtensionCommandContext) => { - if (!ctx.hasUI) { - ctx.ui.notify("/handoff requires interactive mode", "error"); - return; - } - - if (!ctx.model) { - ctx.ui.notify("No model selected", "error"); - return; - } - - // Parse optional -model flag from args - let remaining = args; - let newSessionModel: string | undefined; - - const modelMatch = remaining.match(/(?:^|\s)-model\s+(\S+)/); - if (modelMatch) { - newSessionModel = modelMatch[1]; - remaining = remaining.replace(modelMatch[0], " "); - } - - let goal = remaining.trim(); - if (!goal) { - const entered = await ctx.ui.input("handoff goal", "What should the new thread do?"); - if (!entered?.trim()) { - ctx.ui.notify("Handoff cancelled", "info"); - return; - } - goal = entered.trim(); - } - - const branch = ctx.sessionManager.getBranch(); - const messages = branch - .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") - .map((entry) => entry.message); - - if (messages.length === 0) { - ctx.ui.notify("No conversation to hand off", "warning"); - return; - } - - const llmMessages = convertToLlm(messages); - const conversationText = serializeConversation(llmMessages); - const currentSessionFile = ctx.sessionManager.getSessionFile(); - const candidateFiles = [...extractCandidateFiles(branch, conversationText)]; - const loadedSkills = extractLoadedSkills(branch); - const extractionModel = resolveExtractionModel(ctx) ?? ctx.model; - - const result = await ctx.ui.custom((tui, theme, _kb, done) => { - const loader = new BorderedLoader(tui, theme, "Extracting handoff context..."); - loader.onAbort = () => done(null); - - const run = async () => { - const auth = await ctx.modelRegistry.getApiKeyAndHeaders(extractionModel); - if (!auth.ok) { - throw new Error(`Failed to get API key: ${auth.error}`); - } - - return extractHandoffContext( - extractionModel, - auth.apiKey, - auth.headers, - conversationText, - goal, - candidateFiles, - loadedSkills, - loader.signal, - ); - }; - - run() - .then(done) - .catch((err) => { - console.error("handoff generation failed", err); - done(null); - }); - - return loader; - }); - - if (!result) { - ctx.ui.notify("Handoff cancelled", "info"); - return; - } - - const prefillDraft = assembleHandoffDraft(result, goal, currentSessionFile); - - const editedPrompt = await ctx.ui.editor("Edit handoff draft", prefillDraft); - if (editedPrompt === undefined) { - ctx.ui.notify("Handoff cancelled", "info"); - return; - } - - const next = await ctx.newSession({ - parentSession: currentSessionFile ?? undefined, - }); - - if (next.cancelled) { - ctx.ui.notify("New session cancelled", "info"); - return; - } - - // Apply -model if specified - if (newSessionModel) { - const slashIdx = newSessionModel.indexOf("/"); - if (slashIdx > 0) { - const provider = newSessionModel.slice(0, slashIdx); - const modelId = newSessionModel.slice(slashIdx + 1); - const model = ctx.modelRegistry.find(provider, modelId); - if (model) { - await pi.setModel(model); - } else { - ctx.ui.notify(`Unknown model: ${newSessionModel}`, "warning"); - } - } else { - ctx.ui.notify(`Invalid model format "${newSessionModel}", expected provider/modelId`, "warning"); - } - } - - const newSessionFile = ctx.sessionManager.getSessionFile(); - ctx.ui.notify(`Switched to new session: ${newSessionFile ?? "(unnamed)"}`, "info"); + // --- Register tools and commands --- - ctx.ui.setEditorText(editedPrompt); - startCountdown(ctx); - }, + registerSessionQueryTool(pi); + registerHandoffTool(pi, (h) => { + pendingHandoff = h; }); + registerHandoffCommand(pi, startCountdown); } diff --git a/packages/handoff/src/session-analysis.ts b/packages/handoff/src/session-analysis.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ba1cff63fe7071fe122e747a96fc1e92573abd6 --- /dev/null +++ b/packages/handoff/src/session-analysis.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import type { ExtensionContext, SessionEntry } from "@mariozechner/pi-coding-agent"; +import * as path from "node:path"; + +/** + * Model used for handoff extraction calls. Set PI_HANDOFF_MODEL env var + * as "provider/modelId" (e.g. "anthropic/claude-haiku-4-5") to use a + * different model for extraction than the session's current model. + */ +const HANDOFF_MODEL_OVERRIDE = process.env.PI_HANDOFF_MODEL; + +/** + * 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(); +} + +/** + * Resolve the model to use for handoff extraction calls. Uses the + * PI_HANDOFF_MODEL env var if set, otherwise falls back to the session model. + */ +export function resolveExtractionModel(ctx: { + model: ExtensionContext["model"]; + modelRegistry: ExtensionContext["modelRegistry"]; +}): ExtensionContext["model"] { + if (!HANDOFF_MODEL_OVERRIDE) return ctx.model; + const slashIdx = HANDOFF_MODEL_OVERRIDE.indexOf("/"); + if (slashIdx <= 0) return ctx.model; + const provider = HANDOFF_MODEL_OVERRIDE.slice(0, slashIdx); + const modelId = HANDOFF_MODEL_OVERRIDE.slice(slashIdx + 1); + return ctx.modelRegistry.find(provider, modelId) ?? ctx.model; +} diff --git a/packages/handoff/src/session-paths.ts b/packages/handoff/src/session-paths.ts new file mode 100644 index 0000000000000000000000000000000000000000..df4c67156b53a332125922f4ecb650195f3bd9df --- /dev/null +++ b/packages/handoff/src/session-paths.ts @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +export function getSessionsRoot(sessionFile: string | undefined): string | undefined { + if (!sessionFile) return undefined; + const normalized = sessionFile.replace(/\\/g, "/"); + const marker = "/sessions/"; + const idx = normalized.indexOf(marker); + if (idx === -1) { + return path.dirname(path.resolve(sessionFile)); + } + return normalized.slice(0, idx + marker.length - 1); +} + +export function getFallbackSessionsRoot(): string | undefined { + const configuredDir = process.env.PI_CODING_AGENT_DIR; + const candidate = configuredDir + ? path.resolve(configuredDir, "sessions") + : path.resolve(os.homedir(), ".pi", "agent", "sessions"); + return fs.existsSync(candidate) ? candidate : undefined; +} + +export function normalizeSessionPath(sessionPath: string, sessionsRoot: string | undefined): string { + if (path.isAbsolute(sessionPath)) return path.resolve(sessionPath); + if (sessionsRoot) return path.resolve(sessionsRoot, sessionPath); + return path.resolve(sessionPath); +} + +export function sessionPathAllowed(candidate: string, sessionsRoot: string | undefined): boolean { + if (!sessionsRoot) return false; // fail closed when root unknown + const root = path.resolve(sessionsRoot); + const resolved = path.resolve(candidate); + return resolved === root || resolved.startsWith(`${root}${path.sep}`); +} diff --git a/packages/handoff/src/session-query-tool.ts b/packages/handoff/src/session-query-tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..673d9dba5e01188eea3f672ae3b606342aa3e946 --- /dev/null +++ b/packages/handoff/src/session-query-tool.ts @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Amolith +// SPDX-FileCopyrightText: Petr Baudis +// +// SPDX-License-Identifier: MIT + +import { complete, type Message } from "@mariozechner/pi-ai"; +import type { ExtensionAPI, SessionEntry } from "@mariozechner/pi-coding-agent"; +import { SessionManager, convertToLlm, serializeConversation } from "@mariozechner/pi-coding-agent"; +import { Type } from "@sinclair/typebox"; +import * as fs from "node:fs"; +import { getFallbackSessionsRoot, getSessionsRoot, normalizeSessionPath, sessionPathAllowed } from "./session-paths.js"; + +const QUERY_SYSTEM_PROMPT = `You answer questions about a prior pi session. + +Rules: +- Use only facts from the provided conversation. +- Prefer concrete outputs: file paths, decisions, TODOs, errors. +- If not present, say explicitly: "Not found in provided session.". +- Keep answer concise.`; + +export function registerSessionQueryTool(pi: ExtensionAPI) { + pi.registerTool({ + name: "session_query", + label: "Session Query", + description: + "Query a prior pi session file. Use when handoff prompt references a parent session and you need details.", + parameters: Type.Object({ + sessionPath: Type.String({ + description: + "Session .jsonl path. Absolute path, or relative to sessions root (e.g. 2026-02-16/foo/session.jsonl)", + }), + question: Type.String({ description: "Question about that session" }), + }), + async execute(_toolCallId, params, signal, onUpdate, ctx) { + const currentSessionFile = ctx.sessionManager.getSessionFile(); + const sessionsRoot = getSessionsRoot(currentSessionFile) ?? getFallbackSessionsRoot(); + const resolvedPath = normalizeSessionPath(params.sessionPath, sessionsRoot); + + const error = (text: string) => ({ + content: [{ type: "text" as const, text }], + details: { error: true } as const, + }); + + const cancelled = () => ({ + content: [{ type: "text" as const, text: "Session query cancelled." }], + details: { cancelled: true } as const, + }); + + if (signal?.aborted) { + return cancelled(); + } + + if (!resolvedPath.endsWith(".jsonl")) { + return error(`Invalid session path (expected .jsonl): ${params.sessionPath}`); + } + + if (!sessionPathAllowed(resolvedPath, sessionsRoot)) { + return error(`Session path outside allowed sessions directory: ${params.sessionPath}`); + } + + if (!fs.existsSync(resolvedPath)) { + return error(`Session file not found: ${resolvedPath}`); + } + + let fileStats: fs.Stats; + try { + fileStats = fs.statSync(resolvedPath); + } catch (err) { + return error(`Failed to stat session file: ${String(err)}`); + } + + if (!fileStats.isFile()) { + return error(`Session path is not a file: ${resolvedPath}`); + } + + onUpdate?.({ + content: [{ type: "text", text: `Querying: ${resolvedPath}` }], + details: { status: "loading", sessionPath: resolvedPath }, + }); + + let sessionManager: SessionManager; + try { + sessionManager = SessionManager.open(resolvedPath); + } catch (err) { + return error(`Failed to open session: ${String(err)}`); + } + + const branch = sessionManager.getBranch(); + const messages = branch + .filter((entry): entry is SessionEntry & { type: "message" } => entry.type === "message") + .map((entry) => entry.message); + + if (messages.length === 0) { + return { + content: [{ type: "text" as const, text: "Session has no messages." }], + details: { empty: true, sessionPath: resolvedPath }, + }; + } + + if (!ctx.model) { + return error("No model selected for session query."); + } + + const conversationText = serializeConversation(convertToLlm(messages)); + try { + const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model); + if (!auth.ok) { + return error(`Failed to get API key: ${auth.error}`); + } + const userMessage: Message = { + role: "user", + content: [ + { + type: "text", + text: `## Session\n\n${conversationText}\n\n## Question\n\n${params.question}`, + }, + ], + timestamp: Date.now(), + }; + + const response = await complete( + ctx.model, + { systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] }, + { apiKey: auth.apiKey, headers: auth.headers, signal: signal as AbortSignal }, + ); + + if (response.stopReason === "aborted") { + return cancelled(); + } + + const answer = response.content + .filter((c): c is { type: "text"; text: string } => c.type === "text") + .map((c) => c.text) + .join("\n") + .trim(); + + return { + content: [{ type: "text" as const, text: answer || "No answer generated." }], + details: { + sessionPath: resolvedPath, + question: params.question, + messageCount: messages.length, + }, + }; + } catch (err) { + if (signal?.aborted) { + return cancelled(); + } + if (err instanceof Error && err.name === "AbortError") { + return cancelled(); + } + return error(`Session query failed: ${String(err)}`); + } + }, + }); +}