import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { uuidv7 } from "@earendil-works/pi-ai"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui"; import type { ChatSuggestConfig } from "./config.ts"; import { getLatestAssistantResponse, limitConversation } from "./messages.ts"; import type { SuggestionState } from "./state.ts"; const SUGGESTION_STATUS_KEY = "chat-suggest-model"; export const DEFAULT_SUGGESTION_PROMPT_FILE = fileURLToPath(new URL("./prompt.txt", import.meta.url)); export interface SuggestionProviderOptions { ctx: ExtensionContext; getConfig: () => ChatSuggestConfig; getSuggestionState: () => SuggestionState | undefined; getSessionSummary: () => string; } interface SuggestionCache { key: string; createdAt: number; prompts: string[]; } function editorText(lines: string[]): string { return lines.join("\n"); } function cursorAtDocumentEnd(lines: string[], cursorLine: number, cursorCol: number): boolean { return cursorLine === lines.length - 1 && cursorCol === (lines[cursorLine]?.length ?? 0); } export function isCommandArgumentContext(lines: string[], cursorLine: number, cursorCol: number): boolean { if (cursorLine !== 0 || lines.length !== 1) return false; return /^\/(?:suggest|summary)\s/.test((lines[0] ?? "").slice(0, cursorCol)); } export function isBuiltInCompletionContext(lines: string[], cursorLine: number, cursorCol: number): boolean { const line = lines[cursorLine] ?? ""; const beforeCursor = line.slice(0, cursorCol); const trimmedDocument = editorText(lines).trimStart(); if (trimmedDocument.startsWith("/")) return true; const token = beforeCursor.match(/\S+$/)?.[0] ?? ""; if (!token) return false; if (/^[@#$]/.test(token)) return true; if (/^(?:~\/|\.\.?\/|\/)/.test(token)) return true; return token.includes("/") || token.includes("\\"); } export function resolveSuggestionPromptFile(config: ChatSuggestConfig, cwd: string): string { const configured = config.promptFile?.trim(); if (!configured) return DEFAULT_SUGGESTION_PROMPT_FILE; if (configured === "~") return homedir(); if (configured.startsWith("~/")) return resolve(homedir(), configured.slice(2)); return isAbsolute(configured) ? configured : resolve(cwd, configured); } export function loadSuggestionPromptTemplate(config: ChatSuggestConfig, cwd: string): string { const path = resolveSuggestionPromptFile(config, cwd); const template = readFileSync(path, "utf8").trim(); if (!template) throw new Error(`Chat Suggest prompt file is empty: ${path}`); return template; } export function buildSuggestionPrompt( count: number, latestAgentResponse: string, sessionSummary: string, draft: string, template = readFileSync(DEFAULT_SUGGESTION_PROMPT_FILE, "utf8").trim(), ): string { const draftInstruction = draft.trim() ? "The editor contains a draft. Complete its intent as exact send-ready user input." : "The editor is empty. Predict the exact input the user is most likely to type next."; const instructions = template .replaceAll("{{count}}", String(count)) .replaceAll("{{draft_instruction}}", draftInstruction) .trim(); return [ instructions, "", "", latestAgentResponse, "", "", "", sessionSummary || "(not available)", "", "", "", draft || "(empty)", "", ].join("\n"); } export function parseSuggestionResponse(text: string, count: number): string[] { const unfenced = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, ""); const start = unfenced.indexOf("["); const end = unfenced.lastIndexOf("]"); if (start < 0 || end <= start) throw new Error("Suggestion model did not return a JSON array"); let parsed: unknown; try { parsed = JSON.parse(unfenced.slice(start, end + 1)); } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Suggestion model returned invalid JSON: ${message}`); } if (!Array.isArray(parsed)) throw new Error("Suggestion model returned an invalid value"); const unique: string[] = []; for (const value of parsed) { if (typeof value !== "string") continue; const prompt = value.trim().replace(/\s+/g, " ").slice(0, 2_000); if (!prompt || unique.includes(prompt)) continue; unique.push(prompt); if (unique.length >= count) break; } if (unique.length < 1) throw new Error("Suggestion model returned no usable choices"); return unique; } function resolveModel(ctx: ExtensionContext, config: ChatSuggestConfig) { if (config.model.provider && config.model.id) { const configured = ctx.modelRegistry.find(config.model.provider, config.model.id); if (!configured) throw new Error(`Configured model ${config.model.provider}/${config.model.id} was not found`); if (!ctx.modelRegistry.hasConfiguredAuth(configured)) { throw new Error(`No authentication is configured for ${config.model.provider}/${config.model.id}`); } return configured; } if (!ctx.model) throw new Error("No active model is available"); if (!ctx.modelRegistry.hasConfiguredAuth(ctx.model)) { throw new Error(`No authentication is configured for ${ctx.model.provider}/${ctx.model.id}`); } return ctx.model; } export async function generateSuggestions( ctx: ExtensionContext, config: ChatSuggestConfig, latestAgentResponse: string, sessionSummary: string, draft: string, signal: AbortSignal, ): Promise { const template = loadSuggestionPromptTemplate(config, ctx.cwd); const response = await ctx.modelRegistry.complete( resolveModel(ctx, config), { messages: [ { role: "user", content: [{ type: "text", text: buildSuggestionPrompt(config.suggestions.count, latestAgentResponse, sessionSummary, draft, template) }], timestamp: Date.now(), }, ], }, { maxTokens: config.model.maxTokens, reasoningEffort: config.model.reasoningEffort, signal, cacheRetention: "none", sessionId: uuidv7(), }, ); const text = response.content .filter((part): part is { type: "text"; text: string } => part.type === "text") .map((part) => part.text) .join("\n"); if (!text.trim()) throw new Error("Suggestion model returned no text"); return parseSuggestionResponse(text, config.suggestions.count); } function suggestionItems(prompts: string[]): AutocompleteItem[] { return prompts.map((prompt, index) => ({ value: prompt, label: prompt.length > 100 ? `${prompt.slice(0, 99)}…` : prompt, description: `Chat Suggest choice ${index + 1}`, })); } export function createSuggestionProvider( current: AutocompleteProvider, options: SuggestionProviderOptions, ): AutocompleteProvider { const ownedItems = new WeakSet(); let cache: SuggestionCache | undefined; let lastError: string | undefined; const canSuggest = (lines: string[], cursorLine: number, cursorCol: number): boolean => { const config = options.getConfig(); if (!config.enabled || !cursorAtDocumentEnd(lines, cursorLine, cursorCol)) return false; if (isBuiltInCompletionContext(lines, cursorLine, cursorCol)) return false; return Boolean(getLatestAssistantResponse(options.ctx.sessionManager.getBranch())); }; return { async getSuggestions(lines, cursorLine, cursorCol, request): Promise { if (request.force && isCommandArgumentContext(lines, cursorLine, cursorCol)) { return current.getSuggestions(lines, cursorLine, cursorCol, { ...request, force: false }); } if (!request.force || !canSuggest(lines, cursorLine, cursorCol)) { return current.getSuggestions(lines, cursorLine, cursorCol, request); } const config = options.getConfig(); const latest = getLatestAssistantResponse(options.ctx.sessionManager.getBranch()); if (!latest) return current.getSuggestions(lines, cursorLine, cursorCol, request); const draft = limitConversation(editorText(lines).trim(), config.suggestions.maxDraftChars); const sessionSummary = options.getSessionSummary(); const automaticState = options.getSuggestionState(); const latestResponse = limitConversation(latest.text, config.suggestions.maxAgentResponseChars); const cacheKey = JSON.stringify({ entryId: latest.entryId, draft, sessionSummary, count: config.suggestions.count, provider: config.model.provider, model: config.model.id, reasoningEffort: config.model.reasoningEffort, }); const cacheAge = cache ? Date.now() - cache.createdAt : Number.POSITIVE_INFINITY; const automaticPrompts = !draft && automaticState?.latestAssistantEntryId === latest.entryId && automaticState.prompts.length >= 1 ? automaticState.prompts.slice(0, config.suggestions.count) : undefined; let prompts: string[]; if (automaticPrompts) { prompts = automaticPrompts; } else if (cache?.key === cacheKey && cacheAge <= config.suggestions.cacheSeconds * 1_000) { prompts = cache.prompts; } else { if (options.ctx.mode === "tui") options.ctx.ui.setStatus(SUGGESTION_STATUS_KEY, "Suggesting…"); try { prompts = await generateSuggestions(options.ctx, config, latestResponse, sessionSummary, draft, request.signal); cache = { key: cacheKey, createdAt: Date.now(), prompts }; lastError = undefined; } catch (error) { if (request.signal.aborted) return null; const message = error instanceof Error ? error.message : String(error); if (message !== lastError) { lastError = message; options.ctx.ui.notify(`Chat Suggest failed: ${message}`, "error"); } return null; } finally { if (options.ctx.mode === "tui") options.ctx.ui.setStatus(SUGGESTION_STATUS_KEY, undefined); } } const items = suggestionItems(prompts); for (const item of items) ownedItems.add(item); return { items, prefix: "" }; }, applyCompletion(lines, cursorLine, cursorCol, item, prefix) { if (!ownedItems.has(item)) return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix); const replacement = item.value.split("\n"); const lastLine = replacement.length - 1; return { lines: replacement, cursorLine: lastLine, cursorCol: replacement[lastLine]?.length ?? 0, }; }, shouldTriggerFileCompletion(lines, cursorLine, cursorCol) { if (canSuggest(lines, cursorLine, cursorCol)) return true; return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true; }, }; }