SLOPSHOPPER

council

Asks a council of models about a hard problem, when the model calls its tool or you run /council <question>: Claude models, and Gemini models when gemini-core…

newguardcommandprompttoolmodel
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · council
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /council ⎿ council: off: the model cannot call the council; /council <question> still runs ⎿ council: members: opus 5.5, sonnet 5.5, fable 5.1, haiku 4.5, gemini-3.8-flash (skipped: gemini-core is not installed) ⎿ council: chair: the model of the session, forked ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

council

Some problems do not yield to one more attempt: the same error survives every fix, or two designs each look right. A second opinion helps, and several independent ones help more. This mod asks a council of models about the problem: each member answers the same question on its own, and the session's own model, as the chair, turns their answers into one verdict. The model calls it through a tool when it is stuck, and you can run it yourself with /council <question>.

What it does

  1. The members are Opus 5.5, Sonnet 5, Fable 5.1 and Haiku 4.5, plus gemini-3.8-flash when gemini-core is installed and has a key. Without gemini-core, or without a key, the Gemini members are skipped and the result says why. /council members changes the list.
  2. Every member is asked at the same time:
  3. The member that runs on the session's own model forks the session with $.model.fork, so it reads the whole conversation from the prompt cache.
  4. Every other Claude member gets the conversation as text through $.model.complete, at high effort. Above 400,000 characters the longest tool outputs are cut first, then the oldest messages are left out. Haiku 4.5 reads at most 560,000 characters, because its window is 200k tokens.
  5. A Gemini member gets the same text through gemini-core, which holds the key, the tier and the thinking level. It needs gemini-core 0.3.0 or later, because each request names its own model.
  6. The chair forks the session too. It reads the answers under letters (Member A, Member B), not model names, and writes where the members agree, where they disagree and which side the conversation supports, what they missed, and the next step.
  7. The caller reads the verdict and every answer, each headed with its letter, model and time. A member that failed is named with its reason. When no member answers, the call is refused with each reason, and no chair runs.
  8. A call from a subagent sends no conversation, because a fork and $.session.messages() read the main thread. Every member and the chair then get the question alone, as a completion.

When the model calls it

The tool is mcp__council__convene with one question input. It is listed without ToolSearch, and a note in the system prompt tells the model when to call it:

  • the same error survived two attempts to fix it;
  • the root cause is still unclear after it investigated;
  • it has to choose between two designs that each have real trade-offs;
  • before a change that is hard to undo.

The note is fixed at the session's start and at /clear, so /council on gives the model the tool at once and the note from the next session. There is no limit on how often the model calls it. The setting is shared by every window: a /council on in another window gives this window's model the tool at its next turn and the note at its next /clear or session, and a /council off there makes the tool refuse here at once.

What you see

With the sidebar open, a standing section follows the run: the question and how long the run has taken, then every member with its state word (running yellow, answered green with its time and tokens, failed red with the reason), then the chair, and finally the first words of the verdict. Each model's name is coloured by its family: opus red, fable yellow, sonnet green, haiku faint, Gemini blue.

council: run avg.js boş dizi için ne dönmeli: NaN, 0, yoksa hata mı fırl… · done in 44s opus 5.5 · fork · answered 11s · 89k in, 902 out sonnet 5.5 · complete · answered 17s · 6.7k in, 1.1k out fable 5.1 · complete · answered 24s · 6.7k in, 1.4k out haiku 4.5 · complete · answered 9s · 5.2k in, 634 out gemini-3.8-flash · gemini · answered 8s · 4.7k in, 1.3k out · free tier chair · opus 5.5 · fork · done 20s

With the sidebar closed, you get one line at the end:

council: 1 of 2 members answered in 4s; the chair wrote the verdict

Command

/council on or off, the members, the chair, the last run (also /council status) /council on | off whether the model has the tool; off by default /council members the members /council members <model> ... opus, sonnet, fable, haiku, a claude- id, a gemini- or gemma- id /council members reset the default members /council <question> runs the council now, also while it is off

/council <question> answers convened: 5 members at once. When the members and the chair have answered, the mod hands the verdict to the model through /council:send, so the model reads it as your message and tells you what it takes from it. A turn that is running meanwhile holds it until the turn ends (measured on 2.1.283).

Cost

Each run makes one request per member and one for the chair. Measured on Claude Code 2.1.283:

  • A run of the five default members took 25 to 44 seconds; the slowest member sets the pace. Forks took 4 to 11 seconds and read the conversation from the cache (83k tokens in a short session).
  • 400,000 characters of conversation, the default limit, are 163,828 input tokens on Opus 5.5, Sonnet 5 and Fable 5.1, 128,018 on Haiku 4.5 and 120,057 on gemini-3.8-flash.

At list prices, one run at the full 400,000 characters costs about $2.20, estimated: Fable 5.1 about $1.70 ($10 per million input tokens), Sonnet 5.5 about $0.34 (the same list price as Sonnet 5, whose token count the check measured), Haiku 4.5 about $0.13, and the two forks a few cents of cache reads. A shorter conversation costs proportionally less. On a Claude subscription the requests count toward your usage limits instead. To lower the cost, leave a member out with /council members, for example /council members opus sonnet haiku gemini-3.8-flash.

gemini-3.1-pro-preview answered HTTP 429 on every free-tier key in the check, because the free tier has no quota for it. Add it to the members only with a paid key.

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install council@kilimcininkoroglu-mods

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. Restart Claude Code.
  2. Run /council on if the model should call the council by itself. It is off after an install, and /council <question> works either way.
  3. For Gemini members, install gemini-core 0.3.0 or later and give it a key (see its README). The council does not depend on it and runs with Claude members alone without it.

Options

OptionDefaultWhat it sets
maxInputChars400000How much of the conversation, in characters, each member reads when it does not fork the session; from 20,000 to 2,000,000

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=council}, prompt.section{name=env_info_simple}, tool.describe{tool=/"^mcp__council__convene$"/}, tool.call{tool=/"^mcp__council__convene$"/}, turn.step ❯ ./register.ts calls: $.clock.after (via runManual), $.clock.now (via askClaude, askGemini, askGeminiModel, convene, drawRun, ended, verdictOf), $.command.register, $.command.run (via send), $.gemini.enroll (via enrollGemini), $.gemini.read (via askGeminiModel), $.gemini.request (via askGeminiModel), $.gemini.settings (via geminiReach), $.http.fetch (via askGeminiModel), $.model.complete (via askChair, askClaude), $.model.fork (via askChair, askClaude), $.prompt.submit (via send), $.session.messages (via contextOf), $.sidebar.set (via drawRun), $.store.delete (via runCommand), $.store.get (via isEnabled, membersNow), $.store.set (via runCommand, storeEnabled), $.tool.register (via declareTool), $.ui.log (via enrollGemini, runManual, send, toPerson)

