Has Gemini review each plan before the approval dialog, from the plan and the conversation, and sends a plan with a blocking finding back to the model, at most…

In plan mode the model writes a plan and you approve it. A plan that leaves out a step you asked for, or rests on an assumption the code contradicts, is easy to approve without noticing. This mod has Gemini review every plan before it reaches you. When the model calls ExitPlanMode, Gemini reads the plan and the conversation before the approval dialog opens. A plan with a blocking finding goes back to the model, at most twice; you see the plan once it passes.
tool.call hook runs before the permission prompt, so a plan sent back never opens the approval dialog.planFilePath, else the one the engine's plan mode note names (## Plan File Info: ... create your plan at /.../x.md). The call's own plan field is used only when no path is known, because on 2.1.278 the first call after a new plan file carried neither plan nor planFilePath, and a later call carried the plan as it was before the model's last edit (measured).generateContent request with a schema: a list of findings, each blocker or minor, with a message. The plan always goes whole. When the conversation is over maxInputChars (2,000,000 characters by default), its longest tool outputs are cut to one common length, each keeping its head and tail. gemini-core builds the request with the key, the model and the thinking level it holds for gemini-plan-review, and reads the answer.blocker means a step the goal needs that the plan leaves out, an assumption the conversation or the code shown in it contradicts, a goal with no way to check that it was reached, or a decision against what you asked for. Everything else is minor.## Gemini plan review heading. Gemini reads that section in the next round and is told to accept an answer the conversation supports;In a live check on 2.1.278 with gemini-3.5-flash, the request was a --json flag with a unit test and the plan was 1. Add a --json flag to /task-poke. 2. Done.. Round 1 sent it back with The plan does not include the requested unit test for the --json flag. The model added the test step, and the second call opened the approval dialog. With gemini-3.8-flash on a free key, the same review got only 503 answers and the plan reached the dialog with the reason.
After each review a toast stays for 10 seconds, and /gemini-plan-review shows the last one:
gemini-plan-review: plan reviewed · 1 blocker, 0 minor · 621 in, 1k out · sent to Gemini free tier
The sent to Gemini free tier part appears only on the free tier. A transcript line appears when a plan goes back, passes with open blockers, or is not reviewed:
gemini-plan-review: plan sent back (round 1 of 2): plan reviewed · 1 blocker, 0 minor · 621 in, 1k out gemini-plan-review: plan reached you without a review: Gemini HTTP 503: This model is currently experiencing high demand. ...
/gemini-plan-review on or off, the model, thinking level and tier gemini-core holds, whether a key is set, the last review /gemini-plan-review on | off off: plans reach you without a review; on is refused while gemini-core has no key /gemini-plan-review reset off again, the default
The review is off after an install, so nothing goes to Gemini before you set a key and turn it on.
The key, the tier, the model (default gemini-3.8-flash) and the thinking level belong to gemini-core:
/gemini-core model plan-review gemini-3.5-flash /gemini-core thinking plan-review low /gemini-core paid
Every review sends the plan and the conversation: your prompts, the commands the model ran and the contents of the files it read. On the free tier Google may use them and human reviewers may read them; the gemini-core README quotes the Gemini API Additional Terms. On a project you would not show to Google, use a key with billing enabled and set /gemini-core paid.
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install gemini-plan-review@kilimcininkoroglu-mods
It depends on gemini-core, which claude plugin install adds. Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
/gemini-plan-review on. Without a key it answers still off: gemini-core has no Gemini key and stays off./gemini-plan-review. The first line reads on · <model> · thinking ... · <tier> tier · key set.Gemini HTTP 429 or repeated Gemini HTTP 503, pick another model with /gemini-core model plan-review.| Option | Default | What it sets |
|---|---|---|
maxInputChars | 2000000 | Characters of conversation sent at most, 10,000 to 4,000,000; the plan always goes whole |
A value outside the range, or one that is not a whole number, falls back to the default.
Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=gemini-plan-review}, prompt.submit, prompt.attachment{type=plan_mode}, tool.call{tool=ExitPlanMode} ❯ ./register.ts calls: $.clock.now (via askGemini), $.clock.sleep (via askGemini), $.command.register, $.fs.read (via planOf), $.gemini.enroll, $.gemini.read (via askGemini), $.gemini.request (via askGemini), $.gemini.settings (via review, runCommand, storeEnabled), $.http.fetch (via askGemini), $.session.messages (via review), $.store.delete (via runCommand), $.store.get (via isEnabled), $.store.set (via storeEnabled), $.ui.log (via notReviewed, verdictOf), $.ui.toast (via verdictOf)
Reach L3, reaches the network.
$.http.fetch ends after 30 seconds without a complete answer (measured on 2.1.278). A slow model then lets the plan through without a review.the call carries no plan text.$.session.messages() answers the newest 4096 messages.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 166 lines1import type { EngineInterface, PromptOrigin, Register, ToolCallResult } from 'claude-code'
2import { changeText, ENABLED_KEY, NO_KEY_ON, parseCommand, RESET_TEXT, statusText } from './command.ts'
3import { CONSUMER, configFrom, DEADLINE_MS, DEFAULT_MODEL, MAX_ROUNDS, type Config } from './config.ts'
4import { buildPlanBody, bullets, denyText, failedContext, parseFindings, passContext, planFileIn, summaryText, type Answer, type Finding } from './review.ts'
5import { renderTranscript } from './transcript.ts'
6
7/**
8 * The plans sent back since the user last spoke, the plan file the engine's
9 * plan mode note names, and the last review's line for the status.
10 */
11type State = { rounds: number; planFile?: string; last?: string }
12
13/** What the review decided: send the plan back, or let it reach the user with a note for the model. */
14type Verdict = { deny?: string; context?: string }
15
16/** What ExitPlanMode's call carries of the plan. */
17type PlanInput = { plan?: string; planFilePath?: string }
18
19/** Gemini's answer and the tier it went to, or why there is none. */
20type Asked = { answer: Answer; tier: 'free' | 'paid' } | { error: string }
21
22/** A prompt from these is the user speaking, which gives the next plan its rounds again. */
23const USER_ORIGINS: ReadonlySet<PromptOrigin['kind']> = new Set(['composer', 'bridge', 'sdk'])
24
25function errorText(err: unknown): string {
26 return err instanceof Error ? err.message : String(err)
27}
28
29/** Off until the user turns it on, so a fresh install sends nothing to Gemini. */
30async function isEnabled($: EngineInterface): Promise<boolean> {
31 return (await $.store.get(ENABLED_KEY)) === true
32}
33
34/** Stores on or off; `on` is refused while gemini-core has no key. */
35async function storeEnabled($: EngineInterface, enabled: boolean): Promise<string> {
36 if (enabled && !(await $.gemini.settings({ consumer: CONSUMER })).hasKey) return NO_KEY_ON
37 await $.store.set(ENABLED_KEY, enabled)
38 return changeText(enabled)
39}
40
41/**
42 * gemini-core builds the request and reads each answer; the request is sent
43 * here, again after a 503 while it allows, and with the next key after a 429
44 * or a key error.
45 */
46async function askGemini($: EngineInterface, body: Record<string, unknown>): Promise<Asked> {
47 const prepared = await $.gemini.request({ consumer: CONSUMER, body })
48 if ('error' in prepared) return prepared
49 const started = await $.clock.now()
50 let http = prepared.http
51 for (let attempt = 1; ; attempt++) {
52 const r = await $.http.fetch(http.url, http.init)
53 const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started, deadlineMs: DEADLINE_MS })
54 if ('answer' in read) return { answer: read.answer, tier: prepared.tier }
55 if ('error' in read) return read
56 if ('next' in read) http = read.next
57 else await $.clock.sleep(read.retryInMs)
58 }
59}
60
61function notReviewed($: EngineInterface, state: State, reason: string): Verdict {
62 state.last = `not reviewed: ${reason}`
63 $.ui.log(`plan reached you without a review: ${reason}`)
64 return { context: failedContext(reason) }
65}
66
67function verdictOf($: EngineInterface, state: State, tier: 'free' | 'paid', findings: readonly Finding[], summary: string): Verdict {
68 const blockers = findings.filter(f => f.severity === 'blocker')
69 const minors = findings.filter(f => f.severity === 'minor')
70 $.ui.toast(tier === 'free' ? `${summary} · sent to Gemini free tier` : summary, { timeoutMs: 10_000 })
71 if (blockers.length > 0 && state.rounds < MAX_ROUNDS) {
72 state.last = `sent back (round ${state.rounds + 1} of ${MAX_ROUNDS}): ${summary}`
73 $.ui.log(`plan ${state.last}`)
74 return { deny: denyText(blockers, minors, state.rounds + 1, MAX_ROUNDS) }
75 }
76 state.last = summary
77 if (blockers.length > 0) $.ui.log(`plan reached you after ${MAX_ROUNDS} rounds with ${blockers.length} open blocker(s):\n${bullets(blockers)}`)
78 return { context: passContext(blockers, minors, MAX_ROUNDS) }
79}
80
81/**
82 * The plan as the approval dialog shows it: the plan file on disk. The call's
83 * own `plan` is read only without a path, because the engine adds it to some
84 * calls only (the first after a new plan file carried neither it nor a path)
85 * and once carried the version before the model's last edit (measured on 2.1.278).
86 */
87async function planOf($: EngineInterface, input: PlanInput): Promise<string> {
88 if (input.planFilePath === undefined) return input.plan?.trim() ?? ''
89 return (await $.fs.read(input.planFilePath)).trim()
90}
91
92/** Asks Gemini about the plan. Anything that keeps the review from an answer lets the plan reach the user with a note. */
93async function review($: EngineInterface, state: State, config: Config, input: PlanInput): Promise<Verdict> {
94 try {
95 if (!(await $.gemini.settings({ consumer: CONSUMER })).hasKey) return notReviewed($, state, 'no Gemini key: set GEMINI_API_KEY or the gemini-core apiKey option')
96 const plan = await planOf($, input)
97 if (plan === '') return notReviewed($, state, 'the call carries no plan text')
98 const transcript = renderTranscript(await $.session.messages(), config.maxInputChars)
99 const asked = await askGemini($, buildPlanBody(transcript, plan))
100 if ('error' in asked) return notReviewed($, state, asked.error)
101 const findings = parseFindings(asked.answer)
102 return verdictOf($, state, asked.tier, findings, summaryText(findings, asked.answer))
103 } catch (err) {
104 return notReviewed($, state, errorText(err))
105 }
106}
107
108/** Adds a note the model reads after the tool's result; an error or a denial is left as it is. */
109function withContext(r: ToolCallResult, text: string | undefined): ToolCallResult {
110 if (text === undefined || r.deny !== undefined || r.isError === true) return r
111 return { ...r, context: [...(r.context ?? []), text] }
112}
113
114async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
115 const command = parseCommand(args)
116 if (command.kind === 'error') return command.text
117 if (command.kind === 'reset') {
118 await $.store.delete(ENABLED_KEY)
119 return RESET_TEXT
120 }
121 if (command.kind === 'set') return storeEnabled($, command.enabled)
122 return statusText(await isEnabled($), await $.gemini.settings({ consumer: CONSUMER }), state.last)
123}
124
125export const register: Register = (on, options) => {
126 const config = configFrom(options)
127 const state: State = { rounds: 0 }
128
129 on('session.start', async ($, e, next) => {
130 const r = await next(e)
131 await $.gemini.enroll({ consumer: CONSUMER, defaultModel: DEFAULT_MODEL })
132 await $.command.register({
133 name: 'gemini-plan-review',
134 description: 'Gemini plan review: status, on, off, reset; /gemini-core sets the model, thinking and tier (gemini-plan-review)',
135 argumentHint: '[on | off | reset]',
136 })
137 return r
138 })
139
140 on('command.run', { command: 'gemini-plan-review' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
141
142 on('prompt.submit', async (_, e, next) => {
143 if (USER_ORIGINS.has(e.origin.kind)) state.rounds = 0
144 return next(e)
145 })
146
147 on('prompt.attachment', { type: 'plan_mode' }, async (_, e, next) => {
148 const path = e.agentId === undefined ? planFileIn(e.text) : undefined
149 if (path !== undefined) state.planFile = path
150 return next(e)
151 })
152
153 on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
154 // A subagent's plan does not reach the user's approval dialog.
155 if (e.agentId !== undefined || !(await isEnabled($))) return next(e)
156 const verdict = await review($, state, config, { plan: e.plan, planFilePath: e.planFilePath ?? state.planFile })
157 if (verdict.deny !== undefined) {
158 state.rounds++
159 return { deny: verdict.deny }
160 }
161 // The user sees this plan, so a later plan gets its rounds again.
162 state.rounds = 0
163 return withContext(await next(e), verdict.context)
164 })
165}
166hooks/command.ts 46 lines1/** The setting /gemini-plan-review changes, and the reading of its argument. */
2import type { EngineInterface } from 'claude-code'
3
4/** What gemini-core says this mod runs with. */
5export type GeminiSettings = Awaited<ReturnType<EngineInterface['gemini']['settings']>>
6
7export type Command = { kind: 'status' } | { kind: 'reset' } | { kind: 'set'; enabled: boolean } | { kind: 'error'; text: string }
8
9export const USAGE = 'expects on, off, or reset; /gemini-core sets the model, the thinking level and the tier'
10
11/** The store key of the on/off setting. */
12export const ENABLED_KEY = 'enabled'
13
14const WORDS: Record<string, Command> = {
15 '': { kind: 'status' },
16 status: { kind: 'status' },
17 reset: { kind: 'reset' },
18 on: { kind: 'set', enabled: true },
19 off: { kind: 'set', enabled: false },
20}
21
22/** Reads the argument of /gemini-plan-review. */
23export function parseCommand(args: string): Command {
24 return WORDS[args.trim()] ?? { kind: 'error', text: USAGE }
25}
26
27/** What `on` answers while gemini-core has no key; nothing is stored. */
28export const NO_KEY_ON = 'still off: gemini-core has no Gemini key. Set GEMINI_API_KEY or the gemini-core apiKey option, restart Claude Code, then run /gemini-plan-review on'
29
30/** What `reset` answers: the review is off until it is turned on. */
31export const RESET_TEXT = 'off: back to the default; /gemini-plan-review on turns it on'
32
33/** The line /gemini-plan-review prints for a change. */
34export function changeText(enabled: boolean): string {
35 return enabled ? 'on: every plan is reviewed before it reaches you' : 'off: plans reach you without a review'
36}
37
38/** The status of /gemini-plan-review, with what gemini-core says it runs with. */
39export function statusText(enabled: boolean, s: GeminiSettings, last: string | undefined): string {
40 const key = s.hasKey ? 'key set' : 'no key: set GEMINI_API_KEY or the gemini-core apiKey option'
41 const lines = [`${enabled ? 'on' : 'off'} · ${s.model} · thinking ${s.thinking ?? 'model default'} · ${s.tier} tier · ${key}`]
42 if (!enabled) lines.push('off until /gemini-plan-review on; the model, the thinking level and the tier are /gemini-core settings')
43 if (last !== undefined) lines.push(`last: ${last}`)
44 return lines.join('\n')
45}
46hooks/config.ts 27 lines1/** The plugin options with their defaults. */
2import type { PluginOptions } from 'claude-code'
3
4export type Config = { maxInputChars: number }
5
6export const DEFAULTS: Config = { maxInputChars: 2_000_000 }
7
8/** The plugin name gemini-core knows this mod by, and the model it uses until /gemini-core sets another. */
9export const CONSUMER = 'gemini-plan-review'
10export const DEFAULT_MODEL = 'gemini-3.8-flash'
11
12/** No new Gemini attempt starts once this much has passed. */
13export const DEADLINE_MS = 50_000
14
15/** A plan is sent back to the model at most this many times before it reaches the user. */
16export const MAX_ROUNDS = 2
17
18function numberOption(options: PluginOptions, key: string, fallback: number, min: number, max: number): number {
19 const value = options[key]
20 return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : fallback
21}
22
23/** The plugin options, each out-of-range or missing one replaced by its default. */
24export function configFrom(options: PluginOptions): Config {
25 return { maxInputChars: numberOption(options, 'maxInputChars', DEFAULTS.maxInputChars, 10_000, 4_000_000) }
26}
27hooks/review.ts 123 lines1/** The plan review Gemini is asked for, its answer, and what the model and the user read. */
2import type { EngineInterface } from 'claude-code'
3
4/** Gemini's answer as gemini-core reads it. */
5export type Answer = Extract<Awaited<ReturnType<EngineInterface['gemini']['read']>>, { answer: unknown }>['answer']
6
7export type Severity = 'blocker' | 'minor'
8
9export type Finding = { severity: Severity; message: string }
10
11const SEVERITIES: readonly Severity[] = ['blocker', 'minor']
12
13/** The plan section in which the model answers a finding it holds wrong. */
14export const REBUTTAL_HEADING = '## Gemini plan review'
15
16export const REVIEW_TASK = `You review an implementation plan a coding agent (Claude, in Claude Code) is about to show its user for approval. Below are the conversation so far, which says what the user asked for and what the agent found in the code, and the plan.
17
18Report problems in the plan. For each, give its severity:
19- blocker: a step the goal needs that the plan leaves out, an assumption the conversation or the code shown in it contradicts, a goal with no way to check that it was reached, or a decision against what the user asked for.
20- minor: anything else worth saying: an unclear step, a risk, a missing test, a simpler way.
21Report a blocker only with evidence in the plan or the conversation; when unsure, it is minor. An empty list is the right answer for a sound plan.
22A section headed "${REBUTTAL_HEADING}" holds the agent's answers to earlier findings. Accept an answer that gives a reason the conversation supports, and do not report that finding again as a blocker.
23Write each message in the language of the conversation, in one or two sentences, naming the fix.`
24
25/** The generateContent body asking for findings on the plan, in a schema Gemini must follow. */
26export function buildPlanBody(transcript: string | undefined, plan: string): Record<string, unknown> {
27 const conversation = transcript === undefined ? 'The conversation is not available; only the plan is.' : `The conversation:\n\n${transcript}`
28 return {
29 contents: [{ role: 'user', parts: [{ text: `${REVIEW_TASK}\n\n${conversation}\n\nThe plan:\n\n${plan}` }] }],
30 generationConfig: {
31 responseMimeType: 'application/json',
32 responseSchema: {
33 type: 'OBJECT',
34 properties: {
35 findings: {
36 type: 'ARRAY',
37 items: {
38 type: 'OBJECT',
39 properties: { severity: { type: 'STRING', enum: [...SEVERITIES] }, message: { type: 'STRING' } },
40 required: ['severity', 'message'],
41 },
42 },
43 },
44 required: ['findings'],
45 },
46 },
47 }
48}
49
50/** The plan mode note names the plan file under this heading ("create your plan at /…/x.md", measured on 2.1.278). */
51const PLAN_FILE = /## Plan File Info:[^/]*(\/[^\s`'"]+\.md)/
52
53/** The plan file the engine's plan mode note names, if it names one. */
54export function planFileIn(text: string): string | undefined {
55 return PLAN_FILE.exec(text)?.[1]
56}
57
58function isRecord(value: unknown): value is Record<string, unknown> {
59 return typeof value === 'object' && value !== null && !Array.isArray(value)
60}
61
62function findingOf(value: unknown): Finding {
63 if (!isRecord(value)) throw new Error('a finding is not an object')
64 const severity = SEVERITIES.find(s => s === value.severity)
65 if (severity === undefined) throw new Error(`unknown severity ${JSON.stringify(value.severity)}`)
66 if (typeof value.message !== 'string' || value.message.trim() === '') throw new Error('a finding has no message')
67 return { severity, message: value.message.trim() }
68}
69
70/** Reads Gemini's findings; an answer outside the schema throws. */
71export function parseFindings(answer: Answer): Finding[] {
72 if (answer.finishReason === 'MAX_TOKENS') throw new Error('the review hit the output token limit')
73 let value: unknown
74 try {
75 value = JSON.parse(answer.text)
76 } catch {
77 throw new Error('the review is not JSON')
78 }
79 if (!isRecord(value) || !Array.isArray(value.findings)) throw new Error('the review has no findings list')
80 return value.findings.map(findingOf)
81}
82
83export function bullets(findings: readonly Finding[]): string {
84 return findings.map(f => `- ${f.message}`).join('\n')
85}
86
87function minorBlock(minors: readonly Finding[]): string {
88 return minors.length === 0 ? '' : `\n\nMinor notes, not blocking:\n${bullets(minors)}`
89}
90
91/** What the model reads when the plan is sent back; `round` counts from 1. */
92export function denyText(blockers: readonly Finding[], minors: readonly Finding[], round: number, maxRounds: number): string {
93 return [
94 `gemini-plan-review sent this plan back before the user saw it (round ${round} of ${maxRounds}): Gemini found ${blockers.length} blocking problem(s).`,
95 bullets(blockers),
96 `Fix the plan and call ExitPlanMode again. If a finding is wrong, keep that part and answer it in the plan file under a "${REBUTTAL_HEADING}" heading with your reason; Gemini reads that section in the next round.${minorBlock(minors)}`,
97 ].join('\n')
98}
99
100/** What the model reads after the plan passed; blockers are left only once the rounds are used up. */
101export function passContext(blockers: readonly Finding[], minors: readonly Finding[], maxRounds: number): string {
102 if (blockers.length > 0) {
103 return `gemini-plan-review let this plan reach the user after ${maxRounds} rounds with ${blockers.length} blocking finding(s) still open:\n${bullets(blockers)}${minorBlock(minors)}\nTell the user about them.`
104 }
105 if (minors.length > 0) return `gemini-plan-review let this plan reach the user with ${minors.length} minor note(s):\n${bullets(minors)}\nTell the user about the ones worth acting on.`
106 return 'gemini-plan-review: Gemini reviewed this plan and found nothing to report.'
107}
108
109/** What the model reads after a plan that reached the user without a review. */
110export function failedContext(reason: string): string {
111 return `gemini-plan-review could not review this plan and let it reach the user: ${reason}`
112}
113
114function tokens(n: number): string {
115 return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
116}
117
118/** `plan reviewed · 1 blocker, 2 minor · 12k in, 1k out` */
119export function summaryText(findings: readonly Finding[], answer: Answer): string {
120 const blockers = findings.filter(f => f.severity === 'blocker').length
121 return `plan reviewed · ${blockers} blocker, ${findings.length - blockers} minor · ${tokens(answer.inputTokens)} in, ${tokens(answer.outputTokens)} out`
122}
123hooks/transcript.ts 66 lines1/** The conversation as Gemini reads it, cut to a character limit. */
2import type { SessionMessage, ToolUseSummary } from 'claude-code'
3
4/** The output of each tool call, by tool_use_id: its result block, else the call's own text. */
5function outputs(messages: readonly SessionMessage[]): Map<string, { text: string; isError: boolean }> {
6 const out = new Map<string, { text: string; isError: boolean }>()
7 for (const m of messages) {
8 for (const use of m.toolUses) out.set(use.tool_use_id, { text: use.text ?? '', isError: use.isError === true })
9 }
10 for (const m of messages) {
11 for (const r of m.toolResults ?? []) out.set(r.tool_use_id, { text: r.text, isError: r.isError })
12 }
13 return out
14}
15
16/** Cuts a text to about `max` characters: its head and its tail around one note. */
17export function clip(text: string, max: number): string {
18 if (text.length <= max) return text
19 const half = Math.max(0, Math.floor(max / 2))
20 return `${text.slice(0, half)}\n[… ${text.length - 2 * half} chars omitted …]\n${text.slice(text.length - half)}`
21}
22
23function callLines(use: ToolUseSummary, output: { text: string; isError: boolean } | undefined, cap: number): string[] {
24 const status = output?.isError === true ? 'error' : 'output'
25 return [` [call] ${use.tool} ${JSON.stringify(use.input) ?? '{}'}`, ` ${status}: ${clip(output?.text ?? '', cap)}`]
26}
27
28function render(messages: readonly SessionMessage[], byUse: ReadonlyMap<string, { text: string; isError: boolean }>, cap: number): string {
29 const lines: string[] = []
30 messages.forEach((m, i) => {
31 lines.push(`#${i + 1} ${m.role}: ${m.text}`)
32 for (const use of m.toolUses) lines.push(...callLines(use, byUse.get(use.tool_use_id), cap))
33 })
34 return lines.join('\n')
35}
36
37/** The longest output length that makes the transcript fit, found by bisection. */
38function outputCap(fixed: number, lengths: readonly number[], max: number): number | undefined {
39 const size = (cap: number): number => fixed + lengths.reduce((sum, n) => sum + Math.min(n, cap + 40), 0)
40 if (size(0) > max) return undefined
41 let low = 0
42 let high = Math.max(0, ...lengths)
43 while (low < high) {
44 const mid = Math.ceil((low + high) / 2)
45 if (size(mid) <= max) low = mid
46 else high = mid - 1
47 }
48 return low
49}
50
51/**
52 * Every message in order, each tool call with its input and output. Over
53 * `maxChars`, the longest outputs are cut to one common length; a
54 * conversation over it without any output throws.
55 */
56export function renderTranscript(messages: readonly SessionMessage[], maxChars: number): string {
57 const byUse = outputs(messages)
58 const full = render(messages, byUse, Infinity)
59 if (full.length <= maxChars) return full
60 const lengths = messages.flatMap(m => m.toolUses.map(u => byUse.get(u.tool_use_id)?.text.length ?? 0))
61 const fixed = full.length - lengths.reduce((a, b) => a + b, 0)
62 const cap = outputCap(fixed, lengths, maxChars)
63 if (cap === undefined) throw new Error(`the conversation is over ${maxChars} characters even without tool output`)
64 return render(messages, byUse, cap)
65}
66