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",
);
}