@staix/agent-mem 0.4.0 → 0.6.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.
@@ -1,13 +1,14 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { appendFileSync, chmodSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { chmodSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { looksSecret } from "./screen.ts";
6
- import { isDenied } from "./platform.ts";
6
+ import { normalizeCaptureFields, type CaptureFields } from "./capture-input.ts";
7
+ export { CAPTURE_MAX_EXCERPT, CAPTURE_MAX_PATHS } from "./capture-input.ts";
7
8
  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";
9
+ import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
10
+ import { machineId } from "./machine.ts";
11
+ import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
11
12
 
12
13
  /**
13
14
  * #13 design A, tier 2, born sharded (#15 T1): machine-local capture, one shard per project key. A shard lives at
@@ -25,10 +26,10 @@ import { fts5Available } from "./search-fts.ts";
25
26
  * ponytail: the cap counts row payload bytes, not the sqlite file's page slack or WAL; a VACUUM blocks writers and
26
27
  * stays out of the hook path, so file size can exceed the cap until pages are reused.
27
28
  *
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.
29
+ * R2 contention: a writer that cannot take its shard's lock within `busyTimeoutMs` closes a private segment
30
+ * before publishing it atomically in `spool/`. The next uncontended writer (or explicit `drain()`) replays it
31
+ * under a unique claim name. Segments are immutable; a claim's byte offset advances only after the rows commit.
32
+ * Replay is idempotent per spooled line, so a crash between insert and acknowledgement never doubles a row.
32
33
  *
33
34
  * Migration: the pre-T1 machine-wide `capture/` (this branch never shipped) is drained per project on open —
34
35
  * each project key's rows move into its shard with their original timestamps, and the legacy store is removed
@@ -39,24 +40,23 @@ export const CAPTURE_DEFAULT_CAP_MB = 512;
39
40
  export const CAPTURE_DEFAULT_RETENTION_DAYS = 90;
40
41
  /** Well under the 150 ms R2 wall budget even with process startup: contention spools instead of waiting. */
41
42
  export const CAPTURE_BUSY_TIMEOUT_MS = 25;
42
- export const CAPTURE_MAX_EXCERPT = 2000;
43
- export const CAPTURE_MAX_PATHS = 32;
44
43
  /** Eviction and drain work per invocation, so a shrunk cap or a long outage is worked off over many hooks. */
45
44
  export const CAPTURE_EVICT_BATCH_ROWS = 256;
46
45
  export const CAPTURE_EVICT_MAX_BATCHES = 4;
47
46
  /** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
48
47
  export const CAPTURE_EVICT_MAX_MS = 25;
49
48
  export const CAPTURE_DRAIN_MAX_LINES = 1000;
50
- const ROW_OVERHEAD_BYTES = 96;
51
49
  const SPOOL = "spool.jsonl";
52
50
  const DRAINING = "spool.jsonl.draining";
51
+ const SPOOL_DIR = "spool";
52
+ const SPOOL_MAX_FILES = 32;
53
+ const SPOOL_MAX_BYTES = 256 * 1024;
54
+ const SPOOL_MAX_MS = 25;
55
+ const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.draining-[0-9a-f]+)?$/;
56
+ let spoolSequence = 0;
57
+ 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")}`;
53
58
 
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
- }
59
+ export interface CaptureInput extends CaptureFields { project: string }
60
60
  export interface CaptureRow {
61
61
  id: number; at: string; project: string; agent: string; event: string;
62
62
  tool: string | null; paths: string[]; status: string | null; excerpt: string;
@@ -73,10 +73,20 @@ export interface CaptureOptions {
73
73
  /** Host-supplied R6 values win over the shard and home config files. */
74
74
  capMb?: number; retentionDays?: number;
75
75
  busyTimeoutMs?: number;
76
+ /** Host-only brief view: open existing capture only, without schema/index/replay writes. */
77
+ readOnly?: boolean;
78
+ /** Host-only row ceiling for the read-only snapshot; overflow is unavailable, never empty. */
79
+ maxReadRows?: number;
80
+ /** Host-only optional brief: retain the newest maxReadRows ids, with explicit partial coverage. */
81
+ recentReadRows?: boolean;
76
82
  /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
77
83
  screen?: (text: string) => boolean;
78
84
  }
79
- export type CaptureAppend = { id: number } | { spooled: true };
85
+ export interface CaptureAppendOptions {
86
+ /** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
87
+ receipt?: string;
88
+ }
89
+ export type CaptureAppend = { id: number; already?: true } | { spooled: true };
80
90
 
81
91
  /** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
82
92
  interface NormalizedCapture {
@@ -151,24 +161,77 @@ export class CaptureStore {
151
161
  private readonly retentionDays: number;
152
162
  /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
153
163
  private contended = false;
164
+ private readonly readOnly: boolean;
165
+ private recentReadIds?: number[];
166
+ /** True when the optional recent snapshot excludes older capture rows. */
167
+ readonly recentReadTruncated: boolean = false;
154
168
 
155
169
  constructor(opts: CaptureOptions) {
156
170
  if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
157
171
  if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
172
+ this.readOnly = opts.readOnly === true;
173
+ if (opts.readOnly !== undefined && typeof opts.readOnly !== "boolean") throw new Error("invalid capture read-only option");
174
+ if (opts.recentReadRows !== undefined && (typeof opts.recentReadRows !== "boolean" || !this.readOnly)) throw new Error("recent capture rows require a read-only store");
175
+ const maxReadRows = opts.maxReadRows ?? 128;
176
+ if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
158
177
  this.base = opts.memoryDir ?? memoryBase(opts.home);
159
- const machine = opts.machine ?? machineId(this.base).id;
178
+ const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
179
+ if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
160
180
  this.requestedKey = opts.project;
161
181
  this.key = resolveAlias(this.base, opts.project);
162
- ensureShardDescriptor(this.base, this.key);
182
+ if (this.readOnly) {
183
+ const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
184
+ if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
185
+ || readShardDescriptor(this.base, shardKeyHash(this.key))?.key !== this.key) throw new Error("capture shard identity unavailable");
186
+ } else ensureShardDescriptor(this.base, this.key);
163
187
  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
188
+ if (!this.readOnly) {
189
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
190
+ chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
191
+ }
192
+ if (this.readOnly) for (const file of [join(this.dir, "config.json"), join(this.base, "capture-config.json")]) {
193
+ const stat = lstatSync(file, { throwIfNoEntry: false });
194
+ if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)) throw new Error("capture configuration unavailable");
195
+ }
166
196
  const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
