SLOPSHOPPER

Auto Compact

Spots task boundaries in Claude Code sessions with a System One decision model and offers to compact or clear the context

newspinnercommandstatuspromptmodel
v0.1.0MITupdated 2026-10-23arthur-fontaine/cc-mod-auto-compact
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · auto-compact
› 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 › /auto-compact ⎿ auto-compact: auto-compact mode: suggest ⎿ auto-compact: Provider: none, run /auto-compact setup ⎿ auto-compact: Tree: 2 messages, 0 summaries, up to date · summarizer claude-haiku-5-5 ⎿ auto-compact: Last decision: none yet ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ auto-compact: auto-compact: run /auto-compact setup to choose a provider
README

Auto Compact

A Claude Code mod that notices when a prompt starts a new task and offers to compact or clear the context before it is sent. A decision model reads a summary of the session and the new prompt, and puts the prompt in one of five categories: it continues the current task, follows up on it, starts a related task, starts an unrelated one, or returns to an earlier one. A follow-up keeps everything; a related task is worth a compaction that keeps the general picture; an unrelated one is worth a /clear.

The local model is Spisev (spisev-alpha), a small model distilled from Baseten's Mammoth that runs on your machine with llama.cpp; any System One endpoint (Mammoth, Jev) works too.

Install

At the prompt of a terminal session:

/plugin install auto-compact --marketplace arthur-fontaine/cc-mod-auto-compact

From a shell: claude plugin install auto-compact --marketplace arthur-fontaine/cc-mod-auto-compact.

Then pick a provider with /auto-compact setup. Until then the mod does nothing.

Providers

  • Spisev (local): /auto-compact setup asks before downloading the GGUF (about 1.9 GB) from Hugging Face and starts llama-server on 127.0.0.1 with -hf, for the rest of the session. It needs llama.cpp build 11361 or later (llama-server or llama on your PATH, or AUTO_COMPACT_LLAMA_SERVER). A .gguf path in AUTO_COMPACT_MODEL is served with -m.
  • Mammoth (Baseten) or Jev (OpenCode Zen): setup saves the endpoint and model; the key is read from AUTO_COMPACT_API_KEY in the environment and never stored. Any other System One endpoint works with AUTO_COMPACT_ENDPOINT and AUTO_COMPACT_MODEL.

Privacy

With Spisev nothing leaves the machine. With a cloud provider, only the clipped state goes out: the session's summary lines and the new prompt. The summaries themselves are written by Claude Haiku through your own Claude Code session ($.model.complete), like any other request of the session.

Commands

  • /auto-compact: the provider, the mode, the state of the summary tree and the last decision.
  • /auto-compact setup: choose a provider.
  • /auto-compact mode off|suggest|auto: saved across sessions.

In suggest mode (the default), a prompt that looks like a related, returning or unrelated task opens a question before it is sent: compact (or clear) first, or send it as is. Nothing happens without your press. In auto mode the mod acts only when the decision's confidence is at least AUTO_COMPACT_MIN_CONFIDENCE (0.85). continuation and follow_up never act. The mod never acts on subagents, on a timeout or an error, without a provider, or while the previous turn's summaries are still being built (the status line says auto-compact: summaries catching up).

Acting holds the prompt back (Claude Code does not compact under a prompt being submitted), compacts with the current summary lines as what to keep, or runs /clear, then sends the same prompt. A prompt with attachments is never held back. The footer shows each decision: context kept · 92%, compact? related · 71%, cleared · independent · 91%; the debug log has [auto-compact] <category> · <confidence> → <action>.

Environment variables

| Variable | Default | | | :- | :- | :- | | AUTO_COMPACT_PROVIDER | none | spisev, or systemone (implied by an endpoint) | | AUTO_COMPACT_ENDPOINT | Spisev: http://127.0.0.1:8766/v1/systemone | a System One endpoint | | AUTO_COMPACT_MODEL | Spisev: arthur-fontaine/spisev-alpha-GGUF:Q8_0 | the endpoint's model, or a GGUF for llama-server | | AUTO_COMPACT_AUTH | Bearer | Api-Key for Baseten | | AUTO_COMPACT_API_KEY | | the cloud provider's key, never stored | | AUTO_COMPACT_MODE | suggest | overrides the saved mode | | AUTO_COMPACT_MIN_CONFIDENCE | 0.85 | auto mode's threshold | | AUTO_COMPACT_TIMEOUT_MS | 4000 | a slower answer keeps the context | | AUTO_COMPACT_SUMMARIZER_MODEL | claude-haiku-5-5 | a full model id | | AUTO_COMPACT_DATA_DIR | ~/.claude/plugins/data/auto-compact | where each session's summary tree is saved | | AUTO_COMPACT_LLAMA_SERVER | llama-server, then llama | |

How it decides

After each turn the mod summarizes the new messages with Haiku into an OptChat tree: each message becomes a line of at most 512 bytes, and adjacent lines merge in pairs. On each prompt it sends the view (the last 16 messages one line each, older ones as coarser lines) and the prompt to the decision model, clipped to 12,000 tokens, and maps the category it picks through an editable table. The same code builds the training data (training/README.md); parity tests check that the mod and the pipeline clip, summarize and ask alike.

Benchmark

Right action against median latency, one point per model. Spisev 1.7B, Elephant and Mammoth are in the fast and accurate zone; Qwen3.5-2B sits on its latency edge and Kev-4B well outside it.

The test: 418 decisions, from 334 held-out prompts of 94 real coding sessions plus 84 held-out splices (the end of one session followed by the first prompt of another, where the context should go). None of these sessions was used for training or to pick a checkpoint. The answer key is the action two Claude teachers, Sonnet 5.5 and Opus 5.5, agreed on after seeing what the classifier sees plus hindsight: the last 8 messages, what the agent did next and the developer's following prompts. They agreed on 96% of the points; the rest are left out. Always keeping the context gets 76.8% right.

The setup: each model got the request the mod sends it, one at a time. The local models ran in llama.cpp b11669 on an Apple M4 Pro with 24 GB. Elephant and Mammoth, from the same lab, ran on Baseten; their latency includes the round trip from that Mac.

The green zone is where a decision model should be. Both edges are set by a rule, not read off the results:

  • Fast: under 400 ms at the median, the Doherty threshold, below which people stay engaged instead of waiting. The mod asks before every prompt, so this is time added to each one.
  • Accurate: at least 80.4%, the lowest score that beats always keeping the context by more than chance on these points (one-sided exact binomial test, p < 0.05), computed by benchmark.py.

