@staix/agent-mem 0.5.0 → 0.7.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.
Files changed (76) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +15 -0
  3. package/README.md +186 -21
  4. package/bin/amem.js +26 -3
  5. package/hooks/hooks.json +81 -0
  6. package/package.json +3 -1
  7. package/src/cli.ts +492 -39
  8. package/src/commands-table.ts +56 -15
  9. package/src/hook-contract.ts +3 -0
  10. package/src/hook-git.ts +42 -0
  11. package/src/hook-native.ts +92 -0
  12. package/src/hook-project.ts +206 -0
  13. package/src/hook-resources.ts +15 -0
  14. package/src/hook-spool.ts +566 -0
  15. package/src/hooks.ts +253 -0
  16. package/src/index.ts +18 -0
  17. package/src/ingest.ts +351 -0
  18. package/src/mcp-memory.ts +141 -33
  19. package/src/mcp.ts +42 -7
  20. package/src/memory/aliases.ts +87 -0
  21. package/src/memory/bridge.ts +8 -2
  22. package/src/memory/candidate-pending.ts +119 -0
  23. package/src/memory/candidates.ts +144 -47
  24. package/src/memory/capacity-split.ts +76 -0
  25. package/src/memory/capacity.ts +162 -59
  26. package/src/memory/capture-input.ts +36 -0
  27. package/src/memory/capture.ts +153 -39
  28. package/src/memory/catalog.ts +43 -10
  29. package/src/memory/commands.ts +98 -44
  30. package/src/memory/config.ts +167 -0
  31. package/src/memory/cross-project.ts +75 -0
  32. package/src/memory/curated-read.ts +29 -0
  33. package/src/memory/decay-review.ts +136 -0
  34. package/src/memory/doctor.ts +412 -0
  35. package/src/memory/editor.ts +58 -0
  36. package/src/memory/eval-fixture.ts +137 -0
  37. package/src/memory/eval-set.json +443 -0
  38. package/src/memory/eval.ts +150 -0
  39. package/src/memory/gc.ts +97 -0
  40. package/src/memory/generation-index.ts +22 -17
  41. package/src/memory/generation-sync.ts +3 -0
  42. package/src/memory/goal-promotion.ts +71 -0
  43. package/src/memory/home.ts +15 -4
  44. package/src/memory/intent-brief.ts +89 -0
  45. package/src/memory/lifecycle-log.ts +54 -0
  46. package/src/memory/lifecycle.ts +61 -0
  47. package/src/memory/machine.ts +31 -0
  48. package/src/memory/metrics.ts +63 -0
  49. package/src/memory/mutation.ts +6 -4
  50. package/src/memory/note-add.ts +100 -0
  51. package/src/memory/path-glob.ts +94 -0
  52. package/src/memory/plan-guard.ts +47 -0
  53. package/src/memory/platform.ts +9 -4
  54. package/src/memory/policy.ts +44 -13
  55. package/src/memory/private-spans.ts +32 -0
  56. package/src/memory/project-export.ts +122 -0
  57. package/src/memory/project.ts +129 -20
  58. package/src/memory/reader-lease.ts +22 -10
  59. package/src/memory/recall-staleness.ts +56 -0
  60. package/src/memory/recall.ts +74 -0
  61. package/src/memory/repo-scope.ts +404 -0
  62. package/src/memory/shard-sync-format.ts +116 -0
  63. package/src/memory/shard-sync.ts +421 -0
  64. package/src/memory/shard.ts +3 -80
  65. package/src/memory/store.ts +286 -56
  66. package/src/memory/sync-cadence.ts +139 -0
  67. package/src/memory/sync-screen.ts +86 -0
  68. package/src/memory/sync.ts +79 -11
  69. package/src/memory/usage.ts +35 -0
  70. package/src/memory/use-telemetry.ts +51 -0
  71. package/src/memory/work-lists.ts +115 -38
  72. package/src/session-brief-worker.ts +82 -0
  73. package/src/session-brief.ts +261 -0
  74. package/src/setup-owned.ts +120 -0
  75. package/src/setup-toml.ts +291 -0
  76. package/src/setup.ts +327 -0