167
197
  this.capMb = opts.capMb ?? config.capMb;
168
198
  this.retentionDays = opts.retentionDays ?? config.retentionDays;
169
199
  this.now = opts.now ?? Date.now;
170
200
  this.screen = opts.screen ?? looksSecret;
171
201
  const file = join(this.dir, "capture.db");
202
+ if (this.readOnly) {
203
+ // Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
204
+ for (const directory of [this.base, join(this.base, "projects"), join(this.base, "projects", shardKeyHash(this.key)),
205
+ join(this.base, "projects", shardKeyHash(this.key), "machine"), this.dir]) {
206
+ const stat = lstatSync(directory);
207
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("capture read path unavailable");
208
+ }
209
+ const stat = lstatSync(file);
210
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("capture database unavailable");
211
+ const timeout = opts.busyTimeoutMs ?? 0;
212
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 3000) throw new Error("invalid capture busy timeout");
213
+ const db = this.db = new Database(file, { readonly: true });
214
+ try {
215
+ db.run(`PRAGMA busy_timeout = ${timeout}`); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
216
+ db.run("BEGIN");
217
+ if (opts.recentReadRows) {
218
+ // The existing rows_at index supplies the reverse time/id walk; retain ids only.
219
+ // One sentinel records excluded history without counting or loading its bodies.
220
+ const sql = "SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC, id DESC LIMIT ?";
221
+ const plan = db.query(`EXPLAIN QUERY PLAN ${sql}`).all(maxReadRows + 1) as { detail: string }[];
222
+ if (plan.some(row => row.detail.includes("TEMP B-TREE"))) throw new Error("capture recent index unavailable");
223
+ const recent = db.query(sql).all(maxReadRows + 1) as { id: number }[];
224
+ this.recentReadIds = recent.slice(0, maxReadRows).map(row => row.id);
225
+ this.recentReadTruncated = recent.length > maxReadRows;
226
+ } else {
227
+ const count = (db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT ?)").get(maxReadRows + 1) as { n: number }).n;
228
+ if (count > maxReadRows) throw new Error("capture brief row bound exceeded");
229
+ }
230
+ if (resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
231
+ } catch (error) { db.close(); throw error; }
232
+ this.migrated = 0;
233
+ return;
234
+ }
172
235
  const db = new Database(file, { create: true });
