Context waste analyzer for Claude Code: what fills the window, re-reads, near-duplicate outputs, what compaction kept, advice

English | 한국어
A mod that shows what fills the context window in a Claude Code session, why the same information comes back in again, and what compaction kept and lost. It only observes: it never changes rows, tool calls or compaction.
/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install context-inspector@my-claude-code-mods
Requires Claude Code 2.1.287 or later (mods enabled by default). Older builds need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
| Command | What it does | ||||||
|---|---|---|---|---|---|---|---|
| `/context-inspect [overview\ | occupants\ | rereads\ | similar\ | compactions\ | efficiency\ | advice] [--agents]` | Text report. Defaults to the overview of the main context; --agents includes subagents |
/context-pane | Live pane (terminal, desktop app) | ||||||
/context-export | Saves metadata only, no raw content, as JSONL: <outputDir>/<sessionId>/context-<load>-<n>.jsonl |
Status line: ctx 62% · waste≈8.1k (window occupancy is measured, waste is estimated). Waste is the waste still in the window now — it shrinks when compaction drops duplicate copies. The whole-session re-read cost stays in session ≈… of /context-inspect rereads.
| Grade | Examples |
|---|---|
| measured | Input tokens the API reported for each request (uncached + cache read + cache write), tokens before/after compaction |
| observed | Character counts and hashes of rows that entered the conversation, message sets before/after compaction |
estimated (≈) | Per-item tokens = chars/4 (images 1,500) × calibration factor k (calibrated against measured input growth, between 0.5 and 2.0; the report shows (clamped) when it hits a bound) |
Per-token attention inside the model and exact occupancy are not visible from outside, so the mod does not claim them.
attachment:<name>, prompt/response) and by target (file, command).engine-deduped (the engine replaced it with an "unchanged" notice), restored (the engine re-attached it after compaction), different-range, after-edit, after-compaction, changed-externally, unchanged-live (the earlier copy is still in the window = pure waste).#, so test outputs that differ only in run time are grouped. Comparisons and window waste are computed separately per context. Outputs longer than 262,144 characters or 20,000 normalized words participate in exact matching only; a partial sample cannot justify whole-output near-duplicate waste.| Key | Default | Meaning |
|---|---|---|
outputDir | ~/.claude/context-inspector | Where /context-export writes |
toastAdvice | true | One toast per session the first time a high-priority piece of advice appears |
One JSON object per line: meta, item (seq, ctx, turn, door, category, target, chars, est, hash, simhash, born, epoch, live …), edit, sample, compaction, summary. No raw content. Non-file targets (commands, patterns, URLs and agent descriptions) are cut to 200 characters after best-effort secret masking. Masking covers secret environment assignments, Authorization/Bearer credentials, --password/--token/--api-key/--secret options, headers ending in -key/-token/-secret, URL passwords, curl -u/--user credentials, mysql -p passwords and common JSON credential fields. Shell-quoted headers treat escaped quotes as part of the value. File paths are retained unchanged. This is not a complete secret detector: review exports before sharing. Existing export files are not retroactively sanitized by an update; review or remove older files, and rotate any real credentials that were exposed. Errors and user identifiers among compaction entities, and item contents, are kept only as hashes (unsalted; short values can be confirmed by guessing) — this is not anonymization. summary carries wasteEst (window now) together with currentWasteEst and sessionRereadEst (re-read waste: window now / session total).
| Environment | Behavior |
|---|---|
| Terminal | Status line, pane, toasts and commands |
| VS Code chat panel | Hooks and command text only (the mod's UI is not shown) — /context-inspect is the main output |
claude -p | Recording only; commands can run with -p "/context-inspect" |
/clear, the ledger is cleared and only the main conversation is recovered from $.session.messages() (no attachments, subagents or timing). /clear is detected from session.end (reason clear) without a session.start.$.session.messages() does not provide one). So the first compaction after a reload counts all of them as "dropped". Tool results keep matching by tool_use_id.-<n> (a single file is context-<load>-1.jsonl). Running /context-export again within the same load overwrites that load's files with a full new snapshot.Reports, panels and display labels mask supported credential formats before truncation; this does not change the underlying analysis. Existing saved files are not rewritten.
CLAUDE292=~/.vscode/extensions/anthropic.claude-code-2.1.292-darwin-arm64/resources/native-binary/claude
"$CLAUDE292" plugin test context-inspector
cp -R agent-profiler/.claude-plugin/types context-inspector/.claude-plugin/types # once, for tsc
npx -y -p typescript@5 tsc -p context-inspector --noEmit
"$CLAUDE292" plugin validate context-inspectorhooks/register.tsx 236 lines1import type { EngineInterface, PluginOptions, Register } from 'claude-code'
2
3import type { BreakdownLite } from './core/advice.ts'
4import { chunkLines, exportLines } from './core/export.ts'
5import { fmtCount } from './core/format.ts'
6import { backfill, ingestAppend, needsBackfill, ingestCompaction, ingestToolCall, ingestToolResult } from './core/ingest.ts'
7import { Ledger } from './core/ledger.ts'
8import { paneLines, parseArgs, renderReport, statusLine, type UsageLite } from './core/report.ts'
9import { summarize, type Scope, type Summary } from './core/summary.ts'
10import { inputOf } from './core/tokens.ts'
11import { MAIN } from './core/types.ts'
12
13const VERSION = '0.1.3'
14const PANE = 'context-inspector'
15const COMMANDS = [
16 {
17 name: 'context-inspect',
18 description: 'Show what fills the context window and what is wasted: [overview|occupants|rereads|similar|compactions|efficiency|advice] [--agents]',
19 },
20 { name: 'context-pane', description: 'Open the live Context Inspector pane' },
21 { name: 'context-export', description: "Write this session's context ledger as JSONL (metadata only, no content)" },
22]
23// Names this module load's export files, so a reload never overwrites an earlier load's.
24const LOAD_ID = Date.now().toString(36)
25
26let ledger = new Ledger()
27let percent: number | undefined
28let paintQueued = false
29let toasted = false
30const cache = new Map<Scope, { ledger: Ledger; version: number; sum: Summary }>()
31
32// The inspector never breaks the session: every bookkeeping step goes through here.
33function safe(fn: () => void): void {
34 try {
35 fn()
36 } catch {
37 // dropped on purpose
38 }
39}
40
41function summary(scope: Scope = 'main'): Summary {
42 const hit = cache.get(scope)
43 if (hit && hit.ledger === ledger && hit.version === ledger.version) return hit.sum
44 const sum = summarize(ledger.snapshot(), scope)
45 cache.set(scope, { ledger, version: ledger.version, sum })
46 return sum
47}
48
49// A new conversation in this process (start, reload, resume, /clear): forget the old one and read back,
50// off the calling path, what the new one already holds. Rows that arrive before the read-back are kept.
51function reset($: EngineInterface): void {
52 const own = new Ledger()
53 ledger = own
54 cache.clear()
55 toasted = false
56 percent = undefined
57 safe(() =>
58 $.clock.after(0, async () => {
59 try {
60 const messages = await $.session.messages()
61 if (own === ledger && needsBackfill(own) && messages.length > 0) backfill(own, messages)
62 } catch {
63 // nothing to read back
64 }
65 }),
66 )
67}
68
69// Paints are queued behind the event that asked, at most one pending.
70function paint($: EngineInterface, options: PluginOptions): void {
71 if (paintQueued) return
72 paintQueued = true
73 try {
74 $.clock.after(0, async () => {
75 paintQueued = false
76 try {
77 percent = (await $.session.usage()).context.percent
78 } catch {
79 // keep the last figure
80 }
81 safe(() => {
82 const sum = summary()
83 $.ui.status(statusLine(sum, percent))
84 $.ui.invalidate('ui.render')
85 const high = sum.advice.find(a => a.severity === 'high')
86 if (high && !toasted && options.toastAdvice !== false) {
87 toasted = true
88 $.ui.toast(`context-inspector: ${high.title} (saves ≈${fmtCount(high.savings)}) — /context-inspect advice`)
89 }
90 })
91 })
92 } catch {
93 paintQueued = false
94 }
95}
96
97const reportFailed = (error: unknown): string => `context-inspector: report failed: ${error instanceof Error ? error.message : String(error)}`
98
99async function measured($: EngineInterface): Promise<{ usage?: UsageLite; breakdown?: BreakdownLite }> {
100 try {
101 const u = await $.session.usage({ breakdown: 'summary' })
102 const b = u.context.breakdown
103 return {
104 usage: { percent: u.context.percent, tokens: u.context.tokens, window: u.context.window },
105 breakdown: b
106 ? {
107 mcpTools: b.mcpTools.map(t => ({ name: t.name, serverName: t.serverName, tokens: t.tokens, isLoaded: t.isLoaded })),
108 memoryFiles: b.memoryFiles.map(f => ({ path: f.path, tokens: f.tokens })),
109 }
110 : undefined,
111 }
112 } catch {
113 return {}
114 }
115}
116
117async function outputDir($: EngineInterface, options: PluginOptions): Promise<string> {
118 const dir = (String(options.outputDir ?? '').trim() || '~/.claude/context-inspector').replace(/\/+$/, '')
119 if (!/^~(?=\/|$)/.test(dir)) return dir
120 return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
121}
122
123async function exportLedger($: EngineInterface, options: PluginOptions): Promise<string> {
124 let sessionId = 'unknown'
125 try {
126 sessionId = await $.session.id()
127 } catch {
128 // files go under 'unknown'
129 }
130 const dir = `${await outputDir($, options)}/${sessionId}`
131 const snap = ledger.snapshot()
132 const chunks = chunkLines(exportLines(snap, summarize(snap, 'all'), { sessionId, version: VERSION, at: new Date().toISOString() }))
133 const written: string[] = []
134 for (const [i, body] of chunks.entries()) {
135 const path = `${dir}/context-${LOAD_ID}-${i + 1}.jsonl`
136 await $.fs.write(path, body)
137 written.push(path)
138 }
139 return `wrote ${written.join(', ')}`
140}
141
142export const register: Register = (on, options) => {
143 on('session.start', async ($, e, next) => {
144 safe(() => reset($))
145 for (const command of COMMANDS) {
146 try {
147 await $.command.register(command)
148 } catch {
149 // the command stays unavailable
150 }
151 }
152 return next(e)
153 })
154
155 // After /clear (or when another session takes this one's place) the process goes on under a new
156 // session id and no session.start fires: this is where the old conversation's ledger is dropped.
157 on('session.end', async ($, e, next) => {
158 const result = await next(e)
159 if (e.reason === 'clear' || e.reason === 'resume') safe(() => reset($))
160 return result
161 }).catch(($, e, next) => next(e))
162
163 on('session.append', async ($, e, next) => {
164 safe(() => ingestAppend(ledger, e))
165 return next(e)
166 }).catch(($, e, next) => next(e))
167
168 on('tool.call', async ($, e, next) => {
169 const id = e.tool_use_id
170 if (id !== undefined) safe(() => ingestToolCall(ledger, id, String(e.tool), e.agentId, e as unknown as Record<string, unknown>))
171 const answer = await next(e)
172 if (id !== undefined) safe(() => ingestToolResult(ledger, id, answer))
173 return answer
174 }).catch(($, e, next) => next(e))
175
176 on('turn.step', async function* ($, e, next) {
177 for await (const chunk of next(e)) {
178 if (chunk.kind === 'stop') safe(() => ledger.sample(e.agentId ?? MAIN, inputOf(chunk.usage)))
179 yield chunk
180 }
181 })
182
183 on('session.compact', async ($, e, next) => {
184 const result = await next(e)
185 safe(() => ingestCompaction(ledger, e, result))
186 paint($, options)
187 return result
188 }).catch(($, e, next) => next(e))
189
190 on('turn.complete', async ($, e, next) => {
191 paint($, options)
192 return next(e)
193 })
194
195 on('command.run', { command: 'context-inspect' }, async ($, e) => {
196 const { section, scope, unknown } = parseArgs(e.args)
197 const { usage, breakdown } = await measured($)
198 try {
199 const text = renderReport(summarize(ledger.snapshot(), scope, breakdown), section, usage)
200 return { text: unknown ? `unknown section '${unknown}'; showing the overview\n\n${text}` : text }
201 } catch (error) {
202 return { text: reportFailed(error) }
203 }
204 })
205
206 on('command.run', { command: 'context-pane' }, async $ => {
207 await $.ui.open({ id: PANE, title: 'Context Inspector' })
208 return { text: 'Context Inspector pane opened (shown in the terminal and the desktop app).' }
209 })
210
211 on('command.run', { command: 'context-export' }, async $ => {
212 try {
213 return { text: await exportLedger($, options) }
214 } catch (error) {
215 return { text: `export failed: ${String(error)}` }
216 }
217 })
218
219 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220 const { Box, Text } = $.ui.resolve(e)
221 let lines
222 try {
223 lines = paneLines(summary(), e.props.bodyColumns ?? 60)
224 } catch (error) {
225 return <Text>{reportFailed(error)}</Text>
226 }
227 return (
228 <Box flexDirection="column">
229 {lines.map(line => (
230 <Text bold={line.bold}>{line.text || ' '}</Text>
231 ))}
232 </Box>
233 )
234 })
235}
236hooks/core/advice.ts 168 lines1import type { CompactionView } from './analyze/compaction.ts'
2import type { Rereads } from './analyze/rereads.ts'
3import type { Cluster } from './analyze/similar.ts'
4import { fmtCount } from './format.ts'
5import type { ContextItem } from './types.ts'
6import { redactSecrets } from './redact.ts'
7
8export type Severity = 'high' | 'medium' | 'low'
9export type Advice = { id: string; severity: Severity; title: string; evidence: string; action: string; savings: number }
10export type BreakdownLite = {
11 mcpTools: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
12 memoryFiles: { path: string; tokens: number }[]
13}
14export type AdviceInput = {
15 items: readonly ContextItem[]
16 k: number
17 rereads: Rereads
18 clusters: readonly Cluster[]
19 compactions: readonly CompactionView[]
20 breakdown?: BreakdownLite
21}
22
23const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 }
24
25export function severityOf(savings: number): Severity {
26 return savings >= 10_000 ? 'high' : savings >= 3_000 ? 'medium' : 'low'
27}
28
29const tok = (n: number): string => `≈${fmtCount(n)}`
30const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
31const short = (s: string): string => {
32 const line = redactSecrets(s).replace(/\s+/g, ' ').trim()
33 return line.length > 40 ? `${line.slice(0, 39)}…` : line
34}
35
36function list<T>(xs: readonly T[], show: (x: T) => string, n = 3): string {
37 const head = xs.slice(0, n).map(show).join(', ')
38 return xs.length > n ? `${head}, +${xs.length - n} more` : head
39}
40
41// Rule-based recommendations (spec §4.6); every one names its evidence and an estimated saving.
42export function advise(input: AdviceInput): Advice[] {
43 const { items, k, rereads, clusters, compactions, breakdown } = input
44 const est = (i: ContextItem): number => Math.round(i.est * k)
45 const out: Advice[] = []
46 const push = (a: Omit<Advice, 'severity'>, floor?: Severity): void => {
47 let severity = severityOf(a.savings)
48 if (floor && RANK[floor] < RANK[severity]) severity = floor
49 out.push({ ...a, severity })
50 }
51
52 const rereadFiles = rereads.files.filter(
53 f => (f.classes['unchanged-live'] ?? 0) > 0 && (f.wasteEst >= 2_000 || f.reads - (f.classes['engine-deduped'] ?? 0) >= 3),
54 )
55 if (rereadFiles.length > 0)
56 push({
57 id: 'reread-unchanged',
58 title: 'Unchanged files were read again while the earlier copy was still in context',
59 evidence: list(rereadFiles, f => `${base(f.path)} ×${f.reads} (${tok(f.wasteEst)})`),
60 action: 'Refer to the earlier result; for one part, Grep or Read with offset/limit',
61 savings: rereadFiles.reduce((s, f) => s + f.wasteEst, 0),
62 })
63
64 const bigReads = items.filter(i => i.category === 'Read' && i.target?.full === true && est(i) >= 8_000 && !rereads.wasteSeqs.has(i.seq))
65 if (bigReads.length > 0) {
66 const byPath = new Map<string, { count: number; total: number }>()
67 for (const i of bigReads) {
68 const g = byPath.get(i.target!.value) ?? { count: 0, total: 0 }
69 g.count += 1
70 g.total += est(i)
71 byPath.set(i.target!.value, g)
72 }
73 const groups = [...byPath.entries()].sort((x, y) => y[1].total - x[1].total)
74 push({
75 id: 'large-read',
76 title: 'Whole large files read into context',
77 evidence: list(groups, ([path, g]) => `${base(path)}${g.count > 1 ? ` ×${g.count}` : ''} (${tok(g.total)})`),
78 action: 'Grep for the symbol first, then Read with offset/limit',
79 savings: bigReads.reduce((s, i) => s + est(i) - 2_000, 0),
80 })
81 }
82
83 const bigBash = items.filter(i => i.category === 'Bash' && est(i) >= 5_000).sort((a, b) => b.est - a.est)
84 if (bigBash.length > 0)
85 push({
86 id: 'large-bash',
87 title: 'Long command output',
88 evidence: list(bigBash, i => `${short(i.target?.value ?? 'Bash')} (${tok(est(i))})`),
89 action: 'Pipe through tail or grep, or use quiet flags (-q, --silent)',
90 savings: bigBash.reduce((s, i) => s + est(i) - 1_000, 0),
91 })
92
93 const repeated = clusters.filter(c => c.wasteEst > 0 && (c.size >= 3 || c.wasteEst >= 3_000))
94 if (repeated.length > 0)
95 push({
96 id: 'repeated-output',
97 title: 'Near-identical tool output repeated',
98 evidence: list(repeated, c => `${c.category} ${short(c.label)} ×${c.size} (${tok(c.wasteEst)})`),
99 action: 'Narrow the rerun (only the failing test, a filter) or refer back to one result',
100 savings: repeated.reduce((s, c) => s + c.wasteEst, 0),
101 })
102
103 const lost = compactions.filter(c => c.lostEdited.length > 0)
104 if (lost.length > 0)
105 push(
106 {
107 id: 'compaction-lost-edits',
108 title: 'Compaction summaries dropped files that were edited',
109 evidence: list(lost.flatMap(c => c.lostEdited), base, 5),
110 action: 'Say what to keep: /compact keep <files and plan>, or keep the plan in a file',
111 savings: lost.reduce((s, c) => s + c.reflowEst, 0),
112 },
113 'medium',
114 )
115
116 const reflow = compactions.reduce((s, c) => s + c.reflowEst, 0)
117 if (reflow >= 5_000)
118 push({
119 id: 'compaction-reflow',
120 title: 'Files read again after compaction',
121 evidence: list(compactions.filter(c => c.reflowEst > 0), c => `${c.trigger} @turn ${c.turn} (${tok(c.reflowEst)})`),
122 action: 'Compact with instructions naming the key files, or compact at a task boundary',
123 savings: reflow,
124 })
125
126 const verbose = items.filter(i => (i.category === 'Agent' || i.category === 'Task') && est(i) >= 4_000).sort((a, b) => b.est - a.est)
127 if (verbose.length > 0)
128 push({
129 id: 'agent-verbose',
130 title: 'Subagents returned long results',
131 evidence: list(verbose, i => `${short(i.target?.value ?? i.category)} (${tok(est(i))})`),
132 action: 'Ask subagents for a short summary with file:line pointers',
133 savings: verbose.reduce((s, i) => s + est(i) - 1_000, 0),
134 })
135
136 if (breakdown) {
137 const used = new Set(items.map(i => i.category))
138 const servers = new Map<string, { tokens: number; used: boolean }>()
139 for (const t of breakdown.mcpTools) {
140 if (!t.isLoaded) continue
141 const s = servers.get(t.serverName) ?? { tokens: 0, used: false }
142 s.tokens += t.tokens
143 s.used ||= used.has(t.name)
144 servers.set(t.serverName, s)
145 }
146 const idle = [...servers.entries()].filter(([, s]) => !s.used && s.tokens >= 3_000).sort((a, b) => b[1].tokens - a[1].tokens)
147 if (idle.length > 0)
148 push({
149 id: 'mcp-unused',
150 title: 'MCP tool schemas loaded but never called',
151 evidence: list(idle, ([name, s]) => `${name} (${tok(s.tokens)})`),
152 action: 'Disable the server for this project (/mcp), or let tool schemas load on demand',
153 savings: idle.reduce((s, [, x]) => s + x.tokens, 0),
154 })
155 const memory = breakdown.memoryFiles.filter(f => f.tokens >= 4_000).sort((a, b) => b.tokens - a.tokens)
156 if (memory.length > 0)
157 push({
158 id: 'memory-large',
159 title: 'Large memory files loaded with every request',
160 evidence: list(memory, f => `${base(f.path)} (${tok(f.tokens)})`),
161 action: 'Trim CLAUDE.md; move rarely needed detail into files it links to',
162 savings: memory.reduce((s, f) => s + f.tokens - 2_000, 0),
163 })
164 }
165
166 return out.sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.savings - a.savings)
167}
168hooks/core/export.ts 95 lines1import { contentHash } from './hash.ts'
2import { redactSecrets } from './redact.ts'
3import type { Summary } from './summary.ts'
4import type { Snapshot, Target } from './types.ts'
5
6// Under `$.fs.write`'s 4 MiB limit with room to spare.
7export const MAX_EXPORT_BYTES = Math.floor(3.5 * 1024 * 1024)
8
9export type ExportMeta = { sessionId: string; version: string; at: string }
10
11// Paths stay; everything else (agent names from descriptions, command lines, patterns and URLs) is redacted and cut to 200 characters.
12function exportTarget(t: Target | undefined): Target | undefined {
13 if (!t) return undefined
14 const value = t.kind === 'file' ? t.value : redactSecrets(t.value).slice(0, 200)
15 return { ...t, value }
16}
17
18// One JSON record per line: meta, items, edits, samples, compactions, summary. No row content, and
19// entity values only for files (errors and user identifiers are hashed).
20export function exportLines(s: Snapshot, sum: Summary, meta: ExportMeta): string[] {
21 const out: string[] = [JSON.stringify({ type: 'meta', schema: 'context-inspector/1', ...meta })]
22 for (const i of s.items)
23 out.push(
24 JSON.stringify({
25 type: 'item',
26 seq: i.seq,
27 ctx: i.ctx,
28 turn: i.turn,
29 door: i.door,
30 category: i.category,
31 toolUseId: i.toolUseId,
32 target: exportTarget(i.target),
33 chars: i.chars,
34 est: i.est,
35 hash: i.hash,
36 simhash: i.simhash,
37 born: i.born,
38 epoch: i.epoch,
39 live: i.live,
40 isError: i.isError,
41 deduped: i.deduped,
42 attached: i.attached,
43 backfilled: i.backfilled,
44 }),
45 )
46 for (const e of s.edits) out.push(JSON.stringify({ type: 'edit', ...e }))
47 for (const x of s.samples) out.push(JSON.stringify({ type: 'sample', ...x }))
48 for (const c of s.compactions)
49 out.push(
50 JSON.stringify({
51 type: 'compaction',
52 ...c,
53 entities: c.entities.map(e =>
54 e.type === 'edited-file' || e.type === 'read-file' ? { type: e.type, kept: e.kept, value: e.value } : { type: e.type, kept: e.kept, hash: contentHash(e.value) },
55 ),
56 }),
57 )
58 out.push(
59 JSON.stringify({
60 type: 'summary',
61 k: sum.k,
62 segments: sum.segments,
63 liveEst: sum.occupancy.liveEst,
64 wasteEst: sum.wasteEst,
65 currentWasteEst: sum.rereads.currentWasteEst,
66 sessionRereadEst: sum.sessionRereadEst,
67 rereadWasteEst: sum.rereads.wasteEst,
68 similarWasteEst: sum.similar.wasteEst,
69 compactionReflowEst: sum.rereads.compactionEst,
70 advice: sum.advice.map(a => ({ id: a.id, severity: a.severity, savings: a.savings })),
71 }),
72 )
73 return out
74}
75
76// Newline-terminated chunks of whole lines, each at most `max` bytes (a single longer line goes alone).
77export function chunkLines(lines: readonly string[], max = MAX_EXPORT_BYTES): string[] {
78 const encoder = new TextEncoder()
79 const chunks: string[] = []
80 let current: string[] = []
81 let size = 0
82 for (const line of lines) {
83 const bytes = encoder.encode(line).length + 1
84 if (current.length > 0 && size + bytes > max) {
85 chunks.push(`${current.join('\n')}\n`)
86 current = []
87 size = 0
88 }
89 current.push(line)
90 size += bytes
91 }
92 if (current.length > 0) chunks.push(`${current.join('\n')}\n`)
93 return chunks
94}
95hooks/core/format.ts 22 lines1export function pct(part: number, whole: number): string {
2 // part * 100 first: (6050 / 10000) * 100 is 60.49999… in floating point.
3 return whole > 0 ? `${Math.round((part * 100) / whole)}%` : '0%'
4}
5
6export function clip(text: string, n: number): string {
7 const line = text.replace(/[\r\n\t]+/g, ' ')
8 return line.length <= n ? line : `${line.slice(0, Math.max(0, n - 1))}…`
9}
10
11export function bar(share: number, width = 10): string {
12 const filled = Math.max(0, Math.min(width, Math.round(share * width)))
13 return '█'.repeat(filled) + '░'.repeat(width - filled)
14}
15
16export function fmtCount(n: number): string {
17 const abs = Math.abs(n)
18 if (abs >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
19 if (abs >= 1_000) return `${(n / 1_000).toFixed(1)}k`
20 return String(Math.round(n))
21}
22hooks/core/ingest.ts 224 lines1import { extractEntities, flattenMessages, isMentioned, type MessageLike } from './entities.ts'
2import { contentHash, MAX_SIMHASH_WORDS, simhash64 } from './hash.ts'
3import { Ledger, type ToolInfo } from './ledger.ts'
4import { changedLines, EDIT_TOOLS } from './lines.ts'
5import { blockText, HASH_CHARS, normalize } from './text.ts'
6import { estimate } from './tokens.ts'
7import { MAIN, type Target } from './types.ts'
8
9// The parts of a `session.append` input the ledger reads.
10export type AppendLike = {
11 door: string
12 uuid: string
13 agentId?: string
14 origin?: { kind: string; tool?: string }
15 message: { type: string; name?: string; role?: string; content: unknown }
16}
17
18export type CompactIn = { trigger: string; agentId?: string; messages: readonly MessageLike[] }
19export type CompactOut = { messages?: readonly MessageLike[]; tokensBefore?: number; tokensAfter?: number; skip?: string }
20
21const SIMHASH_MIN_CHARS = 200
22const FILE_ATTACHMENT = /"file_path"\s*:\s*"((?:[^"\\]|\\.)*)"/
23
24const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
25
26function unescapeJson(s: string): string {
27 try {
28 return JSON.parse(`"${s}"`) as string
29 } catch {
30 return s
31 }
32}
33
34export function targetOf(tool: string, input: Record<string, unknown>): Target | undefined {
35 switch (tool) {
36 case 'Read': {
37 const path = str(input.file_path)
38 if (!path) return undefined
39 const offset = typeof input.offset === 'number' ? input.offset : undefined
40 const limit = typeof input.limit === 'number' ? input.limit : undefined
41 if (offset === undefined && limit === undefined) return { kind: 'file', value: path }
42 return { kind: 'file', value: path, range: `${offset ?? 1}:${limit ?? 'all'}` }
43 }
44 case 'Edit':
45 case 'MultiEdit':
46 case 'Write': {
47 const path = str(input.file_path)
48 return path ? { kind: 'file', value: path } : undefined
49 }
50 case 'NotebookEdit': {
51 const path = str(input.notebook_path)
52 return path ? { kind: 'file', value: path } : undefined
53 }
54 case 'Bash': {
55 const command = str(input.command)
56 return command ? { kind: 'command', value: command } : undefined
57 }
58 case 'Grep':
59 case 'Glob': {
60 const pattern = str(input.pattern)
61 if (!pattern) return undefined
62 const where = str(input.path)
63 return { kind: 'pattern', value: where ? `${pattern} in ${where}` : pattern }
64 }
65 case 'WebFetch': {
66 const url = str(input.url)
67 return url ? { kind: 'url', value: url } : undefined
68 }
69 case 'WebSearch': {
70 const query = str(input.query)
71 return query ? { kind: 'pattern', value: query } : undefined
72 }
73 case 'Agent':
74 case 'Task': {
75 const agent = str(input.subagent_type) ?? str(input.description)
76 return agent ? { kind: 'agent', value: agent } : undefined
77 }
78 default:
79 return undefined
80 }
81}
82
83// Before the call runs: what it targets and, for an edit, how many lines it changes.
84export function ingestToolCall(l: Ledger, id: string, tool: string, agentId: string | undefined, input: Record<string, unknown>): void {
85 const info: ToolInfo = { tool, ctx: agentId ?? MAIN, target: targetOf(tool, input) }
86 if (EDIT_TOOLS.has(tool)) info.lines = changedLines(tool, input)
87 l.toolStart(id, info)
88}
89
90// After the call: an engine-deduplicated Read, whether a Read was whole, an error or a deny.
91export function ingestToolResult(l: Ledger, id: string, answer: unknown): void {
92 const a = (answer ?? {}) as Record<string, unknown>
93 const result = (a.result ?? {}) as Record<string, unknown>
94 const file = (result.file ?? {}) as Record<string, unknown>
95 const { startLine, numLines, totalLines } = file
96 const full =
97 typeof startLine === 'number' && typeof numLines === 'number' && typeof totalLines === 'number'
98 ? startLine <= 1 && numLines >= totalLines
99 : undefined
100 l.toolEnd(id, {
101 deduped: result.type === 'file_unchanged',
102 isError: a.isError === true || typeof a.deny === 'string',
103 full,
104 })
105}
106
107function measure(text: string, images: number, tool: boolean): { chars: number; est: number; hash: string; simhash?: string } {
108 const normalized = tool && text.length >= SIMHASH_MIN_CHARS && text.length <= HASH_CHARS ? normalize(text) : undefined
109 const complete = normalized !== undefined && normalized.split(' ').filter(Boolean).length <= MAX_SIMHASH_WORDS
110 return {
111 chars: text.length,
112 est: estimate(text.length, images),
113 hash: contentHash(text),
114 // Near similarity cannot justify whole-output waste when only a prefix was measured.
115 simhash: complete ? simhash64(normalized!) : undefined,
116 }
117}
118
119// One row entering a conversation: one item per tool_result block, one for the rest of the row.
120export function ingestAppend(l: Ledger, e: AppendLike): void {
121 if (e.message.role === undefined) return // a notice or a record no request carries
122 if (l.revive(e.uuid)) return // re-appended after a compaction
123 const ctx = e.agentId ?? MAIN
124 if (ctx === MAIN && e.door === 'prompt') l.nextTurn()
125 const blocks: unknown[] = Array.isArray(e.message.content) ? e.message.content : [e.message.content]
126 let made = 0
127 const nextUuid = (): string => {
128 made += 1
129 return made === 1 ? e.uuid : `${e.uuid}#${made - 1}`
130 }
131 const rest: unknown[] = []
132 for (const raw of blocks) {
133 const block = (raw ?? {}) as Record<string, unknown>
134 if (block.type !== 'tool_result') {
135 rest.push(raw)
136 continue
137 }
138 const id = str(block.tool_use_id)
139 const info = id ? l.tool(id) : undefined
140 const { text, images } = blockText(block.content)
141 if (id) l.forgetTool(id)
142 if (text === '' && images === 0) continue
143 l.add({
144 uuid: nextUuid(),
145 row: e.uuid,
146 ctx,
147 door: e.door,
148 category: info?.tool ?? e.origin?.tool ?? 'tool',
149 toolUseId: id,
150 target: info?.target,
151 ...measure(text, images, true),
152 isError: block.is_error === true || info?.isError === true || undefined,
153 deduped: info?.deduped || undefined,
154 })
155 }
156 const { text, images } = blockText(rest)
157 if (text === '' && images === 0) return
158 const attachment = e.message.type === 'attachment'
159 const name = e.message.name ?? 'unknown'
160 const filePath = attachment && name === 'file' ? FILE_ATTACHMENT.exec(text)?.[1] : undefined
161 l.add({
162 uuid: nextUuid(),
163 row: e.uuid,
164 ctx,
165 door: e.door,
166 category: attachment ? `attachment:${name}` : e.door,
167 target: filePath ? { kind: 'file', value: unescapeJson(filePath) } : undefined,
168 ...measure(text, images, false),
169 attached: filePath ? true : undefined,
170 })
171}
172
173// A compaction that stands: which items stay in the window, and which entities the result still mentions.
174export function ingestCompaction(l: Ledger, e: CompactIn, r: CompactOut): void {
175 if (e.trigger === 'precompute' || typeof r.skip === 'string' || !r.messages) return
176 const before = new Set(e.messages.flatMap(m => (typeof m.handle === 'string' ? [m.handle] : [])))
177 const keptRows = new Set<string>()
178 const keptTools = new Set<string>()
179 for (const m of r.messages) {
180 // The summary carries a handle too, but not one of the messages it replaced.
181 if (m.handle === undefined || !before.has(m.handle)) continue
182 keptRows.add(m.handle)
183 for (const u of m.toolUses) keptTools.add(u.tool_use_id)
184 for (const t of m.toolResults ?? []) keptTools.add(t.tool_use_id)
185 }
186 const after = flattenMessages(r.messages)
187 const entities = extractEntities(e.messages).map(found => ({ ...found, kept: isMentioned(after, found) }))
188 l.compacted(e.agentId ?? MAIN, keptRows, keptTools, { trigger: e.trigger, tokensBefore: r.tokensBefore, tokensAfter: r.tokensAfter, entities })
189}
190
191// A fresh ledger is read back from the conversation until it holds a prompt or a response: tool results
192// and attachments may arrive before the deferred backfill runs, and backfill skips those.
193export function needsBackfill(l: Ledger): boolean {
194 return !l.snapshot().items.some(i => i.door === 'prompt' || i.door === 'response')
195}
196
197// After a reload, resume or /clear: what the main conversation already holds (no attachments, no timing).
198// Tool calls whose result the ledger already holds were recorded live and are not added again.
199export function backfill(l: Ledger, messages: readonly MessageLike[]): void {
200 const before = l.snapshot().items
201 const held = new Set(before.flatMap(i => (i.toolUseId !== undefined ? [i.toolUseId] : [])))
202 const afterSeq = before.at(-1)?.seq ?? 0
203 messages.forEach((m, i) => {
204 const row = `backfill-${i}`
205 if (m.role === 'assistant') {
206 for (const u of m.toolUses) {
207 if (held.has(u.tool_use_id)) continue
208 ingestToolCall(l, u.tool_use_id, u.tool, undefined, u.input)
209 ingestToolResult(l, u.tool_use_id, { isError: u.isError === true, result: (u as { result?: unknown }).result })
210 }
211 const content = [{ type: 'text', text: m.text }, ...m.toolUses.map(u => ({ type: 'tool_use', name: u.tool, input: u.input }))]
212 ingestAppend(l, { door: 'response', uuid: row, message: { type: 'assistant', role: 'assistant', content } })
213 } else if ((m.toolResults?.length ?? 0) > 0) {
214 const results = (m.toolResults ?? []).filter(t => !held.has(t.tool_use_id))
215 if (results.length === 0) return
216 const content = results.map(t => ({ type: 'tool_result', tool_use_id: t.tool_use_id, content: t.text, is_error: t.isError }))
217 ingestAppend(l, { door: 'tool-result', uuid: row, message: { type: 'user', role: 'user', content } })
218 } else {
219 ingestAppend(l, { door: 'prompt', uuid: row, message: { type: 'user', role: 'user', content: m.text } })
220 }
221 })
222 l.markBackfilled(afterSeq)
223}
224hooks/core/ledger.ts 189 lines1import type { CompactionRec, ContextItem, EditRec, Sample, Snapshot, Target } from './types.ts'
2
3export type ToolInfo = {
4 tool: string
5 ctx: string
6 target?: Target
7 // Lines an edit tool changes, counted from its input; recorded once the call succeeds.
8 lines?: number
9 deduped?: boolean
10 isError?: boolean
11}
12
13export type ToolOutcome = { deduped?: boolean; isError?: boolean; full?: boolean }
14
15export type NewItem = Omit<ContextItem, 'seq' | 'turn' | 'born' | 'epoch' | 'live'>
16
17const MAX_TOOLS = 2_000
18
19export class Ledger {
20 version = 0
21 turn = 0
22 dropped = 0
23 droppedLive = 0
24 // The largest seq of any item trim removed; 0 until the cap is first reached.
25 trimmedThrough = 0
26 // One counter orders items, edits, samples and compactions.
27 private seq = 0
28 private items: ContextItem[] = []
29 private byUuid = new Map<string, ContextItem>()
30 private edits: EditRec[] = []
31 private samples: Sample[] = []
32 private compactions: CompactionRec[] = []
33 private epochs = new Map<string, number>()
34 private tools = new Map<string, ToolInfo>()
35
36 constructor(readonly maxItems = 20_000) {}
37
38 get size(): number {
39 return this.items.length
40 }
41
42 epochOf(ctx: string): number {
43 return this.epochs.get(ctx) ?? 0
44 }
45
46 nextTurn(): void {
47 this.turn += 1
48 this.version += 1
49 }
50
51 // A row the engine appends again (after a compaction) is live once more, in the current epoch.
52 revive(row: string): boolean {
53 let item = this.byUuid.get(row)
54 if (!item) return false
55 for (let n = 1; item; n += 1) {
56 item.live = true
57 item.epoch = this.epochOf(item.ctx)
58 item = this.byUuid.get(`${row}#${n}`)
59 }
60 this.version += 1
61 return true
62 }
63
64 toolStart(id: string, info: ToolInfo): void {
65 this.tools.set(id, info)
66 if (this.tools.size > MAX_TOOLS) {
67 const oldest = this.tools.keys().next().value
68 if (oldest !== undefined) this.tools.delete(oldest)
69 }
70 }
71
72 tool(id: string): ToolInfo | undefined {
73 return this.tools.get(id)
74 }
75
76 // The call's result is known: a Read learns whether it was whole or deduplicated; an edit that went through counts.
77 toolEnd(id: string, outcome: ToolOutcome): void {
78 const info = this.tools.get(id)
79 if (!info) return
80 info.deduped = outcome.deduped
81 info.isError = outcome.isError
82 if (info.target && outcome.full !== undefined) info.target = { ...info.target, full: outcome.full }
83 if (!outcome.isError && info.lines !== undefined && info.target?.kind === 'file') this.edit(info.ctx, info.target.value, info.lines)
84 }
85
86 forgetTool(id: string): void {
87 this.tools.delete(id)
88 }
89
90 add(item: NewItem): ContextItem {
91 this.seq += 1
92 const epoch = this.epochOf(item.ctx)
93 const full: ContextItem = { ...item, seq: this.seq, turn: this.turn, born: epoch, epoch, live: true }
94 this.items.push(full)
95 this.byUuid.set(full.uuid, full)
96 this.version += 1
97 if (this.items.length > this.maxItems) this.trim()
98 return full
99 }
100
101 edit(ctx: string, path: string, lines: number): void {
102 this.seq += 1
103 this.edits.push({ seq: this.seq, ctx, turn: this.turn, path, lines })
104 if (this.edits.length > this.maxItems) this.edits.splice(0, this.edits.length - this.maxItems)
105 this.version += 1
106 }
107
108 sample(ctx: string, input: number): void {
109 if (!Number.isFinite(input) || input <= 0) return
110 this.seq += 1
111 this.samples.push({ seq: this.seq, ctx, turn: this.turn, epoch: this.epochOf(ctx), input })
112 if (this.samples.length > this.maxItems) this.samples.splice(0, this.samples.length - this.maxItems)
113 this.version += 1
114 }
115
116 // Live items of `ctx` that neither sit in a kept row nor answer a kept tool call leave the window.
117 compacted(
118 ctx: string,
119 keptRows: ReadonlySet<string>,
120 keptTools: ReadonlySet<string>,
121 rec: Pick<CompactionRec, 'trigger' | 'tokensBefore' | 'tokensAfter' | 'entities'>,
122 ): CompactionRec {
123 const epoch = this.epochOf(ctx) + 1
124 this.epochs.set(ctx, epoch)
125 let keptItems = 0
126 let droppedItems = 0
127 let droppedEst = 0
128 for (const item of this.items) {
129 if (item.ctx !== ctx || !item.live) continue
130 if (keptRows.has(item.row) || (item.toolUseId !== undefined && keptTools.has(item.toolUseId))) {
131 item.epoch = epoch
132 keptItems += 1
133 } else {
134 item.live = false
135 droppedItems += 1
136 droppedEst += item.est
137 }
138 }
139 this.seq += 1
140 const full: CompactionRec = { ...rec, seq: this.seq, ctx, turn: this.turn, keptItems, droppedItems, droppedEst }
141 this.compactions.push(full)
142 this.version += 1
143 return full
144 }
145
146 // Items added after `afterSeq` came from a backfill; ones recorded live before it stay unmarked.
147 markBackfilled(afterSeq = 0): void {
148 for (const item of this.items) if (item.seq > afterSeq) item.backfilled = true
149 this.version += 1
150 }
151
152 snapshot(): Snapshot {
153 return {
154 items: this.items,
155 edits: this.edits,
156 samples: this.samples,
157 compactions: this.compactions,
158 turn: this.turn,
159 dropped: this.dropped,
160 droppedLive: this.droppedLive,
161 trimmedThrough: this.trimmedThrough,
162 }
163 }
164
165 // Down to 90% of the cap: the oldest items already out of the window first, then the oldest live ones.
166 private trim(): void {
167 let excess = this.items.length - Math.floor(this.maxItems * 0.9)
168 const keep: ContextItem[] = []
169 for (const item of this.items) {
170 if (excess > 0 && !item.live) {
171 this.byUuid.delete(item.uuid)
172 this.trimmedThrough = Math.max(this.trimmedThrough, item.seq)
173 this.dropped += 1
174 excess -= 1
175 } else {
176 keep.push(item)
177 }
178 }
179 if (excess > 0) {
180 for (const item of keep.splice(0, excess)) {
181 this.byUuid.delete(item.uuid)
182 this.trimmedThrough = Math.max(this.trimmedThrough, item.seq)
183 this.droppedLive += 1
184 }
185 }
186 this.items = keep
187 }
188}
189hooks/core/report.ts 148 lines1import { bar, clip as clipText, fmtCount, pct } from './format.ts'
2import { redactSecrets } from './redact.ts'
3import type { Scope, Summary } from './summary.ts'
4import { MAIN } from './types.ts'
5
6export const SECTIONS = ['overview', 'occupants', 'rereads', 'similar', 'compactions', 'efficiency', 'advice'] as const
7export type Section = (typeof SECTIONS)[number]
8export type UsageLite = { percent?: number; tokens?: number; window?: number }
9export type PaneLine = { text: string; bold?: boolean }
10
11const tok = (n: number): string => `≈${fmtCount(n)}`
12const signed = (n: number): string => `${n >= 0 ? '+' : '−'}${fmtCount(Math.abs(n))}`
13const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
14const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
15const clip = (text: string, n: number): string => clipText(redactSecrets(text), n)
16
17export function parseArgs(args: string): { section: Section; scope: Scope; unknown?: string } {
18 const words = args.trim().split(/\s+/).filter(w => w !== '')
19 const scope: Scope = words.includes('--agents') ? 'all' : 'main'
20 const first = words.find(w => w !== '--agents')
21 if (first === undefined) return { section: 'overview', scope }
22 if ((SECTIONS as readonly string[]).includes(first)) return { section: first as Section, scope }
23 return { section: 'overview', scope, unknown: first }
24}
25
26// k at a bound of calibrate's clamp: the measured ratio lies beyond it.
27const clamped = (sum: Summary): boolean => sum.segments > 0 && (sum.k === 0.5 || sum.k === 2)
28
29function header(sum: Summary, usage?: UsageLite): string[] {
30 const where = sum.scope === 'main' ? 'main context' : 'main + subagents'
31 const window =
32 usage?.tokens !== undefined && usage.window ? ` · window ${fmtCount(usage.tokens)}/${fmtCount(usage.window)} tokens (${usage.percent ?? 0}%, measured)` : ''
33 const lines = [
34 `Context Inspector — ${where}${window}`,
35 `Live ${tok(sum.occupancy.liveEst)} tokens in ${sum.occupancy.liveItems} items · ${sum.turns} turn(s) · ${sum.compactions.length} compaction(s) · waste ${tok(sum.wasteEst)}`,
36 `≈ = estimate (chars/4 × k=${sum.k.toFixed(2)}${clamped(sum) ? ' (clamped)' : ''}, ${sum.segments} calibration segment(s)); figures without ≈ are measured or engine-reported`,
37 ]
38 if (sum.dropped + sum.droppedLive > 0) lines.push(`(ledger cap reached: ${sum.dropped} old and ${sum.droppedLive} live items dropped)`)
39 return lines
40}
41
42function occupants(sum: Summary, n: number): string[] {
43 const o = sum.occupancy
44 if (o.liveItems === 0) return ['Top occupants', ' nothing recorded yet']
45 const out = ['Top occupants (live, by category)']
46 for (const r of o.byCategory.slice(0, n))
47 out.push(` ${pad(clip(r.key, 28), 28)} ${pad(tok(r.est), 8)} ${pad(pct(r.est, o.liveEst), 4)} ${bar(r.est / o.liveEst)} ${r.count} item(s)`)
48 out.push('Largest live items')
49 for (const t of o.top.slice(0, n)) out.push(` ${pad(tok(t.est), 8)} ${pad(clip(t.category, 14), 14)} ${clip(t.label, 60)} · turn ${t.turn}`)
50 return out
51}
52
53function rereadLines(sum: Summary, n: number): string[] {
54 const r = sum.rereads
55 const out = [`Re-reads — waste now ${tok(r.currentWasteEst)} · session ${tok(r.wasteEst)} · compaction-induced ${tok(r.compactionEst)} · engine-deduped ${r.deduped}`]
56 if (r.files.length === 0) return [...out, ' no file was read twice']
57 for (const f of r.files.slice(0, n)) {
58 const classes = Object.entries(f.classes)
59 .filter(([c]) => c !== 'first')
60 .map(([c, x]) => `${c}×${x}`)
61 .join(' ')
62 out.push(` ${clip(f.path, 50)} ${f.reads} reads ${classes}${f.wasteEst > 0 ? ` waste ${tok(f.wasteEst)}` : ''}`)
63 }
64 return out
65}
66
67function similarLines(sum: Summary, n: number): string[] {
68 const out = [`Similar tool outputs (lexical SimHash ≤3 bits, not semantic) — waste ${tok(sum.similar.wasteEst)}`]
69 if (sum.similar.clusters.length === 0) return [...out, ' none']
70 for (const c of sum.similar.clusters.slice(0, n))
71 out.push(` ${c.category} ×${c.size} ${c.kind} waste ${tok(c.wasteEst)} of ${tok(c.totalEst)} ${clip(c.label, 50)}`)
72 return out
73}
74
75function compactionLines(sum: Summary, n: number): string[] {
76 if (sum.compactions.length === 0) return ['Compactions', ' none yet']
77 const out = ['Compactions (string presence in summary/kept messages; not semantic retention)']
78 const shown = sum.compactions.slice(-n)
79 shown.forEach((c, i) => {
80 const index = sum.compactions.length - shown.length + i + 1
81 const sizes = c.tokensBefore !== undefined ? ` ${fmtCount(c.tokensBefore)}→${c.tokensAfter !== undefined ? fmtCount(c.tokensAfter) : '?'} tokens` : ''
82 const who = c.ctx === MAIN ? '' : ` (${c.ctx})`
83 out.push(` #${index} ${c.trigger}${who} @turn ${c.turn}${sizes} dropped ${c.droppedItems} item(s) ${tok(c.droppedEst)}`)
84 const s = c.stats
85 const lost = c.lostEdited.length > 0 ? ` · lost edits: ${c.lostEdited.map(base).join(', ')}` : ''
86 const reflow = c.reflowEst > 0 ? ` · reflow ${tok(c.reflowEst)}` : ''
87 out.push(
88 ` kept: edited ${s['edited-file'].kept}/${s['edited-file'].total} · read ${s['read-file'].kept}/${s['read-file'].total} · errors ${s.error.kept}/${s.error.total} · requirements ${s.requirement.kept}/${s.requirement.total}${lost}${reflow}`,
89 )
90 })
91 return out
92}
93
94function efficiencyLines(sum: Summary, n: number): string[] {
95 const e = sum.efficiency
96 const out = ['Efficiency (measured input growth vs changed lines; exploration is not a fault)']
97 if (e.turns.length === 0) return [...out, ' no measured requests yet']
98 const median = e.medianPerLine !== undefined ? `median ${fmtCount(e.medianPerLine)} tokens/line` : 'no edits yet'
99 out.push(` ${e.turns.length} turn(s) · +${fmtCount(e.growth)} tokens · ${e.lines} line(s) changed · ${median} · ${e.exploration} exploration turn(s)`)
100 const top = e.turns
101 .filter(t => t.growth !== undefined)
102 .sort((a, b) => (b.growth ?? 0) - (a.growth ?? 0))
103 .slice(0, n)
104 for (const t of top) {
105 const tail = t.perLine !== undefined ? ` ${fmtCount(t.perLine)} tokens/line` : t.lines === 0 && (t.growth ?? 0) > 0 ? ' exploration' : ''
106 out.push(` turn ${t.turn} ${signed(t.growth ?? 0)} ${t.lines} line(s)${tail}`)
107 }
108 return out
109}
110
111function adviceLines(sum: Summary, n: number): string[] {
112 const out = [`Advice${sum.hasBreakdown ? '' : ' (MCP and memory checks run from /context-inspect)'}`]
113 if (sum.advice.length === 0) return [...out, ' nothing stands out']
114 for (const a of sum.advice.slice(0, n)) {
115 out.push(` [${a.severity}] ${a.title} — saves ${tok(a.savings)}`)
116 out.push(` ${a.evidence}`)
117 out.push(` → ${a.action}`)
118 }
119 return out
120}
121
122const PARTS: [Section, (sum: Summary, n: number) => string[]][] = [
123 ['occupants', occupants],
124 ['rereads', rereadLines],
125 ['similar', similarLines],
126 ['compactions', compactionLines],
127 ['efficiency', efficiencyLines],
128 ['advice', adviceLines],
129]
130
131export function renderReport(sum: Summary, section: Section = 'overview', usage?: UsageLite): string {
132 const n = section === 'overview' ? 3 : 15
133 const blocks: string[][] = [header(sum, usage)]
134 for (const [name, part] of PARTS) if (section === 'overview' || section === name) blocks.push(part(sum, n))
135 return blocks.map(b => b.map(redactSecrets).join('\n')).join('\n\n')
136}
137
138export function statusLine(sum: Summary, percent?: number): string {
139 const head = percent !== undefined ? `ctx ${percent}%` : `ctx ${tok(sum.occupancy.liveEst)}`
140 return `${head} · waste${tok(sum.wasteEst)}`
141}
142
143export function paneLines(sum: Summary, width: number): PaneLine[] {
144 return renderReport(sum, 'overview')
145 .split('\n')
146 .map(text => ({ text: clip(text, Math.max(20, width)), bold: text !== '' && /^[A-Z]/.test(text) }))
147}
148hooks/core/summary.ts 59 lines1import { advise, type Advice, type BreakdownLite } from './advice.ts'
2import { compactionViews, type CompactionView } from './analyze/compaction.ts'
3import { efficiency, type Efficiency } from './analyze/efficiency.ts'
4import { occupancy, type Occupancy } from './analyze/occupancy.ts'
5import { rereads, type Rereads } from './analyze/rereads.ts'
6import { similar, type Similar } from './analyze/similar.ts'
7import { calibrate } from './tokens.ts'
8import { MAIN, type Snapshot } from './types.ts'
9
10export type Scope = 'main' | 'all'
11
12export type Summary = {
13 scope: Scope
14 k: number
15 segments: number
16 turns: number
17 occupancy: Occupancy
18 rereads: Rereads
19 similar: Similar
20 compactions: CompactionView[]
21 efficiency: Efficiency
22 advice: Advice[]
23 // Waste still in the window: unchanged re-reads whose copy is live plus near-duplicate live outputs,
24 // each item counted once.
25 wasteEst: number
26 // Every unchanged re-read of the session, in the window or not.
27 sessionRereadEst: number
28 dropped: number
29 droppedLive: number
30 hasBreakdown: boolean
31}
32
33export function summarize(s: Snapshot, scope: Scope, breakdown?: BreakdownLite): Summary {
34 const inScope = (ctx: string): boolean => scope === 'all' || ctx === MAIN
35 const items = s.items.filter(i => inScope(i.ctx))
36 // Calibration uses every context: more segments, and k is a property of the tokenizer, not the scope.
37 const { k, segments } = calibrate(s.samples, s.items, s.trimmedThrough)
38 const rr = rereads(items, s.edits, k)
39 const sim = similar(items, k, rr.wasteSeqs)
40 const comps = compactionViews(s.compactions.filter(c => inScope(c.ctx)), rr.rows, k)
41 return {
42 scope,
43 k,
44 segments,
45 turns: s.turn,
46 occupancy: occupancy(items, k),
47 rereads: rr,
48 similar: sim,
49 compactions: comps,
50 efficiency: efficiency(s.samples, s.edits),
51 advice: advise({ items, k, rereads: rr, clusters: sim.clusters, compactions: comps, breakdown }),
52 wasteEst: rr.currentWasteEst + sim.wasteEst,
53 sessionRereadEst: rr.wasteEst,
54 dropped: s.dropped,
55 droppedLive: s.droppedLive,
56 hasBreakdown: breakdown !== undefined,
57 }
58}
59hooks/core/tokens.ts 48 lines1import type { ContextItem, Sample } from './types.ts'
2
3export const CHARS_PER_TOKEN = 4
4export const IMAGE_TOKENS = 1_500
5// A calibration segment counts only when its items add up to at least this many estimated tokens.
6export const MIN_SEGMENT_EST = 2_000
7
8export function estimate(chars: number, images = 0): number {
9 return Math.ceil(chars / CHARS_PER_TOKEN) + images * IMAGE_TOKENS
10}
11
12// What a request was answered over: uncached, cache-written and cache-read input together.
13export function inputOf(usage: unknown): number {
14 const u = (usage ?? {}) as Record<string, unknown>
15 const n = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
16 return n(u.input_tokens) + n(u.cache_read_input_tokens) + n(u.cache_creation_input_tokens)
17}
18
19export type Calibration = { k: number; segments: number }
20
21// Per context and epoch, the measured growth between its first and last request over the estimated
22// tokens of the items that arrived in between. Telescoping makes the order of rows and stop chunks
23// inside the segment irrelevant. A span whose first sample precedes `trimmedThrough` may have lost
24// items to the ledger cap, so it is skipped.
25export function calibrate(samples: readonly Sample[], items: readonly Pick<ContextItem, 'ctx' | 'seq' | 'est'>[], trimmedThrough = 0): Calibration {
26 const spans = new Map<string, { a: Sample; b: Sample }>()
27 for (const s of samples) {
28 const key = `${s.ctx}\u0000${s.epoch}`
29 const span = spans.get(key)
30 if (!span) spans.set(key, { a: s, b: s })
31 else span.b = s
32 }
33 let growth = 0
34 let est = 0
35 let segments = 0
36 for (const { a, b } of spans.values()) {
37 if (b.seq === a.seq || b.input <= a.input || a.seq < trimmedThrough) continue
38 let sum = 0
39 for (const item of items) if (item.ctx === a.ctx && item.seq > a.seq && item.seq <= b.seq) sum += item.est
40 if (sum < MIN_SEGMENT_EST) continue
41 growth += b.input - a.input
42 est += sum
43 segments += 1
44 }
45 if (segments === 0) return { k: 1, segments: 0 }
46 return { k: Math.min(2, Math.max(0.5, growth / est)), segments }
47}
48hooks/core/types.ts 75 lines1// The main conversation's context key; a subagent's is its agentId.
2export const MAIN = 'main'
3
4export type TargetKind = 'file' | 'command' | 'pattern' | 'url' | 'agent'
5
6export type Target = {
7 kind: TargetKind
8 value: string
9 // Read only: 'offset:limit' from the call's input; absent for a whole-file read.
10 range?: string
11 // Read only: the result covered the whole file (startLine <= 1 and numLines >= totalLines).
12 full?: boolean
13}
14
15export type ContextItem = {
16 seq: number
17 // Unique per item: the row uuid for a row's first item, '<row>#<n>' for its later ones.
18 uuid: string
19 row: string
20 ctx: string
21 turn: number
22 door: string
23 // The tool's name for a tool result, 'attachment:<name>' for attachments and hook context, else the door.
24 category: string
25 toolUseId?: string
26 target?: Target
27 chars: number
28 est: number
29 hash: string
30 simhash?: string
31 // The epoch the item arrived in, and the last epoch it was live in.
32 born: number
33 epoch: number
34 live: boolean
35 isError?: boolean
36 // A Read the engine answered with "file unchanged" instead of the content.
37 deduped?: boolean
38 // File content the engine attached itself (a `file` attachment: an @-mention, or restored after compaction).
39 attached?: boolean
40 backfilled?: boolean
41}
42
43export type EditRec = { seq: number; ctx: string; turn: number; path: string; lines: number }
44
45// One request's measured input: uncached + cache-written + cache-read tokens.
46export type Sample = { seq: number; ctx: string; turn: number; epoch: number; input: number }
47
48export type EntityType = 'edited-file' | 'read-file' | 'error' | 'requirement'
49export type Entity = { type: EntityType; value: string; kept: boolean }
50
51export type CompactionRec = {
52 seq: number
53 ctx: string
54 turn: number
55 trigger: string
56 tokensBefore?: number
57 tokensAfter?: number
58 entities: Entity[]
59 keptItems: number
60 droppedItems: number
61 droppedEst: number
62}
63
64export type Snapshot = {
65 items: readonly ContextItem[]
66 edits: readonly EditRec[]
67 samples: readonly Sample[]
68 compactions: readonly CompactionRec[]
69 turn: number
70 dropped: number
71 droppedLive: number
72 // The largest seq of any item the ledger trimmed (0 when never trimmed): spans starting before it are incomplete.
73 trimmedThrough: number
74}
75hooks/core/analyze/compaction.ts 53 lines1import type { CompactionRec, EntityType } from '../types.ts'
2import type { RereadRow } from './rereads.ts'
3
4export type EntityStat = { total: number; kept: number }
5export type CompactionView = {
6 seq: number
7 ctx: string
8 turn: number
9 trigger: string
10 tokensBefore?: number
11 tokensAfter?: number
12 keptItems: number
13 droppedItems: number
14 droppedEst: number
15 stats: Record<EntityType, EntityStat>
16 lostEdited: string[]
17 // Estimated tokens of files read again (or restored by the engine) because this compaction dropped them.
18 reflowEst: number
19}
20
21const TYPES: EntityType[] = ['edited-file', 'read-file', 'error', 'requirement']
22
23export function compactionViews(recs: readonly CompactionRec[], rows: readonly RereadRow[], k: number): CompactionView[] {
24 const views: CompactionView[] = recs.map(rec => {
25 const stats = Object.fromEntries(TYPES.map(t => [t, { total: 0, kept: 0 }])) as Record<EntityType, EntityStat>
26 for (const e of rec.entities) {
27 stats[e.type].total += 1
28 if (e.kept) stats[e.type].kept += 1
29 }
30 return {
31 seq: rec.seq,
32 ctx: rec.ctx,
33 turn: rec.turn,
34 trigger: rec.trigger,
35 tokensBefore: rec.tokensBefore,
36 tokensAfter: rec.tokensAfter,
37 keptItems: rec.keptItems,
38 droppedItems: rec.droppedItems,
39 droppedEst: Math.round(rec.droppedEst * k),
40 stats,
41 lostEdited: rec.entities.filter(e => e.type === 'edited-file' && !e.kept).map(e => e.value),
42 reflowEst: 0,
43 }
44 })
45 for (const row of rows) {
46 if (row.cls !== 'after-compaction' && row.cls !== 'restored') continue
47 let owner: CompactionView | undefined
48 for (const v of views) if (v.ctx === row.ctx && v.seq < row.seq && (!owner || v.seq > owner.seq)) owner = v
49 if (owner) owner.reflowEst += row.est
50 }
51 return views
52}
53hooks/core/analyze/rereads.ts 90 lines1import type { ContextItem, EditRec } from '../types.ts'
2
3export type RereadClass =
4 | 'first'
5 | 'engine-deduped'
6 | 'restored'
7 | 'different-range'
8 | 'after-edit'
9 | 'after-compaction'
10 | 'changed-externally'
11 | 'unchanged-live'
12
13export type RereadRow = { seq: number; ctx: string; path: string; cls: RereadClass; est: number; live: boolean }
14export type FileRereads = { path: string; reads: number; wasteEst: number; classes: Partial<Record<RereadClass, number>> }
15// wasteEst: every unchanged-live re-read of the session; currentWasteEst: those whose copy is still in the window.
16export type Rereads = {
17 rows: RereadRow[]
18 files: FileRereads[]
19 wasteEst: number
20 currentWasteEst: number
21 compactionEst: number
22 deduped: number
23 wasteSeqs: Set<number>
24}
25
26// The first rule that matches wins (spec §4.2 and §10).
27function classify(r: ContextItem, prior: readonly ContextItem[], edits: readonly number[]): RereadClass {
28 if (prior.length === 0) return 'first'
29 if (r.deduped) return 'engine-deduped'
30 const sameRange = prior.findLast(p => p.target?.range === r.target?.range)
31 const p = sameRange ?? prior[prior.length - 1]!
32 // p.epoch is the last epoch p was live in; r.born the epoch r arrived in.
33 if (r.attached && p.epoch < r.born) return 'restored'
34 if (!sameRange) return 'different-range'
35 if (edits.some(seq => seq > p.seq && seq < r.seq)) return 'after-edit'
36 if (p.epoch < r.born) return 'after-compaction'
37 if (p.hash !== r.hash) return 'changed-externally'
38 return 'unchanged-live'
39}
40
41export function rereads(items: readonly ContextItem[], edits: readonly EditRec[], k: number): Rereads {
42 const reads = items.filter(i => i.target?.kind === 'file' && (i.category === 'Read' || i.attached)).sort((a, b) => a.seq - b.seq)
43 const editSeqs = new Map<string, number[]>()
44 for (const e of edits) {
45 const key = `${e.ctx}\u0000${e.path}`
46 const list = editSeqs.get(key)
47 if (list) list.push(e.seq)
48 else editSeqs.set(key, [e.seq])
49 }
50 const history = new Map<string, ContextItem[]>()
51 const rows: RereadRow[] = []
52 for (const r of reads) {
53 const path = r.target!.value
54 const key = `${r.ctx}\u0000${path}`
55 const prior = history.get(key) ?? []
56 rows.push({ seq: r.seq, ctx: r.ctx, path, cls: classify(r, prior, editSeqs.get(key) ?? []), est: Math.round(r.est * k), live: r.live })
57 prior.push(r)
58 history.set(key, prior)
59 }
60 const files = new Map<string, FileRereads>()
61 const wasteSeqs = new Set<number>()
62 let wasteEst = 0
63 let currentWasteEst = 0
64 let compactionEst = 0
65 let deduped = 0
66 for (const row of rows) {
67 const f = files.get(row.path) ?? { path: row.path, reads: 0, wasteEst: 0, classes: {} }
68 f.reads += 1
69 f.classes[row.cls] = (f.classes[row.cls] ?? 0) + 1
70 if (row.cls === 'unchanged-live') {
71 f.wasteEst += row.est
72 wasteEst += row.est
73 if (row.live) currentWasteEst += row.est
74 wasteSeqs.add(row.seq)
75 }
76 if (row.cls === 'after-compaction' || row.cls === 'restored') compactionEst += row.est
77 if (row.cls === 'engine-deduped') deduped += 1
78 files.set(row.path, f)
79 }
80 return {
81 rows,
82 files: [...files.values()].filter(f => f.reads > 1).sort((a, b) => b.wasteEst - a.wasteEst || b.reads - a.reads || a.path.localeCompare(b.path)),
83 wasteEst,
84 currentWasteEst,
85 compactionEst,
86 deduped,
87 wasteSeqs,
88 }
89}
90