@@ -1,12 +1,16 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
- import { chmodSync, closeSync, existsSync, mkdirSync, openSync, opendirSync, readFileSync, readSync, 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";
9
+ import { captureShardDir, ensureShardDescriptor, readShardDescriptor, resolveAlias, shardKeyHash } from "./shard.ts";
10
+ import { assertProjectActive } from "./lifecycle.ts";
11
+ import { machineId } from "./machine.ts";
12
+ import { loadAmemConfig, repoConfigRoot } from "./config.ts";
13
+ import { fold } from "./policy.ts";
10
14
  import { readWindowSql, readRef, type ReadWindow, type ReadRef, ftsTerms, fts5Available } from "./search-fts.ts";
11
15
 
12
16
  /**
@@ -39,15 +43,12 @@ export const CAPTURE_DEFAULT_CAP_MB = 512;
39
43
  export const CAPTURE_DEFAULT_RETENTION_DAYS = 90;
40
44
  /** Well under the 150 ms R2 wall budget even with process startup: contention spools instead of waiting. */
41
45
  export const CAPTURE_BUSY_TIMEOUT_MS = 25;
42
- export const CAPTURE_MAX_EXCERPT = 2000;
43
- export const CAPTURE_MAX_PATHS = 32;
44
46
  /** Eviction and drain work per invocation, so a shrunk cap or a long outage is worked off over many hooks. */
45
47
  export const CAPTURE_EVICT_BATCH_ROWS = 256;
46
48
  export const CAPTURE_EVICT_MAX_BATCHES = 4;
47
49
  /** Wall-time bound for one invocation's eviction: with the batch bound, an append stays inside R2 after a cap shrink. */
48
50
  export const CAPTURE_EVICT_MAX_MS = 25;
49
51
  export const CAPTURE_DRAIN_MAX_LINES = 1000;
50
- const ROW_OVERHEAD_BYTES = 96;
51
52
  const SPOOL = "spool.jsonl";
52
53
  const DRAINING = "spool.jsonl.draining";
53
54
  const SPOOL_DIR = "spool";
@@ -58,12 +59,7 @@ const segmentName = /^spool-[0-9a-f-]+\.jsonl(?:\.offset-(\d+)(-skip)?)?(?:\.dra
58
59
  let spoolSequence = 0;
59
60
  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
 
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
- }
62
+ export interface CaptureInput extends CaptureFields { project: string }
67
63
  export interface CaptureRow {
68
64
  id: number; at: string; project: string; agent: string; event: string;
69
65
  tool: string | null; paths: string[]; status: string | null; excerpt: string;
@@ -80,10 +76,29 @@ export interface CaptureOptions {
80
76
  /** Host-supplied R6 values win over the shard and home config files. */
81
77
  capMb?: number; retentionDays?: number;
82
78
  busyTimeoutMs?: number;
79
+ /** Host-only brief view: open existing capture only, without schema/index/replay writes. */
80
+ readOnly?: boolean;
81
+ /** Host-only row ceiling for the read-only snapshot; overflow is unavailable, never empty. */
82
+ maxReadRows?: number;
83
+ /** Host-only optional brief: retain the newest maxReadRows ids, with explicit partial coverage. */
84
+ recentReadRows?: boolean;
83
85
  /** Excerpt screen; defaults to the built-in secret screen. A host passes its stricter policy. */
84
86
  screen?: (text: string) => boolean;
87
+ /** Caps a caller may lower (F4); the built-in caps and the person's configured caps are the ceiling, so a larger value is ignored (a host can tighten, never loosen). */
88
+ excerptBytes?: number; capturePaths?: number;
89
+ /** The working directory the capture belongs to: its repository's `.amem/config` narrows the policy (deny paths, caps) for this store. Absent: the machine configuration only. */
90
+ cwd?: string;
91
+ /**
92
+ * #15 T5: a writable open of an archived or forgotten project is refused (the person took it out of recall; `amem project unarchive` brings an archived one back).
93
+ * `ignore` is for the maintenance that must still reach the shard (gc, export): it never appends.
94
+ */
95
+ lifecycle?: "enforce" | "ignore";
85
96
  }
86
- export type CaptureAppend = { id: number } | { spooled: true };
97
+ export interface CaptureAppendOptions {
98
+ /** Opaque stable source receipt for deterministic transcript replay. Requires an explicit source timestamp. */
99
+ receipt?: string;
100
+ }
101
+ export type CaptureAppend = { id: number; already?: true } | { spooled: true };
87
102
 
88
103
  /** One row after validation, deny-filtering, bounding and screening: what both the insert and the spool carry. */
89
104
  interface NormalizedCapture {
@@ -122,13 +137,17 @@ export function writeCaptureConfigFile(path: string, config: Partial<CaptureConf
122
137
  return next;
123
138
  }
124
139
 
125
- /** The effective R6 settings for a shard directory: shard config over the home default over the built-in defaults. */
140
+ /**
141
+ * The effective R6 settings for a shard directory: the shard's config over the home default (`capture-config.json`, which the capacity advisor writes)
142
+ * over the person's machine configuration (`amem.config.json` `capture`, F4) over the built-in defaults. A malformed machine configuration throws.
143
+ */
126
144
  export function readCaptureConfig(dir: string, base?: string): CaptureConfig {
127
145
  const homeDefault = base === undefined ? {} : readCaptureConfigFile(join(base, "capture-config.json"));
128
146
  const shard = readCaptureConfigFile(join(dir, "config.json"));
147
+ const machine = base === undefined ? {} : loadAmemConfig(base).config.capture;
129
148
  return {
130
- capMb: shard.capMb ?? homeDefault.capMb ?? CAPTURE_DEFAULT_CAP_MB,
131
- retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
149
+ capMb: shard.capMb ?? homeDefault.capMb ?? machine.capMb ?? CAPTURE_DEFAULT_CAP_MB,
150
+ retentionDays: shard.retentionDays ?? homeDefault.retentionDays ?? machine.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
132
151
  };
133
152
  }
134
153
 
@@ -154,28 +173,90 @@ export class CaptureStore {
154
173
  private readonly db: Database;
155
174
  private readonly now: () => number;
156
175
  private readonly screen: (text: string) => boolean;
176
+ private readonly caps: import("./capture-input.ts").CaptureCaps;
157
177
  private readonly capMb: number;
158
178
  private readonly retentionDays: number;
159
179
  /** Set when the schema setup itself met the lock: appends spool without touching the database again. */
160
180
  private contended = false;
181
+ private readonly readOnly: boolean;
182
+ private recentReadIds?: number[];
183
+ /** True when the optional recent snapshot excludes older capture rows. */
184
+ readonly recentReadTruncated: boolean = false;
161
185
 
162
186
  constructor(opts: CaptureOptions) {
163
187
  if (typeof opts.project !== "string" || !opts.project.trim()) throw new Error("capture shards need a project key");
164
188
  if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
189
+ this.readOnly = opts.readOnly === true;
190
+ if (opts.readOnly !== undefined && typeof opts.readOnly !== "boolean") throw new Error("invalid capture read-only option");
191
+ if (opts.recentReadRows !== undefined && (typeof opts.recentReadRows !== "boolean" || !this.readOnly)) throw new Error("recent capture rows require a read-only store");
192
+ const maxReadRows = opts.maxReadRows ?? 128;
193
+ if (this.readOnly && (!Number.isSafeInteger(maxReadRows) || maxReadRows < 1 || maxReadRows >= Number.MAX_SAFE_INTEGER)) throw new Error("invalid capture read row bound");
165
194
  this.base = opts.memoryDir ?? memoryBase(opts.home);
166
- const machine = opts.machine ?? machineId(this.base).id;
195
+ const machine = opts.machine ?? (this.readOnly ? this.existingMachine() : machineId(this.base).id);
196
+ if (this.readOnly && !/^[a-f0-9]{16}$/.test(machine)) throw new Error("capture machine identity unavailable");
167
197
  this.requestedKey = opts.project;
168
198
  this.key = resolveAlias(this.base, opts.project);
169
- ensureShardDescriptor(this.base, this.key);
199
+ if (!this.readOnly && opts.lifecycle !== "ignore") assertProjectActive(this.base, this.key);
200
+ if (this.readOnly) {
201
+ const descriptor = lstatSync(join(this.base, "projects", shardKeyHash(this.key), "project.json"));
202
+ if (!descriptor.isFile() || descriptor.isSymbolicLink() || descriptor.size > 4096
203
+ || readShardDescriptor(this.base, shardKeyHash(this.key))?.key !== this.key) throw new Error("capture shard identity unavailable");
204
+ } else ensureShardDescriptor(this.base, this.key);
170
205
  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
206
+ if (!this.readOnly) {
207
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
208
+ chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
209
+ }
210
+ if (this.readOnly) for (const file of [join(this.dir, "config.json"), join(this.base, "capture-config.json")]) {
211
+ const stat = lstatSync(file, { throwIfNoEntry: false });
212
+ if (stat && (!stat.isFile() || stat.isSymbolicLink() || stat.size > 64 * 1024)) throw new Error("capture configuration unavailable");
213
+ }
173
214
  const config = readCaptureConfig(this.dir, this.base); // each invocation: a config change applies at the next hook
174
215
  this.capMb = opts.capMb ?? config.capMb;
175
216
  this.retentionDays = opts.retentionDays ?? config.retentionDays;
176
217
  this.now = opts.now ?? Date.now;
177
- this.screen = opts.screen ?? looksSecret;
218
+ const configured = loadAmemConfig(this.base, opts.cwd === undefined ? undefined : repoConfigRoot(opts.cwd)).config;
219
+ // F4: the person's PII patterns screen every excerpt and their deny paths filter every path, whoever appends (hook, ingest or library). A host's own `screen` replaces only the secret part.
220
+ const patterns = configured.piiPatterns.map(fold).filter(Boolean);
221
+ const base = opts.screen ?? looksSecret;
222
+ this.screen = patterns.length ? (text: string) => base(text) || patterns.some(pattern => fold(text).includes(pattern)) : base;
223
+ const lowest = (a: number | undefined, b: number | undefined) => a === undefined ? b : b === undefined ? a : Math.min(a, b);
224
+ this.caps = { excerptBytes: lowest(opts.excerptBytes, configured.excerptBytes), paths: lowest(opts.capturePaths, configured.capturePaths),
225
+ ...(configured.denyPaths.length ? { denyPaths: configured.denyPaths } : {}) };
178
226
  const file = join(this.dir, "capture.db");
227
+ if (this.readOnly) {
228
+ // Refuse absent/cold state without authoring machine, shard, schema, FTS or spool metadata.
229
+ for (const directory of [this.base, join(this.base, "projects"), join(this.base, "projects", shardKeyHash(this.key)),
230
+ join(this.base, "projects", shardKeyHash(this.key), "machine"), this.dir]) {
231
+ const stat = lstatSync(directory);
232
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("capture read path unavailable");
233
+ }
234
+ const stat = lstatSync(file);
235
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("capture database unavailable");
236
+ const timeout = opts.busyTimeoutMs ?? 0;
237
+ if (!Number.isSafeInteger(timeout) || timeout < 0 || timeout > 3000) throw new Error("invalid capture busy timeout");
238
+ const db = this.db = new Database(file, { readonly: true });
239
+ try {
240
+ db.run(`PRAGMA busy_timeout = ${timeout}`); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
241
+ db.run("BEGIN");
242
+ if (opts.recentReadRows) {
243
+ // The existing rows_at index supplies the reverse time/id walk; retain ids only.
244
+ // One sentinel records excluded history without counting or loading its bodies.
245
+ const sql = "SELECT id FROM rows INDEXED BY rows_at ORDER BY at DESC, id DESC LIMIT ?";
246
+ const plan = db.query(`EXPLAIN QUERY PLAN ${sql}`).all(maxReadRows + 1) as { detail: string }[];
247
+ if (plan.some(row => row.detail.includes("TEMP B-TREE"))) throw new Error("capture recent index unavailable");
248
+ const recent = db.query(sql).all(maxReadRows + 1) as { id: number }[];
249
+ this.recentReadIds = recent.slice(0, maxReadRows).map(row => row.id);
250
+ this.recentReadTruncated = recent.length > maxReadRows;
251
+ } else {
252
+ const count = (db.query("SELECT COUNT(*) AS n FROM (SELECT 1 FROM rows LIMIT ?)").get(maxReadRows + 1) as { n: number }).n;
253
+ if (count > maxReadRows) throw new Error("capture brief row bound exceeded");
254
+ }
255
+ if (resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
256
+ } catch (error) { db.close(); throw error; }
257
+ this.migrated = 0;
258
+ return;
259
+ }
179
260
  const db = new Database(file, { create: true });
180
261
  this.db = db;
181
262
  try { chmodSync(file, 0o600); } catch { /* best effort */ }
@@ -195,6 +276,20 @@ export class CaptureStore {
195
276
  this.migrated = this.contended ? 0 : this.migrateLegacy();
196
277
  }
197
278
 
279
+ private existingMachine(): string {
280
+ const file = join(this.base, "machine.json"), stat = lstatSync(file);
281
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4096) throw new Error("capture machine identity unavailable");
282
+ const value = JSON.parse(readFileSync(file, "utf8")) as { id?: unknown };
283
+ if (typeof value?.id !== "string" || !/^[a-f0-9]{16}$/.test(value.id)) throw new Error("capture machine identity unavailable");
284
+ return value.id;
285
+ }
286
+ private writable(): void {
287
+ if (this.readOnly) throw new Error("capture store is read-only");
288
+ }
289
+ private checkReadBinding(): void {
290
+ if (this.readOnly && resolveAlias(this.base, this.requestedKey) !== this.key) throw new Error("capture alias binding changed");
291
+ }
292
+
198
293
  /** 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
294
  private migrateLegacy(): number {
200
295
  const legacyDir = join(this.base, "capture"); // the pre-T1 machine-wide location (this branch never shipped)
@@ -270,6 +365,7 @@ export class CaptureStore {
270
365
  private mcpReady = false;
271
366
  /** One-time MCP read cost only: hooks neither create this derived index nor scan history to fill it. */
272
367
  private ensureMcpFts(): boolean {
368
+ if (this.readOnly) return !!this.db.query("SELECT 1 FROM sqlite_master WHERE name = 'mcp_fts' AND type = 'table'").get();
273
369
  if (!fts5Available()) return false;
274
370
  if (this.mcpReady) return true;
275
371
  try {
@@ -303,46 +399,54 @@ export class CaptureStore {
303
399
  * and a later drainer — under any screen — inserts exactly that row. On lock contention the row goes to the spool
304
400
  * and the caller exits fast (R2); the next uncontended writer replays it with its original timestamp.
305
401
  */
306
- append(input: CaptureInput): CaptureAppend {
402
+ append(input: CaptureInput, options: CaptureAppendOptions = {}): CaptureAppend {
403
+ this.writable();
307
404
  const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
308
405
  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
406
  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");
407
+ const receipt = options.receipt;
408
+ 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");
310
409
  const row = this.normalize(input);
410
+ // Reuse the immutable spool format and its existing unique index: a crash between capture and cursor
411
+ // advance replays exactly the same screened line, whether first written directly or under contention.
412
+ const spoolId = receipt === undefined ? null : createHash("sha256").update(this.spoolLine(row, receipt)).digest("hex");
311
413
  if (this.contended) {
312
- this.spool(row);
414
+ this.spool(row, receipt);
313
415
  return { spooled: true };
314
416
  }
315
417
  try {
316
418
  this.drain(); // replay earlier spooled rows first, so row ids stay in hook order
317
419
  // 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();
420
+ const result = this.db.transaction((): CaptureAppend => {
421
+ const id = this.insertRow(row, spoolId);
422
+ if (id !== undefined) return { id };
423
+ const prior = this.db.query("SELECT id FROM rows WHERE spool_id = ?").get(spoolId) as { id: number } | null;
424
+ if (!prior) throw new Error("capture receipt lookup failed");
425
+ return { id: prior.id, already: true };
426
+ }).immediate();
319
427
  try { this.evict(); } catch (error) {
320
428
  if (!lockError(error)) throw error; // the row is already committed; a locked eviction simply resumes on a later append
321
429
  }
322
- return { id: id! };
430
+ return result;
323
431
  } catch (error) {
324
432
  if (!lockError(error)) throw error;
325
- this.spool(row);
433
+ this.spool(row, receipt);
326
434
  return { spooled: true };
327
435
  }
328
436
  }
329
437
 
330
438
  /** The one place input becomes a row: caps fields, deny-filters paths, screens the excerpt, counts its bytes. */
331
439
  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 };
440
+ const { at, agent, event, tool, paths, status, excerpt, bytes } = normalizeCaptureFields(input, () => this.now(), text => this.screen(text), this.caps);
441
+ return { at, project: input.project, agent, event, tool, paths, status, excerpt, bytes };
342
442
  }
343
443
 
344
- private spool(row: NormalizedCapture): void {
345
- this.spoolRaw(JSON.stringify({ ...row, nonce: randomBytes(12).toString("hex") }));
444
+ private spoolLine(row: NormalizedCapture, nonce = randomBytes(12).toString("hex")): string {
445
+ return JSON.stringify({ ...row, nonce });
446
+ }
447
+
448
+ private spool(row: NormalizedCapture, nonce?: string): void {
449
+ this.spoolRaw(this.spoolLine(row, nonce));
346
450
  }
347
451
 
348
452
  /**
@@ -355,6 +459,7 @@ export class CaptureStore {
355
459
  * already-open shared inode cannot be made immutable by a newer reader.
356
460
  */
357
461
  drain(): { drained: number; skipped: number } {
462
+ this.writable();
358
463
  let budget = CAPTURE_DRAIN_MAX_LINES, byteBudget = SPOOL_MAX_BYTES, drained = 0, skipped = 0;
359
464
  if (this.contended) return { drained, skipped };
360
465
  const started = performance.now();
@@ -469,6 +574,7 @@ export class CaptureStore {
469
574
  * deleted; the curated store is a different file this class never opens (AC5).
470
575
  */
471
576
  evict(): { expired: number; trimmed: number } {
577
+ this.writable();
472
578
  if (this.contended) return { expired: 0, trimmed: 0 };
473
579
  const start = performance.now();
474
580
  const expired = this.evictWhere("at < ?", [new Date(this.now() - this.retentionDays * 24 * 3600_000).toISOString()], start);
@@ -501,7 +607,9 @@ export class CaptureStore {
501
607
  }
502
608
 
503
609
  rows(f: { project?: string; sinceId?: number; limit?: number } = {}): CaptureRow[] {
610
+ this.checkReadBinding();
504
611
  const where: string[] = [], bind: (string | number)[] = [];
612
+ if (this.recentReadIds) { where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds)); }
505
613
  if (f.project) { where.push("project = ?"); bind.push(f.project); }
506
614
  if (f.sinceId !== undefined) { where.push("id > ?"); bind.push(f.sinceId); }
507
615
  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[];
@@ -510,7 +618,12 @@ export class CaptureStore {
510
618
 
511
619
  /** Exact-project, bounded body pages; MCP screens before filling its public candidate window. */
512
620
  readPage(window: ReadWindow & { query?: string; match?: "substring" }): { rows: CaptureRow[]; next?: ReadRef; engine?: "fts5" | "substring" } {
621
+ this.checkReadBinding();
513
622
  const { columns, where, bind } = readWindowSql(window, "id");
623
+ if (this.recentReadIds) {
624
+ if (window.query !== undefined) throw new Error("recent capture brief does not support query indexes");
625
+ where.push("id IN (SELECT value FROM json_each(?))"); bind.push(JSON.stringify(this.recentReadIds));
626
+ }
514
627
  const engine = window.query === undefined ? undefined : window.match !== "substring" && this.ensureMcpFts() ? "fts5" : "substring";
515
628
  if (window.query !== undefined && engine === "fts5") {
516
629
  const match = ftsTerms(window.query); if (!match) return { rows: [], engine };
@@ -522,6 +635,7 @@ export class CaptureStore {
522
635
  }
523
636
 
524
637
  stats(): { rows: number; bytes: number; capMb: number; retentionDays: number; fileBytes: number } {
638
+ this.checkReadBinding();
525
639
  let fileBytes = 0;
526
640
  try { fileBytes = statSync(join(this.dir, "capture.db")).size; } catch { /* not created yet */ }
527
641
  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 };
@@ -1,11 +1,12 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { chmodSync, existsSync, readdirSync } from "node:fs";
2
+ import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { memoryBase } from "./home.ts";
5
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";
9
+ import { lifecycleResolver, type LifecycleState } from "./lifecycle.ts";
9
10
 
10
11
  /**
11
12
  * #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
@@ -20,12 +21,16 @@ export interface CatalogProject {
20
21
  captureRows: number; captureBytes: number; captureLastAt: string | null;
21
22
  curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
22
23
  capMb: number; retentionDays: number;
24
+ /** #15 T5: the person's lifecycle decision (`lifecycle.jsonl`); forgotten projects stay in the scan so the state is visible, and listings leave them out. */
25
+ state: LifecycleState;
26
+ /** Bytes the shard directory holds on disk (its logs, indexes, capture and work lists), counting files and never following a link. */
27
+ diskBytes: number;
23
28
  }
24
29
 
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 }
30
+ 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; state: string; disk_bytes: number }
26
31
 
27
32
  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 });
33
+ 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, state: r.state === "archived" || r.state === "forgotten" ? r.state : "active", diskBytes: r.disk_bytes });
29
34
 
30
35
  /** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
31
36
  function capturedSelection(base: string): boolean {
@@ -41,7 +46,7 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
41
46
  const dir = join(base, "projects", hash);
42
47
  // A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
43
48
  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 });
49
+ const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
45
50
  try {
46
51
  const db = new Database(join(dir, "index.db"), { readonly: true });
47
52
  try {
@@ -52,6 +57,23 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
52
57
  } finally { store.close(); }
53
58
  }
54
59
 
60
+ /** Bytes of regular files under a shard directory. A link is not followed (counted as nothing), an unreadable entry as nothing: the figure is a lower bound, never a guess. */
61
+ export function directoryBytes(dir: string, depth = 0): number {
62
+ if (depth > 12) return 0;
63
+ let total = 0;
64
+ let entries: import("node:fs").Dirent[] = [];
65
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return 0; }
66
+ for (const entry of entries) {
67
+ const path = join(dir, entry.name);
68
+ try {
69
+ if (entry.isSymbolicLink()) continue;
70
+ if (entry.isDirectory()) total += directoryBytes(path, depth + 1);
71
+ else if (entry.isFile()) total += lstatSync(path).size;
72
+ } catch { /* vanished or unreadable: not counted */ }
73
+ }
74
+ return total;
75
+ }
76
+
55
77
  /** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
56
78
  export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
57
79
  const projectsDir = join(base, "projects");
@@ -75,6 +97,7 @@ export function scanCatalog(base: string): CatalogProject[] {
75
97
  const captured = capturedSelection(base);
76
98
  // 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
99
  const latest = effectiveAliases(base);
100
+ const stateOf = lifecycleResolver(base);
78
101
  const out: CatalogProject[] = [];
79
102
  for (const shard of walkShards(base)) {
80
103
  let rows = 0, bytes = 0, lastAt: string | null = null;
@@ -88,11 +111,17 @@ export function scanCatalog(base: string): CatalogProject[] {
88
111
  const curated = curatedStats(base, shard.hash, shard.key, captured);
89
112
  out.push({ hash: shard.hash, key: shard.key, local: shard.local,
90
113
  aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
91
- captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays });
114
+ captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays,
115
+ state: stateOf(shard.key), diskBytes: directoryBytes(join(base, "projects", shard.hash)) });
92
116
  }
93
117
  return out.sort((a, b) => a.key.localeCompare(b.key));
94
118
  }
95
119
 
120
+ /** A fresh scan of the shards for a read-only command (`amem usage`, `amem projects`): it writes nothing, `catalog.db` included. */
121
+ export function readCatalogFresh(base: string): CatalogProject[] {
122
+ return scanCatalog(base);
123
+ }
124
+
96
125
  /** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
97
126
  export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
98
127
  const base = memoryBase(opts.home);
@@ -105,12 +134,13 @@ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
105
134
  db.transaction(() => {
106
135
  const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
107
136
  // This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
108
- 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");
137
+ if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at")
138
+ || !columns.some(column => column.name === "state") || !columns.some(column => column.name === "disk_bytes"))) db.run("DROP TABLE projects");
109
139
  db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
110
- 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)`);
140
+ 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, state TEXT NOT NULL DEFAULT 'active', disk_bytes INTEGER NOT NULL DEFAULT 0)`);
111
141
  db.run("DELETE FROM projects");
112
- 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
113
- .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);
142
+ 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, state, disk_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
143
+ .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, p.state, p.diskBytes);
114
144
  }).immediate();
115
145
  } finally { db.close(); }
116
146
  return projects;
@@ -127,8 +157,11 @@ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
127
157
  db = new Database(file, { readonly: true });
128
158
  db.run("PRAGMA busy_timeout = 3000");
129
159
  const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
130
- if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
160
+ if (!columns.has("curated_entries") || !columns.has("curated_last_at") || !columns.has("state") || !columns.has("disk_bytes")) throw new Error("catalog schema needs rebuild");
161
+ // The state is the person's decision in lifecycle.jsonl, which changes without touching a shard: read it live, never from the cached row.
162
+ const stateOf = lifecycleResolver(base);
131
163
  return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
164
+ .map(project => ({ ...project, state: stateOf(project.key) }))
132
165
  .map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
133
166
  } catch { return rebuildCatalog(opts); }
134
167
  finally { db?.close(); }