173
236
  this.db = db;
174
237
  try { chmodSync(file, 0o600); } catch { /* best effort */ }
@@ -182,12 +245,26 @@ export class CaptureStore {
182
245
  db.run("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)");
183
246
  this.ensureFts();
184
247
  } catch (error) {
185
- if (!lockError(error)) throw error;
248
+ if (!lockError(error)) { db.close(); throw error; }
186
249
  this.contended = true; // R2: the hook spools and exits instead of waiting out another writer
187
250
  }
188
251
  this.migrated = this.contended ? 0 : this.migrateLegacy();
189
252
  }
190
253
 
254
+ private existingMachine(): string {
255
+ const file = join(this.base, "machine.json"), stat = lstatSync(file);
256
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("capture machine identity unavailable");
257
+ const value = JSON.parse(readFileSync(file, "utf8")) as { id?: unknown };
258
+ if (typeof value?.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id)) throw new Error("capture machine identity unavailable");
259
+ return value.id;
260
+ }
261
+ private writable(): void {
262
+ if (this.readOnly) throw new Error("capture store is read-only");
263
+ }
264
+ private checkReadBinding(): void {
265
+ if (this.readOnly && resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
266
+ }
267
+
191
268
  /** 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
269
  private migrateLegacy(): number {
193
270
  const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
@@ -239,7 +316,13 @@ export class CaptureStore {
239
316
 
240
317
  /** A line moved from the legacy spool, byte for byte: the shard's drain normalizes whatever format it holds. */
241
318
  private spoolRaw(line: string): void {
242
- appendFileSync(join(this.dir, SPOOL), line + "\n", { mode: 0o600 });
319
+ const directory = join(this.dir, SPOOL_DIR), writing = join(directory, ".writing");
320
+ mkdirSync(writing, { recursive: true, mode: 0o700 });
321
+ const name = `spool-${spoolToken()}.jsonl`, temporary = join(writing, name);
322
+ try {
323
+ writeFileSync(temporary, line + "\n", { mode: 0o600, flag: "wx" }); // closes before readers can see it
324
+ renameSync(temporary, join(directory, name));
325
+ } finally { rmSync(temporary, { force: true }); }
243
326
  }
244
327
 
245
328
  /** External-content FTS5 over the excerpts, kept in sync by triggers, so capture search needs no catch-up pass. */
@@ -254,6 +337,30 @@ export class CaptureStore {
254
337
  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
338
  }
256
339
 
340
+ private mcpReady = false;
341
+ /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
342
+ private ensureMcpFts(): boolean {
343
+ if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
344
+ if (!fts5Available()) return false;
345
+ if (this.mcpReady) return true;
346
+ try {
347
+ this.db.transaction(() => {
348
+ const hadMcp = !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
349
+ const body = (row: string) => `${row}.excerpt || char(10) || COALESCE((SELECT group_concat(value, ' ') FROM json_each(${row}.paths)), '') || char(10) || ${row}.event`;
350
+ this.db.run("CREATE VIRTUAL TABLE IF NOT EXISTS mcp_fts USING fts5(body)");
351
+ 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`);
352
+ 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");
353
+ 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`);
354
+ if (!hadMcp) this.db.run(`INSERT INTO mcp_fts(rowid, body) SELECT rows.id, ${body("rows")} FROM rows`);
355
+ }).immediate();
356
+ this.mcpReady = true;
357
+ return true;
358
+ } catch {
359
+ // Missing/unavailable derived search is not a capture read failure. Retry on the next independent read.
360
+ return false;
361
+ }
362
+ }
363
+
257
364
  private bytes(): number {
258
365
  return Number((this.db.query("SELECT value FROM meta WHERE key = 'bytes'").get() as { value: string } | null)?.value ?? "0");
259
366
  }
