SLOPSHOPPER

context-inspector

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

newpaneguardcommandtoaststatus
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 — context waste analyzer

English | 한국어

A mod that shows what fills the context window in a Claude Code session, why the same information comes back in again, and what compaction kept and lost. It only observes: it never changes rows, tool calls or compaction.

Install

/plugin marketplace add YeonwooSung/my-claude-code-mods
/plugin install context-inspector@my-claude-code-mods

Requires Claude Code 2.1.287 or later (mods enabled by default). Older builds need CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.

Commands

CommandWhat it does
`/context-inspect [overview\occupants\rereads\similar\compactions\efficiency\advice] [--agents]`Text report. Defaults to the overview of the main context; --agents includes subagents
/context-paneLive pane (terminal, desktop app)
/context-exportSaves metadata only, no raw content, as JSONL: <outputDir>/<sessionId>/context-<load>-<n>.jsonl

Status line: ctx 62% · waste≈8.1k (window occupancy is measured, waste is estimated). Waste is the waste still in the window now — it shrinks when compaction drops duplicate copies. The whole-session re-read cost stays in session ≈… of /context-inspect rereads.

What it measures — observability grades

GradeExamples
measuredInput tokens the API reported for each request (uncached + cache read + cache write), tokens before/after compaction
observedCharacter counts and hashes of rows that entered the conversation, message sets before/after compaction
estimated (≈)Per-item tokens = chars/4 (images 1,500) × calibration factor k (calibrated against measured input growth, between 0.5 and 2.0; the report shows (clamped) when it hits a bound)

Per-token attention inside the model and exact occupancy are not visible from outside, so the mod does not claim them.

Analyses

  • Occupants: items still in the current window, by category (tool name, attachment:<name>, prompt/response) and by target (file, command).
  • Re-reads: classifies every read of a file after the first — engine-deduped (the engine replaced it with an "unchanged" notice), restored (the engine re-attached it after compaction), different-range, after-edit, after-compaction, changed-externally, unchanged-live (the earlier copy is still in the window = pure waste).
  • Similar outputs: same full-content hash (exact) or 64-bit SimHash Hamming distance ≤ 3 (near). This is lexical similarity over word 3-shingles, not semantic (embedding) similarity. Numbers are folded to #, so test outputs that differ only in run time are grouped. Comparisons and window waste are computed separately per context. Outputs longer than 262,144 characters or 20,000 normalized words participate in exact matching only; a partial sample cannot justify whole-output near-duplicate waste.
  • Compactions: extracts edited files, read files, errors and user identifiers from the pre-compaction conversation and checks string presence in the summary and the kept messages. Presence does not establish that the associated decisions or constraints survived. Reports lost edited files and how much was re-read because of the compaction (reflow).
  • Efficiency: lines changed (Edit/MultiEdit/Write/NotebookEdit) against measured input growth for each main turn. Turns that grew without edits are only marked as "exploration turns" (exploration is not a defect).
  • Advice: unchanged re-reads, whole reads of large files, long command output, repeated output, edited files lost by compaction, re-inflow after compaction, long subagent results, MCP servers never called, large memory files. Each piece of advice comes with its evidence and ≈savings.

Settings (userConfig)

KeyDefaultMeaning
outputDir~/.claude/context-inspectorWhere /context-export writes
toastAdvicetrueOne toast per session the first time a high-priority piece of advice appears

Export format

One JSON object per line: meta, item (seq, ctx, turn, door, category, target, chars, est, hash, simhash, born, epoch, live …), edit, sample, compaction, summary. No raw content. Non-file targets (commands, patterns, URLs and agent descriptions) are cut to 200 characters after best-effort secret masking. Masking covers secret environment assignments, Authorization/Bearer credentials, --password/--token/--api-key/--secret options, headers ending in -key/-token/-secret, URL passwords, curl -u/--user credentials, mysql -p passwords and common JSON credential fields. Shell-quoted headers treat escaped quotes as part of the value. File paths are retained unchanged. This is not a complete secret detector: review exports before sharing. Existing export files are not retroactively sanitized by an update; review or remove older files, and rotate any real credentials that were exposed. Errors and user identifiers among compaction entities, and item contents, are kept only as hashes (unsalted; short values can be confirmed by guessing) — this is not anonymization. summary carries wasteEst (window now) together with currentWasteEst and sessionRereadEst (re-read waste: window now / session total).

Environments

