Keeps a ledger of a long chat (what you asked and decided, what was learned, the files that matter, one line per turn) that the agent can read, and hands it…

A Claude Code mod that keeps a ledger of a long chat, so the agent does not forget what you asked, what you decided, what it learned the hard way, and which files matter. It writes the ledger after every turn, tells the agent where it is, and hands it back right after Claude Code compacts the chat. A side panel shows one line per turn.
The mod never changes, starts or blocks compaction. It only notices that one happened.
In a Claude Code terminal session:
/plugin install session-state --marketplace kargarisaac/session-state
Answer y to add the marketplace, pick a scope, and paste your OpenRouter key when asked (it is stored as a secret). From a shell instead:
claude plugin install session-state --marketplace kargarisaac/session-state
The mod is active right away in that session and in every session you start after. After an update, run /reload-plugins. To try it from a clone without installing: claude --plugin-dir <folder>.
No OpenRouter key? Set the gate setting to off (see Privacy).
Work as usual. Then:
/session-state opens one side panel with three tabs at the top (press 1, 2 or 3, or click): Steps (each turn with its outcome, files and the notes it added), Ledger (open asks, decisions and rules, learnings and files, colour-coded by kind, with a switch between Now and History) and Stats (recorder status, counts, models, cost and recent gate decisions). Colours come from your Claude Code theme./session-state steps | ledger | stats opens the panel on that tab./session-state close | pause | resume | retry | path | digest closes the panel, stops recording, resumes, re-runs failed turns, prints the folder, or prints what the agent gets after a compaction./session-state rebuild moves the chat's ledger files to before-rebuild/ (before-rebuild-2/ and on, for the next rebuilds) and reads the whole chat again from Claude Code's transcript. It leaves the files alone when the transcript has no turns to read, or when the chat continues one that was compacted under a new session id (its transcript starts after the compaction, so the earlier turns are only in the ledger).The panel reads state.md, steps.md and history.md again every few seconds, so a hand edit shows without reopening it. Closing the panel, or asking for a tab, takes the Ledger tab back to state.md.
Steps: one line per turn, with what the tools did and what the turn added (+M-002) or finished (done M-002).

Ledger: state.md as it stands: open asks, decisions and rules with why and your words, learnings, and the files that matter.

Stats: the gate's decision for each turn, how many turns got a summary or an extraction, and the cost.

