import { complete, type Message, type Model } from "@earendil-works/pi-ai/compat"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { QueryResultData } from "./storage.ts"; import { loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.ts"; const PREFERRED_SUMMARY_MODELS = [ { provider: "anthropic", id: "claude-haiku-4-5" }, { provider: "openai-codex", id: "gpt-5.3-codex-spark" }, ] as const; export interface SummaryMeta { model: string | null; durationMs: number; tokenEstimate: number; fallbackUsed: boolean; fallbackReason?: string; edited?: boolean; } export type SummaryGenerationContext = Pick< ExtensionContext, "model" | "modelRegistry" | "cwd" | "isProjectTrusted" >; function estimateTokens(text: string): number { const trimmed = text.trim(); if (trimmed.length === 0) return 0; return Math.max(1, Math.ceil(trimmed.length / 4)); } function summarizeQueryResult(result: QueryResultData): string { if (result.error) { return `Query: ${result.query}\nStatus: Error\nError: ${result.error}`; } const lines = [ `Query: ${result.query}`, `Provider: ${result.provider ?? "unknown"}`, `Answer: ${result.answer || "(no answer text returned)"}`, ]; if (result.results.length === 0) { lines.push("Sources: none"); return lines.join("\n"); } lines.push("Sources:"); for (let i = 0; i < result.results.length; i++) { const source = result.results[i]; lines.push(`${i + 1}. ${source.title} — ${source.url}`); } return lines.join("\n"); } export function buildSummaryPrompt(results: QueryResultData[], feedback?: string): string { const sections = [ "You are writing the final web search summary for a coding assistant.", "Write a concise, factual summary using only the provided search results.", "Requirements:", "- Keep it readable and skimmable.", "- Include key findings and caveats.", "- Do not invent sources or claims.", "- If evidence is weak or conflicting, say so explicitly.", '- End with a short "Sources" section listing the most relevant URLs.', ]; if (feedback) { sections.push("- Incorporate the user feedback provided below into the summary."); } sections.push(""); sections.push(""); for (let i = 0; i < results.length; i++) { sections.push(`\n[Result ${i + 1}]`); sections.push(summarizeQueryResult(results[i])); } sections.push("\n"); if (feedback) { sections.push(""); sections.push(""); sections.push(feedback); sections.push(""); } return sections.join("\n"); } function buildDeterministicAnswerPreview(answer: string): string { let text = answer.replace(/\s+/g, " ").trim(); if (text.length === 0) return ""; const sourceMarker = text.search(/\bSources?\s*:/i); if (sourceMarker >= 0) text = text.slice(0, sourceMarker).trim(); if (text.length === 0) return ""; return text.length > 240 ? `${text.slice(0, 237)}...` : text; } function buildDeterministicSummaryLines(results: QueryResultData[]): string[] { if (results.length === 0) { return [ "No completed search results were available when the curator session finished.", "", "Sources", "- None", ]; } const lines: string[] = ["Summary based on the currently selected search results.", ""]; const sourceUrls: string[] = []; let successful = 0; let failed = 0; for (const result of results) { if (result.error) { failed += 1; lines.push(`- ${result.query}: failed (${result.error})`); continue; } successful += 1; const preview = buildDeterministicAnswerPreview(result.answer); if (preview.length > 0) { lines.push(`- ${result.query}: ${preview}`); } else { lines.push( `- ${result.query}: returned ${result.results.length} source${result.results.length === 1 ? "" : "s"} without answer text.`, ); } for (const source of result.results) { if (!sourceUrls.includes(source.url)) { sourceUrls.push(source.url); } } } lines.push(""); lines.push(`Completed queries: ${results.length}`); lines.push(`Successful: ${successful}`); lines.push(`Failed: ${failed}`); lines.push(""); lines.push("Sources"); if (sourceUrls.length === 0) { lines.push("- None"); } else { for (const url of sourceUrls.slice(0, 12)) { lines.push(`- ${url}`); } if (sourceUrls.length > 12) { lines.push(`- ... and ${sourceUrls.length - 12} more`); } } return lines; } export function buildDeterministicSummary(results: QueryResultData[]): { summary: string; meta: SummaryMeta; } { const summary = buildDeterministicSummaryLines(results).join("\n").trim(); const nonEmptySummary = summary.length > 0 ? summary : "No completed search results were available when the curator session finished.\n\nSources\n- None"; return { summary: nonEmptySummary, meta: { model: null, durationMs: 0, tokenEstimate: estimateTokens(nonEmptySummary), fallbackUsed: true, fallbackReason: "deterministic-submit-fallback", edited: false, }, }; } function parseModelSelector(value: string): { provider: string; id: string } { const slashIndex = value.indexOf("/"); if (slashIndex <= 0 || slashIndex >= value.length - 1) { throw new Error(`Invalid summary model: ${value}. Use provider/model-id.`); } return { provider: value.slice(0, slashIndex), id: value.slice(slashIndex + 1), }; } async function resolveSummaryModelCandidates( ctx: SummaryGenerationContext, modelOverride?: string, ): Promise<{ candidates: Array<{ // biome-ignore lint/suspicious/noExplicitAny: Model generic requires an API type; any is the Pi convention for model registry lookups model: Model; apiKey: string; headers?: Record; }>; errors: string[]; }> { const enabledModelPatterns = loadEnabledModelPatterns(ctx); const specs: Array<{ provider: string; id: string }> = []; const normalizedOverride = typeof modelOverride === "string" ? modelOverride.trim() : ""; if (normalizedOverride.length > 0) specs.push(parseModelSelector(normalizedOverride)); specs.push(...PREFERRED_SUMMARY_MODELS); const candidates: Array<{ // biome-ignore lint/suspicious/noExplicitAny: Model generic requires an API type; any is the Pi convention for model registry lookups model: Model; apiKey: string; headers?: Record; }> = []; const errors: string[] = []; const seen = new Set(); for (const spec of specs) { const value = `${spec.provider}/${spec.id}`; if (seen.has(value)) continue; seen.add(value); const model = ctx.modelRegistry.find(spec.provider, spec.id); if (!model) { errors.push(`Summary model not found: ${value}`); continue; } if (!modelMatchesEnabledPatterns(model, enabledModelPatterns)) { errors.push(`Summary model is not enabled: ${value}`); continue; } const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model); if (!auth.ok || !auth.apiKey) { errors.push(`No API key available for summary model ${value}`); continue; } candidates.push({ model, apiKey: auth.apiKey, headers: auth.headers }); } return { candidates, errors }; } function buildFallbackSummary( results: QueryResultData[], fallbackReason: string, ): { summary: string; meta: SummaryMeta } { const deterministic = buildDeterministicSummary(results); return { summary: deterministic.summary, meta: { ...deterministic.meta, fallbackReason, }, }; } function isAbortError(err: unknown): boolean { if (!err || typeof err !== "object") return false; const name = (err as { name?: unknown }).name; const message = (err as { message?: unknown }).message; return ( name === "AbortError" || (typeof message === "string" && message.toLowerCase().includes("abort")) ); } function getTextFromContentPart(part: unknown): string { if (!part || typeof part !== "object") return ""; const value = part as Record; if (typeof value.text === "string") return value.text; if (typeof value.refusal === "string") return value.refusal; return ""; } function getContentPartType(part: unknown): string { if (!part || typeof part !== "object") return "unknown"; const value = part as Record; return typeof value.type === "string" ? value.type : "unknown"; } export async function generateSummaryDraft( results: QueryResultData[], ctx: SummaryGenerationContext, signal?: AbortSignal, modelOverride?: string, feedback?: string, ): Promise<{ summary: string; meta: SummaryMeta }> { if (!ctx?.modelRegistry) { throw new Error("Summary generation context unavailable"); } const prompt = buildSummaryPrompt(results, feedback); let resolved: Awaited>; try { resolved = await resolveSummaryModelCandidates(ctx, modelOverride); } catch (err) { const message = err instanceof Error ? err.message : String(err); return buildFallbackSummary(results, `summary-model-settings-error: ${message}`); } let lastError = resolved.errors.at(-1); for (const { model, apiKey, headers } of resolved.candidates) { const startedAt = Date.now(); try { const userMessage: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now(), }; const response = await complete( model, { messages: [userMessage] }, { apiKey, headers, signal }, ); if (response.stopReason === "aborted") { throw new Error("Aborted"); } const contentParts = Array.isArray(response.content) ? response.content : []; const summary = contentParts .map((part) => getTextFromContentPart(part)) .filter((text) => text.trim().length > 0) .join("\n") .trim(); if (summary.length === 0) { const partTypes = contentParts.map((part) => getContentPartType(part)); const typesLabel = partTypes.length > 0 ? partTypes.join(", ") : "none"; throw new Error(`Summary model returned empty response (content parts: ${typesLabel})`); } return { summary, meta: { model: `${model.provider}/${model.id}`, durationMs: Math.max(0, Date.now() - startedAt), tokenEstimate: estimateTokens(summary), fallbackUsed: false, edited: false, }, }; } catch (err) { if (isAbortError(err)) throw err; lastError = err instanceof Error ? err.message : String(err); } } return buildFallbackSummary( results, lastError ? `summary-model-unavailable: ${lastError}` : "summary-model-unavailable", ); }