@staix/agent-mem 0.3.1 → 0.4.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,460 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readFileSync, 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 { 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` appends the row to the
29
+ * append-only `spool.jsonl` and exits instead of blocking the agent. The next uncontended writer (or an explicit
30
+ * `drain()`) replays the spool; replay is idempotent per spooled line, so a crash between insert and truncation
31
+ * 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
+
54
+ export interface CaptureInput {
55
+ project: string; agent: string; event: string;
56
+ tool?: string; paths?: string[]; status?: string; excerpt?: string;
57
+ /** Tests and the spool replay pin the time; hooks leave it to the store clock. */
58
+ at?: string;
59
+ }
60
+ export interface CaptureRow {
61
+ id: number; at: string; project: string; agent: string; event: string;
62
+ tool: string | null; paths: string[]; status: string | null; excerpt: string;
63
+ }
64
+ export interface CaptureConfig { capMb: number; retentionDays: number }
65
+ export interface CaptureOptions {
66
+ memoryDir?: string;
67
+ home?: string;
68
+ /** The project key this shard belongs to; aliases resolve to the canonical key (#15 D2). */
69
+ project: string;
70
+ /** Defaults to this machine's `machine.json` identity, shared with the curated store. */
71
+ machine?: string;
72
+ now?: () => number;
73
+ /** Host-supplied R6 values win over the shard and home config files. */
74
+ capMb?: number; retentionDays?: number;
75
+ busyTimeoutMs?: number;
76
+ /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
77
+ screen?: (text: string) => boolean;
78
+ }
79
+ export type CaptureAppend = { id: number } | { spooled: true };
80
+
81
+ /** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
82
+ interface NormalizedCapture {
83
+ at: string; project: string; agent: string; event: string;
84
+ tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
85
+ /** Entropy per spooled line, so two byte-identical hook events are two rows; a replayed line still dedupes. */
86
+ nonce?: string;
87
+ }
88
+
89
+ const configValues = (parsed: Record<string, unknown>): Partial<CaptureConfig> => {
90
+ const capMb = Number(parsed.cap_mb), retentionDays = Number(parsed.retention_days);
91
+ return {
92
+ ...(Number.isFinite(capMb) && capMb > 0 ? { capMb } : {}),
93
+ ...(Number.isFinite(retentionDays) && retentionDays > 0 ? { retentionDays } : {}),
94
+ };
95
+ };
96
+
97
+ /** The R6 settings one config file holds; absent or invalid keys are simply not set. */
98
+ export function readCaptureConfigFile(path: string): Partial<CaptureConfig> {
99
+ try { return configValues(JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>); } catch { return {}; }
100
+ }
101
+
102
+ /** Merge `config` into one config file; only given keys change. The person (or R7's apply) writes these, never an agent. */
103
+ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConfig>): Partial<CaptureConfig> {
104
+ const next = { ...readCaptureConfigFile(path) };
105
+ if (config.capMb !== undefined) {
106
+ if (!Number.isFinite(config.capMb) || config.capMb <= 0) throw new Error("capture.cap_mb must be a positive number");
107
+ next.capMb = config.capMb;
108
+ }
109
+ if (config.retentionDays !== undefined) {
110
+ if (!Number.isFinite(config.retentionDays) || config.retentionDays <= 0) throw new Error("capture.retention_days must be a positive number");
111
+ next.retentionDays = config.retentionDays;
112
+ }
113
+ mkdirSync(join(path, ".."), { recursive: true, mode: 0o700 });
114
+ writeFileSync(path, JSON.stringify({ cap_mb: next.capMb ?? CAPTURE_DEFAULT_CAP_MB, retention_days: next.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS }) + "\n", { mode: 0o600 });
115
+ return next;
116
+ }
117
+
118
+ /** The effective R6 settings for a shard directory: shard config over the home default over the built-in defaults. */
119
+ export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
120
+ const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
121
+ const shard = readCaptureConfigFile(join(dir, "config.json"));
122
+ return {
123
+ capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
124
+ retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
125
+ };
126
+ }
127
+
128
+ /** Adjust a shard's own R6 bounds (an override of the home default); merged over that shard's current file. */
129
+ export function writeCaptureConfig(dir: string, config: Partial<CaptureConfig>): CaptureConfig {
130
+ const next = writeCaptureConfigFile(join(dir, "config.json"), config);
131
+ return { capMb: next.capMb ?? CAPTURE_DEFAULT_CAP_MB, retentionDays: next.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS };
132
+ }
133
+
134
+ const lockError = (error: unknown): boolean => /lock|busy/i.test((error as Error)?.message ?? "");
135
+
136
+ interface RawRow { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string; bytes: number }
137
+ 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 });
138
+
139
+ export class CaptureStore {
140
+ readonly dir: string;
141
+ /** The canonical project key of this shard (aliases resolved). */
142
+ readonly key: string;
143
+ /** Rows the pre-T1 machine-wide capture store handed this shard on open. */
144
+ readonly migrated: number;
145
+ private readonly base: string;
146
+ private readonly requestedKey: string;
147
+ private readonly db: Database;
148
+ private readonly now: () => number;
149
+ private readonly screen: (text: string) => boolean;
150
+ private readonly capMb: number;
151
+ private readonly retentionDays: number;
152
+ /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
153
+ private contended = false;
154
+
155
+ constructor(opts: CaptureOptions) {
156
+ if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
157
+ if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
158
+ this.base = opts.memoryDir ?? memoryBase(opts.home);
159
+ const machine = opts.machine ?? machineId(this.base).id;
160
+ this.requestedKey = opts.project;
161
+ this.key = resolveAlias(this.base, opts.project);
162
+ ensureShardDescriptor(this.base, this.key);
163
+ this.dir = captureShardDir(this.base, opts.project, machine);
164
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
165
+ chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
166
+ const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
167
+ this.capMb = opts.capMb ?? config.capMb;
168
+ this.retentionDays = opts.retentionDays ?? config.retentionDays;
169
+ this.now = opts.now ?? Date.now;
170
+ this.screen = opts.screen ?? looksSecret;
171
+ const file = join(this.dir, "capture.db");
172
+ const db = new Database(file, { create: true });
173
+ this.db = db;
174
+ try { chmodSync(file, 0o600); } catch { /* best effort */ }
175
+ try {
176
+ db.run(`PRAGMA busy_timeout = ${opts.busyTimeoutMs ?? CAPTURE_BUSY_TIMEOUT_MS}`);
177
+ db.run("PRAGMA journal_mode = WAL");
178
+ 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,
179
+ tool TEXT, paths TEXT NOT NULL, status TEXT, excerpt TEXT NOT NULL, bytes INTEGER NOT NULL, spool_id TEXT)`);
180
+ db.run("CREATE UNIQUE INDEX IF NOT EXISTS rows_spool ON rows (spool_id) WHERE spool_id IS NOT NULL");
181
+ db.run("CREATE INDEX IF NOT EXISTS rows_at ON rows (at)");
182
+ db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
183
+ this.ensureFts();
184
+ } catch (error) {
185
+ if (!lockError(error)) throw error;
186
+ this.contended = true; // R2: the hook spools and exits instead of waiting out another writer
187
+ }
188
+ this.migrated = this.contended ? 0 : this.migrateLegacy();
189
+ }
190
+
191
+ /** 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. */
192
+ private migrateLegacy(): number {
193
+ const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
194
+ const file = join(legacyDir, "capture.db");
195
+ if (!existsSync(file)) return 0;
196
+ const homeDefault = join(this.base, "capture-config.json");
197
+ if (existsSync(join(legacyDir, "config.json")) && !existsSync(homeDefault)) renameSync(join(legacyDir, "config.json"), homeDefault);
198
+ let legacy: Database | undefined;
199
+ let moved = 0;
200
+ try {
201
+ legacy = new Database(file);
202
+ legacy.run(`PRAGMA busy_timeout = ${CAPTURE_BUSY_TIMEOUT_MS}`); // never stall a hook on another project's migration
203
+ const rows = legacy.query("SELECT * FROM rows WHERE project = ? OR project = ? ORDER BY id").all(this.key, this.requestedKey) as RawRow[];
204
+ // `legacy:<id>` spool ids make the move idempotent: a crash between the two writes replays nothing.
205
+ this.db.transaction(() => {
206
+ for (const r of rows) {
207
+ const row: NormalizedCapture = { at: r.at, project: this.key, agent: r.agent, event: r.event, tool: r.tool, status: r.status,
208
+ paths: JSON.parse(r.paths) as string[], excerpt: r.excerpt, bytes: r.bytes };
209
+ if (this.insertRow(row, `legacy:${r.id}`) !== undefined) moved++;
210
+ }
211
+ }).immediate();
212
+ legacy.transaction(() => { for (const r of rows) legacy!.query("DELETE FROM rows WHERE id = ?").run(r.id); }).immediate();
213
+ // Legacy spool lines for this key move to the shard spool (they drain on the next append); other keys' lines stay.
214
+ let spoolLeft = 0;
215
+ for (const name of ["spool.jsonl", "spool.jsonl.draining"]) {
216
+ const legacySpool = join(legacyDir, name);
217
+ if (!existsSync(legacySpool)) continue;
218
+ const lines = readFileSync(legacySpool, "utf8").split("\n").filter(Boolean);
219
+ const keep: string[] = [];
220
+ for (const line of lines) {
221
+ let project = "";
222
+ try { project = String((JSON.parse(line) as { project?: unknown }).project ?? ""); } catch { /* unparseable: keep */ }
223
+ if (project === this.key || project === this.requestedKey) this.spoolRaw(line);
224
+ else keep.push(line);
225
+ }
226
+ if (keep.length) { writeFileSync(legacySpool, keep.join("\n") + "\n", { mode: 0o600 }); spoolLeft += keep.length; }
227
+ else rmSync(legacySpool, { force: true });
228
+ }
229
+ if ((legacy.query("SELECT COUNT(*) AS n FROM rows").get() as { n: number }).n === 0 && spoolLeft === 0) {
230
+ legacy.close();
231
+ legacy = undefined;
232
+ for (const name of ["capture.db", "capture.db-wal", "capture.db-shm"]) rmSync(join(legacyDir, name), { force: true });
233
+ try { rmSync(legacyDir, { recursive: true }); } catch { /* a leftover file keeps the directory */ }
234
+ }
235
+ } catch { return moved; } // locked or unreadable: the next open of this project retries
236
+ finally { legacy?.close(); }
237
+ return moved;
238
+ }
239
+
240
+ /** A line moved from the legacy spool, byte for byte: the shard's drain normalizes whatever format it holds. */
241
+ private spoolRaw(line: string): void {
242
+ appendFileSync(join(this.dir, SPOOL), line + "\n", { mode: 0o600 });
243
+ }
244
+
245
+ /** External-content FTS5 over the excerpts, kept in sync by triggers, so capture search needs no catch-up pass. */
246
+ private ensureFts(): void {
247
+ if (!fts5Available()) return; // the R5 fallback: capture rows are stored, search skips this tier
248
+ const had = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'fts' AND type = 'table'").get();
249
+ this.db.run("CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(body, content='rows', content_rowid='id')");
250
+ 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");
251
+ 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");
252
+ 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");
253
+ // Rows written before FTS existed (or before a platform gained it) are indexed once; triggers keep it level.
254
+ 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')");
255
+ }
256
+
257
+ private bytes(): number {
258
+ return Number((this.db.query("SELECT value FROM meta WHERE key = 'bytes'").get() as { value: string } | null)?.value ?? "0");
259
+ }
260
+ private addBytes(delta: number): void {
261
+ 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);
262
+ }
263
+
264
+ /**
265
+ * One capture row. Validation, deny-filtering, bounding and screening happen once, BEFORE anything is written:
266
+ * the spool holds only the screened row (never raw input, so no secret or PII excerpt ever reaches spool.jsonl),
267
+ * and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
268
+ * and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
269
+ */
270
+ append(input: CaptureInput): CaptureAppend {
271
+ const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
272
+ 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");
273
+ 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");
274
+ const row = this.normalize(input);
275
+ if (this.contended) {
276
+ this.spool(row);
277
+ return { spooled: true };
278
+ }
279
+ try {
280
+ this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
281
+ // The row and its running byte count commit or roll back together: a lock after the INSERT cannot spool a duplicate.
282
+ const id = this.db.transaction(() => this.insertRow(row, null)).immediate();
283
+ try { this.evict(); } catch (error) {
284
+ if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
285
+ }
286
+ return { id: id! };
287
+ } catch (error) {
288
+ if (!lockError(error)) throw error;
289
+ this.spool(row);
290
+ return { spooled: true };
291
+ }
292
+ }
293
+
294
+ /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
295
+ private normalize(input: CaptureInput): NormalizedCapture {
296
+ const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
297
+ // Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
298
+ const at = new Date(input.at === undefined ? this.now() : Date.parse(input.at)).toISOString();
299
+ const tool = str(input.tool, 64) ? input.tool! : null;
300
+ const status = str(input.status, 64) ? input.status! : null;
301
+ const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
302
+ let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
303
+ if (excerpt && this.screen(excerpt)) excerpt = "[secret-screened]";
304
+ return { at, project: input.project, agent: input.agent, event: input.event, tool, paths, status, excerpt,
305
+ bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
306
+ }
307
+
308
+ private spool(row: NormalizedCapture): void {
309
+ appendFileSync(join(this.dir, SPOOL), JSON.stringify({ ...row, nonce: randomBytes(4).toString("hex") }) + "\n", { mode: 0o600 });
310
+ }
311
+
312
+ /**
313
+ * Replay the spool, finishing a leftover draining file from a crash first. Idempotent per spooled line and bounded
314
+ * per invocation; malformed or incomplete lines are skipped and counted instead of poisoning every later append.
315
+ * ponytail: the shared spool/draining names cannot fully close multi-process replacement and already-open-writer
316
+ * races. Per-writer spool files with unique drain names are the upgrade path if that hard guarantee is required.
317
+ */
318
+ drain(): { drained: number; skipped: number } {
319
+ let budget = CAPTURE_DRAIN_MAX_LINES, drained = 0, skipped = 0;
320
+ if (this.contended) return { drained, skipped };
321
+ const validRow = (row: Partial<NormalizedCapture>): row is NormalizedCapture =>
322
+ typeof row.at === "string" && row.at.length > 0 && typeof row.project === "string" && row.project.length > 0
323
+ && typeof row.agent === "string" && row.agent.length > 0 && typeof row.event === "string" && row.event.length > 0
324
+ && (row.tool === null || typeof row.tool === "string") && Array.isArray(row.paths) && row.paths.every((p) => typeof p === "string")
325
+ && (row.status === null || typeof row.status === "string") && typeof row.excerpt === "string"
326
+ && typeof row.bytes === "number" && Number.isFinite(row.bytes) && row.bytes >= 0;
327
+ for (const renameFirst of [false, true]) {
328
+ const file = join(this.dir, DRAINING);
329
+ if (renameFirst && existsSync(join(this.dir, SPOOL))) {
330
+ if (budget <= 0) break;
331
+ // link+remove, not rename: a draining file another process owns is never clobbered at claim time.
332
+ try { linkSync(join(this.dir, SPOOL), file); rmSync(join(this.dir, SPOOL)); } catch { continue; }
333
+ }
334
+ if (!existsSync(file)) continue;
335
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean);
336
+ if (!lines.length) { rmSync(file, { force: true }); continue; }
337
+ const batch = lines.slice(0, Math.max(0, budget));
338
+ try {
339
+ this.db.transaction(() => {
340
+ for (const line of batch) {
341
+ let raw: Partial<NormalizedCapture> & { bytes?: number };
342
+ try { raw = JSON.parse(line) as typeof raw; } catch { skipped++; continue; }
343
+ let candidate: NormalizedCapture;
344
+ try {
345
+ // Spool lines written since the screened-spool rule are already bounded and screened; older lines
346
+ // (this branch's pre-release format) pass through normalize once.
347
+ candidate = typeof raw.bytes === "number" && typeof raw.excerpt === "string" ? raw as NormalizedCapture : this.normalize(raw as CaptureInput);
348
+ } catch { skipped++; continue; }
349
+ if (!validRow(candidate)) { skipped++; continue; }
350
+ const added = this.insertRow(candidate, createHash("sha256").update(line).digest("hex")) !== undefined;
351
+ if (added) drained++;
352
+ }
353
+ }).immediate();
354
+ } catch (error) {
355
+ if (!lockError(error)) throw error;
356
+ break; // still contended: the spool stays for the next invocation
357
+ }
358
+ budget -= batch.length;
359
+ if (batch.length === lines.length) rmSync(file, { force: true });
360
+ else { writeFileSync(file, lines.slice(batch.length).join("\n") + "\n", { mode: 0o600 }); break; }
361
+ }
362
+ return { drained, skipped };
363
+ }
364
+
365
+ /** The row insert shared by append, drain and the legacy migration; with a spool id the insert is idempotent. */
366
+ private insertRow(row: NormalizedCapture, spoolId: string | null): number | undefined {
367
+ // bun:sqlite's run().changes is not reliable for an ignored insert inside a transaction (FTS triggers confuse
368
+ // it), so the replay check is a plain indexed lookup, never the statement's change count.
369
+ if (spoolId !== null && this.db.query("SELECT 1 FROM rows WHERE spool_id = ?").get(spoolId)) return undefined;
370
+ try {
371
+ const id = Number(this.db.query("INSERT INTO rows (at, project, agent, event, tool, paths, status, excerpt, bytes, spool_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
372
+ .run(row.at, row.project, row.agent, row.event, row.tool, JSON.stringify(row.paths), row.status, row.excerpt, row.bytes, spoolId).lastInsertRowid);
373
+ this.addBytes(row.bytes);
374
+ return id;
375
+ } catch (error) {
376
+ if (String((error as Error)?.message ?? "").includes("UNIQUE")) return undefined; // a racing drain won this line
377
+ throw error;
378
+ }
379
+ }
380
+
381
+ /**
382
+ * R6 eviction: expired rows first, then the oldest rows while over the cap, bounded per invocation by batches AND
383
+ * by wall time, so even a big cap shrink finishes over later appends and one hook stays inside R2. The running
384
+ * size in `meta.bytes` is adjusted by the removed rows' own bytes; no SUM rescan. Only capture rows are ever
385
+ * deleted; the curated store is a different file this class never opens (AC5).
386
+ */
387
+ evict(): { expired: number; trimmed: number } {
388
+ if (this.contended) return { expired: 0, trimmed: 0 };
389
+ const start = performance.now();
390
+ const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
391
+ const capBytes = this.capMb * 1024 * 1024;
392
+ let trimmed = 0;
393
+ while (this.bytes() > capBytes && performance.now() - start < CAPTURE_EVICT_MAX_MS) {
394
+ const removed = this.evictWhere("1 = 1", [], start);
395
+ if (!removed.rows) break;
396
+ trimmed += removed.rows;
397
+ }
398
+ return { expired: expired.rows, trimmed };
399
+ }
400
+
401
+ private evictWhere(where: string, bind: string[], start: number): { rows: number; bytes: number } {
402
+ let rows = 0, bytes = 0;
403
+ for (let batch = 0; batch < CAPTURE_EVICT_MAX_BATCHES && performance.now() - start < CAPTURE_EVICT_MAX_MS; batch++) {
404
+ 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 }[];
405
+ if (!old.length) break;
406
+ const removedBytes = old.reduce((sum, r) => sum + r.bytes, 0);
407
+ // Deleting rows and adjusting the running size is atomic too; a lock cannot leave a permanently stale counter.
408
+ this.db.transaction(() => {
409
+ for (const { id } of old) this.db.query("DELETE FROM rows WHERE id = ?").run(id);
410
+ if (removedBytes) this.addBytes(-removedBytes);
411
+ }).immediate();
412
+ rows += old.length;
413
+ bytes += removedBytes;
414
+ if (old.length < CAPTURE_EVICT_BATCH_ROWS) break;
415
+ }
416
+ return { rows, bytes };
417
+ }
418
+
419
+ rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
420
+ const where: string[] = [], bind: (string | number)[] = [];
421
+ if (f.project) { where.push("project = ?"); bind.push(f.project); }
422
+ if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
423
+ 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[];
424
+ return rows.map(toRow);
425
+ }
426
+
427
+ stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
428
+ let fileBytes = 0;
429
+ try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
430
+ 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 };
431
+ }
432
+
433
+ close(): void { this.db.close(); }
434
+ }
435
+
436
+ /** One shard's capture totals for the catalog and the capacity advisor, without opening a writer. */
437
+ export function captureShardStats(dir: string): { rows: number; bytes: number; lastAt: string | null } {
438
+ if (!existsSync(join(dir, "capture.db"))) return { rows: 0, bytes: 0, lastAt: null };
439
+ let db: Database | undefined;
440
+ try {
441
+ db = new Database(join(dir, "capture.db"), { readonly: true });
442
+ db.run("PRAGMA busy_timeout = 3000");
443
+ 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 };
444
+ return { rows: row.n, bytes: row.b, lastAt: row.last };
445
+ } catch { return { rows: 0, bytes: 0, lastAt: null }; }
446
+ finally { db?.close(); }
447
+ }
448
+
449
+ /** One shard's capture added since an ISO time: the growth input of the R7 capacity advisor. */
450
+ export function captureShardGrowth(dir: string, since: string): { rows: number; bytes: number } {
451
+ if (!existsSync(join(dir, "capture.db"))) return { rows: 0, bytes: 0 };
452
+ let db: Database | undefined;
453
+ try {
454
+ db = new Database(join(dir, "capture.db"), { readonly: true });
455
+ db.run("PRAGMA busy_timeout = 3000");
456
+ 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 };
457
+ return { rows: row.n, bytes: row.b };
458
+ } catch { return { rows: 0, bytes: 0 }; }
459
+ finally { db?.close(); }
460
+ }
@@ -0,0 +1,136 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { memoryBase } from "./home.ts";
5
+ import { listAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
+ import { readProfileJSON, validGeneration } from "./generation.ts";
7
+ import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
+ import { openShardStore } from "./store.ts";
9
+
10
+ /**
11
+ * #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
12
+ * only from the shards (`projects/* /project.json`, their `machine/<id>` capture stores and configs) and the
13
+ * append-only `aliases.jsonl`; it carries no truth of its own, so deleting it and rebuilding from the shards always
14
+ * reproduces the same rows. The D3 management commands (`amem projects/usage/gc/export/archive/forget`) are #15 T5
15
+ * and are deliberately not built here.
16
+ */
17
+
18
+ export interface CatalogProject {
19
+ hash: string; key: string; local: boolean; aliases: string[];
20
+ captureRows: number; captureBytes: number; captureLastAt: string | null;
21
+ curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
22
+ capMb: number; retentionDays: number;
23
+ }
24
+
25
+ interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
26
+
27
+ const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
28
+ captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
29
+
30
+ /** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
31
+ function capturedSelection(base: string): boolean {
32
+ const value = readProfileJSON(join(base, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
33
+ if (value === undefined) return false;
34
+ if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || (value.phase !== "captured" && value.phase !== "active") || typeof value.selectorRevision !== "string") throw new Error("curated shard selection is unavailable or invalid");
35
+ return value.phase === "captured";
36
+ }
37
+
38
+ /** Refresh the disposable curated index from its sealed logs before deriving an own-project receipt. */
39
+ function curatedStats(base: string, hash: string, key: string, captured: boolean): { entries: number | null; lastAt: string | null } {
40
+ if (captured) return { entries: null, lastAt: null };
41
+ const dir = join(base, "projects", hash);
42
+ // A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
43
+ if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
44
+ const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
45
+ try {
46
+ const db = new Database(join(dir, "index.db"), { readonly: true });
47
+ try {
48
+ db.run("PRAGMA busy_timeout = 0");
49
+ const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?").get(key) as { n: number; last: string | null };
50
+ return { entries: row.n, lastAt: row.last };
51
+ } finally { db.close(); }
52
+ } finally { store.close(); }
53
+ }
54
+
55
+ /** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
56
+ export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
57
+ const projectsDir = join(base, "projects");
58
+ let hashes: string[] = [];
59
+ try { hashes = readdirSync(projectsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name)).map((e) => e.name).sort(); } catch { /* no shards yet */ }
60
+ const out: { hash: string; key: string; local: boolean; machineDirs: string[] }[] = [];
61
+ for (const hash of hashes) {
62
+ const descriptor = readShardDescriptor(base, hash);
63
+ if (!descriptor) continue; // a half-created or corrupt shard is skipped, never fatal
64
+ const machineDirs: string[] = [];
65
+ try {
66
+ for (const entry of readdirSync(join(projectsDir, hash, "machine"), { withFileTypes: true })) if (entry.isDirectory()) machineDirs.push(join(projectsDir, hash, "machine", entry.name));
67
+ } catch { /* no machine scope yet */ }
68
+ out.push({ hash, key: descriptor.key, local: descriptor.local, machineDirs: machineDirs.sort() });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** What the shards and the alias log say, before anything is persisted. */
74
+ export function scanCatalog(base: string): CatalogProject[] {
75
+ const captured = capturedSelection(base);
76
+ // The latest op per alias: an alias lists only under the key it currently points at, never under a past target.
77
+ const latest = new Map<string, string>();
78
+ for (const op of listAliases(base)) latest.set(op.alias, op.key);
79
+ const out: CatalogProject[] = [];
80
+ for (const shard of walkShards(base)) {
81
+ let rows = 0, bytes = 0, lastAt: string | null = null;
82
+ for (const dir of shard.machineDirs) {
83
+ const stats = captureShardStats(dir);
84
+ rows += stats.rows;
85
+ bytes += stats.bytes;
86
+ if (stats.lastAt && (lastAt === null || stats.lastAt > lastAt)) lastAt = stats.lastAt;
87
+ }
88
+ const config = readCaptureConfig(shard.machineDirs[shard.machineDirs.length - 1] ?? join(base, "projects", shard.hash, "machine", "_"), base);
89
+ const curated = curatedStats(base, shard.hash, shard.key, captured);
90
+ out.push({ hash: shard.hash, key: shard.key, local: shard.local,
91
+ aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
92
+ captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays });
93
+ }
94
+ return out.sort((a, b) => a.key.localeCompare(b.key));
95
+ }
96
+
97
+ /** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
98
+ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
99
+ const base = memoryBase(opts.home);
100
+ const projects = scanCatalog(base);
101
+ const file = join(base, "catalog.db");
102
+ const db = new Database(file, { create: true });
103
+ try {
104
+ db.run("PRAGMA busy_timeout = 3000");
105
+ try { chmodSync(file, 0o600); } catch { /* best effort */ }
106
+ db.transaction(() => {
107
+ const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
108
+ // This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
109
+ if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at"))) db.run("DROP TABLE projects");
110
+ db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
111
+ capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
112
+ db.run("DELETE FROM projects");
113
+ for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
114
+ .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
115
+ }).immediate();
116
+ } finally { db.close(); }
117
+ return projects;
118
+ }
119
+
120
+ /** The stored catalog, rebuilt when absent. Listing may lag the shards; `rebuildCatalog` is the fresh read. */
121
+ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
122
+ const base = memoryBase(opts.home);
123
+ const file = join(base, "catalog.db");
124
+ if (!existsSync(file)) return rebuildCatalog(opts);
125
+ const captured = capturedSelection(base);
126
+ let db: Database | undefined;
127
+ try {
128
+ db = new Database(file, { readonly: true });
129
+ db.run("PRAGMA busy_timeout = 3000");
130
+ const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
131
+ if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
132
+ return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
133
+ .map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
134
+ } catch { return rebuildCatalog(opts); }
135
+ finally { db?.close(); }
136
+ }
@@ -4,6 +4,8 @@ import { importClaudeMem } from "./import.ts";
4
4
  import { memoryProjectKey, projectChain } from "./project.ts";