@@ -267,74 +374,127 @@ export class CaptureStore {
267
374
  * and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
268
375
  * and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
269
376
  */
270
- append(input: CaptureInput): CaptureAppend {
377
+ append(input: CaptureInput, options: CaptureAppendOptions = {}): CaptureAppend {
378
+ this.writable();
271
379
  const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
272
380
  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
381
  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");
382
+ const receipt = options.receipt;
383
+ if (receipt !== undefined && (typeof receipt !== "string" || !/^[a-f0-9]{64}$/.test(receipt) || input.at === undefined)) throw new Error("capture receipt needs a stable time and a 64-hex digest");
274
384
  const row = this.normalize(input);
385
+ // Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
386
+ // advance replays exactly the same screened line, whether first written directly or under contention.
387
+ const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
275
388
  if (this.contended) {
276
- this.spool(row);
389
+ this.spool(row, receipt);
277
390
  return { spooled: true };
278
391
  }
279
392
  try {
280
393
  this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
281
394
  // 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();
395
+ const result = this.db.transaction((): CaptureAppend => {
396
+ const id = this.insertRow(row, spoolId);
397
+ if (id !== undefined) return { id };
398
+ const prior = this.db.query("SELECT id FROM rows WHERE spool_id = ?").get(spoolId) as { id: number } | null;
399
+ if (!prior) throw new Error("capture receipt lookup failed");
400
+ return { id: prior.id, already: true };
401
+ }).immediate();
283
402
  try { this.evict(); } catch (error) {
284
403
  if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
285
404
  }
286
- return { id: id! };
405
+ return result;
287
406
  } catch (error) {
288
407
  if (!lockError(error)) throw error;
289
- this.spool(row);
408
+ this.spool(row, receipt);
290
409
  return { spooled: true };
291
410
  }
292
411
  }
293
412
 
294
413
  /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
295
414
  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 };
415
+ const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text));
416
+ return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
306
417
  }
307
418
 
308
- private spool(row: NormalizedCapture): void {
309
- appendFileSync(join(this.dir, SPOOL), JSON.stringify({ ...row, nonce: randomBytes(4).toString("hex") }) + "\n", { mode: 0o600 });
419
+ private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
420
+ return JSON.stringify({ ...row, nonce });
421
+ }
422
+
423
+ private spool(row: NormalizedCapture, nonce?: string): void {
424
+ this.spoolRaw(this.spoolLine(row, nonce));
310
425
  }
311
426
 
312
427
  /**
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.
428
+ * Immutable segments with unique claims: a stale drainer can remove only its own old name. Recovery may
429
+ * steal a live claim; both readers can replay it safely because row ids dedupe and neither mutates its inode.
430
+ * Offset acknowledgement is another rename, after commit, so a crash always leaves replayable evidence.
431
+ * ponytail: unpublished files left in .writing by a killed writer are not replayed (they may be partial).
432
+ * Quiescent maintenance may remove them; durable recovery of an interrupted append needs a journal/fsync.
433
+ * Legacy fixed names are accepted for upgrade, but old-version writers must stop before upgrading: their
434
+ * already-open shared inode cannot be made immutable by a newer reader.
317
435
  */
318
436
  drain(): { drained: number; skipped: number } {
319
- let budget = CAPTURE_DRAIN_MAX_LINES, drained = 0, skipped = 0;
437
+ this.writable();
438
+ let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
320
439
  if (this.contended) return { drained, skipped };
440
+ const started = performance.now();
441
+ const directory = join(this.dir, SPOOL_DIR);
442
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
443
+ const candidates: { file: string; name: string; offset: number; skip: boolean }[] = [];
444
+ // Fixed-name imports are only compatibility inputs; all acknowledgements use unique names thereafter.
445
+ for (const name of [DRAINING, SPOOL]) {
446
+ if (existsSync(join(this.dir, name))) candidates.push({ file: join(this.dir, name), name: `spool-${spoolToken()}.jsonl`, offset: 0, skip: false });
447
+ }
448
+ const entries = opendirSync(directory);
449
+ try {
450
+ for (let scanned = 0; scanned < 256 && candidates.length < SPOOL_MAX_FILES; scanned++) {
451
+ const entry = entries.readSync();
452
+ if (!entry) break;
453
+ const match = segmentName.exec(entry.name);
454
+ if (entry.isFile() && match) candidates.push({ file: join(directory, entry.name), name: entry.name.split(".offset-")[0]!.split(".draining-")[0]!,
455
+ offset: Number(match[1] ?? 0), skip: !!match[2] });
456
+ }
457
+ } finally { entries.closeSync(); }
321
458
  const validRow = (row: Partial<NormalizedCapture>): row is NormalizedCapture =>
322
459
  typeof row.at === "string" && row.at.length > 0 && typeof row.project === "string" && row.project.length > 0
323
460
  && typeof row.agent === "string" && row.agent.length > 0 && typeof row.event === "string" && row.event.length > 0
324
461
  && (row.tool === null || typeof row.tool === "string") && Array.isArray(row.paths) && row.paths.every((p) => typeof p === "string")
325
462
  && (row.status === null || typeof row.status === "string") && typeof row.excerpt === "string"
326
463
  && 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; }
