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

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.
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.
/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.AUTO_COMPACT_API_KEY in the environment and never stored. Any other System One endpoint works with AUTO_COMPACT_ENDPOINT and AUTO_COMPACT_MODEL.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.
/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>.
| 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 | |
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.
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:
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% | | |
What stands out:
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.
pnpm test # claude plugin test
pnpm validate # claude plugin validate . --strict
pnpm eval # the configured provider on sample decisions (reads .env)
claude --plugin-dir .hooks/register.js 523 lines1import { 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}
523hooks/llama.js 40 lines1// 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}
40hooks/policy.js 144 lines1// 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}
144hooks/summarizer.js 41 lines1// 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}
41hooks/task.js 63 lines1// 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}
63hooks/text.js 124 lines1// 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}
124hooks/tree.js 102 lines1// 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}
102types/index.d.ts 8 lines1export type AutoCompactLabel = string | null
2
3declare module 'claude-code' {
4 interface PluginState {
5 'auto-compact': { label: AutoCompactLabel }
6 }
7}
8