SLOPSHOPPER

gemini-advisor

Gives the model a Gemini advisor tool it calls by itself: Gemini reads the whole conversation and the model's message and answers with a second opinion. Off…

newguardcommandtoastprompttool
A shopper browsing a rack in a slop shop
README

gemini-advisor

The model makes a plan, picks one of two approaches or says the work is done, and nobody looks at it a second time. This mod gives the model a Gemini advisor it calls by itself. The model writes what it did or is about to do and its question; Gemini reads the whole conversation so far, tool calls and outputs included, and its second opinion comes back as the tool result.

The idea follows the advisor tool of the Claude API, where the executor model calls a stronger model that reads its transcript. Here Gemini answers, and the model also sends a message of its own.

What it does

  1. At session start, while the advisor is on, the mod declares the tool mcp__gemini-advisor__advise with one input, message.
  2. The engine lists a plugin's tool behind ToolSearch, where the model sees only its name (measured on 2.1.277). So the mod adds a # Gemini advisor note to the end of the env_info_simple section of the system prompt: what the tool does, how to load it, and when to call it. The note does not change during a session, so the prompt cache holds.
  3. The note makes the call required, without you asking, at four moments: before a substantial change or a multi-step plan, when the model is stuck (the same error twice), when it chooses between two approaches, and before it says the work is done. It also tells the model to check the advice against the code and to tell you where it disagrees.
  4. At a call, the mod reads the conversation with $.session.messages(), which includes the running turn (measured). It writes out every message and each tool call with its input and output, and sends that with the model's message in one generateContent request. When the text is over maxInputChars (2,000,000 characters by default), the longest tool outputs are cut to one common length, each keeping its head and tail; a conversation over the limit even without any output is an error. gemini-core builds the request with the key, the model and the thinking level it holds for gemini-advisor, and reads the answer.
  5. The advice comes back as the tool result. Every failure comes back as an error result that says why, so the model sees it; nothing is swallowed. An empty advice, and one Gemini cut at maxOutputTokens, are errors too.
  6. Gemini answers HTTP 503 ("high demand") now and then, and the next request often works (measured: 2 of 5 requests on two flash models). gemini-core then has the mod ask again after 1 s, 2 s and 3 s, at most four attempts in all, and no attempt starts whose wait would end past 40 s, so the last one fits the 60-second tool timeout. After a 429 or a key error, gemini-core hands over the request with its next key, when it holds one.

In a live check on 2.1.277 with gemini-3.8-flash, the model called the advisor on its own while choosing between two approaches, sent 27 messages (15k tokens), got the advice in 7.1 seconds, and its answer used the advice. With a softer note ("call it on your own") the model did not call it in two such turns, and said afterwards that the turn was one the note named.

What it shows

After each advice a toast stays for 10 seconds, and /gemini-advisor shows the last one:

gemini-advisor: asked gemini-3.8-flash · 27 messages · 15k in, 2k out · sent to Gemini free tier

The sent to Gemini free tier part appears only on the free tier. A call from a subagent reads message only in place of the message count. The tool row in the transcript holds the model's message and the advice (ctrl+o).

Command

/gemini-advisor on or off, the model, thinking level and tier gemini-core holds, whether a key is set, the last advice /gemini-advisor on | off on is refused while gemini-core has no key; off: a call answers that the advisor is off /gemini-advisor reset off again, the default

The advisor is off after an install: the model gets no tool and no note, and nothing goes to Gemini. on declares the tool at once. The system prompt note follows the setting at /clear or the next session, not at once, because a change of the system prompt in the middle of a session makes the next request write the whole prompt cache again. After off the note leaves at /clear or the next session and the tool at the next session; until then a call answers that the advisor is off. The setting is one for every window: an on in another window declares the tool here at this window's next turn and the note at its next /clear or session, and an off there makes a call here answer at once that the advisor is off.

The key, the tier, the model (default gemini-3.8-flash) and the thinking level belong to gemini-core, and a change applies from the next call:

/gemini-core model advisor gemini-3.7-flash /gemini-core thinking advisor high /gemini-core paid

Free tier or paid tier

Every call sends 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.

With the free key used in the live check, gemini-3.1-pro-preview answered HTTP 429 (quota exceeded), so a pro model needs a paid key.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install gemini-advisor@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.