These come from a 7-turn demo chat. In turn 4 the user answered "the first one" (pytest); Jev scored that short reply 0.32, so it got a step but no note.
Each chat gets a folder, ~/.claude/session-state/<project>/<date>_<id8>/, with an INDEX.md per project:
| File | Holds |
|---|---|
state.md | What is true now: open asks, decisions and rules, learnings, files that matter. Edit or delete an item by hand and the change sticks; lines under "Your notes" are kept. |
steps.md | One line per turn, never rewritten: - t41 · ✓ · Moved the parser to its own file · edited core/parse.ts · +M-012 · done M-009 |
history.md | Items that left state.md: replaced by a newer one, or done. |
events.jsonl | Each turn as it was (redacted, clipped, with its tool facts), and each gate and Haiku run. |
A note in state.md:
### M-012 · decision · t41
Store the ledger as one folder per session, with state.md rewritten and steps.md append-only.
Why: Keeps the current state short while every turn stays on record.
Context: Choosing the layout for the session ledger mod.
Quote: "Folder, split files"
Source: t41 · turn 3f2a9c1b · answers t40
When Claude Code auto-compacts a chat and carries on under a new session id, the new session starts from a copy of the old session's ledger and keeps numbering its turns from there: the folder this session was recording into or, failing that, the folder whose transcript holds the message that the compaction boundary at the top of the new transcript points back to. The agent still gets the digest on your next message. The old folder is left as it was.
A chat the mod meets part-way (installed or upgraded mid-chat, or resumed) is read back from Claude Code's transcript: its last 100 turns are queued like live ones, so each gets a step line and its notes in the background, with the right turn numbers. A mod cannot read a file over 4 MiB, so a chat whose transcript is bigger than that is not read back. Only when no turns can be read back from the transcript (there is none, or it is that big) does a notes file from an older version (<date>_<id8>.md) seed the folder. Old files are left as they were.
flowchart LR
T[Turn ends] --> F[Tool facts: files read and edited, failed commands, commits]
T --> J{Jev: did the user say something lasting?}
J -- no --> S[Haiku, low effort: summarize]
J -- yes --> E[Haiku, set effort: summarize and extract]
F --> S
F --> E
S --> C[Code checks]
E --> C
C --> L[state.md, steps.md, history.md, events.jsonl]
L --> P[System prompt: where the ledger is, and your rules]
L --> D[After a compaction: digest on the next prompt]
L --> UI[Side panel]
Every turn the mod takes what you typed, including messages sent while the agent was working and your answers to its multiple-choice questions. It records what the main agent's tools did (files read and edited, commands that failed, commits) and archives the turn. Jev (a yes/no decision model on OpenRouter) reads your message, the agent's reply and the end of the previous reply, and decides whether you said something that will still matter 50 turns later. Then one Haiku call runs on your Claude Code login (plan usage, not billed):
Haiku sees the ledger, the last 15 steps, your last 10 messages word for word, the whole previous reply and this turn. Code then checks its answer: a note's quote must be in your message, a learning's evidence in the reply or a failed command, a file role must be for a file edited this turn. Anything else is dropped.
What the agent is told. Two things go into the system prompt. A fixed line, the same on every request, so it costs one cache write:
Session ledger: a tool keeps a ledger of this conversation in
<folder>/. state.md holds what is true now: the user's open asks, their decisions and rules with the reason and their exact words, what was learned the hard way, and the files that matter with their roles. steps.md has one line per turn; history.md has replaced and finished items. ...
And your rules: the active requirement and preference notes (the newest 8). That part changes only when a rule is added or removed. Turn it off with pinRules.
After a compaction. Claude Code's summary drops much of what a plugin added and reloads only a few files. When the mod sees the main chat compacted, it attaches a digest of the ledger (open asks, rules, learnings, files, recent steps; at most 8,000 characters, each note cut short) to the next message you type, once. A message from a task, a schedule or another session does not use it up. Turns still being recorded are not in the ledger yet, so the digest lists your messages from them first. A ledger with nothing in it gives no digest.
| Setting | Default | Meaning |
|---|---|---|
openrouterKey | empty | Jev key; falls back to OPENROUTER_API_KEY |
gate | jev | jev, or off to extract on every turn |
threshold | 0.63 | Jev probability needed for extraction |
jevModel | jev-1.14 | OpenRouter model id for the gate |
haikuModel / effort | claude-haiku-5-5 / high | Haiku model, and its effort for extraction (summarization is always low) |
maxWords | 15 | Longest note title |
auditRate | 0 | Share of turns below the threshold that get extraction anyway, to check the gate |
pinRules | on | Put your rules in the agent's system prompt |
openPanelOnStart | off | Open the panel with each session |
gate: jev, each turn's message, answer and the end of the previous answer (keys and tokens redacted, text capped) go to OpenRouter and TypeSafe in the US.gate: off, nothing leaves Claude Code; Haiku extracts on every turn and uses more plan quota.grep -q on that one file (by argument vector, no shell, nothing leaves your machine).events.jsonl keeps each turn's text (redacted, clipped) on your disk, so later turns can still read it after a compaction.prompt.compose hooks do not run in organization-managed installs: there the agent is not told about the ledger, but the files and panel still work.The gate. Calibrated on 14 real chats (815 turns), labelled by LLMs with no human in the loop. With jev-1.14 and the previous reply in view, the shipped 0.63 keeps 92% of passed turns on target and passes 62% of the important ones; showing the previous reply raised held-out AUC from 0.84 to 0.91. A turn below the threshold is not lost: it still gets a step, and its words stay in the archive. See eval/REPORT.md.
The ledger. eval/PROBES.md replays real chats through the mod, then asks questions an agent must still answer after a compaction (what you asked, which file, what was decided, what is still open) and grades the answers 0 to 5 with only a compaction-style summary, only the ledger, or both. On two held-out chats (75 turns, 24 questions), summary and ledger together scored 3.54 against 2.83 for the summary alone. The ledger is strongest on what you asked (3.67 alone against 2.17 for the summary) and weakest on what was pending at the very end, which the summary covers. Both miss the agent's own reasons for its choices, which the ledger does not record. The sample is small and Sonnet wrote and graded the questions.
core/ is host-free logic, hooks/ the Claude Code adapter, eval/ the calibration and the probe test. Check with claude plugin validate . and claude plugin test .. To rerun the calibration on your own chats: bun eval/build.ts, label.ts, score.ts, fit.ts (set EVAL_PROJECT_PREFIX to pick project folders under ~/.claude/projects; data stays in git-ignored eval/data/). The probe test: bun eval/probe.ts <transcript.jsonl>....
hooks/register.tsx 882 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register, ThemeKey } from 'claude-code'
3
4import { digestText } from '../core/digest'
5import { addToolCall, emptyFacts } from '../core/facts'
6import { fileIndex, rankFiles } from '../core/files'
7import { archivedTurns, fitLedger, parseLedger, parsePending, serializeLedger, serializePending, summarize, turnRecord } from '../core/ledger'
8import { activeNotes, newFront, parseOldNotes, pointerText, rulesText, upsertIndex } from '../core/notes'
9import { processTurn } from '../core/pipeline'
10import { emptyState, HISTORY_HEAD, historyBlock, learnLabel, noteLabel, parseState, renderState, seedState } from '../core/state'
11import { appendStep, hasStep, lastStep, stepLines } from '../core/steps'
12import { answersOf, continuesFrom, hasMessage, parseTranscript, withAnswers } from '../core/transcript'
13import type { Config, Kind, State, Turn } from '../core/types'
14import type { ExtractEvent, LedgerEvent, PendingTurn, Status, Tab } from '../types'
15import { rows, statusOf } from './format'
16import { configOf, hostOf } from './host'
17import { baseOf, dirOf, eventsOf, folderName, historyOf, pendingOf, projectOf, stateOf, stepsOf, transcriptOf } from './paths'
18import { gateTone, parseStep, rule } from './view'
19
20const PANE = 'session-state'
21const TITLE = 'Session ledger'
22const MAX_FAILED = 5
23const PANEL_STEPS = 40
24// A chat met mid-way (new install, upgrade, resume) is read back from its transcript, up to this many turns.
25const BACKFILL_MAX = 100
26const TABS: Tab[] = ['steps', 'ledger', 'stats']
27const TAB_LABEL: Record<Tab, string> = { steps: 'Steps', ledger: 'Ledger', stats: 'Stats' }
28// What kind of note it is, as a theme colour, so a glance tells an ask from a rule.
29const KIND_TONE: Record<Kind, ThemeKey> = { ask: 'warning', decision: 'permission', requirement: 'suggestion', preference: 'remember', correction: 'error' }
30
31const sessionId = atom({ plugin: 'session-state', key: 'sessionId' } as const, '')
32// This session's ledger folder: <project>/<date>_<id8>.
33const dir = atom({ plugin: 'session-state', key: 'dir' } as const, '')
34// Where the chat started. The session's own directory moves when the person enters a worktree.
35const origin = atom({ plugin: 'session-state', key: 'origin' } as const, '')
36const md = atom({ plugin: 'session-state', key: 'md' } as const, '')
37const steps = atom({ plugin: 'session-state', key: 'steps' } as const, '')
38const history = atom({ plugin: 'session-state', key: 'history' } as const, '')
39const events = atom({ plugin: 'session-state', key: 'events' } as const, [] as LedgerEvent[])
40const queue = atom({ plugin: 'session-state', key: 'queue' } as const, [] as PendingTurn[])
41const failed = atom({ plugin: 'session-state', key: 'failed' } as const, [] as PendingTurn[])
42const pendingUser = atom({ plugin: 'session-state', key: 'pendingUser' } as const, '')
43const previousAnswer = atom({ plugin: 'session-state', key: 'previousAnswer' } as const, '')
44const facts = atom({ plugin: 'session-state', key: 'facts' } as const, emptyFacts())
45const turns = atom({ plugin: 'session-state', key: 'turns' } as const, 0)
46const status = atom({ plugin: 'session-state', key: 'status' } as const, { isBusy: false, turn: 0, message: '' } as Status)
47const isPaused = atom({ plugin: 'session-state', key: 'isPaused' } as const, false)
48// Set when Claude Code compacts the main chat; the next typed prompt carries the digest and clears it.
49const isCompacted = atom({ plugin: 'session-state', key: 'isCompacted' } as const, false)
50const tab = atom({ plugin: 'session-state', key: 'tab' } as const, 'steps' as Tab)
51const isHistoryShown = atom({ plugin: 'session-state', key: 'isHistoryShown' } as const, false)
52
53// Bookkeeping never fails the event it watches.
54const quiet = () => undefined
55
56const readText = async ($: EngineInterface, path: string): Promise<string> => {
57 try {
58 return await $.fs.read(path)
59 } catch {
60 return ''
61 }
62}
63
64const listOf = async ($: EngineInterface, path: string) => {
65 try {
66 return await $.fs.list(path)
67 } catch {
68 return []
69 }
70}
71
72// This project's folder first, then every other project: a resumed chat may start elsewhere.
73const findIn = async ($: EngineInterface, root: string, here: string, isMatch: (name: string, kind: string) => boolean): Promise<string | undefined> => {
74 const look = async (d: string) => (await listOf($, d)).find(e => isMatch(e.name, e.kind))?.name
75 const found = await look(here)
76 if (found !== undefined) return `${here}/${found}`
77 for (const project of await listOf($, root)) {
78 if (project.kind !== 'dir' || `${root}/${project.name}` === here) continue
79 const name = await look(`${root}/${project.name}`)
80 if (name !== undefined) return `${root}/${project.name}/${name}`
81 }
82 return undefined
83}
84
85const dayOf = async ($: EngineInterface): Promise<string> => new Date(await $.clock.now()).toISOString().slice(0, 10)
86
87// The front matter of this session's files. A boot passes the id it is setting up: the atom holds it only at the end.
88const frontOf = async ($: EngineInterface, settingUp?: string): Promise<Record<string, string>> => {
89 const id = settingUp ?? (await read($, sessionId))
90 const cwd = (await read($, origin)) || (await $.session.cwd())
91 return newFront({ sessionId: id, project: projectOf(cwd), cwd, started: await dayOf($), transcript: transcriptOf(cwd, id) })
92}
93
94// The file is the truth: read it back each turn so the person's edits stand.
95const loadState = (text: string, front: Record<string, string>): State => {
96 if (text === '') return emptyState(front)
97 const parsed = parseState(text)
98 return { ...parsed, front: { ...front, ...parsed.front } }
99}
100
101const LEDGER_FILES = [stateOf, stepsOf, historyOf, eventsOf, pendingOf]
102const MAX_PARENTS = 12
103
104// The transcript a ledger folder names in its state.md.
105const transcriptPathIn = async ($: EngineInterface, home: string, folder: string): Promise<string | undefined> => {
106 const path = /^transcript:\s*(.+)$/m.exec(await readText($, stateOf(folder)))?.[1]?.trim()
107 return path?.replace(/^~/, home)
108}
109
110// The text of a transcript; empty when there is none, or $.fs.read cannot read it (it rejects a file over 4 MiB, as
111// a long chat's is).
112const transcriptIn = async ($: EngineInterface, home: string, folder: string): Promise<string> => {
113 const path = await transcriptPathIn($, home, folder)
114 return path === undefined ? '' : readText($, path)
115}
116
117// Whether a ledger folder's transcript holds a message. One too big to read is searched by grep, which is run by
118// its argument vector (no shell) on that one file, with an id that has nothing but letters, digits and dashes.
119const holdsMessage = async ($: EngineInterface, home: string, folder: string, uuid: string): Promise<boolean> => {
120 const path = await transcriptPathIn($, home, folder)
121 if (path === undefined) return false
122 const text = await readText($, path)
123 if (text !== '') return hasMessage(text, uuid)
124 if (!/^[A-Za-z0-9-]{1,64}$/.test(uuid)) return false
125 try {
126 const found = await $.process.run(['grep', '-q', '-E', '-e', `"uuid"[[:space:]]*:[[:space:]]*"${uuid}"`, '--', path])
127 return found.exitCode === 0
128 } catch {
129 return false
130 }
131}
132
133// The ledger folder of the chat this one continues. In the process that compacted it, that is the folder it was
134// recording into, whatever the size of its transcript. Otherwise it is found by the last message the new transcript
135// points back to, among the folders whose ledgers were written last.
136const parentFolder = async ($: EngineInterface, here: string, folder: string, home: string, uuid: string | undefined, previous: string): Promise<string | undefined> => {
137 // A transcript that cannot be read cannot disagree.
138 if (previous !== '' && previous !== folder) {
139 const text = await transcriptIn($, home, previous)
140 if (uuid === undefined || text === '' || hasMessage(text, uuid)) return previous
141 }
142 if (uuid === undefined) return undefined
143 const written: { path: string; at: number }[] = []
144 for (const entry of await listOf($, here)) {
145 const path = `${here}/${entry.name}`
146 if (entry.kind !== 'dir' || path === folder) continue
147 const stat = await $.fs.stat(stateOf(path)).catch(() => undefined)
148 if (stat !== undefined) written.push({ path, at: stat.mtimeMs })
149 }
150 for (const { path } of written.sort((a, b) => b.at - a.at).slice(0, MAX_PARENTS)) {
151 if (await holdsMessage($, home, path, uuid)) return path
152 }
153 return undefined
154}
155
156// A compaction that moved the chat to a new session id keeps its ledger: the old files are copied over, for this
157// session to carry on from. The old folder stays as it was. Says whether there was a parent.
158const inherit = async ($: EngineInterface, here: string, folder: string, home: string, id: string, transcript: string, previous: string): Promise<boolean> => {
159 const uuid = continuesFrom(transcript)
160 // A transcript that has messages and no boundary is a chat of its own, not the continuation of the one before it.
161 const parent = await parentFolder($, here, folder, home, uuid, transcript === '' || uuid !== undefined ? previous : '')
162 if (parent === undefined) return false
163 const front = await frontOf($, id)
164 for (const pathOf of LEDGER_FILES) {
165 const text = await readText($, pathOf(parent))
166 if (text === '') continue
167 const own = pathOf === stateOf ? text.replace(/^(session_id|transcript|resume):.*$/gm, (_, key: string) => `${key}: ${front[key]}`) : text
168 await $.fs.write(pathOf(folder), own).catch(quiet)
169 }
170 return true
171}
172
173// The turns of a transcript that are over: the last one is left out while its reply is still being written.
174const pastTurns = (transcript: string): Turn[] => {
175 const past = parseTranscript(transcript)
176 if (past.at(-1)?.answer === '') past.pop()
177 return past
178}
179
180const boot = async ($: EngineInterface, id: string): Promise<string> => {
181 if ((await read($, sessionId)) === id) {
182 const known = await read($, dir)
183 if (known !== '') return known
184 }
185 // What this process was recording until now. When that session compacted, this one is its continuation, and it
186 // started in the same place, even if the person has since moved into a worktree.
187 const before = { dir: await read($, dir), origin: await read($, origin), isCompacted: await read($, isCompacted), id: await read($, sessionId) }
188 const previous = before.isCompacted ? before.dir : ''
189 const home = (await $.env.get('HOME')) ?? ''
190 const cwd = previous !== '' && before.origin !== '' ? before.origin : await $.session.cwd()
191 const root = `${home}/.claude/session-state`
192 const here = `${root}/${projectOf(cwd)}`
193 const id8 = `_${id.slice(0, 8)}`
194 const found = await findIn($, root, here, (name, kind) => kind === 'dir' && name.endsWith(id8))
195 const folder = found ?? `${here}/${folderName(await dayOf($), id)}`
196 const transcript = await readText($, transcriptOf(cwd, id).replace(/^~/, home))
197 await update($, dir, () => folder)
198 await update($, origin, () => cwd)
199 const isContinued = found === undefined && (await inherit($, here, folder, home, id, transcript, previous))
200
201 let ledger = parseLedger(await readText($, eventsOf(folder)))
202 let pending = parsePending(await readText($, pendingOf(folder)))
203 let text = await readText($, stateOf(folder))
204 const stepsMd = await readText($, stepsOf(folder))
205 if (ledger.length === 0 && pending.length === 0) {
206 // The turns before the mod met this chat, read back from Claude Code's transcript and queued like live
207 // ones. A continuation numbers them on from the steps it was given.
208 const past = pastTurns(transcript).map(t => ({ ...t, turn: t.turn + (isContinued ? lastStep(stepsMd) : 0) }))
209 if (past.length > 0) {
210 const now = await $.clock.now()
211 pending = past.slice(-BACKFILL_MAX)
212 ledger = pending.map(t => turnRecord(t, now))
213 // The transcript is a fuller source than notes seeded from an older version, but not than a parent's ledger.
214 if (!isContinued) {
215 if (text !== '') await $.fs.write(stateOf(folder), '').catch(quiet)
216 text = ''
217 }
218 await $.fs.write(eventsOf(folder), serializeLedger(ledger)).catch(quiet)
219 await $.fs.write(pendingOf(folder), serializePending(pending)).catch(quiet)
220 }
221 }
222 if (text === '' && pending.length === 0) {
223 // A notes file from before 0.3 seeds the new ledger once; the old file is left as it is.
224 const old = await findIn($, root, here, (name, kind) => kind === 'file' && name.endsWith(`${id8}.md`))
225 if (old !== undefined) {
226 const { title, notes } = parseOldNotes(await readText($, old))
227 const front = { ...(await frontOf($, id)), title }
228 text = renderState(seedState(notes, front), [])
229 await $.fs.write(stateOf(folder), text).catch(quiet)
230 }
231 }
232 const archive = archivedTurns(ledger)
233 await update($, md, () => text)
234 await update($, steps, () => stepsMd)
235 const historyMd = await readText($, historyOf(folder))
236 await update($, history, () => historyMd)
237 await update($, events, () => ledger)
238 await update($, queue, () => pending)
239 // pendingUser and facts are not reset: a turn that is running now ends under this id, and they are its.
240 if (!isContinued) await update($, failed, () => [])
241 await update($, previousAnswer, () => pending.at(-1)?.answer ?? archive.at(-1)?.answer ?? '')
242 await update($, turns, () => Math.max(0, ...archive.map(r => r.turn), ...pending.map(t => t.turn), lastStep(stepsMd)))
243 // A turn may be being written up right now; isBusy is the drain's to clear.
244 await update($, status, s => ({ ...s, message: '' }))
245 // A chat that was compacted still owes the agent its digest: under the new id, and after a rebuild (which
246 // empties the session id, then boots the same chat again).
247 await update($, isCompacted, () => isContinued || (before.isCompacted && before.id === ''))
248 // Last, because it says the boot is done: a boot that failed on the way is run again, not taken for finished.
249 await update($, sessionId, () => id)
250 return folder
251}
252
253// One boot per session id, however many hooks ask at once. A /clear or a resume changes the id.
254let booting: { id: string; run: Promise<string> } | undefined
255
256const ensure = async ($: EngineInterface): Promise<string> => {
257 const id = await $.session.id()
258 if (booting === undefined || booting.id !== id) {
259 const run = boot($, id)
260 booting = { id, run }
261 // A boot that failed is tried again by the next hook, not kept as the answer for the rest of the session.
262 run.catch(() => {
263 if (booting?.run === run) booting = undefined
264 })
265 }
266 return booting.run
267}
268
269const ranked = (state: State, ledger: readonly LedgerEvent[], now: number) => rankFiles(fileIndex(archivedTurns(ledger)), state, now)
270
271const writeIndex = async ($: EngineInterface, folder: string, state: State, stepCount: number): Promise<void> => {
272 const indexPath = `${dirOf(folder)}/INDEX.md`
273 const next = upsertIndex(await readText($, indexPath), state.front.project ?? '', {
274 file: `${baseOf(folder)}/state.md`,
275 date: baseOf(folder).slice(0, 10),
276 title: state.front.title ?? '',
277 active: activeNotes(state).length,
278 learned: state.learnings.filter(l => l.status === 'active').length,
279 steps: stepCount,
280 })
281 await $.fs.write(indexPath, next)
282}
283
284const savePending = async ($: EngineInterface): Promise<void> => {
285 await $.fs.write(pendingOf(await read($, dir)), serializePending(await read($, queue)))
286}
287
288const saveEvents = async ($: EngineInterface, add: LedgerEvent[]): Promise<LedgerEvent[]> => {
289 // The new events are added to what the atom holds when the write lands: a turn that ends while the last one is
290 // being saved adds its record to the same list. The archive is kept inside the engine's file size limit.
291 const all = await update($, events, list => fitLedger([...list, ...add]))
292 await $.fs.write(eventsOf(await read($, dir)), serializeLedger(all))
293 return all
294}
295
296const isFailure = (e: LedgerEvent): boolean => e.type === 'extract' && e.error !== undefined
297
298// A turn whose step line is written and whose last run did not fail was recorded before the process stopped (it died
299// between the step line and emptying pending.json). Running it again would add its notes a second time.
300const isRecorded = (stepsMd: string, all: readonly LedgerEvent[], turn: PendingTurn): boolean => {
301 const runs = all.filter((e): e is ExtractEvent => e.type === 'extract' && e.turn === turn.turn && e.turnId === turn.turnId)
302 return runs.length > 0 && runs[runs.length - 1]!.error === undefined && hasStep(stepsMd, turn.turn)
303}
304
305// A /clear, a resume or a compaction moves the process to another chat while a turn is still being recorded.
306const isHere = async ($: EngineInterface, folder: string): Promise<boolean> => (await read($, dir)) === folder
307
308// Says whether the turn is done. It is not when the chat moved to a new session id while Haiku was writing it up:
309// nothing is written then, and the turn stays queued in the folder that has it (a compaction's new session has
310// inherited that queue, so it is handled again there). The same when the chat moves during the writes.
311const handle = async ($: EngineInterface, config: Config, turn: PendingTurn, folder: string): Promise<boolean> => {
312 const state = loadState(await readText($, stateOf(folder)), await frontOf($))
313 const stepsMd = await readText($, stepsOf(folder))
314 const all = await read($, events)
315 if (isRecorded(stepsMd, all, turn)) return true
316 // A turn read back from the transcript sees only the turns before it, as it would have live.
317 const ledger = all.filter(e => e.type !== 'turn' || e.turn <= turn.turn)
318
319 const outcome = await processTurn(
320 hostOf($, config),
321 config,
322 { state, steps: stepLines(stepsMd), archive: archivedTurns(ledger), files: ranked(state, ledger, turn.turn) },
323 turn,
324 )
325 if (!(await isHere($, folder))) return false
326
327 // The state and the step line matter more than the archive: a failed write of it must not stop them.
328 await saveEvents($, outcome.events).catch(quiet)
329 const out = renderState(outcome.state, ranked(outcome.state, ledger, turn.turn))
330 await $.fs.write(stateOf(folder), out)
331 await update($, md, () => out)
332 if (outcome.moved.length > 0) {
333 const before = await readText($, historyOf(folder))
334 const blocks = outcome.moved.map(historyBlock).join('\n\n')
335 const nextHistory = `${before === '' ? HISTORY_HEAD : before}\n${blocks}\n`
336 await $.fs.write(historyOf(folder), nextHistory)
337 await update($, history, () => nextHistory)
338 }
339 const nextSteps = appendStep(stepsMd, outcome.step)
340 if (nextSteps !== stepsMd) await $.fs.write(stepsOf(folder), nextSteps)
341 await update($, steps, () => nextSteps)
342 await writeIndex($, folder, outcome.state, stepLines(nextSteps).length).catch(quiet)
343 if (outcome.events.some(isFailure)) await update($, failed, list => [...list, turn].slice(-MAX_FAILED))
344 // The chat can move on while these files are written. The folder it left has the turn, but the new one may have
345 // copied that folder before, so the turn stays in the new queue (a copy that has it skips it).
346 return isHere($, folder)
347}
348
349// Runs outside any hook's budget, one turn at a time, so the next prompt is never held up.
350const drain = async ($: EngineInterface, config: Config): Promise<void> => {
351 // The loop is claimed in one step on the busy flag itself: of two wake-ups at once, one gets it, so a turn is
352 // never recorded twice.
353 let isClaimed = false
354 await update($, status, s => {
355 isClaimed = !s.isBusy
356 return isClaimed ? { ...s, isBusy: true } : s
357 })
358 if (!isClaimed) return
359 try {
360 for (;;) {
361 // A boot sets the folder first and the queue last. Read in between, they belong to two different chats, and a
362 // turn would be recorded over the ledger that is still being copied. Wait for it, then read both again.
363 await booting?.run.catch(quiet)
364 const folder = await read($, dir)
365 const next = (await read($, queue))[0]
366 if (next === undefined) break
367 await update($, status, s => ({ ...s, turn: next.turn }))
368 let isDone = true
369 try {
370 isDone = await handle($, config, next, folder)
371 await update($, status, s => ({ ...s, message: '' }))
372 } catch (err) {
373 if (!(await isHere($, folder))) continue
374 await update($, status, s => ({ ...s, message: `error: ${String(err).slice(0, 140)}` }))
375 await update($, failed, list => [...list, next].slice(-MAX_FAILED))
376 }
377 // A turn left queued is the other chat's to write down: the queue and its file are not touched.
378 if (isDone) {
379 await update($, queue, list => list.slice(1))
380 await savePending($).catch(quiet)
381 }
382 }
383 } finally {
384 await update($, status, s => ({ ...s, isBusy: false }))
385 }
386}
387
388// Before the process goes, finish what is queued, within the time a hook is given.
389const flush = async ($: EngineInterface, options: PluginOptions): Promise<void> => {
390 const config = await configOf($, options)
391 const deadline = (await $.clock.now()) + 8000
392 while ((await $.clock.now()) < deadline) {
393 const isBusy = (await read($, status)).isBusy
394 if (!isBusy && (await read($, queue)).length === 0) return
395 if (isBusy) await $.clock.sleep(150)
396 else await drain($, config)
397 }
398}
399
400const kick = ($: EngineInterface, options: PluginOptions): void =>
401 void $.clock.after(0, async () => {
402 await drain($, await configOf($, options)).catch(quiet)
403 })
404
405// The panel follows the files, so a hand edit, a rollback or a rebuild shows within seconds. A read that fails
406// comes back empty: a file the panel holds is let go only after two empty reads in a row.
407const empties = new Map<string, number>()
408
409const hasChanged = (path: string, held: string, text: string): boolean => {
410 const misses = text === '' && held !== '' ? (empties.get(path) ?? 0) + 1 : 0
411 empties.set(path, misses)
412 return misses !== 1 && text !== held
413}
414
415const syncPanel = async ($: EngineInterface): Promise<void> => {
416 // The atoms are read first: a turn that lands while the files are read changes them, and then the turn wins.
417 const held = { md: await read($, md), steps: await read($, steps), history: await read($, history) }
418 const folder = await read($, dir)
419 if (folder === '') return
420 const disk = { md: await readText($, stateOf(folder)), steps: await readText($, stepsOf(folder)), history: await readText($, historyOf(folder)) }
421 if (hasChanged(stateOf(folder), held.md, disk.md)) await update($, md, now => (now === held.md ? disk.md : now))
422 if (hasChanged(stepsOf(folder), held.steps, disk.steps)) await update($, steps, now => (now === held.steps ? disk.steps : now))
423 if (hasChanged(historyOf(folder), held.history, disk.history)) await update($, history, now => (now === held.history ? disk.history : now))
424}
425
426// Empty when the ledger has nothing to hand over.
427const digestOf = async ($: EngineInterface): Promise<string> => {
428 const folder = await ensure($)
429 const state = loadState(await read($, md), await frontOf($))
430 const ledger = await read($, events)
431 const stepsMd = await read($, steps)
432 // The queue still holds the turns Haiku has not finished, so the ledger leaves them out.
433 const unrecorded = (await read($, queue)).filter(t => !hasStep(stepsMd, t.turn)).map(t => ({ turn: t.turn, user: t.user }))
434 return digestText(state, stepLines(stepsMd), ranked(state, ledger, await read($, turns)), folder, undefined, unrecorded)
435}
436
437export const register: Register = (on, options) => {
438 on('session.start', async ($, e, next) => {
439 await $.command.register({
440 name: 'session-state',
441 description: 'The ledger of this chat: open the panel (steps, ledger, stats), pause, retry, rebuild',
442 }).catch(quiet)
443 await ensure($).catch(quiet)
444 // The session starts, or the module reloaded: either way no loop of this copy is recording, whatever the
445 // atoms kept from the copy before. Without this a recording that the reload cut short blocks the queue for good.
446 await update($, status, () => ({ isBusy: false, turn: 0, message: '' })).catch(quiet)
447 if ((await read($, queue)).length > 0) kick($, options)
448 // Picks up the person's hand edits for the panel and wakes a stranded queue.
449 $.clock.every(3000, async () => {
450 await syncPanel($).catch(quiet)
451 if ((await read($, queue)).length > 0 && !(await read($, status)).isBusy) kick($, options)
452 })
453 if (options.openPanelOnStart === true) void $.ui.open({ id: PANE, title: TITLE })
454 return next(e)
455 })
456
457 on('session.end', async ($, e, next) => {
458 // A /clear starts a chat of its own: a compaction before it does not carry over to the new id.
459 if (e.reason === 'clear') await update($, isCompacted, () => false).catch(quiet)
460 await flush($, options).catch(quiet)
461 return next(e)
462 })
463
464 on('prompt.submit', async ($, e, next) => {
465 // Typed by the person: at the keyboard, from a phone or from the SDK. Not a task notification, a
466 // schedule, a peer or another plugin.
467 const kind = e.origin?.kind
468 const isTyped = kind === undefined || kind === 'composer' || kind === 'bridge' || kind === 'sdk'
469 if (!isTyped || e.command !== undefined || e.isMeta === true || e.text.trim() === '' || (await read($, isPaused))) return next(e)
470 let digest = ''
471 try {
472 await ensure($)
473 await update($, pendingUser, p => (p === '' ? e.text : `${p}\n\n${e.text}`))
474 // Taken before the digest is built: a second prompt that lands while it is being built finds the flag
475 // down, so the agent is handed the digest once. If it cannot be built, the next prompt is owed it.
476 let isOwed = false
477 await update($, isCompacted, owed => {
478 isOwed = owed
479 return false
480 })
481 if (isOwed) {
482 try {
483 digest = await digestOf($)
484 } catch (error) {
485 await update($, isCompacted, () => true).catch(quiet)
486 throw error
487 }
488 }
489 } catch {
490 // never block a prompt
491 }
492 const out = await next(digest === '' ? e : { ...e, context: [...(e.context ?? []), digest] })
493 if (out.drop !== undefined) {
494 // Dropped beneath: the agent never saw it. It is not something the person asked, and the next typed
495 // prompt carries the digest.
496 await update($, pendingUser, p => (p === e.text ? '' : p.endsWith(`\n\n${e.text}`) ? p.slice(0, -(e.text.length + 2)) : p)).catch(quiet)
497 if (digest !== '') await update($, isCompacted, () => true).catch(quiet)
498 }
499 return out
500 })
501
502 on('turn.start', async ($, e, next) => {
503 if (e.agentId === undefined) await update($, facts, () => emptyFacts()).catch(quiet)
504 return next(e)
505 })
506
507 // Watches the main agent's tool calls for the turn's facts. It never changes a call or its result.
508 on('tool.call', async ($, e, next) => {
509 const out = await next(e)
510 if (e.agentId === undefined && out.deny === undefined) {
511 const call = { tool: e.tool, input: e as unknown as Record<string, unknown>, isError: out.isError === true, text: out.text ?? '' }
512 await update($, facts, f => addToolCall(f, call)).catch(quiet)
513 if (e.tool === 'AskUserQuestion' && out.isError !== true) {
514 const answers = answersOf(out.result ?? out.text)
515 if (answers !== '') await update($, pendingUser, p => withAnswers(p, answers)).catch(quiet)
516 }
517 }
518 return out
519 })
520
521 // Only notes that the main chat was compacted. It never changes, starts or skips a compaction.
522 on('session.compact', async ($, e, next) => {
523 const out = await next(e)
524 if (e.agentId === undefined && e.trigger !== 'precompute' && out.skip === undefined) await update($, isCompacted, () => true).catch(quiet)
525 return out
526 })
527
528 on('turn.complete', async ($, e, next) => {
529 if (e.agentId === undefined) {
530 try {
531 // Taken and cleared in one step each, before anything else is awaited: a message or a tool call that lands
532 // while this turn is being stored belongs to the next turn and must not be cleared with it.
533 let user = ''
534 let toolFacts = emptyFacts()
535 await update($, pendingUser, p => {
536 user = p
537 return ''
538 })
539 await update($, facts, f => {
540 toolFacts = f
541 return emptyFacts()
542 })
543 if (user.trim() !== '' && !(await read($, isPaused))) {
544 await ensure($)
545 const n = (await read($, turns)) + 1
546 const turn: PendingTurn = {
547 turn: n,
548 turnId: e.turnId,
549 user,
550 answer: e.answer,
551 previousAnswer: await read($, previousAnswer),
552 isAborted: e.isAborted,
553 facts: toolFacts,
554 }
555 await update($, turns, () => n)
556 await saveEvents($, [turnRecord(turn, await $.clock.now())]).catch(quiet)
557 await update($, queue, q => [...q, turn])
558 await savePending($).catch(quiet)
559 kick($, options)
560 }
561 if (e.answer !== '') await update($, previousAnswer, () => e.answer)
562 } catch {
563 // never fail the turn's end
564 }
565 }
566 return next(e)
567 })
568
569 on('prompt.compose', async ($, e, next) => {
570 const out = await next(e)
571 try {
572 const folder = await ensure($)
573 const sections = [...out.sections, { id: 'session-state:ledger', text: pointerText(folder), scope: 'session' as const }]
574 if ((await configOf($, options)).pinRules) {
575 const rules = rulesText(loadState(await readText($, stateOf(folder)), {}))
576 if (rules !== '') sections.push({ id: 'session-state:rules', text: rules, scope: 'session' as const })
577 }
578 return { sections }
579 } catch {
580 return out
581 }
582 })
583
584 on('command.run', { command: 'session-state' }, async ($, e) => {
585 const arg = e.args.trim().toLowerCase()
586 if (arg === 'close') {
587 await update($, isHistoryShown, () => false)
588 await $.ui.close({ id: PANE })
589 return { text: 'Session ledger panel closed.' }
590 }
591 if (arg === 'pause' || arg === 'resume') {
592 await update($, isPaused, () => arg === 'pause')
593 return { text: arg === 'pause' ? 'Session ledger paused: new turns are not recorded.' : 'Session ledger resumed.' }
594 }
595 if (arg === 'retry') {
596 const again = await read($, failed)
597 await update($, failed, () => [])
598 await update($, queue, q => [...q, ...again])
599 await savePending($).catch(quiet)
600 kick($, options)
601 return { text: `Retrying ${again.length} turn${again.length === 1 ? '' : 's'}.` }
602 }
603 if (arg === 'rebuild') {
604 if ((await read($, status)).isBusy || (await read($, queue)).length > 0) return { text: 'Still recording turns; run rebuild when the queue is empty.' }
605 const folder = await ensure($)
606 const home = (await $.env.get('HOME')) ?? ''
607 const transcript = await readText($, ((await frontOf($)).transcript ?? '').replace(/^~/, home))
608 // Nothing is set aside unless the transcript has turns to rebuild the ledger from.
609 if (pastTurns(transcript).length === 0) return { text: 'No earlier turns in the transcript to rebuild from. The ledger is unchanged.' }
610 // A chat Claude Code continued under a new session id has a transcript that starts after the compaction: the
611 // turns before it, and the notes they gave, are only in the ledger it was given.
612 const parent = continuesFrom(transcript)
613 if (parent !== undefined && !hasMessage(transcript, parent)) {
614 return { text: 'This chat continues one that was compacted, and its transcript starts after the compaction, so a rebuild would lose the earlier part of the ledger. The ledger is unchanged.' }
615 }
616 // The current files move to before-rebuild/ (before-rebuild-2/ and on, when an earlier rebuild used it), then the
617 // next boot reads the chat back from its transcript.
618 const taken = new Set((await listOf($, folder)).map(entry => entry.name))
619 let aside = 'before-rebuild'
620 for (let n = 2; taken.has(aside); n++) aside = `before-rebuild-${n}`
621 for (const path of [stateOf(folder), stepsOf(folder), historyOf(folder), eventsOf(folder), pendingOf(folder)]) {
622 const text = await readText($, path)
623 if (text === '') continue
624 await $.fs.write(`${folder}/${aside}/${baseOf(path)}`, text)
625 await $.fs.write(path, '')
626 }
627 booting = undefined
628 await update($, sessionId, () => '')
629 await ensure($)
630 kick($, options)
631 const queued = (await read($, queue)).length
632 return { text: `Rebuilding the ledger from the transcript: ${queued} turns queued. The old files are in ${aside}/.` }
633 }
634 if (arg === 'path') return { text: await ensure($) }
635 if (arg === 'digest') return { text: (await digestOf($)) || 'Nothing in the ledger yet, so a compaction would hand the agent nothing.' }
636 const chosen = TABS.find(t => t === arg)
637 if (chosen !== undefined) {
638 await update($, tab, () => chosen)
639 await update($, isHistoryShown, () => false)
640 }
641 await ensure($).catch(quiet)
642 await $.ui.open({ id: PANE, title: TITLE })
643 return { text: 'Session ledger panel opened.' }
644 })
645
646 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
647 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
648 const shown = await read($, tab)
649 const folder = await read($, dir)
650 const columns = e.props.bodyColumns
651 const state = loadState(await read($, md), {})
652 const active = activeNotes(state)
653 const learnings = state.learnings.filter(l => l.status === 'active')
654 const stepList = stepLines(await read($, steps))
655 const count: Record<Tab, string> = {
656 steps: stepList.length === 0 ? '' : String(stepList.length),
657 ledger: active.length + learnings.length === 0 ? '' : String(active.length + learnings.length),
658 stats: '',
659 }
660
661 // A tab is a pressable label: the shown one is bold and underlined in the accent colour, the others recede.
662 const tabs = (
663 <Box flexDirection="column" marginBottom={1}>
664 <Box gap={2}>
665 {TABS.map((t, i) => (
666 <Button key={t} plain hotkey={String(i + 1)} label={TAB_LABEL[t]} onPress={() => void update($, tab, () => t)}>
667 <Text color={t === shown ? 'claude' : 'inactive'} bold={t === shown} underline={t === shown}>
668 {TAB_LABEL[t]}
669 </Text>
670 {count[t] !== '' && <Text dimColor>{` ${count[t]}`}</Text>}
671 </Button>
672 ))}
673 </Box>
674 <Text dimColor>{rule(columns)}</Text>
675 </Box>
676 )
677
678 // A section title with the number of things under it.
679 const heading = (title: string, n?: number) => (
680 <Box marginTop={1}>
681 <Text bold color="subtle">
682 {title.toUpperCase()}
683 </Text>
684 {n !== undefined && <Text dimColor>{` ${n}`}</Text>}
685 </Box>
686 )
687
688 if (shown === 'steps') {
689 const s = await read($, status)
690 const queued = (await read($, queue)).length
691 const views = stepList.slice(-PANEL_STEPS).map(parseStep)
692 const wide = Math.max(20, columns - 10)
693 return (
694 <Box flexDirection="column">
695 {tabs}
696 {stepList.length === 0 && (
697 <Box flexDirection="column">
698 <Text>No steps yet.</Text>
699 <Text dimColor>After each turn one line appears here: what was done, which files changed, and the notes it added.</Text>
700 </Box>
701 )}
702 {stepList.length > PANEL_STEPS && <Text dimColor>{`… ${stepList.length - PANEL_STEPS} earlier steps in steps.md`}</Text>}
703 {views.map((v, i) =>
704 v === undefined ? (
705 <Text key={i} dimColor>
706 {stepList.slice(-PANEL_STEPS)[i]!.slice(2)}
707 </Text>
708 ) : (
709 <Box key={v.turn} marginBottom={1}>
710 <Box width={6}>
711 <Text dimColor>{`t${v.turn}`}</Text>
712 </Box>
713 <Box width={2}>
714 <Text color={v.mark === '✓' ? 'success' : v.mark === '✗' ? 'error' : 'inactive'} bold>
715 {v.mark}
716 </Text>
717 </Box>
718 <Box flexDirection="column" width={wide}>
719 <Text dimColor={v.mark === '◌'}>{v.text}</Text>
720 {(v.facts.length > 0 || v.added.length > 0 || v.done.length > 0) && (
721 <Text>
722 {v.facts.length > 0 && <Text dimColor>{v.facts.join(' · ')}</Text>}
723 {v.facts.length > 0 && v.added.length + v.done.length > 0 && <Text dimColor>{' '}</Text>}
724 {v.added.map(id => (
725 <Text key={id} color={id.startsWith('L') ? 'warning' : 'success'}>{`+${id} `}</Text>
726 ))}
727 {v.done.map(id => (
728 <Text key={id} color="merged">{`✓ ${id} `}</Text>
729 ))}
730 </Text>
731 )}
732 </Box>
733 </Box>
734 ),
735 )}
736 {s.isBusy && (
737 <Text color="warning">{`◐ recording t${s.turn}…${queued > 1 ? ` ${queued - 1} more waiting` : ''}`}</Text>
738 )}
739 </Box>
740 )
741 }
742
743 if (shown === 'ledger') {
744 const isHistory = await read($, isHistoryShown)
745 const switcher = (
746 <Box gap={2}>
747 {[false, true].map(isTarget => (
748 <Button key={isTarget ? 'history' : 'state'} plain label={isTarget ? 'History' : 'Now'} onPress={() => void update($, isHistoryShown, () => isTarget)}>
749 <Text color={isTarget === isHistory ? 'claude' : 'inactive'} bold={isTarget === isHistory}>
750 {isTarget ? 'History' : 'Now'}
751 </Text>
752 </Button>
753 ))}
754 <Text dimColor>{isHistory ? 'replaced or finished' : 'true right now'}</Text>
755 </Box>
756 )
757 if (isHistory) {
758 // The front matter and the file's own heading are for tools; the panel shows the entries.
759 const text = (await read($, history)).replace(/^---\n[\s\S]*?\n---\n?/, '').replace(HISTORY_HEAD, '').trim()
760 return (
761 <Box flexDirection="column">
762 {tabs}
763 {switcher}
764 <Box marginTop={1} flexDirection="column">
765 <Markdown text={text === '' ? '_Nothing has been replaced or finished yet._' : text} />
766 </Box>
767 </Box>
768 )
769 }
770 const asks = active.filter(n => n.kind === 'ask')
771 const rules = active.filter(n => n.kind !== 'ask')
772 const roles = Object.entries(state.roles)
773 const wide = Math.max(20, columns - 4)
774 // One entry: its id and kind on a line, then what it says, why, and the user's own words.
775 const entry = (id: string, kind: string, tone: ThemeKey, turn: number, text: string, why: string, quote: string) => (
776 <Box key={id} flexDirection="column" marginBottom={1} width={wide}>
777 <Text>
778 <Text color={tone} bold>{`${kind} `}</Text>
779 <Text dimColor>{`${id} · t${turn}`}</Text>
780 </Text>
781 <Text>{text}</Text>
782 {why !== '' && <Text dimColor>{why}</Text>}
783 {quote !== '' && <Text dimColor italic>{quote}</Text>}
784 </Box>
785 )
786 return (
787 <Box flexDirection="column">
788 {tabs}
789 {switcher}
790 {active.length + learnings.length === 0 && (
791 <Box flexDirection="column" marginTop={1}>
792 <Text>No ledger yet.</Text>
793 <Text dimColor>Asks, decisions, rules and what failed collect here as the chat goes on.</Text>
794 </Box>
795 )}
796 {asks.length > 0 && heading('Open asks', asks.length)}
797 {asks.map(n => entry(noteLabel(n.id), 'ask', KIND_TONE.ask, n.turn, n.text, n.why, n.quote === '' ? '' : `“${n.quote.replace(/^"|"$/g, '')}”`))}
798 {rules.length > 0 && heading('Decisions and rules', rules.length)}
799 {rules.map(n => entry(noteLabel(n.id), n.kind, KIND_TONE[n.kind], n.turn, n.text, n.why, n.quote === '' ? '' : `“${n.quote.replace(/^"|"$/g, '')}”`))}
800 {learnings.length > 0 && heading('Learned the hard way', learnings.length)}
801 {learnings.map(l => entry(learnLabel(l.id), l.kind, l.kind === 'failed' ? 'error' : 'warning', l.turn, l.text, l.why, l.evidence))}
802 {roles.length > 0 && heading('Files', roles.length)}
803 {roles.map(([path, role]) => (
804 <Box key={path} flexDirection="column" width={wide}>
805 <Text wrap="truncate-middle">{path}</Text>
806 <Text dimColor>{role}</Text>
807 </Box>
808 ))}
809 {state.own.some(line => line.trim() !== '') && heading('Your notes')}
810 {state.own.some(line => line.trim() !== '') && <Markdown text={state.own.filter((l, i) => i > 0 || !l.startsWith('## ')).join('\n').trim()} />}
811 </Box>
812 )
813 }
814
815 const config = await configOf($, options)
816 const ledger = await read($, events)
817 const stats = summarize(ledger)
818 const failures = (await read($, failed)).length
819 const input = {
820 stats,
821 asks: active.filter(n => n.kind === 'ask').length,
822 rules: active.filter(n => n.kind !== 'ask').length,
823 learnings: learnings.length,
824 files: fileIndex(archivedTurns(ledger)).length,
825 config,
826 status: await read($, status),
827 isPaused: await read($, isPaused),
828 failed: failures,
829 }
830 const now = statusOf(input)
831 const chips: [number, string][] = [
832 [input.asks, input.asks === 1 ? 'open ask' : 'open asks'],
833 [input.rules, input.rules === 1 ? 'decision or rule' : 'decisions and rules'],
834 [input.learnings, input.learnings === 1 ? 'learning' : 'learnings'],
835 [input.files, input.files === 1 ? 'file' : 'files'],
836 ]
837 return (
838 <Box flexDirection="column">
839 {tabs}
840 <Text color={now.tone}>{`● ${now.text}`}</Text>
841 {heading('Ledger')}
842 <Box columnGap={3} flexWrap="wrap">
843 {chips.map(([n, label]) => (
844 <Text key={label}>
845 <Text bold>{`${n} `}</Text>
846 <Text dimColor>{label}</Text>
847 </Text>
848 ))}
849 </Box>
850 {heading('Pipeline')}
851 {rows(input).map(r => (
852 <Box key={r.label}>
853 <Box width={8}>
854 <Text dimColor>{r.label}</Text>
855 </Box>
856 <Box width={Math.max(20, columns - 10)}>
857 <Text>{r.value}</Text>
858 </Box>
859 </Box>
860 ))}
861 {stats.lastGates.length > 0 && heading('Recent gate decisions')}
862 {stats.lastGates.map(g => (
863 <Text key={g.turn}>
864 <Text dimColor>{`t${g.turn}`.padEnd(5)}</Text>
865 <Text>{(g.p === null ? '—' : `p ${g.p.toFixed(2)}`).padEnd(8)}</Text>
866 <Text color={gateTone(g.action)}>{g.action}</Text>
867 {g.error !== undefined && <Text color="error">{` ${g.error}`}</Text>}
868 </Text>
869 ))}
870 {heading('Folder')}
871 <Text dimColor wrap="truncate-middle">
872 {folder === '' ? 'No folder yet' : folder}
873 </Text>
874 <Box marginTop={1} gap={2}>
875 <Button key="copy" label="Copy path" onPress={() => void $.ui.copy({ text: folder, surface: e.surface })} />
876 <Button key="pause" label={input.isPaused ? 'Resume' : 'Pause'} onPress={() => void update($, isPaused, p => !p)} />
877 </Box>
878 </Box>
879 )
880 })
881}
882core/digest.ts 86 lines1import { shortPath } from './facts'
2import { activeNotes } from './notes'
3import { learnLabel, noteLabel, roleOf } from './state'
4import type { FileEntry, State } from './types'
5
6const CAP = 8000
7const STEPS = 10
8// One long note, quote or path must not use up the room of the rest.
9const TEXT_CHARS = 400
10const WHY_CHARS = 200
11const QUOTE_CHARS = 240
12const LINE_CHARS = 400
13// Turns the ledger has not caught up with: the newest few, each cut short.
14const UNRECORDED = 5
15const UNRECORDED_CHARS = 300
16
17type Unrecorded = { turn: number; user: string }
18
19const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
20
21// Cut to `max` characters of one line, counted as code points so a surrogate pair is never split. A code
22// fence the cut leaves open is dropped, so it cannot swallow what follows.
23const short = (s: string, max: number): string => {
24 const chars = Array.from(oneLine(s))
25 if (chars.length <= max) return chars.join('')
26 const head = chars.slice(0, max - 1).join('')
27 const isOpen = (head.match(/```/g)?.length ?? 0) % 2 === 1
28 return `${(isOpen ? head.slice(0, head.lastIndexOf('```')) : head).trimEnd()}…`
29}
30
31// What goes beside the first prompt after Claude Code compacts the chat: the ledger in short, most
32// important first, so whatever the cap cuts is the least needed. Ends with where the full ledger is. Empty
33// when there is nothing to hand over. `unrecorded` are the turns still being recorded: the ledger leaves them
34// out, so their messages come first.
35export const digestText = (
36 state: State,
37 steps: readonly string[],
38 files: readonly FileEntry[],
39 dir: string,
40 cap = CAP,
41 unrecorded: readonly Unrecorded[] = [],
42): string => {
43 const cwd = state.front.cwd ?? ''
44 const active = activeNotes(state)
45 const note = (n: (typeof active)[number]) =>
46 `- ${noteLabel(n.id)} (t${n.turn}) ${short(n.text, TEXT_CHARS)}${n.why === '' ? '' : ` Why: ${short(n.why, WHY_CHARS)}`}${n.quote === '' ? '' : ` User: "${short(n.quote, QUOTE_CHARS)}"`}`
47 const blocks: [string, string[]][] = [
48 [
49 'Not in the ledger yet (these turns are still being recorded, so the sections below leave them out)',
50 unrecorded.slice(-UNRECORDED).map(t => `- t${t.turn}: ${short(t.user, UNRECORDED_CHARS)}`),
51 ],
52 ['Open asks', active.filter(n => n.kind === 'ask').map(note)],
53 ['Decisions and rules', active.filter(n => n.kind !== 'ask').map(note)],
54 ['Learnings', state.learnings.filter(l => l.status === 'active').map(l => `- ${learnLabel(l.id)} [${l.kind}] ${short(l.text, TEXT_CHARS)}`)],
55 ['Files that matter', files.map(f => short(`- ${shortPath(f.path, cwd)}${roleOf(state, f.path) === '' ? '' : ` · ${roleOf(state, f.path)}`}`, LINE_CHARS))],
56 ['Recent steps', steps.slice(-STEPS).map(s => short(s, LINE_CHARS))],
57 ]
58 if (blocks.every(([, items]) => items.length === 0)) return ''
59 const head = `The chat was just compacted. This is the session ledger as it stands, so nothing the user asked or decided is lost.`
60 const tail = `Full ledger: ${dir}/state.md (steps.md has every turn).`
61 const cut = '(cut to fit; the rest is in state.md)'
62 const lines = [head]
63 // Room is kept for the tail and the cut notice, so the whole digest stays under the cap.
64 let size = head.length + tail.length + cut.length + 3
65 let isCut = false
66 for (const [title, items] of blocks) {
67 const heading = `\n## ${title}`
68 let isOpen = false
69 for (const item of items) {
70 // A heading comes with its first item, never alone.
71 const need = (isOpen ? 0 : heading.length + 1) + item.length + 1
72 if (size + need > cap) {
73 isCut = true
74 break
75 }
76 if (!isOpen) lines.push(heading)
77 isOpen = true
78 lines.push(item)
79 size += need
80 }
81 }
82 if (isCut) lines.push(cut)
83 lines.push('', tail)
84 return lines.join('\n')
85}
86core/facts.ts 62 lines1import { redact, tidy } from './redact'
2import type { TurnFacts } from './types'
3
4const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
5const COMMAND_CHARS = 200
6const ERROR_CHARS = 300
7const MAX_FAILURES = 5
8
9export type ToolCall = { tool: string; input: Record<string, unknown>; isError: boolean; text: string }
10
11export const emptyFacts = (): TurnFacts => ({ read: [], edited: [], commands: 0, failures: [], commits: [] })
12
13const add = (list: string[], item: string): string[] => (item === '' || list.includes(item) ? list : [...list, item])
14
15const pathOf = (input: Record<string, unknown>): string => {
16 const p = input.file_path ?? input.notebook_path
17 return typeof p === 'string' ? p : ''
18}
19
20// `[main 3fa2c1b] Fix the parser` in git's output, or the -m message in the command.
21const commitOf = (command: string, text: string): string => {
22 const out = /\[[^\]\s]+(?: \([^)]*\))? ([0-9a-f]{7,40})\] (.+)/.exec(text)
23 if (out !== null) return `${out[1]!.slice(0, 7)} ${out[2]!.trim()}`.slice(0, 120)
24 const msg = /-m\s+(?:"([^"]+)"|'([^']+)')/.exec(command)
25 return (msg?.[1] ?? msg?.[2] ?? 'commit').split('\n')[0]!.slice(0, 120)
26}
27
28// Folds one finished tool call into the turn's facts. Pure.
29export const addToolCall = (f: TurnFacts, call: ToolCall): TurnFacts => {
30 if (call.tool === 'Read') return call.isError ? f : { ...f, read: add(f.read, pathOf(call.input)) }
31 if (EDIT_TOOLS.has(call.tool)) return call.isError ? f : { ...f, edited: add(f.edited, pathOf(call.input)) }
32 if (call.tool !== 'Bash') return f
33 const command = typeof call.input.command === 'string' ? call.input.command : ''
34 const next = { ...f, commands: f.commands + 1 }
35 if (call.isError) {
36 const error = redact(call.text).trim().slice(-ERROR_CHARS)
37 const failures = [...f.failures, { command: tidy(command, COMMAND_CHARS), error }].slice(-MAX_FAILURES)
38 return { ...next, failures }
39 }
40 if (/\bgit\s+(?:-C\s+\S+\s+)?commit\b/.test(command)) return { ...next, commits: [...f.commits, redact(commitOf(command, call.text))] }
41 return next
42}
43
44const short = (path: string, cwd: string): string => (cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path)
45
46const names = (paths: string[], cwd: string, max: number): string => {
47 const shown = paths.slice(0, max).map(p => short(p, cwd))
48 return paths.length > max ? `${shown.join(', ')} +${paths.length - max}` : shown.join(', ')
49}
50
51// One line such as "edited a.ts, b.ts · read 3 files · 5 commands, 1 failed · commit 3fa2c1b Fix the parser".
52export const factsText = (f: TurnFacts, cwd = ''): string => {
53 const parts: string[] = []
54 if (f.edited.length > 0) parts.push(`edited ${names(f.edited, cwd, 3)}`)
55 if (f.read.length > 0) parts.push(`read ${f.read.length} file${f.read.length === 1 ? '' : 's'}`)
56 if (f.commands > 0) parts.push(`${f.commands} command${f.commands === 1 ? '' : 's'}${f.failures.length > 0 ? `, ${f.failures.length} failed` : ''}`)
57 for (const c of f.commits) parts.push(`commit ${c}`)
58 return parts.join(' · ')
59}
60
61export const shortPath = short
62core/files.ts 42 lines1import { roleOf } from './state'
2import type { FileEntry, State, TurnRecord } from './types'
3
4const TOP = 15
5
6// Every file the main agent read or edited, from the archived turns.
7export const fileIndex = (records: readonly TurnRecord[]): FileEntry[] => {
8 const byPath = new Map<string, FileEntry>()
9 const touch = (path: string, turn: number, isEdit: boolean) => {
10 const e = byPath.get(path) ?? { path, reads: 0, edits: 0, first: turn, last: turn, role: '', score: 0 }
11 if (isEdit) e.edits += 1
12 else e.reads += 1
13 e.first = Math.min(e.first, turn)
14 e.last = Math.max(e.last, turn)
15 byPath.set(path, e)
16 }
17 for (const r of records) {
18 for (const p of r.facts.edited) touch(p, r.turn, true)
19 for (const p of r.facts.read) touch(p, r.turn, false)
20 }
21 return [...byPath.values()]
22}
23
24const baseName = (path: string): string => path.slice(path.lastIndexOf('/') + 1)
25
26// Edited files first, then the ones touched often and lately, and the ones the user's notes name.
27export const rankFiles = (index: readonly FileEntry[], state: State, now: number, top = TOP): FileEntry[] => {
28 const noteText = state.notes
29 .filter(n => n.status === 'active')
30 .map(n => `${n.text} ${n.why}`)
31 .join(' ')
32 return index
33 .map(e => {
34 const isNamed = noteText.includes(baseName(e.path))
35 const recency = 1 / (1 + Math.max(0, now - e.last) / 10)
36 const score = (e.edits > 0 ? 3 : 0) + Math.log(1 + e.reads + e.edits) + 2 * recency + (isNamed ? 2 : 0)
37 return { ...e, role: roleOf(state, e.path), score }
38 })
39 .sort((a, b) => b.score - a.score || a.path.localeCompare(b.path))
40 .slice(0, top)
41}
42core/ledger.ts 178 lines1import { emptyFacts } from './facts'
2import { clip, redact } from './redact'
3import type { ExtractEvent, GateEvent, LedgerEvent, Turn, TurnFacts, TurnRecord, Usage } from './types'
4
5// $ per million tokens, claude-haiku-5-5 up to 100k prompt tokens. Third-party figures; confirm on
6// Anthropic's pricing page. The mod runs on a subscription, so this is plan usage expressed in API dollars.
7export const HAIKU_PRICE = { input: 0.1, output: 0.5, cacheRead: 0.01, cacheWrite: 0.125 }
8
9export const haikuCost = (u: Usage): number =>
10 (u.input * HAIKU_PRICE.input + u.output * HAIKU_PRICE.output + u.cacheRead * HAIKU_PRICE.cacheRead + u.cacheWrite * HAIKU_PRICE.cacheWrite) /
11 1_000_000
12
13export const emptyUsage = (): Usage => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0 })
14
15export const addUsage = (a: Usage, b: Usage): Usage => ({
16 input: a.input + b.input,
17 output: a.output + b.output,
18 cacheRead: a.cacheRead + b.cacheRead,
19 cacheWrite: a.cacheWrite + b.cacheWrite,
20})
21
22export type Stats = {
23 turns: number
24 passed: number
25 skipped: number
26 gateErrors: number
27 summaries: number
28 extractions: number
29 haikuErrors: number
30 saved: number
31 learned: number
32 closed: number
33 dropped: number
34 lookups: number
35 jevCost: number
36 jevMs: number
37 jevCalls: number
38 haikuUsage: Usage
39 haikuCost: number
40 haikuMs: number
41 lastGates: GateEvent[]
42 lastError?: string
43}
44
45export const summarize = (events: readonly LedgerEvent[]): Stats => {
46 const stats: Stats = {
47 turns: 0, passed: 0, skipped: 0, gateErrors: 0, summaries: 0, extractions: 0, haikuErrors: 0, saved: 0, learned: 0,
48 closed: 0, dropped: 0, lookups: 0, jevCost: 0, jevMs: 0, jevCalls: 0, haikuUsage: emptyUsage(), haikuCost: 0, haikuMs: 0,
49 lastGates: [],
50 }
51 for (const e of events) {
52 if (e.type === 'turn') continue
53 if (e.type === 'gate') {
54 stats.turns += 1
55 if (e.action === 'skip') stats.skipped += 1
56 else if (e.action === 'error') {
57 stats.gateErrors += 1
58 stats.lastError = e.error
59 } else stats.passed += 1
60 if (e.p !== null) {
61 stats.jevCalls += 1
62 stats.jevCost += e.cost
63 stats.jevMs += e.ms
64 }
65 stats.lastGates.push(e)
66 continue
67 }
68 if (e.mode === 'extract') stats.extractions += 1
69 else stats.summaries += 1
70 stats.saved += e.kept
71 stats.learned += e.learned
72 stats.closed += e.closed
73 stats.dropped += e.dropped.length
74 if (e.need !== undefined) stats.lookups += 1
75 stats.haikuUsage = addUsage(stats.haikuUsage, e.usage)
76 stats.haikuCost += haikuCost(e.usage)
77 stats.haikuMs += e.ms
78 if (e.error !== undefined) {
79 stats.haikuErrors += 1
80 stats.lastError = e.error
81 }
82 }
83 stats.lastGates = stats.lastGates.slice(-8)
84 return stats
85}
86
87const isFacts = (f: unknown): f is TurnFacts =>
88 f !== null && typeof f === 'object' && Array.isArray((f as TurnFacts).read) && Array.isArray((f as TurnFacts).edited)
89
90export const parseLedger = (text: string): LedgerEvent[] =>
91 text
92 .split('\n')
93 .filter(line => line.trim() !== '')
94 .flatMap((line): LedgerEvent[] => {
95 try {
96 const e = JSON.parse(line) as LedgerEvent
97 if (e.type === 'gate' || e.type === 'extract') return [e]
98 if (e.type === 'turn') return [{ ...e, facts: isFacts(e.facts) ? { ...emptyFacts(), ...e.facts } : emptyFacts() }]
99 return []
100 } catch {
101 return []
102 }
103 })
104
105export const serializeLedger = (events: readonly LedgerEvent[]): string => events.map(e => JSON.stringify(e)).join('\n') + '\n'
106
107// $.fs.write refuses a file over 4 MiB, and a chat that runs for days gets there. The file is kept under this.
108export const LEDGER_MAX = 3_000_000
109// What an old turn keeps of the user's words once the archive is full.
110const OLD_USER = 300
111const OLD_SLACK = 60
112// The newest turns are never shrunk: the later turns read their words back.
113const KEEP_RECENT = 20
114
115const sizeOf = (e: LedgerEvent): number => new TextEncoder().encode(JSON.stringify(e)).length + 1
116
117// Keeps the ledger under `max` bytes: the oldest turns give up their answer and most of their message first (their
118// tool facts stay, so the file index keeps its counts), then the oldest events go. The newest turns are untouched.
119export const fitLedger = (events: readonly LedgerEvent[], max = LEDGER_MAX): LedgerEvent[] => {
120 const kept = [...events]
121 const sizes = kept.map(sizeOf)
122 let total = sizes.reduce((sum, n) => sum + n, 0)
123 const recent = new Set(kept.flatMap((e, i) => (e.type === 'turn' ? [i] : [])).slice(-KEEP_RECENT))
124 for (let i = 0; i < kept.length && total > max; i++) {
125 const e = kept[i]!
126 if (e.type !== 'turn' || recent.has(i) || (e.answer === '' && e.user.length <= OLD_USER + OLD_SLACK)) continue
127 kept[i] = { ...e, user: clip(e.user, OLD_USER), answer: '' }
128 const size = sizeOf(kept[i]!)
129 total += size - sizes[i]!
130 sizes[i] = size
131 }
132 let dropped = 0
133 while (total > max && dropped < kept.length) total -= sizes[dropped++]!
134 return kept.slice(dropped)
135}
136
137// The archive keeps each turn so later turns can still read it after Claude Code compacts the chat.
138const ARCHIVE_USER = 8000
139const ARCHIVE_ANSWER = 4000
140
141export const turnRecord = (t: Turn, at: number): TurnRecord => ({
142 type: 'turn',
143 at,
144 turn: t.turn,
145 turnId: t.turnId,
146 user: clip(redact(t.user), ARCHIVE_USER),
147 answer: clip(redact(t.answer), ARCHIVE_ANSWER),
148 facts: t.facts,
149})
150
151export const archivedTurns = (events: readonly LedgerEvent[]): TurnRecord[] =>
152 events.filter((e): e is TurnRecord => e.type === 'turn')
153
154export type { ExtractEvent }
155
156// Turns waiting for Haiku survive a quit: the next start of the session picks them up.
157export const serializePending = (turns: readonly Turn[]): string => JSON.stringify(turns)
158
159export const parsePending = (text: string): Turn[] => {
160 try {
161 const list: unknown = JSON.parse(text)
162 if (!Array.isArray(list)) return []
163 return list
164 .filter(
165 (t): t is Turn =>
166 t !== null && typeof t === 'object' && typeof t.turn === 'number' && typeof t.turnId === 'string' && typeof t.user === 'string' && typeof t.answer === 'string',
167 )
168 .map(t => ({
169 ...t,
170 previousAnswer: typeof t.previousAnswer === 'string' ? t.previousAnswer : '',
171 isAborted: t.isAborted === true,
172 facts: isFacts(t.facts) ? { ...emptyFacts(), ...t.facts } : emptyFacts(),
173 }))
174 } catch {
175 return []
176 }
177}
178core/notes.ts 115 lines1import { KINDS } from './types'
2import type { Kind, Memory, Moved, Note, State } from './types'
3
4export const activeNotes = (state: State): Note[] => state.notes.filter(n => n.status === 'active')
5
6// Requests still to be done. Only these can be marked finished: a rule or a choice never is.
7export const openAsks = (state: State): Note[] => activeNotes(state).filter(n => n.kind === 'ask')
8
9export type OldNote = { id: number; turn: number; kind: Kind; text: string }
10
11const OLD_FRONT = /^---\n([\s\S]*?)\n---\n?/
12const OLD_LINE = /^- (~~)?M-(\d+) · t(\d+) · ([a-z]+) · (.*?)(?:~~)?(?: → (?:superseded by M-(\d+)|done t(\d+)))?\s*$/
13
14// A notes file from before 0.3 (one `<date>_<id8>.md`): its title and its active notes, to seed a ledger.
15export const parseOldNotes = (md: string): { title: string; notes: OldNote[] } => {
16 const head = OLD_FRONT.exec(md)
17 const title = head === null ? '' : (/^title:\s*(.*)$/m.exec(head[1]!)?.[1] ?? '').trim()
18 const notes: OldNote[] = []
19 for (const line of md.split('\n')) {
20 const m = OLD_LINE.exec(line)
21 if (m === null || m[1] !== undefined || m[6] !== undefined || m[7] !== undefined) continue
22 if (!(KINDS as readonly string[]).includes(m[4]!)) continue
23 notes.push({ id: Number(m[2]), turn: Number(m[3]), kind: m[4] as Kind, text: m[5]!.trimEnd() })
24 }
25 return { title, notes }
26}
27
28export type SessionFacts = { sessionId: string; project: string; cwd: string; started: string; transcript: string }
29
30export const newFront = (f: SessionFacts): Record<string, string> => ({
31 session_id: f.sessionId,
32 project: f.project,
33 cwd: f.cwd,
34 started: f.started,
35 transcript: f.transcript,
36 resume: `claude --resume ${f.sessionId}`,
37 title: '',
38 next_id: '1',
39 next_learning: '1',
40})
41
42// New notes get the next id; a note that replaces or finishes another moves the old one out of the state.
43export const applyMemories = (
44 state: State,
45 memories: Memory[],
46 turn: number,
47 turnId: string,
48 title: string,
49 done: number[] = [],
50): { state: State; moved: Moved[] } => {
51 let next = Math.max(Number(state.front.next_id) || 1, state.notes.reduce((top, n) => Math.max(top, n.id + 1), 1))
52 let notes = [...state.notes]
53 const moved: Moved[] = []
54 for (const m of memories) {
55 const id = next++
56 for (const old of notes.filter(n => m.supersedes.includes(n.id))) moved.push({ item: old, reason: 'superseded', by: id, turn })
57 notes = notes.filter(n => !m.supersedes.includes(n.id))
58 notes.push({
59 id,
60 turn,
61 kind: m.kind,
62 text: m.text,
63 why: m.why,
64 context: m.context,
65 quote: m.quote,
66 source: { turnId, refersTo: m.refersTo },
67 status: 'active',
68 })
69 }
70 for (const old of notes.filter(n => done.includes(n.id) && n.kind === 'ask')) moved.push({ item: old, reason: 'done', turn })
71 notes = notes.filter(n => !(done.includes(n.id) && n.kind === 'ask'))
72 const front: Record<string, string> = { ...state.front, next_id: String(next) }
73 if (title !== '' && (front.title === undefined || front.title === '')) front.title = title
74 return { state: { ...state, notes, front }, moved }
75}
76
77// What the agent is told about the ledger. It must read the same on every request of a session, so the
78// prompt cache is written once: no counts, no times, only the folder.
79export const pointerText = (dir: string): string =>
80 `Session ledger: a tool keeps a ledger of this conversation in ${dir}/. ` +
81 'state.md holds what is true now: the user\'s open asks, their decisions and rules with the reason and their exact words, ' +
82 'what was learned the hard way, and the files that matter with their roles. steps.md has one line per turn; history.md has ' +
83 'replaced and finished items. The files may not exist until the first turn is recorded. Read state.md when you need to ' +
84 'recall what the user asked or decided or which files matter, after a compaction, or when you are unsure which decision is ' +
85 'current. Do not edit these files; they are maintained automatically.'
86
87const MAX_RULES = 8
88
89// The few rules that must hold all chat long, for the system prompt. Empty when the person has none.
90export const rulesText = (state: State): string => {
91 const rules = activeNotes(state).filter(n => n.kind === 'requirement' || n.kind === 'preference')
92 if (rules.length === 0) return ''
93 const shown = rules.slice(-MAX_RULES)
94 const older = rules.length - shown.length
95 return [
96 'Standing rules the user gave in this conversation, from the session ledger. They still apply:',
97 ...shown.map(r => `- ${r.text}`),
98 ...(older > 0 ? [`(${older} older rule${older === 1 ? ' is' : 's are'} in state.md.)`] : []),
99 ].join('\n')
100}
101
102export type IndexRow = { file: string; date: string; title: string; active: number; learned: number; steps: number }
103
104const rowOf = (r: IndexRow): string =>
105 `| ${r.date} | ${r.title === '' ? '(untitled)' : r.title.replace(/\|/g, '/')} | ${r.active} notes, ${r.learned} learned, ${r.steps} turns | [${r.file}](${r.file}) |`
106
107export const upsertIndex = (md: string, project: string, row: IndexRow): string => {
108 const rows = md
109 .split('\n')
110 .filter(line => line.startsWith('| ') && line.includes('](') && !line.includes(`[${row.file}](`))
111 rows.push(rowOf(row))
112 rows.sort((a, b) => (a.split('[').pop() ?? '').localeCompare(b.split('[').pop() ?? '')).reverse()
113 return `# Session ledgers: ${project}\n\n| Date | Title | Ledger | Folder |\n| --- | --- | --- | --- |\n${rows.join('\n')}\n`
114}
115core/pipeline.ts 126 lines1import { contextText } from './context'
2import type { TurnContext } from './context'
3import { checkDone, checkFileRoles, checkLearnings, checkMemories, checkStep, parseExtraction, systemPrompt } from './extract'
4import type { Extraction, Mode } from './extract'
5import { factsText } from './facts'
6import { runGate } from './gate'
7import { addUsage, emptyUsage } from './ledger'
8import { activeNotes, applyMemories, openAsks } from './notes'
9import { applyFileRoles, applyLearnings, learnLabel, noteLabel } from './state'
10import type { CompleteResult, Config, ExtractEvent, FileEntry, Host, LedgerEvent, Moved, State, Step, Turn, TurnRecord } from './types'
11
12export type Ledger = { state: State; steps: readonly string[]; archive: readonly TurnRecord[]; files: readonly FileEntry[] }
13
14export type Outcome = { state: State; moved: Moved[]; step: Step; events: LedgerEvent[] }
15
16const ATTEMPTS = 2
17
18// One Haiku call per turn. Summarization always: the step line, finished asks, file roles and learnings.
19// Extraction too when Jev says the user said something lasting (or Jev is off or failed): the notes.
20// Never throws: a failure becomes an event, and the step line falls back to the user's first words.
21export const processTurn = async (host: Host, config: Config, ledger: Ledger, turn: Turn): Promise<Outcome> => {
22 const gate = await runGate(host, config, turn)
23 const mode: Mode = gate.isPassed || gate.event.action === 'error' ? 'extract' : 'summarize'
24 const { state } = ledger
25 const cwd = state.front.cwd ?? ''
26 const earlier = ledger.archive.filter(r => r.turn < turn.turn)
27 const base: TurnContext = { state, steps: ledger.steps, earlier, files: ledger.files, turn }
28 const system = systemPrompt(mode, config.maxWords)
29 const effort = mode === 'extract' ? config.effort : 'low'
30 const start = await host.now()
31 let usage = emptyUsage()
32 let failure = ''
33
34 const ask = async (prompt: string): Promise<Extraction | undefined> => {
35 for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
36 let reply: CompleteResult
37 try {
38 reply = await host.complete({ system, prompt, effort })
39 } catch (err) {
40 reply = { isOk: false, reason: `threw ${String(err).slice(0, 120)}`, usage: emptyUsage() }
41 }
42 usage = addUsage(usage, reply.usage)
43 if (!reply.isOk) {
44 failure = `haiku: ${reply.reason}`
45 continue
46 }
47 const parsed = parseExtraction(reply.text)
48 if ('error' in parsed) {
49 failure = `haiku reply: ${parsed.error}`
50 continue
51 }
52 return parsed
53 }
54 return undefined
55 }
56
57 let context = base
58 let got = await ask(contextText(context))
59 // One look further back, only when Haiku names an archived turn it was not shown in full.
60 const need = mode === 'extract' ? got?.need : undefined
61 const looked = need === undefined ? undefined : earlier.find(r => r.turn === need)
62 if (looked !== undefined) {
63 context = { ...base, looked }
64 got = (await ask(contextText(context))) ?? got
65 }
66
67 const active = activeNotes(state)
68 const dropped: string[] = []
69 let memories = got === undefined || mode === 'summarize' ? undefined : checkMemories(got.memories, turn.user, active, config.maxWords, turn.turn)
70 if (got !== undefined && memories !== undefined && memories.overLong.length > 0) {
71 const list = memories.overLong.map(m => `"${m.text}"`).join('; ')
72 const again = await ask(
73 `${contextText(context)}\n\nThese notes were over ${config.maxWords} words: ${list}. Reply with the full JSON again, every note at most ${config.maxWords} words.`,
74 )
75 if (again !== undefined) {
76 got = { ...again, step: again.step.text === '' ? got.step : again.step }
77 memories = checkMemories(again.memories, turn.user, active, config.maxWords, turn.turn)
78 }
79 dropped.push(...memories.overLong.map(m => `too long: ${m.text.slice(0, 40)}`))
80 }
81 dropped.push(...(memories?.dropped ?? []))
82
83 const done = got === undefined ? [] : checkDone(got.done, openAsks(state))
84 const learned = got === undefined ? { kept: [], dropped: [] } : checkLearnings(got.learnings, turn.answer, turn.facts, state.learnings)
85 dropped.push(...learned.dropped)
86 const roles = got === undefined ? [] : checkFileRoles(got.files, turn.facts.edited, cwd)
87
88 const notes = applyMemories(state, memories?.kept ?? [], turn.turn, turn.turnId, got?.title ?? '', done)
89 const withLearnings = applyLearnings(notes.state, learned.kept, turn.turn)
90 const next = applyFileRoles(withLearnings.state, roles)
91 const moved = [...notes.moved, ...withLearnings.moved]
92
93 const firstNote = Number(state.front.next_id) || 1
94 const firstLearning = Number(state.front.next_learning) || 1
95 const added = [
96 ...next.notes.filter(n => n.turn === turn.turn && n.id >= firstNote).map(n => noteLabel(n.id)),
97 ...next.learnings.filter(l => l.turn === turn.turn && l.id >= firstLearning).map(l => learnLabel(l.id)),
98 ]
99 const step: Step = {
100 turn: turn.turn,
101 outcome: turn.isAborted ? 'aborted' : (got?.step.outcome ?? 'ok'),
102 text: checkStep(got?.step.text ?? '', turn.user),
103 facts: factsText(turn.facts, cwd),
104 added,
105 done: done.map(noteLabel),
106 }
107 const event: ExtractEvent = {
108 type: 'extract',
109 at: start,
110 turn: turn.turn,
111 turnId: turn.turnId,
112 model: config.haikuModel,
113 mode,
114 ms: (await host.now()) - start,
115 usage,
116 kept: memories?.kept.length ?? 0,
117 learned: learned.kept.length,
118 closed: done.length,
119 roles: roles.length,
120 ...(looked === undefined ? {} : { need: looked.turn }),
121 dropped,
122 ...(got === undefined ? { error: failure } : {}),
123 }
124 return { state: next, moved, step, events: [gate.event, event] }
125}
126core/state.ts 225 lines1import { shortPath } from './facts'
2import { KINDS, LEARN_KINDS } from './types'
3import type { FileEntry, Kind, LearnKind, Learning, LearningDraft, Moved, Note, State } from './types'
4
5export const LEGEND =
6 'What the user asked and decided in this chat, what the agent learned, and the files that matter. Only what is true now: ' +
7 'replaced and finished items move to history.md, and every turn has a line in steps.md. Each note quotes the user and names ' +
8 'its turn; the transcript in the front matter has the full chat. Lines you write under "## Your notes" are kept.'
9
10const SECTIONS = { asks: 'Open asks', rules: 'Decisions and rules', learnings: 'Learnings', files: 'Files', own: 'Your notes' } as const
11
12const FRONT = /^---\n([\s\S]*?)\n---\n?/
13const NOTE_HEAD = /^### M-(\d+) · ([a-z]+) · t(\d+)\s*$/
14const LEARN_HEAD = /^### L-(\d+) · ([a-z]+) · t(\d+)\s*$/
15const FILE_LINE = /^- `([^`]+)`.*?(?: · role: (.+))?$/
16
17export const noteLabel = (id: number): string => `M-${String(id).padStart(3, '0')}`
18export const learnLabel = (id: number): string => `L-${String(id).padStart(3, '0')}`
19
20const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
21
22export const emptyState = (front: Record<string, string>): State => ({ front: { ...front }, notes: [], learnings: [], roles: {}, own: [] })
23
24const parseFront = (md: string): { front: Record<string, string>; body: string } => {
25 const front: Record<string, string> = {}
26 const head = FRONT.exec(md)
27 if (head === null) return { front, body: md }
28 for (const line of head[1]!.split('\n')) {
29 const at = line.indexOf(':')
30 if (at > 0) front[line.slice(0, at).trim()] = line.slice(at + 1).trim()
31 }
32 return { front, body: md.slice(head[0].length) }
33}
34
35type Block = { head: RegExpExecArray; isNote: boolean; text: string[]; fields: Record<string, string> }
36
37const FIELD = /^(Why|Context|Quote|Source|Evidence): ?(.*)$/
38
39const unquote = (s: string): string => (s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s)
40
41const sourceOf = (s: string): Note['source'] => ({
42 turnId: /\bturn (\S+)/.exec(s)?.[1] ?? '',
43 refersTo: [...(/answers ((?:t\d+(?:, )?)+)/.exec(s)?.[1] ?? '').matchAll(/t(\d+)/g)].map(m => Number(m[1])),
44})
45
46const finish = (b: Block, state: State): void => {
47 const text = oneLine(b.text.join(' '))
48 if (b.isNote) {
49 if (!(KINDS as readonly string[]).includes(b.head[2]!)) return
50 state.notes.push({
51 id: Number(b.head[1]),
52 kind: b.head[2] as Kind,
53 turn: Number(b.head[3]),
54 text,
55 why: b.fields.Why ?? '',
56 context: b.fields.Context ?? '',
57 quote: unquote(b.fields.Quote ?? ''),
58 source: sourceOf(b.fields.Source ?? ''),
59 status: 'active',
60 })
61 } else {
62 if (!(LEARN_KINDS as readonly string[]).includes(b.head[2]!)) return
63 state.learnings.push({
64 id: Number(b.head[1]),
65 kind: b.head[2] as LearnKind,
66 turn: Number(b.head[3]),
67 text,
68 why: b.fields.Why ?? '',
69 evidence: unquote(b.fields.Evidence ?? ''),
70 status: 'active',
71 })
72 }
73}
74
75// Reads state.md back, so the person's edits stand. Anything it does not recognise outside "Your notes" is
76// dropped on the next write; nothing throws.
77export const parseState = (md: string): State => {
78 const { front, body } = parseFront(md.replace(/\r\n?/g, '\n'))
79 const state = emptyState(front)
80 let section = ''
81 let block: Block | undefined
82 const close = () => {
83 if (block !== undefined) finish(block, state)
84 block = undefined
85 }
86 for (const line of body.split('\n')) {
87 // "Your notes" is the last section: from its heading on, everything is the person's, blank lines and headings too.
88 if (section === SECTIONS.own) {
89 if (state.own.length > 0 || line.trim() !== '') state.own.push(line)
90 continue
91 }
92 if (line.startsWith('## ')) {
93 close()
94 section = line.slice(3).trim()
95 continue
96 }
97 const note = NOTE_HEAD.exec(line)
98 const learn = note === null ? LEARN_HEAD.exec(line) : null
99 if (note !== null || learn !== null) {
100 close()
101 block = { head: (note ?? learn)!, isNote: note !== null, text: [], fields: {} }
102 continue
103 }
104 // A heading the person broke by hand ends the block above it and drops its own lines, so they cannot rewrite it.
105 if (line.startsWith('### ')) {
106 close()
107 continue
108 }
109 if (section === SECTIONS.files) {
110 const f = FILE_LINE.exec(line)
111 if (f !== null && f[2] !== undefined) state.roles[f[1]!] = f[2].trim()
112 continue
113 }
114 if (block === undefined || line.trim() === '') continue
115 const field = FIELD.exec(line)
116 if (field !== null) block.fields[field[1]!] = field[2]!.trim()
117 else block.text.push(line.trim())
118 }
119 close()
120 while (state.own.length > 0 && state.own[state.own.length - 1]!.trim() === '') state.own.pop()
121 // state.md groups asks before rules; the ledger keeps them in id order.
122 state.notes.sort((a, b) => a.id - b.id)
123 return state
124}
125
126const sourceLine = (n: Note): string => {
127 const parts = [`t${n.turn}`]
128 if (n.source.turnId !== '') parts.push(`turn ${n.source.turnId.slice(0, 8)}`)
129 if (n.source.refersTo.length > 0) parts.push(`answers ${n.source.refersTo.map(t => `t${t}`).join(', ')}`)
130 return parts.join(' · ')
131}
132
133export const noteBlock = (n: Note, tail = ''): string =>
134 [
135 `### ${noteLabel(n.id)} · ${n.kind} · t${n.turn}${tail}`,
136 oneLine(n.text),
137 ...(n.why === '' ? [] : [`Why: ${oneLine(n.why)}`]),
138 ...(n.context === '' ? [] : [`Context: ${oneLine(n.context)}`]),
139 ...(n.quote === '' ? [] : [`Quote: "${oneLine(n.quote)}"`]),
140 `Source: ${sourceLine(n)}`,
141 ].join('\n')
142
143export const learningBlock = (l: Learning, tail = ''): string =>
144 [
145 `### ${learnLabel(l.id)} · ${l.kind} · t${l.turn}${tail}`,
146 oneLine(l.text),
147 ...(l.why === '' ? [] : [`Why: ${oneLine(l.why)}`]),
148 ...(l.evidence === '' ? [] : [`Evidence: "${oneLine(l.evidence)}"`]),
149 ].join('\n')
150
151export const fileLine = (f: FileEntry, cwd: string): string => {
152 const counts = [f.edits > 0 ? `edited ${f.edits}` : '', f.reads > 0 ? `read ${f.reads}` : ''].filter(Boolean).join(', ')
153 const turns = f.first === f.last ? `t${f.first}` : `t${f.first}–t${f.last}`
154 return `- \`${shortPath(f.path, cwd)}\` · ${counts} · ${turns}${f.role === '' ? '' : ` · role: ${oneLine(f.role)}`}`
155}
156
157const section = (title: string, items: string[]): string => `## ${title}\n${items.length === 0 ? '_None._' : items.join('\n\n')}\n`
158
159// The whole of state.md. `files` are the ranked files; their roles come from the state.
160export const renderState = (state: State, files: readonly FileEntry[]): string => {
161 const front = Object.entries(state.front)
162 .map(([k, v]) => `${k}: ${v}`)
163 .join('\n')
164 const title = state.front.title === undefined || state.front.title === '' ? 'Session ledger' : state.front.title
165 const cwd = state.front.cwd ?? ''
166 const active = state.notes.filter(n => n.status === 'active')
167 const asks = active.filter(n => n.kind === 'ask').map(n => noteBlock(n))
168 const rules = active.filter(n => n.kind !== 'ask').map(n => noteBlock(n))
169 const learnings = state.learnings.filter(l => l.status === 'active').map(l => learningBlock(l))
170 const fileLines = files.map(f => fileLine({ ...f, role: roleOf(state, f.path) || f.role }, cwd))
171 return [
172 `---\n${front}\n---\n# ${title}\n${LEGEND}\n`,
173 section(SECTIONS.asks, asks),
174 section(SECTIONS.rules, rules),
175 section(SECTIONS.learnings, learnings),
176 `## ${SECTIONS.files}\n${fileLines.length === 0 ? '_None yet._' : fileLines.join('\n')}\n`,
177 `## ${SECTIONS.own}\n${state.own.join('\n')}${state.own.length > 0 ? '\n' : ''}`,
178 ].join('\n')
179}
180
181export const HISTORY_HEAD = '# History\nItems that left state.md, oldest first: replaced by a newer one, or done.\n'
182
183export const historyBlock = (m: Moved): string => {
184 const isNote = isNoteItem(m.item)
185 const by = isNote ? noteLabel(m.by ?? 0) : learnLabel(m.by ?? 0)
186 const why = m.reason === 'done' ? ` · done at t${m.turn}` : ` · replaced by ${by} at t${m.turn}`
187 return isNoteItem(m.item) ? noteBlock(m.item, why) : learningBlock(m.item, why)
188}
189
190const isNoteItem = (item: Note | Learning): item is Note => 'quote' in item
191
192// An old single-file notes file (before 0.3) seeds a new ledger once: its active notes, nothing else.
193export const seedState = (oldNotes: { id: number; turn: number; kind: Kind; text: string }[], front: Record<string, string>): State => {
194 const next = oldNotes.reduce((top, n) => Math.max(top, n.id + 1), Number(front.next_id) || 1)
195 return {
196 ...emptyState({ ...front, next_id: String(next) }),
197 notes: oldNotes.map(n => ({ ...n, why: '', context: '', quote: '', source: { turnId: '', refersTo: [] }, status: 'active' as const })),
198 }
199}
200
201const nextId = (state: State, key: 'next_id' | 'next_learning', used: number[]): number =>
202 Math.max(Number(state.front[key]) || 1, used.reduce((top, id) => Math.max(top, id + 1), 1))
203
204export const applyLearnings = (state: State, drafts: LearningDraft[], turn: number): { state: State; moved: Moved[] } => {
205 if (drafts.length === 0) return { state, moved: [] }
206 let next = nextId(state, 'next_learning', state.learnings.map(l => l.id))
207 let learnings = [...state.learnings]
208 const moved: Moved[] = []
209 for (const d of drafts) {
210 const id = next++
211 for (const old of learnings.filter(l => d.replaces.includes(l.id))) moved.push({ item: old, reason: 'superseded', by: id, turn })
212 learnings = learnings.filter(l => !d.replaces.includes(l.id))
213 learnings.push({ id, turn, kind: d.kind, text: d.text, why: d.why, evidence: d.evidence, status: 'active' })
214 }
215 return { state: { ...state, learnings, front: { ...state.front, next_learning: String(next) } }, moved }
216}
217
218// Roles are kept under the path as state.md shows it (relative to the session's folder), so hand edits match.
219export const roleOf = (state: State, path: string): string => state.roles[shortPath(path, state.front.cwd ?? '')] ?? ''
220
221export const applyFileRoles = (state: State, roles: { path: string; role: string }[]): State =>
222 roles.length === 0
223 ? state
224 : { ...state, roles: { ...state.roles, ...Object.fromEntries(roles.map(r => [shortPath(r.path, state.front.cwd ?? ''), r.role])) } }
225core/steps.ts 33 lines1import type { Step } from './types'
2
3export const STEPS_HEAD =
4 '# Steps\nOne line per turn, oldest first: ✓ done, ✗ failed, ◌ stopped. The facts come from the tool calls; ' +
5 '+M-/L- is what the turn added to state.md, "done" what it finished.\n'
6
7const MARK: Record<Step['outcome'], string> = { ok: '✓', failed: '✗', aborted: '◌' }
8
9const oneLine = (s: string): string => s.replace(/\s+/g, ' ').trim()
10
11// - t41 · ✓ · Moved the parser to its own file · edited parse.ts · +M-012 · done M-009
12export const stepLine = (s: Step): string =>
13 [`- t${s.turn}`, MARK[s.outcome], oneLine(s.text), s.facts, ...s.added.map(l => `+${l}`), ...s.done.map(l => `done ${l}`)]
14 .filter(part => part !== '')
15 .join(' · ')
16
17const STEP = /^- t(\d+) · /
18
19export const stepLines = (md: string): string[] => md.split('\n').filter(line => STEP.test(line))
20
21// The highest turn number steps.md holds: turns go on from there even when events.jsonl is missing.
22export const lastStep = (md: string): number => stepLines(md).reduce((top, line) => Math.max(top, Number(STEP.exec(line)?.[1])), 0)
23
24export const hasStep = (md: string, turn: number): boolean => stepLines(md).some(line => STEP.exec(line)?.[1] === String(turn))
25
26// steps.md is append-only: the new line goes under the last one. A turn run again (a retry after Haiku failed)
27// replaces its own line where it stands.
28export const appendStep = (md: string, step: Step): string => {
29 if (hasStep(md, step.turn)) return md.split('\n').map(line => (STEP.exec(line)?.[1] === String(step.turn) ? stepLine(step) : line)).join('\n')
30 const base = md === '' ? STEPS_HEAD : md.endsWith('\n') ? md : `${md}\n`
31 return `${base}${stepLine(step)}\n`
32}
33core/transcript.ts 122 lines1// Reads a Claude Code transcript (<id>.jsonl) back into turns, the way the mod records them live:
2// the person's typed message, the main agent's last reply, and what its tools did.
3import { addToolCall, emptyFacts } from './facts'
4import type { Turn } from './types'
5
6type Line = Record<string, unknown>
7type ToolUse = { name: string; input: Record<string, unknown> }
8
9// Messages Claude Code or a plugin wrote into the user's slot.
10const AUTOMATED = /^(<command-name>|<local-command-|<system-reminder>|Caveat:|\[Request interrupted|This session is being continued|<task-notification>|<bash-)/
11
12const blocks = (d: Line): Line[] => {
13 const content = (d.message as { content?: unknown } | undefined)?.content
14 return Array.isArray(content) ? (content as Line[]) : []
15}
16
17// A message the person typed: text, maybe with pasted images; never a tool result.
18const humanText = (d: Line): string | undefined => {
19 if (d.type !== 'user' || d.isMeta === true || d.isSidechain === true || d.isCompactSummary === true) return undefined
20 const content = (d.message as { content?: unknown } | undefined)?.content
21 const parts = blocks(d)
22 const text =
23 typeof content === 'string'
24 ? content
25 : parts.length > 0 && parts.every(b => b.type === 'text' || b.type === 'image')
26 ? parts.flatMap(b => (b.type === 'text' ? [String(b.text)] : [])).join('\n')
27 : undefined
28 if (text === undefined || text.trim() === '' || AUTOMATED.test(text.trim())) return undefined
29 return text
30}
31
32const assistantText = (d: Line): string | undefined => {
33 if (d.type !== 'assistant' || d.isSidechain === true) return undefined
34 const text = blocks(d)
35 .filter(b => b.type === 'text')
36 .map(b => String(b.text))
37 .join('\n')
38 return text.trim() === '' ? undefined : text
39}
40
41const resultText = (content: unknown): string =>
42 typeof content === 'string'
43 ? content
44 : Array.isArray(content)
45 ? content.map(b => (typeof (b as { text?: unknown }).text === 'string' ? (b as { text: string }).text : '')).join('\n')
46 : ''
47
48// What the person picked in the agent's multiple-choice questions: their words too.
49export const answersOf = (result: unknown): string => {
50 const record = (result as { answers?: unknown } | null)?.answers
51 const pairs: [string, string][] =
52 record !== null && typeof record === 'object'
53 ? Object.entries(record as Record<string, unknown>).map(([q, a]) => [q, String(a)])
54 : typeof result === 'string'
55 ? [...result.matchAll(/"([^"\n]+)"="([^"]*)"/g)].map(m => [m[1]!, m[2]!])
56 : []
57 return pairs.length === 0 ? '' : `[Answers to the agent's questions]\n${pairs.map(([q, a]) => `- ${q} → ${a}`).join('\n')}`
58}
59
60export const withAnswers = (user: string, answers: string): string => (answers === '' ? user : `${user}\n\n${answers}`)
61
62// After an auto-compaction Claude Code can continue the chat under a new session id. The new transcript opens
63// with a compact boundary whose logicalParentUuid is the last message of the old one.
64export const continuesFrom = (text: string): string | undefined => {
65 for (const raw of text.split('\n', 40)) {
66 if (!raw.includes('compact_boundary')) continue
67 try {
68 const d = JSON.parse(raw) as Line
69 if (d.subtype === 'compact_boundary' && typeof d.logicalParentUuid === 'string') return d.logicalParentUuid
70 } catch {
71 continue
72 }
73 }
74 return undefined
75}
76
77// Whether a transcript holds the message itself, not only a record that points back at it (a boundary's
78// logicalParentUuid, or a child's parentUuid), so a sibling of the parent is not taken for the parent.
79export const hasMessage = (text: string, uuid: string): boolean => new RegExp(`"uuid"\\s*:\\s*"${uuid.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`).test(text)
80
81export const parseTranscript = (text: string): Turn[] => {
82 const turns: Turn[] = []
83 const uses = new Map<string, ToolUse>()
84 // A tool result can be written twice; each call counts once.
85 const done = new Set<string>()
86 for (const raw of text.split('\n')) {
87 if (raw === '') continue
88 let d: Line
89 try {
90 d = JSON.parse(raw) as Line
91 } catch {
92 continue
93 }
94 const user = humanText(d)
95 if (user !== undefined) {
96 turns.push({ turn: turns.length + 1, turnId: String(d.uuid ?? ''), user, answer: '', previousAnswer: turns.at(-1)?.answer ?? '', isAborted: false, facts: emptyFacts() })
97 continue
98 }
99 const last = turns.at(-1)
100 if (last === undefined || d.isSidechain === true) continue
101 // A message typed while the agent was working joins the turn it was typed in.
102 const queued = d.type === 'attachment' ? (d.attachment as { type?: string; prompt?: unknown } | undefined) : undefined
103 if (queued?.type === 'queued_command' && typeof queued.prompt === 'string' && !AUTOMATED.test(queued.prompt.trim())) {
104 last.user = `${last.user}\n\n${queued.prompt}`
105 continue
106 }
107 for (const b of blocks(d)) {
108 if (d.type === 'assistant' && b.type === 'tool_use') uses.set(String(b.id), { name: String(b.name), input: (b.input ?? {}) as Record<string, unknown> })
109 if (d.type !== 'user' || b.type !== 'tool_result') continue
110 const use = uses.get(String(b.tool_use_id))
111 if (use === undefined || done.has(String(b.tool_use_id))) continue
112 done.add(String(b.tool_use_id))
113 const isError = b.is_error === true
114 if (use.name === 'AskUserQuestion' && !isError) last.user = withAnswers(last.user, answersOf(d.toolUseResult ?? resultText(b.content)))
115 last.facts = addToolCall(last.facts, { tool: use.name, input: use.input, isError, text: resultText(b.content) })
116 }
117 const answer = assistantText(d)
118 if (answer !== undefined) last.answer = answer
119 }
120 return turns
121}
122core/types.ts 180 lines1export const KINDS = ['decision', 'preference', 'requirement', 'correction', 'ask'] as const
2export type Kind = (typeof KINDS)[number]
3
4export const LEARN_KINDS = ['failed', 'constraint'] as const
5export type LearnKind = (typeof LEARN_KINDS)[number]
6
7// Where a note came from: the turn, the engine's id for it, and the earlier turns it answers.
8export type Source = { turnId: string; refersTo: number[] }
9
10export type Note = {
11 id: number
12 turn: number
13 kind: Kind
14 text: string
15 why: string
16 context: string
17 quote: string
18 source: Source
19 status: 'active' | 'superseded' | 'done'
20 supersededBy?: number
21 doneAt?: number
22}
23
24// Something the agent found out the hard way: an approach that failed, or a hard constraint.
25export type Learning = {
26 id: number
27 turn: number
28 kind: LearnKind
29 text: string
30 why: string
31 evidence: string
32 status: 'active' | 'superseded'
33 supersededBy?: number
34}
35
36// The ledger as state.md holds it: only what is true now. `roles` maps a file path to what it is for.
37export type State = {
38 front: Record<string, string>
39 notes: Note[]
40 learnings: Learning[]
41 roles: Record<string, string>
42 own: string[]
43}
44
45// An item that left state.md, for history.md.
46export type Moved = { item: Note | Learning; reason: 'superseded' | 'done'; by?: number; turn: number }
47
48export type Step = {
49 turn: number
50 outcome: 'ok' | 'failed' | 'aborted'
51 text: string
52 facts: string
53 // Labels of what the turn added (M-012, L-003) and finished (M-009).
54 added: string[]
55 done: string[]
56}
57
58export type Failure = { command: string; error: string }
59
60// What the tools did in a turn, collected by code from the tool calls.
61export type TurnFacts = {
62 read: string[]
63 edited: string[]
64 commands: number
65 failures: Failure[]
66 commits: string[]
67}
68
69export type Turn = {
70 turn: number
71 turnId: string
72 user: string
73 answer: string
74 previousAnswer: string
75 isAborted: boolean
76 facts: TurnFacts
77}
78
79export type FileEntry = { path: string; reads: number; edits: number; first: number; last: number; role: string; score: number }
80
81export type Usage = { input: number; output: number; cacheRead: number; cacheWrite: number }
82
83export type Memory = {
84 kind: Kind
85 text: string
86 why: string
87 context: string
88 quote: string
89 refersTo: number[]
90 supersedes: number[]
91}
92
93export type LearningDraft = { kind: LearnKind; text: string; why: string; evidence: string; replaces: number[] }
94
95export type Config = {
96 gate: 'jev' | 'off'
97 threshold: number
98 jevModel: string
99 jevKey: string
100 haikuModel: string
101 effort: 'low' | 'medium' | 'high' | 'xhigh' | 'max'
102 maxWords: number
103 auditRate: number
104 pinRules: boolean
105}
106
107export const DEFAULTS: Config = {
108 gate: 'jev',
109 // eval/REPORT.md: the lowest threshold whose precision reaches 0.9 on both halves, jev-1.14 with the previous reply in view.
110 threshold: 0.63,
111 jevModel: 'jev-1.14',
112 jevKey: '',
113 haikuModel: 'claude-haiku-5-5',
114 effort: 'high',
115 maxWords: 15,
116 auditRate: 0,
117 pinRules: true,
118}
119
120export type CompleteResult =
121 | { isOk: true; text: string; usage: Usage }
122 | { isOk: false; reason: string; usage: Usage }
123
124export type PostResult = { status: number; text: string }
125
126// Everything the pipeline needs from the agent it runs in. The Claude Code adapter fills it with `$`;
127// a Pi or Codex adapter would fill it with that agent's own calls.
128export type Host = {
129 now: () => Promise<number>
130 random: () => number
131 complete: (req: { system: string; prompt: string; effort?: Config['effort'] }) => Promise<CompleteResult>
132 post: (url: string, headers: Record<string, string>, body: string) => Promise<PostResult>
133}
134
135export type GateEvent = {
136 type: 'gate'
137 at: number
138 turn: number
139 turnId: string
140 p: number | null
141 model: string
142 ms: number
143 inputTokens: number
144 cost: number
145 action: 'extract' | 'skip' | 'audit' | 'error' | 'off'
146 error?: string
147}
148
149// One Haiku run on a turn: summarization always, extraction too when the gate passed.
150export type ExtractEvent = {
151 type: 'extract'
152 at: number
153 turn: number
154 turnId: string
155 model: string
156 mode: 'summarize' | 'extract'
157 ms: number
158 usage: Usage
159 kept: number
160 learned: number
161 closed: number
162 roles: number
163 need?: number
164 dropped: string[]
165 error?: string
166}
167
168// A turn as it was, redacted and clipped, so later turns can read it after Claude Code compacts.
169export type TurnRecord = {
170 type: 'turn'
171 at: number
172 turn: number
173 turnId: string
174 user: string
175 answer: string
176 facts: TurnFacts
177}
178
179export type LedgerEvent = GateEvent | ExtractEvent | TurnRecord
180hooks/format.ts 69 lines1import type { Stats } from '../core/ledger'
2import type { Config } from '../core/types'
3import type { Status } from '../types'
4import { plural } from './view'
5
6export const money = (usd: number): string => {
7 if (usd === 0) return '$0'
8 if (usd < 0.0001) return '<$0.0001'
9 return `$${usd.toFixed(usd < 0.01 ? 4 : 3)}`
10}
11
12export const kilo = (n: number): string => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n))
13
14export const duration = (ms: number): string => (ms >= 1000 ? `${(ms / 1000).toFixed(1)} s` : `${Math.round(ms)} ms`)
15
16const mean = (total: number, count: number): number => (count === 0 ? 0 : total / count)
17
18export type Row = { label: string; value: string }
19
20export type PanelInput = {
21 stats: Stats
22 asks: number
23 rules: number
24 learnings: number
25 files: number
26 config: Config
27 status: Status
28 isPaused: boolean
29 failed: number
30}
31
32export const rows = (p: PanelInput): Row[] => {
33 const { stats, config } = p
34 const haikuIn = stats.haikuUsage.input + stats.haikuUsage.cacheRead + stats.haikuUsage.cacheWrite
35 return [
36 { label: 'Turns', value: `${stats.turns} seen · ${stats.passed} passed the gate · ${stats.skipped} skipped` },
37 {
38 label: 'Gate',
39 value:
40 config.gate === 'off'
41 ? 'off: Haiku reads every turn'
42 : `${config.jevModel} via OpenRouter · pass at p ≥ ${config.threshold} · avg ${duration(mean(stats.jevMs, stats.jevCalls))}`,
43 },
44 {
45 label: 'Haiku',
46 value: `${config.haikuModel} · ${stats.summaries} summaries (low) · ${stats.extractions} extractions (${config.effort}) · ${stats.lookups} look-backs · avg ${duration(mean(stats.haikuMs, stats.summaries + stats.extractions))}`,
47 },
48 {
49 label: 'Saved',
50 value: `${stats.saved} notes · ${stats.learned} learnings · ${stats.closed} asks closed · ${stats.dropped} dropped by checks`,
51 },
52 {
53 label: 'Cost',
54 value: `Jev ${money(stats.jevCost)} · Haiku ${kilo(haikuIn)} in, ${kilo(stats.haikuUsage.output)} out ≈ ${money(stats.haikuCost)} (plan usage, not billed)`,
55 },
56 ]
57}
58
59export type Tone = 'success' | 'warning' | 'error' | 'inactive'
60
61// The recorder's state as one coloured line: what it is doing, and what went wrong if anything did.
62export const statusOf = (p: Pick<PanelInput, 'status' | 'isPaused' | 'failed'>): { tone: Tone; text: string } => {
63 const note = p.status.message === '' ? '' : ` · ${p.status.message}`
64 if (p.isPaused) return { tone: 'inactive', text: `Paused${note}` }
65 if (p.status.isBusy) return { tone: 'warning', text: `Recording turn ${p.status.turn}${note}` }
66 if (p.failed > 0) return { tone: 'error', text: `${plural(p.failed, 'turn')} failed; run /session-state retry${note}` }
67 return { tone: 'success', text: `Idle${note}` }
68}
69