@staix/agent-mem 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,557 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { looksSecret } from "./screen.ts";
6
+ import { isDenied } from "./platform.ts";
7
+ import { memoryBase } from "./home.ts";
8
+ import { captureShardDir, ensureShardDescriptor, resolveAlias } from "./shard.ts";
9
+ import { machineId } from "./store.ts";
10
+ import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
11
+
12
+ /**
13
+ * #13 design A, tier 2, born sharded (#15 T1): machine-local capture, one shard per project key. A shard lives at
14
+ * `projects/<key-hash>/machine/<machine-id>/` under the memory home (D1's shard root, the scope table's machine
15
+ * level), has its own locks, so sessions in two projects never contend, and is never synced and never read as
16
+ * curated evidence. Deterministic extraction only (R4: no model call, no network): an event, a tool, paths, an
17
+ * exit status and a bounded, secret-screened excerpt per row. No code path here touches the curated logs, so
18
+ * curated notes, goals and work lists are never evicted or rewritten by capture maintenance (AC5).
19
+ *
20
+ * R6 bounds: a soft size cap and a retention window (defaults 512 MB per shard, 90 days), resolved at every
21
+ * invocation from `CaptureOptions`, then the shard's `config.json`, then the home default `capture-config.json`
22
+ * the capacity advisor (R7) writes. The 2026-10-17 owner decision keeps the P1 cap per shard; the machine-wide
23
+ * total is #15 T5. Eviction removes the oldest capture rows first, in bounded batches per invocation, so shrinking
24
+ * a cap finishes over later invocations instead of breaking the hook budget (R2).
25
+ * ponytail: the cap counts row payload bytes, not the sqlite file's page slack or WAL; a VACUUM blocks writers and
26
+ * stays out of the hook path, so file size can exceed the cap until pages are reused.
27
+ *
28
+ * R2 contention: a writer that cannot take its shard's lock within `busyTimeoutMs` closes a private segment
29
+ * before publishing it atomically in `spool/`. The next uncontended writer (or explicit `drain()`) replays it
30
+ * under a unique claim name. Segments are immutable; a claim's byte offset advances only after the rows commit.
31
+ * Replay is idempotent per spooled line, so a crash between insert and acknowledgement never doubles a row.
32
+ *
33
+ * Migration: the pre-T1 machine-wide `capture/` (this branch never shipped) is drained per project on open —
34
+ * each project key's rows move into its shard with their original timestamps, and the legacy store is removed
35
+ * once empty.
36
+ */
37
+
38
+ export const CAPTURE_DEFAULT_CAP_MB = 512;
39
+ export const CAPTURE_DEFAULT_RETENTION_DAYS = 90;
40
+ /** Well under the 150 ms R2 wall budget even with process startup: contention spools instead of waiting. */
41
+ export const CAPTURE_BUSY_TIMEOUT_MS = 25;
42
+ export const CAPTURE_MAX_EXCERPT = 2000;
43
+ export const CAPTURE_MAX_PATHS = 32;
44
+ /** Eviction and drain work per invocation, so a shrunk cap or a long outage is worked off over many hooks. */
45
+ export const CAPTURE_EVICT_BATCH_ROWS = 256;
46
+ export const CAPTURE_EVICT_MAX_BATCHES = 4;
47
+ /** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
48
+ export const CAPTURE_EVICT_MAX_MS = 25;
49
+ export const CAPTURE_DRAIN_MAX_LINES = 1000;
50
+ const ROW_OVERHEAD_BYTES = 96;
51
+ const SPOOL = "spool.jsonl";
52
+ const DRAINING = "spool.jsonl.draining";
53
+ const SPOOL_DIR = "spool";
54
+ const SPOOL_MAX_FILES = 32;
55
+ const SPOOL_MAX_BYTES = 256 * 1024;
56
+ const SPOOL_MAX_MS = 25;
57
+ const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.draining-[0-9a-f]+)?$/;
58
+ let spoolSequence = 0;
59
+ const spoolToken = () => `${Date.now().toString(16).padStart(13, "0")}-${process.pid.toString(16).padStart(8, "0")}-${(spoolSequence++).toString(16).padStart(8, "0")}-${randomBytes(12).toString("hex")}`;
60
+
61
+ export interface CaptureInput {
62
+ project: string; agent: string; event: string;
63
+ tool?: string; paths?: string[]; status?: string; excerpt?: string;
64
+ /** Tests and the spool replay pin the time; hooks leave it to the store clock. */
65
+ at?: string;
66
+ }
67
+ export interface CaptureRow {
68
+ id: number; at: string; project: string; agent: string; event: string;
69
+ tool: string | null; paths: string[]; status: string | null; excerpt: string;
70
+ }
71
+ export interface CaptureConfig { capMb: number; retentionDays: number }
72
+ export interface CaptureOptions {
73
+ memoryDir?: string;
74
+ home?: string;
75
+ /** The project key this shard belongs to; aliases resolve to the canonical key (#15 D2). */
76
+ project: string;
77
+ /** Defaults to this machine's `machine.json` identity, shared with the curated store. */
78
+ machine?: string;
79
+ now?: () => number;
80
+ /** Host-supplied R6 values win over the shard and home config files. */
81
+ capMb?: number; retentionDays?: number;
82
+ busyTimeoutMs?: number;
83
+ /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
84
+ screen?: (text: string) => boolean;
85
+ }
86
+ export type CaptureAppend = { id: number } | { spooled: true };
87
+
88
+ /** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
89
+ interface NormalizedCapture {
90
+ at: string; project: string; agent: string; event: string;
91
+ tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
92
+ /** Entropy per spooled line, so two byte-identical hook events are two rows; a replayed line still dedupes. */
93
+ nonce?: string;
94
+ }
95
+
96
+ const configValues = (parsed: Record<string, unknown>): Partial<CaptureConfig> => {
97
+ const capMb = Number(parsed.cap_mb), retentionDays = Number(parsed.retention_days);
98
+ return {
99
+ ...(Number.isFinite(capMb) && capMb > 0 ? { capMb } : {}),
100
+ ...(Number.isFinite(retentionDays) && retentionDays > 0 ? { retentionDays } : {}),
101
+ };
102
+ };
103
+
104
+ /** The R6 settings one config file holds; absent or invalid keys are simply not set. */
105
+ export function readCaptureConfigFile(path: string): Partial<CaptureConfig> {
106
+ try { return configValues(JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>); } catch { return {}; }
107
+ }
108
+
109
+ /** Merge `config` into one config file; only given keys change. The person (or R7's apply) writes these, never an agent. */
110
+ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConfig>): Partial<CaptureConfig> {
111
+ const next = { ...readCaptureConfigFile(path) };
112
+ if (config.capMb !== undefined) {
113
+ if (!Number.isFinite(config.capMb) || config.capMb <= 0) throw new Error("capture.cap_mb must be a positive number");
114
+ next.capMb = config.capMb;
115
+ }
116
+ if (config.retentionDays !== undefined) {
117
+ if (!Number.isFinite(config.retentionDays) || config.retentionDays <= 0) throw new Error("capture.retention_days must be a positive number");
118
+ next.retentionDays = config.retentionDays;
119
+ }
120
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
121
+ writeFileSync(path, JSON.stringify({ cap_mb: next.capMb ?? CAPTURE_DEFAULT_CAP_MB, retention_days: next.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS }) + "\n", { mode: 0o600 });
122
+ return next;
123
+ }
124
+
125
+ /** The effective R6 settings for a shard directory: shard config over the home default over the built-in defaults. */
126
+ export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
127
+ const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
128
+ const shard = readCaptureConfigFile(join(dir, "config.json"));
129
+ return {
130
+ capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
131
+ retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
132
+ };
133
+ }
134
+
135
+ /** Adjust a shard's own R6 bounds (an override of the home default); merged over that shard's current file. */
136
+ export function writeCaptureConfig(dir: string, config: Partial<CaptureConfig>): CaptureConfig {
137
+ const next = writeCaptureConfigFile(join(dir, "config.json"), config);
138
+ return { capMb: next.capMb ?? CAPTURE_DEFAULT_CAP_MB, retentionDays: next.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS };
139
+ }
140
+
141
+ const lockError = (error: unknown): boolean => /lock|busy/i.test((error as Error)?.message ?? "");
142
+
143
+ interface RawRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; bytes: number }
144
+ const toRow = (r: RawRow): CaptureRow => ({ id: r.id, at: r.at, project: r.project, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt });
145
+
146
+ export class CaptureStore {
147
+ readonly dir: string;
148
+ /** The canonical project key of this shard (aliases resolved). */
149
+ readonly key: string;
150
+ /** Rows the pre-T1 machine-wide capture store handed this shard on open. */
151
+ readonly migrated: number;
152
+ private readonly base: string;
153
+ private readonly requestedKey: string;
154
+ private readonly db: Database;
155
+ private readonly now: () => number;
156
+ private readonly screen: (text: string) => boolean;
157
+ private readonly capMb: number;
158
+ private readonly retentionDays: number;
159
+ /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
160
+ private contended = false;
161
+
162
+ constructor(opts: CaptureOptions) {
163
+ if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
164
+ if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
165
+ this.base = opts.memoryDir ?? memoryBase(opts.home);
166
+ const machine = opts.machine ?? machineId(this.base).id;
167
+ this.requestedKey = opts.project;
168
+ this.key = resolveAlias(this.base, opts.project);
169
+ ensureShardDescriptor(this.base, this.key);
170
+ this.dir = captureShardDir(this.base, opts.project, machine);
171
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
172
+ chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
173
+ const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
174
+ this.capMb = opts.capMb ?? config.capMb;
175
+ this.retentionDays = opts.retentionDays ?? config.retentionDays;
176
+ this.now = opts.now ?? Date.now;
177
+ this.screen = opts.screen ?? looksSecret;
178
+ const file = join(this.dir, "capture.db");
179
+ const db = new Database(file, { create: true });
180
+ this.db = db;
181
+ try { chmodSync(file, 0o600); } catch { /* best effort */ }
182
+ try {
183
+ db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? CAPTURE_BUSY_TIMEOUT_MS}`);
184
+ db.run("PRAGMA journal_mode = WAL");
185
+ db.run(`CREATE TABLE IF NOT EXISTS rows (id INTEGER PRIMARY KEY AUTOINCREMENT, at TEXT NOT NULL, project TEXT NOT NULL, agent TEXT NOT NULL, event TEXT NOT NULL,
186
+ tool TEXT, paths TEXT NOT NULL, status TEXT, excerpt TEXT NOT NULL, bytes INTEGER NOT NULL, spool_id TEXT)`);
187
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS rows_spool ON rows (spool_id) WHERE spool_id IS NOT NULL");
188
+ db.run("CREATE INDEX IF NOT EXISTS rows_at ON rows (at)");
189
+ db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
190
+ this.ensureFts();
191
+ } catch (error) {
192
+ if (!lockError(error)) { db.close(); throw error; }
193
+ this.contended = true; // R2: the hook spools and exits instead of waiting out another writer
194
+ }
195
+ this.migrated = this.contended ? 0 : this.migrateLegacy();
196
+ }
197
+
198
+ /** The pre-T1 machine-wide store hands this project key's rows AND spool lines to the shard, once; an emptied legacy store goes away. */
199
+ private migrateLegacy(): number {
200
+ const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
201
+ const file = join(legacyDir, "capture.db");
202
+ if (!existsSync(file)) return 0;
203
+ const homeDefault = join(this.base, "capture-config.json");
204
+ if (existsSync(join(legacyDir, "config.json")) && !existsSync(homeDefault)) renameSync(join(legacyDir, "config.json"), homeDefault);
205
+ let legacy: Database | undefined;
206
+ let moved = 0;
207
+ try {
208
+ legacy = new Database(file);
209
+ legacy.run(`PRAGMA busy_timeout = ${CAPTURE_BUSY_TIMEOUT_MS}`); // never stall a hook on another project's migration
210
+ const rows = legacy.query("SELECT * FROM rows WHERE project = ? OR project = ? ORDER BY id").all(this.key, this.requestedKey) as RawRow[];
211
+ // `legacy:<id>` spool ids make the move idempotent: a crash between the two writes replays nothing.
212
+ this.db.transaction(() => {
213
+ for (const r of rows) {
214
+ const row: NormalizedCapture = { at: r.at, project: this.key, agent: r.agent, event: r.event, tool: r.tool, status: r.status,
215
+ paths: JSON.parse(r.paths) as string[], excerpt: r.excerpt, bytes: r.bytes };
216
+ if (this.insertRow(row, `legacy:${r.id}`) !== undefined) moved++;
217
+ }
218
+ }).immediate();
219
+ legacy.transaction(() => { for (const r of rows) legacy!.query("DELETE FROM rows WHERE id = ?").run(r.id); }).immediate();
220
+ // Legacy spool lines for this key move to the shard spool (they drain on the next append); other keys' lines stay.
221
+ let spoolLeft = 0;
222
+ for (const name of ["spool.jsonl", "spool.jsonl.draining"]) {
223
+ const legacySpool = join(legacyDir, name);
224
+ if (!existsSync(legacySpool)) continue;
225
+ const lines = readFileSync(legacySpool, "utf8").split("\n").filter(Boolean);
226
+ const keep: string[] = [];
227
+ for (const line of lines) {
228
+ let project = "";
229
+ try { project = String((JSON.parse(line) as { project?: unknown }).project ?? ""); } catch { /* unparseable: keep */ }
230
+ if (project === this.key || project === this.requestedKey) this.spoolRaw(line);
231
+ else keep.push(line);
232
+ }
233
+ if (keep.length) { writeFileSync(legacySpool, keep.join("\n") + "\n", { mode: 0o600 }); spoolLeft += keep.length; }
234
+ else rmSync(legacySpool, { force: true });
235
+ }
236
+ if ((legacy.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n === 0 && spoolLeft === 0) {
237
+ legacy.close();
238
+ legacy = undefined;
239
+ for (const name of ["capture.db", "capture.db-wal", "capture.db-shm"]) rmSync(join(legacyDir, name), { force: true });
240
+ try { rmSync(legacyDir, { recursive: true }); } catch { /* a leftover file keeps the directory */ }
241
+ }
242
+ } catch { return moved; } // locked or unreadable: the next open of this project retries
243
+ finally { legacy?.close(); }
244
+ return moved;
245
+ }
246
+
247
+ /** A line moved from the legacy spool, byte for byte: the shard's drain normalizes whatever format it holds. */
248
+ private spoolRaw(line: string): void {
249
+ const directory = join(this.dir, SPOOL_DIR), writing = join(directory, ".writing");
250
+ mkdirSync(writing, { recursive: true, mode: 0o700 });
251
+ const name = `spool-${spoolToken()}.jsonl`, temporary = join(writing, name);
252
+ try {
253
+ writeFileSync(temporary, line + "\n", { mode: 0o600, flag: "wx" }); // closes before readers can see it
254
+ renameSync(temporary, join(directory, name));
255
+ } finally { rmSync(temporary, { force: true }); }
256
+ }
257
+
258
+ /** External-content FTS5 over the excerpts, kept in sync by triggers, so capture search needs no catch-up pass. */
259
+ private ensureFts(): void {
260
+ if (!fts5Available()) return; // the R5 fallback: capture rows are stored, search skips this tier
261
+ const had = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get();
262
+ this.db.run("CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(body, content='rows', content_rowid='id')");
263
+ this.db.run("CREATE TRIGGER IF NOT EXISTS rows_ai AFTER INSERT ON rows BEGIN INSERT INTO fts (rowid, body) VALUES (new.id, new.excerpt); END");
264
+ this.db.run("CREATE TRIGGER IF NOT EXISTS rows_ad AFTER DELETE ON rows BEGIN INSERT INTO fts (fts, rowid, body) VALUES ('delete', old.id, old.excerpt); END");
265
+ this.db.run("CREATE TRIGGER IF NOT EXISTS rows_au AFTER UPDATE ON rows BEGIN INSERT INTO fts (fts, rowid, body) VALUES ('delete', old.id, old.excerpt); INSERT INTO fts (rowid, body) VALUES (new.id, new.excerpt); END");
266
+ // Rows written before FTS existed (or before a platform gained it) are indexed once; triggers keep it level.
267
+ if (!had && (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n > 0) this.db.run("INSERT INTO fts (fts) VALUES ('rebuild')");
268
+ }
269
+
270
+ private mcpReady = false;
271
+ /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
272
+ private ensureMcpFts(): boolean {
273
+ if (!fts5Available()) return false;
274
+ if (this.mcpReady) return true;
275
+ try {
276
+ this.db.transaction(() => {
277
+ const hadMcp = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
278
+ const body = (row: string) => `${row}.excerpt || char(10) || COALESCE((SELECT group_concat(value, ' ') FROM json_each(${row}.paths)), '') || char(10) || ${row}.event`;
279
+ this.db.run("CREATE VIRTUAL TABLE IF NOT EXISTS mcp_fts USING fts5(body)");
280
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS rows_mcp_ai AFTER INSERT ON rows BEGIN INSERT INTO mcp_fts(rowid, body) VALUES (new.id, ${body("new")}); END`);
281
+ this.db.run("CREATE TRIGGER IF NOT EXISTS rows_mcp_ad AFTER DELETE ON rows BEGIN DELETE FROM mcp_fts WHERE rowid = old.id; END");
282
+ this.db.run(`CREATE TRIGGER IF NOT EXISTS rows_mcp_au AFTER UPDATE ON rows BEGIN DELETE FROM mcp_fts WHERE rowid = old.id; INSERT INTO mcp_fts(rowid, body) VALUES (new.id, ${body("new")}); END`);
283
+ if (!hadMcp) this.db.run(`INSERT INTO mcp_fts(rowid, body) SELECT rows.id, ${body("rows")} FROM rows`);
284
+ }).immediate();
285
+ this.mcpReady = true;
286
+ return true;
287
+ } catch {
288
+ // Missing/unavailable derived search is not a capture read failure. Retry on the next independent read.
289
+ return false;
290
+ }
291
+ }
292
+
293
+ private bytes(): number {
294
+ return Number((this.db.query("SELECT value FROM meta WHERE key = 'bytes'").get() as { value: string } | null)?.value ?? "0");
295
+ }
296
+ private addBytes(delta: number): void {
297
+ this.db.query("INSERT INTO meta (key, value) VALUES ('bytes', ?) ON CONFLICT(key) DO UPDATE SET value = CAST(value AS INTEGER) + ?").run(String(Math.max(0, delta)), delta);
298
+ }
299
+
300
+ /**
301
+ * One capture row. Validation, deny-filtering, bounding and screening happen once, BEFORE anything is written:
302
+ * the spool holds only the screened row (never raw input, so no secret or PII excerpt ever reaches spool.jsonl),
303
+ * and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
304
+ * and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
305
+ */
306
+ append(input: CaptureInput): CaptureAppend {
307
+ const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
308
+ if (!str(input.project, 400) || !str(input.agent, 64) || !str(input.event, 64)) throw new Error("capture rows need a project, an agent and an event");
309
+ if (input.at !== undefined && (typeof input.at !== "string" || !Number.isFinite(Date.parse(input.at)))) throw new Error("capture row time must be a parseable time string");
310
+ const row = this.normalize(input);
311
+ if (this.contended) {
312
+ this.spool(row);
313
+ return { spooled: true };
314
+ }
315
+ try {
316
+ this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
317
+ // The row and its running byte count commit or roll back together: a lock after the INSERT cannot spool a duplicate.
318
+ const id = this.db.transaction(() => this.insertRow(row, null)).immediate();
319
+ try { this.evict(); } catch (error) {
320
+ if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
321
+ }
322
+ return { id: id! };
323
+ } catch (error) {
324
+ if (!lockError(error)) throw error;
325
+ this.spool(row);
326
+ return { spooled: true };
327
+ }
328
+ }
329
+
330
+ /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
331
+ private normalize(input: CaptureInput): NormalizedCapture {
332
+ const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
333
+ // Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
334
+ const at = new Date(input.at === undefined ? this.now() : Date.parse(input.at)).toISOString();
335
+ const tool = str(input.tool, 64) ? input.tool! : null;
336
+ const status = str(input.status, 64) ? input.status! : null;
337
+ const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
338
+ let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
339
+ if (excerpt && this.screen(excerpt)) excerpt = "[secret-screened]";
340
+ return { at, project: input.project, agent: input.agent, event: input.event, tool, paths, status, excerpt,
341
+ bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
342
+ }
343
+
344
+ private spool(row: NormalizedCapture): void {
345
+ this.spoolRaw(JSON.stringify({ ...row, nonce: randomBytes(12).toString("hex") }));
346
+ }
347
+
348
+ /**
349
+ * Immutable segments with unique claims: a stale drainer can remove only its own old name. Recovery may
350
+ * steal a live claim; both readers can replay it safely because row ids dedupe and neither mutates its inode.
351
+ * Offset acknowledgement is another rename, after commit, so a crash always leaves replayable evidence.
352
+ * ponytail: unpublished files left in .writing by a killed writer are not replayed (they may be partial).
353
+ * Quiescent maintenance may remove them; durable recovery of an interrupted append needs a journal/fsync.
354
+ * Legacy fixed names are accepted for upgrade, but old-version writers must stop before upgrading: their
355
+ * already-open shared inode cannot be made immutable by a newer reader.
356
+ */
357
+ drain(): { drained: number; skipped: number } {
358
+ let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
359
+ if (this.contended) return { drained, skipped };
360
+ const started = performance.now();
361
+ const directory = join(this.dir, SPOOL_DIR);
362
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
363
+ const candidates: { file: string; name: string; offset: number; skip: boolean }[] = [];
364
+ // Fixed-name imports are only compatibility inputs; all acknowledgements use unique names thereafter.
365
+ for (const name of [DRAINING, SPOOL]) {
366
+ if (existsSync(join(this.dir, name))) candidates.push({ file: join(this.dir, name), name: `spool-${spoolToken()}.jsonl`, offset: 0, skip: false });
367
+ }
368
+ const entries = opendirSync(directory);
369
+ try {
370
+ for (let scanned = 0; scanned < 256 && candidates.length < SPOOL_MAX_FILES; scanned++) {
371
+ const entry = entries.readSync();
372
+ if (!entry) break;
373
+ const match = segmentName.exec(entry.name);
374
+ if (entry.isFile() && match) candidates.push({ file: join(directory, entry.name), name: entry.name.split(".offset-")[0]!.split(".draining-")[0]!,
375
+ offset: Number(match[1] ?? 0), skip: !!match[2] });
376
+ }
377
+ } finally { entries.closeSync(); }
378
+ const validRow = (row: Partial<NormalizedCapture>): row is NormalizedCapture =>
379
+ typeof row.at === "string" && row.at.length > 0 && typeof row.project === "string" && row.project.length > 0
380
+ && typeof row.agent === "string" && row.agent.length > 0 && typeof row.event === "string" && row.event.length > 0
381
+ && (row.tool === null || typeof row.tool === "string") && Array.isArray(row.paths) && row.paths.every((p) => typeof p === "string")
382
+ && (row.status === null || typeof row.status === "string") && typeof row.excerpt === "string"
383
+ && typeof row.bytes === "number" && Number.isFinite(row.bytes) && row.bytes >= 0;
384
+ candidates.sort((a, b) => a.name.localeCompare(b.name));
385
+ for (const source of candidates) {
386
+ if (budget <= 0 || byteBudget <= 0 || performance.now() - started >= SPOOL_MAX_MS) break;
387
+ const file = join(directory, `${source.name}.offset-${source.offset}${source.skip ? "-skip" : ""}.draining-${randomBytes(12).toString("hex")}`);
388
+ try { renameSync(source.file, file); } catch { continue; } // another drainer claimed this exact name
389
+ let bytes: Buffer, size: number;
390
+ try {
391
+ const fd = openSync(file, "r");
392
+ try {
393
+ size = statSync(file).size;
394
+ bytes = Buffer.alloc(Math.min(byteBudget, Math.max(0, size - source.offset)));
395
+ bytes = bytes.subarray(0, readSync(fd, bytes, 0, bytes.length, source.offset));
396
+ } finally { closeSync(fd); }
397
+ } catch { continue; } // a recovery reader may already have moved our unique claim
398
+ byteBudget -= bytes.length;
399
+ let consumed = 0, skipping = source.skip;
400
+ const batch: string[] = [];
401
+ while (consumed < bytes.length && batch.length < budget) {
402
+ const newline = bytes.indexOf(10, consumed);
403
+ if (newline < 0) {
404
+ if (source.offset + bytes.length < size) {
405
+ // A short window may only be the allowance left after another file, not an oversized row.
406
+ if (consumed === 0 && bytes.length === SPOOL_MAX_BYTES) { consumed = bytes.length; skipping = true; }
407
+ break;
408
+ }
409
+ if (!skipping) batch.push(bytes.subarray(consumed).toString("utf8"));
410
+ else skipped++;
411
+ consumed = bytes.length; skipping = false; break;
412
+ }
413
+ const line = bytes.subarray(consumed, newline).toString("utf8");
414
+ consumed = newline + 1;
415
+ if (skipping) { skipped++; skipping = false; }
416
+ else if (line) batch.push(line);
417
+ }
418
+ try {
419
+ this.db.transaction(() => {
420
+ for (const line of batch) {
421
+ let raw: Partial<NormalizedCapture> & { bytes?: number };
422
+ try { raw = JSON.parse(line) as typeof raw; } catch { skipped++; continue; }
423
+ let candidate: NormalizedCapture;
424
+ try {
425
+ // Spool lines written since the screened-spool rule are already bounded and screened; older lines
426
+ // (this branch's pre-release format) pass through normalize once.
427
+ candidate = typeof raw.bytes === "number" && typeof raw.excerpt === "string" ? raw as NormalizedCapture : this.normalize(raw as CaptureInput);
428
+ } catch { skipped++; continue; }
429
+ if (!validRow(candidate)) { skipped++; continue; }
430
+ const added = this.insertRow(candidate, createHash("sha256").update(line).digest("hex")) !== undefined;
431
+ if (added) drained++;
432
+ }
433
+ }).immediate();
434
+ } catch (error) {
435
+ if (!lockError(error)) throw error;
436
+ break; // still contended: the spool stays for the next invocation
437
+ }
438
+ budget -= batch.length;
439
+ const next = source.offset + consumed;
440
+ if (next >= size) rmSync(file, { force: true });
441
+ else {
442
+ // Never rewrite a shared inode. Only the owner of this exact claim can acknowledge its prefix.
443
+ try { renameSync(file, join(directory, `${source.name}.offset-${next}${skipping ? "-skip" : ""}`)); } catch { /* recovered elsewhere */ }
444
+ }
445
+ }
446
+ return { drained, skipped };
447
+ }
448
+
449
+ /** The row insert shared by append, drain and the legacy migration; with a spool id the insert is idempotent. */
450
+ private insertRow(row: NormalizedCapture, spoolId: string | null): number | undefined {
451
+ // bun:sqlite's run().changes is not reliable for an ignored insert inside a transaction (FTS triggers confuse
452
+ // it), so the replay check is a plain indexed lookup, never the statement's change count.
453
+ if (spoolId !== null && this.db.query("SELECT 1 FROM rows WHERE spool_id = ?").get(spoolId)) return undefined;
454
+ try {
455
+ const id = Number(this.db.query("INSERT INTO rows (at, project, agent, event, tool, paths, status, excerpt, bytes, spool_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
456
+ .run(row.at, row.project, row.agent, row.event, row.tool, JSON.stringify(row.paths), row.status, row.excerpt, row.bytes, spoolId).lastInsertRowid);
457
+ this.addBytes(row.bytes);
458
+ return id;
459
+ } catch (error) {
460
+ if (String((error as Error)?.message ?? "").includes("UNIQUE")) return undefined; // a racing drain won this line
461
+ throw error;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * R6 eviction: expired rows first, then the oldest rows while over the cap, bounded per invocation by batches AND
467
+ * by wall time, so even a big cap shrink finishes over later appends and one hook stays inside R2. The running
468
+ * size in `meta.bytes` is adjusted by the removed rows' own bytes; no SUM rescan. Only capture rows are ever
469
+ * deleted; the curated store is a different file this class never opens (AC5).
470
+ */
471
+ evict(): { expired: number; trimmed: number } {
472
+ if (this.contended) return { expired: 0, trimmed: 0 };
473
+ const start = performance.now();
474
+ const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
475
+ const capBytes = this.capMb * 1024 * 1024;
476
+ let trimmed = 0;
477
+ while (this.bytes() > capBytes && performance.now() - start < CAPTURE_EVICT_MAX_MS) {
478
+ const removed = this.evictWhere("1 = 1", [], start);
479
+ if (!removed.rows) break;
480
+ trimmed += removed.rows;
481
+ }
482
+ return { expired: expired.rows, trimmed };
483
+ }
484
+
485
+ private evictWhere(where: string, bind: string[], start: number): { rows: number; bytes: number } {
486
+ let rows = 0, bytes = 0;
487
+ for (let batch = 0; batch < CAPTURE_EVICT_MAX_BATCHES && performance.now() - start < CAPTURE_EVICT_MAX_MS; batch++) {
488
+ const old = this.db.query(`SELECT id, bytes FROM rows WHERE ${where} ORDER BY at, id LIMIT ?`).all(...bind, CAPTURE_EVICT_BATCH_ROWS) as { id: number; bytes: number }[];
489
+ if (!old.length) break;
490
+ const removedBytes = old.reduce((sum, r) => sum + r.bytes, 0);
491
+ // Deleting rows and adjusting the running size is atomic too; a lock cannot leave a permanently stale counter.
492
+ this.db.transaction(() => {
493
+ for (const { id } of old) this.db.query("DELETE FROM rows WHERE id = ?").run(id);
494
+ if (removedBytes) this.addBytes(-removedBytes);
495
+ }).immediate();
496
+ rows += old.length;
497
+ bytes += removedBytes;
498
+ if (old.length < CAPTURE_EVICT_BATCH_ROWS) break;
499
+ }
500
+ return { rows, bytes };
501
+ }
502
+
503
+ rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
504
+ const where: string[] = [], bind: (string | number)[] = [];
505
+ if (f.project) { where.push("project = ?"); bind.push(f.project); }
506
+ if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
507
+ const rows = this.db.query(`SELECT * FROM rows${where.length ? ` WHERE ${where.join(" AND ")}` : ""} ORDER BY id LIMIT ?`).all(...bind, f.limit ?? 1000) as RawRow[];
508
+ return rows.map(toRow);
509
+ }
510
+
511
+ /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
512
+ readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
513
+ const { columns, where, bind } = readWindowSql(window, "id");
514
+ const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
515
+ if (window.query !== undefined && engine === "fts5") {
516
+ const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
517
+ where.push("id IN (SELECT rowid FROM mcp_fts WHERE mcp_fts MATCH ?)"); bind.push(match);
518
+ }
519
+ const rows = this.db.query(`SELECT *, ${columns} FROM rows WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
520
+ .all(...bind, window.limit) as (RawRow & { candidate_id: string; candidate_position: number })[];
521
+ return { rows: rows.map(toRow), ...(engine ? { engine } : {}), ...(rows.length ? { next: readRef(rows[rows.length - 1]!) } : {}) };
522
+ }
523
+
524
+ stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
525
+ let fileBytes = 0;
526
+ try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
527
+ return { rows: (this.db.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n, bytes: this.bytes(), capMb: this.capMb, retentionDays: this.retentionDays, fileBytes };
528
+ }
529
+
530
+ close(): void { this.db.close(); }
531
+ }
532
+
533
+ /** One shard's capture totals for the catalog and the capacity advisor, without opening a writer. */
534
+ export function captureShardStats(dir: string): { rows: number; bytes: number; lastAt: string | null } {
535
+ if (!existsSync(join(dir, "capture.db"))) return { rows: 0, bytes: 0, lastAt: null };
536
+ let db: Database | undefined;
537
+ try {
538
+ db = new Database(join(dir, "capture.db"), { readonly: true });
539
+ db.run("PRAGMA busy_timeout = 3000");
540
+ const row = db.query("SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b, MAX(at) AS last FROM rows").get() as { n: number; b: number; last: string | null };
541
+ return { rows: row.n, bytes: row.b, lastAt: row.last };
542
+ } catch { return { rows: 0, bytes: 0, lastAt: null }; }
543
+ finally { db?.close(); }
544
+ }
545
+
546
+ /** One shard's capture added since an ISO time: the growth input of the R7 capacity advisor. */
547
+ export function captureShardGrowth(dir: string, since: string): { rows: number; bytes: number } {
548
+ if (!existsSync(join(dir, "capture.db"))) return { rows: 0, bytes: 0 };
549
+ let db: Database | undefined;
550
+ try {
551
+ db = new Database(join(dir, "capture.db"), { readonly: true });
552
+ db.run("PRAGMA busy_timeout = 3000");
553
+ const row = db.query("SELECT COUNT(*) AS n, COALESCE(SUM(bytes), 0) AS b FROM rows WHERE at >= ?").get(since) as { n: number; b: number };
554
+ return { rows: row.n, bytes: row.b };
555
+ } catch { return { rows: 0, bytes: 0 }; }
556
+ finally { db?.close(); }
557
+ }