After installing

  1. Set the Gemini key and the tier in gemini-core, as its After installing section says, then restart Claude Code.
  2. Run /gemini-advisor on, then /clear or start a new session, so the system prompt note reaches the model. Without a key on answers still off: gemini-core has no Gemini key and stays off.
  3. Run /gemini-advisor. The first line reads on · <model> · thinking ... · <tier> tier · key set.
  4. When an advice call fails with Gemini HTTP 429, the model has no quota on your key. Pick another with /gemini-core model advisor.

After an update from 0.1.x: claude plugin update does not add gemini-core (measured on 2.1.278), so run claude plugin install gemini-core@kilimcininkoroglu-mods once. Version 0.2.0 moved the key, tier and model to gemini-core; the apiKey, tier and model options and the settings /gemini-advisor free|paid|model stored before are no longer read, so set them again in gemini-core. Version 0.3.0 made the advisor off by default: after an update from an earlier version it is off unless you ran /gemini-advisor on before, so run /gemini-advisor on once.

Options

OptionDefaultWhat it sets
maxInputChars2000000Characters of conversation sent at most; 10,000 to 4,000,000
maxOutputTokens8192The longest advice, thinking included; 256 to 65,536; a cut advice is an error

A value outside its range, or one that is not a whole number, falls back to the default.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, turn.start, classic.SessionStart, command.run{command=gemini-advisor}, prompt.section{name=env_info_simple}, tool.call{tool=mcp__gemini-advisor__advise} ❯ ./register.ts calls: $.clock.now (via askGemini), $.clock.sleep (via askGemini), $.command.register, $.gemini.enroll, $.gemini.read (via askGemini), $.gemini.request (via askGemini), $.gemini.settings (via runCommand, storeEnabled), $.http.fetch (via askGemini), $.session.messages (via conversation), $.store.delete (via runCommand), $.store.get (via isEnabled), $.store.set (via storeEnabled), $.tool.register (via declareTool), $.ui.toast (via advise)

Reach L3, reaches the network.

  1. Reads: the conversation at each advisor call (messages, tool inputs and outputs); its own $.store; from gemini-core, the request with the key
  2. Runs: no process; while on, it adds one note to the system prompt and declares one tool
  3. Sends: the conversation and the model's message, one request per call (up to four after a 503, and once more per extra key after a 429 or a key error), to the URL gemini-core builds (generativelanguage.googleapis.com) with the key in the x-goog-api-key header, never in the URL
  4. Persists: in $.store, the on/off setting; the last usage line lives in memory
  5. Hostile input: the advice is untrusted text the model reads as a tool result, so a hostile or wrong advice can steer the model as text in a file it reads can; the note tells the model to check it

Limits

  • Whether the model calls the advisor is its own decision. The live check covered one kind of turn.
  • The tool description names no model. The engine keeps the description it first sent for the whole session: a tool registered again after a model change still reached the model with the old text (measured on 2.1.278). The toast and /gemini-advisor name the model a call went to.
  • The engine serves the tool with a 60-second MCP timeout (debug log, 2.1.277). A call that takes longer fails, and the model reads the error.
  • The note goes into the env_info_simple section. A setup whose system prompt has no such section gets no note, and the model sees the tool's name only. Only one setup was checked.
  • A subagent's call sends only its message, because which transcript $.session.messages() answers inside a subagent was not verified.
  • $.session.messages() answers the newest 4096 messages of a long transcript.
  • Every call sends the whole conversation, so a long session makes each call larger and slower.

Development

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