| Model | Runs | Right action | Kept when it should | Acted when it should | Right action, auto mode | Right category | Latency p50 / p95 | | :- | :- | -: | -: | -: | -: | -: | -: | | Spisev 1.7B | Local · llama.cpp · Q8_0, 1.8 GB | 93.3% | 97.5% | 79.4% | 87.1% | 81.5% | 138 / 2590 ms | | Qwen3.5-2B, same recipe | Local · llama.cpp · Q8_0, 2.0 GB | 93.8% | 97.2% | 82.5% | 87.8% | 83.6% | 431 / 1913 ms | | Kev-4B | Local · llama.cpp · Q8_0, 4.5 GB | 83.0% | 87.5% | 68.0% | 76.8% | 62.0% | 1219 / 6225 ms | | Elephant 9B | Cloud · Baseten | 94.3% | 98.1% | 81.4% | 86.6% | 74.7% | 194 / 276 ms | | Mammoth 27B | Cloud · Baseten | 96.9% | 98.4% | 91.8% | 91.9% | 86.8% | 225 / 415 ms | | Always keep | | 76.8% | 100% | 0% | 76.8% | | |

  • Right action: the model's category, through the policy, leads to the teachers' action: keep, compact or clear.
  • Kept when it should: of the 321 points where the teachers keep the context, how many the model keeps. A miss here is the costly one: it drops context the agent needed.
  • Acted when it should: of the 97 points where the teachers compact or clear, how many the model handles the same way.
  • Right action, auto mode: what the mod ends up doing in auto mode. It acts only at a confidence of 0.85 or more and within its 4 s timeout, and otherwise keeps the context.
  • Right category: the exact category, on the 379 points where the teachers also agreed on it.

What stands out:

  • Every model beats always keeping, compared point by point (one-sided exact McNemar test): Spisev, Qwen3.5-2B, Elephant and Mammoth at p < 0.0001, Kev at p = 0.007.
  • Spisev, Qwen3.5-2B and Elephant are tied: against Spisev, 13 points only Qwen3.5-2B gets right and 11 the other way (p = 0.84); 14 only Elephant and 10 the other way (p = 0.54). Spisev answers three times faster than Qwen3.5-2B at the median, and needs no network or key, which is why it is the default. Elephant is the steadiest: 276 ms at p95, but it gets the exact category less often (74.7%).
  • Mammoth is ahead of Spisev (6 points against 21, p = 0.006), as expected: it is the teacher Spisev was distilled from, at 16 times the size. It needs the network and a Baseten key; Spisev runs on the Mac.
  • Kev drops context it should keep: it compacts or clears 12.5% of the prompts the teachers keep, five times as often as Spisev. It is never confident enough for auto mode (no answer at 0.85 or more, median 0.38), so in auto mode it does exactly what always keeping does. It answers zero-shot; it was trained for auto-effort's question, not this one.
  • The local p95 is prefill, not noise: the slowest 10% of states are 3k to 11k tokens, against 650 at the median, and the M4 Pro reads 2,000 tokens a second at 1k falling to 1,300 at 8k (llama-bench; larger batches or flash attention do not help). The prompt cache saves little there, since the coarse lines at the front of the view change as the session grows. Spisev passed the 4 s timeout on 3 of 345 prompts, where the mod keeps the context; Kev on 47. The cloud models read long states on bigger GPUs: their p95 stays under 0.5 s.

To rerun it, run uv run python pipeline/benchmark.py then uv run python pipeline/plot.py in training/, which also explains how the answer key and Spisev were built.

Development