Reach L3: one request per member and one for the chair.

  1. Reads: the conversation of the main thread, the question, and the model of each main-loop request
  2. Runs: no process
  3. Sends: the conversation and the question to each Claude member through Claude Code's own API connection, and to Google for each Gemini member through gemini-core
  4. Persists: in $.store, the on/off setting and the member list; a run lives in memory
  5. Hostile input: the answers are text the model reads and never run; a member id must match [a-z0-9.-] before it reaches a Gemini URL; a free-tier Gemini key lets Google read what it is sent

Limits

  • Every member answers from what it is sent. A member that does not fork reads the conversation as text, cut at maxInputChars, so it can miss what was cut.
  • The chair runs on the session's model and judges answers from its own family too; the letters hide which answer came from which model, not what the answers say.
  • A member or chair answer is capped at 8,192 tokens, thinking included, and a member that has not answered after 5 minutes counts as failed.
  • After a Gemini HTTP 503 the request goes out again at once, without the wait gemini-core names, because $.clock.sleep counts against the hook's 10-second budget (measured on 2.1.283). Three quick retries can all hit the same load.
  • Measured on 2.1.283: a plugin tool call ran 333 seconds without a timeout. On 2.1.277 the limit was 60 seconds, so an older Claude Code can cut a slow run short.

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 with --plugin-dir ../sidebar --plugin-dir ../gemini-core make validate make test # claude plugin test