Source 5 files
hooks/register.ts 144 lines
1import type { EngineInterface, Register, ToolCallInput, ToolCallResult } from 'claude-code'
2import { adviceText, buildAdviceBody, INPUT_SCHEMA, messageOf, SYSTEM_GUIDANCE, TOOL_DESCRIPTION, TOOL_NAME, usageText, type Answer } from './advice.ts'
3import { changeText, ENABLED_KEY, NO_KEY_ON, parseCommand, RESET_TEXT, statusText } from './command.ts'
4import { CONSUMER, configFrom, DEADLINE_MS, DEFAULT_MODEL, type Config } from './config.ts'
5import { renderTranscript } from './transcript.ts'
6
7/**
8 * The last advice's usage line, for the status; whether the system prompt
9 * carries the note, fixed at a session start or /clear so a change of the
10 * setting does not change the prompt the cache holds; and whether this session
11 * declared the tool.
12 */
13type State = { last?: string; guidance: boolean; declared: boolean }
14
15/** Gemini's answer, the model and the tier it went to, or why there is none. */
16type Asked = { answer: Answer; model: string; tier: 'free' | 'paid' } | { error: string }
17
18function errorText(err: unknown): string {
19  return err instanceof Error ? err.message : String(err)
20}
21
22/** Off until the user turns it on, so a fresh install offers the model no tool that can only fail. */
23async function isEnabled($: EngineInterface): Promise<boolean> {
24  return (await $.store.get(ENABLED_KEY)) === true
25}
26
27async function declareTool($: EngineInterface, state: State): Promise<void> {
28  await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
29  state.declared = true
30}
31
32/** Stores on or off; `on` is refused while gemini-core has no key, and declares the tool at once. */
33async function storeEnabled($: EngineInterface, state: State, enabled: boolean): Promise<string> {
34  if (enabled && !(await $.gemini.settings({ consumer: CONSUMER })).hasKey) return NO_KEY_ON
35  await $.store.set(ENABLED_KEY, enabled)
36  if (enabled) await declareTool($, state)
37  return changeText(enabled)
38}
39
40/**
41 * The conversation for Gemini and its message count. A subagent's call sends
42 * none, because which transcript `$.session.messages()` answers there was not
43 * verified.
44 */
45async function conversation($: EngineInterface, config: Config, e: ToolCallInput): Promise<{ text: string; count: number } | undefined> {
46  if (e.agentId !== undefined) return undefined
47  const messages = await $.session.messages()
48  return { text: renderTranscript(messages, config.maxInputChars), count: messages.length }
49}
50
51/**
52 * gemini-core builds the request and reads each answer; the request is sent
53 * here, again after a 503 while it allows, and with the next key after a 429
54 * or a key error.
55 */
56async function askGemini($: EngineInterface, body: Record<string, unknown>): Promise<Asked> {
57  const prepared = await $.gemini.request({ consumer: CONSUMER, body })
58  if ('error' in prepared) return prepared
59  const started = await $.clock.now()
60  let http = prepared.http
61  for (let attempt = 1; ; attempt++) {
62    const r = await $.http.fetch(http.url, http.init)
63    const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started, deadlineMs: DEADLINE_MS })
64    if ('answer' in read) return { answer: read.answer, model: prepared.model, tier: prepared.tier }
65    if ('error' in read) return read
66    if ('next' in read) http = read.next
67    else await $.clock.sleep(read.retryInMs)
68  }
69}
70
71async function advise($: EngineInterface, state: State, config: Config, e: ToolCallInput): Promise<ToolCallResult> {
72  if (!(await isEnabled($))) return { deny: 'gemini-advisor is off; the user can turn it on with /gemini-advisor on' }
73  try {
74    const message = messageOf(e as Record<string, unknown>)
75    const sent = await conversation($, config, e)
76    const asked = await askGemini($, buildAdviceBody(sent?.text, message, config.maxOutputTokens))
77    if ('error' in asked) throw new Error(asked.error)
78    const advice = adviceText(asked.answer)
79    state.last = usageText(asked.model, sent?.count, asked.answer)
80    $.ui.toast(asked.tier === 'free' ? `${state.last} · sent to Gemini free tier` : state.last, { timeoutMs: 10_000 })
81    return { result: advice }
82  } catch (err) {
83    state.last = `failed: ${errorText(err)}`
84    return { deny: `Gemini advisor failed: ${errorText(err)}` }
85  }
86}
87
88async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
89  const command = parseCommand(args)
90  if (command.kind === 'error') return command.text
91  if (command.kind === 'reset') {
92    await $.store.delete(ENABLED_KEY)
93    return RESET_TEXT
94  }
95  if (command.kind === 'set') return storeEnabled($, state, command.enabled)
96  return statusText(await isEnabled($), await $.gemini.settings({ consumer: CONSUMER }), state.last)
97}
98
99export const register: Register = (on, options) => {
100  const config = configFrom(options)
101  const state: State = { guidance: false, declared: false }
102
103  on('session.start', async ($, e, next) => {
104    const r = await next(e)
105    await $.gemini.enroll({ consumer: CONSUMER, defaultModel: DEFAULT_MODEL })
106    await $.command.register({
107      name: 'gemini-advisor',
108      description: 'Gemini advisor: status, on, off, reset; /gemini-core sets the model, thinking and tier (gemini-advisor)',
109      argumentHint: '[on | off | reset]',
110    })
111    state.guidance = await isEnabled($)
112    if (state.guidance) await declareTool($, state)
113    return r
114  })
115
116  // Every window shares the store: a /gemini-advisor on made in another window declares the tool here
117  // before the next turn's first request. The note waits for /clear, as after /gemini-advisor on in this window.
118  on('turn.start', async ($, e, next) => {
119    if (!state.declared && (await isEnabled($))) await declareTool($, state)
120    return next(e)
121  })
122
123  // /clear arrives only through the classic seam; the note follows the setting from there.
124  on('classic.SessionStart', async ($, e, next) => {
125    const r = await next(e)
126    if (e.agent_id === undefined) state.guidance = await isEnabled($)
127    return r
128  })
129
130  on('command.run', { command: 'gemini-advisor' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
131
132  // The engine puts a plugin's tool behind ToolSearch, where the model sees
133  // only its name and never the description that says when to call it
134  // (measured on 2.1.277). The system prompt says it instead, at the end of
135  // a section every session has; the text is stable, so the cache holds.
136  on('prompt.section', { name: 'env_info_simple' }, async (_, e, next) => {
137    const r = await next(e)
138    return r.text === null || !state.guidance ? r : { text: `${r.text}\n\n${SYSTEM_GUIDANCE}` }
139  })
140
141  // A literal, so the validator names the tool; it equals TOOL_ID.
142  on('tool.call', { tool: 'mcp__gemini-advisor__advise' }, async ($, e) => advise($, state, config, e))
143}
144
hooks/advice.ts 96 lines
1/** The advise tool as the model sees it, and the question Gemini is asked. */
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 const TOOL_NAME = 'advise'
8
9export const TOOL_ID = `mcp__gemini-advisor__${TOOL_NAME}` as const
10
11export const INPUT_SCHEMA = {
12  type: 'object',
13  properties: {
14    message: {
15      type: 'string',
16      description: 'What you did or are about to do, what you found, and the specific question you want a second opinion on.',
17    },
18  },
19  required: ['message'],
20}
21
22const WHEN = [
23  'The user installed this advisor and wants it used. Calling it at these moments is required, not optional; the user does not have to ask:',
24  '- before you present or start a substantial change or a multi-step plan: call it with the plan, then present the plan;',
25  '- when you are stuck: the same error twice, or an approach that keeps failing;',
26  '- when you choose between two approaches: call it before you give the user your choice;',
27  '- before you tell the user the work is done, with what you did and how you checked it.',
28  'Skip it only for simple, mechanical steps, or when the user said not to use tools. The advice can be wrong: check it against the code before you act on it, and tell the user where you disagree.',
29]
30
31/**
32 * What the model reads about the tool: what it is, and when to call it. It
33 * names no model: the engine keeps the description it first sent for the
34 * whole session (measured on 2.1.278), so a named model would go stale after
35 * a /gemini-core change.
36 */
37export const TOOL_DESCRIPTION = [
38  'Ask Gemini, a second model, for advice. It reads the whole conversation so far, tool calls and outputs included, and your message, and answers with a second opinion.',
39  ...WHEN,
40].join('\n')
41
42/**
43 * The system prompt's note about the tool. The engine lists the tool behind
44 * ToolSearch, so the model would see its name alone; this names the model
45 * nowhere, so a model change does not alter the prompt.
46 */
47export const SYSTEM_GUIDANCE = [
48  '# Gemini advisor',
49  `You have a second-opinion tool, ${TOOL_ID}: Gemini reads the whole conversation so far, tool calls and outputs included, and your message, and answers with advice. When it is listed only by name, load it with ToolSearch (query "select:${TOOL_ID}").`,
50  ...WHEN,
51].join('\n')
52
53export const ADVISOR_TASK = `You advise a coding agent (Claude, in Claude Code) that is working with a user. Below is its conversation so far: the user's messages, the agent's replies, and each tool call it made with the output. After it, the agent asks you for advice.
54
55Give a second opinion the agent can act on:
56- Answer the agent's question directly first.
57- Point out mistakes, risks, missed steps and wrong assumptions, each with the evidence in the conversation (a file, an output, a user message).
58- Say so when the plan or the work is sound; do not invent problems.
59- Prefer concrete next steps over general advice. Keep it short.
60- Do not state as fact anything the conversation does not show; say what the agent should check instead.
61- Answer in the language of the agent's message.`
62
63/** The generateContent body asking for advice on the agent's message, with the conversation when there is one. */
64export function buildAdviceBody(transcript: string | undefined, message: string, maxOutputTokens: number): Record<string, unknown> {
65  const conversation = transcript === undefined ? 'The conversation is not available; only the agent\'s message is.' : `The conversation:\n\n${transcript}`
66  return {
67    contents: [{ role: 'user', parts: [{ text: `${ADVISOR_TASK}\n\n${conversation}\n\nThe agent asks:\n\n${message}` }] }],
68    generationConfig: { maxOutputTokens },
69  }
70}
71
72/** The advice; an empty one or one Gemini cut at its output limit throws. */
73export function adviceText(answer: Answer): string {
74  if (answer.finishReason === 'MAX_TOKENS') throw new Error('the advice hit the output token limit (raise maxOutputTokens)')
75  const text = answer.text.trim()
76  if (text === '') throw new Error('the advice is empty')
77  return text
78}
79
80/** The model's message from the tool input; a missing or empty one throws. */
81export function messageOf(input: Record<string, unknown>): string {
82  const message = input.message
83  if (typeof message !== 'string' || message.trim() === '') throw new Error('message is required: what you did and the question')
84  return message.trim()
85}
86
87function tokens(n: number): string {
88  return n >= 1000 ? `${Math.round(n / 1000)}k` : String(n)
89}
90
91/** `asked gemini-3.8-flash · 58 messages · 312k in, 1k out`; `message only` when no conversation was sent. */
92export function usageText(model: string, messages: number | undefined, answer: Answer): string {
93  const sent = messages === undefined ? 'message only' : `${messages} messages`
94  return `asked ${model} · ${sent} · ${tokens(answer.inputTokens)} in, ${tokens(answer.outputTokens)} out`
95}
96
hooks/command.ts 51 lines
1/** The setting /gemini-advisor 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-advisor. */
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-advisor on'
29
30/** What `reset` answers: the advisor is off until it is turned on. */
31export const RESET_TEXT = 'off: back to the default; /gemini-advisor on turns it on'
32
33/**
34 * The line /gemini-advisor prints for a change. The system prompt note only
35 * changes at a session start or /clear, so the prompt cache holds.
36 */
37export function changeText(enabled: boolean): string {
38  return enabled
39    ? 'on: the advise tool is available now; the system prompt note that says when to call it comes at /clear or the next session'
40    : 'off: a call answers that the advisor is off; the note leaves at /clear or the next session, the tool at the next session'
41}
42
43/** The status of /gemini-advisor, with what gemini-core says it runs with. */
44export function statusText(enabled: boolean, s: GeminiSettings, last: string | undefined): string {
45  const key = s.hasKey ? 'key set' : 'no key: set GEMINI_API_KEY or the gemini-core apiKey option'
46  const lines = [`${enabled ? 'on' : 'off'} · ${s.model} · thinking ${s.thinking ?? 'model default'} · ${s.tier} tier · ${key}`]
47  if (!enabled) lines.push('off until /gemini-advisor on; the model, the thinking level and the tier are /gemini-core settings')
48  if (last !== undefined) lines.push(`last: ${last}`)
49  return lines.join('\n')
50}
51
hooks/config.ts 31 lines
1/** The plugin options with their defaults. */
2import type { PluginOptions } from 'claude-code'
3
4export type Config = { maxInputChars: number; maxOutputTokens: number }
5
6export const DEFAULTS: Config = { maxInputChars: 2_000_000, maxOutputTokens: 8192 }
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-advisor'
10export const DEFAULT_MODEL = 'gemini-3.8-flash'
11
12/**
13 * No new Gemini attempt starts once this much has passed. The engine serves
14 * the tool with a 60 s timeout (debug log), and one Gemini answer took 12 s
15 * (measured), so the last attempt still fits.
16 */
17export const DEADLINE_MS = 40_000
18
19function numberOption(options: PluginOptions, key: string, fallback: number, min: number, max: number): number {
20  const value = options[key]
21  return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max ? value : fallback
22}
23
24/** The plugin options, each out-of-range or missing one replaced by its default. */
25export function configFrom(options: PluginOptions): Config {
26  return {
27    maxInputChars: numberOption(options, 'maxInputChars', DEFAULTS.maxInputChars, 10_000, 4_000_000),
28    maxOutputTokens: numberOption(options, 'maxOutputTokens', DEFAULTS.maxOutputTokens, 256, 65_536),
29  }
30}
31
hooks/transcript.ts 66 lines
1/** 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