SLOPSHOPPER

ghost-lens

Dotfiles – (neo)vim, tmux and zsh configuration

newpanebandguardcommandtoast
v?no licenseupdated 2026-10-06rcliao/dotfiles/dot_claude/mods/ghost-lens
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · ghost-lens
│ ┃ Ghost ✕ › fix the failing auth test and add an audit log call │ ┃ Loaded 0 · 0 tok · 0 used · 0 unused (0 tok) │ ┃ · 0 pending ⏺ Read(src/auth.ts) │ ┃ tokens: start 0 · prompt 0/6.0k cap · ask 0 ⎿ Read 6 lines │ ┃ · total 0 (0.0% of context) · unused 0 ⏺ Update(src/auth.ts) │ ┃ no corrections yet · shadow mode, nothing ⎿ Added 2 lines, removed 1 line │ ┃ written to ghost ⏺ Bash(bun test) │ ┃ Esc or /ghost close to close ⎿ 3 pass, 1 fail │ ┃ │ ┃ Memories ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ turn from score tok verdict key │ ┃ Nothing loaded yet. ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Hook runs › /ghost │ ┃ No trace yet (hooks write ⎿ ghost-lens: ghost: 0 loaded (0 tok), 0 used, 0 unused (0 tok), 0 │ ┃ /tmp/ghost-trace-<session>.jsonl). │ ┃ │ ┃ Captured │ ┃ No agent writes this session. │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ghost
Loaded 0 · 0 tok · 0 used · 0 unused (0 tok) · 0 pending tokens: start 0 · prompt 0/6.0k cap · ask 0 · total 0 (0.0% of context) · unused 0 no corrections yet · shadow mode, nothing written to ghost Esc or /ghost close to close Memories turn from score tok verdict key Nothing loaded yet. Hook runs No trace yet (hooks write /tmp/ghost-trace-<session>.jsonl). Captured No agent writes this session.
README

Eric's Dotfiles

Managed with chezmoi. Shell, terminals, editors, git, and the Claude Code setup I actually work in.

Set up a new machine

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:

PromptWhy it is per-machine
Full namegit author name
Git emailso a work laptop and a personal box can share this repo without cross-signing commits
Ghost hookssee 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.

Day to day

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.

What is in here

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.

Ghost memory hooks (opt-in)

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.

aerospace

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

What is deliberately not tracked

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.

Source 4 files
hooks/register.tsx 504 lines
1import { 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-&lt;session&gt;.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}
504
hooks/lens.ts 481 lines
1import 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}
481
hooks/report.ts 132 lines
1// 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}
132
types/index.d.ts 83 lines
1/** 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