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

Claude Code 세션에서 컨텍스트 창을 무엇이 채우는지, 같은 정보가 왜 다시 들어오는지, 컴팩션이 무엇을 남기고 잃었는지 보여 주는 mod. 관찰만 한다: 행·도구 호출·컴팩션을 바꾸지 않는다.
/plugin install context-inspector --marketplace YeonwooSung/my-claude-code-mods
Claude Code 2.1.287 이상(mods 기본 활성화). 그보다 오래된 빌드는 CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
| 명령 | 내용 | ||||||
|---|---|---|---|---|---|---|---|
| `/context-inspect [overview\ | occupants\ | rereads\ | similar\ | compactions\ | efficiency\ | advice] [--agents]` | 텍스트 보고서. 기본은 main 컨텍스트의 overview, --agents는 서브에이전트 포함 |
/context-pane | 실시간 패널(터미널, 데스크톱 앱) | ||||||
/context-export | 원문 없이 메타데이터만 JSONL로 저장: <outputDir>/<sessionId>/context-<load>-<n>.jsonl |
상태줄: ctx 62% · waste≈8.1k (창 점유율은 측정값, 낭비는 추정). waste는 지금 창에 남아 있는 낭비다 — 컴팩션이 중복 사본을 지우면 줄어든다. 세션 전체의 재읽기 비용은 /context-inspect rereads의 session ≈…에 남는다.
| 등급 | 예 |
|---|---|
| measured | 요청마다 API가 보고한 입력 토큰(uncached + cache read + cache write), 컴팩션 전/후 토큰 |
| observed | 대화에 들어온 행의 문자 수·해시, 컴팩션 전/후 메시지 집합 |
estimated (≈) | 항목별 토큰 = 문자 수/4(이미지 1,500) × 보정 계수 k (측정된 입력 증가량으로 0.5–2.0 사이에서 보정; 한계에 걸리면 보고서에 (clamped)) |
모델 내부의 토큰별 주의(attention)나 정확한 점유율은 외부에서 보이지 않으므로 주장하지 않는다.
attachment:<name>, prompt/response)와 대상(파일, 명령)별로.engine-deduped(엔진이 "unchanged" 안내로 대신함), restored(컴팩션 후 엔진이 다시 붙임), different-range, after-edit, after-compaction, changed-externally, unchanged-live(이전 사본이 아직 창에 있음 = 순수 낭비).#로 접어 실행 시간만 다른 테스트 출력도 묶인다.| 키 | 기본 | 뜻 |
|---|---|---|
outputDir | ~/.claude/context-inspector | /context-export 위치 |
toastAdvice | true | high 추천이 처음 생길 때 세션당 한 번 토스트 |
한 줄에 JSON 하나: meta, item(seq, ctx, turn, door, category, target, chars, est, hash, simhash, born, epoch, live …), edit, sample, compaction, summary. 원문은 없다. 명령·패턴·URL 대상은 비밀값 마스킹 후 200자로 자른다. 컴팩션 엔티티 중 오류와 사용자 식별자, 그리고 항목 내용은 해시(솔트 없음, 짧은 값은 추측으로 확인 가능)로만 남는다 — 익명화가 아니다. summary에는 wasteEst(지금 창), currentWasteEst·sessionRereadEst(재읽기 낭비: 지금 창 / 세션 누적)가 함께 들어간다.
| 환경 | 동작 |
|---|---|
| 터미널 | 상태줄, 패널, 토스트, 명령 모두 |
| VS Code 채팅 패널 | 훅과 명령 텍스트만(mod의 그림은 표시되지 않음) — /context-inspect가 기본 출력 |
claude -p | 기록만; 명령은 -p "/context-inspect"로 실행 가능 |
/clear 시 원장을 비우고 $.session.messages()로 메인 대화만 복구한다(attachment, 서브에이전트, 시간 정보 없음). /clear는 session.start 없이 session.end(reason clear)로 알아챈다.$.session.messages()가 주지 않는다). 그래서 리로드 후 첫 컴팩션은 이들을 전부 "버려진 것"으로 센다. 도구 결과는 tool_use_id로 계속 대응된다.-<n>으로 끝난다(파일 하나면 context-<load>-1.jsonl). 같은 로드 안에서 /context-export를 다시 실행하면 그 로드의 파일을 전체 새 스냅샷으로 덮어쓴다.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.0'
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 167 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'
6
7export type Severity = 'high' | 'medium' | 'low'
8export type Advice = { id: string; severity: Severity; title: string; evidence: string; action: string; savings: number }
9export type BreakdownLite = {
10 mcpTools: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
11 memoryFiles: { path: string; tokens: number }[]
12}
13export type AdviceInput = {
14 items: readonly ContextItem[]
15 k: number
16 rereads: Rereads
17 clusters: readonly Cluster[]
18 compactions: readonly CompactionView[]
19 breakdown?: BreakdownLite
20}
21
22const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 }
23
24export function severityOf(savings: number): Severity {
25 return savings >= 10_000 ? 'high' : savings >= 3_000 ? 'medium' : 'low'
26}
27
28const tok = (n: number): string => `≈${fmtCount(n)}`
29const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
30const short = (s: string): string => {
31 const line = s.replace(/\s+/g, ' ').trim()
32 return line.length > 40 ? `${line.slice(0, 39)}…` : line
33}
34
35function list<T>(xs: readonly T[], show: (x: T) => string, n = 3): string {
36 const head = xs.slice(0, n).map(show).join(', ')
37 return xs.length > n ? `${head}, +${xs.length - n} more` : head
38}
39
40// Rule-based recommendations (spec §4.6); every one names its evidence and an estimated saving.
41export function advise(input: AdviceInput): Advice[] {
42 const { items, k, rereads, clusters, compactions, breakdown } = input
43 const est = (i: ContextItem): number => Math.round(i.est * k)
44 const out: Advice[] = []
45 const push = (a: Omit<Advice, 'severity'>, floor?: Severity): void => {
46 let severity = severityOf(a.savings)
47 if (floor && RANK[floor] < RANK[severity]) severity = floor
48 out.push({ ...a, severity })
49 }
50
51 const rereadFiles = rereads.files.filter(
52 f => (f.classes['unchanged-live'] ?? 0) > 0 && (f.wasteEst >= 2_000 || f.reads - (f.classes['engine-deduped'] ?? 0) >= 3),
53 )
54 if (rereadFiles.length > 0)
55 push({
56 id: 'reread-unchanged',
57 title: 'Unchanged files were read again while the earlier copy was still in context',
58 evidence: list(rereadFiles, f => `${base(f.path)} ×${f.reads} (${tok(f.wasteEst)})`),
59 action: 'Refer to the earlier result; for one part, Grep or Read with offset/limit',
60 savings: rereadFiles.reduce((s, f) => s + f.wasteEst, 0),
61 })
62
63 const bigReads = items.filter(i => i.category === 'Read' && i.target?.full === true && est(i) >= 8_000 && !rereads.wasteSeqs.has(i.seq))
64 if (bigReads.length > 0) {
65 const byPath = new Map<string, { count: number; total: number }>()
66 for (const i of bigReads) {
67 const g = byPath.get(i.target!.value) ?? { count: 0, total: 0 }
68 g.count += 1
69 g.total += est(i)
70 byPath.set(i.target!.value, g)
71 }
72 const groups = [...byPath.entries()].sort((x, y) => y[1].total - x[1].total)
73 push({
74 id: 'large-read',
75 title: 'Whole large files read into context',
76 evidence: list(groups, ([path, g]) => `${base(path)}${g.count > 1 ? ` ×${g.count}` : ''} (${tok(g.total)})`),
77 action: 'Grep for the symbol first, then Read with offset/limit',
78 savings: bigReads.reduce((s, i) => s + est(i) - 2_000, 0),
79 })
80 }
81
82 const bigBash = items.filter(i => i.category === 'Bash' && est(i) >= 5_000).sort((a, b) => b.est - a.est)
83 if (bigBash.length > 0)
84 push({
85 id: 'large-bash',
86 title: 'Long command output',
87 evidence: list(bigBash, i => `${short(i.target?.value ?? 'Bash')} (${tok(est(i))})`),
88 action: 'Pipe through tail or grep, or use quiet flags (-q, --silent)',
89 savings: bigBash.reduce((s, i) => s + est(i) - 1_000, 0),
90 })
91
92 const repeated = clusters.filter(c => c.wasteEst > 0 && (c.size >= 3 || c.wasteEst >= 3_000))
93 if (repeated.length > 0)
94 push({
95 id: 'repeated-output',
96 title: 'Near-identical tool output repeated',
97 evidence: list(repeated, c => `${c.category} ${short(c.label)} ×${c.size} (${tok(c.wasteEst)})`),
98 action: 'Narrow the rerun (only the failing test, a filter) or refer back to one result',
99 savings: repeated.reduce((s, c) => s + c.wasteEst, 0),
100 })
101
102 const lost = compactions.filter(c => c.lostEdited.length > 0)
103 if (lost.length > 0)
104 push(
105 {
106 id: 'compaction-lost-edits',
107 title: 'Compaction summaries dropped files that were edited',
108 evidence: list(lost.flatMap(c => c.lostEdited), base, 5),
109 action: 'Say what to keep: /compact keep <files and plan>, or keep the plan in a file',
110 savings: lost.reduce((s, c) => s + c.reflowEst, 0),
111 },
112 'medium',
113 )
114
115 const reflow = compactions.reduce((s, c) => s + c.reflowEst, 0)
116 if (reflow >= 5_000)
117 push({
118 id: 'compaction-reflow',
119 title: 'Files read again after compaction',
120 evidence: list(compactions.filter(c => c.reflowEst > 0), c => `${c.trigger} @turn ${c.turn} (${tok(c.reflowEst)})`),
121 action: 'Compact with instructions naming the key files, or compact at a task boundary',
122 savings: reflow,
123 })
124
125 const verbose = items.filter(i => (i.category === 'Agent' || i.category === 'Task') && est(i) >= 4_000).sort((a, b) => b.est - a.est)
126 if (verbose.length > 0)
127 push({
128 id: 'agent-verbose',
129 title: 'Subagents returned long results',
130 evidence: list(verbose, i => `${short(i.target?.value ?? i.category)} (${tok(est(i))})`),
131 action: 'Ask subagents for a short summary with file:line pointers',
132 savings: verbose.reduce((s, i) => s + est(i) - 1_000, 0),
133 })
134
135 if (breakdown) {
136 const used = new Set(items.map(i => i.category))
137 const servers = new Map<string, { tokens: number; used: boolean }>()
138 for (const t of breakdown.mcpTools) {
139 if (!t.isLoaded) continue
140 const s = servers.get(t.serverName) ?? { tokens: 0, used: false }
141 s.tokens += t.tokens
142 s.used ||= used.has(t.name)
143 servers.set(t.serverName, s)
144 }
145 const idle = [...servers.entries()].filter(([, s]) => !s.used && s.tokens >= 3_000).sort((a, b) => b[1].tokens - a[1].tokens)
146 if (idle.length > 0)
147 push({
148 id: 'mcp-unused',
149 title: 'MCP tool schemas loaded but never called',
150 evidence: list(idle, ([name, s]) => `${name} (${tok(s.tokens)})`),
151 action: 'Disable the server for this project (/mcp), or let tool schemas load on demand',
152 savings: idle.reduce((s, [, x]) => s + x.tokens, 0),
153 })
154 const memory = breakdown.memoryFiles.filter(f => f.tokens >= 4_000).sort((a, b) => b.tokens - a.tokens)
155 if (memory.length > 0)
156 push({
157 id: 'memory-large',
158 title: 'Large memory files loaded with every request',
159 evidence: list(memory, f => `${base(f.path)} (${tok(f.tokens)})`),
160 action: 'Trim CLAUDE.md; move rarely needed detail into files it links to',
161 savings: memory.reduce((s, f) => s + f.tokens - 2_000, 0),
162 })
163 }
164
165 return out.sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.savings - a.savings)
166}
167hooks/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 222 lines1import { extractEntities, flattenMessages, isMentioned, type MessageLike } from './entities.ts'
2import { contentHash, 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 cut = text.length > HASH_CHARS ? text.slice(0, HASH_CHARS) : text
109 return {
110 chars: text.length,
111 est: estimate(text.length, images),
112 hash: contentHash(cut),
113 simhash: tool && text.length >= SIMHASH_MIN_CHARS ? simhash64(normalize(cut)) : undefined,
114 }
115}
116
117// One row entering a conversation: one item per tool_result block, one for the rest of the row.
118export function ingestAppend(l: Ledger, e: AppendLike): void {
119 if (e.message.role === undefined) return // a notice or a record no request carries
120 if (l.revive(e.uuid)) return // re-appended after a compaction
121 const ctx = e.agentId ?? MAIN
122 if (ctx === MAIN && e.door === 'prompt') l.nextTurn()
123 const blocks: unknown[] = Array.isArray(e.message.content) ? e.message.content : [e.message.content]
124 let made = 0
125 const nextUuid = (): string => {
126 made += 1
127 return made === 1 ? e.uuid : `${e.uuid}#${made - 1}`
128 }
129 const rest: unknown[] = []
130 for (const raw of blocks) {
131 const block = (raw ?? {}) as Record<string, unknown>
132 if (block.type !== 'tool_result') {
133 rest.push(raw)
134 continue
135 }
136 const id = str(block.tool_use_id)
137 const info = id ? l.tool(id) : undefined
138 const { text, images } = blockText(block.content)
139 if (id) l.forgetTool(id)
140 if (text === '' && images === 0) continue
141 l.add({
142 uuid: nextUuid(),
143 row: e.uuid,
144 ctx,
145 door: e.door,
146 category: info?.tool ?? e.origin?.tool ?? 'tool',
147 toolUseId: id,
148 target: info?.target,
149 ...measure(text, images, true),
150 isError: block.is_error === true || info?.isError === true || undefined,
151 deduped: info?.deduped || undefined,
152 })
153 }
154 const { text, images } = blockText(rest)
155 if (text === '' && images === 0) return
156 const attachment = e.message.type === 'attachment'
157 const name = e.message.name ?? 'unknown'
158 const filePath = attachment && name === 'file' ? FILE_ATTACHMENT.exec(text)?.[1] : undefined
159 l.add({
160 uuid: nextUuid(),
161 row: e.uuid,
162 ctx,
163 door: e.door,
164 category: attachment ? `attachment:${name}` : e.door,
165 target: filePath ? { kind: 'file', value: unescapeJson(filePath) } : undefined,
166 ...measure(text, images, false),
167 attached: filePath ? true : undefined,
168 })
169}
170
171// A compaction that stands: which items stay in the window, and which entities the result still mentions.
172export function ingestCompaction(l: Ledger, e: CompactIn, r: CompactOut): void {
173 if (e.trigger === 'precompute' || typeof r.skip === 'string' || !r.messages) return
174 const before = new Set(e.messages.flatMap(m => (typeof m.handle === 'string' ? [m.handle] : [])))
175 const keptRows = new Set<string>()
176 const keptTools = new Set<string>()
177 for (const m of r.messages) {
178 // The summary carries a handle too, but not one of the messages it replaced.
179 if (m.handle === undefined || !before.has(m.handle)) continue
180 keptRows.add(m.handle)
181 for (const u of m.toolUses) keptTools.add(u.tool_use_id)
182 for (const t of m.toolResults ?? []) keptTools.add(t.tool_use_id)
183 }
184 const after = flattenMessages(r.messages)
185 const entities = extractEntities(e.messages).map(found => ({ ...found, kept: isMentioned(after, found) }))
186 l.compacted(e.agentId ?? MAIN, keptRows, keptTools, { trigger: e.trigger, tokensBefore: r.tokensBefore, tokensAfter: r.tokensAfter, entities })
187}
188
189// A fresh ledger is read back from the conversation until it holds a prompt or a response: tool results
190// and attachments may arrive before the deferred backfill runs, and backfill skips those.
191export function needsBackfill(l: Ledger): boolean {
192 return !l.snapshot().items.some(i => i.door === 'prompt' || i.door === 'response')
193}
194
195// After a reload, resume or /clear: what the main conversation already holds (no attachments, no timing).
196// Tool calls whose result the ledger already holds were recorded live and are not added again.
197export function backfill(l: Ledger, messages: readonly MessageLike[]): void {
198 const before = l.snapshot().items
199 const held = new Set(before.flatMap(i => (i.toolUseId !== undefined ? [i.toolUseId] : [])))
200 const afterSeq = before.at(-1)?.seq ?? 0
201 messages.forEach((m, i) => {
202 const row = `backfill-${i}`
203 if (m.role === 'assistant') {
204 for (const u of m.toolUses) {
205 if (held.has(u.tool_use_id)) continue
206 ingestToolCall(l, u.tool_use_id, u.tool, undefined, u.input)
207 ingestToolResult(l, u.tool_use_id, { isError: u.isError === true, result: (u as { result?: unknown }).result })
208 }
209 const content = [{ type: 'text', text: m.text }, ...m.toolUses.map(u => ({ type: 'tool_use', name: u.tool, input: u.input }))]
210 ingestAppend(l, { door: 'response', uuid: row, message: { type: 'assistant', role: 'assistant', content } })
211 } else if ((m.toolResults?.length ?? 0) > 0) {
212 const results = (m.toolResults ?? []).filter(t => !held.has(t.tool_use_id))
213 if (results.length === 0) return
214 const content = results.map(t => ({ type: 'tool_result', tool_use_id: t.tool_use_id, content: t.text, is_error: t.isError }))
215 ingestAppend(l, { door: 'tool-result', uuid: row, message: { type: 'user', role: 'user', content } })
216 } else {
217 ingestAppend(l, { door: 'prompt', uuid: row, message: { type: 'user', role: 'user', content: m.text } })
218 }
219 })
220 l.markBackfilled(afterSeq)
221}
222hooks/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 146 lines1import { bar, clip, fmtCount, pct } from './format.ts'
2import type { Scope, Summary } from './summary.ts'
3import { MAIN } from './types.ts'
4
5export const SECTIONS = ['overview', 'occupants', 'rereads', 'similar', 'compactions', 'efficiency', 'advice'] as const
6export type Section = (typeof SECTIONS)[number]
7export type UsageLite = { percent?: number; tokens?: number; window?: number }
8export type PaneLine = { text: string; bold?: boolean }
9
10const tok = (n: number): string => `≈${fmtCount(n)}`
11const signed = (n: number): string => `${n >= 0 ? '+' : '−'}${fmtCount(Math.abs(n))}`
12const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
13const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
14
15export function parseArgs(args: string): { section: Section; scope: Scope; unknown?: string } {
16 const words = args.trim().split(/\s+/).filter(w => w !== '')
17 const scope: Scope = words.includes('--agents') ? 'all' : 'main'
18 const first = words.find(w => w !== '--agents')
19 if (first === undefined) return { section: 'overview', scope }
20 if ((SECTIONS as readonly string[]).includes(first)) return { section: first as Section, scope }
21 return { section: 'overview', scope, unknown: first }
22}
23
24// k at a bound of calibrate's clamp: the measured ratio lies beyond it.
25const clamped = (sum: Summary): boolean => sum.segments > 0 && (sum.k === 0.5 || sum.k === 2)
26
27function header(sum: Summary, usage?: UsageLite): string[] {
28 const where = sum.scope === 'main' ? 'main context' : 'main + subagents'
29 const window =
30 usage?.tokens !== undefined && usage.window ? ` · window ${fmtCount(usage.tokens)}/${fmtCount(usage.window)} tokens (${usage.percent ?? 0}%, measured)` : ''
31 const lines = [
32 `Context Inspector — ${where}${window}`,
33 `Live ${tok(sum.occupancy.liveEst)} tokens in ${sum.occupancy.liveItems} items · ${sum.turns} turn(s) · ${sum.compactions.length} compaction(s) · waste ${tok(sum.wasteEst)}`,
34 `≈ = estimate (chars/4 × k=${sum.k.toFixed(2)}${clamped(sum) ? ' (clamped)' : ''}, ${sum.segments} calibration segment(s)); figures without ≈ are measured or engine-reported`,
35 ]
36 if (sum.dropped + sum.droppedLive > 0) lines.push(`(ledger cap reached: ${sum.dropped} old and ${sum.droppedLive} live items dropped)`)
37 return lines
38}
39
40function occupants(sum: Summary, n: number): string[] {
41 const o = sum.occupancy
42 if (o.liveItems === 0) return ['Top occupants', ' nothing recorded yet']
43 const out = ['Top occupants (live, by category)']
44 for (const r of o.byCategory.slice(0, n))
45 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)`)
46 out.push('Largest live items')
47 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}`)
48 return out
49}
50
51function rereadLines(sum: Summary, n: number): string[] {
52 const r = sum.rereads
53 const out = [`Re-reads — waste now ${tok(r.currentWasteEst)} · session ${tok(r.wasteEst)} · compaction-induced ${tok(r.compactionEst)} · engine-deduped ${r.deduped}`]
54 if (r.files.length === 0) return [...out, ' no file was read twice']
55 for (const f of r.files.slice(0, n)) {
56 const classes = Object.entries(f.classes)
57 .filter(([c]) => c !== 'first')
58 .map(([c, x]) => `${c}×${x}`)
59 .join(' ')
60 out.push(` ${clip(f.path, 50)} ${f.reads} reads ${classes}${f.wasteEst > 0 ? ` waste ${tok(f.wasteEst)}` : ''}`)
61 }
62 return out
63}
64
65function similarLines(sum: Summary, n: number): string[] {
66 const out = [`Similar tool outputs (lexical SimHash ≤3 bits, not semantic) — waste ${tok(sum.similar.wasteEst)}`]
67 if (sum.similar.clusters.length === 0) return [...out, ' none']
68 for (const c of sum.similar.clusters.slice(0, n))
69 out.push(` ${c.category} ×${c.size} ${c.kind} waste ${tok(c.wasteEst)} of ${tok(c.totalEst)} ${clip(c.label, 50)}`)
70 return out
71}
72
73function compactionLines(sum: Summary, n: number): string[] {
74 if (sum.compactions.length === 0) return ['Compactions', ' none yet']
75 const out = ['Compactions (entities still found in the summary and kept messages)']
76 const shown = sum.compactions.slice(-n)
77 shown.forEach((c, i) => {
78 const index = sum.compactions.length - shown.length + i + 1
79 const sizes = c.tokensBefore !== undefined ? ` ${fmtCount(c.tokensBefore)}→${c.tokensAfter !== undefined ? fmtCount(c.tokensAfter) : '?'} tokens` : ''
80 const who = c.ctx === MAIN ? '' : ` (${c.ctx})`
81 out.push(` #${index} ${c.trigger}${who} @turn ${c.turn}${sizes} dropped ${c.droppedItems} item(s) ${tok(c.droppedEst)}`)
82 const s = c.stats
83 const lost = c.lostEdited.length > 0 ? ` · lost edits: ${c.lostEdited.map(base).join(', ')}` : ''
84 const reflow = c.reflowEst > 0 ? ` · reflow ${tok(c.reflowEst)}` : ''
85 out.push(
86 ` 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}`,
87 )
88 })
89 return out
90}
91
92function efficiencyLines(sum: Summary, n: number): string[] {
93 const e = sum.efficiency
94 const out = ['Efficiency (measured input growth vs changed lines; exploration is not a fault)']
95 if (e.turns.length === 0) return [...out, ' no measured requests yet']
96 const median = e.medianPerLine !== undefined ? `median ${fmtCount(e.medianPerLine)} tokens/line` : 'no edits yet'
97 out.push(` ${e.turns.length} turn(s) · +${fmtCount(e.growth)} tokens · ${e.lines} line(s) changed · ${median} · ${e.exploration} exploration turn(s)`)
98 const top = e.turns
99 .filter(t => t.growth !== undefined)
100 .sort((a, b) => (b.growth ?? 0) - (a.growth ?? 0))
101 .slice(0, n)
102 for (const t of top) {
103 const tail = t.perLine !== undefined ? ` ${fmtCount(t.perLine)} tokens/line` : t.lines === 0 && (t.growth ?? 0) > 0 ? ' exploration' : ''
104 out.push(` turn ${t.turn} ${signed(t.growth ?? 0)} ${t.lines} line(s)${tail}`)
105 }
106 return out
107}
108
109function adviceLines(sum: Summary, n: number): string[] {
110 const out = [`Advice${sum.hasBreakdown ? '' : ' (MCP and memory checks run from /context-inspect)'}`]
111 if (sum.advice.length === 0) return [...out, ' nothing stands out']
112 for (const a of sum.advice.slice(0, n)) {
113 out.push(` [${a.severity}] ${a.title} — saves ${tok(a.savings)}`)
114 out.push(` ${a.evidence}`)
115 out.push(` → ${a.action}`)
116 }
117 return out
118}
119
120const PARTS: [Section, (sum: Summary, n: number) => string[]][] = [
121 ['occupants', occupants],
122 ['rereads', rereadLines],
123 ['similar', similarLines],
124 ['compactions', compactionLines],
125 ['efficiency', efficiencyLines],
126 ['advice', adviceLines],
127]
128
129export function renderReport(sum: Summary, section: Section = 'overview', usage?: UsageLite): string {
130 const n = section === 'overview' ? 3 : 15
131 const blocks: string[][] = [header(sum, usage)]
132 for (const [name, part] of PARTS) if (section === 'overview' || section === name) blocks.push(part(sum, n))
133 return blocks.map(b => b.join('\n')).join('\n\n')
134}
135
136export function statusLine(sum: Summary, percent?: number): string {
137 const head = percent !== undefined ? `ctx ${percent}%` : `ctx ${tok(sum.occupancy.liveEst)}`
138 return `${head} · waste${tok(sum.wasteEst)}`
139}
140
141export function paneLines(sum: Summary, width: number): PaneLine[] {
142 return renderReport(sum, 'overview')
143 .split('\n')
144 .map(text => ({ text: clip(text, Math.max(20, width)), bold: text !== '' && /^[A-Z]/.test(text) }))
145}
146hooks/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