Makes compaction bearable: the older part of the conversation is summarized in the assistant's own voice with exact identifiers kept, the recent stretch stays…

Makes compaction bearable.
A conversation with an AI lives in a window of limited size. When it is nearly full, Claude Code compacts it: everything so far is replaced by one summary, and the conversation carries on from the summary. That keeps you going, but the summary is written generically, in one pass, and the details go: file paths, ids, the reason behind a decision, what you were about to do next.
Before chronicle: one blob of summary, written without knowing what matters, replacing the whole past. The last few exchanges, the ones you most need, are summarised too. If something was lost, it is gone.
With chronicle: the older part is summarised in the assistant's own voice, told to keep identifiers, decisions and open threads. The recent part is kept exactly as it was. You pick the moment with /squish, and a meter tells you before it gets urgent. Every message was already saved to your disk as it happened, so if the summary dropped something, recall finds the original.
/squish any time, and a context meter that flags at 75% and 90%.recall brings the sharp version back from the mirror, or from an archive if you add one./squish [focus] runs /compact itself, so the trigger stays manual and everything above runs exactly as for a typed /compact. /squish status prints the fill. /squish auto on|off flips Claude Code's autoCompact setting.[context · 142k of 1M (14%)] appended as context, then · squish soon from 75% and · SQUISH NOW from 90%.<archiveRoot>/<day>/<session>.jsonl, redacted. Folders are 0700, files 0600.mcp__chronicle__recall. Searches the mirror (rows, compaction summaries, memories) newest first. With an archive configured it also searches that.Linux and macOS. It needs ripgrep (rg) on the path for recall, and sh, mkdir and cat, which every Linux and macOS has. Windows is not supported yet.
~/.claude/mods/chronicle.CLAUDE_CODE_PLUGIN_DIRS=/path/to/chronicle (colon-separate several) in the environment that starts Claude Code, or pass --plugin-dir /path/to/chronicle.It runs with no configuration file at all. Rows are mirrored to ~/.chronicle/archive, compaction is steered with the default steer, /squish and the context line are on, memories are off, and there is no archive.
cp chronicle.example.json chronicle.json and edit it. The real chronicle.json is git-ignored. A missing key, or a value of the wrong type, falls back to the default shown; unknown keys are ignored. The mod never fails to load over its settings.
| Key | Default | Meaning |
|---|---|---|
archiveRoot | ~/.chronicle/archive | Where the mirror is written. ~ is your home folder. |
timezone | system zone | Day folders are named in this zone (an IANA name such as Europe/Rome). The zone is recorded in <archiveRoot>/.chronicle.json the first time it is written, and that record is used afterwards, so changing your machine's zone cannot split a day. |
names.user, names.agent | You, Assistant | The labels recall prints for each side. |
mirror.enabled | true | false writes nothing (and so disables memories and session recall). |
compaction.tailTokens | 60000 | How much of the recent conversation to keep raw, at least. |
compaction.maxTailTokens | 120000 | The most to keep raw (also capped at 30% of the window). |
compaction.minOlderMessages | 6 | Below this many older messages, compaction is left to the native one (still steered). |
compaction.voice | empty | A short paragraph put in front of the steer, for example who the assistant is to you. |
compaction.steer | empty | Replaces the default steer completely. A sentence forbidding credentials is added if yours lacks it. |
memories.enabled | false | See "Memories". |
memories.chunkMinTokens, chunkMaxTokens | 15000, 25000 | The size of a stretch a memory is written for. |
memories.voice | empty | A paragraph put in front of the memory instructions. |
memories.memoryVoice | empty | Replaces the memory instructions completely. |
memories.assemble | manual | manual: memories replace the older context only for a /compact or /squish you run. auto: for every compaction. false: never. |
memories.gapRawMaxTokens | 12000 | A stretch not yet covered by a memory is kept raw up to this size; a bigger one is summarised. |
squish.enabled | true | Registers /squish. |
contextLine.enabled, warnAt, urgentAt | true, 75, 90 | The meter. |
tools.rgPath | rg | The ripgrep binary. |
archive.command | none | See "Archive". |
archive.timeoutMs | 30000 | How long the archive command may run. |
legacyToolNames | none | Names this tool had under an earlier plugin name, for example ["mcp__old-name__recall"], so rows mirrored then are still recognised. |
Older flat keys (tailTokens, maxTailTokens, minOlderMessages, steer, chunkMinTokens, chunkMaxTokens, memoryVoice, assembleWithMemories, gapRawMaxTokens) are still read; the nested keys win when both are present. A file that already used the flat memory keys is taken to want memories on, unless memories.enabled says otherwise.
The summary of the older part is asked to be written in the first person, as the assistant, about "the person I'm working with", keeping every exact identifier verbatim, every decision and who made it, promises and open threads, the person's stated preferences and state where they matter, the tone between you, and what was about to happen next. It is told never to copy credentials. Set compaction.voice to add who you are to each other; set compaction.steer to replace it.
With memories.enabled: true, every 15-25k tokens of conversation, at the end of a turn, the assistant writes a short memory of that stretch in the first person, in the background, using a forked call over the session's cached transcript. At a manual compaction, if the memories provably cover the older part (an unbroken chain from the first row, each memory signed with a key kept outside the mirror), the older part is replaced by them with no summariser call at all. Anything short of that proof falls back to split compaction.
This spends model calls in the background. The prefix is cached, so it is cheap, but it is still your usage. It is also the biggest saving at compaction time, which is why the switch exists rather than the feature being removed. It is off until you turn it on.
The signature is an integrity check, not secrecy: it stops a memory file pulled in from somewhere else (a git pull, say) from reaching the context. Memories are redacted like everything else.
Without an archive, recall offers the session source only, and says so: asking for source: archive returns "no archive is set up" and how to set one up. Nothing but rg is run.
To search every past conversation, install resonant-archive and set:
"archive": { "command": ["resonant-archive", "recall"] }
For source: archive (the default once a command is set) the mod runs
<command...> --json <action> [options] [-- <positional>]
with no shell, one argument per element. <action> is search, around, overview, drill or associate.
| Action | Options | Positional | |||
|---|---|---|---|---|---|
search | --source a,b --from YYYY-MM-DD --to YYYY-MM-DD --speaker x,y --context x,y --limit N `--mode hybrid\ | fts\ | vector\ | anchor --hidden --recent` | the query |
around | --before N --after N --hidden | a message id | |||
associate | --k N --recent --hidden | a message id | |||
overview | --from --to (both required) --source a,b --memories-only --no-memories | none | |||
drill | --hidden | a memory #index or its from_uuid |
Standard output is one JSON object, {"ok": true, "text": "<plain text for the assistant to read>"}, or {"ok": false, "error": "<message>"}. The text should be compact (about 6,000 characters) and say when it capped itself; the mod cuts anything over 12,000. Plain text that is not JSON is passed through as is. A non-zero exit with nothing on standard output is reported with the last lines of standard error. The command holds its own database settings and credentials: the mod passes none.
<archiveRoot>/<YYYY-MM-DD>/<session id>.jsonl holds one JSON object per line:
{"t": <ms>, "sid", "uuid", "door", "origin", "role", "text", "tools": [...], "results": [...]}, redacted. Tool results are cut at 2,000 characters; a result from a secret-bearing source (a .env, a private key, env, ps, a search for passwords; best effort, see docs/PRIVACY.md in the repository) is replaced by [withheld: secret-bearing source], and the input of that call is cut to the tool name and the file or program; recall output is not mirrored.{"t", "event": "compaction" | "session_fresh" | "memory_formed" | "memory_skipped" | "memory_failed" | "mirror_dropped", ...}. A compaction event carries the (redacted) summary.<session id>.memories.jsonl holds {"event": "memory", "level": 1, "ordinal", "text", "from_uuid", "to_uuid", "t_from", "t_to", "mac", ...} lines. <archiveRoot>/.chronicle.json records the day-folder zone.
The mirror is private data. Keep it out of any public repository.
claude plugin validate .
claude plugin test .
./scripts/lint.sh
claude plugin test . runs the tests in tests/ (they use claude-code/testing). scripts/lint.sh checks that the tree holds no private names, hosts or paths (the public checks always; your own terms from the file named by PRIVATE_STRIP_TERMS_FILE, see scripts/private_strip_lint.py in the repository root) and that chronicle.example.json matches the copy the tests use. To typecheck, tsc --noEmit -p . after Claude Code has written .claude-plugin/types/ (the tests do not need it).
Tested against Claude Code 2.1.301.
.claude-plugin/types/ itself when it loads the mod, so they are not included here./squish starts /compact just after answering, because Claude Code refuses to run a command from inside a command hook.rg, sh, mkdir and cat: Linux and macOS only.Built by Nekyia Labs.
hooks/register.ts 2549 lines1import type { EngineInterface, Register, SessionCompactResult, SessionMessage } from 'claude-code'
2
3// chronicle, v0.1. Keeps a conversation bearable across compaction.
4// 1. Mirror: every main-loop row is written to a private folder on disk (secrets redacted first), day by day.
5// 2. Split compaction: the older part goes to native compaction, steered toward the assistant's own voice and exact
6// identifiers; the recent raw tail comes through whole (prompts keep their engine handles; the rest is rebuilt,
7// see keptMessages). Any failure before the summarizer runs falls back to native compaction.
8// 3. Recall: the tool mcp__chronicle__recall. The session source searches the mirror (rows, compaction summaries,
9// memories) with ripgrep. The archive source, only when archive.command is configured, hands the query to an
10// external `resonant-archive recall --json` command (see README.md for the contract).
11// 4. Memories (off by default, memories.enabled): every 15-25k tokens of mirrored rows, at a turn's end, the assistant
12// writes its own memory of that stretch with $.model.fork over the session's cached transcript, in the
13// background, one at a time. Saved beside the mirror.
14// 5. Assembly: at compaction, when the signed memories provably cover the older part (from the session's first row,
15// with no hole, checked against the mirror's uuid order), the older part becomes the memories block instead of a
16// summary: no model call when the uncovered gap is small, one summarizer call over the gap alone when it is not.
17// Any doubt keeps the split compaction of item 2 exactly as it was. Until it has run supervised in a real engine,
18// assembly runs only for a manual /compact (memories.assemble: "manual").
19// 6. Redaction: every string bound for the mirror (rows, memories, summaries) goes through redact() before any cut,
20// and a tool result from a secret-bearing source (a .env, a private key, `env`, `ps`) is withheld whole.
21// 7. /squish and the context-fill line (75% and 90% flags).
22//
23// Engine facts this file leans on (claude-code index.d.ts, 2.1.301):
24// - A hook that throws or overruns gets `next(e)` run on its behalf. So after a hook has called `next`,
25// nothing in it may throw: that would double-append a row or double-compact.
26// - /clear changes the session id with no session.start after it, so every row carries the id it was appended under.
27// - A hot reload drops module memory. The buffer is flushed on a timer, at a size threshold and at each turn's end,
28// so the window a reload can lose is small. No unload event exists to flush on.
29
30// The settings as the code reads them: normalizeConfig() turns chronicle.json (new nested keys, or the older flat ones)
31// into this, with a safe default for anything missing or of the wrong type.
32type Cfg = {
33 archiveRoot: string
34 timezone: string // '' = the stored zone of this archive, else the system zone
35 names: { user: string; agent: string }
36 mirrorEnabled: boolean
37 tailTokens: number; maxTailTokens: number; minOlderMessages: number
38 voice: string // a persona paragraph put in front of the steer
39 steer: string // '' = the default steer; anything else replaces it
40 memoriesEnabled: boolean
41 chunkMinTokens: number; chunkMaxTokens: number
42 memoryVoice: string // a persona paragraph put in front of the memory instructions
43 memoryPrompt: string // '' = the default memory instructions; anything else replaces them
44 // false: off. 'manual' (the default): only a /compact the person runs. true or 'auto': every main-loop compaction.
45 assembleWithMemories: boolean | 'manual' | 'auto'; gapRawMaxTokens: number
46 squishEnabled: boolean
47 contextLine: { enabled: boolean; warnAt: number; urgentAt: number }
48 rgPath: string
49 archiveCommand: string[]; archiveTimeoutMs: number
50 legacyToolNames: string[] // names of this tool under an earlier plugin name, so old mirrored rows are still recognised
51 ignored: string[] // keys in the file that this version does not know
52}
53// suffix '.memories' sends the line to <sid>.memories.jsonl instead of the mirror file <sid>.jsonl.
54type Entry = { day: string; sid: string; line: string; suffix?: string }
55
56const FLUSH_EVERY_MS = 30_000
57const FLUSH_AT_CHARS = 256 * 1024
58const BUFFER_CAP_CHARS = 5 * 1024 * 1024
59const WARN_AFTER_FAILURES = 3
60const RUN_TIMEOUT_MS = 10_000
61const TAIL_SHARE_OF_WINDOW = 0.3
62const SUMMARY_IN_LOG_CHARS = 20_000
63// Double-quoted on purpose: an apostrophe inside a single-quoted string once kept this plugin from loading at all.
64const TAIL_NOTE = "\n\nThe final reply of this slice, and the most recent stretch of the conversation after it, are kept verbatim right after your summary: the last request of the slice has already been answered. End your summary where this slice ends, and under pending tasks and next step list only what is still genuinely open, not that last request."
65
66// Module state. A hot reload starts it over.
67let buf: Entry[] = []
68let bufChars = 0
69let dropped = 0 // rows refused while the buffer was at its cap; recorded in the next flush
70let failStreak = 0
71let warned = false
72let sizeFlushQueued = false
73let chain: Promise<void> = Promise.resolve() // flushes run one at a time, in order
74let inflight: Entry[] = [] // the batch a flush is writing right now (neither in buf nor surely on disk): read by memoryEvidence
75let timer: { cancel: () => void } | undefined
76let sid: string | undefined // cached session id; reset at session.end (a /clear or resume brings a new one)
77let lastT = 0 // last good clock reading, a fallback stamp
78// The uuids of the prompts of the turn now running (the person typed or queued them). recall leaves them out: the request
79// that asked for a search holds its query verbatim, so it would always rank first (seen in live testing). Cleared at each
80// turn end and session end; capped so a long turn cannot grow it.
81const turnPrompts = new Set<string>()
82// Set when the /exit (or /quit) command row is seen: the farewell the engine appends during shutdown is neither
83// mirrored nor counted (seen in live testing: the final flush never wrote it, but it became the next chunk's start).
84let exitingSid: string | undefined
85const TURN_PROMPTS_MAX = 32
86let cfgCache: Cfg | undefined
87
88// The tool's full name. A tool registered by the plugin `chronicle` is called mcp__chronicle__<name>.
89export const RECALL_TOOL = 'mcp__chronicle__recall'
90// Written once into the archive root: the zone its day folders are named in, so a changed system zone cannot split a day.
91const ARCHIVE_MARKER = '.chronicle.json'
92const DEFAULT_ROOT = '~/.chronicle/archive'
93const CONFIG_KEYS = new Set([
94 'archiveRoot', 'timezone', 'names', 'mirror', 'compaction', 'memories', 'squish', 'contextLine', 'tools', 'archive', 'legacyToolNames',
95 // older flat keys, still read
96 'tailTokens', 'maxTailTokens', 'minOlderMessages', 'steer', 'chunkMinTokens', 'chunkMaxTokens', 'memoryVoice',
97 'assembleWithMemories', 'gapRawMaxTokens',
98])
99
100function rec(v: unknown): Record<string, unknown> {
101 return v && typeof v === 'object' && !Array.isArray(v) ? v as Record<string, unknown> : {}
102}
103function pickStr(v: unknown, d: string): string { return typeof v === 'string' ? v : d }
104function pickBool(v: unknown, d: boolean): boolean { return typeof v === 'boolean' ? v : d }
105function pickNum(v: unknown, d: number): number { return typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : d }
106function pickPct(v: unknown, d: number): number { return typeof v === 'number' && Number.isFinite(v) && v > 0 && v <= 100 ? v : d }
107function firstDefined(...vs: unknown[]): unknown { return vs.find(v => v !== undefined) }
108
109export function validZone(z: string): boolean {
110 if (!z) return false
111 try { new Intl.DateTimeFormat('en-GB', { timeZone: z }); return true } catch { return false }
112}
113
114function expandHome(p: string, home: string | undefined): string {
115 if (p === '~' || p.startsWith('~/')) return home ? `${home.replace(/\/+$/, '')}${p.slice(1)}` : ''
116 return p
117}
118
119// Pure. Never throws: the mod must not fail to load over its settings. Wrong types fall back to the default, unknown
120// keys are listed in `ignored`. The new nested keys win over the older flat ones when both are present.
121export function normalizeConfig(raw: unknown, home?: string): Cfg {
122 const r = rec(raw)
123 const compaction = rec(r.compaction), memories = rec(r.memories), mirrorC = rec(r.mirror), squish = rec(r.squish)
124 const line = rec(r.contextLine), tools = rec(r.tools), archive = rec(r.archive), names = rec(r.names)
125 const legacyMemories = r.chunkMinTokens !== undefined || r.chunkMaxTokens !== undefined || r.assembleWithMemories !== undefined
126 const asm = firstDefined(memories.assemble, r.assembleWithMemories)
127 const warnAt = pickPct(line.warnAt, 75)
128 const urgent = pickPct(line.urgentAt, 90)
129 const command = Array.isArray(archive.command) ? archive.command.filter((x): x is string => typeof x === 'string' && x.length > 0) : []
130 const root = expandHome(pickStr(r.archiveRoot, DEFAULT_ROOT).trim() || DEFAULT_ROOT, home)
131 const min = pickNum(firstDefined(memories.chunkMinTokens, r.chunkMinTokens), DEFAULT_CHUNK_MIN)
132 const zone = pickStr(r.timezone, '').trim()
133 return {
134 archiveRoot: root.replace(/\/+$/, '') || root,
135 timezone: validZone(zone) ? zone : '',
136 names: { user: pickStr(names.user, '').trim() || 'You', agent: pickStr(names.agent, '').trim() || 'Assistant' },
137 mirrorEnabled: pickBool(mirrorC.enabled, true),
138 tailTokens: pickNum(firstDefined(compaction.tailTokens, r.tailTokens), 60_000),
139 maxTailTokens: pickNum(firstDefined(compaction.maxTailTokens, r.maxTailTokens), 120_000),
140 minOlderMessages: pickNum(firstDefined(compaction.minOlderMessages, r.minOlderMessages), 6),
141 voice: pickStr(compaction.voice, '').trim(),
142 steer: pickStr(firstDefined(compaction.steer, r.steer), '').trim(),
143 // Memories are off unless asked for. An older file that already tuned them (flat chunk or assembly keys) is
144 // taken to have wanted them, so an install made before the switch existed keeps working.
145 memoriesEnabled: pickBool(memories.enabled, legacyMemories),
146 chunkMinTokens: min,
147 chunkMaxTokens: Math.max(min, pickNum(firstDefined(memories.chunkMaxTokens, r.chunkMaxTokens), DEFAULT_CHUNK_MAX)),
148 memoryVoice: pickStr(memories.voice, '').trim(),
149 memoryPrompt: pickStr(firstDefined(memories.memoryVoice, r.memoryVoice), '').trim(),
150 assembleWithMemories: asm === false || asm === true || asm === 'auto' || asm === 'manual' ? asm : 'manual',
151 gapRawMaxTokens: pickNum(firstDefined(memories.gapRawMaxTokens, r.gapRawMaxTokens), DEFAULT_GAP_RAW_MAX),
152 squishEnabled: pickBool(squish.enabled, true),
153 contextLine: { enabled: pickBool(line.enabled, true), warnAt, urgentAt: Math.max(urgent, warnAt) },
154 rgPath: pickStr(tools.rgPath, '').trim() || 'rg',
155 archiveCommand: command,
156 archiveTimeoutMs: pickNum(archive.timeoutMs, 30_000),
157 legacyToolNames: (Array.isArray(r.legacyToolNames) ? r.legacyToolNames : []).filter((x): x is string => typeof x === 'string' && /^mcp__[\w-]+__[\w-]+$/.test(x)),
158 ignored: Object.keys(r).filter(k => !CONFIG_KEYS.has(k)),
159 }
160}
161
162// What the rest of the file reads without a $ in hand: set from the settings the first time they are read.
163let zone = (() => { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC' } catch { return 'UTC' } })()
164let labels = { user: 'You', agent: 'Assistant' }
165let rgBin = 'rg'
166let memoriesOn = false
167let recallNames: readonly string[] = [RECALL_TOOL]
168let zoneToStore: string | undefined // set when the archive has no .chronicle.json yet: flush writes it once
169
170function applyConfig(cfg: Cfg) {
171 labels = cfg.names
172 rgBin = cfg.rgPath
173 memoriesOn = cfg.memoriesEnabled
174 recallNames = [RECALL_TOOL, ...cfg.legacyToolNames]
175}
176
177// Reads chronicle.json once (a missing file is not an error: the defaults run), works out the day-folder zone and
178// the archive root, and caches. Never throws.
179async function cfgOf($: EngineInterface): Promise<Cfg> {
180 if (cfgCache) return cfgCache
181 let raw: unknown
182 try { raw = JSON.parse(String(await $.fs.read(`${$.plugin.root}/chronicle.json`))) } catch (err) {
183 if (!/ENOENT|no such file|not found|does not exist|no implementation/i.test(why(err))) { try { $.ui.log(`chronicle: chronicle.json not used (${why(err)}); defaults apply`, { to: 'debug' }) } catch {} }
184 }
185 let home: string | undefined
186 try { home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) } catch {}
187 const cfg = normalizeConfig(raw, home)
188 if (cfg.ignored.length) { try { $.ui.log(`chronicle: unknown settings ignored: ${cfg.ignored.join(', ')}`, { to: 'debug' }) } catch {} }
189 // The day folders are named in one zone for the life of an archive: the one it recorded, unless the settings name one.
190 let stored = ''
191 if (cfg.archiveRoot) {
192 try {
193 const m = JSON.parse(String(await $.fs.read(`${cfg.archiveRoot}/${ARCHIVE_MARKER}`))) as { timezone?: unknown }
194 if (typeof m.timezone === 'string' && validZone(m.timezone)) stored = m.timezone
195 } catch {}
196 }
197 if (cfg.timezone) zone = cfg.timezone
198 else if (stored) zone = stored
199 zoneToStore = stored === zone ? undefined : zone
200 applyConfig(cfg)
201 cfgCache = cfg
202 return cfg
203}
204
205function dayOf(ms: number): string {
206 if (!ms) return 'undated'
207 const p = new Intl.DateTimeFormat('en-GB', { timeZone: zone, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(ms))
208 const g = (t: string) => p.find(x => x.type === t)?.value ?? ''
209 return `${g('year')}-${g('month')}-${g('day')}`
210}
211
212// Path-safe piece of a folder or file name.
213function safe(s: string): string {
214 return s.replace(/[^A-Za-z0-9._-]/g, '_') || 'unknown'
215}
216
217function why(err: unknown): string {
218 return String((err as { message?: unknown } | undefined)?.message ?? err).slice(0, 200)
219}
220
221// ---------- mirror buffer ----------
222
223// `keep`: never dropped at the cap (a memory is small and cannot be made again cheaply).
224function push(entry: Entry, keep = false) {
225 if (!keep && bufChars + entry.line.length > BUFFER_CAP_CHARS) { dropped++; return }
226 buf.push(entry)
227 bufChars += entry.line.length
228}
229
230function requeue(entries: Entry[]) {
231 buf = entries.concat(buf)
232 bufChars = buf.reduce((n, x) => n + x.line.length, 0)
233}
234
235function noteFailure($: EngineInterface, reason: string) {
236 failStreak++
237 try { $.ui.log(`chronicle: mirror flush failed (${reason})`, { to: 'debug' }) } catch {}
238 if (failStreak >= WARN_AFTER_FAILURES && !warned) {
239 warned = true
240 try { $.ui.log(`chronicle: archive mirror has failed ${failStreak} times in a row (${reason}). Rows are held in memory (cap 5 MB) and retried.`) } catch {}
241 }
242}
243
244function noteSuccess($: EngineInterface) {
245 if (warned) { try { $.ui.log('chronicle: archive mirror recovered.') } catch {} }
246 failStreak = 0
247 warned = false
248}
249
250// Writes the buffer to <archiveRoot>/<day of each row, in the archive's zone>/<row's session id>.jsonl.
251// Failed groups go back to the front of the buffer, in order. Never throws.
252async function flush($: EngineInterface, timeoutMs: number): Promise<void> {
253 sizeFlushQueued = false
254 if (!buf.length) return
255 const batch = buf
256 buf = []
257 bufChars = 0
258 inflight = batch
259 if (dropped > 0) {
260 const last = batch[batch.length - 1]!
261 batch.push({ day: last.day, sid: last.sid, line: JSON.stringify({ t: lastT, event: 'mirror_dropped', count: dropped, reason: 'buffer cap' }) + '\n' })
262 dropped = 0
263 }
264 let root: string
265 try { root = (await cfgOf($)).archiveRoot } catch (err) { inflight = []; requeue(batch); noteFailure($, `config: ${why(err)}`); return }
266 if (!root) { inflight = []; requeue(batch); noteFailure($, 'no archive folder: archiveRoot starts with ~ and HOME is not set'); return }
267 await writeZoneMarker($, root)
268
269 const groups = new Map<string, Entry[]>()
270 for (const x of batch) {
271 const k = `${x.day}\u0000${x.sid}\u0000${x.suffix ?? ''}`
272 const g = groups.get(k)
273 if (g) g.push(x); else groups.set(k, [x])
274 }
275 const failed: Entry[] = []
276 let reason = ''
277 for (const entries of groups.values()) {
278 const dir = `${root}/${safe(entries[0]!.day)}`
279 const file = `${dir}/${safe(entries[0]!.sid)}${entries[0]!.suffix ?? ''}.jsonl`
280 try {
281 // umask 077: the mirror is private. New folders 0700, new files 0600, whatever the inherited umask.
282 const r = await $.process.run(['sh', '-c', 'umask 077; mkdir -p -m 700 "$1" && cat >> "$2"', 'sh', dir, file], { stdin: entries.map(x => x.line).join(''), timeoutMs })
283 if (r.exitCode !== 0) { failed.push(...entries); reason = r.stderr.trim().slice(0, 200) || `exit ${r.exitCode}` }
284 } catch (err) {
285 // A timeout may have written part of the group; the retry can then duplicate rows (see the uuid TODO below).
286 failed.push(...entries)
287 reason = why(err)
288 }
289 }
290 inflight = []
291 if (failed.length) { requeue(failed); noteFailure($, reason) } else noteSuccess($)
292}
293
294// Records the day-folder zone once, in the archive root, if it has none yet. Best effort; never throws.
295async function writeZoneMarker($: EngineInterface, root: string): Promise<void> {
296 const z = zoneToStore
297 if (!z) return
298 zoneToStore = undefined
299 try {
300 const file = `${root}/${ARCHIVE_MARKER}`
301 await $.process.run(['sh', '-c', 'umask 077; mkdir -p -m 700 "$1" && { [ -e "$2" ] || cat > "$2"; }', 'sh', root, file], { stdin: JSON.stringify({ timezone: z, format: 1 }) + '\n', timeoutMs: RUN_TIMEOUT_MS })
302 } catch {}
303}
304
305// Queues a flush behind any in flight, so batches never interleave or reorder.
306function scheduleFlush($: EngineInterface, timeoutMs: number = RUN_TIMEOUT_MS): Promise<void> {
307 chain = chain.then(() => flush($, timeoutMs)).catch(() => {})
308 return chain
309}
310
311// Detaches a flush from the current dispatch (the scribe's pattern), so the hook returns at once.
312function flushSoon($: EngineInterface) {
313 try { $.clock.after(1, () => { void scheduleFlush($) }) } catch { void scheduleFlush($) }
314}
315
316// One periodic flush timer per module environment. Started at session.start, and lazily on the first row
317// after a hot reload (session.start does not fire again then).
318function ensureTimer($: EngineInterface, restart: boolean) {
319 if (timer && !restart) return
320 try { timer?.cancel() } catch {}
321 timer = undefined
322 try { timer = $.clock.every(FLUSH_EVERY_MS, () => { void scheduleFlush($) }) } catch {}
323}
324
325// ---------- redaction ----------
326// The mirror is private data and must stay out of any public repository; redaction is the last line of defence, not a
327// licence to share it. redact() runs over the WHOLE string before any cut (a key split by a cut would slip past a
328// pattern), and replaces the secret value only, keeping a key name before it. Over-redaction is accepted: the record
329// loses a few characters; a leaked key can only be rotated.
330const REDACT_SCAN_MAX = 512 * 1024 // longer strings are cut here first (every caller keeps far less than this)
331// Private keys are found by a linear scan, not a BEGIN...END regex (that backtracks quadratically on many BEGIN lines
332// with no END: 500 KB took over a minute). BEGIN, then the first END after it, then on; no END at all: the rest goes.
333const PRIVATE_KEY_MARK = '[redacted:private-key]'
334const PK_BEGIN = /-----BEGIN [A-Z ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g
335const PK_END = /-----END [A-Z ]{0,40}PRIVATE KEY(?: BLOCK)?-----/g
336export function scrubPrivateKeys(s: string): string {
337 if (!s.includes('PRIVATE KEY')) return s
338 let out = '', pos = 0
339 for (;;) {
340 PK_BEGIN.lastIndex = pos
341 const b = PK_BEGIN.exec(s)
342 if (!b) break
343 out += s.slice(pos, b.index) + PRIVATE_KEY_MARK
344 PK_END.lastIndex = b.index + b[0].length
345 const e = PK_END.exec(s)
346 if (!e) return out
347 pos = e.index + e[0].length
348 }
349 return out + s.slice(pos)
350}
351
352// The twin of PATTERNS in resonant_archive/redact.py: change both together. A second pass over redacted text is meant to
353// change nothing (the tests check it for every shape listed); it is not proven for arbitrary input.
354const FLAG_VALUE = String.raw`('[^']*'|"[^"]*"|[^\s"']+)`
355const REDACTIONS: readonly [RegExp, string][] = [
356 [/\bsk-(?:ant-|or-v1-|proj-)?[A-Za-z0-9_-]{20,}/g, '[redacted:api-key]'],
357 [/\bxai-[A-Za-z0-9_-]{16,}/g, '[redacted:api-key]'],
358 [/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, '[redacted:api-key]'],
359 [/\bwhsec_[A-Za-z0-9]{16,}/g, '[redacted:api-key]'],
360 [/\bnpm_[A-Za-z0-9]{30,}/g, '[redacted:npm-token]'],
361 [/\bhf_[A-Za-z0-9]{30,}/g, '[redacted:hf-token]'],
362 [/\bgl(?:pat|ptt|rt|dt|oas|soat|ft|imt)-[A-Za-z0-9_-]{20,}/g, '[redacted:gitlab-token]'],
363 [/\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g, '[redacted:sendgrid-key]'],
364 [/\bgh[pousr]_[A-Za-z0-9]{30,}/g, '[redacted:github-token]'],
365 [/\bgithub_pat_[A-Za-z0-9_]{40,}/g, '[redacted:github-token]'],
366 [/\b(?:AKIA|ASIA)[0-9A-Z]{16}/g, '[redacted:aws-key]'],
367 [/(aws[ _-]?secret[ _-]?(?:access[ _-]?)?key["']?[ \t]*[:=][ \t]*["']?)(?!\[redacted)[A-Za-z0-9/+=]{16,}/gi, '$1[redacted:secret]'],
368 [/(\b(?:AccountKey|SharedAccessKey|Pwd)[ \t]*=[ \t]*)(?!\[redacted)[^;\s"']{8,}/gi, '$1[redacted:secret]'],
369 [/\bAIza[0-9A-Za-z_-]{35}/g, '[redacted:google-key]'],
370 [/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, '[redacted:slack-token]'],
371 [/https?:\/\/hooks\.slack\.com\/(?:services|workflows|triggers|actions)\/[A-Za-z0-9/_-]{8,}/g, '[redacted:webhook]'],
372 [/https?:\/\/(?:[a-z0-9-]+\.)?discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d+\/[A-Za-z0-9._-]{20,}/g, '[redacted:webhook]'],
373 [/\b[MNO][A-Za-z0-9_-]{23,27}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{25,}/g, '[redacted:discord-token]'],
374 [/\b(?:AC|SK)[0-9a-f]{32}\b/g, '[redacted:twilio]'],
375 [/\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, '[redacted:jwt]'],
376 [/\b\d{8,10}:AA[A-Za-z0-9_-]{30,}/g, '[redacted:telegram-token]'],
377 // any scheme://user:password@host; the password may hold '@' (the last one before the host ends it) but not '/'
378 [/\b([a-z][a-z0-9+.-]{1,20}:\/\/[^:\/\s@"'<>]*:)[^\s\/"'<>]+@/gi, '$1[redacted:password]@'],
379 [/(?<![\w-])((?:-u|--user)(?:[ \t]+|=)["']?)(?!\[redacted)[^\s:"']{1,200}:[^\s"']+/g, '$1[redacted:credentials]'],
380 [/(?<![\w-])(--(?!no-)(?:[a-z0-9]+-)*(?:password|passwd|pass|passphrase|pwd|token|secret|api-key|apikey)(?:=|[ \t]+)["']?)(?!-|\[redacted)[^\s"']{4,}/gi, '$1[redacted:secret]'],
381 [new RegExp(String.raw`(\bsshpass[ \t]+-p[ \t]*)(?!\[redacted)` + FLAG_VALUE, 'g'), '$1[redacted:password]'],
382 [new RegExp(String.raw`(\bmysql(?:dump|admin|pump|sh)?\b[^\n]{0,200}?[ \t]-p)(?![\s\[])` + FLAG_VALUE, 'g'), '$1[redacted:password]'],
383 [new RegExp(String.raw`(\bredis-cli\b[^\n]{0,200}?[ \t]-a[ \t]+)(?!\[redacted)` + FLAG_VALUE, 'g'), '$1[redacted:password]'],
384 [/\b(bearer)(\s+)[A-Za-z0-9._~+\/=-]{16,}/gi, '$1$2[redacted:token]'],
385 [/\b(basic)(\s+)(?=[A-Za-z0-9+\/=]*\d)[A-Za-z0-9+\/]{16,}={0,2}/gi, '$1$2[redacted:token]'],
386 [/(\bauthorization["']?[ \t]*[:=][ \t]*["']?(?:(?:token|basic|bearer|digest|apikey|api-key|key)[ \t]+)?)(?!\[redacted|(?:token|basic|bearer|digest)[ \t])[^\s"',;}]{8,}/gi, '$1[redacted:secret]'],
387 [/\/mcp\/[A-Za-z0-9_-]{16,}/g, '/mcp/[redacted:secret-path]'],
388 [/\bmcp_[0-9a-f]{8,}/g, '[redacted:secret-path]'],
389 [/([?&](?:key|token|api_key|apikey|access_token|sig|secret)=)[^&\s"'<>]+/gi, '$1[redacted]'],
390 [/^([ \t]*(?:export\s+)?[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|PASS)[A-Z0-9_]*\s*=\s*).+$/gm, '$1[redacted:secret]'],
391 // name [: or =] value, the name anywhere in the word (DB_PASSWORD, "apiKey"); placeholders and short values stay
392 [/((?:secret[ _-]?access[ _-]?key|secret[ _-]?key|access[ _-]?key|api[ _-]?key|private[ _-]?key|client[ _-]?secret|secret|password|passwd|passphrase|token|credentials?)[A-Za-z0-9_-]{0,30}["']?[ \t]*[:=][ \t]*["']?)(?!\[redacted|[$<{%])[^\s"',;}\]]{8,}/gi, '$1[redacted:secret]'],
393 // a short value after an explicit password key (the rule above wants 8+); a type name or a call is left, and so is a
394 // path after `pwd` (the current directory in an environment listing)
395 [/((?:password|passwd)[A-Za-z0-9_-]{0,30}["']?[ \t]*[:=][ \t]*["']?|pwd["']?[ \t]*[:=][ \t]*["']?(?![\/~]))(?!\[redacted|[$<{%])(?!(?:none|null|nil|true|false|undefined|string|str|bool|boolean|int|integer|number|input|getpass|prompt|hidden|text|field|value|optional|required|default|self|args|env|os|config|process|form|request|user|data|dict|list|type)(?![\w]))(?![A-Za-z_][\w.]*[(\[])[^\s"',;}\]]{4,}/gi, '$1[redacted:secret]'],
396 // ~/.pgpass lines: host:port:database:user:password
397 [/^([^\s:#]+:(?:\d{1,5}|\*):[^\s:]+:[^\s:]+:)\S+$/gm, '$1[redacted:password]'],
398 // prose: "the password is hunter2"
399 [/\b((?:password|passwd|passphrase|passcode|secret|api[ _-]key|token)s?(?:[ \t]+for[ \t]+[\w.@-]{1,40})?[ \t]+(?:is|was|=)[ \t]*:?[ \t]*["'`]?)(?!\[redacted)(?!(?:the|an?|not|now|set|stored|required|wrong|correct|incorrect|invalid|valid|empty|missing|expired|different|same|here|in|on|for|to|used|sent|ok|fine|right|changed|reset|updated|saved|unchanged|supposed|needed|still|also|only|just|being|that|this|there)(?![\w-]))[^\s"'`,;]{6,}/gi, '$1[redacted:secret]'],
400 // last: a long token (40+: shorter ones were mostly identifiers in code) mixing upper, lower and digits, not hex-only (commit ids, UUIDs and paths pass)
401 [/(?<![A-Za-z0-9_+-])(?=[A-Za-z0-9_+-]*[a-z])(?=[A-Za-z0-9_+-]*[A-Z])(?=[A-Za-z0-9_+-]*\d)[A-Za-z0-9_+-]{40,}/g, '[redacted:secret]'],
402]
403
404// Pure. Never throws on a string.
405export function redact(input: string): string {
406 let s = String(input ?? '')
407 if (!s) return s
408 if (s.length > REDACT_SCAN_MAX) s = s.slice(0, REDACT_SCAN_MAX)
409 s = scrubPrivateKeys(s)
410 for (const [re, to] of REDACTIONS) { re.lastIndex = 0; s = s.replace(re, to) }
411 return s
412}
413
414// Every string inside a tool input, redacted (so a multi-line command meets the line patterns before JSON escapes it).
415function redactDeep(v: unknown, depth = 0): unknown {
416 if (typeof v === 'string') return redact(v)
417 if (depth > 6 || v === null || typeof v !== 'object') return v
418 if (Array.isArray(v)) return v.map(x => redactDeep(x, depth + 1))
419 const o: Record<string, unknown> = {}
420 for (const [k, x] of Object.entries(v as Record<string, unknown>)) o[k] = redactDeep(x, depth + 1)
421 return o
422}
423
424// Cuts a string already redacted, saying how much went.
425function capText(s: string, max: number): string {
426 return s.length <= max ? s : `${s.slice(0, max)}[…cut ${s.length - max} chars]`
427}
428
429// Second layer: a tool call that reads a secret-bearing source has its result withheld whole. The tool_use row
430// reaches the mirror before its result row, so the call is remembered by id (the last few hundred).
431// Best effort, not a guarantee: the common shapes only (files by name, commands that print an environment or a
432// credential, searches whose pattern or path names a secret). The twin of resonant_archive/secret_sources.py: change
433// both together, and keep the case lists in both test suites the same.
434const SECRET_FILE_SOURCE = String.raw`[/.](?:env(?:\.[\w-]{1,40}){0,4}|dev\.vars|mcp\.json|pem|key|netrc|pgpass|environ|p12|pfx|jks|keystore|ppk|kdbx|htpasswd)$|(?:^|[/\\])\.env[\w.~*?-]*$|(?:^|[/\\])\.(?:npmrc|pypirc|git-credentials|vault-token|bash_history|zsh_history)$|\.docker/config\.json$|\.kube/config$|(?:^|[/.\\])(?:credentials(?:\.json)?|id_[a-z0-9]+)$|(?:^|[/\\])secrets?\.[\w.*-]+$|terraform\.tfstate[\w.-]{0,40}$|\.tfvars(?:\.json)?$|/proc/[^/\s]+/(?:environ|cmdline)$|/etc/g?shadow$|(?:^|[/\\])[\w.-]{0,100}(?:service[-_]?account|application_default_credentials)[\w.-]{0,100}\.json$|(?:^|[/\\])client_secrets?\.json$|/private/`
435const SECRET_HINT_SOURCE = String.raw`pass(?:word|wd|phrase|code)|secret|token|api[_ -]?key|credential|private[_ -]?key|bearer|\.env\b|\benv\b|id_rsa|id_ed25519|\.pem\b|\.pfx|\.p12|\.npmrc|\.netrc|\.pgpass|BEGIN[ \w]{0,40}PRIVATE`
436const SECRET_PROGRAM_SOURCE = String.raw`env(?:\s|$)|\S*/env\s*$|\S*/env\s+-\S+|(?:\S*/)?(?:printenv|ps)(?:\s|$)|(?:\S*/)?pm2\s+(?:env|show|describe|prettylist|jlist)\b|set\s*$|(?:export|declare|typeset)(?:\s+-[A-Za-z]+)?\s*$|compgen\s+-[ve]\b|history(?:\s|$)|pgrep\s+-[A-Za-z]*[af]|(?:docker|podman)(?:\s+compose|-compose)?\s+(?:config|inspect)\b|(?:docker|podman)\s+(?:exec|run)\b[^\n]*\s(?:env|printenv)(?:\s|$)|kubectl\s+(?:(?:get|describe)\s+secrets?\b|config\s+view\b)|systemctl\s+(?:--?\S+\s+)*(?:show|cat)\b|ssh-add\s+-[A-Za-z]*[Ll]|gh\s+auth\s+(?:token\b|status\b[^\n]*--show-token)|gcloud\s+auth\s+(?:print-access-token|print-identity-token|application-default\s+print-access-token)|aws\s+(?:configure\s+(?:get|list|export-credentials)|secretsmanager\s+get-secret-value|ssm\s+get-parameters?(?:-by-path)?|sts\s+get-session-token)|vault\s+(?:read|kv\s+get|token\s+lookup)|op\s+(?:read|item\s+get|inject)`
437const SECRET_FILE = new RegExp(SECRET_FILE_SOURCE, 'i')
438const SECRET_HINT = new RegExp(SECRET_HINT_SOURCE, 'i')
439const SECRET_PROGRAM = new RegExp('^(?:' + SECRET_PROGRAM_SOURCE + ')')
440const SEARCH_COMMAND = new RegExp(String.raw`^(?:\S*/)?(?:grep|egrep|fgrep|zgrep|rg|ag|ack|find|locate|fd)(?:\s|$)`)
441// Prefixes that run the next command, peeled off one at a time (sudo is tried with and without an option argument).
442const PEELERS: readonly RegExp[] = [
443 new RegExp(String.raw`^[A-Za-z_]\w*=(?:'[^']*'|\"[^\"]*\"|\S*)\s+`),
444 new RegExp(String.raw`^(?:exec|time|command|builtin|nohup|setsid)\s+(?:-\S+\s+)*`),
445 new RegExp(String.raw`^(?:sudo|doas)\s+(?:-\S+\s+)*`),
446 new RegExp(String.raw`^(?:sudo|doas)\s+(?:-\S+\s+)*\S+\s+`),
447 new RegExp(String.raw`^xargs\s+(?:-\S+\s+)*`),
448 new RegExp(String.raw`^(?:nice|ionice|stdbuf|timeout)\s+(?:-\S+\s+(?:\d+\s+)?)*(?:\d+[smhd]?\s+)?`),
449]
450const SHELL_C = new RegExp(String.raw`^(?:\S*/)?(?:ba|z|da|k|fi|a|c)?sh\s+(?:-\S+\s+)*?-[A-Za-z]*c\s+(.+)$`)
451const SEGMENTS = /[;&|\n]+|\$\(|`/
452const TOKENS = /[\s'"`;&|()<>=]+/
453const ENV_BIN = /\/bin\/env$/
454const ENV_READ = /\b(?:os\.environ|os\.getenv|process\.env|ENV\[)/
455const PATH_KEYS = ['file_path', 'path', 'notebook_path']
456const SEARCH_KEYS = ['pattern', 'path', 'glob', 'include', 'query']
457const ASSIGN_VALUE = /^[A-Za-z_]\w*=/
458const WITHHELD = '[withheld: secret-bearing source]'
459const INPUT_WITHHELD = '[input withheld: secret-bearing source]'
460const ROW_TEXT_MAX = 64_000
461const ROW_RESULTS_MAX = 20
462const RESULT_CHARS = 2000
463const TOOL_INPUT_CHARS = 300
464const NOTE_COUNT_MAX_CHARS = 4000
465const secretUses = new Map<string, true>()
466// Recall output quotes the archive back: mirrored, every recall would find the last recall first (seen in live
467// testing). Its call is remembered by id like a secret one, and its output is replaced by RECALL_RESULT_NOTE.
468const RECALL_RESULT_NOTE = '[recall output: not mirrored, it quotes the archive]'
469// The head of every recall output (search, plain read, around, nothing found, errors), for rows mirrored before the
470// fix: recall leaves them out of its own results.
471const RECALL_OUTPUT_HEAD = /^recall(?: failed)?: |^recall (?:around [A-Za-z0-9-]{8,64} \(session |.*?\((?:this session|all sessions)[^)\n]*\): )/
472const recallUses = new Map<string, true>()
473
474// A loop, not a regex ending in `$`: a run of 400,000 quotes followed by one more character made that regex quadratic.
475function trimSegment(seg: string): string {
476 const t = seg.trim().replace(/^[({\s]+/, '')
477 let e = t.length
478 while (e > 0 && /[)}"'\s]/.test(t[e - 1]!)) e--
479 return t.slice(0, e)
480}
481
482// The quotes and spaces around a `sh -c` body, with a loop for the same reason: `/["'\s]+$/` was quadratic on a long run
483// of quotes inside the body (2 s at 50,000, 40 s at 200,000).
484export function trimQuotes(s: string): string {
485 const t = s.replace(/^["'\s]+/, '')
486 let e = t.length
487 while (e > 0 && /["'\s]/.test(t[e - 1]!)) e--
488 return t.slice(0, e)
489}
490
491function segmentSecret(segment: string, depth = 0): boolean {
492 const seg = trimSegment(segment)
493 if (!seg || depth > 5) return false
494 if (SECRET_PROGRAM.test(seg)) return true
495 for (const rx of PEELERS) {
496 const m = rx.exec(seg)
497 if (m && segmentSecret(seg.slice(m[0].length), depth + 1)) return true
498 }
499 const m = SHELL_C.exec(seg)
500 if (m) {
501 const inner = trimQuotes(m[1]!)
502 return inner.split(SEGMENTS).some(x => segmentSecret(x, depth + 1))
503 }
504 return false
505}
506
507// True when a tool call reads a secret-bearing file, prints the environment or a credential, or searches for a secret by
508// name. Best effort (see above).
509export function secretBearing(name: unknown, input: unknown): boolean {
510 const o = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
511 for (const k of PATH_KEYS) {
512 const v = o[k]
513 if (typeof v === 'string' && SECRET_FILE.test(v.trim())) return true
514 }
515 if (typeof name === 'string' && ['grep', 'glob'].includes(name.toLowerCase())) {
516 for (const k of SEARCH_KEYS) {
517 const v = o[k]
518 if (typeof v === 'string' && (SECRET_HINT.test(v) || SECRET_FILE.test(v.trim()))) return true
519 }
520 }
521 const cmd = typeof o.command === 'string' ? o.command : ''
522 if (cmd) {
523 if (ENV_READ.test(cmd)) return true
524 for (const seg of cmd.split(SEGMENTS)) {
525 if (segmentSecret(seg)) return true
526 const t = trimSegment(seg)
527 if (SEARCH_COMMAND.test(t) && SECRET_HINT.test(t)) return true
528 }
529 for (const tok of cmd.split(TOKENS)) if (tok && !ENV_BIN.test(tok) && SECRET_FILE.test(tok)) return true
530 }
531 return false
532}
533
534// What is kept of a secret-bearing call's input: the file it names, or for a command only the program (and the names of
535// any secret files it mentions); never arguments, content, new_string, old_string or a heredoc body.
536export function secretCallSummary(_name: unknown, input: unknown): string {
537 const o = (input && typeof input === 'object' ? input : {}) as Record<string, unknown>
538 for (const k of PATH_KEYS) {
539 const v = o[k]
540 if (typeof v === 'string' && v.trim()) return v.trim().slice(0, 300)
541 }
542 const cmd = typeof o.command === 'string' ? o.command : ''
543 if (cmd) {
544 const first = (cmd.trimStart().split(SEGMENTS)[0] ?? '').split(/[<>]/)[0]!.trim()
545 const words = first.split(/\s+/).filter(w => w && !ASSIGN_VALUE.test(w))
546 const prog = (words[0] ?? '').slice(0, 80)
547 const files = cmd.split(TOKENS).filter(t => t && !ENV_BIN.test(t) && SECRET_FILE.test(t))
548 let out = prog + (words.length > 1 || cmd.trim().length > prog.length ? ' [arguments withheld]' : '')
549 if (files.length) out += ' ' + [...new Set(files.slice(0, 5).map(f => f.slice(0, 200)))].join(' ')
550 return out.trim() || INPUT_WITHHELD
551 }
552 return INPUT_WITHHELD
553}
554
555export function blockText(content: unknown): { text: string; tools: string[]; results: string[] } {
556 const text: string[] = [], tools: string[] = [], results: string[] = []
557 let extra = 0
558 for (const b of (Array.isArray(content) ? content : []) as any[]) {
559 if (b?.type === 'text' && b.text) text.push(String(b.text))
560 else if (b?.type === 'tool_use') {
561 const secret = secretBearing(b.name, b.input)
562 if (secret && typeof b.id === 'string') {
563 secretUses.set(b.id, true)
564 if (secretUses.size > 512) secretUses.delete(secretUses.keys().next().value as string)
565 }
566 if (recallNames.includes(String(b.name)) && typeof b.id === 'string') {
567 recallUses.set(b.id, true)
568 if (recallUses.size > 256) recallUses.delete(recallUses.keys().next().value as string)
569 }
570 tools.push(secret
571 ? `${b.name}(${redact(secretCallSummary(b.name, b.input)).slice(0, TOOL_INPUT_CHARS)})`
572 : `${b.name}(${redact(JSON.stringify(redactDeep(b.input ?? {}))).slice(0, TOOL_INPUT_CHARS)})`)
573 } else if (b?.type === 'tool_result') {
574 if (results.length >= ROW_RESULTS_MAX) { extra++; continue }
575 if (typeof b.tool_use_id === 'string' && recallUses.has(b.tool_use_id)) { results.push(RECALL_RESULT_NOTE); continue }
576 if (typeof b.tool_use_id === 'string' && secretUses.has(b.tool_use_id)) { results.push(WITHHELD); continue }
577 const c = typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter((x: any) => x?.type === 'text').map((x: any) => x.text).join('\n') : ''
578 results.push(redact(String(c)).slice(0, RESULT_CHARS))
579 }
580 }
581 if (extra) results.push(`[… ${extra} more results not kept]`)
582 return { text: capText(redact(text.join('\n')), ROW_TEXT_MAX), tools, results }
583}
584
585const REMINDER_CLOSE = '</system-reminder>'
586const QUEUED_HEAD = /^The user sent a new message while you were working:[ \t]*\n/
587const QUEUED_NOTE = 'This is how Claude Code surfaces messages the user sends mid-turn'
588
589// The person's words inside a prompt folded into a running turn (an attachment row named queued_command), from the
590// engine's rendering. `user` false: the wrapper was not recognised, so the rendering is kept whole (never lost).
591// No regex here runs to `$` over the person's words: `\s*<\/system-reminder>\s*$` and the lazy body before the engine's
592// note were both quadratic on a long run of spaces or newlines (2 s and 4 s at 50,000).
593export function queuedPrompt(rendered: string): { text: string; user: boolean } {
594 let s = String(rendered ?? '').replace(/^\s*<system-reminder>\s*/, '')
595 const end = s.trimEnd()
596 if (end.endsWith(REMINDER_CLOSE)) s = end.slice(0, -REMINDER_CLOSE.length).trimEnd()
597 const head = QUEUED_HEAD.exec(s)
598 if (!head) return { text: s.trim(), user: false }
599 const body = s.slice(head[0].length)
600 return { text: body.slice(0, queuedNoteStart(body)).trim(), user: true }
601}
602
603// Where the engine's note starts: the first newline of the whitespace run before the first occurrence of the note that
604// has a newline in that run (the end of the body when there is none). Each run is walked once, so this is linear.
605function queuedNoteStart(body: string): number {
606 for (let q = body.indexOf(QUEUED_NOTE); q !== -1; q = body.indexOf(QUEUED_NOTE, q + 1)) {
607 let b = q
608 while (b > 0 && /\s/.test(body[b - 1]!)) b--
609 const nl = body.indexOf('\n', b)
610 if (nl !== -1 && nl < q) return nl
611 }
612 return body.length
613}
614
615// Records one stored row. Called after next(): must never throw (the caller also wraps it).
616function mirror($: EngineInterface, e: { door: string; origin: unknown }, stored: any, t: number, rowSid: string) {
617 if (cfgCache && !cfgCache.mirrorEnabled) return
618 if (!stored || stored.deny !== undefined) return
619 const m = stored.message
620 if (!m) return
621 // A prompt the person types while the assistant is working is stored as an attachment row: their words, so they are kept.
622 const queued = m.type === 'attachment' && m.name === 'queued_command'
623 if (m.type !== 'user' && m.type !== 'assistant' && !queued) return
624 const bt = blockText(m.content)
625 const { tools, results } = bt
626 let text = bt.text
627 let queuedAs: string | undefined
628 if (queued) {
629 const q = queuedPrompt(text)
630 text = q.text
631 queuedAs = q.user ? 'prompt' : 'other'
632 }
633 if (!text && !tools.length && !results.length) return
634 // The engine's local-command caveat is boilerplate, not conversation.
635 if (m.isMeta && !tools.length && !results.length && text.trimStart().startsWith('<local-command-caveat>')) return
636 if (e.door === 'command' && /^<command-name>\/(?:exit|quit)\b/.test(text.trimStart())) { exitingSid = rowSid; return }
637 if (exitingSid !== undefined && exitingSid === rowSid) return // the farewell and anything after /exit
638 // The memories block and its acknowledgement, as an assembled compaction put them back, and a memory fork if one
639 // ever surfaced as a row: tagged, so the memory chunks and the coverage check skip them (one rule for both).
640 const chron = queued ? undefined : synthKind(m.type, text)
641 const row = {
642 t, sid: rowSid, uuid: stored.uuid, door: e.door, chronicle: chron, queued: queuedAs,
643 origin: (e.origin as { kind?: string } | undefined)?.kind, role: queued ? 'user' : (m.role ?? m.type), meta: m.isMeta ? true : undefined,
644 text: (chron === 'memories' ? blockNote(text) : chron === 'fork' ? '[a memory formation prompt]' : text) || undefined,
645 tools: tools.length ? tools : undefined, results: results.length ? results : undefined,
646 }
647 push({ day: dayOf(t), sid: rowSid, line: JSON.stringify(row) + '\n' })
648 if (typeof stored.uuid === 'string' && !m.isMeta && !results.length && text && (queued ? queuedAs === 'prompt' : e.door === 'prompt')) {
649 if (turnPrompts.size >= TURN_PROMPTS_MAX) turnPrompts.clear()
650 turnPrompts.add(stored.uuid)
651 }
652 if (bufChars >= FLUSH_AT_CHARS && !sizeFlushQueued) { sizeFlushQueued = true; flushSoon($) }
653 ensureTimer($, false)
654 // Local-command output (/context and the like, door note) is counted at most NOTE_COUNT_MAX_CHARS: a chunk is about
655 // the conversation, not a table the engine printed.
656 if (memoriesOn) try { noteRow(row, e.door === 'note' ? Math.min(rowChars(m.content), NOTE_COUNT_MAX_CHARS) : rowChars(m.content)) } catch {}
657}
658
659// ---------- split compaction ----------
660
661// A user message with words in it and no tool results. Injected text-only rows (a clock receipt, a scribe
662// whisper) also pass this, which is why splitPoint adds its own pairing and turn-boundary checks.
663function isPrompt(m: SessionMessage): boolean {
664 return m.role === 'user' && !(m.toolResults && m.toolResults.length) && String(m.text ?? '').trim().length > 0
665}
666
667// Rough token estimate (~3.6 characters per token for our mix of prose and code).
668// A tool use counts its input; its outcome counts once, on the result row, or on the use when no result row exists.
669function estimate(m: SessionMessage, resultAt: Map<string, number>): number {
670 let n = String(m.text ?? '').length
671 for (const u of m.toolUses ?? []) {
672 n += JSON.stringify(u.input ?? {}).length
673 if (!resultAt.has(u.tool_use_id)) n += String(u.text ?? '').length
674 }
675 for (const r of m.toolResults ?? []) n += String(r.text ?? '').length
676 return Math.ceil(n / 3.6)
677}
678
679// Where the raw tail starts, or -1. A cut at i is valid only when:
680// (a) msgs[i] is a prompt-shaped user message,
681// (b) no tool_use before i has its tool_result at or after i (nothing orphaned either side),
682// (c) msgs[i-1] is an assistant message with no tool uses (a real turn boundary).
683// Picks the smallest valid tail of at least `target` tokens; if that would pass `cap`, the largest valid tail under it.
684export function splitPoint(msgs: readonly SessionMessage[], target: number, cap: number): number {
685 const resultAt = new Map<string, number>()
686 msgs.forEach((m, i) => { for (const r of m.toolResults ?? []) resultAt.set(r.tool_use_id, i) })
687
688 // open[i] > 0 when some pair straddles a cut at i (use at j < i, result at k >= i). Difference array over [j+1, k].
689 const diff = new Int32Array(msgs.length + 2)
690 msgs.forEach((m, j) => {
691 for (const u of m.toolUses ?? []) {
692 const k = resultAt.get(u.tool_use_id)
693 if (k !== undefined && k > j) { diff[j + 1]!++; diff[k + 1]!-- }
694 }
695 })
696 const open = new Int32Array(msgs.length + 1)
697 for (let i = 0, s = 0; i <= msgs.length; i++) { s += diff[i]!; open[i] = s }
698
699 const valid = (i: number) => {
700 const prev = msgs[i - 1]
701 return isPrompt(msgs[i]!) && open[i] === 0 && !!prev && prev.role === 'assistant' && !(prev.toolUses && prev.toolUses.length)
702 }
703
704 let acc = 0
705 let best = -1
706 for (let i = msgs.length - 1; i >= 1; i--) {
707 acc += estimate(msgs[i]!, resultAt)
708 if (acc > cap) break
709 if (valid(i)) {
710 best = i
711 if (acc >= target) return i
712 }
713 }
714 return best
715}
716
717// ---------- recall ----------
718// A summary alone loses things; searching back into the record finds them.
719// Archive layout recall reads (the mirror format, documented in README.md):
720// <archiveRoot>/<YYYY-MM-DD in the archive's zone>/<sid>.jsonl rows, plus event lines (compaction carries `summary`)
721// <archiveRoot>/<YYYY-MM-DD in the archive's zone>/<sid>.memories.jsonl memories (step 2): {t, event:"memory", level, sid, text, ...}
722// A line is classified by its content, never by its file name, so a memory line in either file is found.
723
724type RecallInput = {
725 query?: unknown; from?: unknown; to?: unknown; session?: unknown; kind?: unknown; limit?: unknown; around?: unknown
726 // source archive
727 source?: unknown; action?: unknown; id?: unknown; memory?: unknown; collection?: unknown; speaker?: unknown; context?: unknown
728 hidden?: unknown; mode?: unknown; before?: unknown; after?: unknown; k?: unknown; recent?: unknown; memories?: unknown
729}
730type Kind = 'row' | 'summary' | 'memory'
731type Item = { kind: Kind; t: number; sid: string; uuid?: string; label: string; parts: { tag: string; s: string }[] }
732type Bound = { day?: string; ms?: number }
733
734const RECALL_OUT_CAP = 12_000
735const RECALL_SNIPPET = 400
736const RECALL_MAX_CANDIDATES = 3000
737const RECALL_FILES_PER_RUN = 300
738const RECALL_RUN_MS = 20_000
739// The recall tool as the model sees it. Built from the settings at session start: with no archive command only the
740// session fields are offered (and the model is told so); with one, the archive fields and actions are added.
741export function recallSpec(hasArchive: boolean): { description: string; inputSchema: Record<string, unknown> } {
742 const sessionProps = {
743 query: { type: 'string', description: 'Words to look for, in any order; double quotes for an exact phrase.' },
744 from: { type: 'string', description: 'Start, YYYY-MM-DD (a day in the archive\'s time zone) or an ISO time.' },
745 to: { type: 'string', description: 'End, inclusive, YYYY-MM-DD or an ISO time.' },
746 session: { type: 'string', enum: ['current', 'all'], description: 'Only this session, or all (default all).' },
747 kind: { type: 'string', enum: ['rows', 'memories', 'summaries', 'all'], description: 'What to search (default all).' },
748 limit: { type: 'number', description: 'Hits to return (default 12, max 40). With around: rows each side.' },
749 around: { type: 'string', description: 'A row uuid from an earlier hit: shows the rows around it.' },
750 }
751 const sessionText = 'Search the record of this and earlier sessions kept on disk by chronicle: every row, compaction summaries and memories, newest first. Arguments: query (words, any order; double quotes for an exact phrase), from/to (YYYY-MM-DD or ISO time), session (current or all), kind (rows, memories, summaries or all), limit, around=<row uuid> (a stretch of rows around an earlier hit). Use it to bring back the exact wording of something a compaction summary lost.'
752 if (!hasArchive) {
753 return {
754 description: `${sessionText} No archive of older conversations is set up, so this searches the session record only.`,
755 inputSchema: { type: 'object', properties: sessionProps },
756 }
757 }
758 return {
759 description: `Search the record of my past conversations. Two sources.\n\nsource archive (the default): the archive of every conversation imported into it, by meaning as well as by words. action:\n- search (default): query is required. Hybrid of full text, meaning (vectors) and exact anchors (paths, urls, ids, quoted phrases), fused by rank. Each hit gives its id, source, time, conversation title, speaker, which signals found it, and a snippet. mode narrows to one signal: fts, vector or anchor.\n- around: id = a hit's id. Reads that conversation around it (before/after: messages each side, default 4).\n- overview: from and to (YYYY-MM-DD) required. What a stretch held: counts by source and speaker, the busiest conversations, and chronicle memories in range if the archive holds any.\n- drill: memory = a #index from overview (or a memory's from_uuid). The archive messages that memory was written from.\n- associate: id = a hit's id. Its nearest kin by meaning in other conversations.\nFilters for search (and associate): collection (source labels, comma-separated), speaker (user, agent, assistant, peer, system, tool), context (free tags), from/to as YYYY-MM-DD; limit (default 10, max 40). Tool and system rows are left out unless hidden is true. The archive may leave out the most recent hours of this live conversation, which is already in context, unless recent is true; its footer says how many.\n\nAsking about time: search ranks by meaning and words, never by date, so for "when", "first" or "last time" questions set from/to. First: search an early window, read the hit dates, then narrow the window until the earliest real match stays put; never call the top hit the first. Last: a window ending today. A stretch you can't place: overview over from/to.\n\nsource session: ${sessionText} Use it for what happened minutes ago, which the archive may not have loaded yet.`,
760 inputSchema: {
761 type: 'object',
762 properties: {
763 source: { type: 'string', enum: ['archive', 'session'], description: 'archive (default): the whole archive. session: the record of this chronicle mirror.' },
764 action: { type: 'string', enum: ['search', 'around', 'overview', 'drill', 'associate'], description: 'archive only. Default search.' },
765 ...sessionProps,
766 query: { type: 'string', description: 'search: words or a phrase. session source: keywords (double quotes for an exact phrase).' },
767 id: { type: 'string', description: 'around / associate: a message id from an archive hit.' },
768 memory: { type: 'string', description: 'drill: a memory #index from overview, or its from_uuid.' },
769 collection: { type: 'string', description: 'archive: comma-separated source labels to search (as the archive lists them).' },
770 speaker: { type: 'string', description: 'archive search: comma-separated: user, agent, assistant, peer, system, tool.' },
771 context: { type: 'string', description: 'archive search: comma-separated context tags.' },
772 hidden: { type: 'boolean', description: 'archive: include tool/system rows (default false).' },
773 mode: { type: 'string', enum: ['hybrid', 'fts', 'vector', 'anchor'], description: 'archive search: one signal only (default hybrid).' },
774 limit: { type: 'number', description: 'Hits to return (archive default 10, session default 12; max 40). session with around: rows each side.' },
775 before: { type: 'number', description: 'archive around: messages before (default 4).' },
776 after: { type: 'number', description: 'archive around: messages after (default 4).' },
777 k: { type: 'number', description: 'archive associate: neighbours (default 8, max 20).' },
778 recent: { type: 'boolean', description: 'archive search/associate: include the most recent hours too (default false).' },
779 memories: { type: 'string', enum: ['include', 'only', 'none'], description: 'archive overview: list memories (include, default), only them, or none.' },
780 around: { type: 'string', description: 'session source: a row uuid from an earlier hit. (archive: same as id with action around.)' },
781 },
782 },
783 }
784}
785
786let recallWarned = false
787
788function warnOnce($: EngineInterface, line: string) {
789 try { $.ui.log(`chronicle: ${line}`, { to: 'debug' }) } catch {}
790 if (recallWarned) return
791 recallWarned = true
792 try { $.ui.log(`chronicle: ${line}`) } catch {}
793}
794
795function flat(s: string): string {
796 return s.replace(/\s+/g, ' ').trim()
797}
798
799// The text of a JSON string value as it sits inside a JSON line (quotes, backslashes, control characters escaped).
800// JSON escapes one character at a time, so a substring of the text is a substring of its escaped form: rg on this is a superset.
801export function jsonEscaped(s: string): string {
802 return JSON.stringify(s).slice(1, -1)
803}
804
805function zoneParts(ms: number): Record<string, string> {
806 const p = new Intl.DateTimeFormat('en-GB', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }).formatToParts(new Date(ms))
807 const o: Record<string, string> = {}
808 for (const x of p) o[x.type] = x.value
809 return o
810}
811
812function stampOf(ms: number): string {
813 if (!ms) return 'undated'
814 const o = zoneParts(ms)
815 return `${o.year}-${o.month}-${o.day} ${o.hour}:${o.minute}`
816}
817
818// The zone's wall-clock time minus UTC at this instant, in ms.
819function zoneOffset(ms: number): number {
820 const o = zoneParts(ms)
821 return Date.UTC(+o.year!, +o.month! - 1, +o.day!, +o.hour!, +o.minute!, +o.second!) - (ms - (((ms % 1000) + 1000) % 1000))
822}
823
824// A bound from YYYY-MM-DD (a whole day in the archive's zone, matched on the folder) or an ISO time (in that zone when it names no offset).
825export function parseBound(raw: unknown): Bound | string | undefined {
826 if (raw === undefined || raw === null || String(raw).trim() === '') return undefined
827 const s = String(raw).trim()
828 if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return { day: s }
829 const iso = s.replace(' ', 'T')
830 let ms: number
831 if (/(Z|[+-]\d{2}:?\d{2})$/i.test(iso)) ms = Date.parse(iso)
832 else {
833 const asUtc = Date.parse(`${iso}Z`)
834 ms = asUtc
835 if (Number.isFinite(asUtc)) {
836 ms = asUtc - zoneOffset(asUtc)
837 ms = asUtc - zoneOffset(ms)
838 }
839 }
840 if (!Number.isFinite(ms)) return `could not read the date "${s.slice(0, 40)}" (use YYYY-MM-DD or an ISO time)`
841 return { day: dayOf(ms), ms }
842}
843
844function rowLabel(o: any): string {
845 const role = String(o.role ?? '')
846 const door = String(o.door ?? '')
847 const origin = o.origin ? String(o.origin) : ''
848 if (role === 'assistant') return labels.agent
849 if (o.queued === 'prompt') return `${labels.user} (mid-turn)`
850 if (o.queued) return 'queued message'
851 if (door === 'prompt' && (origin === 'composer' || origin === 'bridge' || origin === '')) return labels.user
852 if (door === 'tool-result') return 'tool result'
853 if (door === 'tool-message') return 'tool message'
854 return `${role || 'row'} ${door}${origin ? ` (${origin})` : ''}${o.meta ? ' meta' : ''}`
855}
856
857// One archive line, or undefined for a line recall does not show (a mirror_dropped note, a cut or broken line).
858export function toItem(line: string): Item | undefined {
859 let o: any
860 try { o = JSON.parse(line) } catch { return undefined }
861 if (!o || typeof o !== 'object') return undefined
862 const t = Number(o.t) || 0
863 const sid = String(o.sid ?? o.session ?? '')
864 if (o.event === 'compaction') {
865 if (typeof o.summary !== 'string' || !o.summary) return undefined
866 return { kind: 'summary', t, sid, label: `compaction summary (${o.trigger ?? 'compaction'}; ${o.summarized ?? '?'} rows summarized, ${o.kept_raw ?? '?'} kept raw)`, parts: [{ tag: '', s: o.summary }] }
867 }
868 if (o.event === 'memory') {
869 if (typeof o.text !== 'string' || !o.text) return undefined
870 const span = o.t_from && o.t_to ? `, ${stampOf(Number(o.t_from))} to ${stampOf(Number(o.t_to))}` : ''
871 return { kind: 'memory', t, sid, label: `L${o.level ?? 1} memory${span}`, parts: [{ tag: '', s: o.text }] }
872 }
873 if (o.event !== undefined || typeof o.uuid !== 'string') return undefined
874 const parts: { tag: string; s: string }[] = []
875 if (typeof o.text === 'string' && o.text) parts.push({ tag: '', s: o.text })
876 // The assistant's own recall calls and their output are not searched or shown: they only echo the archive (the person's prompt asking
877 // for a recall stays, those are their words).
878 for (const x of Array.isArray(o.tools) ? o.tools : []) { const s = String(x); if (recallNames.some(n => s.startsWith(`${n}(`))) continue; parts.push({ tag: '[tool] ', s }) }
879 for (const x of Array.isArray(o.results) ? o.results : []) { const s = String(x); if (s === RECALL_RESULT_NOTE || RECALL_OUTPUT_HEAD.test(s)) continue; parts.push({ tag: '[result] ', s }) }
880 if (!parts.length) return undefined
881 return { kind: 'row', t, sid, uuid: o.uuid, label: rowLabel(o), parts }
882}
883
884// Words to look for, and the phrase when the query is quoted. Words are deduped, at most 12.
885export function parseQuery(raw: unknown): { words: string[]; phrase?: string } {
886 let q = flat(String(raw ?? ''))
887 let quoted = false
888 if (q.length >= 2 && q.startsWith('"') && q.endsWith('"')) { q = flat(q.slice(1, -1)); quoted = true }
889 // A word is cut at 200 characters: rg takes each as an argument, and a huge one fails the whole search.
890 const words = [...new Set(q.toLowerCase().split(' ').filter(Boolean).map(w => w.slice(0, 200)))].slice(0, 12)
891 return { words, phrase: quoted && q ? q.toLowerCase() : undefined }
892}
893
894// How well an item matches: 0 = not a hit. A quoted phrase must appear whole; otherwise one point per word present,
895// plus one when the whole query appears as written. Also says where to cut the snippet.
896export function scoreItem(it: Item, words: string[], phrase: string | undefined): { score: number; part: number; at: number; len: number; matched: number } {
897 const flats = it.parts.map(p => flat(p.s).toLowerCase())
898 const find = (needle: string): { part: number; at: number } | undefined => {
899 for (let i = 0; i < flats.length; i++) { const at = flats[i]!.indexOf(needle); if (at >= 0) return { part: i, at } }
900 return undefined
901 }
902 if (phrase) {
903 const hit = find(phrase)
904 return hit ? { score: words.length + 1, ...hit, len: phrase.length, matched: words.length } : { score: 0, part: 0, at: 0, len: 0, matched: 0 }
905 }
906 let matched = 0
907 let first: { part: number; at: number; len: number } | undefined
908 for (const w of words) {
909 const hit = find(w)
910 if (hit) { matched++; if (!first) first = { ...hit, len: w.length } }
911 }
912 if (!matched) return { score: 0, part: 0, at: 0, len: 0, matched: 0 }
913 const whole = words.length > 1 ? find(words.join(' ')) : undefined
914 const anchor = whole ? { ...whole, len: words.join(' ').length } : first!
915 return { score: matched + (whole ? 1 : 0), ...anchor, matched }
916}
917
918function snippet(it: Item, part: number, at: number, len: number, width: number): string {
919 const p = it.parts[part] ?? it.parts[0]!
920 const s = flat(p.s)
921 if (s.length <= width) return p.tag + s
922 const start = Math.max(0, Math.min(at - Math.floor((width - len) / 3), s.length - width))
923 return `${p.tag}${start > 0 ? '…' : ''}${s.slice(start, start + width)}${start + width < s.length ? '…' : ''}`
924}
925
926function wholeText(it: Item, width: number): string {
927 const s = it.parts.map(p => p.tag + flat(p.s)).join(' | ')
928 return s.length <= width ? s : `${s.slice(0, width)}…`
929}
930
931function hitHeader(it: Item, showSid: boolean): string {
932 const where = showSid && it.sid ? ` · s:${it.sid.slice(0, 8)}` : ''
933 return `• ${stampOf(it.t)} · ${it.label}${it.uuid ? ` · uuid ${it.uuid}` : ''}${where}`
934}
935
936// ripgrep, at tools.rgPath. A spawn that fails (not installed, not executable) becomes one clear message instead of a
937// stack of engine text; any other failure passes through.
938function rgHelp(): string {
939 return `ripgrep ("${rgBin}") was not found or could not run. Install ripgrep, or set tools.rgPath in chronicle.json to its full path.`
940}
941
942class RgMissing extends Error {}
943
944async function rgRun($: EngineInterface, args: string[], timeoutMs: number) {
945 let r
946 try { r = await $.process.run([rgBin, ...args], { timeoutMs }) } catch (err) {
947 if (/ENOENT|not found|no such file|spawn|EACCES|permission denied/i.test(why(err))) throw new RgMissing(rgHelp())
948 throw err
949 }
950 if (r.exitCode === 127) throw new RgMissing(rgHelp())
951 return r
952}
953
954// Lists <root>/<day>/*.jsonl as { day, name, path }. Missing archive: an empty list and `missing`.
955async function archiveFiles($: EngineInterface, root: string): Promise<{ files: { day: string; name: string; path: string }[]; missing?: boolean; error?: string }> {
956 let r
957 try { r = await rgRun($, ['--files', '--no-config', '--no-ignore', '--hidden', '--max-depth', '2', '-g', '*.jsonl', root], RECALL_RUN_MS) } catch (err) {
958 if (err instanceof RgMissing) return { files: [], error: err.message }
959 throw err
960 }
961 if (r.exitCode !== 0 && !r.stdout.trim()) {
962 if (/No such file|not found|os error 2/i.test(r.stderr)) return { files: [], missing: true }
963 if (r.exitCode === 1) return { files: [] }
964 return { files: [], error: r.stderr.trim().split('\n')[0]?.slice(0, 200) || `exit ${r.exitCode}` }
965 }
966 const files: { day: string; name: string; path: string }[] = []
967 for (const path of r.stdout.split('\n')) {
968 if (!path.startsWith(`${root}/`)) continue
969 const rel = path.slice(root.length + 1).split('/')
970 if (rel.length !== 2) continue
971 files.push({ day: rel[0]!, name: rel[1]!, path })
972 }
973 return { files }
974}
975
976// Lines of the given files that hold any pattern (fixed strings, case-insensitive); every line when no pattern.
977async function grepLines($: EngineInterface, patterns: string[], paths: string[]): Promise<{ lines: string[]; cut: boolean; error?: string }> {
978 const argv = ['--no-config', '--no-ignore', '--hidden', '--fixed-strings', '--ignore-case', '--no-filename', '--no-line-number', '--no-heading', '--color', 'never']
979 if (patterns.length) for (const p of patterns) argv.push('-e', p)
980 else argv.push('-e', '')
981 argv.push('--', ...paths)
982 const r = await rgRun($, argv, RECALL_RUN_MS)
983 const error = r.exitCode > 1 ? (r.stderr.trim().split('\n')[0]?.slice(0, 200) || `exit ${r.exitCode}`) : undefined
984 return { lines: r.stdout.split('\n').filter(Boolean), cut: r.isStdoutTruncated, error }
985}
986
987async function currentSid($: EngineInterface): Promise<string> {
988 if (!sid) sid = String(await $.session.id())
989 return sid
990}
991
992// The whole recall. Returns the text the assistant reads. Throws only on an engine failure; the tool hook catches that.
993async function recall($: EngineInterface, input: RecallInput): Promise<string> {
994 const cfg = await cfgOf($)
995 const root = cfg.archiveRoot.replace(/\/+$/, '')
996 if (!cfg.mirrorEnabled) return 'recall: the mirror is switched off (mirror.enabled is false in chronicle.json), so there is no session record to search.'
997 if (!root) return 'recall: no archive folder (archiveRoot starts with ~ and HOME is not set).'
998 // Rows from the last few seconds are still in the buffer: write them first so they can be found.
999 try { await scheduleFlush($, 5000) } catch {}
1000
1001 const listed = await archiveFiles($, root)
1002 if (listed.missing) return `The mirror at ${root} does not exist yet: nothing has been mirrored there.`
1003 if (listed.error) warnOnce($, `recall could not list the mirror (${listed.error})`)
1004 if (!listed.files.length) return listed.error ? `recall could not list the mirror: ${listed.error}` : `The mirror at ${root} is empty so far.`
1005
1006 const lim = Math.floor(Number(input.limit))
1007 const limitGiven = Number.isFinite(lim) && lim > 0
1008
1009 if (input.around !== undefined && String(input.around).trim()) {
1010 return recallAround($, listed.files, String(input.around).trim(), limitGiven ? Math.min(lim, 40) : 6)
1011 }
1012
1013 const limit = limitGiven ? Math.min(lim, 40) : 12
1014 const from = parseBound(input.from)
1015 const to = parseBound(input.to)
1016 if (typeof from === 'string') return `recall: from: ${from}`
1017 if (typeof to === 'string') return `recall: to: ${to}`
1018 const kindIn = String(input.kind ?? 'all')
1019 const kinds: Kind[] = kindIn === 'rows' ? ['row'] : kindIn === 'memories' ? ['memory'] : kindIn === 'summaries' ? ['summary'] : ['row', 'summary', 'memory']
1020 const onlySid = input.session === 'current' ? await currentSid($) : undefined
1021 const { words, phrase } = parseQuery(input.query)
1022 const ranked = words.length > 0
1023
1024 // Files in range. Folders are days in the archive's zone; `undated` only when no bound is given.
1025 const isDay = (d: string) => /^\d{4}-\d{2}-\d{2}$/.test(d)
1026 let files = listed.files.filter(f => {
1027 if (!isDay(f.day)) return f.day === 'undated' && !from && !to
1028 if (from?.day && f.day < from.day) return false
1029 if (to?.day && f.day > to.day) return false
1030 if (onlySid && !f.name.startsWith(`${safe(onlySid)}.`)) return false
1031 return true
1032 })
1033 // A query or no `from`: newest days first (so a cap drops the oldest). A plain read from `from`: oldest first.
1034 const ascending = !ranked && !!from
1035 files.sort((a, b) => (a.day === b.day ? a.name.localeCompare(b.name) : a.day < b.day ? -1 : 1) * (ascending ? 1 : -1))
1036
1037 const patterns = words.map(jsonEscaped)
1038 const needed = ranked ? RECALL_MAX_CANDIDATES : limit
1039 const items: { it: Item; score: number; part: number; at: number; len: number; matched: number }[] = []
1040 const seen = new Set<string>()
1041 const notes: string[] = []
1042 let stoppedAt: string | undefined
1043 // Batches never split a day, so a plain read can stop at a day boundary with its order intact.
1044 let i = 0
1045 while (i < files.length) {
1046 if (items.length >= needed) { stoppedAt = files[i]!.day; break }
1047 const batch: string[] = []
1048 const day0 = files[i]!.day
1049 while (i < files.length && (batch.length < RECALL_FILES_PER_RUN || files[i]!.day === files[i - 1]?.day) && (ranked || files[i]!.day === day0)) batch.push(files[i++]!.path)
1050 const g = await grepLines($, patterns, batch)
1051 if (g.error && !notes.some(n => n.startsWith('search error'))) notes.push(`search error: ${g.error}`)
1052 if (g.cut) notes.push('one search returned more than 4 MB and was cut; narrow the query or the dates')
1053 for (const line of g.lines) {
1054 const it = toItem(line)
1055 if (!it || !kinds.includes(it.kind)) continue
1056 if (it.uuid && turnPrompts.has(it.uuid)) continue // this turn's own request (see turnPrompts)
1057 if (onlySid && it.sid !== onlySid) continue
1058 if (from?.ms !== undefined && it.t < from.ms) continue
1059 if (to?.ms !== undefined && it.t > to.ms) continue
1060 const key = it.uuid ?? `${it.kind}:${it.t}:${it.sid}`
1061 if (seen.has(key)) continue
1062 if (ranked) {
1063 const s = scoreItem(it, words, phrase)
1064 if (!s.score) continue
1065 seen.add(key)
1066 items.push({ it, ...s })
1067 } else {
1068 seen.add(key)
1069 items.push({ it, score: 0, part: 0, at: 0, len: 0, matched: 0 })
1070 }
1071 }
1072 }
1073
1074 let shown: typeof items
1075 if (ranked) shown = [...items].sort((a, b) => b.score - a.score || b.it.t - a.it.t)
1076 else {
1077 const byTime = [...items].sort((a, b) => a.it.t - b.it.t)
1078 shown = ascending ? byTime.slice(0, limit) : byTime.slice(-limit)
1079 }
1080 const total = shown.length
1081 shown = shown.slice(0, limit)
1082
1083 const scope = [
1084 onlySid ? 'this session' : 'all sessions',
1085 from || to ? `${input.from ? String(input.from) : '…'} to ${input.to ? String(input.to) : 'now'}` : '',
1086 kindIn !== 'all' ? kindIn : '',
1087 ].filter(Boolean).join(', ')
1088 if (!total) {
1089 const what = ranked ? `nothing matched ${phrase ? `"${phrase}"` : words.join(' ')}` : 'nothing in that range'
1090 return [`recall (${scope}): ${what}.`, ...notes, stoppedAt ? `Search stopped before ${stoppedAt}.` : ''].filter(Boolean).join('\n')
1091 }
1092
1093 const showSid = !onlySid
1094 const head = ranked
1095 ? `recall ${phrase ? `"${phrase}"` : words.join(' ')} (${scope}): ${total > limit ? `top ${shown.length} of ${total}${stoppedAt ? '+' : ''}` : `${total}`} hit${total === 1 ? '' : 's'}, most words matched first, then newest. around=<uuid> opens a stretch.`
1096 : `recall (${scope}): ${shown.length} ${ascending ? 'earliest' : 'latest'} entries, in time order. around=<uuid> opens a stretch.`
1097 const out: string[] = [head]
1098 let used = head.length
1099 let omitted = 0
1100 for (const h of shown) {
1101 const partial = ranked && !phrase && h.matched < words.length ? ` (matched ${h.matched}/${words.length} words)` : ''
1102 const body = ranked ? snippet(h.it, h.part, h.at, h.len, RECALL_SNIPPET) : snippet(h.it, 0, 0, 0, RECALL_SNIPPET)
1103 const block = `${hitHeader(h.it, showSid)}${partial}\n ${body}`
1104 if (used + block.length + 1 > RECALL_OUT_CAP) { omitted++; continue }
1105 out.push(block)
1106 used += block.length + 1
1107 }
1108 if (omitted) notes.push(`truncated: ${omitted} more hit${omitted === 1 ? '' : 's'} did not fit in ${RECALL_OUT_CAP} characters; lower limit or narrow the query`)
1109 if (stoppedAt) {
1110 if (ranked) notes.push(`stopped after ${RECALL_MAX_CANDIDATES} matches; ${stoppedAt} and earlier days were not searched (use from/to)`)
1111 else if (ascending) notes.push(`more follows from ${stoppedAt} on: call again with from set to the last time shown`)
1112 else notes.push(`earlier days (${stoppedAt} and before) not read: set to, or from, to go back`)
1113 }
1114 if (ranked && total > limit) notes.push(`${total - shown.length} lower-ranked hits not shown (raise limit up to 40, or narrow)`)
1115 return [...out, ...notes.map(n => `[${n}]`)].join('\n')
1116}
1117
1118// A row with `n` rows each side, from its session file(s) across days, in order.
1119async function recallAround($: EngineInterface, files: { day: string; name: string; path: string }[], uuid: string, n: number): Promise<string> {
1120 if (!/^[A-Za-z0-9-]{8,64}$/.test(uuid)) return `recall: around wants a row uuid from an earlier hit, not "${uuid.slice(0, 80)}".`
1121 const sessionFiles = files.filter(f => !f.name.endsWith('.memories.jsonl'))
1122 const found = await grepLines($, [`"uuid":"${uuid}"`], sessionFiles.map(f => f.path))
1123 const target = found.lines.map(toItem).find(x => x?.uuid === uuid)
1124 if (!target) return `recall: no row with uuid ${uuid} in the mirror${found.error ? ` (search error: ${found.error})` : ''}.`
1125 const own = sessionFiles.filter(f => f.name === `${safe(target.sid || 'unknown')}.jsonl`).sort((a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0))
1126 if (!own.length) return `recall: found ${uuid} but not its session file.`
1127 const r = await $.process.run(['cat', '--', ...own.map(f => f.path)], { timeoutMs: RECALL_RUN_MS })
1128 const readNote = r.isStdoutTruncated ? '[the session file was larger than one read allows: rows past that point are not shown here; narrow with from/to]' : r.exitCode !== 0 ? `[the session file was not read whole (exit ${r.exitCode})]` : ''
1129 const seq: Item[] = []
1130 const seen = new Set<string>()
1131 for (const line of r.stdout.split('\n')) {
1132 if (!line) continue
1133 const it = toItem(line)
1134 if (!it || it.kind === 'memory') continue
1135 const key = it.uuid ?? `${it.kind}:${it.t}`
1136 if (seen.has(key)) continue
1137 seen.add(key)
1138 seq.push(it)
1139 }
1140 const at = seq.findIndex(x => x.uuid === uuid)
1141 if (at < 0) return `recall: found ${uuid} but could not read it back from its session file.${readNote ? ` ${readNote}` : ''}`
1142 const lo = Math.max(0, at - n)
1143 const hi = Math.min(seq.length, at + n + 1)
1144 const width = Math.max(200, Math.min(1200, Math.floor(10_000 / (hi - lo))))
1145 const blocks: string[] = []
1146 for (let k = lo; k < hi; k++) {
1147 const it = seq[k]!
1148 if (it.kind === 'summary') { blocks.push(`— ${stampOf(it.t)} · compaction here —`); continue }
1149 const mark = k === at ? '▶ ' : ''
1150 blocks.push(`${mark}${hitHeader(it, false).slice(2)}\n ${wholeText(it, k === at ? Math.max(width, 3000) : width)}`)
1151 }
1152 // Keep the target: trim from the far ends first when over the cap (the header and the note are budgeted too).
1153 const note = `[trimmed to fit ${RECALL_OUT_CAP} characters; ask again with a smaller limit for wider rows]`
1154 const mid = at - lo
1155 let first = 0, last = blocks.length - 1
1156 const size = () => 160 + note.length + blocks.slice(first, last + 1).reduce((s, b) => s + b.length + 1, 0)
1157 let trimmed = false
1158 while (size() > RECALL_OUT_CAP && (first < mid || last > mid)) {
1159 if (mid - first >= last - mid) first++; else last--
1160 trimmed = true
1161 }
1162 const a = lo + first, b = lo + last + 1
1163 const head = `recall around ${uuid} (session ${target.sid.slice(0, 8)}, rows ${a + 1}-${b} of ${seq.length}${a === 0 ? ', from its start' : ''}${b === seq.length ? ', to its end' : ''}):`
1164 const out = [head, ...blocks.slice(first, last + 1)]
1165 if (trimmed) out.push(note)
1166 if (readNote) out.push(readNote)
1167 return out.join('\n')
1168}
1169
1170// ---------- recall: the optional archive ----------
1171// With archive.command set (for example ["resonant-archive", "recall"]), source "archive" runs
1172// <command...> --json <action> [options] [-- <positional>]
1173// and returns the "text" of the JSON it prints (README.md, "Archive contract"). This side builds the argv and passes
1174// the output through; the command checks every value itself (sources, dates, ranges): a bad one comes back as its own
1175// message. The mod holds no database settings and no credentials: the command reads its own.
1176
1177function argStr(v: unknown): string | undefined {
1178 if (v === undefined || v === null) return undefined
1179 const s = (Array.isArray(v) ? v.map(String).join(',') : String(v)).trim()
1180 return s || undefined
1181}
1182
1183function argInt(v: unknown): string | undefined {
1184 if (v === undefined || v === null || String(v).trim() === '') return undefined
1185 const n = Number(v)
1186 return Number.isFinite(n) ? String(Math.floor(n)) : String(v).trim()
1187}
1188
1189// The argv after the command and --json, or a message for the assistant when the call cannot be made.
1190export function archiveArgv(input: RecallInput): string[] | string {
1191 const around = argStr(input.around)
1192 const action = argStr(input.action) ?? (around ? 'around' : 'search')
1193 const id = argStr(input.id) ?? around
1194 const opt = (flag: string, v: string | undefined, a: string[]) => { if (v !== undefined) a.push(flag, v) }
1195 const a: string[] = []
1196 switch (action) {
1197 case 'search': {
1198 const q = argStr(input.query)
1199 if (!q) return 'recall: search needs a query. For a stretch of time with no query, use action overview with from and to.'
1200 a.push('search')