import type { Api, AssistantMessage, Context, Model, ProviderStreamOptions, UserMessage } from "@earendil-works/pi-ai/compat"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { consumeSideStream, defaultSideModelClient, type SideModelClient } from "./side-runtime.ts"; import type { BtwUsageTotals } from "./threads.ts"; /** This deliberately has no relationship to the main or side-question instructions. */ export const SUMMARY_SYSTEM_INSTRUCTION = "Summarize the supplied side Q/A for future side questions. Preserve concrete facts, decisions, file and symbol names, and unresolved caveats. The raw recent Q/A remains authoritative. Output only the compact summary."; export type SummaryRunResult = | { kind: "skipped"; issued: false } | { kind: "success"; issued: true; text: string; usage?: BtwUsageTotals } | { kind: "failed"; issued: true; error: string; usage?: BtwUsageTotals } | { kind: "aborted"; issued: true; usage?: BtwUsageTotals }; export type SummaryRunnerDependencies = { modelClient?: SideModelClient; now?: () => number; model?: Model }; function copiedUsage(message: AssistantMessage | undefined): BtwUsageTotals | undefined { if (!message?.usage) return undefined; // Do not retain a provider-owned object; the store is the validation boundary. try { return structuredClone(message.usage) as BtwUsageTotals; } catch { return undefined; } } function visibleText(message: AssistantMessage): string { return message.content.filter((part): part is { type: "text"; text: string } => part.type === "text").map((part) => part.text).join("\n"); } function hasTool(message: AssistantMessage): boolean { return message.content.some((part) => part.type === "toolCall"); } function classify(message: AssistantMessage, text: string): SummaryRunResult { const usage = copiedUsage(message); if (message.stopReason === "aborted") return { kind: "aborted", issued: true, ...(usage ? { usage } : {}) }; if (message.stopReason === "error") return { kind: "failed", issued: true, error: message.errorMessage || "provider error", ...(usage ? { usage } : {}) }; const trimmed = text.trim(); if ((message.stopReason === "stop" || message.stopReason === "length") && trimmed && !hasTool(message)) { return { kind: "success", issued: true, text: trimmed, ...(usage ? { usage } : {}) }; } return { kind: "failed", issued: true, error: hasTool(message) ? "summary attempted a tool call" : "summary returned no terminal visible text", ...(usage ? { usage } : {}) }; } /** * A one-request maintenance runner. It intentionally builds its own sterile context: * no main system prompt, grounding, side prefix, prior messages, or tools can enter it. */ export async function runSummary( ctx: ExtensionContext, input: string, summaryMaxTokens: number, signal: AbortSignal, deps: SummaryRunnerDependencies = {}, ): Promise { // Maintenance captures this object before persistence; a later ctx.model change // must not alter the request that a successful attempt scheduled. const model = deps.model ?? ctx.model; if (!model || signal.aborted) return { kind: "skipped", issued: false }; let auth: Awaited>; try { const getAuth = (ctx.modelRegistry as { getApiKeyAndHeaders?: (candidate: Model) => Promise } | undefined)?.getApiKeyAndHeaders; if (!getAuth) return { kind: "skipped", issued: false }; auth = await getAuth.call(ctx.modelRegistry, model); } catch { return { kind: "skipped", issued: false }; } if (!auth.ok || signal.aborted) return { kind: "skipped", issued: false }; const now = deps.now ?? Date.now; const user: UserMessage = { role: "user", content: [{ type: "text", text: input }], timestamp: now() }; const context: Context = { systemPrompt: SUMMARY_SYSTEM_INSTRUCTION, messages: [user], tools: [] }; const options: ProviderStreamOptions = { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, maxTokens: summaryMaxTokens, cacheRetention: "short", signal }; const client = deps.modelClient ?? defaultSideModelClient; let issued = false; try { if (client.stream) { issued = true; const outcome = await consumeSideStream(client.stream(model as Model, context, options), signal); // A partial AssistantMessage may look complete, but only an emitted // terminal event owns billable usage. Cancellation/protocol failures do not. const terminalUsage = outcome.terminalEvent ? copiedUsage(outcome.message) : undefined; if (outcome.kind === "aborted") return { kind: "aborted", issued: true, ...(terminalUsage ? { usage: terminalUsage } : {}) }; if (outcome.kind === "error") return { kind: "failed", issued: true, error: outcome.error, ...(terminalUsage ? { usage: terminalUsage } : {}) }; const classified = classify(outcome.message, outcome.text || visibleText(outcome.message)); return terminalUsage ? { ...classified, usage: terminalUsage } as SummaryRunResult : classified; } if (client.complete) { issued = true; const message = await client.complete(model as Model, context, options); if (signal.aborted) return { kind: "aborted", issued: true, ...(copiedUsage(message) ? { usage: copiedUsage(message)! } : {}) }; return classify(message, visibleText(message)); } return { kind: "skipped", issued: false }; } catch (error) { if (signal.aborted && issued) return { kind: "aborted", issued: true }; return { kind: "failed", issued, error: error instanceof Error ? error.message : String(error) } as SummaryRunResult; } }