|
| 1 | +import { completeSimple } from "@earendil-works/pi-ai"; |
| 2 | +import type { AgentBundle } from "./agent.js"; |
| 3 | + |
| 4 | +/** |
| 5 | + * Prompt-suggestion ghost-text generation. After the agent goes idle, |
| 6 | + * we issue a one-shot side call on the user's main model that reuses |
| 7 | + * the parent's system prompt + conversation prefix and asks the model |
| 8 | + * to predict the user's next input as a short string. The matching |
| 9 | + * prefix lets the upstream's prompt cache reuse the parent's work, so |
| 10 | + * the marginal cost is approximately the suggestion's own output |
| 11 | + * tokens. |
| 12 | + * |
| 13 | + * Cost notes by provider: |
| 14 | + * - Codebase Auto: zero marginal cost (in-house inference). |
| 15 | + * - Anthropic / OpenAI: small (native prompt caching hits the prefix). |
| 16 | + * - Other BYOK upstreams (Groq, Mistral, …): a small uncached call |
| 17 | + * per suggestion. Disable via CODEBASE_NO_SUGGESTIONS=1. |
| 18 | + */ |
| 19 | + |
| 20 | +/** |
| 21 | + * Instructions appended as a final user message. The *technique* — |
| 22 | + * meta-prompting an assistant to predict the next user input — is a |
| 23 | + * common pattern across coding tools, but the wording below is |
| 24 | + * original to this project (Claude Code uses a similar idea with |
| 25 | + * different words; we're not copying that text). |
| 26 | + */ |
| 27 | +const SUGGESTION_PROMPT = `Switching to autocomplete mode for one reply. |
| 28 | +
|
| 29 | +Reread the conversation so far and forecast the user's next message — the words they themselves would type into the input box, not advice about what they should do. |
| 30 | +
|
| 31 | +Calibration: if the user would read your reply and think "yeah, I was about to send that," you nailed it. If they'd read it and think "the assistant is talking to me," you missed. |
| 32 | +
|
| 33 | +Guideline patterns: |
| 34 | +- After a job that finished cleanly, the next ask is usually the obvious follow-through: run the tests, push the commit, open the PR, ship it. |
| 35 | +- When you (the assistant) listed options, prefer the one the user clearly wants given the thread above. |
| 36 | +- When you asked a yes/no, pick the answer their tone implies. |
| 37 | +- When the last assistant turn was a stack trace, a clarifying question, or anything where the user needs a beat to react: produce nothing. |
| 38 | +
|
| 39 | +Things that disqualify a suggestion: |
| 40 | +- Anything in your own voice ("I'll handle it", "Let me check", "Here's a plan"). |
| 41 | +- Praise or filler the user would never type to drive forward ("nice", "looks great", "thanks"). |
| 42 | +- Open-ended questions thrown back at you ("what do you think?"). |
| 43 | +- A pivot to a new topic the user didn't ask about. |
| 44 | +- More than one sentence; more than about a dozen words. |
| 45 | +
|
| 46 | +Output: just the predicted user message, no quotes, no preamble, no trailing punctuation flourish. If nothing fits, return an empty reply.`; |
| 47 | + |
| 48 | +/** Don't suggest before this many assistant turns — needs context to predict from. */ |
| 49 | +const MIN_ASSISTANT_TURNS = 2; |
| 50 | + |
| 51 | +/** Cap output so a runaway model can't burn tokens. 60 ≈ 12 words × 5 token/word, with slack. */ |
| 52 | +const MAX_SUGGESTION_TOKENS = 60; |
| 53 | + |
| 54 | +/** |
| 55 | + * Try to produce a suggestion for the current conversation. Returns null |
| 56 | + * when we shouldn't suggest (early turn, last response was an error, |
| 57 | + * abort fired, model returned empty or filtered text). |
| 58 | + */ |
| 59 | +export async function generateSuggestion( |
| 60 | + bundle: AgentBundle, |
| 61 | + options: { signal?: AbortSignal } = {}, |
| 62 | +): Promise<string | null> { |
| 63 | + const agentState = bundle.agent.state; |
| 64 | + const messages = agentState.messages; |
| 65 | + |
| 66 | + // Need at least a couple assistant turns to predict from — anything |
| 67 | + // less and the suggestion is guessing into the void. |
| 68 | + const assistantTurnCount = messages.reduce((n, m) => n + (m.role === "assistant" ? 1 : 0), 0); |
| 69 | + if (assistantTurnCount < MIN_ASSISTANT_TURNS) return null; |
| 70 | + |
| 71 | + // If the latest assistant message errored, the user should read/react, |
| 72 | + // not be nudged to type more. |
| 73 | + const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant"); |
| 74 | + if (lastAssistant && "errorMessage" in lastAssistant && lastAssistant.errorMessage) return null; |
| 75 | + |
| 76 | + const suggestionMessages = [ |
| 77 | + ...messages, |
| 78 | + { |
| 79 | + role: "user" as const, |
| 80 | + content: SUGGESTION_PROMPT, |
| 81 | + timestamp: Date.now(), |
| 82 | + }, |
| 83 | + ]; |
| 84 | + |
| 85 | + let assistantMessage: Awaited<ReturnType<typeof completeSimple>>; |
| 86 | + try { |
| 87 | + assistantMessage = await completeSimple( |
| 88 | + agentState.model, |
| 89 | + { |
| 90 | + systemPrompt: agentState.systemPrompt, |
| 91 | + messages: suggestionMessages, |
| 92 | + }, |
| 93 | + { |
| 94 | + signal: options.signal, |
| 95 | + maxTokens: MAX_SUGGESTION_TOKENS, |
| 96 | + temperature: 0.7, |
| 97 | + // `cacheRetention: "short"` is pi-ai's default — keeps the |
| 98 | + // suggestion fork from extending the parent's cache TTL, |
| 99 | + // which the agent loop manages on its own. |
| 100 | + }, |
| 101 | + ); |
| 102 | + } catch { |
| 103 | + return null; |
| 104 | + } |
| 105 | + |
| 106 | + const text = extractText(assistantMessage).trim(); |
| 107 | + if (!text) return null; |
| 108 | + if (shouldFilterSuggestion(text)) return null; |
| 109 | + |
| 110 | + return text; |
| 111 | +} |
| 112 | + |
| 113 | +function extractText(message: Awaited<ReturnType<typeof completeSimple>>): string { |
| 114 | + if (typeof message.content === "string") return message.content; |
| 115 | + if (!Array.isArray(message.content)) return ""; |
| 116 | + const parts: string[] = []; |
| 117 | + for (const block of message.content) { |
| 118 | + if (block.type === "text" && typeof block.text === "string") { |
| 119 | + parts.push(block.text); |
| 120 | + } |
| 121 | + } |
| 122 | + return parts.join(""); |
| 123 | +} |
| 124 | + |
| 125 | +/** |
| 126 | + * Filter out suggestions that don't look like real user input. The list |
| 127 | + * is a pragmatic subset of Claude Code's filters — anything the model |
| 128 | + * tends to emit when it doesn't know what to suggest but isn't quite |
| 129 | + * willing to say nothing. |
| 130 | + */ |
| 131 | +function shouldFilterSuggestion(text: string): boolean { |
| 132 | + const lower = text.toLowerCase(); |
| 133 | + if (lower === "done" || lower === "done.") return true; |
| 134 | + if (lower === "nothing found" || lower === "nothing found.") return true; |
| 135 | + if (lower.startsWith("nothing to suggest") || lower.startsWith("no suggestion")) return true; |
| 136 | + if (/\bstay(s|ing)? silent\b|\bsilence is\b/.test(lower)) return true; |
| 137 | + if (/^\W*silence\W*$/.test(lower)) return true; |
| 138 | + // Assistant-voice slippage — model addresses the user as if it were |
| 139 | + // replying in-character instead of predicting their input. |
| 140 | + if (/^(let me|i('|\s)ll|here('|\s)s|i can|i('|\s)d)\b/i.test(text)) return true; |
| 141 | + // Evaluative one-liners — these aren't actions, they're chitchat the |
| 142 | + // user wouldn't typically type to drive forward. |
| 143 | + if (/^(looks good|thanks|nice|great|ok|okay|yes)\.?$/i.test(text.trim())) return true; |
| 144 | + // Too long — keep ghost-text legible (12 words is the cap CC uses). |
| 145 | + if (text.split(/\s+/).length > 12) return true; |
| 146 | + // Multi-sentence — pick a single utterance. |
| 147 | + if (/[.!?]\s+\S/.test(text)) return true; |
| 148 | + return false; |
| 149 | +} |
0 commit comments