Source 7 files
hooks/register.ts 409 lines
1import type { EngineInterface, ModelForkResult, Register, SessionMessage, ToolCallResult } from 'claude-code'
2import { runLines, statusText, summaryText, type Chair, type Run, type Seat, type Via } from './board.ts'
3import { changeText, convenedText, ENABLED_KEY, MEMBERS_KEY, membersText, OFF_DENY, parseCommand } from './command.ts'
4import { configFrom, HAIKU_MAX_CHARS, MAX_TOKENS, MEMBER_MS, type Config } from './config.ts'
5import { bare, DEFAULT_MEMBERS, isSessionModel, labelOfModel, storedMembers, type Member } from './members.ts'
6import { chairPrompt, forkMemberPrompt, INPUT_SCHEMA, letterOf, MEMBER_SYSTEM, memberPrompt, noAnswerText, questionOf, resultText, sendText, SYSTEM_GUIDANCE, TOOL_DESCRIPTION, TOOL_NAME, type Lettered, type Row } from './prompts.ts'
7import { renderTranscript } from './transcript.ts'
8
9/** The name gemini-core knows this mod by, and the model it enrolls with; every request names its own. */
10const CONSUMER = 'council'
11const GEMINI_ENROLL_MODEL = 'gemini-3.8-flash'
12
13const SEND_COMMAND = 'council:send'
14
15const SECTION = { consumer: 'council', key: 'run' }
16
17/**
18 * Whether the system prompt carries the note (fixed at a session start or /clear), whether this session
19 * declared the tool, the main loop's model, and the runs.
20 */
21type State = { guidance: boolean; declared: boolean; model?: string; runs: number; current?: number; last?: string }
22
23/** Whether gemini-core can take a request now, or why not. */
24type Reach = { ok: true } | { ok: false; why: string }
25
26/** What a run knows: the question, the conversation (absent for a subagent's call), the model of the session. */
27type Ctx = { question: string; config: Config; messages?: SessionMessage[]; sessionModel?: string; gemini: Reach; rendered: Map<number, string> }
28
29type Answer = { text: string; ms: number; inTokens: number; outTokens: number; freeTier?: boolean }
30
31type Asked = { answer: Answer } | { why: string }
32
33type Outcome = { text: string } | { deny: string }
34
35function errorText(err: unknown): string {
36  return err instanceof Error ? err.message : String(err)
37}
38
39async function isEnabled($: EngineInterface): Promise<boolean> {
40  return (await $.store.get(ENABLED_KEY)) === true
41}
42
43async function membersNow($: EngineInterface): Promise<Member[]> {
44  return storedMembers(await $.store.get(MEMBERS_KEY))
45}
46
47async function declareTool($: EngineInterface, state: State): Promise<void> {
48  await $.tool.register({ name: TOOL_NAME, description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA })
49  state.declared = true
50}
51
52async function storeEnabled($: EngineInterface, state: State, enabled: boolean): Promise<string> {
53  await $.store.set(ENABLED_KEY, enabled)
54  if (enabled) await declareTool($, state)
55  return changeText(enabled)
56}
57
58/**
59 * gemini-core is optional: a session without it has no `$.gemini`, so a call on it throws a TypeError, and
60 * the Gemini members are skipped. The validator refuses `$.gemini` read as a value, so the call itself is
61 * the test.
62 */
63function notInstalled(err: unknown): boolean {
64  return err instanceof TypeError
65}
66
67async function enrollGemini($: EngineInterface): Promise<void> {
68  try {
69    await $.gemini.enroll({ consumer: CONSUMER, defaultModel: GEMINI_ENROLL_MODEL, ownModels: true })
70  } catch (err) {
71    if (!notInstalled(err)) $.ui.log(`gemini-core refused the enrollment, so the Gemini members will fail: ${errorText(err)}`)
72  }
73}
74
75async function geminiReach($: EngineInterface): Promise<Reach> {
76  try {
77    const s = await $.gemini.settings({ consumer: CONSUMER })
78    return s.hasKey ? { ok: true } : { ok: false, why: 'gemini-core has no key' }
79  } catch (err) {
80    return { ok: false, why: notInstalled(err) ? 'gemini-core is not installed' : `gemini-core did not answer: ${errorText(err)}` }
81  }
82}
83
84/** One run: the mod's state, the run as drawn, what it knows, and the chair's model. */
85type Job = { state: State; run: Run; ctx: Ctx; model: string }
86
87/** The run's section in the sidebar; false while the pane is closed, the sidebar mod is not installed, or a newer run holds it. */
88async function drawRun($: EngineInterface, job: Job): Promise<boolean> {
89  if (job.state.current !== job.run.id) return false
90  try {
91    // The pane draws the consumer in front of the title, so the title does not repeat it.
92    return await $.sidebar.set({ ...SECTION, title: 'run', lines: runLines(job.run, await $.clock.now()), until: 'session', order: 7 })
93  } catch {
94    // The sidebar mod is not installed; the run's end writes one log line instead.
95    return false
96  }
97}
98
99/** The run's end: the section when the sidebar takes it, else one log line. */
100async function toPerson($: EngineInterface, job: Job): Promise<void> {
101  if (!(await drawRun($, job))) $.ui.log(summaryText(job.run))
102}
103
104function viaOf(member: Member, ctx: Ctx): Via {
105  if (member.kind === 'gemini') return 'gemini'
106  return ctx.messages !== undefined && isSessionModel(member, ctx.sessionModel) ? 'fork' : 'complete'
107}
108
109function seatOf(member: Member, ctx: Ctx): Seat {
110  const via = viaOf(member, ctx)
111  if (member.kind === 'gemini' && !ctx.gemini.ok) return { label: member.label, via, state: 'skipped', why: ctx.gemini.why }
112  return { label: member.label, via, state: 'running' }
113}
114
115/** The chair's model: the session's without its window mark, or before its first request the first Claude member. */
116function chairModel(ctx: Ctx, members: readonly Member[]): string {
117  if (ctx.sessionModel !== undefined) return bare(ctx.sessionModel)
118  return members.find(m => m.kind === 'claude')?.id ?? 'claude-opus-5-5'
119}
120
121/** The conversation as one member reads it; Haiku 4.5 reads less, because its window is smaller. */
122function transcriptOf(ctx: Ctx, member: Member): string | undefined {
123  if (ctx.messages === undefined) return undefined
124  const cap = member.id.includes('haiku') ? Math.min(ctx.config.maxInputChars, HAIKU_MAX_CHARS) : ctx.config.maxInputChars
125  const cached = ctx.rendered.get(cap)
126  if (cached !== undefined) return cached
127  const text = renderTranscript(ctx.messages, cap)
128  ctx.rendered.set(cap, text)
129  return text
130}
131
132function reasonText(r: Exclude<ModelForkResult, { isAnswered: true }>): string {
133  return r.reason === 'api-error' ? `api-error ${r.status ?? 'without a response'} ${r.error}` : r.reason
134}
135
136function fromModel(r: ModelForkResult, ms: number): Asked {
137  if (!r.isAnswered) return { why: reasonText(r) }
138  const u = r.usage
139  return { answer: { text: r.text.trim(), ms, inTokens: u.input_tokens + u.cache_read_input_tokens + u.cache_creation_input_tokens, outTokens: u.output_tokens } }
140}
141
142/**
143 * A Claude member: a fork of the session when it runs on the session's model, else a completion over the
144 * conversation as text. A session with nothing to fork yet gets the completion, and the seat says so.
145 */
146async function askClaude($: EngineInterface, member: Member, seat: Seat, ctx: Ctx): Promise<Asked> {
147  const started = await $.clock.now()
148  if (seat.via === 'fork') {
149    const forked = await $.model.fork({ prompt: forkMemberPrompt(ctx.question) })
150    if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return fromModel(forked, (await $.clock.now()) - started)
151    seat.via = 'complete'
152  }
153  const prompt = memberPrompt(transcriptOf(ctx, member), ctx.question)
154  const r = await $.model.complete({ model: member.id, system: MEMBER_SYSTEM, prompt, effort: 'high', maxTokens: MAX_TOKENS, timeoutMs: MEMBER_MS })
155  return fromModel(r, (await $.clock.now()) - started)
156}
157
158type GeminiGot = { text: string; inTokens: number; outTokens: number; freeTier: boolean } | { why: string }
159
160/**
161 * One Gemini request through gemini-core, with the next key after a 429. After a 503 the same request
162 * goes again without the wait gemini-core names: `$.clock.sleep` counts against the hook's 10 s budget
163 * (measured on 2.1.283), and the waits of members asked at once add up.
164 */
165async function askGeminiModel($: EngineInterface, body: Record<string, unknown>, model: string): Promise<GeminiGot> {
166  const prepared = await $.gemini.request({ consumer: CONSUMER, body, model })
167  if ('error' in prepared) return { why: prepared.error }
168  if (prepared.model !== model) return { why: 'gemini-core is older than 0.3.0 and sends every request to one model; update it' }
169  const started = await $.clock.now()
170  let http = prepared.http
171  for (let attempt = 1; ; attempt++) {
172    const r = await $.http.fetch(http.url, http.init)
173    const read = await $.gemini.read({ http, status: r.status, ok: r.ok, text: r.text, attempt, elapsedMs: (await $.clock.now()) - started, deadlineMs: MEMBER_MS })
174    if ('error' in read) return { why: read.error }
175    if ('answer' in read) {
176      const cut = read.answer.finishReason === 'MAX_TOKENS' ? '\n[the answer was cut at the output token limit]' : ''
177      return { text: `${read.answer.text.trim()}${cut}`, inTokens: read.answer.inputTokens, outTokens: read.answer.outputTokens, freeTier: prepared.tier === 'free' }
178    }
179    if ('next' in read) http = read.next
180  }
181}
182
183async function askGemini($: EngineInterface, member: Member, ctx: Ctx): Promise<Asked> {
184  const started = await $.clock.now()
185  const text = `${MEMBER_SYSTEM}\n\n${memberPrompt(transcriptOf(ctx, member), ctx.question)}`
186  const got = await askGeminiModel($, { contents: [{ role: 'user', parts: [{ text }] }], generationConfig: { maxOutputTokens: MAX_TOKENS } }, member.id)
187  if ('why' in got) return got
188  return { answer: { ...got, ms: (await $.clock.now()) - started } }
189}
190
191/** Asks one member and redraws its seat with the answer or the reason there is none. */
192async function sit($: EngineInterface, job: Job, seat: Seat, member: Member): Promise<Asked> {
193  if (seat.state === 'skipped') return { why: seat.why ?? 'skipped' }
194  let asked: Asked
195  try {
196    asked = member.kind === 'gemini' ? await askGemini($, member, job.ctx) : await askClaude($, member, seat, job.ctx)
197  } catch (err) {
198    asked = { why: errorText(err) }
199  }
200  if ('answer' in asked) {
201    const { ms, inTokens, outTokens, freeTier } = asked.answer
202    Object.assign(seat, { state: 'answered', ms, inTokens, outTokens, ...(freeTier === undefined ? {} : { freeTier }) })
203  } else Object.assign(seat, { state: 'failed', why: asked.why })
204  await drawRun($, job)
205  return asked
206}
207
208/** Each member's row for the caller; the members that answered get a letter, in order. */
209function rowsOf(members: readonly Member[], asked: readonly Asked[]): Row[] {
210  let next = 0
211  return members.map((m, i) => {
212    const a = asked[i] ?? { why: 'not asked' }
213    if ('why' in a) return { label: m.label, why: a.why }
214    return { letter: letterOf(next++), label: m.label, ms: a.answer.ms, text: a.answer.text }
215  })
216}
217
218async function askChair($: EngineInterface, job: Job, answers: readonly Lettered[]): Promise<ModelForkResult> {
219  const prompt = chairPrompt(job.ctx.question, answers, job.ctx.messages !== undefined)
220  if (job.run.chair.via === 'fork') {
221    const forked = await $.model.fork({ prompt })
222    if (forked.isAnswered || forked.reason !== 'nothing-to-fork') return forked
223    Object.assign(job.run.chair, { via: 'complete', label: labelOfModel(job.model) })
224  }
225  return $.model.complete({ model: job.model, prompt, effort: 'high', maxTokens: MAX_TOKENS, timeoutMs: MEMBER_MS })
226}
227
228/**
229 * The chair as the person reads it. A fork runs on the session's model, which is not known after a module
230 * reload until the next main-loop request, so the label then names the session and not the fallback model.
231 */
232function chairOf(ctx: Ctx, model: string): Chair {
233  if (ctx.messages === undefined) return { label: labelOfModel(model), via: 'complete', state: 'waiting' }
234  return { label: ctx.sessionModel === undefined ? 'the model of the session' : labelOfModel(ctx.sessionModel), via: 'fork', state: 'waiting' }
235}
236
237/** The chair's verdict over the lettered answers, or why there is none; the chair's seat is redrawn either way. */
238async function verdictOf($: EngineInterface, job: Job, answers: readonly Lettered[]): Promise<{ text: string } | { why: string }> {
239  const chair = job.run.chair
240  chair.state = 'running'
241  await drawRun($, job)
242  const started = await $.clock.now()
243  let got: { text: string } | { why: string }
244  try {
245    const r = await askChair($, job, answers)
246    got = r.isAnswered ? { text: r.text.trim() } : { why: reasonText(r) }
247  } catch (err) {
248    got = { why: errorText(err) }
249  }
250  Object.assign(chair, 'text' in got ? { state: 'answered', ms: (await $.clock.now()) - started } : { state: 'failed', why: got.why })
251  if ('text' in got) job.run.verdict = got.text
252  return got
253}
254
255async function contextOf($: EngineInterface, state: State, config: Config, question: string, agentId: string | undefined): Promise<Ctx> {
256  const messages = agentId === undefined ? await $.session.messages() : undefined
257  return {
258    question,
259    config,
260    gemini: await geminiReach($),
261    rendered: new Map(),
262    ...(messages === undefined ? {} : { messages }),
263    ...(state.model === undefined ? {} : { sessionModel: state.model }),
264  }
265}
266
267async function ended($: EngineInterface, job: Job): Promise<void> {
268  job.run.endedAt = await $.clock.now()
269  job.state.last = summaryText(job.run)
270  await toPerson($, job)
271}
272
273/** One council run: every member at once, then the chair over the answers. */
274async function convene($: EngineInterface, state: State, config: Config, question: string, agentId: string | undefined): Promise<Outcome> {
275  const ctx = await contextOf($, state, config, question, agentId)
276  const members = await membersNow($)
277  const model = chairModel(ctx, members)
278  const chair = chairOf(ctx, model)
279  const run: Run = { id: ++state.runs, question, startedAt: await $.clock.now(), seats: members.map(m => seatOf(m, ctx)), chair }
280  const job: Job = { state, run, ctx, model }
281  state.current = run.id
282  await drawRun($, job)
283  const asked = await Promise.all(members.map((m, i) => sit($, job, run.seats[i] as Seat, m)))
284  const rows = rowsOf(members, asked)
285  const answers = rows.flatMap(r => (r.text === undefined ? [] : [{ letter: r.letter ?? '?', text: r.text }]))
286  if (answers.length === 0) {
287    Object.assign(run.chair, { state: 'failed', why: 'no member answered' })
288    await ended($, job)
289    return { deny: noAnswerText(rows) }
290  }
291  const verdict = await verdictOf($, job, answers)
292  await ended($, job)
293  return { text: resultText(verdict, chair.label, rows) }
294}
295
296async function onToolCall($: EngineInterface, state: State, config: Config, input: Record<string, unknown>, agentId: string | undefined): Promise<ToolCallResult> {
297  if (!(await isEnabled($))) return { deny: OFF_DENY }
298  let question: string
299  try {
300    question = questionOf(input)
301  } catch (err) {
302    return { deny: errorText(err) }
303  }
304  const outcome = await convene($, state, config, question, agentId)
305  return 'deny' in outcome ? outcome : { result: outcome.text }
306}
307
308/**
309 * Hands the result to the model through the mod's own `send` command, so the model reads the text alone,
310 * as the person would type it. A turn that runs meanwhile holds it until its end (measured on 2.1.283).
311 * A run the engine refuses sends it as a plugin prompt instead.
312 */
313function send($: EngineInterface, text: string): void {
314  $.command.run({ command: SEND_COMMAND, args: text }).catch((err: unknown) => {
315    $.ui.log(`the send command did not run, the verdict goes out as a plugin prompt: ${errorText(err)}`)
316    $.prompt.submit({ text }).catch((e: unknown) => $.ui.log(`the verdict was not sent: ${errorText(e)}`))
317  })
318}
319
320/** /council <question>: the run starts from a timer, because the engine refuses `$.command.run` inside the hook the command waits on. */
321function runManual($: EngineInterface, state: State, config: Config, question: string): void {
322  $.clock.after(0, () => {
323    convene($, state, config, question, undefined).then(
324      outcome => ('text' in outcome ? send($, sendText(question, outcome.text)) : $.ui.log(outcome.deny)),
325      (err: unknown) => $.ui.log(`the council did not run: ${errorText(err)}`),
326    )
327  })
328}
329
330async function statusOf($: EngineInterface, state: State): Promise<string> {
331  const reach = await geminiReach($)
332  const members = (await membersNow($)).map(m => (m.kind === 'gemini' && !reach.ok ? { label: m.label, skipped: reach.why } : { label: m.label }))
333  const chair = state.model === undefined ? 'the model of the session, forked' : `${labelOfModel(state.model)}, the model of the session, forked`
334  return statusText(await isEnabled($), members, chair, state.last)
335}
336
337async function runCommand($: EngineInterface, state: State, config: Config, args: string): Promise<string> {
338  const command = parseCommand(args)
339  switch (command.kind) {
340    case 'error': return command.text
341    case 'status': return statusOf($, state)
342    case 'set': return storeEnabled($, state, command.enabled)
343    case 'members': return membersText((await membersNow($)).map(m => m.label))
344    case 'setMembers':
345      await $.store.set(MEMBERS_KEY, command.members)
346      return membersText((await membersNow($)).map(m => m.label))
347    case 'resetMembers':
348      await $.store.delete(MEMBERS_KEY)
349      return membersText(storedMembers(DEFAULT_MEMBERS).map(m => m.label))
350    case 'ask':
351      runManual($, state, config, command.question)
352      return convenedText((await membersNow($)).length)
353  }
354}
355
356export const register: Register = (on, options) => {
357  const config = configFrom(options)
358  const state: State = { guidance: false, declared: false, runs: 0 }
359
360  on('session.start', async ($, e, next) => {
361    const r = await next(e)
362    await $.command.register({
363      name: 'council',
364      description: 'A council of models on a hard problem: status, on, off, members [models | reset], or a question to ask it (council)',
365      argumentHint: '[on | off | members [models | reset] | <question>]',
366    })
367    await enrollGemini($)
368    state.guidance = await isEnabled($)
369    if (state.guidance) await declareTool($, state)
370    return r
371  })
372
373  // Every window shares the store: a /council on made in another window declares the tool here before the
374  // next turn's first request. The note waits for /clear, as after /council on in this window.
375  on('turn.start', async ($, e, next) => {
376    if (!state.declared && (await isEnabled($))) await declareTool($, state)
377    return next(e)
378  })
379
380  // /clear arrives only through the classic seam; the note follows the setting from there.
381  on('classic.SessionStart', async ($, e, next) => {
382    const r = await next(e)
383    if (e.agent_id === undefined) state.guidance = await isEnabled($)
384    return r
385  })
386
387  on('command.run', { command: 'council' }, async ($, e) => ({ text: await runCommand($, state, config, String(e.args ?? '')) }))
388
389  // The note is fixed at a session start or /clear, so a change of the setting does not change the prompt the cache holds.
390  on('prompt.section', { name: 'env_info_simple' }, async (_, e, next) => {
391    const r = await next(e)
392    return r.text === null || !state.guidance ? r : { text: `${r.text}\n\n${SYSTEM_GUIDANCE}` }
393  })
394
395  // The tool matchers are patterns that equal TOOL_ID, because a literal typechecks only while the tool is
396  // declared in the session /plugin-types ran in, and the tool is declared only while the council is on.
397
398  // A plugin's tool waits behind ToolSearch by default; this one is listed, so the model can call it at once.
399  on('tool.describe', { tool: /^mcp__council__convene$/ }, async (_, e, next) => ({ ...(await next(e)), isDeferred: false }))
400
401  on('tool.call', { tool: /^mcp__council__convene$/ }, async ($, e) => onToolCall($, state, config, e as Record<string, unknown>, e.agentId))
402
403  // The main loop's model decides which member forks the session and which model chairs.
404  on('turn.step', async function* (_, e, next) {
405    if (e.agentId === undefined) state.model = e.model
406    return yield* next(e)
407  })
408}
409
hooks/board.ts 115 lines
1/** A council run as the person sees it: the sidebar section, the one log line, and the /council status. */
2
3/** How a member is asked: a fork of the session, a completion with the conversation as text, or gemini-core. */
4export type Via = 'fork' | 'complete' | 'gemini'
5
6export type SeatState = 'running' | 'answered' | 'failed' | 'skipped'
7
8/** One member's place in a run. */
9export type Seat = { label: string; via: Via; state: SeatState; ms?: number; inTokens?: number; outTokens?: number; why?: string; freeTier?: boolean }
10
11export type ChairState = 'waiting' | 'running' | 'answered' | 'failed'
12
13export type Chair = { label: string; via: 'fork' | 'complete'; state: ChairState; ms?: number; why?: string }
14
15/** A run from start to verdict; `endedAt` is set once the chair has answered or failed. */
16export type Run = { id: number; question: string; startedAt: number; seats: Seat[]; chair: Chair; verdict?: string; endedAt?: number }
17
18/** A sidebar line, as the sidebar contract spells it; kept here so this file stays free of `$`. */
19export type Kind = 'ok' | 'warn' | 'error' | 'dim' | 'info'
20export type Part = { text: string; kind?: Kind }
21export type Line = { text: string; kind?: Kind; parts?: readonly Part[] }
22
23function line(parts: readonly Part[]): Line {
24  return { text: parts.map(p => p.text).join(''), parts }
25}
26
27function tokens(n: number): string {
28  return n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n)
29}
30
31function secs(ms: number): string {
32  return `${Math.round(ms / 1000)}s`
33}
34
35function cut(text: string, max: number): string {
36  const flat = text.replace(/\s+/g, ' ').trim()
37  return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`
38}
39
40const SEAT_KIND: Record<SeatState, Kind> = { running: 'warn', answered: 'ok', failed: 'error', skipped: 'dim' }
41
42/** What follows a seat's state word: the time and tokens of an answer, or why there is none. */
43function seatTail(s: Seat): string {
44  if (s.state === 'answered') return ` ${secs(s.ms ?? 0)} · ${tokens(s.inTokens ?? 0)} in, ${tokens(s.outTokens ?? 0)} out${s.freeTier === true ? ' · free tier' : ''}`
45  return s.why === undefined ? '' : `: ${s.why}`
46}
47
48/**
49 * The model's colour by family, as session-watch and subagent-ledger give it: opus red, fable yellow,
50 * sonnet green, haiku faint, and the whole Gemini family blue.
51 */
52function modelTone(label: string): Kind | undefined {
53  const families: [RegExp, Kind][] = [[/opus/i, 'error'], [/fable/i, 'warn'], [/sonnet/i, 'ok'], [/haiku/i, 'dim'], [/^(?:gemini|gemma)-/i, 'info']]
54  return families.find(([family]) => family.test(label))?.[1]
55}
56
57/** A model's label as a part, coloured by its family. */
58function modelPart(label: string): Part {
59  const kind = modelTone(label)
60  return kind === undefined ? { text: label } : { text: label, kind }
61}
62
63function seatLine(s: Seat): Line {
64  return line([modelPart(s.label), { text: ` · ${s.via} · ` }, { text: s.state, kind: SEAT_KIND[s.state] }, { text: seatTail(s) }])
65}
66
67const CHAIR_WORD: Record<ChairState, { text: string; kind: Kind }> = {
68  waiting: { text: 'waiting', kind: 'dim' },
69  running: { text: 'writing', kind: 'warn' },
70  answered: { text: 'done', kind: 'ok' },
71  failed: { text: 'failed', kind: 'error' },
72}
73
74function chairLine(c: Chair): Line {
75  const word = CHAIR_WORD[c.state]
76  const tail = c.state === 'answered' ? ` ${secs(c.ms ?? 0)}` : c.why === undefined ? '' : `: ${c.why}`
77  return line([{ text: 'chair · ' }, modelPart(c.label), { text: ` · ${c.via} · ` }, word, { text: tail }])
78}
79
80function headLine(run: Run, now: number): Line {
81  const state = run.endedAt === undefined ? { text: `running ${secs(now - run.startedAt)}`, kind: 'warn' as const } : { text: `done in ${secs(run.endedAt - run.startedAt)}`, kind: 'ok' as const }
82  return line([{ text: cut(run.question, 60), kind: 'dim' }, { text: ' · ' }, state])
83}
84
85/** The verdict's first words as plain text: markdown marks and a leading "verdict" title left out. */
86function plainVerdict(text: string): string {
87  return text.replace(/[*_`#>]/g, '').replace(/\s+/g, ' ').trim().replace(/^(?:council )?verdict:?\s*/i, '')
88}
89
90/** The section's lines: the question, each member, the chair, and the verdict's first words once there is one. */
91export function runLines(run: Run, now: number): Line[] {
92  const verdict = run.verdict === undefined ? [] : [{ text: `verdict: ${cut(plainVerdict(run.verdict), 80)}` }]
93  return [headLine(run, now), ...run.seats.map(seatLine), chairLine(run.chair), ...verdict]
94}
95
96function answeredCount(run: Run): number {
97  return run.seats.filter(s => s.state === 'answered').length
98}
99
100/** One line for a closed sidebar, and the last run in the status. */
101export function summaryText(run: Run): string {
102  const took = run.endedAt === undefined ? 'still running' : `in ${secs(run.endedAt - run.startedAt)}`
103  const chair = run.chair.state === 'answered' ? 'the chair wrote the verdict' : `the chair wrote none: ${run.chair.why ?? 'no member answered'}`
104  return `${answeredCount(run)} of ${run.seats.length} members answered ${took}; ${chair}`
105}
106
107/** A member in the status: its label, and why a run would skip it. */
108export type StatusMember = { label: string; skipped?: string }
109
110export function statusText(enabled: boolean, members: readonly StatusMember[], chair: string, last: string | undefined): string {
111  const head = enabled ? 'on: the model can call the council when it is stuck' : 'off: the model cannot call the council; /council <question> still runs'
112  const names = members.map(m => (m.skipped === undefined ? m.label : `${m.label} (skipped: ${m.skipped})`)).join(', ')
113  return [head, `members: ${names}`, `chair: ${chair}`, ...(last === undefined ? [] : [`last: ${last}`])].join('\n')
114}
115
hooks/command.ts 47 lines
1/** The argument of /council and the lines it prints. */
2import { parseMembers } from './members.ts'
3
4export const ENABLED_KEY = 'enabled'
5export const MEMBERS_KEY = 'members'
6
7export type Command =
8  | { kind: 'status' }
9  | { kind: 'set'; enabled: boolean }
10  | { kind: 'members' }
11  | { kind: 'setMembers'; members: string[] }
12  | { kind: 'resetMembers' }
13  | { kind: 'ask'; question: string }
14  | { kind: 'error'; text: string }
15
16function membersCommand(rest: readonly string[]): Command {
17  if (rest.length === 0) return { kind: 'members' }
18  if (rest.length === 1 && rest[0] === 'reset') return { kind: 'resetMembers' }
19  const parsed = parseMembers(rest)
20  return 'error' in parsed ? { kind: 'error', text: parsed.error } : { kind: 'setMembers', members: parsed.members }
21}
22
23/** `on`, `off`, `status` and `members ...` are commands; any other text is a question for the council. */
24export function parseCommand(args: string): Command {
25  const text = args.trim()
26  if (text === '' || text === 'status') return { kind: 'status' }
27  if (text === 'on' || text === 'off') return { kind: 'set', enabled: text === 'on' }
28  const [first, ...rest] = text.split(/\s+/)
29  return first === 'members' ? membersCommand(rest) : { kind: 'ask', question: text }
30}
31
32export function changeText(enabled: boolean): string {
33  return enabled
34    ? 'on: the model can call the council tool now; the system prompt note that says when comes at /clear or the next session'
35    : 'off: a call answers that the council is off; the note leaves at /clear or the next session, the tool at the next session; /council <question> still runs'
36}
37
38export function membersText(labels: readonly string[]): string {
39  return `members: ${labels.join(', ')}`
40}
41
42export function convenedText(count: number): string {
43  return `convened: ${count} members; the verdict comes as a message when they have answered`
44}
45
46export const OFF_DENY = 'the council is off; the user can turn it on with /council on'
47
hooks/config.ts 26 lines
1/** The plugin option and the fixed limits of one council run. */
2import type { PluginOptions } from 'claude-code'
3
4export type Config = { maxInputChars: number }
5
6/**
7 * 400,000 characters of rendered conversation were 163,828 input tokens on Opus 5.5, Sonnet 5 and
8 * Fable 5.1, 128,018 on Haiku 4.5 and 120,057 on gemini-3.8-flash (measured on 2.1.283).
9 */
10export const DEFAULTS: Config = { maxInputChars: 400_000 }
11
12/** Haiku 4.5 has a 200k token window; 560,000 characters are about 180k of its tokens. */
13export const HAIKU_MAX_CHARS = 560_000
14
15/** A member that has not answered by then is counted as failed; it guards against a hung request only. */
16export const MEMBER_MS = 300_000
17
18/** The longest answer of a member or the chair, thinking included. */
19export const MAX_TOKENS = 8192
20
21export function configFrom(options: PluginOptions): Config {
22  const value = options.maxInputChars
23  const ok = typeof value === 'number' && Number.isInteger(value) && value >= 20_000 && value <= 2_000_000
24  return { maxInputChars: ok ? value : DEFAULTS.maxInputChars }
25}
26
hooks/members.ts 78 lines
1/** The council's members: how a typed token names a model, the defaults, and the labels the person reads. */
2
3/** A Claude model asked through the session's own client, or a Gemini model asked through gemini-core. */
4export type Member = { kind: 'claude' | 'gemini'; id: string; label: string }
5
6const ALIASES = new Map([
7  ['opus', 'claude-opus-5-5'],
8  ['sonnet', 'claude-sonnet-5-5'],
9  ['fable', 'claude-fable-5-1'],
10  ['haiku', 'claude-haiku-4-5-20251001'],
11])
12
13const LABELS = new Map([
14  ['claude-opus-5-5', 'opus 5.5'],
15  ['claude-sonnet-5-5', 'sonnet 5.5'],
16  ['claude-sonnet-5', 'sonnet 5'],
17  ['claude-fable-5-1', 'fable 5.1'],
18  ['claude-haiku-4-5-20251001', 'haiku 4.5'],
19])
20
21/**
22 * The members of a fresh install. `gemini-3.1-pro-preview` is left out, because a free-tier key has no
23 * quota for it: all 34 free keys of a check answered HTTP 429 (measured on 2.1.283).
24 */
25export const DEFAULT_MEMBERS: readonly string[] = ['opus', 'sonnet', 'fable', 'haiku', 'gemini-3.8-flash']
26
27/** A model id goes into a URL path for Gemini, so only a plain id is taken. */
28const MODEL_ID = /^[a-z0-9][a-z0-9.-]{0,79}$/
29
30const GEMINI_PREFIX = /^(?:gemini|gemma)-/
31
32export const MEMBER_HINT = 'use opus, sonnet, fable, haiku, a claude- model id, or a gemini- or gemma- model id'
33
34/** What a typed token names, or undefined when it names no model. */
35export function memberOf(token: string): Member | undefined {
36  const id = ALIASES.get(token) ?? token
37  if (!MODEL_ID.test(id)) return undefined
38  const label = LABELS.get(id) ?? id
39  if (id.startsWith('claude-')) return { kind: 'claude', id, label }
40  return GEMINI_PREFIX.test(id) ? { kind: 'gemini', id, label } : undefined
41}
42
43/** The tokens of `/council members <tokens>`, each once, or why they are refused. */
44export function parseMembers(tokens: readonly string[]): { members: string[] } | { error: string } {
45  if (tokens.length === 0) return { error: `members takes one or more models: ${MEMBER_HINT}` }
46  const unknown = tokens.find(t => memberOf(t) === undefined)
47  if (unknown !== undefined) return { error: `${unknown} is not a model: ${MEMBER_HINT}` }
48  return { members: [...new Set(tokens)] }
49}
50
51/** The stored member tokens, or the defaults when the store holds none that name a model. */
52export function storedMembers(value: unknown): Member[] {
53  const tokens = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : []
54  const members = tokens.map(memberOf).filter((m): m is Member => m !== undefined)
55  return members.length > 0 ? members : DEFAULT_MEMBERS.map(memberOf).filter((m): m is Member => m !== undefined)
56}
57
58/** A model id without the context-window mark the engine adds (`claude-opus-5-5[1m]`). */
59export function bare(model: string): string {
60  return model.replace(/\[[^\]]*\]$/, '')
61}
62
63/** A model id without its release date (`claude-haiku-4-5-20251001`). */
64function undated(model: string): string {
65  return bare(model).replace(/-\d{8}$/, '')
66}
67
68/** Whether a member is the model the session's main loop runs on, dated id or not. */
69export function isSessionModel(member: Member, sessionModel: string | undefined): boolean {
70  if (member.kind !== 'claude' || sessionModel === undefined) return false
71  return undated(member.id) === undated(sessionModel)
72}
73
74/** The label of a model the session runs on, as a member of that model would read. */
75export function labelOfModel(model: string): string {
76  return LABELS.get(bare(model)) ?? bare(model)
77}
78
hooks/prompts.ts 140 lines
1/** The convene tool as the model sees it, what each member and the chair are asked, and the answer the caller reads. */
2
3export const TOOL_NAME = 'convene'
4
5export const TOOL_ID = `mcp__council__${TOOL_NAME}` as const
6
7export const INPUT_SCHEMA = {
8  type: 'object',
9  properties: {
10    question: {
11      type: 'string',
12      description: 'The problem, what you tried and what you found, and the specific question you want the council to answer.',
13    },
14  },
15  required: ['question'],
16}
17
18const WHEN = [
19  'Call it when you are stuck, not for routine work:',
20  '- the same error survived two attempts to fix it;',
21  '- the root cause is still unclear after you investigated;',
22  '- you must choose between two designs that each have real trade-offs;',
23  '- before a change that is hard to undo.',
24  'Skip it for simple, mechanical steps. The answers can be wrong: check them against the code before you act, and tell the user where you disagree.',
25]
26
27/** What the model reads about the tool. It names no model, so a change of the members does not make it stale. */
28export const TOOL_DESCRIPTION = [
29  'Convene a council of models on a hard problem. Several models read the conversation so far and your question and answer on their own; then the model of this session, as the chair, writes one verdict from their answers. You get the verdict and every answer. A call takes about half a minute.',
30  ...WHEN,
31].join('\n')
32
33/** The system prompt's note about the tool; the text is fixed, so the prompt cache holds. */
34export const SYSTEM_GUIDANCE = [
35  '# Council',
36  `You have a council tool, ${TOOL_ID}: several models answer your question on their own and the model of this session writes one verdict from their answers. When it is listed only by name, load it with ToolSearch (query "select:${TOOL_ID}").`,
37  ...WHEN,
38].join('\n')
39
40const ANSWER_RULES = `Answer on your own; the other members do not see your answer:
41- Answer the question directly first.
42- Name the evidence for each claim: a file, an output, a message of the conversation.
43- Point out mistakes, risks, missed steps and wrong assumptions. Say so when the plan is sound; do not invent problems.
44- Prefer concrete next steps over general advice. Keep it short.
45- Do not state as fact what the conversation does not show; say what the agent should check instead.
46- Answer in the language of the question.`
47
48/** A member's system prompt when it reads the conversation as text. */
49export const MEMBER_SYSTEM = `You are one member of a council of models that advises a coding agent (Claude, in Claude Code) working with a user. You get the agent's conversation so far (the user's messages, the agent's replies, each tool call with its output) and then the agent's question.
50
51${ANSWER_RULES}`
52
53/** A member's prompt: the conversation when there is one, then the question. */
54export function memberPrompt(transcript: string | undefined, question: string): string {
55  const conversation = transcript === undefined ? 'The conversation is not available; only the question is.' : `The conversation:\n\n${transcript}`
56  return `${conversation}\n\nThe agent asks the council:\n\n${question}`
57}
58
59/** A member's prompt when it forks the session and reads the conversation itself. */
60export function forkMemberPrompt(question: string): string {
61  return `You are now one member of a council of models that the agent convened. Do not call any tool; answer in text. The conversation above is the one to judge.
62
63${ANSWER_RULES}
64
65The question:
66
67${question}`
68}
69
70/** One member's answer as the chair reads it: a letter in place of the model's name. */
71export type Lettered = { letter: string; text: string }
72
73export function letterOf(index: number): string {
74  return String.fromCharCode(65 + index)
75}
76
77/** The chair's prompt; the members are letters, so the chair judges the answers and not the models. */
78export function chairPrompt(question: string, answers: readonly Lettered[], hasConversation: boolean): string {
79  const context = hasConversation ? 'the evidence in this conversation' : 'the question and the answers'
80  const body = answers.map(a => `Member ${a.letter}:\n${a.text}`).join('\n\n')
81  return `You chair a council of models that the agent convened. Do not call any tool; answer in text. ${answers.length} members answered the question below on their own; their answers follow, named by letter.
82
83Write the council's verdict for the agent:
84- where the members agree;
85- where they disagree, and which side ${context} supports;
86- what the members missed;
87- the next step you recommend.
88Start with the verdict itself, without a title. Name members by letter. Keep it short. Answer in the language of the question.
89
90The question:
91
92${question}
93
94${body}`
95}
96
97/** How one member's run ended, as the caller reads it. */
98export type Row = { letter?: string; label: string; ms?: number; text?: string; why?: string }
99
100function seconds(ms: number): string {
101  return `${Math.round(ms / 1000)} s`
102}
103
104function rowText(r: Row): string {
105  if (r.text === undefined) return `## ${r.label} · no answer: ${r.why ?? 'unknown'}`
106  return `## ${r.letter ?? '?'}: ${r.label} · ${seconds(r.ms ?? 0)}\n\n${r.text}`
107}
108
109/** The verdict when the chair wrote one, and every member's answer or why it gave none. */
110export function resultText(verdict: { text: string } | { why: string }, chair: string, rows: readonly Row[]): string {
111  const answered = rows.filter(r => r.text !== undefined).length
112  const head = 'text' in verdict
113    ? `Council verdict (${answered} of ${rows.length} members answered; the chair, ${chair}, wrote it):\n\n${verdict.text}`
114    : `The chair, ${chair}, wrote no verdict (${verdict.why}); ${answered} of ${rows.length} members answered.`
115  return `${head}\n\nMember answers:\n\n${rows.map(rowText).join('\n\n')}`
116}
117
118/** The refusal when no member answered, naming each reason. */
119export function noAnswerText(rows: readonly Row[]): string {
120  return `the council got no answer: ${rows.map(r => `${r.label}: ${r.why ?? 'unknown'}`).join('; ')}`
121}
122
123/** The prompt that hands a /council run's result to the model, in the person's voice. */
124export function sendText(question: string, result: string): string {
125  return `I convened the council with /council. Read the verdict and the answers, check them against the code, and tell me what you take from them and what you would do next.
126
127My question:
128
129${question}
130
131${result}`
132}
133
134/** The question from the tool input; a missing or empty one throws. */
135export function questionOf(input: Record<string, unknown>): string {
136  const question = input.question
137  if (typeof question !== 'string' || question.trim() === '') throw new Error('question is required: the problem, what you tried, and what the council should answer')
138  return question.trim()
139}
140
hooks/transcript.ts 101 lines
1/** The conversation as a council member reads it, cut to a character limit. */
2import type { SessionMessage, ToolUseSummary } from 'claude-code'
3
4type Output = { text: string; isError: boolean }
5
6/** The output of each tool call, by tool_use_id: its result block, else the call's own text. */
7function outputs(messages: readonly SessionMessage[]): Map<string, Output> {
8  const out = new Map<string, Output>()
9  for (const m of messages) {
10    for (const use of m.toolUses) out.set(use.tool_use_id, { text: use.text ?? '', isError: use.isError === true })
11  }
12  for (const m of messages) {
13    for (const r of m.toolResults ?? []) out.set(r.tool_use_id, { text: r.text, isError: r.isError })
14  }
15  return out
16}
17
18/** Cuts a text to about `max` characters: its head and its tail around one note. */
19export function clip(text: string, max: number): string {
20  if (text.length <= max) return text
21  const half = Math.max(0, Math.floor(max / 2))
22  return `${text.slice(0, half)}\n[… ${text.length - 2 * half} chars omitted …]\n${text.slice(text.length - half)}`
23}
24
25function callLines(use: ToolUseSummary, output: Output | undefined, cap: number): string[] {
26  const status = output?.isError === true ? 'error' : 'output'
27  return [`  [call] ${use.tool} ${JSON.stringify(use.input) ?? '{}'}`, `  ${status}: ${clip(output?.text ?? '', cap)}`]
28}
29
30/** One message's lines, numbered by its place in the whole conversation. */
31function messageText(m: SessionMessage, n: number, byUse: ReadonlyMap<string, Output>, cap: number): string {
32  return [`#${n} ${m.role}: ${m.text}`, ...m.toolUses.flatMap(use => callLines(use, byUse.get(use.tool_use_id), cap))].join('\n')
33}
34
35function render(messages: readonly SessionMessage[], first: number, byUse: ReadonlyMap<string, Output>, cap: number): string {
36  return messages.map((m, i) => messageText(m, first + i + 1, byUse, cap)).join('\n')
37}
38
39/** The output lengths of one message's calls. */
40function outputLengths(m: SessionMessage, byUse: ReadonlyMap<string, Output>): number[] {
41  return m.toolUses.map(u => byUse.get(u.tool_use_id)?.text.length ?? 0)
42}
43
44/** The size of a message with every output cut to `cap`, as `clip` cuts it (the note is about 40 characters). */
45function sizeAt(fixed: number, lengths: readonly number[], cap: number): number {
46  return fixed + lengths.reduce((sum, n) => sum + Math.min(n, cap + 40), 0)
47}
48
49/** The longest output length that makes the messages fit, found by bisection; undefined when none does. */
50function outputCap(fixed: number, lengths: readonly number[], max: number): number | undefined {
51  if (sizeAt(fixed, lengths, 0) > max) return undefined
52  let low = 0
53  let high = Math.max(0, ...lengths)
54  while (low < high) {
55    const mid = Math.ceil((low + high) / 2)
56    if (sizeAt(fixed, lengths, mid) <= max) low = mid
57    else high = mid - 1
58  }
59  return low
60}
61
62type Sized = { fixed: number; lengths: number[] }
63
64/** Each message's length without its outputs, and its outputs' lengths. */
65function sizes(messages: readonly SessionMessage[], byUse: ReadonlyMap<string, Output>): Sized[] {
66  return messages.map((m, i) => {
67    const lengths = outputLengths(m, byUse)
68    const full = messageText(m, i + 1, byUse, Infinity).length + 1
69    return { fixed: full - lengths.reduce((a, b) => a + b, 0), lengths }
70  })
71}
72
73/** How many of the oldest messages to leave out, so the rest fits with every output cut to its shortest. */
74function dropped(sized: readonly Sized[], max: number): number {
75  let total = sized.reduce((sum, s) => sum + sizeAt(s.fixed, s.lengths, 0), 0)
76  let first = 0
77  while (first < sized.length - 1 && total > max) {
78    total -= sizeAt(sized[first]?.fixed ?? 0, sized[first]?.lengths ?? [], 0)
79    first++
80  }
81  return first
82}
83
84/**
85 * Every message in order, each tool call with its input and output. Over `maxChars`, the longest outputs
86 * are cut to one common length; when the conversation is over it even without output, the oldest
87 * messages are left out and a first line says how many, and a last message alone over it is cut.
88 */
89export function renderTranscript(messages: readonly SessionMessage[], maxChars: number): string {
90  const byUse = outputs(messages)
91  const full = render(messages, 0, byUse, Infinity)
92  if (full.length <= maxChars) return full
93  const sized = sizes(messages, byUse)
94  const first = dropped(sized, maxChars - 60)
95  const note = first === 0 ? '' : `[the first ${first} messages are left out]\n`
96  const kept = sized.slice(first)
97  const fixed = kept.reduce((sum, s) => sum + s.fixed, 0)
98  const cap = outputCap(fixed, kept.flatMap(s => s.lengths), maxChars - note.length)
99  return clip(`${note}${render(messages.slice(first), first, byUse, cap ?? 0)}`, maxChars)
100}
101