SLOPSHOPPER

context-inspector

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

newpaneguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-09YeonwooSung/my-claude-code-mods/context-inspector
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · context-inspector
│ ┃ Context Inspector ✕ › fix the failing auth test and add an audit log call │ ┃ Context Inspector — main context │ ┃ Live ≈171 tokens in 8 items · 0 turn(s) · 0 ⏺ Read(src/auth.ts) │ ┃ compaction(… ⎿ Read 6 lines │ ┃ ≈ = estimate (chars/4 × k=1.00, 0 ⏺ Update(src/auth.ts) │ ┃ calibration segment(s… ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ Top occupants (live, by category) ⎿ 3 pass, 1 fail │ ┃ Bash ≈76 44% │ ┃ ████░░░░░░… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Read ≈49 29% │ ┃ ███░░░░░░░… ✻ Worked for 42s · done 4:20 PM │ ┃ Write ≈26 15% │ ┃ ██░░░░░░░░… › /context-inspect │ ┃ Largest live items ⎿ context-inspector: Context Inspector — main context · window 97. │ ┃ ≈49 Read ⎿ context-inspector: Live ≈171 tokens in 8 items · 0 turn(s) · 0 c │ ┃ /work/app/src/auth.ts · turn 0 ⎿ context-inspector: ≈ = estimate (chars/4 × k=1.00, 0 calibration │ ┃ ≈40 Bash bun test · turn 0 ⎿ context-inspector: │ ┃ ≈28 Bash cat .env · turn 0 ⎿ context-inspector: Top occupants (live, by category) │ ┃ ⎿ context-inspector: Bash ≈76 44% │ ┃ Re-reads — waste now ≈0 · session ≈0 · │ ┃ compaction-induc… │ ┃ no file was read twice │ ┃ │ ┃ Similar tool outputs (lexical SimHash ≤3 │ ┃ bits, not sema… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Context Inspector
Context Inspector — main context Live ≈171 tokens in 8 items · 0 turn(s) · 0 compaction(… ≈ = estimate (chars/4 × k=1.00, 0 calibration segment(s… Top occupants (live, by category) Bash ≈76 44% ████░░░░░░… Read ≈49 29% ███░░░░░░░… Write ≈26 15% ██░░░░░░░░… Largest live items ≈49 Read /work/app/src/auth.ts · turn 0 ≈40 Bash bun test · turn 0 ≈28 Bash cat .env · turn 0 Re-reads — waste now ≈0 · session ≈0 · compaction-induc… no file was read twice Similar tool outputs (lexical SimHash ≤3 bits, not sema… none Compactions none yet Efficiency (measured input growth vs changed lines; exp… 1 turn(s) · +0 tokens · 15 line(s) changed · no edits… Advice (MCP and memory checks run from /context-inspect) nothing stands out
README

Context Inspector — 컨텍스트 낭비 분석기

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(이전 사본이 아직 창에 있음 = 순수 낭비).
  • 유사 출력: 같은 해시(정확) 또는 64비트 SimHash 해밍 거리 ≤ 3(근사). 단어 3-shingle 기반 어휘 유사도이며 의미(임베딩) 유사도가 아니다. 숫자는 #로 접어 실행 시간만 다른 테스트 출력도 묶인다.
  • 컴팩션: 컴팩션 전 대화에서 편집 파일·읽은 파일·오류·사용자 식별자를 뽑아, 요약과 유지된 메시지에 남았는지 본다. 잃은 편집 파일과, 컴팩션 때문에 다시 읽은 양(reflow)을 보고한다.
  • 효율: 메인 턴별 측정 입력 증가량 대비 변경 라인 수(Edit/MultiEdit/Write/NotebookEdit). 편집 없이 커진 턴은 "탐색 턴"으로 표시만 한다(탐색은 결함이 아니다).
  • 추천: 무변경 재읽기, 큰 파일 통째 읽기, 긴 명령 출력, 반복 출력, 컴팩션이 잃은 편집 파일, 컴팩션 후 재유입, 긴 서브에이전트 결과, 호출되지 않은 MCP 서버, 큰 메모리 파일. 각 추천에 근거와 ≈절감량이 붙는다.

설정 (userConfig)

키기본뜻
outputDir~/.claude/context-inspector/context-export 위치
toastAdvicetruehigh 추천이 처음 생길 때 세션당 한 번 토스트

내보내기 형식

한 줄에 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)로 알아챈다.
  • 복구는 그 뒤 한 번, 지연 실행되며 아직 prompt/response가 기록되지 않았을 때만 돈다. 그 전에 먼저 들어온 도구 결과는 tool_use_id로 알아보고 다시 넣지 않는다.
  • 복구된 prompt/response 항목에는 엔진 핸들이 없다($.session.messages()가 주지 않는다). 그래서 리로드 후 첫 컴팩션은 이들을 전부 "버려진 것"으로 센다. 도구 결과는 tool_use_id로 계속 대응된다.
  • 원장 상한 20,000 항목(넘치면 창 밖 항목부터 버림).
  • 컴팩션 보존 판정은 문자열 포함 여부다(파일은 basename도 인정) — 요약이 다른 말로 바꿔 쓴 것은 잃은 것으로 센다.
  • 유사도 버킷은 밴드 값마다 서로 다른 대표를 최대 256개까지만 보관·비교한다(수천 개의 거의 같은 출력도 대표끼리는 묶인다).
  • 내보내기 파일 이름은 항상 -<n>으로 끝난다(파일 하나면 context-<load>-1.jsonl). 같은 로드 안에서 /context-export를 다시 실행하면 그 로드의 파일을 전체 새 스냅샷으로 덮어쓴다.
  • large-read·large-bash·agent-verbose·reread-unchanged 같은 추천은 세션에서 일어난 모든 일을 대상으로 하며(습관 추천), 이후 컴팩션이 지운 출력도 포함한다. 상태줄의 waste와는 다르다.
  • 원장이 상한에 닿아 항목을 버리면, 버린 항목에 걸친 보정 구간은 k 계산에서 뺀다.

개발

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-inspector
Source 20 files
hooks/register.tsx 236 lines
1import 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}
236
hooks/core/advice.ts 167 lines
1import 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}
167
hooks/core/export.ts 95 lines
1import { 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}
95
hooks/core/format.ts 22 lines
1export 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}
22
hooks/core/ingest.ts 222 lines
1import { 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}
222
hooks/core/ledger.ts 189 lines
1import 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}
189
hooks/core/report.ts 146 lines
1import { 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}
146
hooks/core/summary.ts 59 lines
1import { 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}
59
hooks/core/tokens.ts 48 lines
1import 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}
48
hooks/core/types.ts 75 lines
1// 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}
75
hooks/core/analyze/compaction.ts 53 lines
1import 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}
53
hooks/core/analyze/rereads.ts 90 lines
1import 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