/** * Low-level suggestion model client. * * Pure functions for calling the suggestion model — no module-level state, * no orchestration, no debug logging. * * @module */ import { completeSimple } from "@earendil-works/pi-ai/compat"; // ── Constants ────────────────────────────────────────────────────────── export const GENERATION_TIMEOUT_MS = 20_000; /** * System prompt instructs the model to suggest a follow-up user message * (question, answer, or directive) or return the NO_SUGGESTION sentinel * when none is useful. * * The model receives no PI, SuPi, project, or conversation context — * only the last assistant message text is sent. */ const SYSTEM_PROMPT = "You suggest follow-up messages for a coding assistant conversation. " + "Given the assistant's last message, write a single line the user would type next. " + "It must be a direct question, answer, or directive — nothing else. " + "If the assistant asks for a decision, suggest a concise, specific answer to that decision, not the action that would follow from it. " + "When answering yes/no, include the relevant constraint or option text when present. " + "Do NOT include greetings, thank-yous, politeness, or conversational filler. " + "If there is no useful follow-up, respond with exactly the word NO_SUGGESTION and nothing else. " + "Keep suggestions under 240 characters."; // ── Prompt building ──────────────────────────────────────────────────── /** * Format the tail text as a completion prompt. * * Instructions live in {@link SYSTEM_PROMPT} — the user message * only provides the assistant message content to suggest from. */ export function buildPrompt(tail: string): string { return `\n${tail}\n\n\nSuggestion:`; } // ── Types ────────────────────────────────────────────────────────────── export interface SuggestionClientResult { ok: true; text: string; } export interface SuggestionClientError { ok: false; message: string; } export type SuggestionClientOutput = SuggestionClientResult | SuggestionClientError; export interface SuggestionClientOptions { // biome-ignore lint/suspicious/noExplicitAny: Model is pi's canonical type model: any; auth: { apiKey: string; headers?: Record; env?: Record }; tail: string; signal: AbortSignal; } // ── API call ─────────────────────────────────────────────────────────── /** * Call the suggestion model with a simple completion prompt. * * Pure function — all side effects (HTTP, abort) are scoped to the call. * Returns a structured result; does not log or interact with the extension * context. */ export async function callSuggestionModel( opts: SuggestionClientOptions, ): Promise { const { model, auth, tail, signal } = opts; const response = await completeSimple( model, { systemPrompt: SYSTEM_PROMPT, messages: [ { role: "user", content: [{ type: "text", text: buildPrompt(tail) }], timestamp: Date.now(), }, ], }, { apiKey: auth.apiKey, headers: auth.headers, env: auth.env, signal, }, ); if (response?.stopReason === "error") { const message = `Suggestion model failed: ${response.errorMessage ?? response.stopReason}`; return { ok: false, message }; } if (!response?.content) { const message = `Suggestion model returned no content (stopReason: ${response?.stopReason ?? "undefined"})`; return { ok: false, message }; } // Prompt suggestions are taken from normal user-visible text only. const textContent = response.content .filter((c: { type: string }) => c.type === "text") .map((c: { type: string; text?: string }) => c.text) .join(""); if (!textContent) { const contentTypes = response.content.map((c: { type: string }) => c.type); const message = `Suggestion model returned no text (stopReason: ${response.stopReason ?? "undefined"}, content types: [${contentTypes.join(", ") || "none"}])`; return { ok: false, message }; } return { ok: true, text: textContent }; }