/** * pi adapter for deep scans: resolves the session's already-configured model * (the codebase-memory-mcp lesson — no extra keys/providers) and drives * pi-ai completion through `ctx.modelRegistry`, which owns auth. * * `createLlmSummarizer` returns null when no model is active (headless * runs without a provider, etc.) — callers fall back to light-only. * `deps.complete` is the test seam: unit tests inject a fake and never * touch a network. */ import { contentText, type Api, type AssistantMessage, type Context, type Model, } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { runDeepScan, type DeepScanOptions, type DeepScanResult, type SummarizeFn } from "../core"; export type CompleteFn = ( model: Model, context: Context, options: { maxTokens?: number; signal?: AbortSignal }, ) => Promise; export interface SummarizerDeps { complete?: CompleteFn; } export interface LlmSummarizer { summarize: SummarizeFn; /** Provenance label recorded in sidecar front matter (e.g. "ollama/kimi-k3:cloud"). */ label: string; } const SYSTEM_PROMPT = [ "You write terse, navigation-oriented summaries of source files for a codebase index.", "Rules: 1–3 sentences. What the file does, its outward surface (exports/routes/commands),", "anything surprising (globals, side effects, generated sections). No preamble, no headings, no code fences.", ].join("\n"); const MAX_OUTPUT_TOKENS = 220; const REQUEST_TIMEOUT_MS = 30_000; /** * Shared model wiring for deep and session scans: resolve the session's * already-configured model — no * extra keys or providers (docs/scan-modes.md) — drive completion through * `ctx.modelRegistry`, which owns auth, and reject empty outputs so a * degraded model cannot silently blank a note. * * Returns null when the session has no active model (headless runs without a * provider, etc.) — callers fall back or notify. `deps.complete` is the test * seam: unit tests inject a fake and never touch a network. */ export function createModelSummarizer( ctx: Pick, systemPrompt: string, maxOutputTokens: number, deps: SummarizerDeps = {}, ): LlmSummarizer | null { const model = ctx.model; if (!model || !isUsableModel(model)) return null; const complete: CompleteFn = deps.complete ?? ((m, c, o) => ctx.modelRegistry.complete(m, c, o)); const label = `${model.provider}/${model.id}`; const summarize: SummarizeFn = async ({ path, content }) => { const message = await complete( model, { systemPrompt, messages: [ { role: "user", content: `File: ${path}\n\n\`\`\`\n${content}\n\`\`\``, timestamp: Date.now(), }, ], }, { maxTokens: maxOutputTokens, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }, ); const text = contentText(message.content).trim(); if (text.length === 0) throw new Error(emptySummaryReason(message, maxOutputTokens)); return text; }; return { summarize, label }; } /** * True when `ctx.model` is a real model rather than pi's placeholder. * * A session with no resolved model does not hand back `undefined` — it hands * back pi's `DEFAULT_MODEL`, whose provider and id are the literal string * `"unknown"`. That object is truthy, so a `!model` guard passes it straight * through to `modelRegistry.complete`, which throws `Unknown provider: * unknown` once per file. Treating it as "no model" makes the caller report * the actionable "needs an active session model" instead, before spending a * single call. */ function isUsableModel(model: { provider?: string; id?: string }): boolean { return model.provider !== undefined && model.provider !== "unknown"; } /** * Explain an empty completion using what the response actually carries. * * "model returned an empty summary" is true of every failure mode here and * diagnostic of none: an auth error, a reasoning model that spent its whole * budget thinking, and a refusal all produce zero text blocks. Reporting that * bare string once is unhelpful; reporting it 85 times, once per session, is * an outage with no evidence attached. The provider already distinguishes * these through `stopReason`, `errorMessage` and the reasoning-token count, * so the message says which one happened and what to do about it. */ export function emptySummaryReason(message: AssistantMessage, maxOutputTokens: number): string { const detail = message.errorMessage?.trim(); if (message.stopReason === "error") { return `model call failed${detail ? `: ${detail}` : " with no error detail"}`; } // Reasoning tokens are billed against the same budget as output, so a model // thinking at a high effort level can exhaust it before emitting any text. const reasoning = message.usage?.reasoning ?? 0; if (message.stopReason === "length") { return reasoning > 0 ? `model spent its entire ${maxOutputTokens}-token budget on reasoning (${reasoning} tokens) and produced no summary — lower the thinking level or raise the cap` : `model hit the ${maxOutputTokens}-token cap before producing a summary`; } if (reasoning > 0) { return `model returned only reasoning (${reasoning} tokens), no summary text`; } return `model returned an empty summary (stopReason: ${message.stopReason})${detail ? `: ${detail}` : ""}`; } /** Create the file summarizer for deep scans, or null when no model is active. */ export function createLlmSummarizer( ctx: Pick, deps: SummarizerDeps = {}, ): LlmSummarizer | null { return createModelSummarizer(ctx, SYSTEM_PROMPT, MAX_OUTPUT_TOKENS, deps); } export type DeepScanOutcome = | { kind: "ok"; result: DeepScanResult } | { kind: "no-model" } | { kind: "not-a-repo" }; type DeepScanTuning = { at?: DeepScanOptions["at"]; maxFiles?: DeepScanOptions["maxFiles"]; maxFileBytes?: DeepScanOptions["maxFileBytes"]; concurrency?: DeepScanOptions["concurrency"]; onProgress?: DeepScanOptions["onProgress"]; signal?: DeepScanOptions["signal"]; }; /** Run the deep pass against a repo root using the session model. */ export async function deepScanRepository( repoRoot: string, ctx: Pick, deps: SummarizerDeps & DeepScanTuning = {}, ): Promise { const llm = createLlmSummarizer(ctx, deps); if (!llm) return { kind: "no-model" }; // exactOptionalPropertyTypes: only present keys may be spread in. const result = await runDeepScan(repoRoot, { summarize: llm.summarize, model: llm.label, ...(deps.at !== undefined ? { at: deps.at } : {}), ...(deps.maxFiles !== undefined ? { maxFiles: deps.maxFiles } : {}), ...(deps.maxFileBytes !== undefined ? { maxFileBytes: deps.maxFileBytes } : {}), ...(deps.concurrency !== undefined ? { concurrency: deps.concurrency } : {}), ...(deps.onProgress !== undefined ? { onProgress: deps.onProgress } : {}), ...(deps.signal !== undefined ? { signal: deps.signal } : {}), }); if (result === null) return { kind: "not-a-repo" }; return { kind: "ok", result }; } /** One-line human summary of a deep-scan result (for notify output). */ export function formatDeepScanResult(result: DeepScanResult): string { const parts = [ `${result.written} summarized`, `${result.skippedFresh} unchanged`, ]; if (result.skippedTooBig > 0) parts.push(`${result.skippedTooBig} skipped (size/type)`); if (result.pruned > 0) parts.push(`${result.pruned} pruned`); let text = `${parts.join(", ")} — ${result.considered} files considered`; if (result.failed.length > 0) { const [failed0] = result.failed; if (failed0) { text += `; ${result.failed.length} failed, first: ${failed0.path}: ${failed0.error}`; } } return text; }