Detailed changes
@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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<HandoffExtraction | null>((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);
+ },
+ });
+}
@@ -0,0 +1,160 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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<ExtensionContext["model"]>,
+ apiKey: string | undefined,
+ headers: Record<string, string> | undefined,
+ conversationText: string,
+ goal: string,
+ candidateFiles: string[],
+ loadedSkills: string[],
+ signal?: AbortSignal,
+): Promise<HandoffExtraction | null> {
+ 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<string, unknown>;
+
+ 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();
+}
@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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,
+ };
+ },
+ });
+}
@@ -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<string> {
- const files = new Set<string>();
- 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<string, unknown>;
- 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<string>();
- 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<string, unknown>;
- 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<ExtensionContext["model"]>,
- apiKey: string | undefined,
- headers: Record<string, string> | undefined,
- conversationText: string,
- goal: string,
- candidateFiles: string[],
- loadedSkills: string[],
- signal?: AbortSignal,
-): Promise<HandoffExtraction | null> {
- 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<string, unknown>;
-
- 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<HandoffExtraction | null>((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);
}
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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<string> {
+ const files = new Set<string>();
+ 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<string, unknown>;
+ 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<string>();
+ 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<string, unknown>;
+ 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;
+}
@@ -0,0 +1,40 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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}`);
+}
@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
+// SPDX-FileCopyrightText: Petr Baudis <pasky@ucw.cz>
+//
+// 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)}`);
+ }
+ },
+ });
+}