SLOPSHOPPER

session-state

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…

paneguardcommandpromptmodelprocess
★ 2v0.4.0no licenseupdated 2026-10-21kargarisaac/session-state
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-state
│ ┃ Session ledger ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Steps 2: Ledger 3: Stats │ ┃ ──────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ────────── ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ No steps yet. ⎿ Added 2 lines, removed 1 line │ ┃ After each turn one line appears here: what ⏺ Bash(bun test) │ ┃ was done, which files changed, and the notes ⎿ 3 pass, 1 fail │ ┃ it added. │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /session-state │ ⎿ session-state: Session ledger panel opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session ledger
1: Steps 2: Ledger 3: Stats ────────────────────────────────────────────────────── No steps yet. After each turn one line appears here: what was done, which files changed, and the notes it added.
README

session-state

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.

Install

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).

Use

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).
  • Ask the agent "what did I decide about X?" and it reads 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).

Steps tab: one line per turn

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

Ledger tab: notes, learnings and files

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

Stats tab: models, counts, cost and gate decisions

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:

FileHolds
state.mdWhat 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.mdOne line per turn, never rewritten: - t41 · ✓ · Moved the parser to its own file · edited core/parse.ts · +M-012 · done M-009
history.mdItems that left state.md: replaced by a newer one, or done.
events.jsonlEach 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.

How it works

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):

  • Summarization, every turn, at low effort: the step line, which open asks the reply finished, a role for each newly edited file, and any learning (an approach that failed, or a hard limit), quoted from the reply or a failed command.
  • Extraction, when Jev says yes (or Jev is off or down), at the set effort: all of the above, plus notes about what you said: decisions, rules, preferences, corrections and asks. Each note has a short title, why, context, your exact words and its source turn. If your message only makes sense with an older turn ("do what I said about the deck"), Haiku may ask for that turn once from the archive.

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.

Settings

SettingDefaultMeaning
openrouterKeyemptyJev key; falls back to OPENROUTER_API_KEY
gatejevjev, or off to extract on every turn
threshold0.63Jev probability needed for extraction
jevModeljev-1.14OpenRouter model id for the gate
haikuModel / effortclaude-haiku-5-5 / highHaiku model, and its effort for extraction (summarization is always low)
maxWords15Longest note title
auditRate0Share of turns below the threshold that get extraction anyway, to check the gate
pinRulesonPut your rules in the agent's system prompt
openPanelOnStartoffOpen the panel with each session

Privacy

  • With 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.
  • With gate: off, nothing leaves Claude Code; Haiku extracts on every turn and uses more plan quota.
  • To find the old ledger of a chat that Claude Code continued under a new session id, the mod searches the old chat's transcript for one message id. A transcript over 4 MiB is too big for the mod to read, so it runs 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 mod API is early access and may change between Claude Code versions.

How well it works

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.

Development

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>....

Source 20 files
hooks/register.tsx 882 lines
1import { 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}
882
core/digest.ts 86 lines
1import { 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}
86
core/facts.ts 62 lines
1import { 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
62
core/files.ts 42 lines
1import { 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}
42
core/ledger.ts 178 lines
1import { 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}
178
core/notes.ts 115 lines
1import { 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}
115
core/pipeline.ts 126 lines
1import { 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}
126
core/state.ts 225 lines
1import { 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])) } }
225
core/steps.ts 33 lines
1import 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}
33
core/transcript.ts 122 lines
1// 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}
122
core/types.ts 180 lines
1export 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
180
hooks/format.ts 69 lines
1import 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