/**
* 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 };
}