5
5
 
6
6
  export interface MemoryCommandOptions {
7
+ /** Startup-bound canonical project supplied by a host; no model-facing selector. */
8
+ project?: string;
7
9
  store?: MemoryStore;
8
10
  sync?: Omit<SyncOptions, "remote">;
9
11
  open: () => MemoryStore;
@@ -13,6 +15,7 @@ export interface MemoryCommandOptions {
13
15
  onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
14
16
  }
15
17
  export interface GoalCommandOptions {
18
+ project?: string;
16
19
  store?: MemoryStore;
17
20
  link?: (goal: string, task: string) => Promise<string>;
18
21
  open: () => MemoryStore;
@@ -40,7 +43,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
40
43
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
41
44
  const store = opts.store ?? opts.open();
42
45
  const actor = "user";
43
- const project = memoryProjectKey(cwd);
46
+ const project = opts.project ?? memoryProjectKey(cwd);
44
47
  const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
45
48
  try {
46
49
  switch (sub) {
@@ -108,7 +111,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
108
111
  if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
109
112
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
110
113
  const isPii = opts.importPolicy();
111
- const key = memoryProjectKey(cwd);
114
+ const key = opts.project ?? memoryProjectKey(cwd);
112
115
  const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
113
116
  const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii, ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
114
117
  if (json) return JSON.stringify(r, null, 2);
@@ -125,7 +128,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
125
128
  }
126
129
  case "machine": {
127
130
  const label = flag("--label");
128
- const m = label ? setMachineLabel(store.dir, label) : store.machine;
131
+ const m = label ? setMachineLabel(store.profile.sharded ? store.profile.home : store.dir, label) : store.machine;
129
132
  return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
130
133
  }
131
134
  case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
@@ -154,7 +157,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
154
157
  const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
155
158
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
156
159
  const store = opts.store ?? opts.open();
157
- const project = memoryProjectKey(cwd);
160
+ const project = opts.project ?? memoryProjectKey(cwd);
158
161
  const me = store.machine.id;
159
162
  // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
160
163
  const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);