pnpm test        # claude plugin test
pnpm validate    # claude plugin validate . --strict
pnpm eval        # the configured provider on sample decisions (reads .env)
claude --plugin-dir .
Source 8 files
hooks/register.js 523 lines
1import { atom, read, update } from 'claude-code'
2
3import { parseBuild, parseServerOutput, serverArgs, serverCommand, servesOurModel } from './llama.js'
4import {
5  SPISEV, compactInstructions, decide, describe, headers, logLine, MODES, PRESETS, resolveConfig,
6} from './policy.js'
7import { PROMPT_PATH, SUMMARIZER, summarize } from './summarizer.js'
8import { clipState, request } from './task.js'
9import { cleanPrompt, messages, render } from './text.js'
10import * as tree from './tree.js'
11
12const USER_ORIGINS = ['composer', 'bridge', 'sdk']
13const SUMMARY_CONCURRENCY = 4
14const BUILD_WAIT_MS = 120_000
15
16// The footer label, kept in $.state so a reload keeps it.
17const label = atom({ plugin: 'auto-compact', key: 'label' }, null)
18
19let systemPrompt = null
20let nodes = null // summary line by hash of its request, for this session
21let nodesPath = null
22let building = null
23let lastDecision = null
24let lastError = null
25let clearProblem = null
26let warned = false
27let server = null // { state, stream }
28const resending = new Set() // prompts held back while compacting, sent again as they were
29
30function show($, text) {
31  return update($, label, () => text).catch(() => {})
32}
33
34function debug($, text) {
35  $.ui.log(text, { to: 'debug' })
36}
37
38// FNV-1a over the request: a stable key for a node, whatever position it has in the transcript.
39function hash(text) {
40  let a = 0x811c9dc5
41  let b = 0x01000193
42  for (let i = 0; i < text.length; i++) {
43    const c = text.charCodeAt(i)
44    a = Math.imul(a ^ c, 0x01000193) >>> 0
45    b = Math.imul(b ^ c, 0x5bd1e995) >>> 0
46  }
47  return a.toString(16).padStart(8, '0') + b.toString(16).padStart(8, '0')
48}
49
50async function storedConfig($) {
51  try {
52    const saved = await $.store.get('config')
53    return saved && typeof saved === 'object' ? saved : {}
54  } catch {
55    return {}
56  }
57}
58
59async function loadConfig($) {
60  const env = {
61    provider: await $.env.get('AUTO_COMPACT_PROVIDER'),
62    endpoint: await $.env.get('AUTO_COMPACT_ENDPOINT'),
63    model: await $.env.get('AUTO_COMPACT_MODEL'),
64    auth: await $.env.get('AUTO_COMPACT_AUTH'),
65    apiKey: await $.env.get('AUTO_COMPACT_API_KEY'),
66    mode: await $.env.get('AUTO_COMPACT_MODE'),
67    minConfidence: await $.env.get('AUTO_COMPACT_MIN_CONFIDENCE'),
68    timeoutMs: await $.env.get('AUTO_COMPACT_TIMEOUT_MS'),
69    llamaServer: await $.env.get('AUTO_COMPACT_LLAMA_SERVER'),
70  }
71  return resolveConfig(env, await storedConfig($))
72}
73
74async function saveConfig($, patch) {
75  try {
76    await $.store.set('config', { ...(await storedConfig($)), ...patch })
77    return true
78  } catch {
79    return false
80  }
81}
82
83async function dataDir($) {
84  const own = await $.env.get('AUTO_COMPACT_DATA_DIR')
85  if (own) return own
86  const plugin = await $.env.get('CLAUDE_PLUGIN_DATA')
87  if (plugin) return plugin
88  return (await $.env.get('HOME')) + '/.claude/plugins/data/auto-compact'
89}
90
91async function system($) {
92  if (systemPrompt === null) systemPrompt = await $.fs.read($.plugin.root + '/' + PROMPT_PATH)
93  return systemPrompt
94}
95
96async function summarizerSettings($) {
97  return { ...SUMMARIZER, model: (await $.env.get('AUTO_COMPACT_SUMMARIZER_MODEL')) || SUMMARIZER.model }
98}
99
100// The session's summary lines, saved per session id with $.fs (not $.store: 4 MiB, shared).
101async function loadNodes($) {
102  const path = (await dataDir($)) + '/trees/' + (await $.session.id()) + '.json'
103  if (nodes && nodesPath === path) return nodes
104  nodesPath = path
105  try {
106    nodes = JSON.parse(await $.fs.read(path)).nodes ?? {}
107  } catch {
108    nodes = {}
109  }
110  return nodes
111}
112
113async function saveNodes($) {
114  if (!nodesPath) return
115  try {
116    await $.fs.write(nodesPath, JSON.stringify({ version: 1, summarizer: SUMMARIZER.model, nodes }))
117  } catch (error) {
118    debug($, '[auto-compact] could not save the tree: ' + error.message)
119  }
120}
121
122class Missing extends Error {}
123
124// The text of every node the view at msgs.length needs. With `allowCalls`, missing summaries are
125// built with $.model.complete, up to SUMMARY_CONCURRENCY at once; without, a missing one throws.
126async function treeTexts($, msgs, { allowCalls }) {
127  const store = await loadNodes($)
128  const sys = allowCalls ? await system($) : null
129  const settings = await summarizerSettings($)
130  const memo = new Map()
131  let active = 0
132  const waiting = []
133  const slot = async () => {
134    if (active < SUMMARY_CONCURRENCY) return void active++
135    await new Promise((resolve) => waiting.push(resolve))
136    active++
137  }
138  const release = () => {
139    active--
140    waiting.shift()?.()
141  }
142  const text = (node) => {
143    const k = tree.key(node)
144    if (!memo.has(k)) memo.set(k, build(node))
145    return memo.get(k)
146  }
147  const build = async (node) => {
148    let source
149    if (node[0] === 0) source = render(msgs[node[1]])
150    else source = await Promise.all(tree.children(node).map(text))
151    const free = tree.noCallText(node, source)
152    if (free !== null) return free
153    const context = await Promise.all(tree.contextView(node).map(async (n) => [n, await text(n)]))
154    const prompt = node[0] === 0
155      ? tree.leafPrompt(node[1], source, context)
156      : tree.mergePrompt(...tree.children(node), source[0], source[1], context)
157    const id = hash(settings.model + '\n' + prompt)
158    if (store[id]) return store[id]
159    if (!allowCalls) throw new Missing(tree.name(node))
160    await slot()
161    try {
162      const { line } = await summarize((r) => $.model.complete(r), prompt, sys, settings)
163      store[id] = line
164      return line
165    } finally {
166      release()
167    }
168  }
169  const lines = tree.view(msgs.length)
170  const order = tree.needed([lines])
171  const results = await Promise.allSettled(order.map(text))
172  const failed = results.find((r) => r.status === 'rejected')
173  if (failed) throw failed.reason
174  return Promise.all(lines.map(async (n) => [n, await text(n)]))
175}
176
177async function sessionMessages($) {
178  return messages(await $.session.messages())
179}
180
181async function buildTree($) {
182  const msgs = await sessionMessages($)
183  try {
184    await treeTexts($, msgs, { allowCalls: true })
185    lastError = null
186  } catch (error) {
187    lastError = error.message
188    debug($, '[auto-compact] summaries: ' + error.message)
189    if (error.isLimit) $.ui.status('auto-compact: Claude usage limit, summaries paused')
190  } finally {
191    await saveNodes($)
192  }
193}
194
195async function llamaBuild($, binary) {
196  try {
197    const { stdout, stderr } = await $.process.run([binary, '--version'], { timeoutMs: 10_000 })
198    return parseBuild(stdout + stderr)
199  } catch {
200    return null
201  }
202}
203
204async function findServer($, config) {
205  for (const binary of config.llamaServer ? [config.llamaServer] : SPISEV.servers) {
206    const build = await llamaBuild($, binary)
207    if (build !== null) return { binary, build }
208  }
209  return { binary: config.llamaServer ?? SPISEV.servers[0], build: null }
210}
211
212async function isServing($, config) {
213  try {
214    const r = await $.http.fetch('http://127.0.0.1:' + config.port + '/v1/models')
215    return r.ok && servesOurModel(r.text)
216  } catch {
217    return false
218  }
219}
220
221// Starts llama-server for the rest of the session unless our model is already served; never waits.
222async function startSpisev($, config) {
223  if (server && server.state !== 'stopped') return server.state
224  if (await isServing($, config)) {
225    server = { state: 'ready', stream: null }
226    return 'ready'
227  }
228  const { binary } = await findServer($, config)
229  const current = { state: 'starting', stream: null }
230  server = current
231  void show($, 'Spisev starting · the first start downloads ' + SPISEV.sizeLabel)
232  void (async () => {
233    try {
234      current.stream = $.process.spawn({ argv: serverArgs(config, binary) })
235      for await (const { text } of current.stream) {
236        const seen = parseServerOutput(text ?? '')
237        if (seen.ready && current.state !== 'ready') {
238          current.state = 'ready'
239          void show($, 'Spisev ready')
240        } else if (seen.percent !== undefined && current.state !== 'ready') {
241          current.state = 'downloading'
242          void show($, 'Spisev downloading · ' + seen.percent + '%')
243        }
244      }
245    } catch (error) {
246      debug($, '[auto-compact] llama-server failed to start: ' + error.message)
247      $.ui.status('auto-compact: llama-server failed to start, see the debug log')
248    }
249    current.state = 'stopped'
250  })()
251  return current.state
252}
253
254// The first prompt of a session usually comes while llama-server loads the model (a few seconds):
255// wait for it a little rather than skip the decision. A download is never waited for.
256const START_WAIT_MS = 10_000
257
258async function spisevReady($, config) {
259  let state = await startSpisev($, config)
260  for (let waited = 0; state === 'starting' && waited < START_WAIT_MS; waited += 250) {
261    await $.clock.sleep(250)
262    state = server?.state ?? 'stopped'
263    if (state === 'starting' && (await isServing($, config))) state = server.state = 'ready'
264  }
265  return state
266}
267
268async function askProvider($, config, state) {
269  const model = config.provider === 'spisev' ? SPISEV.alias : config.model
270  const call = $.http.fetch(config.endpoint, {
271    method: 'POST', headers: headers(config), body: JSON.stringify(request(state, model)),
272  })
273  const timeout = $.clock.sleep(config.timeoutMs).then(() => null)
274  const response = await Promise.race([call, timeout])
275  if (response === null) throw new Error('timed out after ' + config.timeoutMs + 'ms')
276  if (!response.ok) throw new Error('HTTP ' + response.status + ' ' + response.text.slice(0, 200))
277  return JSON.parse(response.text)
278}
279
280async function perform($, decision, view, prompt) {
281  if (decision.action === 'clear') {
282    // The engine's own /clear, queued for once the session is idle; the prompt is sent after it.
283    await $.command.run({ command: 'clear' })
284    return 'cleared'
285  }
286  const result = await $.session.compact({ instructions: compactInstructions(decision, view, prompt) })
287  if (result?.skip) throw new Error(result.skip)
288  return 'compacted'
289}
290
291async function offer($, decision) {
292  const verb = decision.action === 'clear' ? 'Clear the context first' : 'Compact the context first'
293  const question = 'auto-compact: this prompt looks ' + decision.category.replace('_', ' ') + ' (' +
294    Math.round(decision.confidence * 100) + '%). ' + verb + '?'
295  try {
296    const choice = await $.ui.ask(question, { header: 'auto-compact', options: [verb, 'Send as is'] })
297    return choice === verb
298  } catch {
299    return false // dismissed, or no one to ask (a -p run): nothing happens without the press
300  }
301}
302
303async function onPrompt($, e) {
304  const config = await loadConfig($)
305  if (config.mode === 'off') return
306  if (!config.provider || config.missing.length || config.problems.length) {
307    if (!warned) {
308      warned = true
309      $.ui.status(config.provider
310        ? 'auto-compact: ' + [...config.missing.map((m) => 'set ' + m), ...config.problems].join('; ')
311        : 'auto-compact: run /auto-compact setup to choose a provider')
312    }
313    return
314  }
315  if (building) await Promise.race([building, $.clock.sleep(2000)])
316  const msgs = await sessionMessages($)
317  if (msgs.length < 2) return
318  let view
319  try {
320    view = await treeTexts($, msgs, { allowCalls: false })
321  } catch (error) {
322    if (!(error instanceof Missing)) throw error
323    $.ui.status('auto-compact: summaries catching up')
324    debug($, '[auto-compact] summaries catching up (' + error.message + ' not built) → keep')
325    return
326  }
327  if (config.provider === 'spisev' && (await spisevReady($, config)) !== 'ready') {
328    debug($, '[auto-compact] Spisev ' + server.state + ' → keep')
329    return
330  }
331  const viewText = tree.viewText(view)
332  const prompt = cleanPrompt(e.text) ?? e.text
333  let decision
334  try {
335    decision = decide(await askProvider($, config, clipState(viewText, prompt)), config)
336  } catch (error) {
337    lastError = error.message
338    debug($, '[auto-compact] provider: ' + error.message + ' → keep')
339    return
340  }
341  let outcome = decision.action === 'keep' ? 'keep' : decision.reason === 'suggest' ? 'suggested' : 'keep (' + decision.reason + ')'
342  const done = false
343  const wants = decision.act || (decision.suggest && (await offer($, decision)))
344  lastDecision = { ...decision, outcome }
345  if (wants && e.attachments?.length) {
346    outcome = 'keep (a prompt with attachments is never held back: compact by hand)'
347  } else if (wants) {
348    // $.session.compact can't run under the prompt.submit hook, which holds the turn: the prompt is
349    // held back, the context compacted or cleared once the hook returns, and the same prompt sent again.
350    debug($, logLine(decision, decision.action === 'clear' ? 'clear' : 'compact'))
351    await show($, describe(decision))
352    return { decision, viewText, prompt }
353  }
354  lastDecision = { ...decision, outcome }
355  debug($, logLine(decision, outcome))
356  $.ui.status(undefined)
357  await show($, describe(decision, done))
358  return null
359}
360
361function actThenResend($, plan, text) {
362  $.clock.after(0, async () => {
363    const { decision, viewText, prompt } = plan
364    let outcome
365    try {
366      outcome = await perform($, decision, viewText, prompt)
367      await show($, describe(decision, true))
368    } catch (error) {
369      if (decision.action === 'clear') clearProblem = error.message
370      outcome = 'keep (could not ' + (decision.action === 'clear' ? 'clear' : 'compact') + ': ' + error.message + ')'
371      await show($, describe(decision))
372    }
373    lastDecision = { ...decision, outcome }
374    debug($, '[auto-compact] ' + outcome + ', sending the prompt')
375    resending.add(text)
376    await $.prompt.submit({ text, wait: false })
377  })
378}
379
380async function setup($) {
381  let choice
382  try {
383    choice = await $.ui.ask('Which decision model should spot task boundaries?', {
384      header: 'Provider', options: ['Spisev (local)', ...Object.keys(PRESETS)],
385    })
386  } catch {
387    return { text: 'Setup cancelled; nothing changed.' }
388  }
389  if (choice === 'Spisev (local)') {
390    const config = resolveConfig({ provider: 'spisev', llamaServer: await $.env.get('AUTO_COMPACT_LLAMA_SERVER'),
391      model: await $.env.get('AUTO_COMPACT_MODEL') }, {})
392    const { binary, build } = await findServer($, config)
393    if (build === null || build < SPISEV.minBuild) {
394      return { text: 'llama.cpp build ' + SPISEV.minBuild + ' or later is needed (llama-server or llama on your PATH, ' +
395        'or AUTO_COMPACT_LLAMA_SERVER). Install it from https://github.com/ggml-org/llama.cpp/releases and run /auto-compact setup again.' }
396    }
397    if (!(await isServing($, config)) && !/\.gguf$/i.test(config.model)) {
398      let go
399      try {
400        go = await $.ui.ask('Download ' + config.model + ' (' + SPISEV.sizeLabel + ') and run it with llama.cpp?', {
401          header: 'Download', options: ['Download and start', 'Cancel'],
402        })
403      } catch {
404        go = null
405      }
406      if (go !== 'Download and start') return { text: 'Setup cancelled; nothing changed.' }
407    }
408    await saveConfig($, { provider: 'spisev', endpoint: config.endpoint, model: config.model, llamaServer: binary })
409    await startSpisev($, { ...config, llamaServer: binary })
410    return { text: 'Saved: Spisev (spisev-alpha), served by ' + serverCommand(binary).join(' ') + ' (build ' + build + ') at ' +
411      config.endpoint + '. llama-server runs while this session does. Nothing leaves this machine.' }
412  }
413  const preset = PRESETS[choice]
414  if (!preset) return { text: 'Under Other, use AUTO_COMPACT_ENDPOINT and AUTO_COMPACT_MODEL for any System One endpoint.' }
415  await saveConfig($, { provider: 'systemone', ...preset })
416  const key = await $.env.get('AUTO_COMPACT_API_KEY')
417  return { text: 'Saved: ' + choice + ' at ' + preset.endpoint + ', model ' + preset.model + '. ' +
418    (key ? 'AUTO_COMPACT_API_KEY is set.' : 'Now set AUTO_COMPACT_API_KEY in your environment; the mod never stores it.') +
419    ' Only the clipped state (summary lines and the new message) is sent.' }
420}
421
422async function status($) {
423  const config = await loadConfig($)
424  const lines = ['auto-compact mode: ' + config.mode + (config.mode === 'auto' ? ' (acts at ≥ ' + config.minConfidence + ')' : '')]
425  if (config.provider === 'spisev') lines.push('Provider: Spisev (spisev-alpha) ' + config.model + ' at ' + config.endpoint + ' (' + (server?.state ?? 'stopped') + ')')
426  else if (config.provider) lines.push('Provider: ' + config.endpoint + ' · model ' + config.model + ' · key ' + (config.apiKey ? 'set' : 'unset'))
427  else lines.push('Provider: none, run /auto-compact setup')
428  for (const p of [...config.missing.map((m) => 'set ' + m), ...config.problems]) lines.push('Problem: ' + p)
429  let tree_ = 'not built yet'
430  try {
431    const msgs = await sessionMessages($)
432    await treeTexts($, msgs, { allowCalls: false })
433    tree_ = msgs.length + ' messages, ' + Object.keys(await loadNodes($)).length + ' summaries, up to date'
434  } catch (error) {
435    tree_ = 'catching up (' + error.message + ')'
436  }
437  lines.push('Tree: ' + tree_ + ' · summarizer ' + (await summarizerSettings($)).model)
438  lines.push('Last decision: ' + (lastDecision ? describe(lastDecision, /ed$/.test(lastDecision.outcome)) + ' → ' + lastDecision.outcome : 'none yet'))
439  if (clearProblem) lines.push('Clear falls back to suggest: ' + clearProblem)
440  if (lastError) lines.push('Last error: ' + lastError)
441  return { text: lines.join('\n') }
442}
443
444export function register(on) {
445  on('session.start', async ($, e, next) => {
446    try {
447      await $.command.register({
448        name: 'auto-compact', description: 'Show or set up the task-boundary classifier that offers to compact',
449        argumentHint: '[setup|mode off|suggest|auto]', immediate: true,
450      })
451    } catch (error) {
452      debug($, '[auto-compact] could not register /auto-compact: ' + error.message)
453    }
454    const result = await next(e)
455    const config = await loadConfig($)
456    if (config.mode !== 'off' && config.provider === 'spisev' && !config.problems.length) await startSpisev($, config)
457    return result
458  })
459
460  on('prompt.submit', async ($, e, next) => {
461    // A delivery into a running turn is no new request; slash commands and hidden prompts aren't either.
462    const intoRunningTurn = e.turnId && !USER_ORIGINS.includes(e.origin?.kind)
463    if (intoRunningTurn || e.isMeta || e.command || !e.text.trim()) return next(e)
464    if (resending.delete(e.text)) return next(e)
465    try {
466      const plan = await onPrompt($, e)
467      if (plan) {
468        actThenResend($, plan, e.text)
469        return { drop: 'auto-compact: ' + (plan.decision.action === 'clear' ? 'clearing' : 'compacting') +
470          ' the context, then sending this prompt' }
471      }
472    } catch (error) {
473      debug($, '[auto-compact] ' + error.message + ' → keep')
474    }
475    return next(e)
476  })
477
478  on('turn.complete', async ($, e, next) => {
479    const result = await next(e)
480    if (e.agentId) return result
481    const config = await loadConfig($)
482    if (config.mode === 'off' || !config.provider) return result
483    // Awaited (bounded) so a -p run saves the tree before it exits.
484    building = buildTree($).finally(() => {
485      building = null
486    })
487    await Promise.race([building, $.clock.sleep(BUILD_WAIT_MS)])
488    return result
489  })
490
491  on('session.end', async ($, e, next) => {
492    await saveNodes($)
493    nodes = null // a /clear starts another session id, with a tree of its own
494    // A /clear ends the session but not the process: llama-server keeps serving the next one.
495    if (e.reason !== 'clear') {
496      try {
497        await server?.stream?.return?.()
498      } catch {
499        // Already gone.
500      }
501    }
502    return next(e)
503  })
504
505  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
506    const text = await read($, label)
507    return text ? next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } }) : next(e)
508  })
509
510  on('command.run', { command: 'auto-compact' }, async ($, e) => {
511    const [arg, value] = e.args.trim().toLowerCase().split(/\s+/)
512    if (arg === 'setup') return setup($)
513    if (arg === 'mode') {
514      if (!MODES.includes(value)) return { text: 'Usage: /auto-compact mode off|suggest|auto' }
515      const saved = await saveConfig($, { mode: value })
516      await show($, value === 'off' ? 'auto-compact off' : null)
517      return { text: 'auto-compact mode: ' + value + (saved ? ' (saved for later sessions).' : ' (this session only).') }
518    }
519    if (arg) return { text: 'Usage: /auto-compact [setup|mode off|suggest|auto]' }
520    return status($)
521  })
522}
523
hooks/llama.js 40 lines
1// The Spisev provider: spisev-alpha, the distilled classifier served by llama.cpp's llama-server.
2// Pure helpers, from auto-effort's nisev.js; register.js makes the calls.
3
4import { SPISEV } from './policy.js'
5
6// `llama` is llama.cpp's unified CLI, whose server is `llama serve`; it takes the same flags.
7export function serverCommand(binary) {
8  return /(^|\/)llama$/.test(binary) ? [binary, 'serve'] : [binary]
9}
10
11export function serverArgs(config, binary) {
12  const source = /\.gguf$/i.test(config.model) ? ['-m', config.model] : ['-hf', config.model]
13  return [
14    ...serverCommand(binary), ...source,
15    '--host', '127.0.0.1', '--port', String(config.port),
16    '--alias', SPISEV.alias,
17    // States are at most 12k tokens; one slot keeps the prompt cache for the session's prefix.
18    '-c', '16384', '-np', '1',
19  ]
20}
21
22export function parseBuild(output) {
23  const m = output.match(/build (\d+)/)
24  return m ? Number(m[1]) : null
25}
26
27export function parseServerOutput(text) {
28  if (/listening on/i.test(text)) return { ready: true }
29  const percent = /download/i.test(text) && text.match(/(\d{1,3}(?:\.\d+)?)%/)
30  return percent ? { percent: Math.round(Number(percent[1])) } : {}
31}
32
33export function servesOurModel(text) {
34  try {
35    return (JSON.parse(text).data ?? []).some((m) => m.id === SPISEV.alias)
36  } catch {
37    return false
38  }
39}
40
hooks/policy.js 144 lines
1// Pure decision logic: no `$`, so it can be unit tested on its own.
2// The category -> action table is training/pipeline/policy.py's (tests check both agree).
3
4import { CATEGORIES, QUESTION_ID } from './task.js'
5
6export { CATEGORIES }
7
8export const POLICY = {
9  continuation: 'keep',
10  follow_up: 'keep',
11  related: 'compact_view',
12  independent: 'clear',
13  return: 'compact_branch',
14}
15
16// False positives destroy context: these never act, whatever the confidence.
17export const NEVER_ACT = new Set(['continuation', 'follow_up'])
18
19export const MODES = ['off', 'suggest', 'auto']
20
21export const DEFAULTS = { mode: 'suggest', minConfidence: 0.85, timeoutMs: 4000 }
22
23// Spisev, the local provider: spisev-alpha, the distilled proxy of Mammoth, served by llama.cpp's llama-server.
24export const SPISEV = {
25  model: 'arthur-fontaine/spisev-alpha-GGUF:Q8_0',
26  sizeLabel: 'about 1.9 GB',
27  endpoint: 'http://127.0.0.1:8766/v1/systemone',
28  servers: ['llama-server', 'llama'],
29  alias: 'spisev-alpha',
30  minBuild: 11361,
31}
32
33// Cloud System One endpoints. The key is read from AUTO_COMPACT_API_KEY, never stored.
34export const PRESETS = {
35  'Mammoth (Baseten)': {
36    endpoint: 'https://inference.baseten.co/v1/systemone',
37    model: 'decision-models/baseten-mammoth-27b-latest',
38    auth: 'Api-Key',
39  },
40  'Jev (OpenCode Zen)': { endpoint: 'https://opencode.ai/zen/v1/systemone', model: 'jev-1.13', auth: 'Bearer' },
41}
42
43export function action(category) {
44  return POLICY[category] ?? 'keep'
45}
46
47function pick(...values) {
48  return values.find((v) => v !== undefined && v !== null && v !== '')
49}
50
51function toNumber(value, fallback) {
52  const n = typeof value === 'number' ? value : Number(value)
53  return value !== undefined && value !== null && value !== '' && Number.isFinite(n) ? n : fallback
54}
55
56export function localPort(endpoint) {
57  const m = /^http:\/\/(?:127\.0\.0\.1|localhost):(\d+)\/v1\/systemone$/.exec(endpoint ?? '')
58  return m ? Number(m[1]) : null
59}
60
61// `env` holds the AUTO_COMPACT_* values; `stored` what /auto-compact setup and /auto-compact mode saved (never a key).
62// No default provider: with neither, the mod does nothing.
63export function resolveConfig(env = {}, stored = {}) {
64  const provider = [env.provider, env.endpoint && 'systemone', stored.provider].find((p) => ['spisev', 'systemone'].includes(p))
65  const saved = stored.provider === provider ? stored : {}
66  const spisev = provider === 'spisev'
67  const config = {
68    provider,
69    mode: MODES.includes(env.mode) ? env.mode : MODES.includes(stored.mode) ? stored.mode : DEFAULTS.mode,
70    minConfidence: toNumber(env.minConfidence, DEFAULTS.minConfidence),
71    timeoutMs: toNumber(env.timeoutMs, DEFAULTS.timeoutMs),
72    endpoint: pick(env.endpoint, saved.endpoint, spisev ? SPISEV.endpoint : undefined),
73    model: pick(env.model, saved.model, spisev ? SPISEV.model : undefined),
74    auth: pick(env.auth, saved.auth, 'Bearer'),
75    apiKey: spisev ? undefined : pick(env.apiKey),
76    llamaServer: spisev ? pick(env.llamaServer, saved.llamaServer) : undefined,
77    problems: [],
78    missing: [],
79  }
80  if (spisev) {
81    config.port = localPort(config.endpoint)
82    if (!config.port) config.problems.push('AUTO_COMPACT_ENDPOINT is ' + config.endpoint + ', not a local URL such as ' + SPISEV.endpoint)
83  } else if (provider) {
84    if (!config.endpoint) config.missing.push('AUTO_COMPACT_ENDPOINT')
85    if (!config.model) config.missing.push('AUTO_COMPACT_MODEL')
86    if (!config.apiKey) config.missing.push('AUTO_COMPACT_API_KEY')
87  }
88  return config
89}
90
91export function headers(config) {
92  const h = { 'Content-Type': 'application/json' }
93  if (config.apiKey) h.Authorization = (config.auth === 'Api-Key' ? 'Api-Key ' : 'Bearer ') + config.apiKey
94  return h
95}
96
97// What to do with a provider's answer. `act` is true only for an action this mode may take
98// without asking (auto, confident enough); `suggest` is true when the user should be offered it.
99export function decide(response, config) {
100  const answer = (response?.answers ?? response?.result?.answers)?.[QUESTION_ID]
101  if (!answer || answer.type !== 'choice' || !CATEGORIES.includes(answer.choice)) {
102    return { category: null, confidence: null, action: 'keep', act: false, suggest: false, reason: 'no answer' }
103  }
104  const category = answer.choice
105  const confidence = Number(answer.probabilities?.[category] ?? answer.confidence ?? 0)
106  const wanted = action(category)
107  const base = { category, confidence, action: wanted, probabilities: answer.probabilities ?? {} }
108  if (NEVER_ACT.has(category) || wanted === 'keep') return { ...base, action: 'keep', act: false, suggest: false, reason: 'keep' }
109  if (config.mode === 'off') return { ...base, act: false, suggest: false, reason: 'off' }
110  if (config.mode === 'auto' && confidence >= config.minConfidence) return { ...base, act: true, suggest: false, reason: 'auto' }
111  if (config.mode === 'auto') return { ...base, act: false, suggest: false, reason: 'low confidence' }
112  return { ...base, act: false, suggest: true, reason: 'suggest' }
113}
114
115const pct = (x) => Math.round(x * 100) + '%'
116const VERB = { compact_view: 'compact', compact_branch: 'compact', clear: 'clear' }
117const DONE = { compact_view: 'compacted', compact_branch: 'compacted', clear: 'cleared' }
118
119// The footer label, like auto-effort's `effort high · 90%`: `context kept · 92%`,
120// `compact? related · 71%`, `cleared · independent · 91%`.
121export function describe(decision, done = false) {
122  if (!decision?.category) return 'context kept (' + (decision?.reason ?? 'no answer') + ')'
123  if (decision.action === 'keep') return 'context kept · ' + pct(decision.confidence)
124  if (done) return DONE[decision.action] + ' · ' + decision.category + ' · ' + pct(decision.confidence)
125  return VERB[decision.action] + '? ' + decision.category + ' · ' + pct(decision.confidence)
126}
127
128// The debug log line: `[auto-compact] <category> · <confidence> → <action>`.
129export function logLine(decision, outcome) {
130  return '[auto-compact] ' + (decision.category ?? 'none') + ' · ' +
131    (decision.confidence === null || decision.confidence === undefined ? 'n/a' : decision.confidence.toFixed(2)) +
132    ' → ' + outcome
133}
134
135export function compactInstructions(decision, viewText, prompt) {
136  if (decision.action === 'compact_branch') {
137    return 'The next message returns to an earlier task of this conversation: keep that task, its files, ' +
138      'decisions and open questions in full, and summarize the rest briefly. The next message:\n' + prompt +
139      '\n\nThe conversation, one summary line per span:\n' + viewText
140  }
141  return 'The next message starts a related task: keep the general picture of the project (repo, files, ' +
142    'decisions, conventions) these lines describe, and drop the details of the finished work.\n' + viewText
143}
144
hooks/summarizer.js 41 lines
1// The mod's summarizer: the same request as training/pipeline/summarizer.py, sent with
2// $.model.complete through the user's own session. System prompt = prompts/compact.md, one
3// user message (a leaf, a merge, or a "too long" retry written into one message), the model
4// pinned by full id (never the `haiku` alias), the same effort and output cap.
5
6import { LIMIT, RETRIES, shortest } from './tree.js'
7import { utf8Len } from './text.js'
8import { retryPrompt } from './tree.js'
9
10export const SUMMARIZER = { model: 'claude-haiku-5-5', effort: 'low', maxTokens: 4096 }
11export const PROMPT_PATH = 'prompts/compact.md'
12
13export function summaryRequest(prompt, system, settings = SUMMARIZER) {
14  return { model: settings.model, system, prompt, effort: settings.effort, maxTokens: settings.maxTokens }
15}
16
17// One node: (line, calls). `complete` is $.model.complete; a usage limit or an API error throws.
18export async function summarize(complete, prompt, system, settings = SUMMARIZER) {
19  const lines = []
20  let ask = prompt
21  let calls = 0
22  for (let attempt = 0; attempt <= RETRIES; attempt++) {
23    const r = await complete(summaryRequest(ask, system, settings))
24    calls++
25    if (!r.isAnswered) {
26      const error = new Error('summary call failed: ' + r.reason + (r.status ? ' ' + r.status : '') +
27        (r.error ? ' ' + r.error : ''))
28      error.isLimit = r.status === 429 || r.error === 'rate_limit'
29      throw error
30    }
31    const line = (r.text ?? '').trim()
32    if (line) {
33      lines.push(line)
34      if (utf8Len(line) <= LIMIT) break
35      ask = retryPrompt(prompt, line)
36    }
37  }
38  if (!lines.length) throw new Error('the summarizer returned no text')
39  return { line: shortest(lines), calls }
40}
41
hooks/task.js 63 lines
1// The decision task, as training/pipeline/task.py builds it: the view and the new message,
2// clipped to the same token and byte budgets, and the question spisev-alpha was trained on.
3
4import { clip, estimateTokens, pyJson } from './text.js'
5
6export const QUESTION_ID = 'boundary'
7export const CATEGORIES = ['continuation', 'follow_up', 'related', 'independent', 'return']
8
9export const INSTRUCTIONS =
10  'conversation is a coding session between a developer and an AI coding agent, as one-line summaries ' +
11  '(id+n|summary: the n messages from id on), oldest first; recent lines cover one message, older lines ' +
12  "cover more. latest_user_message is the developer's new message. How does it relate to the work in the " +
13  'conversation? The answer decides how much of the conversation the agent keeps in its context.'
14
15export const CRITERIA = {
16  continuation: 'Carries on the current task directly: answers the agent, approves, corrects, or asks for ' +
17    'the next step of the same work.',
18  follow_up: 'Builds on the task just done or in progress: a fix, refinement, test or extension that needs ' +
19    'its details.',
20  related: 'A new task in the same project or area: it needs the general picture (repo, files, decisions), ' +
21    'not the details of the recent work.',
22  independent: 'Unrelated to the conversation: a new topic or task that needs none of it.',
23  return: 'Goes back to an earlier task of the conversation, set aside since, rather than the recent work.',
24}
25
26export const MAX_TOKENS = 12_000
27export const MAX_MESSAGE_BYTES = 24_000
28export const MAX_CONVERSATION_BYTES = 20_000
29
30export function question() {
31  return { type: 'choice', instructions: INSTRUCTIONS, criteria: { ...CRITERIA } }
32}
33
34const fixedTokens = () => estimateTokens(pyJson({ questions: { [QUESTION_ID]: question() } }))
35
36export function sentTokens(state) {
37  return estimateTokens(pyJson({ state, questions: { [QUESTION_ID]: question() } }))
38}
39
40// Decision servers refuse a state holding chat or media tokens (`<|im_start|>`): break them.
41export function neutralize(text) {
42  return text.replace(/<\|([A-Za-z0-9_]+)\|>/g, '< |$1| >')
43}
44
45export function clipState(conversation, message) {
46  conversation = clip(neutralize(conversation), MAX_CONVERSATION_BYTES)
47  message = neutralize(message)
48  let room = MAX_TOKENS - fixedTokens() - estimateTokens(pyJson(conversation)) - 16
49  const state = { conversation, latest_user_message: clip(message, MAX_MESSAGE_BYTES, Math.max(room, 64)) }
50  for (let i = 0; i < 4; i++) {
51    const over = sentTokens(state) - MAX_TOKENS
52    if (over <= 0) break
53    room -= over
54    state.latest_user_message = clip(message, MAX_MESSAGE_BYTES, Math.max(room, 64))
55  }
56  return state
57}
58
59// State first, then the fixed question, so a server reuses the cached prefix of earlier requests.
60export function request(state, model) {
61  return { model, state, questions: { [QUESTION_ID]: question() } }
62}
63
hooks/text.js 124 lines
1// Text rules shared with the training pipeline (training/pipeline/text.py): the same clip,
2// token estimate, prompt filter and message grouping, so serving reads a session the way
3// training did. tests/parity.test.ts checks both against fixtures the Python generates.
4
5export const MARKER = '\n[… truncated …]\n'
6export const BYTES_PER_TOKEN = 3
7export const MAX_AGENT_BYTES = 16_000
8export const MAX_TOOL_INPUT_BYTES = 300
9
10const SKIP_PREFIXES = [
11  '<local-command-', '<command-name>', '<command-message>', '<bash-input>', '<bash-stdout>',
12  '<bash-stderr>', '<task-notification>', '[Request interrupted', 'Caveat:', '<system-reminder>',
13  'This session is being continued', '[REDACTED_CONFIG_DUMP', '<ci_monitor_event>', '<ci-monitor-event>',
14  '[task_started]', '[task_notification]', '[task_progress]', '[init]', '[result]',
15]
16
17const encoder = new TextEncoder()
18
19export function utf8Len(text) {
20  return encoder.encode(text).length
21}
22
23// Never below one token per word, digit, whitespace run or other character: code runs near 2 bytes a token.
24const PIECES = /[A-Za-z]+|[0-9]|\s+|[^A-Za-z0-9\s]/gu
25
26export function estimateTokens(text) {
27  return Math.max(Math.ceil(utf8Len(text) / BYTES_PER_TOKEN), (text.match(PIECES) ?? []).length)
28}
29
30function head(chars, limit) {
31  let size = 0
32  let n = 0
33  for (const c of chars) {
34    const len = utf8Len(c)
35    if (size + len > limit) break
36    size += len
37    n++
38  }
39  return chars.slice(0, n)
40}
41
42// Cut in the middle to fit both budgets, keeping the start and the end, never inside a code point.
43export function clip(text, maxBytes, maxTokens) {
44  text = text ?? ''
45  const limit = maxTokens === undefined || maxTokens === null ? maxBytes : Math.min(maxBytes, maxTokens * BYTES_PER_TOKEN)
46  if (utf8Len(text) <= limit) return text
47  const half = Math.max(0, Math.floor((limit - utf8Len(MARKER)) / 2))
48  const chars = Array.from(text)
49  const start = head(chars, half).join('')
50  const end = head([...chars].reverse(), half).reverse().join('')
51  return start + MARKER + end
52}
53
54export function cleanPrompt(text) {
55  text = (text ?? '').trim()
56  const query = /<user_query>\s*(.*?)\s*<\/user_query>/s.exec(text)
57  if (query) text = query[1]
58  text = text
59    .replace(/<timestamp>.*?<\/timestamp>/gs, '')
60    .replace(/<system-reminder>.*?<\/system-reminder>/gs, '')
61    .replace(/<image_files>.*?<\/image_files>/gs, '')
62    .replace(/<ide_(opened_file|selection|diagnostics)>.*?<\/ide_\1>/gs, '')
63    .replace(/^(\[Image[^\]]*\]\s*)+/, '')
64    .trim()
65  if (!text || SKIP_PREFIXES.some((p) => text.startsWith(p))) return null
66  return text
67}
68
69export function commandOf(text) {
70  const m = /<command-name>\/(clear|compact)<\/command-name>/.exec(text ?? '')
71  return m ? m[1] : null
72}
73
74// JSON as Python's json.dumps writes it, so byte counts and budgets agree.
75export function pyJson(value, { compact = false, sortKeys = false } = {}) {
76  const itemSep = compact ? ',' : ', '
77  const keySep = compact ? ':' : ': '
78  const walk = (v) => {
79    if (v === null || v === undefined) return 'null'
80    if (Array.isArray(v)) return '[' + v.map(walk).join(itemSep) + ']'
81    if (typeof v === 'object') {
82      const keys = Object.keys(v)
83      if (sortKeys) keys.sort()
84      return '{' + keys.map((k) => JSON.stringify(k) + keySep + walk(v[k])).join(itemSep) + '}'
85    }
86    if (typeof v === 'boolean') return v ? 'true' : 'false'
87    return JSON.stringify(v)
88  }
89  return walk(value)
90}
91
92export function toolLine(use) {
93  if (use.line) return use.line
94  const args = pyJson(use.input ?? {}, { compact: true, sortKeys: true })
95  const line = 'tool: ' + (use.tool || '?') + ' ' + clip(args, MAX_TOOL_INPUT_BYTES)
96  return line + (use.isError ? ' (error)' : '')
97}
98
99// Tree messages from $.session.messages() rows: each human prompt, and everything the agent
100// did between two prompts as one `agent` message. Tool results are dropped.
101export function messages(rows) {
102  const out = []
103  rows.forEach((r, i) => {
104    if (r.role === 'user') {
105      if (r.toolResults?.length) return
106      const prompt = cleanPrompt(r.text)
107      if (prompt !== null) out.push({ kind: 'user', text: prompt, row: i })
108      return
109    }
110    const parts = []
111    if ((r.text ?? '').trim()) parts.push('agent: ' + r.text.trim())
112    for (const u of r.toolUses ?? []) parts.push(toolLine(u))
113    if (!parts.length) return
114    const last = out[out.length - 1]
115    if (last && last.kind === 'agent') last.text += '\n' + parts.join('\n')
116    else out.push({ kind: 'agent', text: parts.join('\n'), row: i })
117  })
118  return out
119}
120
121export function render(message) {
122  return clip(message.kind === 'user' ? 'user: ' + message.text : message.text, MAX_AGENT_BYTES)
123}
124
hooks/tree.js 102 lines
1// The OptChat summary tree (training/pipeline/tree.py, same code): node(l, i) covers the 2^l
2// messages from i·2^l on, in at most 512 bytes. The view at time T keeps the last `leaves`
3// messages one line each and merges older pairs, most due first, down to `leaves + coarse` lines.
4
5import { utf8Len } from './text.js'
6
7export const LIMIT = 512
8export const RETRIES = 5
9export const STATE = { leaves: 16, coarse: 16 }
10export const CONTEXT = { leaves: 8, coarse: 8 }
11export const RULER_NOTE = '| ← LIMIT'
12
13export const key = ([level, index]) => level + ':' + index
14
15export function name([level, index]) {
16  return (index * 2 ** level) + '+' + 2 ** level
17}
18
19export function span([level, index]) {
20  return [index * 2 ** level, (index + 1) * 2 ** level]
21}
22
23function moreDue([la, lastA], [lb, lastB], t) {
24  return (t - lastA) * 2 ** lb > (t - lastB) * 2 ** la
25}
26
27export function view(t, leaves = STATE.leaves, coarse = STATE.coarse) {
28  const lines = Array.from({ length: t }, (_, i) => [0, i])
29  const tail = Math.max(0, t - leaves)
30  while (lines.length > leaves + coarse) {
31    let best = null
32    for (let k = 0; k < lines.length - 1; k++) {
33      const [l1, i1] = lines[k]
34      const [l2, i2] = lines[k + 1]
35      if (l1 !== l2 || i1 % 2 || i2 !== i1 + 1) continue
36      const last = span(lines[k + 1])[1] - 1
37      if (last >= tail) continue
38      if (best === null || moreDue([l1, last], best.due, t)) best = { k, due: [l1, last] }
39    }
40    if (best === null) break
41    const [level, index] = lines[best.k]
42    lines.splice(best.k, 2, [level + 1, Math.floor(index / 2)])
43  }
44  return lines
45}
46
47export function children([level, index]) {
48  return level ? [[level - 1, 2 * index], [level - 1, 2 * index + 1]] : []
49}
50
51export function contextView(node) {
52  return view(span(node)[0], CONTEXT.leaves, CONTEXT.coarse)
53}
54
55// Every node the views need, each after what it depends on.
56export function needed(views) {
57  const seen = new Set()
58  const order = []
59  const visit = (node) => {
60    if (seen.has(key(node))) return
61    seen.add(key(node))
62    for (const dep of [...children(node), ...contextView(node)]) visit(dep)
63    order.push(node)
64  }
65  for (const v of views) for (const node of v) visit(node)
66  return order
67}
68
69export function viewText(lines) {
70  return lines.map(([node, text]) => name(node) + '|' + text.split(/\s+/).filter(Boolean).join(' ')).join('\n')
71}
72
73export function leafPrompt(index, rendered, context) {
74  return '<chat>\n' + viewText(context) + '\n</chat>\n' +
75    'Compress message ' + index + ' into one line.\n' +
76    '<input>\n' + rendered + '\n</input>'
77}
78
79export function mergePrompt(a, b, textA, textB, context) {
80  return '<chat>\n' + viewText(context) + '\n</chat>\n' +
81    'Merge lines ' + name(a) + ' and ' + name(b) + ', adjacent, into one line.\n' +
82    '<input>\n' + textA + '\n' + textB + '\n</input>'
83}
84
85export function retryPrompt(prompt, line) {
86  const bytes = new TextEncoder().encode(line).slice(0, LIMIT)
87  const cut = new TextDecoder('utf-8', { fatal: false }).decode(bytes).replace(/�+$/, '')
88  return prompt + '\n\nToo long: your line is ' + utf8Len(line) + ' bytes, over the ' + LIMIT + '-byte limit. ' +
89    'Write the whole line again for the same <input>, cutting just enough of the least valuable ' +
90    'items to fit before this cut:\n' + cut + RULER_NOTE
91}
92
93export function noCallText(node, sources) {
94  if (node[0] === 0) return utf8Len(sources) <= LIMIT ? sources : null
95  const joined = sources[0] + '\n' + sources[1]
96  return utf8Len(joined) <= LIMIT ? joined : null
97}
98
99export function shortest(lines) {
100  return lines.reduce((a, b) => (utf8Len(b) < utf8Len(a) ? b : a))
101}
102
types/index.d.ts 8 lines
1export type AutoCompactLabel = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'auto-compact': { label: AutoCompactLabel }
6  }
7}
8