Dotfiles – (neo)vim, tmux and zsh configuration

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.
brew install chezmoi
chezmoi init --apply rcliao
That prompts for three things and writes them to ~/.config/chezmoi/chezmoi.toml, which stays local to the machine and is never committed:
| Prompt | Why it is per-machine |
|---|---|
| Full name | git author name |
| Git email | so a work laptop and a personal box can share this repo without cross-signing commits |
| Ghost hooks | see Ghost below |
Re-running chezmoi init later is non-interactive — it keeps whatever is already set. To change an answer, edit ~/.config/chezmoi/chezmoi.toml and chezmoi apply. For an unattended install, chezmoi init --apply --promptDefaults takes the personal identity with Ghost off.
The first apply runs brew bundle for everything below. A package failure warns rather than aborting, so the dotfiles land either way.
chezmoi diff # what would change
chezmoi apply # everything
chezmoi apply ~/.zshrc # just one target
chezmoi add ~/.tmux.conf # pull a local edit back into the repo
chezmoi update # git pull + apply
This repo is edited from more than one machine, so read chezmoi diff before applying. Whichever machine committed last is not automatically the machine that is more correct.
Shell — .zshrc (zsh, pure prompt, shared history across panes, cached compinit), .gitconfig (delta pager, zdiff3 conflicts, rerere, auto-set upstream on push), .config/git/ignore, .ssh/config (keychain-backed agent loading — this is what replaces the ssh-add -A that used to run on every shell start).
Terminals — ghostty.
Multiplexers — herdr (primary, keys mapped to match tmux muscle memory), tmux as the fallback. The old zellij config is kept under archive/, which is not deployed.
Editors — neovim (0.12+: one init.lua, plugins via the built-in vim.pack, LSP for Go, TypeScript, Rust, Python and Terraform from Brewfile-installed servers), emacs.
Runtimes — mise, activated at the end of .zshrc so it wins over the PATH exports above it. Homebrew still provides the global node, go, and python; mise only takes over inside a directory that pins a version in mise.toml or .tool-versions.
Window management — aerospace.
Claude Code — CLAUDE.md, settings.json, and the hook scripts under .claude/hooks. settings.json is a template: hooks belonging to tools that may not be installed (herdr, Zero) are only wired in when their script is actually present, so a machine never ends up pointing at a hook that does not exist.
Other — bat, yazi.
The ghost-* hooks drive the Ghost MCP memory server. A machine without Ghost has no use for them, so they are off by default: neither the scripts nor their settings.json entries are installed.
To turn them on, set ghost = true in ~/.config/chezmoi/chezmoi.toml and chezmoi apply. To turn them off again, flip it back and apply.
Homebrew refuses casks from untrusted taps, and trusting one is a decision worth making deliberately, so the bundle does not install aerospace:
brew trust --cask nikitabobko/tap/aerospace # this one cask, not the whole tap
brew install --cask nikitabobko/tap/aerospace
Anything regenerated, tool-managed, or machine-specific — Claude Code session transcripts and caches, plugin directories, herdr's own integration hook, and ~/.config/chezmoi/chezmoi.toml itself. See .chezmoiignore, which lists each exclusion explicitly so chezmoi add ~/.claude can never sweep one in.
This repo is public, so nothing employer-specific goes in it.
hooks/register.tsx 504 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Captured, Recall, TraceRow } from '../types'
5import {
6 addRecalls,
7 bandLines,
8 belowFloor,
9 closeOut,
10 enrich,
11 finalVerdict,
12 flagRisk,
13 fmtTok,
14 hookChannel,
15 inputText,
16 isCorrection,
17 isGhostTool,
18 judge,
19 ledger,
20 ledgerText,
21 logLines,
22 newRecall,
23 parseInjected,
24 parseToolMemories,
25 parseTrace,
26 pathTokens,
27 recallsFromLog,
28 shortKey,
29 statusText,
30 summarize,
31} from './lens.ts'
32import { buildReport, reportText } from './report.ts'
33import type { SessionLogs } from './report.ts'
34
35const recallsAtom = atom({ plugin: 'ghost-lens', key: 'recalls' } as const, [] as Recall[])
36const turnAtom = atom({ plugin: 'ghost-lens', key: 'turn' } as const, 0)
37const tracesAtom = atom({ plugin: 'ghost-lens', key: 'traces' } as const, [] as TraceRow[])
38const traceSeenAtom = atom({ plugin: 'ghost-lens', key: 'traceSeen' } as const, {} as Record<string, number>)
39const sessionAtom = atom({ plugin: 'ghost-lens', key: 'session' } as const, null as string | null)
40// Quiet by default: nothing on screen unless the person turns debugging on.
41const debugAtom = atom({ plugin: 'ghost-lens', key: 'debug' } as const, false)
42const capturedAtom = atom({ plugin: 'ghost-lens', key: 'captured' } as const, [] as Captured[])
43const selectedAtom = atom({ plugin: 'ghost-lens', key: 'selected' } as const, null as string | null)
44
45const PANE = 'ghost-lens'
46const CONTEXT_TOOL = 'mcp__ghost__ghost_context'
47const RECALL_TOOLS = new Set([CONTEXT_TOOL, 'mcp__ghost__ghost_search'])
48const FETCH_TOOLS = new Set(['mcp__ghost__ghost_get', 'mcp__ghost__ghost_expand'])
49const WRITE_TOOLS = new Set(['mcp__ghost__ghost_put', 'mcp__ghost__ghost_patch'])
50// Eval (2026-10-02, run-ledger): a tool description alone was chosen 0/5, one system-prompt sentence 3/3.
51// Live-usage eval (2026-09-23): explicit ghost_context is the best channel but ran in 15 of 45 sessions.
52const NUDGE =
53 `Before non-trivial work in an unfamiliar area, a design decision, or debugging an error you may have seen before, call ${CONTEXT_TOOL} with the task as the query. Automatically injected ghost memories are often off-topic: ignore any that do not apply.`
54const CORPUS_MAX = 200_000
55
56// Module state: rebuilt on reload, which only loses the current turn's evidence.
57let session = 'unknown'
58let logPath: string | undefined
59let logDir: string | undefined
60let tracePath: string | undefined
61let traceDir: string | undefined
62let defaultNs = 'agent:claude-code'
63let ghostBin = 'ghost'
64// The prompt hook's session cap (same variable and default as the hook).
65let tokenCap = 6000
66let isWriting = false
67let prompt = ''
68let corpus = ''
69let fetched = new Set<string>()
70
71function rowText(content: readonly unknown[]): string {
72 return content
73 .map(b => {
74 const block = b as { type?: string; text?: unknown; content?: unknown }
75 if (typeof block.text === 'string') return block.text
76 if (typeof block.content === 'string') return block.content
77 return ''
78 })
79 .join('\n')
80}
81
82async function persist($: EngineInterface): Promise<void> {
83 const recalls = await read($, recallsAtom)
84 $.ui.status((await read($, debugAtom)) ? statusText(summarize(recalls)) : undefined)
85 if (logPath !== undefined) {
86 try {
87 await $.fs.write(logPath, logLines(session, recalls))
88 } catch {
89 // The log is a convenience for measuring the detector; never fail a turn over it.
90 }
91 }
92}
93
94// session.start does not fire on /clear: the id changes under a running process.
95// Point the trace and log at the new id and start this session's view over.
96async function syncSession($: EngineInterface): Promise<void> {
97 const id = await $.session.id()
98 const dir = traceDir ?? '/tmp'
99 session = id
100 tracePath = `${dir}/ghost-trace-${id}.jsonl`
101 if (logDir !== undefined) logPath = `${logDir}/${id}.jsonl`
102 const owner = await read($, sessionAtom)
103 if (owner === id) return
104 // A different session from the one this state belongs to (/clear, /resume, or a
105 // fresh process): start its view over, then take back whatever its log already
106 // holds, so a resumed session keeps its verdicts instead of overwriting them.
107 if (owner !== null) {
108 await update($, tracesAtom, () => [])
109 await update($, capturedAtom, () => [])
110 await update($, turnAtom, () => 0)
111 await update($, selectedAtom, () => null)
112 }
113 const logged = logPath === undefined ? [] : recallsFromLog(await $.fs.read(logPath).catch(() => ''))
114 if (owner !== null || (await read($, recallsAtom)).length === 0) await update($, recallsAtom, () => logged)
115 await update($, sessionAtom, () => id)
116}
117
118// New lines of the hooks' trace: scores, token costs, skip reasons.
119async function readTrace($: EngineInterface): Promise<void> {
120 await syncSession($)
121 if (tracePath === undefined) return
122 const path = tracePath
123 let text: string
124 try {
125 text = await $.fs.read(path)
126 } catch {
127 return // no trace yet: hooks not updated, or nothing ran this session
128 }
129 const turn = await read($, turnAtom)
130 const seenByFile = await read($, traceSeenAtom)
131 // v0.2 kept one number for every file.
132 const seen = typeof seenByFile === 'object' && seenByFile !== null ? (seenByFile[path] ?? 0) : 0
133 const parsed = parseTrace(text, seen, turn)
134 await update($, traceSeenAtom, prev => ({ ...(typeof prev === 'object' && prev !== null ? prev : {}), [path]: parsed.seen }))
135 if (parsed.rows.length === 0) return
136 // /tmp is cleared on reboot: keep a durable copy beside the shadow log for /ghost report.
137 if (logDir !== undefined) {
138 try {
139 await $.fs.write(`${logDir}/${session}.trace.jsonl`, text)
140 } catch {
141 // a convenience copy; the live view does not depend on it
142 }
143 }
144 await update($, tracesAtom, prev => [...prev, ...parsed.rows].slice(-200))
145 await update($, recallsAtom, list => enrich(list, parsed.rows))
146 await persist($)
147}
148
149// Paths a memory names that are gone from this repo: likely stale.
150async function checkStale($: EngineInterface): Promise<void> {
151 const recalls = await read($, recallsAtom)
152 const found = new Map<string, string[]>()
153 for (const r of recalls) {
154 if (r.stale !== undefined) continue
155 const gone: string[] = []
156 for (const p of pathTokens(r.tokens)) {
157 try {
158 if ((await $.fs.exists(p.top)) && !(await $.fs.exists(p.path))) gone.push(p.path)
159 } catch {
160 // unreadable path: no evidence either way
161 }
162 }
163 found.set(`${r.ns}\u0000${r.key}`, gone)
164 }
165 if (found.size === 0) return
166 await update($, recallsAtom, list =>
167 list.map(r => {
168 const gone = found.get(`${r.ns}\u0000${r.key}`)
169 return gone === undefined || r.stale !== undefined ? r : { ...r, stale: gone }
170 }),
171 )
172}
173
174async function addAndCheck($: EngineInterface, found: Recall[]): Promise<void> {
175 if (found.length === 0) return
176 // The trace line may have been read before the hook's text row arrived.
177 const traces = await read($, tracesAtom)
178 await update($, recallsAtom, prev => enrich(addRecalls(prev, found), traces))
179 await checkStale($)
180 await persist($)
181}
182
183async function reportUsed($: EngineInterface, used: Recall[]): Promise<void> {
184 if (!isWriting) return
185 for (const r of used) {
186 const ran = await $.process.run([ghostBin, 'curate', '--ns', r.ns, '--key', r.key, '--op', 'used'])
187 if (ran.exitCode !== 0) $.ui.toast(`ghost-lens: curate used failed for ${r.key}`)
188 }
189}
190
191async function correct($: EngineInterface, key: string, ns: string, to: 'used' | 'unused'): Promise<void> {
192 await update($, recallsAtom, list => list.map(r => (r.key === key && r.ns === ns ? { ...r, corrected: to } : r)))
193 await persist($)
194}
195
196// Every session's shadow log and trace copy under ~/.ghost/lens, aggregated.
197async function crossSessionReport($: EngineInterface): Promise<string> {
198 if (logDir === undefined) return 'ghost report: no log directory ($HOME unset).'
199 await persist($) // this session's latest verdicts first
200 let entries
201 try {
202 entries = await $.fs.list(logDir)
203 } catch {
204 return `ghost report: cannot read ${logDir}.`
205 }
206 const sessions: SessionLogs[] = []
207 for (const f of entries) {
208 if (f.kind !== 'file' || !f.name.endsWith('.jsonl') || f.name.endsWith('.trace.jsonl')) continue
209 const id = f.name.slice(0, -'.jsonl'.length)
210 const shadow = await $.fs.read(`${logDir}/${f.name}`).catch(() => '')
211 const trace = await $.fs.read(`${logDir}/${id}.trace.jsonl`).catch(() => '')
212 sessions.push({ shadow, trace })
213 }
214 return reportText(buildReport(sessions))
215}
216
217// The context window size, for ghost's share of it; undefined where the host has none.
218async function contextWindow($: EngineInterface): Promise<number | undefined> {
219 try {
220 return (await $.session.usage()).context.window
221 } catch {
222 return undefined
223 }
224}
225
226async function select($: EngineInterface, id: string | null): Promise<void> {
227 await update($, selectedAtom, cur => (cur === id ? null : id))
228}
229
230export const register: Register = on => {
231 on('session.start', async ($, e, next) => {
232 defaultNs = (await $.env.get('GHOST_AGENT_NS')) ?? 'agent:claude-code'
233 ghostBin = (await $.env.get('GHOST_BIN')) ?? 'ghost'
234 isWriting = (await $.env.get('GHOST_LENS_WRITE')) === '1'
235 const cap = Number((await $.env.get('GHOST_SESSION_TOKEN_CAP')) ?? '6000')
236 tokenCap = Number.isFinite(cap) && cap >= 0 ? cap : 6000
237 traceDir = (await $.env.get('GHOST_TRACE_DIR')) ?? '/tmp'
238 const home = await $.env.get('HOME')
239 if (home !== undefined) {
240 const dir = `${home}/.ghost/lens`
241 try {
242 await $.process.run(['mkdir', '-p', dir])
243 logDir = dir
244 } catch {
245 logDir = undefined
246 }
247 }
248 await $.command.register({
249 name: 'ghost',
250 description: 'Show what ghost loaded this session and whether the agent used it; debug|quiet toggles the band and status line',
251 argumentHint: '[report|debug|quiet|close]',
252 immediate: true,
253 })
254 if ((await $.env.get('GHOST_LENS_DEBUG')) === '1') await update($, debugAtom, () => true)
255 // Sets session, tracePath and logPath, and reads the session-start trace line.
256 await readTrace($)
257 await persist($)
258 return next(e)
259 })
260
261 // What ghost's shell hooks inject arrives as hook-context rows.
262 on('session.append', async ($, e, next) => {
263 const appended = await next(e)
264 if (e.agentId !== undefined || e.door !== 'hook-context') return appended
265 const text = rowText(e.message.content)
266 const channel = hookChannel(text)
267 if (channel === undefined) return appended
268 // After /clear the SessionStart block arrives before any turn: switch logs first.
269 await syncSession($)
270 const turn = await read($, turnAtom)
271 const at = await $.clock.now()
272 await addAndCheck($, parseInjected(text).map(i => newRecall(i, defaultNs, channel, turn, at)))
273 await readTrace($)
274 return appended
275 })
276
277 on('prompt.submit', async ($, e, next) => {
278 // A delivery into a running turn (turnId set) must not wipe that turn's evidence.
279 if (e.turnId !== undefined) {
280 prompt += `\n${e.text}`
281 return next(e)
282 }
283 await syncSession($)
284 prompt = e.text
285 corpus = ''
286 fetched = new Set()
287 if (isCorrection(e.text)) {
288 const lastTurn = (await read($, turnAtom)) - 1
289 await update($, recallsAtom, list => flagRisk(list, lastTurn))
290 await persist($)
291 }
292 return next(e)
293 })
294
295 // The prompt hook has run by now, skipped or not: pick up its trace line.
296 on('turn.start', async ($, e, next) => {
297 await readTrace($)
298 return next(e)
299 })
300
301 on('tool.call', async ($, e, next) => {
302 const tool = String(e.tool)
303 const input = e as unknown as Record<string, unknown>
304 if (FETCH_TOOLS.has(tool) && typeof input.key === 'string') fetched.add(input.key)
305 // Ghost's own tools name keys to curate, link or store them, not to use them.
306 if (!isGhostTool(tool) && corpus.length < CORPUS_MAX) corpus += `\n${inputText(input)}`
307
308 const ran = await next(e)
309 if (ran.deny !== undefined || ran.isError === true) return ran
310
311 if (RECALL_TOOLS.has(tool) || WRITE_TOOLS.has(tool)) await syncSession($)
312 if (RECALL_TOOLS.has(tool)) {
313 const turn = await read($, turnAtom)
314 const at = await $.clock.now()
315 const query = typeof input.query === 'string' ? input.query : undefined
316 const text = ran.text ?? (typeof ran.result === 'string' ? ran.result : JSON.stringify(ran.result ?? ''))
317 const found = parseToolMemories(text).map(m => ({ ...newRecall(m, m.ns ?? defaultNs, 'explicit', turn, at), query }))
318 await addAndCheck($, found)
319 } else if (WRITE_TOOLS.has(tool) && typeof input.key === 'string') {
320 const key = input.key
321 const ns = typeof input.ns === 'string' ? input.ns : defaultNs
322 const turn = await read($, turnAtom)
323 await update($, capturedAtom, prev => [...prev, { key, ns, turn, tool: tool.replace('mcp__ghost__ghost_', '') }].slice(-100))
324 if (await read($, debugAtom)) $.ui.toast(`ghost saved ${key}`)
325 }
326 return ran
327 })
328
329 on('turn.complete', async ($, e, next) => {
330 const done = await next(e)
331 if (e.agentId !== undefined || e.isAborted) return done
332 await syncSession($)
333 const turn = await read($, turnAtom)
334 const evidence = { corpus: `${corpus}\n${e.answer}`, prompt, fetched }
335 const judged = judge(await read($, recallsAtom), evidence, turn)
336 await update($, recallsAtom, () => judged.recalls)
337 await update($, turnAtom, t => t + 1)
338 corpus = ''
339 fetched = new Set()
340 await persist($)
341 await reportUsed($, judged.changed.filter(r => r.verdict === 'used'))
342 return done
343 })
344
345 on('session.end', async ($, e, next) => {
346 await update($, recallsAtom, list => closeOut(list))
347 await persist($)
348 return next(e)
349 })
350
351 on('prompt.compose', async ($, e, next) => {
352 const composed = await next(e)
353 if (!e.tools.includes(CONTEXT_TOOL)) return composed
354 return { sections: [...composed.sections, { id: 'ghost-lens:recall', text: NUDGE, scope: 'session' }] }
355 })
356
357 on('command.run', { command: 'ghost' }, async ($, e) => {
358 const arg = e.args.trim()
359 if (arg === 'report') return { text: await crossSessionReport($) }
360 if (arg === 'close') {
361 await $.ui.close({ id: PANE })
362 return { text: 'ghost: pane closed.' }
363 }
364 if (arg === 'debug' || arg === 'quiet') {
365 await update($, debugAtom, () => arg === 'debug')
366 await persist($)
367 return { text: arg === 'debug' ? 'ghost: debug on (band, status line, toasts). /ghost quiet to hide.' : 'ghost: quiet. /ghost opens the pane any time.' }
368 }
369 // Focused so Esc closes it when you are done reviewing.
370 await $.ui.open({ id: PANE, title: 'Ghost', focus: true, closeOnEscape: true })
371 const s = summarize(await read($, recallsAtom))
372 return {
373 text: `ghost: ${s.recalled} loaded (${fmtTok(s.tok)} tok), ${s.used} used, ${s.unused} unused (${fmtTok(s.wastedTok)} tok), ${s.pending} pending${isWriting ? '' : ' · shadow mode'}.`,
374 }
375 })
376
377 // One or two lines above the prompt: what ghost did for the prompt just sent.
378 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
379 const below = await next(e)
380 if (e.props.hasSurvey || !(await read($, debugAtom))) return below
381 const traces = await read($, tracesAtom)
382 const latest = [...traces].reverse().find(t => t.hook === 'prompt') ?? [...traces].reverse().find(t => t.hook === 'session-start')
383 const lines = bandLines(latest, e.props.bodyColumns ?? 100, ledger(traces, await read($, recallsAtom), tokenCap))
384 if (lines.length === 0) return below
385 const { Box, Text } = $.ui.resolve(e)
386 return (
387 <Box flexDirection="column">
388 {lines.map((line, i) => (
389 <Text key={`gl${i}`} dimColor={i > 0 || latest?.outcome !== 'injected'} color={i > 0 ? 'yellow' : undefined}>
390 {line}
391 </Text>
392 ))}
393 {below}
394 </Box>
395 )
396 })
397
398 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
399 const { Box, Button, Text } = $.ui.resolve(e)
400 const recalls = await read($, recallsAtom)
401 const traces = await read($, tracesAtom)
402 const captured = await read($, capturedAtom)
403 const selected = await read($, selectedAtom)
404 const s = summarize(recalls)
405 const width = e.props.bodyColumns ?? 80
406 const keyWidth = Math.max(12, width - 34)
407 const rows = [...recalls].reverse()
408 const pick = rows.find(r => `${r.ns}:${r.key}` === selected)
409 const runs = [...traces].reverse().slice(0, 8)
410 const window = await contextWindow($)
411
412 return (
413 <Box flexDirection="column">
414 <Text bold>
415 Loaded {s.recalled} · {fmtTok(s.tok)} tok · {s.used} used · {s.unused} unused ({fmtTok(s.wastedTok)} tok) · {s.pending} pending
416 </Text>
417 <Text>{ledgerText(ledger(traces, recalls, tokenCap), window)}</Text>
418 <Text dimColor>
419 {s.stale > 0 ? `${s.stale} likely stale · ` : ''}
420 {s.risky > 0 ? `${s.risky} to check · ` : ''}
421 {s.reviewed === 0 ? 'no corrections yet' : `you agreed with ${s.agreed} of ${s.reviewed}`}
422 {isWriting ? '' : ' · shadow mode, nothing written to ghost'}
423 </Text>
424 <Text dimColor>Esc or /ghost close to close</Text>
425
426 {pick !== undefined && (
427 <Box flexDirection="column" marginTop={1}>
428 <Text bold>{pick.key}</Text>
429 <Text dimColor>
430 {pick.channel} · turn {pick.shownTurn} · {pick.score === undefined ? 'no score' : `score ${pick.score.toFixed(2)}`}
431 {pick.floor === undefined ? '' : ` / floor ${pick.floor}`}
432 {pick.via !== undefined ? ` · via ${pick.via}` : belowFloor(pick) ? ' (under the floor: came in by edge or rescue)' : ''} · {pick.tok} tok
433 </Text>
434 {pick.query !== undefined && <Text dimColor>query: {pick.query.slice(0, width - 8)}</Text>}
435 <Text>
436 verdict: {finalVerdict(pick)}
437 {pick.corrected !== undefined ? ` (you; mod said ${pick.verdict})` : ''}
438 {pick.evidence !== undefined ? ` · ${pick.evidence}` : ''}
439 </Text>
440 {pick.tokens.length > 0 && <Text dimColor>tokens: {pick.tokens.slice(0, 10).join(', ').slice(0, width - 9)}</Text>}
441 {(pick.stale?.length ?? 0) > 0 && <Text color="yellow">missing here: {pick.stale?.join(', ')}</Text>}
442 {pick.risk !== undefined && <Text color="yellow">check: {pick.risk}</Text>}
443 <Text>{pick.content}</Text>
444 <Box flexDirection="row">
445 <Button key={`du:${pick.key}`} label="used" plain onPress={() => correct($, pick.key, pick.ns, 'used')} />
446 <Text> </Text>
447 <Button key={`dn:${pick.key}`} label="noise" plain onPress={() => correct($, pick.key, pick.ns, 'unused')} />
448 <Text> </Text>
449 <Button key={`dc:${pick.key}`} label="close" plain onPress={() => select($, null)} />
450 </Box>
451 </Box>
452 )}
453
454 <Box marginTop={1}>
455 <Text bold>Memories</Text>
456 </Box>
457 <Text dimColor>{'turn from score tok verdict key'}</Text>
458 {rows.length === 0 && <Text dimColor>Nothing loaded yet.</Text>}
459 {rows.slice(0, 40).map(r => {
460 const v = finalVerdict(r)
461 // ghost's own via when it is more than a plain search hit; the floor guess for older binaries.
462 const how = r.via !== undefined && r.via !== 'search' ? ` ${r.via}` : r.via === undefined && belowFloor(r) ? ' <floor' : ''
463 const flag = (r.stale?.length ?? 0) > 0 ? ' stale' : r.risk !== undefined ? ' check' : how
464 return (
465 <Box flexDirection="row" key={`r:${r.ns}:${r.key}`}>
466 <Text dimColor>
467 {String(r.shownTurn).padStart(4)} {(r.channel === 'session-start' ? 'start' : r.channel === 'prompt' ? 'prompt' : 'ask').padEnd(6)}{' '}
468 {(r.score === undefined ? '—' : r.score.toFixed(2)).padStart(5)} {String(r.tok ?? 0).padStart(5)}{' '}
469 </Text>
470 <Text color={v === 'used' ? 'green' : v === 'pending' ? 'yellow' : undefined} dimColor={v === 'unused'}>
471 {v.padEnd(8)}
472 </Text>
473 <Button key={`s:${r.ns}:${r.key}`} label={shortKey(r.key, keyWidth)} plain onPress={() => select($, `${r.ns}:${r.key}`)} />
474 <Text color="yellow">{flag}</Text>
475 </Box>
476 )
477 })}
478
479 <Box marginTop={1}>
480 <Text bold>Hook runs</Text>
481 </Box>
482 {runs.length === 0 && <Text dimColor>No trace yet (hooks write /tmp/ghost-trace-<session>.jsonl).</Text>}
483 {runs.map((t, i) => (
484 <Text key={`t:${i}`} dimColor={t.outcome !== 'injected'}>
485 {String(t.turn).padStart(4)} {t.hook.padEnd(13)} {t.outcome === 'injected'
486 ? `loaded ${t.injected.length} · ${fmtTok(t.tok)} tok${(t.budget ?? 0) > 0 ? ` (ghost packed ${fmtTok(t.used ?? 0)} of ${fmtTok(t.budget)} budget)` : ''}`
487 : `${t.outcome}: ${t.reason ?? ''}`}
488 </Text>
489 ))}
490
491 <Box marginTop={1}>
492 <Text bold>Captured</Text>
493 </Box>
494 {captured.length === 0 && <Text dimColor>No agent writes this session.</Text>}
495 {[...captured].reverse().slice(0, 10).map((c, i) => (
496 <Text key={`c:${i}`}>
497 {String(c.turn).padStart(4)} {c.tool.padEnd(6)} {shortKey(c.key, width - 14)}
498 </Text>
499 ))}
500 </Box>
501 )
502 })
503}
504hooks/lens.ts 481 lines1import type { Channel, Recall, TraceRow, Verdict } from '../types'
2
3// Ghost keys are kebab/snake slugs, sometimes with ':' or '.'; the hook headers
4// ("[Ghost Memory — Relevant]", "[End Ghost Memory]") contain spaces and never match.
5const KEY = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/
6const LINE = /^\[([^\]]+)\] (.+)$/
7
8// Tokens specific enough that seeing one in the agent's own output means the
9// memory's content was acted on, not just that the topic came up.
10const TOKEN_PATTERNS = [
11 /[\w./-]*\w\.(?:go|tsx?|jsx?|mjs|cjs|md|sh|json|ya?ml|sql|py|rs|toml|css|html|env|Dockerfile)\b/g, // file names and paths
12 /--[a-z][\w-]{2,}/g, // CLI flags
13 /\b[A-Z][A-Z0-9]*_[A-Z0-9_]{2,}\b/g, // ENV_VARS
14 /\b[a-z]+(?:[A-Z][a-z0-9]+)+\b/g, // camelCase
15 /\b[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]+)+\b/g, // PascalCase
16 /\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b/g, // snake_case
17]
18
19/** Distinct identifier-like tokens in a memory body, at most 40. */
20export function distinctiveTokens(text: string): string[] {
21 const found = new Set<string>()
22 for (const pattern of TOKEN_PATTERNS) {
23 for (const m of text.matchAll(pattern)) {
24 if (m[0].length >= 5) found.add(m[0])
25 if (found.size >= 40) return [...found]
26 }
27 }
28 return [...found]
29}
30
31export type Injected = { key: string; content: string; score?: number; via?: string }
32
33/** The channel a ghost hook's injected block came from, by its header; undefined when it is not ghost's. */
34export function hookChannel(text: string): Channel | undefined {
35 if (text.includes('[Ghost Memory — Session Start]')) return 'session-start'
36 if (text.includes('[Ghost Memory — Relevant]')) return 'prompt'
37 return undefined
38}
39
40/** `[key] content` lines from a ghost hook's injected text. */
41export function parseInjected(text: string): Injected[] {
42 const out: Injected[] = []
43 for (const line of text.split('\n')) {
44 const m = LINE.exec(line.trim())
45 const key = m?.[1]
46 if (key === undefined || !KEY.test(key)) continue
47 out.push({ key, content: m?.[2] ?? '' })
48 }
49 return out
50}
51
52/** Memories in a ghost_context / ghost_search tool result (JSON). */
53export function parseToolMemories(text: string): (Injected & { ns?: string })[] {
54 let parsed: unknown
55 try {
56 parsed = JSON.parse(text)
57 } catch {
58 return []
59 }
60 const list = Array.isArray(parsed)
61 ? parsed
62 : Array.isArray((parsed as { memories?: unknown })?.memories)
63 ? (parsed as { memories: unknown[] }).memories
64 : []
65 const out: (Injected & { ns?: string })[] = []
66 for (const item of list) {
67 const m = item as { key?: unknown; content?: unknown; ns?: unknown; score?: unknown; via?: unknown }
68 if (typeof m.key !== 'string' || !KEY.test(m.key)) continue
69 out.push({
70 key: m.key,
71 content: typeof m.content === 'string' ? m.content : '',
72 ns: typeof m.ns === 'string' ? m.ns : undefined,
73 score: typeof m.score === 'number' ? m.score : undefined,
74 via: typeof m.via === 'string' ? m.via : undefined,
75 })
76 }
77 return out
78}
79
80export function newRecall(i: Injected, ns: string, channel: Channel, turn: number, at: number): Recall {
81 return {
82 key: i.key,
83 ns,
84 channel,
85 snippet: (i.content.split('\n')[0] ?? '').slice(0, 160),
86 content: i.content.slice(0, 800),
87 tokens: distinctiveTokens(i.content),
88 score: i.score,
89 via: i.via,
90 tok: Math.ceil(i.content.length / 4),
91 shownTurn: turn,
92 shownAt: at,
93 verdict: 'pending',
94 }
95}
96
97/**
98 * Adds recalls not already tracked (same ns+key), keeping the first sighting.
99 * A `prior` row (loaded from the log on resume) is an earlier exposure: when
100 * ghost injects that memory again it is replaced, so the new exposure is judged.
101 */
102export function addRecalls(prev: Recall[], next: Recall[]): Recall[] {
103 const id = (r: Recall): string => `${r.ns}\u0000${r.key}`
104 const incoming = new Map<string, Recall>()
105 for (const r of next) if (!incoming.has(id(r))) incoming.set(id(r), r)
106 let changed = false
107 const kept = prev.filter(r => {
108 if (!incoming.has(id(r))) return true
109 if (r.prior === true) {
110 changed = true
111 return false // replaced below by the fresh sighting
112 }
113 incoming.delete(id(r)) // already tracked this session: first sighting wins
114 return true
115 })
116 if (incoming.size === 0 && !changed) return prev
117 return [...kept, ...incoming.values()].slice(-500)
118}
119
120export type TurnEvidence = {
121 /** What the agent wrote and did this turn: final answer plus tool inputs. */
122 corpus: string
123 /** What the person typed; tokens shared with it prove nothing (retrieval matched on them). */
124 prompt: string
125 /** Keys the agent fetched directly with ghost_get / ghost_expand. */
126 fetched: Set<string>
127}
128
129const KEY_CHAR = '[A-Za-z0-9_.:-]'
130
131/** The key as a whole token: "auth" does not match "author", "ghost" not "mcp__ghost__ghost_get". */
132export function mentionsKey(corpus: string, key: string): boolean {
133 if (key.length < 6) return false
134 const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
135 return new RegExp(`(?<!${KEY_CHAR})${escaped}(?!${KEY_CHAR}*[A-Za-z0-9])`).test(corpus)
136}
137
138/** Tool inputs that are about ghost itself (curate, edge, put, ...) name keys without using them. */
139export function isGhostTool(tool: string): boolean {
140 return tool.startsWith('mcp__ghost__')
141}
142
143/** A tool call's own arguments as evidence text: no tool name, ids or loop ids. */
144export function inputText(input: Record<string, unknown>): string {
145 const { tool: _tool, tool_use_id: _id, agentId: _agent, ...args } = input
146 return JSON.stringify(args).slice(0, 20_000)
147}
148
149/** Why a recall counts as used this turn, or undefined. */
150export function usedEvidence(r: Recall, ev: TurnEvidence): string | undefined {
151 if (ev.fetched.has(r.key)) return 'fetched with ghost_get'
152 if (mentionsKey(ev.corpus, r.key)) return 'key named'
153 const hits = r.tokens.filter(t => ev.corpus.includes(t) && !ev.prompt.includes(t))
154 if (hits.length >= 2) return `tokens: ${hits.slice(0, 3).join(', ')}`
155 return undefined
156}
157
158/**
159 * Judges pending recalls at the end of main-loop turn `turn`. Prompt and
160 * explicit recalls get the turn they were shown plus one more; session-start
161 * recalls stay pending until used or the session ends.
162 */
163export function judge(recalls: Recall[], ev: TurnEvidence, turn: number): { recalls: Recall[]; changed: Recall[] } {
164 const changed: Recall[] = []
165 const out = recalls.map(r => {
166 // Prior rows belong to an earlier process: no tokens kept, no turn of this one to judge against.
167 if (r.verdict !== 'pending' || r.prior === true) return r
168 const why = usedEvidence(r, ev)
169 if (why !== undefined) {
170 const done = { ...r, verdict: 'used' as const, evidence: why, judgedTurn: turn }
171 changed.push(done)
172 return done
173 }
174 if (r.channel !== 'session-start' && turn - r.shownTurn >= 1) {
175 const done = { ...r, verdict: 'unused' as const, judgedTurn: turn }
176 changed.push(done)
177 return done
178 }
179 return r
180 })
181 return { recalls: changed.length === 0 ? recalls : out, changed }
182}
183
184/** At session end every recall still pending is unused. */
185export function closeOut(recalls: Recall[]): Recall[] {
186 return recalls.map(r => (r.verdict === 'pending' ? { ...r, verdict: 'unused' as const } : r))
187}
188
189export type Summary = {
190 recalled: number
191 used: number
192 unused: number
193 pending: number
194 /** Auto verdicts the person confirmed, out of those they reviewed. */
195 agreed: number
196 reviewed: number
197 /** Estimated context tokens all recalls cost, and the part spent on unused ones. */
198 tok: number
199 wastedTok: number
200 stale: number
201 risky: number
202}
203
204/** The verdict that counts: the person's correction over the mod's own. */
205export function finalVerdict(r: Recall): Verdict {
206 return r.corrected ?? r.verdict
207}
208
209export function summarize(recalls: Recall[]): Summary {
210 const s: Summary = { recalled: recalls.length, used: 0, unused: 0, pending: 0, agreed: 0, reviewed: 0, tok: 0, wastedTok: 0, stale: 0, risky: 0 }
211 for (const r of recalls) {
212 const v = finalVerdict(r)
213 s[v] += 1
214 // Recalls kept in $.state from v0.1 (before token estimates) have no tok.
215 const tok = r.tok ?? 0
216 s.tok += tok
217 if (v === 'unused') s.wastedTok += tok
218 if ((r.stale?.length ?? 0) > 0) s.stale += 1
219 if (r.risk !== undefined) s.risky += 1
220 if (r.corrected !== undefined && r.verdict !== 'pending') {
221 s.reviewed += 1
222 if (r.corrected === r.verdict) s.agreed += 1
223 }
224 }
225 return s
226}
227
228export function fmtTok(n: number): string {
229 return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n)
230}
231
232export function statusText(s: Summary): string | undefined {
233 if (s.recalled === 0) return undefined
234 const extra = [s.stale > 0 ? `${s.stale} stale` : '', s.risky > 0 ? `${s.risky} to check` : ''].filter(x => x !== '')
235 return [`ghost ${s.recalled} in`, `${s.used} used`, ...extra, `${fmtTok(s.tok)} tok`].join(' · ')
236}
237
238// ---- Hook trace (/tmp/ghost-trace-<session>.jsonl, written by the ghost hooks) ----
239
240type TraceMemory = { key?: unknown; score?: unknown; tok?: unknown; via?: unknown }
241type TraceEvent = {
242 at?: unknown
243 hook?: unknown
244 outcome?: unknown
245 reason?: unknown
246 query?: unknown
247 floor?: unknown
248 injected?: unknown
249 injected_tok?: unknown
250 phases?: { memories?: TraceMemory[]; budget?: unknown; used?: unknown }[]
251}
252
253function num(v: unknown): number {
254 return typeof v === 'number' && Number.isFinite(v) ? v : 0
255}
256
257/** Trace rows from the lines past `seen`; a partial last line waits for the next read. */
258export function parseTrace(text: string, seen: number, turn: number): { rows: TraceRow[]; seen: number } {
259 const lines = text.split('\n')
260 // The last element is '' after a trailing newline, or a line still being written.
261 const complete = lines.length - 1
262 // Fewer lines than already read: a new file (new session id after /clear, or /tmp cleaned).
263 const from = seen > complete ? 0 : seen
264 const rows: TraceRow[] = []
265 for (let i = from; i < complete; i++) {
266 let ev: TraceEvent
267 try {
268 ev = JSON.parse(lines[i] ?? '') as TraceEvent
269 } catch {
270 continue
271 }
272 const byKey = new Map<string, { score?: number; tok: number; via?: string }>()
273 for (const phase of ev.phases ?? []) {
274 for (const m of phase.memories ?? []) {
275 if (typeof m.key !== 'string' || byKey.has(m.key)) continue
276 byKey.set(m.key, {
277 score: typeof m.score === 'number' ? m.score : undefined,
278 tok: typeof m.tok === 'number' ? m.tok : 0,
279 via: typeof m.via === 'string' ? m.via : undefined,
280 })
281 }
282 }
283 const keys = Array.isArray(ev.injected) ? ev.injected.filter((k): k is string => typeof k === 'string') : []
284 const injected = keys.map(key => ({ key, score: byKey.get(key)?.score, tok: byKey.get(key)?.tok ?? 0, via: byKey.get(key)?.via }))
285 rows.push({
286 at: typeof ev.at === 'string' ? ev.at : '',
287 turn,
288 hook: typeof ev.hook === 'string' ? ev.hook : '?',
289 outcome: typeof ev.outcome === 'string' ? ev.outcome : '?',
290 reason: typeof ev.reason === 'string' ? ev.reason : undefined,
291 query: typeof ev.query === 'string' ? ev.query : '',
292 floor: typeof ev.floor === 'number' ? ev.floor : undefined,
293 injected,
294 tok: typeof ev.injected_tok === 'number' ? ev.injected_tok : injected.reduce((n, i) => n + i.tok, 0),
295 budget: (ev.phases ?? []).reduce((n, p) => n + num(p.budget), 0),
296 used: (ev.phases ?? []).reduce((n, p) => n + num(p.used), 0),
297 })
298 }
299 return { rows, seen: complete }
300}
301
302/** Where ghost's tokens went this session, by channel, against the prompt hook's session cap. */
303export type Ledger = {
304 start: number
305 prompt: number
306 explicit: number
307 total: number
308 /** The prompt hook's GHOST_SESSION_TOKEN_CAP; 0 = no cap. */
309 cap: number
310 /** Injected tokens the agent did not use (final verdicts). */
311 unused: number
312}
313
314export function ledger(traces: TraceRow[], recalls: Recall[], cap: number): Ledger {
315 let start = 0
316 let prompt = 0
317 for (const t of traces) {
318 if (t.hook === 'session-start') start += t.tok ?? 0
319 else if (t.hook === 'prompt') prompt += t.tok ?? 0
320 }
321 const explicit = recalls.filter(r => r.channel === 'explicit').reduce((n, r) => n + (r.tok ?? 0), 0)
322 const unused = recalls.filter(r => finalVerdict(r) === 'unused').reduce((n, r) => n + (r.tok ?? 0), 0)
323 return { start, prompt, explicit, total: start + prompt + explicit, cap, unused }
324}
325
326/** One line: per-channel tokens, cap, share of the context window. */
327export function ledgerText(l: Ledger, window?: number): string {
328 const share = window !== undefined && window > 0 ? ` (${((l.total / window) * 100).toFixed(1)}% of context)` : ''
329 const promptPart = l.cap > 0 ? `prompt ${fmtTok(l.prompt)}/${fmtTok(l.cap)} cap` : `prompt ${fmtTok(l.prompt)}`
330 return `tokens: start ${fmtTok(l.start)} · ${promptPart} · ask ${fmtTok(l.explicit)} · total ${fmtTok(l.total)}${share} · unused ${fmtTok(l.unused)}`
331}
332
333/** Fills score, tokens, floor and query on recalls the trace explains. */
334export function enrich(recalls: Recall[], rows: TraceRow[]): Recall[] {
335 const info = new Map<string, { score?: number; tok: number; floor?: number; query: string; via?: string }>()
336 for (const row of rows) for (const i of row.injected) info.set(i.key, { score: i.score, tok: i.tok, floor: row.floor, query: row.query, via: i.via })
337 if (info.size === 0) return recalls
338 return recalls.map(r => {
339 const i = r.channel === 'explicit' ? undefined : info.get(r.key)
340 if (i === undefined || r.query !== undefined) return r
341 return { ...r, score: i.score ?? r.score, tok: i.tok > 0 ? i.tok : r.tok, floor: i.floor, query: i.query, via: i.via ?? r.via }
342 })
343}
344
345/** Loaded although it scored under the floor: it came in by an edge or the relevance rescue. */
346export function belowFloor(r: { score?: number; floor?: number }): boolean {
347 return r.score !== undefined && r.floor !== undefined && r.score < r.floor
348}
349
350export function shortKey(key: string, max: number): string {
351 return key.length <= max ? key : `${key.slice(0, Math.max(1, max - 1))}…`
352}
353
354/** The band's lines for the latest prompt-hook run. */
355export function bandLines(row: TraceRow | undefined, width: number, l?: Ledger): string[] {
356 if (row === undefined) return []
357 // The session's running total rides at the end of the first line.
358 const session = l === undefined ? '' : ` · session ${fmtTok(l.total)}${l.cap > 0 ? `, prompt ${fmtTok(l.prompt)}/${fmtTok(l.cap)}` : ''}`
359 if (row.outcome === 'injected') {
360 const budget = (row.budget ?? 0) > 0 ? ` of ${fmtTok(row.budget)} budget` : ''
361 const head = `ghost loaded ${row.injected.length} for this prompt · ${fmtTok(row.tok)} tok${budget} `
362 let line = head
363 for (const i of row.injected) {
364 const part = ` ${i.score === undefined ? '—' : i.score.toFixed(2)} ${shortKey(i.key, 34)}`
365 if (line.length + part.length + session.length > width) break
366 line += part
367 }
368 line += session
369 const under = row.injected.filter(i => belowFloor({ score: i.score, floor: row.floor }))
370 const lines = [line.slice(0, width)]
371 if (under.length > 0) {
372 // ghost's own `via` names the path; older binaries leave it out.
373 const how = (i: { via?: string }): string => (i.via !== undefined ? ` (${i.via})` : '')
374 lines.push(` ${under.length} under the ${row.floor} floor got in: ${under.map(i => `${i.score?.toFixed(2)} ${shortKey(i.key, 30)}${how(i)}`).join(', ')}`.slice(0, width))
375 }
376 return lines
377 }
378 return [`ghost nothing loaded: ${row.reason ?? row.outcome}${session}`.slice(0, width)]
379}
380
381/**
382 * Path tokens a memory names under a top-level directory that exists here,
383 * as `[path, topDir]`. Paths into other repos are skipped: their top directory
384 * is absent, so their absence says nothing.
385 */
386export function pathTokens(tokens: string[]): { path: string; top: string }[] {
387 const out: { path: string; top: string }[] = []
388 for (const t of tokens) {
389 if (!t.includes('/') || t.startsWith('/') || t.startsWith('~') || t.includes('://')) continue
390 const top = t.split('/')[0] ?? ''
391 if (top === '' || top === '.' || top === '..') continue
392 out.push({ path: t, top })
393 }
394 return out.slice(0, 8)
395}
396
397const CORRECTION = /^\s*(no\b|nope\b|wrong\b|that'?s (not|wrong)|not (that|what)|actually\b|stop\b|don'?t\b|undo\b|revert\b)/i
398
399/** The person pushing back on the last turn. */
400export function isCorrection(text: string): boolean {
401 return CORRECTION.test(text)
402}
403
404/** Marks recalls judged used on `turn` for review after the person corrected that turn. */
405export function flagRisk(recalls: Recall[], turn: number): Recall[] {
406 let changed = false
407 const out = recalls.map(r => {
408 if (r.verdict !== 'used' || r.judgedTurn !== turn || r.risk !== undefined) return r
409 changed = true
410 return { ...r, risk: 'used, then your next prompt pushed back' }
411 })
412 return changed ? out : recalls
413}
414
415/**
416 * Recalls back from a shadow log, for a resumed session whose $.state started
417 * empty: without this the first rewrite would drop every earlier verdict.
418 * The log keeps no memory text, so detail views of these show the key only.
419 */
420export function recallsFromLog(text: string): Recall[] {
421 const out: Recall[] = []
422 for (const line of text.split('\n')) {
423 if (line.trim() === '') continue
424 let v: Record<string, unknown>
425 try {
426 v = JSON.parse(line) as Record<string, unknown>
427 } catch {
428 continue
429 }
430 if (typeof v.key !== 'string' || typeof v.ns !== 'string') continue
431 const channel = v.channel === 'session-start' || v.channel === 'prompt' || v.channel === 'explicit' ? v.channel : 'prompt'
432 const verdict = v.verdict === 'used' || v.verdict === 'unused' ? v.verdict : 'pending'
433 out.push({
434 key: v.key,
435 ns: v.ns,
436 channel,
437 snippet: '',
438 content: '',
439 tokens: [],
440 score: typeof v.score === 'number' ? v.score : undefined,
441 floor: typeof v.floor === 'number' ? v.floor : undefined,
442 tok: typeof v.tok === 'number' ? v.tok : 0,
443 shownTurn: 0,
444 shownAt: typeof v.shownAt === 'number' ? v.shownAt : 0,
445 verdict,
446 evidence: typeof v.evidence === 'string' ? v.evidence : undefined,
447 corrected: v.corrected === 'used' || v.corrected === 'unused' ? v.corrected : undefined,
448 stale: Array.isArray(v.stale) ? v.stale.filter((s): s is string => typeof s === 'string') : undefined,
449 risk: typeof v.risk === 'string' ? v.risk : undefined,
450 via: typeof v.via === 'string' ? v.via : undefined,
451 prior: true,
452 })
453 }
454 return out
455}
456
457/** The shadow log: one JSON line per recall, rewritten whole on each change. */
458export function logLines(session: string, recalls: Recall[]): string {
459 return recalls
460 .map(r =>
461 JSON.stringify({
462 session,
463 ns: r.ns,
464 key: r.key,
465 channel: r.channel,
466 shownAt: r.shownAt,
467 score: r.score,
468 floor: r.floor,
469 tok: r.tok,
470 verdict: r.verdict,
471 evidence: r.evidence,
472 via: r.via,
473 corrected: r.corrected,
474 stale: r.stale,
475 risk: r.risk,
476 }),
477 )
478 .join('\n')
479 .concat(recalls.length > 0 ? '\n' : '')
480}
481hooks/report.ts 132 lines1// Aggregates ghost-lens shadow logs and hook traces across sessions: is ghost
2// helping? Pure (no `$`), so the /ghost command and scripts/report.ts share it.
3
4type Row = Record<string, unknown>
5
6function rows(text: string): Row[] {
7 const out: Row[] = []
8 for (const line of text.split('\n')) {
9 if (line.trim() === '') continue
10 try {
11 const v = JSON.parse(line) as unknown
12 if (v !== null && typeof v === 'object') out.push(v as Row)
13 } catch {
14 // a partial last line, or a line from an older format
15 }
16 }
17 return out
18}
19
20function n(v: unknown): number {
21 return typeof v === 'number' && Number.isFinite(v) ? v : 0
22}
23
24export type ChannelStats = {
25 recalled: number
26 used: number
27 unused: number
28 pending: number
29 tok: number
30 usedTok: number
31 wastedTok: number
32}
33
34export type Report = {
35 sessions: number
36 channels: Record<string, ChannelStats>
37 /** Prompt-hook runs by outcome, and the reasons behind skips. */
38 promptRuns: Record<string, number>
39 skipReasons: Record<string, number>
40 capHits: number
41 /** Person's corrections against the mod's verdicts. */
42 reviewed: number
43 agreed: number
44}
45
46export type SessionLogs = { shadow: string; trace: string }
47
48function emptyChannel(): ChannelStats {
49 return { recalled: 0, used: 0, unused: 0, pending: 0, tok: 0, usedTok: 0, wastedTok: 0 }
50}
51
52/** Reason text with numbers stripped, so "prompt is 3 words" and "prompt is 5 words" group. */
53export function reasonKey(reason: string): string {
54 return reason.replace(/\(.*?\)/g, '').replace(/\d+(\.\d+)?/g, 'N').replace(/\s+/g, ' ').trim()
55}
56
57export function buildReport(sessions: SessionLogs[]): Report {
58 const r: Report = { sessions: 0, channels: {}, promptRuns: {}, skipReasons: {}, capHits: 0, reviewed: 0, agreed: 0 }
59 for (const s of sessions) {
60 const recalls = rows(s.shadow)
61 const traces = rows(s.trace)
62 if (recalls.length === 0 && traces.length === 0) continue
63 r.sessions += 1
64 for (const rec of recalls) {
65 const channel = typeof rec.channel === 'string' ? rec.channel : 'unknown'
66 const c = (r.channels[channel] ??= emptyChannel())
67 const verdict = rec.verdict === 'used' || rec.verdict === 'unused' ? rec.verdict : 'pending'
68 const final = rec.corrected === 'used' || rec.corrected === 'unused' ? rec.corrected : verdict
69 const tok = n(rec.tok)
70 c.recalled += 1
71 c[final] += 1
72 c.tok += tok
73 if (final === 'used') c.usedTok += tok
74 if (final === 'unused') c.wastedTok += tok
75 if (rec.corrected !== undefined && verdict !== 'pending') {
76 r.reviewed += 1
77 if (rec.corrected === verdict) r.agreed += 1
78 }
79 }
80 for (const t of traces) {
81 if (t.hook !== 'prompt') continue
82 const outcome = typeof t.outcome === 'string' ? t.outcome : 'unknown'
83 r.promptRuns[outcome] = (r.promptRuns[outcome] ?? 0) + 1
84 if (outcome !== 'injected' && typeof t.reason === 'string') {
85 const key = reasonKey(t.reason)
86 r.skipReasons[key] = (r.skipReasons[key] ?? 0) + 1
87 if (key.startsWith('session cap reached')) r.capHits += 1
88 }
89 }
90 }
91 return r
92}
93
94function pct(a: number, b: number): string {
95 return b === 0 ? '—' : `${Math.round((a / b) * 100)}%`
96}
97
98function tok(v: number): string {
99 return v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)
100}
101
102/** Plain-text table: per channel use rate and token cost, then prompt-hook outcomes. */
103export function reportText(r: Report): string {
104 const lines = [`ghost report · ${r.sessions} session${r.sessions === 1 ? '' : 's'}`, '']
105 lines.push('channel loaded used rate tok tok/used wasted')
106 const order = ['session-start', 'prompt', 'explicit', ...Object.keys(r.channels).filter(k => !['session-start', 'prompt', 'explicit'].includes(k))]
107 for (const name of order) {
108 const c = r.channels[name]
109 if (c === undefined) continue
110 const judged = c.used + c.unused
111 lines.push(
112 [
113 name.padEnd(14),
114 String(c.recalled).padStart(6),
115 String(c.used).padStart(5),
116 pct(c.used, judged).padStart(5),
117 tok(c.tok).padStart(6),
118 (c.used === 0 ? '—' : tok(Math.round(c.tok / c.used))).padStart(9),
119 tok(c.wastedTok).padStart(7),
120 ].join(' '),
121 )
122 }
123 const runs = Object.entries(r.promptRuns).sort((a, b) => b[1] - a[1])
124 if (runs.length > 0) {
125 lines.push('', `prompt hook: ${runs.map(([k, v]) => `${k} ${v}`).join(' · ')}`)
126 const reasons = Object.entries(r.skipReasons).sort((a, b) => b[1] - a[1]).slice(0, 6)
127 for (const [k, v] of reasons) lines.push(` ${String(v).padStart(4)} ${k}`)
128 }
129 lines.push('', r.reviewed === 0 ? 'detector: no corrections yet' : `detector: you agreed with ${r.agreed} of ${r.reviewed} verdicts (${pct(r.agreed, r.reviewed)})`)
130 return lines.join('\n')
131}
132types/index.d.ts 83 lines1/** How a memory reached the conversation. */
2export type Channel = 'session-start' | 'prompt' | 'explicit'
3
4export type Verdict = 'pending' | 'used' | 'unused'
5
6/** One memory recalled into this session, and what became of it. */
7export type Recall = {
8 key: string
9 ns: string
10 channel: Channel
11 /** First line of the memory, for the pane. */
12 snippet: string
13 /** The memory text as injected, cut to 800 chars, for the detail view. */
14 content: string
15 /** Identifier-like tokens from the memory body (paths, flags, env vars, code names). */
16 tokens: string[]
17 /** Retrieval score from ghost, when known (hook trace or tool result). */
18 score?: number
19 /** The score floor in force when it was loaded; a lower score means it entered by edge or rescue. */
20 floor?: number
21 /** How ghost let it in (`ghost context` via): pinned, search, rescued, edge, reserved, parent. */
22 via?: string
23 /** Estimated tokens it cost in context. */
24 tok: number
25 /** The query that pulled it in. */
26 query?: string
27 /** Main-loop turn index when it was injected. */
28 shownTurn: number
29 shownAt: number
30 /** The mod's automatic judgment. */
31 verdict: Verdict
32 /** Turn the verdict was reached. */
33 judgedTurn?: number
34 /** Why it was judged used (key named, ghost_get, tokens). */
35 evidence?: string
36 /** The person's correction from the pane, when given. */
37 corrected?: 'used' | 'unused'
38 /** Paths it names under a directory that exists here but which do not exist: likely stale. */
39 stale?: string[]
40 /** Something to check: e.g. it was used and the next prompt was a correction. */
41 risk?: string
42 /** Loaded back from the log on resume: an earlier exposure, replaced if ghost injects it again. */
43 prior?: boolean
44}
45
46/** One run of a ghost hook, from its trace line. */
47export type TraceRow = {
48 at: string
49 turn: number
50 hook: string
51 outcome: string
52 reason?: string
53 query: string
54 floor?: number
55 injected: { key: string; score?: number; tok: number; via?: string }[]
56 /** Tokens actually injected (after dedup against what the session already has). */
57 tok: number
58 /** What the hook allowed (sum of --budget over its calls) and what ghost reported packing. */
59 budget: number
60 used: number
61}
62
63/** A memory the agent wrote this session. */
64export type Captured = { key: string; ns: string; turn: number; tool: string }
65
66declare module 'claude-code' {
67 interface PluginState {
68 'ghost-lens': {
69 recalls: Recall[]
70 turn: number
71 traces: TraceRow[]
72 /** Lines already read, per trace file (a new session id after /clear is a new file). */
73 traceSeen: Record<string, number>
74 /** The session id the rest of this state belongs to. */
75 session: string | null
76 /** Band, status line and toasts on; off by default so ghost stays out of sight. */
77 debug: boolean
78 captured: Captured[]
79 selected: string | null
80 }
81 }
82}
83