Next-prompt prediction: after each main-thread turn, a fork of the session's own model predicts your next message and shows it as the prompt box's suggestion…

Next-prompt prediction for Claude Code, built on mods.
After each main-thread turn that ends with an answer, the plugin makes one $.model.fork: the session's own model, reusing its prompt cache, with no tools, and nothing written to the transcript. The fork reads the conversation, your recent typed prompts in this project and the short messages you send often, and writes the message you are likely to send next. The plugin shows it as the prompt box's dim suggestion; press Tab to take it. A reply of NONE (or an empty or overlong one) shows nothing and counts as an abstention. Subagent turns do not fork.
In the terminal the plugin hides Claude Code's own prompt suggestion, so only its prediction shows. In the desktop app that hook does not run (anthropics/claude-code#99876), so set "promptSuggestionEnabled": false in your settings to keep the two from competing.
Prints, over the logged outcomes: how many shown suggestions were taken (the prompt you sent starts with the suggestion), how many were near-taken (edit distance within 30% of the suggestion's length), the mean characters kept, the abstentions, the median fork time, and the share of fork input tokens read from cache. It answers in Chinese or English, following the settings language, then LC_ALL / LANG.
In the plugin store:
A -p run neither forks nor records.
One fork per main-thread turn, billed to your session; most of its input is read from the prompt cache.
None.
hooks/predict-hooks.ts 207 lines1import type { EngineInterface, HookFailure, Register } from 'claude-code'
2import { pickLang } from './i18n'
3import type { Lang } from './i18n'
4import { strings } from './predict-i18n'
5import { NO_PREDICTION, editDistance, predictionPrompt, promptSamples } from './predict-prompt'
6import type { TypedPrompt } from './predict-prompt'
7
8const TYPED_PROMPTS_KEY = 'typedPrompts'
9// Enough history for habits to repeat; 300 entries of 300 characters stay far under the store's 4 MiB.
10const TYPED_PROMPTS_KEPT = 300
11const TYPED_PROMPT_CHARS = 300
12// Prompts the person typed: at a terminal, through Remote Control, or in the desktop app (an SDK host).
13const TYPED_ORIGINS: ReadonlySet<string> = new Set(['composer', 'bridge', 'sdk'])
14// A reply this long ignored the prompt's length rule, so it is not a message the person would send.
15const PREDICTION_MAX_CHARS = 600
16const PREDICTION_LOG_KEY = 'predictionLog'
17const PREDICTION_LOG_KEPT = 500
18const PREDICTION_LOG_CHARS = 300
19// An outcome whose edit distance is at most this share of the suggestion's length counts as near-taken.
20const NEAR_TAKEN_SHARE = 0.3
21const PREDICTION_ABSTAINED_KEY = 'predictionAbstained'
22const STATS_COMMAND = 'prediction-stats'
23
24/**
25 * One shown suggestion and the typed prompt after it: `at` epoch ms, `forkMs` the fork's time,
26 * `chars` the suggestion's length, `inputTokens` the fork's whole input (uncached, cache read and cache write), `cachedTokens` the part
27 * read from cache. `editDistance` is between the suggestion and the typed prompt cut to its length; `retainedChars` the suggestion's
28 * length less that distance.
29 */
30type PredictionOutcome = {
31 at: number
32 forkMs: number
33 chars: number
34 isAccepted: boolean
35 inputTokens: number
36 cachedTokens: number
37 suggestion: string
38 actual: string
39 editDistance: number
40 retainedChars: number
41}
42
43// The suggestion in the prompt box now; any submitted prompt ends it, and a reload drops it unlogged.
44let shownPrediction: { text: string; forkMs: number; inputTokens: number; cachedTokens: number } | null = null
45// The desktop app's last next-step box: whether it asked a question, and the prompts its reply buttons send.
46let box: { asked: boolean; replies: string[] } | null = null
47
48// Lives here, not in i18n.ts: validate only follows $ into functions declared in the same file.
49async function resolveLang($: EngineInterface, option: unknown): Promise<Lang> {
50 const settings = await $.settings.read()
51 return pickLang(option, settings.language, (await $.env.get('LC_ALL')) || (await $.env.get('LANG')))
52}
53
54function logFailure($: EngineInterface, event: string, error: HookFailure): void {
55 $.ui.log(`next-prompt: ${event} hook failed (${error.kind}): ${error.message ?? 'no message'}`, { to: 'debug' })
56}
57
58async function typedPrompts($: EngineInterface): Promise<TypedPrompt[]> {
59 const stored = await $.store.get(TYPED_PROMPTS_KEY)
60 return Array.isArray(stored)
61 ? stored.filter((p): p is TypedPrompt => typeof p?.text === 'string' && typeof p?.cwd === 'string')
62 : []
63}
64
65/**
66 * Asks a fork of the main thread for the person's next message and proposes it as the prompt box's suggestion;
67 * a reply that is NONE, empty or too long counts an abstention.
68 */
69async function suggestNext($: EngineInterface): Promise<void> {
70 // A plain -p run has no prompt box to show it in, so the fork would be paid for nothing.
71 if ((await $.session.surfaces()).length === 0) return
72 const samples = promptSamples(await typedPrompts($), await $.session.cwd())
73 const started = await $.clock.now()
74 const reply = await $.model.fork({ prompt: predictionPrompt(samples) })
75 const forkMs = (await $.clock.now()) - started
76 if (!reply.isAnswered) return
77 const text = reply.text.trim().replace(/^["“「]+|["”」]+$/g, '').trim()
78 if (text === '' || text === NO_PREDICTION || text.length > PREDICTION_MAX_CHARS) {
79 const abstained = await $.store.get(PREDICTION_ABSTAINED_KEY)
80 await $.store.set(PREDICTION_ABSTAINED_KEY, (typeof abstained === 'number' ? abstained : 0) + 1)
81 return
82 }
83 const { input_tokens, cache_read_input_tokens, cache_creation_input_tokens } = reply.usage
84 const inputTokens = input_tokens + cache_read_input_tokens + cache_creation_input_tokens
85 if ((await $.prompt.suggest({ text })).isShown) shownPrediction = { text, forkMs, inputTokens, cachedTokens: cache_read_input_tokens }
86}
87
88async function predictionLog($: EngineInterface): Promise<PredictionOutcome[]> {
89 const stored = await $.store.get(PREDICTION_LOG_KEY)
90 return Array.isArray(stored) ? (stored as PredictionOutcome[]) : []
91}
92
93async function predictionStats($: EngineInterface, language: unknown): Promise<string> {
94 const t = strings[await resolveLang($, language)]
95 const log = await predictionLog($)
96 if (log.length === 0) return t.predictionStatsEmpty
97 const ms = log.map(o => o.forkMs).sort((a, b) => a - b)
98 const median = ms.length % 2 === 1 ? (ms[(ms.length - 1) / 2] ?? 0) : ((ms[ms.length / 2 - 1] ?? 0) + (ms[ms.length / 2] ?? 0)) / 2
99 const taken = log.filter(o => o.isAccepted).length
100 // Entries logged before token counts were recorded lack them.
101 const counted = log.filter(o => typeof o.inputTokens === 'number' && typeof o.cachedTokens === 'number')
102 const input = counted.reduce((sum, o) => sum + o.inputTokens, 0)
103 const cached = counted.reduce((sum, o) => sum + o.cachedTokens, 0)
104 const cachePercent = input === 0 ? null : Math.round((100 * cached) / input)
105 // Entries logged before edit distances were recorded lack them, and their turns counted no abstentions.
106 const edited = log.filter(o => typeof o.editDistance === 'number' && typeof o.retainedChars === 'number')
107 const near = edited.filter(o => o.editDistance <= NEAR_TAKEN_SHARE * o.chars).length
108 const edits = edited.length === 0 ? null : {
109 near,
110 counted: edited.length,
111 nearPercent: Math.round((100 * near) / edited.length),
112 meanRetained: (edited.reduce((sum, o) => sum + o.retainedChars, 0) / edited.length).toFixed(1),
113 }
114 const stored = await $.store.get(PREDICTION_ABSTAINED_KEY)
115 const abstained = typeof stored === 'number' ? stored : 0
116 const decisions = abstained + edited.length
117 const abstainPercent = decisions === 0 ? null : Math.round((100 * abstained) / decisions)
118 return t.predictionStats(taken, log.length, Math.round((100 * taken) / log.length), (median / 1000).toFixed(1), cachePercent, edits, abstained, abstainPercent)
119}
120
121export const register: Register = (on, options) => {
122 // A matcher on every hook: in the source tree register.tsx holds the matcher-less one on each of these events, and the engine refuses a second.
123 // The fork answers only after the turn has ended, so it runs on a timer: a dispatch's own work stops with it.
124 on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
125 const done = await next(e)
126 if (e.agentId === undefined && e.reason === 'answer' && box?.asked !== true) {
127 $.clock.after(0, () => void suggestNext($).catch(error => $.ui.log(`next-prompt: prediction failed: ${String(error)}`, { to: 'debug' })))
128 }
129 if (e.agentId === undefined && box !== null) box = { ...box, asked: false }
130 return done
131 }).catch(($, e, next) => {
132 logFailure($, 'turn.complete', next.error)
133 return next(e)
134 })
135
136 // A box that asks a question already offers the next step, so a prediction would repeat its buttons.
137 on('classic.PostToolUse', { tool_name: /^mcp__ccd_turn__wrap_up$/ }, async ($, e, next) => {
138 const input = e.tool_input
139 if (e.agent_id === undefined && typeof input === 'object' && input !== null) {
140 const { ask, actions = [] } = input as { ask?: unknown; actions?: unknown }
141 if (Array.isArray(actions)) {
142 // A button with `action` runs an app action instead of sending a prompt.
143 const replies = actions.flatMap((a: unknown) => {
144 if (typeof a !== 'object' || a === null || 'action' in a) return []
145 const { message, label } = a as { message?: unknown; label?: unknown }
146 const text = message ?? label
147 return typeof text === 'string' && text.trim() !== '' ? [text.trim()] : []
148 })
149 box = { asked: typeof ask === 'string' && ask !== '' && actions.length > 0, replies }
150 }
151 }
152 return next(e)
153 }).catch(($, e, next) => {
154 logFailure($, 'classic.PostToolUse', next.error)
155 return next(e)
156 })
157
158 // The plugin's prediction replaces the engine's own; a desktop session never runs this hook (anthropics/claude-code#99876).
159 on('prompt.suggest', { origin: { kind: 'suggestion' } }, () => ({ isShown: false }))
160
161 on('prompt.submit', { wait: [true, false] }, async ($, e, next) => {
162 const entered = await next(e)
163 const shown = shownPrediction
164 shownPrediction = null
165 const replies = box?.replies ?? []
166 box = null
167 // A box's button text is Claude's, not the person's typing.
168 const isReply = replies.includes(e.text.trim())
169 // A -p run is an SDK host too, so only a session something draws on holds typed prompts.
170 if (!('drop' in entered) && !isReply && TYPED_ORIGINS.has(e.origin.kind) && (await $.session.surfaces()).length > 0) {
171 if (shown !== null) {
172 const { text, ...measured } = shown
173 const distance = editDistance(text, e.text.slice(0, text.length))
174 const outcome: PredictionOutcome = {
175 at: await $.clock.now(),
176 ...measured,
177 chars: text.length,
178 isAccepted: e.text.startsWith(text),
179 suggestion: text.slice(0, PREDICTION_LOG_CHARS),
180 actual: e.text.slice(0, PREDICTION_LOG_CHARS),
181 editDistance: distance,
182 retainedChars: Math.max(0, text.length - distance),
183 }
184 await $.store.set(PREDICTION_LOG_KEY, [...(await predictionLog($)), outcome].slice(-PREDICTION_LOG_KEPT))
185 }
186 if (!e.text.startsWith('/')) {
187 const typed = [...(await typedPrompts($)), { text: e.text.slice(0, TYPED_PROMPT_CHARS), cwd: await $.session.cwd() }]
188 await $.store.set(TYPED_PROMPTS_KEY, typed.slice(-TYPED_PROMPTS_KEPT))
189 }
190 }
191 return entered
192 }).catch(($, e, next) => {
193 logFailure($, 'prompt.submit', next.error)
194 return next(e)
195 })
196
197 on('session.start', { cwd: /^/ }, async ($, e, next) => {
198 await $.command.register({ name: STATS_COMMAND, description: 'Show how often next-prompt predictions were taken and the median fork time' })
199 return next(e)
200 }).catch(($, e, next) => {
201 logFailure($, 'session.start', next.error)
202 return next(e)
203 })
204
205 on('command.run', { command: STATS_COMMAND }, async $ => ({ text: await predictionStats($, options.language) }))
206}
207hooks/i18n.ts 21 lines1export type Lang = 'zh-CN' | 'en'
2
3const CHINESE = /^zh\b|chinese|中文|汉语|漢語|简体|繁體|mandarin/i
4
5/** `language` userConfig → UI language; `auto` follows settings `language`, then the locale, else English. */
6export function pickLang(option: unknown, settingsLanguage: unknown, locale: string | undefined): Lang {
7 if (option === 'zh-CN' || option === 'en') return option
8 if (typeof settingsLanguage === 'string' && settingsLanguage.trim() !== '') return CHINESE.test(settingsLanguage.trim()) ? 'zh-CN' : 'en'
9 return locale !== undefined && /^zh/i.test(locale) ? 'zh-CN' : 'en'
10}
11
12const zh = {
13 idleCompacted: '空闲将满缓存时效,已自动压缩上下文',
14}
15
16const en: typeof zh = {
17 idleCompacted: 'Idle near cache expiry: context compacted',
18}
19
20export const strings: Record<Lang, typeof zh> = { 'zh-CN': zh, en }
21hooks/predict-i18n.ts 34 lines1import type { Lang } from './i18n'
2
3/** Near-taken outcomes among the `counted` that hold an edit distance, and the mean characters kept per outcome. */
4type EditFigures = { near: number; counted: number; nearPercent: number; meanRetained: string }
5
6const zh = {
7 predictionStats: (
8 taken: number,
9 shown: number,
10 percent: number,
11 medianSeconds: string,
12 cachePercent: number | null,
13 edits: EditFigures | null,
14 abstained: number,
15 abstainPercent: number | null,
16 ) =>
17 `下一步预测:最近显示的 ${shown} 条里采纳了 ${taken} 条(${percent}%);fork 耗时中位数 ${medianSeconds} 秒` +
18 (cachePercent === null ? '' : `;fork 输入 token 有 ${cachePercent}% 读自缓存`) +
19 (edits === null ? '' : `;${edits.counted} 条里 ${edits.near} 条接近采纳(${edits.nearPercent}%,编辑距离不超过长度的 30%),平均每条保留 ${edits.meanRetained} 字`) +
20 (abstainPercent === null ? '' : `;放弃显示 ${abstained} 次(占全部决定的 ${abstainPercent}%)`),
21 predictionStatsEmpty: '还没有预测记录:显示过的预测在你发出下一条后才计入',
22}
23
24const en: typeof zh = {
25 predictionStats: (taken, shown, percent, medianSeconds, cachePercent, edits, abstained, abstainPercent) =>
26 `Next-prompt predictions: ${taken} of the last ${shown} shown were taken (${percent}%); median fork time ${medianSeconds} s` +
27 (cachePercent === null ? '' : `; ${cachePercent}% of fork input tokens read from cache`) +
28 (edits === null ? '' : `; ${edits.near} of ${edits.counted} near-taken (${edits.nearPercent}%, edit distance within 30% of the length), mean ${edits.meanRetained} chars kept`) +
29 (abstainPercent === null ? '' : `; ${abstained} abstained (${abstainPercent}% of decisions)`),
30 predictionStatsEmpty: 'No predictions logged yet: a shown prediction counts once you send your next prompt',
31}
32
33export const strings: Record<Lang, typeof zh> = { 'zh-CN': zh, en }
34hooks/predict-prompt.ts 77 lines1/** The fork's reply when it has no prediction worth showing. */
2export const NO_PREDICTION = 'NONE'
3
4/** A prompt the person typed, and the directory of the session it was typed in. */
5export type TypedPrompt = { text: string; cwd: string }
6
7export type PromptSamples = {
8 /** This project's typed prompts, oldest first; the latest few from anywhere when the project has none. */
9 recent: string[]
10 /** Short messages the person sends again and again, most frequent first. */
11 habits: string[]
12}
13
14const PROJECT_KEPT = 12
15const FALLBACK_KEPT = 6
16const HABIT_MAX_CHARS = 40
17const HABIT_MIN_COUNT = 3
18const HABITS_KEPT = 8
19
20/** Picks the samples the prediction prompt shows from `history` (oldest first) for a session in `cwd`. */
21export function promptSamples(history: readonly TypedPrompt[], cwd: string): PromptSamples {
22 const here = history.filter(p => p.cwd === cwd).map(p => p.text)
23 const recent = here.length > 0 ? here.slice(-PROJECT_KEPT) : history.slice(-FALLBACK_KEPT).map(p => p.text)
24 const counts = new Map<string, number>()
25 for (const { text } of history) {
26 const key = text.trim().replace(/[\s.!?\u3002\uff01\uff1f]+$/u, '')
27 if (key !== '' && key.length <= HABIT_MAX_CHARS) counts.set(key, (counts.get(key) ?? 0) + 1)
28 }
29 const habits = [...counts].filter(([, n]) => n >= HABIT_MIN_COUNT).sort((a, b) => b[1] - a[1]).slice(0, HABITS_KEPT).map(([text]) => text)
30 return { recent, habits }
31}
32
33const listed = (texts: readonly string[]) => (texts.length === 0 ? '(none yet)' : texts.map(text => `<message>${text}</message>`).join('\n'))
34
35/** The question the prediction fork asks after a main-thread turn. */
36export function predictionPrompt(samples: PromptSamples): string {
37 return `[NEXT-MESSAGE PREDICTION. This is not a message from the user. Do not answer it, do not continue your work, do not call tools.]
38
39Write the next message the user will send in this conversation. It goes into their prompt box as a suggestion. The goal is a message they send with Tab and Enter because it says what they were about to ask for, as fully as they would write it.
40
41First work out where the user is taking the task:
42- The overall goal: what they set out to do in this conversation, and how it has changed since.
43- The progress: what is done and verified, what is still open, and what your last answer left unverified, uncertain or broken.
44- Their habits in this project: the steps they take at this point (for example review, run the gates, merge, update the docs), from this conversation and from their messages below.
45
46Then write the message:
47- If your last answer ended with questions or choices, answer them in a few words, the way this user decides; they often do not take your recommendation. That is the start of the message, not the whole of it.
48- The main part is the one next step that moves the task forward, with at most one follow-up after it; do not chain a list of steps. It may be something your answer did not list: an open problem, an unverified claim, the next item of the plan, or a question the user wants answered first.
49- Write it as a task request someone can carry out and check: name the concrete files, components, findings and numbers from this conversation, the result they want back, and the scope (what to do now, what not yet).
50- Every detail comes from this conversation. Do not add constraints or steps the user has not asked for in it.
51- Do not report what the user has done or seen (installed, uploaded, restarted, configured, tested on the device, a result they got) unless the conversation already shows it. Write the request, not a report of its outcome.
52- Never ask for a destructive or outward-facing action (push, deploy, publish, delete, send) that the user has not already asked for.
53- Their language and voice, without filler: no sentence-final particles such as \u5427, \u5462, \u554a or \u54e6, and no politeness padding. Keep only words that carry a requirement. One or two sentences, at most 50 words or 120 Chinese characters. No preamble, no quotation marks.
54
55Messages this user wrote earlier in this project, oldest first. Use them for voice and habits; their content may be out of date:
56${listed(samples.recent)}
57
58Short messages this user sends often:
59${listed(samples.habits)}
60
61Reply with the message alone. If you cannot tell where the user is going, reply with exactly: ${NO_PREDICTION}`
62}
63
64/** Levenshtein distance over UTF-16 code units. */
65export function editDistance(a: string, b: string): number {
66 let previous = Array.from({ length: b.length + 1 }, (_, j) => j)
67 for (let i = 1; i <= a.length; i += 1) {
68 const current = [i]
69 for (let j = 1; j <= b.length; j += 1) {
70 const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
71 current.push(Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, substitution))
72 }
73 previous = current
74 }
75 return previous[b.length] ?? 0
76}
77