EnvironmentBehavior
TerminalStatus line, pane, toasts and commands
VS Code chat panelHooks and command text only (the mod's UI is not shown) — /context-inspect is the main output
claude -pRecording only; commands can run with -p "/context-inspect"

Limitations

  • On reload, resume or /clear, the ledger is cleared and only the main conversation is recovered from $.session.messages() (no attachments, subagents or timing). /clear is detected from session.end (reason clear) without a session.start.
  • Recovery runs once afterwards, deferred, and only while no prompt/response has been recorded yet. Tool results that arrived before it are recognized by tool_use_id and not added twice.
  • Recovered prompt/response items have no engine handle ($.session.messages() does not provide one). So the first compaction after a reload counts all of them as "dropped". Tool results keep matching by tool_use_id.
  • The ledger is capped at 20,000 items (on overflow, items outside the window are dropped first).
  • Compaction survival is judged by substring match (files also match by basename) — anything the summary paraphrases counts as lost.
  • Each similarity bucket keeps and compares at most 256 distinct representatives per band value (thousands of nearly identical outputs still group through their representatives).
  • Export file names always end in -<n> (a single file is context-<load>-1.jsonl). Running /context-export again within the same load overwrites that load's files with a full new snapshot.
  • Advice such as large-read, large-bash, agent-verbose and reread-unchanged covers everything that happened in the session (habit advice), including output a later compaction removed. It differs from the status line's waste.
  • When the ledger hits its cap and drops items, calibration intervals spanning dropped items are left out of the k calculation.

Reports, panels and display labels mask supported credential formats before truncation; this does not change the underlying analysis. Existing saved files are not rewritten.

Development

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.3'
14const PANE = 'context-inspector'
15const COMMANDS = [
16  {
17    name: 'context-inspect',
18    description: 'Show what fills the context window and what is wasted: [overview|occupants|rereads|similar|compactions|efficiency|advice] [--agents]',
19  },
20  { name: 'context-pane', description: 'Open the live Context Inspector pane' },
21  { name: 'context-export', description: "Write this session's context ledger as JSONL (metadata only, no content)" },
22]
23// Names this module load's export files, so a reload never overwrites an earlier load's.
24const LOAD_ID = Date.now().toString(36)
25
26let ledger = new Ledger()
27let percent: number | undefined
28let paintQueued = false
29let toasted = false
30const cache = new Map<Scope, { ledger: Ledger; version: number; sum: Summary }>()
31
32// The inspector never breaks the session: every bookkeeping step goes through here.
33function safe(fn: () => void): void {
34  try {
35    fn()
36  } catch {
37    // dropped on purpose
38  }
39}
40
41function summary(scope: Scope = 'main'): Summary {
42  const hit = cache.get(scope)
43  if (hit && hit.ledger === ledger && hit.version === ledger.version) return hit.sum
44  const sum = summarize(ledger.snapshot(), scope)
45  cache.set(scope, { ledger, version: ledger.version, sum })
46  return sum
47}
48
49// A new conversation in this process (start, reload, resume, /clear): forget the old one and read back,
50// off the calling path, what the new one already holds. Rows that arrive before the read-back are kept.
51function reset($: EngineInterface): void {
52  const own = new Ledger()
53  ledger = own
54  cache.clear()
55  toasted = false
56  percent = undefined
57  safe(() =>
58    $.clock.after(0, async () => {
59      try {
60        const messages = await $.session.messages()
61        if (own === ledger && needsBackfill(own) && messages.length > 0) backfill(own, messages)
62      } catch {
63        // nothing to read back
64      }
65    }),
66  )
67}
68
69// Paints are queued behind the event that asked, at most one pending.
70function paint($: EngineInterface, options: PluginOptions): void {
71  if (paintQueued) return
72  paintQueued = true
73  try {
74    $.clock.after(0, async () => {
75      paintQueued = false
76      try {
77        percent = (await $.session.usage()).context.percent
78      } catch {
79        // keep the last figure
80      }
81      safe(() => {
82        const sum = summary()
83        $.ui.status(statusLine(sum, percent))
84        $.ui.invalidate('ui.render')
85        const high = sum.advice.find(a => a.severity === 'high')
86        if (high && !toasted && options.toastAdvice !== false) {
87          toasted = true
88          $.ui.toast(`context-inspector: ${high.title} (saves ≈${fmtCount(high.savings)}) — /context-inspect advice`)
89        }
90      })
91    })
92  } catch {
93    paintQueued = false
94  }
95}
96
97const reportFailed = (error: unknown): string => `context-inspector: report failed: ${error instanceof Error ? error.message : String(error)}`
98
99async function measured($: EngineInterface): Promise<{ usage?: UsageLite; breakdown?: BreakdownLite }> {
100  try {
101    const u = await $.session.usage({ breakdown: 'summary' })
102    const b = u.context.breakdown
103    return {
104      usage: { percent: u.context.percent, tokens: u.context.tokens, window: u.context.window },
105      breakdown: b
106        ? {
107            mcpTools: b.mcpTools.map(t => ({ name: t.name, serverName: t.serverName, tokens: t.tokens, isLoaded: t.isLoaded })),
108            memoryFiles: b.memoryFiles.map(f => ({ path: f.path, tokens: f.tokens })),
109          }
110        : undefined,
111    }
112  } catch {
113    return {}
114  }
115}
116
117async function outputDir($: EngineInterface, options: PluginOptions): Promise<string> {
118  const dir = (String(options.outputDir ?? '').trim() || '~/.claude/context-inspector').replace(/\/+$/, '')
119  if (!/^~(?=\/|$)/.test(dir)) return dir
120  return dir.replace(/^~/, (await $.env.get('HOME')) ?? '.')
121}
122
123async function exportLedger($: EngineInterface, options: PluginOptions): Promise<string> {
124  let sessionId = 'unknown'
125  try {
126    sessionId = await $.session.id()
127  } catch {
128    // files go under 'unknown'
129  }
130  const dir = `${await outputDir($, options)}/${sessionId}`
131  const snap = ledger.snapshot()
132  const chunks = chunkLines(exportLines(snap, summarize(snap, 'all'), { sessionId, version: VERSION, at: new Date().toISOString() }))
133  const written: string[] = []
134  for (const [i, body] of chunks.entries()) {
135    const path = `${dir}/context-${LOAD_ID}-${i + 1}.jsonl`
136    await $.fs.write(path, body)
137    written.push(path)
138  }
139  return `wrote ${written.join(', ')}`
140}
141
142export const register: Register = (on, options) => {
143  on('session.start', async ($, e, next) => {
144    safe(() => reset($))
145    for (const command of COMMANDS) {
146      try {
147        await $.command.register(command)
148      } catch {
149        // the command stays unavailable
150      }
151    }
152    return next(e)
153  })
154
155  // After /clear (or when another session takes this one's place) the process goes on under a new
156  // session id and no session.start fires: this is where the old conversation's ledger is dropped.
157  on('session.end', async ($, e, next) => {
158    const result = await next(e)
159    if (e.reason === 'clear' || e.reason === 'resume') safe(() => reset($))
160    return result
161  }).catch(($, e, next) => next(e))
162
163  on('session.append', async ($, e, next) => {
164    safe(() => ingestAppend(ledger, e))
165    return next(e)
166  }).catch(($, e, next) => next(e))
167
168  on('tool.call', async ($, e, next) => {
169    const id = e.tool_use_id
170    if (id !== undefined) safe(() => ingestToolCall(ledger, id, String(e.tool), e.agentId, e as unknown as Record<string, unknown>))
171    const answer = await next(e)
172    if (id !== undefined) safe(() => ingestToolResult(ledger, id, answer))
173    return answer
174  }).catch(($, e, next) => next(e))
175
176  on('turn.step', async function* ($, e, next) {
177    for await (const chunk of next(e)) {
178      if (chunk.kind === 'stop') safe(() => ledger.sample(e.agentId ?? MAIN, inputOf(chunk.usage)))
179      yield chunk
180    }
181  })
182
183  on('session.compact', async ($, e, next) => {
184    const result = await next(e)
185    safe(() => ingestCompaction(ledger, e, result))
186    paint($, options)
187    return result
188  }).catch(($, e, next) => next(e))
189
190  on('turn.complete', async ($, e, next) => {
191    paint($, options)
192    return next(e)
193  })
194
195  on('command.run', { command: 'context-inspect' }, async ($, e) => {
196    const { section, scope, unknown } = parseArgs(e.args)
197    const { usage, breakdown } = await measured($)
198    try {
199      const text = renderReport(summarize(ledger.snapshot(), scope, breakdown), section, usage)
200      return { text: unknown ? `unknown section '${unknown}'; showing the overview\n\n${text}` : text }
201    } catch (error) {
202      return { text: reportFailed(error) }
203    }
204  })
205
206  on('command.run', { command: 'context-pane' }, async $ => {
207    await $.ui.open({ id: PANE, title: 'Context Inspector' })
208    return { text: 'Context Inspector pane opened (shown in the terminal and the desktop app).' }
209  })
210
211  on('command.run', { command: 'context-export' }, async $ => {
212    try {
213      return { text: await exportLedger($, options) }
214    } catch (error) {
215      return { text: `export failed: ${String(error)}` }
216    }
217  })
218
219  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
220    const { Box, Text } = $.ui.resolve(e)
221    let lines
222    try {
223      lines = paneLines(summary(), e.props.bodyColumns ?? 60)
224    } catch (error) {
225      return <Text>{reportFailed(error)}</Text>
226    }
227    return (
228      <Box flexDirection="column">
229        {lines.map(line => (
230          <Text bold={line.bold}>{line.text || ' '}</Text>
231        ))}
232      </Box>
233    )
234  })
235}
236
hooks/core/advice.ts 168 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'
6import { redactSecrets } from './redact.ts'
7
8export type Severity = 'high' | 'medium' | 'low'
9export type Advice = { id: string; severity: Severity; title: string; evidence: string; action: string; savings: number }
10export type BreakdownLite = {
11  mcpTools: { name: string; serverName: string; tokens: number; isLoaded: boolean }[]
12  memoryFiles: { path: string; tokens: number }[]
13}
14export type AdviceInput = {
15  items: readonly ContextItem[]
16  k: number
17  rereads: Rereads
18  clusters: readonly Cluster[]
19  compactions: readonly CompactionView[]
20  breakdown?: BreakdownLite
21}
22
23const RANK: Record<Severity, number> = { high: 0, medium: 1, low: 2 }
24
25export function severityOf(savings: number): Severity {
26  return savings >= 10_000 ? 'high' : savings >= 3_000 ? 'medium' : 'low'
27}
28
29const tok = (n: number): string => `≈${fmtCount(n)}`
30const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
31const short = (s: string): string => {
32  const line = redactSecrets(s).replace(/\s+/g, ' ').trim()
33  return line.length > 40 ? `${line.slice(0, 39)}…` : line
34}
35
36function list<T>(xs: readonly T[], show: (x: T) => string, n = 3): string {
37  const head = xs.slice(0, n).map(show).join(', ')
38  return xs.length > n ? `${head}, +${xs.length - n} more` : head
39}
40
41// Rule-based recommendations (spec §4.6); every one names its evidence and an estimated saving.
42export function advise(input: AdviceInput): Advice[] {
43  const { items, k, rereads, clusters, compactions, breakdown } = input
44  const est = (i: ContextItem): number => Math.round(i.est * k)
45  const out: Advice[] = []
46  const push = (a: Omit<Advice, 'severity'>, floor?: Severity): void => {
47    let severity = severityOf(a.savings)
48    if (floor && RANK[floor] < RANK[severity]) severity = floor
49    out.push({ ...a, severity })
50  }
51
52  const rereadFiles = rereads.files.filter(
53    f => (f.classes['unchanged-live'] ?? 0) > 0 && (f.wasteEst >= 2_000 || f.reads - (f.classes['engine-deduped'] ?? 0) >= 3),
54  )
55  if (rereadFiles.length > 0)
56    push({
57      id: 'reread-unchanged',
58      title: 'Unchanged files were read again while the earlier copy was still in context',
59      evidence: list(rereadFiles, f => `${base(f.path)} ×${f.reads} (${tok(f.wasteEst)})`),
60      action: 'Refer to the earlier result; for one part, Grep or Read with offset/limit',
61      savings: rereadFiles.reduce((s, f) => s + f.wasteEst, 0),
62    })
63
64  const bigReads = items.filter(i => i.category === 'Read' && i.target?.full === true && est(i) >= 8_000 && !rereads.wasteSeqs.has(i.seq))
65  if (bigReads.length > 0) {
66    const byPath = new Map<string, { count: number; total: number }>()
67    for (const i of bigReads) {
68      const g = byPath.get(i.target!.value) ?? { count: 0, total: 0 }
69      g.count += 1
70      g.total += est(i)
71      byPath.set(i.target!.value, g)
72    }
73    const groups = [...byPath.entries()].sort((x, y) => y[1].total - x[1].total)
74    push({
75      id: 'large-read',
76      title: 'Whole large files read into context',
77      evidence: list(groups, ([path, g]) => `${base(path)}${g.count > 1 ? ` ×${g.count}` : ''} (${tok(g.total)})`),
78      action: 'Grep for the symbol first, then Read with offset/limit',
79      savings: bigReads.reduce((s, i) => s + est(i) - 2_000, 0),
80    })
81  }
82
83  const bigBash = items.filter(i => i.category === 'Bash' && est(i) >= 5_000).sort((a, b) => b.est - a.est)
84  if (bigBash.length > 0)
85    push({
86      id: 'large-bash',
87      title: 'Long command output',
88      evidence: list(bigBash, i => `${short(i.target?.value ?? 'Bash')} (${tok(est(i))})`),
89      action: 'Pipe through tail or grep, or use quiet flags (-q, --silent)',
90      savings: bigBash.reduce((s, i) => s + est(i) - 1_000, 0),
91    })
92
93  const repeated = clusters.filter(c => c.wasteEst > 0 && (c.size >= 3 || c.wasteEst >= 3_000))
94  if (repeated.length > 0)
95    push({
96      id: 'repeated-output',
97      title: 'Near-identical tool output repeated',
98      evidence: list(repeated, c => `${c.category} ${short(c.label)} ×${c.size} (${tok(c.wasteEst)})`),
99      action: 'Narrow the rerun (only the failing test, a filter) or refer back to one result',
100      savings: repeated.reduce((s, c) => s + c.wasteEst, 0),
101    })
102
103  const lost = compactions.filter(c => c.lostEdited.length > 0)
104  if (lost.length > 0)
105    push(
106      {
107        id: 'compaction-lost-edits',
108        title: 'Compaction summaries dropped files that were edited',
109        evidence: list(lost.flatMap(c => c.lostEdited), base, 5),
110        action: 'Say what to keep: /compact keep <files and plan>, or keep the plan in a file',
111        savings: lost.reduce((s, c) => s + c.reflowEst, 0),
112      },
113      'medium',
114    )
115
116  const reflow = compactions.reduce((s, c) => s + c.reflowEst, 0)
117  if (reflow >= 5_000)
118    push({
119      id: 'compaction-reflow',
120      title: 'Files read again after compaction',
121      evidence: list(compactions.filter(c => c.reflowEst > 0), c => `${c.trigger} @turn ${c.turn} (${tok(c.reflowEst)})`),
122      action: 'Compact with instructions naming the key files, or compact at a task boundary',
123      savings: reflow,
124    })
125
126  const verbose = items.filter(i => (i.category === 'Agent' || i.category === 'Task') && est(i) >= 4_000).sort((a, b) => b.est - a.est)
127  if (verbose.length > 0)
128    push({
129      id: 'agent-verbose',
130      title: 'Subagents returned long results',
131      evidence: list(verbose, i => `${short(i.target?.value ?? i.category)} (${tok(est(i))})`),
132      action: 'Ask subagents for a short summary with file:line pointers',
133      savings: verbose.reduce((s, i) => s + est(i) - 1_000, 0),
134    })
135
136  if (breakdown) {
137    const used = new Set(items.map(i => i.category))
138    const servers = new Map<string, { tokens: number; used: boolean }>()
139    for (const t of breakdown.mcpTools) {
140      if (!t.isLoaded) continue
141      const s = servers.get(t.serverName) ?? { tokens: 0, used: false }
142      s.tokens += t.tokens
143      s.used ||= used.has(t.name)
144      servers.set(t.serverName, s)
145    }
146    const idle = [...servers.entries()].filter(([, s]) => !s.used && s.tokens >= 3_000).sort((a, b) => b[1].tokens - a[1].tokens)
147    if (idle.length > 0)
148      push({
149        id: 'mcp-unused',
150        title: 'MCP tool schemas loaded but never called',
151        evidence: list(idle, ([name, s]) => `${name} (${tok(s.tokens)})`),
152        action: 'Disable the server for this project (/mcp), or let tool schemas load on demand',
153        savings: idle.reduce((s, [, x]) => s + x.tokens, 0),
154      })
155    const memory = breakdown.memoryFiles.filter(f => f.tokens >= 4_000).sort((a, b) => b.tokens - a.tokens)
156    if (memory.length > 0)
157      push({
158        id: 'memory-large',
159        title: 'Large memory files loaded with every request',
160        evidence: list(memory, f => `${base(f.path)} (${tok(f.tokens)})`),
161        action: 'Trim CLAUDE.md; move rarely needed detail into files it links to',
162        savings: memory.reduce((s, f) => s + f.tokens - 2_000, 0),
163      })
164  }
165
166  return out.sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.savings - a.savings)
167}
168
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 224 lines
1import { extractEntities, flattenMessages, isMentioned, type MessageLike } from './entities.ts'
2import { contentHash, MAX_SIMHASH_WORDS, simhash64 } from './hash.ts'
3import { Ledger, type ToolInfo } from './ledger.ts'
4import { changedLines, EDIT_TOOLS } from './lines.ts'
5import { blockText, HASH_CHARS, normalize } from './text.ts'
6import { estimate } from './tokens.ts'
7import { MAIN, type Target } from './types.ts'
8
9// The parts of a `session.append` input the ledger reads.
10export type AppendLike = {
11  door: string
12  uuid: string
13  agentId?: string
14  origin?: { kind: string; tool?: string }
15  message: { type: string; name?: string; role?: string; content: unknown }
16}
17
18export type CompactIn = { trigger: string; agentId?: string; messages: readonly MessageLike[] }
19export type CompactOut = { messages?: readonly MessageLike[]; tokensBefore?: number; tokensAfter?: number; skip?: string }
20
21const SIMHASH_MIN_CHARS = 200
22const FILE_ATTACHMENT = /"file_path"\s*:\s*"((?:[^"\\]|\\.)*)"/
23
24const str = (v: unknown): string | undefined => (typeof v === 'string' && v !== '' ? v : undefined)
25
26function unescapeJson(s: string): string {
27  try {
28    return JSON.parse(`"${s}"`) as string
29  } catch {
30    return s
31  }
32}
33
34export function targetOf(tool: string, input: Record<string, unknown>): Target | undefined {
35  switch (tool) {
36    case 'Read': {
37      const path = str(input.file_path)
38      if (!path) return undefined
39      const offset = typeof input.offset === 'number' ? input.offset : undefined
40      const limit = typeof input.limit === 'number' ? input.limit : undefined
41      if (offset === undefined && limit === undefined) return { kind: 'file', value: path }
42      return { kind: 'file', value: path, range: `${offset ?? 1}:${limit ?? 'all'}` }
43    }
44    case 'Edit':
45    case 'MultiEdit':
46    case 'Write': {
47      const path = str(input.file_path)
48      return path ? { kind: 'file', value: path } : undefined
49    }
50    case 'NotebookEdit': {
51      const path = str(input.notebook_path)
52      return path ? { kind: 'file', value: path } : undefined
53    }
54    case 'Bash': {
55      const command = str(input.command)
56      return command ? { kind: 'command', value: command } : undefined
57    }
58    case 'Grep':
59    case 'Glob': {
60      const pattern = str(input.pattern)
61      if (!pattern) return undefined
62      const where = str(input.path)
63      return { kind: 'pattern', value: where ? `${pattern} in ${where}` : pattern }
64    }
65    case 'WebFetch': {
66      const url = str(input.url)
67      return url ? { kind: 'url', value: url } : undefined
68    }
69    case 'WebSearch': {
70      const query = str(input.query)
71      return query ? { kind: 'pattern', value: query } : undefined
72    }
73    case 'Agent':
74    case 'Task': {
75      const agent = str(input.subagent_type) ?? str(input.description)
76      return agent ? { kind: 'agent', value: agent } : undefined
77    }
78    default:
79      return undefined
80  }
81}
82
83// Before the call runs: what it targets and, for an edit, how many lines it changes.
84export function ingestToolCall(l: Ledger, id: string, tool: string, agentId: string | undefined, input: Record<string, unknown>): void {
85  const info: ToolInfo = { tool, ctx: agentId ?? MAIN, target: targetOf(tool, input) }
86  if (EDIT_TOOLS.has(tool)) info.lines = changedLines(tool, input)
87  l.toolStart(id, info)
88}
89
90// After the call: an engine-deduplicated Read, whether a Read was whole, an error or a deny.
91export function ingestToolResult(l: Ledger, id: string, answer: unknown): void {
92  const a = (answer ?? {}) as Record<string, unknown>
93  const result = (a.result ?? {}) as Record<string, unknown>
94  const file = (result.file ?? {}) as Record<string, unknown>
95  const { startLine, numLines, totalLines } = file
96  const full =
97    typeof startLine === 'number' && typeof numLines === 'number' && typeof totalLines === 'number'
98      ? startLine <= 1 && numLines >= totalLines
99      : undefined
100  l.toolEnd(id, {
101    deduped: result.type === 'file_unchanged',
102    isError: a.isError === true || typeof a.deny === 'string',
103    full,
104  })
105}
106
107function measure(text: string, images: number, tool: boolean): { chars: number; est: number; hash: string; simhash?: string } {
108  const normalized = tool && text.length >= SIMHASH_MIN_CHARS && text.length <= HASH_CHARS ? normalize(text) : undefined
109  const complete = normalized !== undefined && normalized.split(' ').filter(Boolean).length <= MAX_SIMHASH_WORDS
110  return {
111    chars: text.length,
112    est: estimate(text.length, images),
113    hash: contentHash(text),
114    // Near similarity cannot justify whole-output waste when only a prefix was measured.
115    simhash: complete ? simhash64(normalized!) : undefined,
116  }
117}
118
119// One row entering a conversation: one item per tool_result block, one for the rest of the row.
120export function ingestAppend(l: Ledger, e: AppendLike): void {
121  if (e.message.role === undefined) return // a notice or a record no request carries
122  if (l.revive(e.uuid)) return // re-appended after a compaction
123  const ctx = e.agentId ?? MAIN
124  if (ctx === MAIN && e.door === 'prompt') l.nextTurn()
125  const blocks: unknown[] = Array.isArray(e.message.content) ? e.message.content : [e.message.content]
126  let made = 0
127  const nextUuid = (): string => {
128    made += 1
129    return made === 1 ? e.uuid : `${e.uuid}#${made - 1}`
130  }
131  const rest: unknown[] = []
132  for (const raw of blocks) {
133    const block = (raw ?? {}) as Record<string, unknown>
134    if (block.type !== 'tool_result') {
135      rest.push(raw)
136      continue
137    }
138    const id = str(block.tool_use_id)
139    const info = id ? l.tool(id) : undefined
140    const { text, images } = blockText(block.content)
141    if (id) l.forgetTool(id)
142    if (text === '' && images === 0) continue
143    l.add({
144      uuid: nextUuid(),
145      row: e.uuid,
146      ctx,
147      door: e.door,
148      category: info?.tool ?? e.origin?.tool ?? 'tool',
149      toolUseId: id,
150      target: info?.target,
151      ...measure(text, images, true),
152      isError: block.is_error === true || info?.isError === true || undefined,
153      deduped: info?.deduped || undefined,
154    })
155  }
156  const { text, images } = blockText(rest)
157  if (text === '' && images === 0) return
158  const attachment = e.message.type === 'attachment'
159  const name = e.message.name ?? 'unknown'
160  const filePath = attachment && name === 'file' ? FILE_ATTACHMENT.exec(text)?.[1] : undefined
161  l.add({
162    uuid: nextUuid(),
163    row: e.uuid,
164    ctx,
165    door: e.door,
166    category: attachment ? `attachment:${name}` : e.door,
167    target: filePath ? { kind: 'file', value: unescapeJson(filePath) } : undefined,
168    ...measure(text, images, false),
169    attached: filePath ? true : undefined,
170  })
171}
172
173// A compaction that stands: which items stay in the window, and which entities the result still mentions.
174export function ingestCompaction(l: Ledger, e: CompactIn, r: CompactOut): void {
175  if (e.trigger === 'precompute' || typeof r.skip === 'string' || !r.messages) return
176  const before = new Set(e.messages.flatMap(m => (typeof m.handle === 'string' ? [m.handle] : [])))
177  const keptRows = new Set<string>()
178  const keptTools = new Set<string>()
179  for (const m of r.messages) {
180    // The summary carries a handle too, but not one of the messages it replaced.
181    if (m.handle === undefined || !before.has(m.handle)) continue
182    keptRows.add(m.handle)
183    for (const u of m.toolUses) keptTools.add(u.tool_use_id)
184    for (const t of m.toolResults ?? []) keptTools.add(t.tool_use_id)
185  }
186  const after = flattenMessages(r.messages)
187  const entities = extractEntities(e.messages).map(found => ({ ...found, kept: isMentioned(after, found) }))
188  l.compacted(e.agentId ?? MAIN, keptRows, keptTools, { trigger: e.trigger, tokensBefore: r.tokensBefore, tokensAfter: r.tokensAfter, entities })
189}
190
191// A fresh ledger is read back from the conversation until it holds a prompt or a response: tool results
192// and attachments may arrive before the deferred backfill runs, and backfill skips those.
193export function needsBackfill(l: Ledger): boolean {
194  return !l.snapshot().items.some(i => i.door === 'prompt' || i.door === 'response')
195}
196
197// After a reload, resume or /clear: what the main conversation already holds (no attachments, no timing).
198// Tool calls whose result the ledger already holds were recorded live and are not added again.
199export function backfill(l: Ledger, messages: readonly MessageLike[]): void {
200  const before = l.snapshot().items
201  const held = new Set(before.flatMap(i => (i.toolUseId !== undefined ? [i.toolUseId] : [])))
202  const afterSeq = before.at(-1)?.seq ?? 0
203  messages.forEach((m, i) => {
204    const row = `backfill-${i}`
205    if (m.role === 'assistant') {
206      for (const u of m.toolUses) {
207        if (held.has(u.tool_use_id)) continue
208        ingestToolCall(l, u.tool_use_id, u.tool, undefined, u.input)
209        ingestToolResult(l, u.tool_use_id, { isError: u.isError === true, result: (u as { result?: unknown }).result })
210      }
211      const content = [{ type: 'text', text: m.text }, ...m.toolUses.map(u => ({ type: 'tool_use', name: u.tool, input: u.input }))]
212      ingestAppend(l, { door: 'response', uuid: row, message: { type: 'assistant', role: 'assistant', content } })
213    } else if ((m.toolResults?.length ?? 0) > 0) {
214      const results = (m.toolResults ?? []).filter(t => !held.has(t.tool_use_id))
215      if (results.length === 0) return
216      const content = results.map(t => ({ type: 'tool_result', tool_use_id: t.tool_use_id, content: t.text, is_error: t.isError }))
217      ingestAppend(l, { door: 'tool-result', uuid: row, message: { type: 'user', role: 'user', content } })
218    } else {
219      ingestAppend(l, { door: 'prompt', uuid: row, message: { type: 'user', role: 'user', content: m.text } })
220    }
221  })
222  l.markBackfilled(afterSeq)
223}
224
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 148 lines
1import { bar, clip as clipText, fmtCount, pct } from './format.ts'
2import { redactSecrets } from './redact.ts'
3import type { Scope, Summary } from './summary.ts'
4import { MAIN } from './types.ts'
5
6export const SECTIONS = ['overview', 'occupants', 'rereads', 'similar', 'compactions', 'efficiency', 'advice'] as const
7export type Section = (typeof SECTIONS)[number]
8export type UsageLite = { percent?: number; tokens?: number; window?: number }
9export type PaneLine = { text: string; bold?: boolean }
10
11const tok = (n: number): string => `≈${fmtCount(n)}`
12const signed = (n: number): string => `${n >= 0 ? '+' : '−'}${fmtCount(Math.abs(n))}`
13const pad = (s: string, n: number): string => (s.length >= n ? s : s + ' '.repeat(n - s.length))
14const base = (path: string): string => path.split('/').filter(Boolean).at(-1) ?? path
15const clip = (text: string, n: number): string => clipText(redactSecrets(text), n)
16
17export function parseArgs(args: string): { section: Section; scope: Scope; unknown?: string } {
18  const words = args.trim().split(/\s+/).filter(w => w !== '')
19  const scope: Scope = words.includes('--agents') ? 'all' : 'main'
20  const first = words.find(w => w !== '--agents')
21  if (first === undefined) return { section: 'overview', scope }
22  if ((SECTIONS as readonly string[]).includes(first)) return { section: first as Section, scope }
23  return { section: 'overview', scope, unknown: first }
24}
25
26// k at a bound of calibrate's clamp: the measured ratio lies beyond it.
27const clamped = (sum: Summary): boolean => sum.segments > 0 && (sum.k === 0.5 || sum.k === 2)
28
29function header(sum: Summary, usage?: UsageLite): string[] {
30  const where = sum.scope === 'main' ? 'main context' : 'main + subagents'
31  const window =
32    usage?.tokens !== undefined && usage.window ? ` · window ${fmtCount(usage.tokens)}/${fmtCount(usage.window)} tokens (${usage.percent ?? 0}%, measured)` : ''
33  const lines = [
34    `Context Inspector — ${where}${window}`,
35    `Live ${tok(sum.occupancy.liveEst)} tokens in ${sum.occupancy.liveItems} items · ${sum.turns} turn(s) · ${sum.compactions.length} compaction(s) · waste ${tok(sum.wasteEst)}`,
36    `≈ = estimate (chars/4 × k=${sum.k.toFixed(2)}${clamped(sum) ? ' (clamped)' : ''}, ${sum.segments} calibration segment(s)); figures without ≈ are measured or engine-reported`,
37  ]
38  if (sum.dropped + sum.droppedLive > 0) lines.push(`(ledger cap reached: ${sum.dropped} old and ${sum.droppedLive} live items dropped)`)
39  return lines
40}
41
42function occupants(sum: Summary, n: number): string[] {
43  const o = sum.occupancy
44  if (o.liveItems === 0) return ['Top occupants', '  nothing recorded yet']
45  const out = ['Top occupants (live, by category)']
46  for (const r of o.byCategory.slice(0, n))
47    out.push(`  ${pad(clip(r.key, 28), 28)} ${pad(tok(r.est), 8)} ${pad(pct(r.est, o.liveEst), 4)} ${bar(r.est / o.liveEst)}  ${r.count} item(s)`)
48  out.push('Largest live items')
49  for (const t of o.top.slice(0, n)) out.push(`  ${pad(tok(t.est), 8)} ${pad(clip(t.category, 14), 14)} ${clip(t.label, 60)} · turn ${t.turn}`)
50  return out
51}
52
53function rereadLines(sum: Summary, n: number): string[] {
54  const r = sum.rereads
55  const out = [`Re-reads — waste now ${tok(r.currentWasteEst)} · session ${tok(r.wasteEst)} · compaction-induced ${tok(r.compactionEst)} · engine-deduped ${r.deduped}`]
56  if (r.files.length === 0) return [...out, '  no file was read twice']
57  for (const f of r.files.slice(0, n)) {
58    const classes = Object.entries(f.classes)
59      .filter(([c]) => c !== 'first')
60      .map(([c, x]) => `${c}×${x}`)
61      .join(' ')
62    out.push(`  ${clip(f.path, 50)}  ${f.reads} reads  ${classes}${f.wasteEst > 0 ? `  waste ${tok(f.wasteEst)}` : ''}`)
63  }
64  return out
65}
66
67function similarLines(sum: Summary, n: number): string[] {
68  const out = [`Similar tool outputs (lexical SimHash ≤3 bits, not semantic) — waste ${tok(sum.similar.wasteEst)}`]
69  if (sum.similar.clusters.length === 0) return [...out, '  none']
70  for (const c of sum.similar.clusters.slice(0, n))
71    out.push(`  ${c.category} ×${c.size} ${c.kind}  waste ${tok(c.wasteEst)} of ${tok(c.totalEst)}  ${clip(c.label, 50)}`)
72  return out
73}
74
75function compactionLines(sum: Summary, n: number): string[] {
76  if (sum.compactions.length === 0) return ['Compactions', '  none yet']
77  const out = ['Compactions (string presence in summary/kept messages; not semantic retention)']
78  const shown = sum.compactions.slice(-n)
79  shown.forEach((c, i) => {
80    const index = sum.compactions.length - shown.length + i + 1
81    const sizes = c.tokensBefore !== undefined ? `  ${fmtCount(c.tokensBefore)}→${c.tokensAfter !== undefined ? fmtCount(c.tokensAfter) : '?'} tokens` : ''
82    const who = c.ctx === MAIN ? '' : ` (${c.ctx})`
83    out.push(`  #${index} ${c.trigger}${who} @turn ${c.turn}${sizes}  dropped ${c.droppedItems} item(s) ${tok(c.droppedEst)}`)
84    const s = c.stats
85    const lost = c.lostEdited.length > 0 ? ` · lost edits: ${c.lostEdited.map(base).join(', ')}` : ''
86    const reflow = c.reflowEst > 0 ? ` · reflow ${tok(c.reflowEst)}` : ''
87    out.push(
88      `     kept: edited ${s['edited-file'].kept}/${s['edited-file'].total} · read ${s['read-file'].kept}/${s['read-file'].total} · errors ${s.error.kept}/${s.error.total} · requirements ${s.requirement.kept}/${s.requirement.total}${lost}${reflow}`,
89    )
90  })
91  return out
92}
93
94function efficiencyLines(sum: Summary, n: number): string[] {
95  const e = sum.efficiency
96  const out = ['Efficiency (measured input growth vs changed lines; exploration is not a fault)']
97  if (e.turns.length === 0) return [...out, '  no measured requests yet']
98  const median = e.medianPerLine !== undefined ? `median ${fmtCount(e.medianPerLine)} tokens/line` : 'no edits yet'
99  out.push(`  ${e.turns.length} turn(s) · +${fmtCount(e.growth)} tokens · ${e.lines} line(s) changed · ${median} · ${e.exploration} exploration turn(s)`)
100  const top = e.turns
101    .filter(t => t.growth !== undefined)
102    .sort((a, b) => (b.growth ?? 0) - (a.growth ?? 0))
103    .slice(0, n)
104  for (const t of top) {
105    const tail = t.perLine !== undefined ? `  ${fmtCount(t.perLine)} tokens/line` : t.lines === 0 && (t.growth ?? 0) > 0 ? '  exploration' : ''
106    out.push(`  turn ${t.turn}  ${signed(t.growth ?? 0)}  ${t.lines} line(s)${tail}`)
107  }
108  return out
109}
110
111function adviceLines(sum: Summary, n: number): string[] {
112  const out = [`Advice${sum.hasBreakdown ? '' : ' (MCP and memory checks run from /context-inspect)'}`]
113  if (sum.advice.length === 0) return [...out, '  nothing stands out']
114  for (const a of sum.advice.slice(0, n)) {
115    out.push(`  [${a.severity}] ${a.title} — saves ${tok(a.savings)}`)
116    out.push(`     ${a.evidence}`)
117    out.push(`     → ${a.action}`)
118  }
119  return out
120}
121
122const PARTS: [Section, (sum: Summary, n: number) => string[]][] = [
123  ['occupants', occupants],
124  ['rereads', rereadLines],
125  ['similar', similarLines],
126  ['compactions', compactionLines],
127  ['efficiency', efficiencyLines],
128  ['advice', adviceLines],
129]
130
131export function renderReport(sum: Summary, section: Section = 'overview', usage?: UsageLite): string {
132  const n = section === 'overview' ? 3 : 15
133  const blocks: string[][] = [header(sum, usage)]
134  for (const [name, part] of PARTS) if (section === 'overview' || section === name) blocks.push(part(sum, n))
135  return blocks.map(b => b.map(redactSecrets).join('\n')).join('\n\n')
136}
137
138export function statusLine(sum: Summary, percent?: number): string {
139  const head = percent !== undefined ? `ctx ${percent}%` : `ctx ${tok(sum.occupancy.liveEst)}`
140  return `${head} · waste${tok(sum.wasteEst)}`
141}
142
143export function paneLines(sum: Summary, width: number): PaneLine[] {
144  return renderReport(sum, 'overview')
145    .split('\n')
146    .map(text => ({ text: clip(text, Math.max(20, width)), bold: text !== '' && /^[A-Z]/.test(text) }))
147}
148
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