464
+ candidates.sort((a, b) => a.name.localeCompare(b.name));
465
+ for (const source of candidates) {
466
+ if (budget <= 0 || byteBudget <= 0 || performance.now() - started >= SPOOL_MAX_MS) break;
467
+ const file = join(directory, `${source.name}.offset-${source.offset}${source.skip ? "-skip" : ""}.draining-${randomBytes(12).toString("hex")}`);
468
+ try { renameSync(source.file, file); } catch { continue; } // another drainer claimed this exact name
469
+ let bytes: Buffer, size: number;
470
+ try {
471
+ const fd = openSync(file, "r");
472
+ try {
473
+ size = statSync(file).size;
474
+ bytes = Buffer.alloc(Math.min(byteBudget, Math.max(0, size - source.offset)));
475
+ bytes = bytes.subarray(0, readSync(fd, bytes, 0, bytes.length, source.offset));
476
+ } finally { closeSync(fd); }
477
+ } catch { continue; } // a recovery reader may already have moved our unique claim
478
+ byteBudget -= bytes.length;
479
+ let consumed = 0, skipping = source.skip;
480
+ const batch: string[] = [];
481
+ while (consumed < bytes.length && batch.length < budget) {
482
+ const newline = bytes.indexOf(10, consumed);
483
+ if (newline < 0) {
484
+ if (source.offset + bytes.length < size) {
485
+ // A short window may only be the allowance left after another file, not an oversized row.
486
+ if (consumed === 0 && bytes.length === SPOOL_MAX_BYTES) { consumed = bytes.length; skipping = true; }
487
+ break;
488
+ }
489
+ if (!skipping) batch.push(bytes.subarray(consumed).toString("utf8"));
490
+ else skipped++;
491
+ consumed = bytes.length; skipping = false; break;
492
+ }
493
+ const line = bytes.subarray(consumed, newline).toString("utf8");
494
+ consumed = newline + 1;
495
+ if (skipping) { skipped++; skipping = false; }
496
+ else if (line) batch.push(line);
333
497
  }
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
498
  try {
339
499
  this.db.transaction(() => {
340
500
  for (const line of batch) {
@@ -356,8 +516,12 @@ export class CaptureStore {
356
516
  break; // still contended: the spool stays for the next invocation
357
517
  }
358
518
  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; }
519
+ const next = source.offset + consumed;
520
+ if (next >= size) rmSync(file, { force: true });
521
+ else {
522
+ // Never rewrite a shared inode. Only the owner of this exact claim can acknowledge its prefix.
523
+ try { renameSync(file, join(directory, `${source.name}.offset-${next}${skipping ? "-skip" : ""}`)); } catch { /* recovered elsewhere */ }
524
+ }
361
525
  }
362
526
  return { drained, skipped };
363
527
  }
@@ -385,6 +549,7 @@ export class CaptureStore {
385
549
  * deleted; the curated store is a different file this class never opens (AC5).
386
550
  */
387
551
  evict(): { expired: number; trimmed: number } {
552
+ this.writable();
388
553
  if (this.contended) return { expired: 0, trimmed: 0 };
389
554
  const start = performance.now();
390
555
  const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
@@ -417,14 +582,35 @@ export class CaptureStore {
417
582
  }
418
583
 
419
584
  rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
585
+ this.checkReadBinding();
420
586
  const where: string[] = [], bind: (string | number)[] = [];
587
+ if (this.recentReadIds) { where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds)); }
421
588
  if (f.project) { where.push("project = ?"); bind.push(f.project); }
422
589
  if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
423
590
  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
591
  return rows.map(toRow);
425
592
  }
426
593
 
594
+ /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
595
+ readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
596
+ this.checkReadBinding();
597
+ const { columns, where, bind } = readWindowSql(window, "id");
598
+ if (this.recentReadIds) {
599
+ if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
600
+ where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
601
+ }
602
+ const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
603
+ if (window.query !== undefined && engine === "fts5") {
604
+ const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
605
+ where.push("id IN (SELECT rowid FROM mcp_fts WHERE mcp_fts MATCH ?)"); bind.push(match);
606
+ }
607
+ const rows = this.db.query(`SELECT *, ${columns} FROM rows WHERE ${where.join(" AND ")} ORDER BY candidate_position, candidate_id LIMIT ?`)
608
+ .all(...bind, window.limit) as (RawRow & { candidate_id: string; candidate_position: number })[];
609
+ return { rows: rows.map(toRow), ...(engine ? { engine } : {}), ...(rows.length ? { next: readRef(rows[rows.length - 1]!) } : {}) };
610
+ }
611
+
427
612
  stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
613
+ this.checkReadBinding();
428
614
  let fileBytes = 0;
429
615
  try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
430
616
  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 };
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
2
2
  import { chmodSync, existsSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
- import { listAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
5
+ import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
6
  import { readProfileJSON, validGeneration } from "./generation.ts";
7
7
  import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
8
  import { openShardStore } from "./store.ts";
@@ -73,9 +73,8 @@ export function walkShards(base: string): { hash: string; key: string; local: bo
73
73
  /** What the shards and the alias log say, before anything is persisted. */
74
74
  export function scanCatalog(base: string): CatalogProject[] {
75
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);
76
+ // The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
77
+ const latest = effectiveAliases(base);
79
78
  const out: CatalogProject[] = [];
80
79
  for (const shard of walkShards(base)) {
81
80
  let rows = 0, bytes = 0, lastAt: string | null = null;
@@ -5,7 +5,7 @@ import { realPath } from "./platform.ts";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
8
- import { listAliases, resolveAlias } from "./shard.ts";
8
+ import { aliasedKeys, resolveAlias } from "./shard.ts";
9
9
  import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
10
10
 
11
11
  /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
@@ -27,8 +27,7 @@ export async function importClaudeMemWorkState(opts: { base: string; project: st
27
27
  const limit = opts.limit ?? 10_000;
28
28
  if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
29
29
  const project = resolveAlias(opts.base, opts.project);
30
- const aliases = [...new Map(listAliases(opts.base).map(op => [op.alias, op.key]))]
31
- .filter(([, key]) => key === project).map(([alias]) => alias).sort();
30
+ const aliases = aliasedKeys(opts.base, project);
32
31
  const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
33
32
  if (!aliases.length) return result;
34
33
  const db = new Database(file, { readonly: true });
@@ -0,0 +1,31 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ export interface Machine { id: string; label: string }
6
+ /** Random per `~/.agenthub`, with a person-given label; created on first use. */
7
+ export function machineId(dir: string): Machine {
8
+ const file = join(dir, "machine.json");
9
+ const read = (): Machine | undefined => {
10
+ try {
11
+ const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
12
+ if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
13
+ } catch { /* none, or unreadable */ }
14
+ return undefined;
15
+ };
16
+ const found = read();
17
+ if (found) return found;
18
+ // Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
19
+ // which fails if another got there first; then everyone reads the one identity that won.
20
+ const id = randomBytes(8).toString("hex");
21
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
22
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
23
+ writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
24
+ try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
25
+ // A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
26
+ // fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
27
+ const won = read();
28
+ if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
29
+ return won;
30
+ }
31
+
@@ -0,0 +1,63 @@
1
+ import { closeSync, constants, fstatSync, openSync, readSync, appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /** Rolling resource samples; importing this leaf never loads a memory store or capacity probe. */
5
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; rawRssMB?: number; idleMB?: number; captureDrops?: number; captureDropReason?: CaptureDropReason }
6
+ const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
7
+
8
+ export function metricsPath(base: string): string {
9
+ return join(base, "metrics.jsonl");
10
+ }
11
+
12
+ /** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
13
+ export function appendMetrics(base: string, samples: MetricSample[]): void {
14
+ mkdirSync(base, { recursive: true, mode: 0o700 });
15
+ const file = metricsPath(base);
16
+ appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
17
+ try {
18
+ if (statSync(file).size <= METRICS_MAX_BYTES) return;
19
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
20
+ writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
21
+ } catch { /* a trim failure loses nothing: the file simply stays larger */ }
22
+ }
23
+
24
+ /** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
25
+ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
26
+ let lines: string[] = [];
27
+ try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
28
+ const out: MetricSample[] = [];
29
+ for (const line of lines.slice(-limit)) {
30
+ try {
31
+ const s = JSON.parse(line) as MetricSample;
32
+ if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
33
+ } catch { /* a malformed line is skipped */ }
34
+ }
35
+ return out;
36
+ }
37
+
38
+
39
+ export type CaptureDropReason = "spool_full" | "cwd_removed" | "undrainable_expired" | "scope_unknown";
40
+ const DROP_REASONS: readonly CaptureDropReason[] = ["spool_full", "cwd_removed", "undrainable_expired", "scope_unknown"];
41
+ /** Numeric notice only, bounded to the rolling metric tail; never an all-time counter. */
42
+ export function captureDropSummary(base: string): Partial<Record<CaptureDropReason, number>> {
43
+ const counts: Partial<Record<CaptureDropReason, number>> = {};
44
+ let fd: number | undefined;
45
+ try {
46
+ fd = openSync(metricsPath(base), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
47
+ const stat = fstatSync(fd); if (!stat.isFile()) return counts;
48
+ const start = Math.max(0, stat.size - METRICS_MAX_BYTES), bytes = Buffer.alloc(stat.size - start);
49
+ const length = readSync(fd, bytes, 0, bytes.length, start);
50
+ let text = new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(0, length));
51
+ if (start) text = text.slice(text.indexOf("\n") + 1);
52
+ for (const line of text.split("\n").filter(Boolean).slice(-METRICS_KEEP)) {
53
+ try {
54
+ const sample = JSON.parse(line) as MetricSample;
55
+ if (sample.kind !== "hook" || !DROP_REASONS.includes(sample.captureDropReason!) || !Number.isSafeInteger(sample.captureDrops) || sample.captureDrops! <= 0) continue;
56
+ const reason = sample.captureDropReason!;
57
+ counts[reason] = (counts[reason] ?? 0) + sample.captureDrops!;
58
+ } catch { /* A malformed metric is never a notice. */ }
59
+ }
60
+ } catch { /* Metric availability never denies the native call. */ }
61
+ finally { if (fd !== undefined) closeSync(fd); }
62
+ return counts;
63
+ }
@@ -6,10 +6,11 @@ export const MUTATION_BUSY_TIMEOUT_MS = 3000;
6
6
  // ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
7
7
  // that bypasses this lock are not serialized. Require every writer to take it, or make segment appends API-only.
8
8
  /** An empty, machine-local SQLite file used only for crash-safe writer exclusion, never for memory data. */
9
- export function mutationDatabase(dir: string): Database {
9
+ export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
10
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid mutation busy timeout");
10
11
  const file = join(dir, "mutation-lock.db"), db = new Database(file, { create: true });
11
12
  chmodSync(file, 0o600);
12
- db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`);
13
+ db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
13
14
  return db;
14
15
  }
15
16
 
@@ -22,13 +23,14 @@ export function acquireMutation(dir: string): (() => void) | undefined {
22
23
  }
23
24
 
24
25
  /** Short local admission fence, never held across sync/network or data mutations. */
25
- export function readerAdmissionDatabase(dir: string): Database {
26
+ export function readerAdmissionDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIMEOUT_MS): Database {
27
+ if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0 || busyTimeoutMs > MUTATION_BUSY_TIMEOUT_MS) throw new Error("invalid reader admission busy timeout");
26
28
  const file = join(dir, "reader-admission.db");
27
29
  for (const suffix of ["", "-wal", "-shm", "-journal"]) {
28
30
  const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
29
31
  if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
30
32
  }
31
33
  const db = new Database(file, { create: true });
32
- try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`); return db; }
34
+ try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${busyTimeoutMs}`); return db; }
33
35
  catch (error) { db.close(); throw error; }
34
36
  }