@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
@@ -7,8 +7,9 @@ import { canonicalJSON, hashJSON } from "./generation.ts";
7
7
  import { mutationDatabase } from "./mutation.ts";
8
8
  import { canonicalPath } from "./platform.ts";
9
9
  import { looksSecret } from "./screen.ts";
10
+ import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
10
11
  import { ensureShardDescriptor, resolveAlias, shardDir } from "./shard.ts";
11
- import { machineId } from "./store.ts";
12
+ import { machineId } from "./machine.ts";
12
13
 
13
14
  const SCHEMA = "agent-mem.work-list/v1", ROW_BYTES = 16_384;
14
15
  export type WorkListFields = Record<string, string | number | boolean | null>;
@@ -63,6 +64,9 @@ function sourceKey(value: string): string {
63
64
  });
64
65
  return parts.join("") + " " + lexicalKey(value);
65
66
  }
67
+ // ponytail: the key spells machine, source and id as 4 hex digits a UTF-16 unit, and the source id twice
68
+ // (sourceKey keeps the full id as its tie-break), so a row at the 500-character input limits carries a key
69
+ // of several KB. Upgrade path: past a length, replace the full-id tie-break with a fixed-width hash of it.
66
70
  /** Derived only: timestamp, import/native origin, natural source id, machine, append position, row hash.
67
71
  * Fixed-width safe integers and encoded strings let the persisted index supply the legacy replay order. */
68
72
  function replayKey(row: WorkRow, position: number): string {
@@ -73,13 +77,14 @@ function replayKey(row: WorkRow, position: number): string {
73
77
  }
74
78
 
75
79
  /** Stream log bytes with bounded carry; neither a long history nor an invalid oversized row is loaded at once. */
76
- function scan(path: string, consume?: (text: string, end: number) => void, prefixBytes = 0): { bytes: number; hash: string; prefix: string } {
80
+ function scan(path: string, consume?: (text: string, end: number) => void, prefixBytes = 0, check: () => void = () => {}): { bytes: number; hash: string; prefix: string } {
77
81
  const stat = lstatSync(path);
78
82
  if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("work log is not a regular file");
79
83
  const fd = openSync(path, "r"), buffer = Buffer.alloc(64 * 1024), full = createHash("sha256"), prefix = createHash("sha256"), decoder = new StringDecoder("utf8");
80
84
  let bytes = 0, text = "", lineEnd = 0;
81
85
  try {
82
86
  for (;;) {
87
+ check();
83
88
  const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break;
84
89
  const chunk = buffer.subarray(0, n); full.update(chunk);
85
90
  if (bytes < prefixBytes) prefix.update(chunk.subarray(0, Math.min(n, prefixBytes - bytes)));
@@ -88,6 +93,7 @@ function scan(path: string, consume?: (text: string, end: number) => void, prefi
88
93
  text += decoder.write(chunk);
89
94
  let end: number;
90
95
  while ((end = text.indexOf("\n")) >= 0) {
96
+ check();
91
97
  const line = text.slice(0, end); lineEnd += Buffer.byteLength(line + "\n"); text = text.slice(end + 1);
92
98
  if (!line || Buffer.byteLength(line) > ROW_BYTES) throw new Error("work log contains an invalid row");
93
99
  consume(line, lineEnd);
@@ -95,6 +101,7 @@ function scan(path: string, consume?: (text: string, end: number) => void, prefi
95
101
  if (Buffer.byteLength(text) > ROW_BYTES) throw new Error("work log contains an oversized row");
96
102
  }
97
103
  }
104
+ check();
98
105
  if (consume && (text + decoder.end()).length) throw new Error("work log has an incomplete row");
99
106
  return { bytes, hash: full.digest("hex"), prefix: prefix.digest("hex") };
100
107
  } finally { closeSync(fd); }
@@ -109,42 +116,78 @@ export class WorkListStore {
109
116
  private readonly machine: string;
110
117
  private readonly policy: WorkListPolicy;
111
118
  private readonly now: () => number;
119
+ private readonly timeRemaining: (() => number) | undefined;
120
+ private readonly busyTimeoutMs: number;
112
121
  private readonly readers = new Set<() => void>();
113
122
  private closed = false;
114
123
  /** #27: the work logs of keys aliased to this project that already have one; read beside this log through their own snapshot cursors, never written. */
115
124
  private readonly aliased: WorkListStore[] = [];
116
125
 
117
- constructor(options: { base: string; project: string; policy: WorkListPolicy; now?: () => number; aliases?: readonly string[]; exact?: boolean }) {
126
+ constructor(options: { base: string; project: string; policy: WorkListPolicy; now?: () => number; aliases?: readonly string[]; exact?: boolean; timeRemaining?: () => number; busyTimeoutMs?: number }) {
118
127
  if (!bounded(options.project, 1000) || typeof options.policy !== "function") throw new Error("invalid work-list input");
128
+ this.timeRemaining = options.timeRemaining;
129
+ this.busyTimeoutMs = options.busyTimeoutMs ?? 3000;
130
+ if (this.timeRemaining !== undefined && typeof this.timeRemaining !== "function"
131
+ || !Number.isSafeInteger(this.busyTimeoutMs) || this.busyTimeoutMs < 0 || this.busyTimeoutMs > 3000) throw new Error("invalid work-list budget");
132
+ this.checkBudget();
119
133
  const base = canonicalPath(options.base);
120
134
  this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
121
135
  regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
136
+ this.checkBudget();
122
137
  ensureShardDescriptor(base, this.project);
123
138
  this.dir = join(shardDir(base, this.project), "work");
124
139
  regular(this.dir, true); regular(join(this.dir, "log"), true);
125
140
  mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
126
141
  mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
127
142
  if (lstatSync(this.dir).isSymbolicLink() || lstatSync(join(this.dir, "log")).isSymbolicLink()) throw new Error("work log directory is invalid");
143
+ this.checkBudget();
128
144
  this.machine = machineId(base).id;
129
145
  this.policy = options.policy; this.now = options.now ?? Date.now;
130
146
  for (const name of ["mutation-lock.db", "index.db", "index.db-wal", "index.db-shm"]) regular(join(this.dir, name));
131
- this.mutation = mutationDatabase(this.dir);
132
- this.db = new Database(join(this.dir, "index.db"), { create: true }); chmodSync(join(this.dir, "index.db"), 0o600);
133
- this.db.run("PRAGMA busy_timeout = 3000"); this.db.run("PRAGMA journal_mode = WAL");
134
- // The index is disposable. An older derived schema is replayed without changing a log row.
135
- const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
136
- if (columns.length && !columns.some(column => column.name === "position")) { this.db.run("DROP TABLE work_ops"); this.db.run("DROP TABLE IF EXISTS work_heads"); }
137
- this.db.run("CREATE TABLE IF NOT EXISTS work_ops (id TEXT PRIMARY KEY, at INTEGER NOT NULL, machine TEXT NOT NULL, payload TEXT NOT NULL, receipt TEXT, position INTEGER NOT NULL, replay TEXT NOT NULL DEFAULT '')");
138
- this.db.run("CREATE INDEX IF NOT EXISTS work_receipts ON work_ops(receipt)");
139
- this.db.run("CREATE TABLE IF NOT EXISTS work_heads (file TEXT PRIMARY KEY, bytes INTEGER NOT NULL, hash TEXT NOT NULL)");
140
- this.db.transaction(() => {
147
+ let openedMutation: Database | undefined, openedDb: Database | undefined;
148
+ try {
149
+ this.mutation = openedMutation = mutationDatabase(this.dir, this.busyTimeoutMs);
150
+ this.checkBudget();
151
+ this.db = openedDb = new Database(join(this.dir, "index.db"), { create: true }); chmodSync(join(this.dir, "index.db"), 0o600);
152
+ this.db.run(`PRAGMA busy_timeout = ${this.busyTimeoutMs}`); this.db.run("PRAGMA journal_mode = WAL");
153
+ // The index is disposable. An older derived schema is replayed without changing a log row.
154
+ this.checkBudget();
141
155
  const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
142
- if (!columns.some(column => column.name === "replay")) this.db.run("ALTER TABLE work_ops ADD COLUMN replay TEXT NOT NULL DEFAULT ''");
143
- this.db.run("CREATE INDEX IF NOT EXISTS work_replay ON work_ops(replay)");
144
- }).immediate();
145
- for (const alias of options.exact === true ? [] : options.aliases ?? []) {
146
- if (alias === this.project || !existsSync(join(shardDir(base, alias), "work", "log"))) continue;
147
- this.aliased.push(new WorkListStore({ base, project: alias, policy: options.policy, ...(options.now ? { now: options.now } : {}), exact: true }));
156
+ if (columns.length && !columns.some(column => column.name === "position")) { this.db.run("DROP TABLE work_ops"); this.db.run("DROP TABLE IF EXISTS work_heads"); }
157
+ this.db.run("CREATE TABLE IF NOT EXISTS work_ops (id TEXT PRIMARY KEY, at INTEGER NOT NULL, machine TEXT NOT NULL, payload TEXT NOT NULL, receipt TEXT, position INTEGER NOT NULL, replay TEXT NOT NULL DEFAULT '')");
158
+ this.db.run("CREATE INDEX IF NOT EXISTS work_receipts ON work_ops(receipt)");
159
+ this.db.run("CREATE TABLE IF NOT EXISTS work_heads (file TEXT PRIMARY KEY, bytes INTEGER NOT NULL, hash TEXT NOT NULL)");
160
+ // Take the write transaction only when the replay column or its index is missing, so an ordinary open never
161
+ // waits on another process's refresh or rebuild (#383). The check repeats inside the transaction.
162
+ const migrated = () => (this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[]).some(column => column.name === "replay")
163
+ && (this.db.query("PRAGMA index_list(work_ops)").all() as { name: string }[]).some(index => index.name === "work_replay");
164
+ this.checkBudget();
165
+ if (!migrated()) this.db.transaction(() => {
166
+ this.checkBudget();
167
+ const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
168
+ if (!columns.some(column => column.name === "replay")) this.db.run("ALTER TABLE work_ops ADD COLUMN replay TEXT NOT NULL DEFAULT ''");
169
+ this.db.run("CREATE INDEX IF NOT EXISTS work_replay ON work_ops(replay)");
170
+ }).immediate();
171
+ this.checkBudget();
172
+ for (const alias of options.exact === true ? [] : options.aliases ?? []) {
173
+ this.checkBudget();
174
+ if (alias === this.project || !existsSync(join(shardDir(base, alias), "work", "log"))) continue;
175
+ this.aliased.push(new WorkListStore({ base, project: alias, policy: options.policy, ...(options.now ? { now: options.now } : {}), exact: true,
176
+ timeRemaining: this.timeRemaining, busyTimeoutMs: this.busyTimeoutMs }));
177
+ }
178
+ this.checkBudget();
179
+ } catch (error) {
180
+ for (const store of this.aliased) try { store.close(); } catch { /* Preserve the construction failure. */ }
181
+ try { openedDb?.close(); } catch { /* Preserve the construction failure. */ }
182
+ try { openedMutation?.close(); } catch { /* Preserve the construction failure. */ }
183
+ throw error;
184
+ }
185
+ }
186
+
187
+ private checkBudget(): void {
188
+ if (this.timeRemaining) {
189
+ const remaining = this.timeRemaining();
190
+ if (!Number.isFinite(remaining) || remaining <= 0) throw new Error("work-list read deadline");
148
191
  }
149
192
  }
150
193
 
@@ -159,7 +202,8 @@ export class WorkListStore {
159
202
  }
160
203
  private locked<T>(run: () => T): T {
161
204
  if (this.closed) throw new Error("work-list store is closed");
162
- return this.mutation.transaction(run).immediate();
205
+ this.checkBudget();
206
+ return this.mutation.transaction(() => { const result = run(); this.checkBudget(); return result; }).immediate();
163
207
  }
164
208
  private decode(text: string): WorkRow {
165
209
  try {
@@ -172,57 +216,73 @@ export class WorkListStore {
172
216
  } catch { throw new Error("work log contains an invalid hashed row"); }
173
217
  }
174
218
  private index(row: WorkRow, position: number): void {
219
+ this.checkBudget();
175
220
  const key = receipt(row);
176
221
  if (key) for (const prior of this.db.query("SELECT payload FROM work_ops WHERE receipt = ?").iterate(key) as Iterable<{ payload: string }>) {
222
+ this.checkBudget();
177
223
  if (importPayload(this.decode(prior.payload)) !== importPayload(row)) throw new Error("work log contains conflicting source receipts");
178
224
  }
179
225
  this.db.query("INSERT OR IGNORE INTO work_ops (id, at, machine, payload, receipt, position, replay) VALUES (?, ?, ?, ?, ?, ?, ?)").run(row.id, row.at, row.machine, canonicalJSON(row), key, position, replayKey(row, position));
180
226
  }
181
227
  private refresh(force = false): void {
182
- const files = readdirSync(join(this.dir, "log")).sort();
228
+ this.checkBudget();
229
+ const files = readdirSync(join(this.dir, "log")).sort((a, b) => { this.checkBudget(); return a < b ? -1 : a > b ? 1 : 0; });
183
230
  if (files.some(file => !/^[a-f0-9]{16}\.jsonl$/.test(file))) throw new Error("work log has an invalid segment name");
184
231
  const heads = new Map((this.db.query("SELECT file, bytes, hash FROM work_heads").all() as Head[]).map(head => [head.file, head]));
185
- const scans = new Map(files.map(file => [file, scan(join(this.dir, "log", file), undefined, heads.get(file)?.bytes ?? 0)]));
232
+ const scans = new Map(files.map(file => [file, scan(join(this.dir, "log", file), undefined, heads.get(file)?.bytes ?? 0, () => this.checkBudget())]));
186
233
  const reset = force || [...heads].some(([file, head]) => !scans.has(file) || scans.get(file)!.bytes < head.bytes || scans.get(file)!.prefix !== head.hash);
187
234
  this.db.transaction(() => {
188
235
  if (reset) { this.db.run("DELETE FROM work_ops"); this.db.run("DELETE FROM work_heads"); }
189
236
  for (const file of files) {
237
+ this.checkBudget();
190
238
  const old = reset ? 0 : heads.get(file)?.bytes ?? 0, current = scans.get(file)!;
191
- if (old !== current.bytes) scan(join(this.dir, "log", file), (line, end) => { if (end > old) this.index(this.decode(line), end); });
192
- this.db.query("INSERT OR REPLACE INTO work_heads (file, bytes, hash) VALUES (?, ?, ?)").run(file, current.bytes, current.hash);
239
+ if (old !== current.bytes) scan(join(this.dir, "log", file), (line, end) => { if (end > old) this.index(this.decode(line), end); }, 0, () => this.checkBudget());
240
+ const head = heads.get(file);
241
+ if (reset || !head || head.bytes !== current.bytes || head.hash !== current.hash) this.db.query("INSERT OR REPLACE INTO work_heads (file, bytes, hash) VALUES (?, ?, ?)").run(file, current.bytes, current.hash);
193
242
  }
194
243
  // Old readers may rebuild/append this disposable index without the additive replay column. Backfill one
195
244
  // row at a time, including on an otherwise unchanged refresh; no payload history is materialized.
196
245
  const missing = this.db.query("SELECT id, payload, position FROM work_ops WHERE replay = '' LIMIT 1");
197
246
  const update = this.db.query("UPDATE work_ops SET replay = ? WHERE id = ?");
198
247
  for (;;) {
248
+ this.checkBudget();
199
249
  const old = missing.get() as { id: string; payload: string; position: number } | null;
200
250
  if (!old) break;
201
251
  update.run(replayKey(this.decode(old.payload), old.position), old.id);
202
252
  }
253
+ this.checkBudget();
203
254
  }).immediate();
204
255
  }
205
256
  rebuild(): void { this.locked(() => this.refresh(true)); }
206
257
  private async permitted(row: WorkRow): Promise<boolean> {
258
+ this.checkBudget();
207
259
  if (looksSecret(canonicalJSON(row))) return false;
208
260
  const copy = structuredClone(row); Object.freeze(copy.fields); if (copy.source) Object.freeze(copy.source); Object.freeze(copy);
209
- try { return await this.policy(copy) === true; } catch { return false; }
261
+ let allowed = false;
262
+ try { allowed = await this.policy(copy) === true; } catch { /* Refusal remains a refusal. */ }
263
+ this.checkBudget();
264
+ return allowed;
210
265
  }
211
266
 
212
267
  /** An ordered, stable read snapshot. The separate read-only connection never holds the mutation lock across
213
268
  * a host policy await, so a concurrent append succeeds without changing this read's history frontier. */
214
269
  private operations(): IterableIterator<IndexedRow> {
215
- const state = this.locked(() => {
270
+ let opened: Database | undefined;
271
+ let state: { db: Database; rows: IterableIterator<{ payload: string; position: number; replay: string }>; first: IteratorResult<{ payload: string; position: number; replay: string }> };
272
+ try { state = this.locked(() => {
216
273
  this.refresh();
217
274
  regular(join(this.dir, "index.db"));
218
- const db = new Database(join(this.dir, "index.db"), { readonly: true });
275
+ const db = opened = new Database(join(this.dir, "index.db"), { readonly: true });
219
276
  try {
220
- db.run("PRAGMA busy_timeout = 3000"); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
277
+ db.run(`PRAGMA busy_timeout = ${this.busyTimeoutMs}`); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
278
+ this.checkBudget();
221
279
  db.run("BEGIN");
222
280
  const rows = db.query("SELECT payload, position, replay FROM work_ops ORDER BY replay").iterate() as IterableIterator<{ payload: string; position: number; replay: string }>;
223
281
  return { db, rows, first: rows.next() }; // Establish the snapshot before releasing the writer lock.
224
- } catch (error) { db.close(); throw error; }
225
- });
282
+ } catch (error) { opened = undefined; db.close(); throw error; }
283
+ }); }
284
+ // The mutation transaction's COMMIT can fail after the snapshot connection opened; close it then (#383).
285
+ catch (error) { opened?.close(); throw error; }
226
286
  let first = true, closed = false;
227
287
  const close = () => {
228
288
  if (closed) return;
@@ -235,7 +295,9 @@ export class WorkListStore {
235
295
  next: () => {
236
296
  if (closed) return { done: true, value: undefined };
237
297
  try {
298
+ this.checkBudget();
238
299
  const next = first ? state.first : state.rows.next(); first = false;
300
+ this.checkBudget();
239
301
  if (next.done) { close(); return { done: true, value: undefined }; }
240
302
  return { done: false, value: { row: this.decode(next.value.payload), position: next.value.position, order: next.value.replay } };
241
303
  } catch (error) { close(); throw error; }
@@ -251,13 +313,17 @@ export class WorkListStore {
251
313
  const cursors: IterableIterator<IndexedRow>[] = [];
252
314
  try { for (const store of [this, ...this.aliased]) cursors.push(store.operations()); }
253
315
  catch (error) { for (const cursor of cursors) cursor.return?.(); throw error; }
254
- const heads = cursors.map(cursor => cursor.next());
316
+ let heads: IteratorResult<IndexedRow>[];
317
+ try { heads = cursors.map(cursor => { this.checkBudget(); return cursor.next(); }); }
318
+ catch (error) { for (const cursor of cursors) try { cursor.return?.(); } catch { /* Preserve the first failure. */ } throw error; }
255
319
  const end = () => { for (const cursor of cursors) { try { cursor.return?.(); } catch { /* the first failure is the one reported */ } } };
256
320
  return {
257
321
  next: () => {
258
322
  try {
323
+ this.checkBudget();
259
324
  let pick = -1;
260
325
  for (let i = 0; i < heads.length; i++) {
326
+ this.checkBudget();
261
327
  const head = heads[i]!;
262
328
  if (!head.done && (pick < 0 || head.value.order < (heads[pick]! as IteratorYieldResult<IndexedRow>).value.order)) pick = i;
263
329
  }
@@ -272,9 +338,12 @@ export class WorkListStore {
272
338
  };
273
339
  }
274
340
 
275
- async write(list: string, input: WorkListFields, source?: WorkListSource): Promise<{ id: string; already: boolean }> {
341
+ async write(rawList: string, input: WorkListFields, source?: WorkListSource): Promise<{ id: string; already: boolean }> {
342
+ if (!bounded(rawList, 300)) return invalid();
343
+ // The list NAME is text too: a `<private>` span in it is never stored (a name that is nothing but a span is invalid), and the MCP write and the claude-mem import both land here.
344
+ const list = stripPrivateSpans(rawList).trim(); // trimmed so "queue <private>x</private>" and "queue" are one list
276
345
  if (!bounded(list, 300)) return invalid();
277
- const fields = fieldsOf(input), origin = sourceOf(source);
346
+ const fields = fieldsOf(stripPrivateDeep(input)), origin = sourceOf(source); // `<private>` spans are never stored
278
347
  const at = origin?.at ?? this.locked(() => {
279
348
  this.refresh();
280
349
  const previous = this.db.query("SELECT max(at) AS at FROM work_ops WHERE machine = ?").get(this.machine) as { at: number | null };
@@ -304,14 +373,18 @@ export class WorkListStore {
304
373
  });
305
374
  }
306
375
 
307
- async read(list?: string, includeClosed = false): Promise<WorkListView[]> {
308
- if (list !== undefined && !bounded(list, 300) || typeof includeClosed !== "boolean") return invalid();
376
+ async read(rawList?: string, includeClosed = false): Promise<WorkListView[]> {
377
+ this.checkBudget();
378
+ if (rawList !== undefined && !bounded(rawList, 300) || typeof includeClosed !== "boolean") return invalid();
379
+ const list = rawList === undefined ? undefined : stripPrivateSpans(rawList).trim(); // the same name a write stored
380
+ if (list !== undefined && !bounded(list, 300)) return invalid();
309
381
  const rows = this.merged();
310
382
  const views = new Map<string, { fields: WorkListFields; items: Map<string, WorkListFields> }>();
311
383
  // ponytail: memory scales with the current distinct lists/items/fields, not historical operations. A caller
312
384
  // requesting an enormous current result still needs that result's memory; paged views are the upgrade path.
313
385
  try {
314
386
  for (const { row } of rows) {
387
+ this.checkBudget();
315
388
  const allowed = await this.permitted(row);
316
389
  if (this.closed) throw new Error("work-list store is closed");
317
390
  if (!allowed) continue;
@@ -320,12 +393,16 @@ export class WorkListStore {
320
393
  const task = row.fields.task;
321
394
  const target = typeof task === "string" ? view.items.get(task) ?? {} : view.fields;
322
395
  if (typeof task === "string") view.items.set(task, target);
323
- for (const [key, value] of Object.entries(row.fields)) if (key !== "task") { if (value === null) delete target[key]; else target[key] = value; }
396
+ for (const [key, value] of Object.entries(row.fields)) if (key !== "task") { this.checkBudget(); if (value === null) delete target[key]; else target[key] = value; }
324
397
  }
325
398
  } finally { rows.return?.(); }
326
- return [...views].sort(([a], [b]) => a.localeCompare(b)).flatMap(([name, view]) => {
399
+ this.checkBudget();
400
+ const result = [...views].sort(([a], [b]) => { this.checkBudget(); return a.localeCompare(b); }).flatMap(([name, view]) => {
401
+ this.checkBudget();
327
402
  const closed = view.fields.status === "done";
328
- return closed && !includeClosed ? [] : [{ list: name, fields: view.fields, items: [...view.items].sort(([a], [b]) => a.localeCompare(b)).map(([task, fields]) => ({ task, fields })), closed }];
403
+ return closed && !includeClosed ? [] : [{ list: name, fields: view.fields, items: [...view.items].sort(([a], [b]) => { this.checkBudget(); return a.localeCompare(b); }).map(([task, fields]) => { this.checkBudget(); return { task, fields }; }), closed }];
329
404
  });
405
+ this.checkBudget();
406
+ return result;
330
407
  }
331
408
  }
@@ -0,0 +1,82 @@
1
+ import type { BriefOptions } from "./session-brief.ts";
2
+ import type { CaptureStore } from "./memory/capture.ts";
3
+ import { resolveHookProjectAsync } from "./hook-project.ts";
4
+ import { hookPeakRssKB } from "./hook-resources.ts";
5
+ import { resolveAlias } from "./memory/aliases.ts";
6
+ import { HOOK_EVENTS, HOOK_INPUT_BYTES } from "./hook-contract.ts";
7
+ import { drainHookCaptures, hookCaptureScope, quarantineHookScope } from "./hook-spool.ts";
8
+ import { createStandalonePolicy } from "./memory/policy.ts";
9
+ import { configuredPolicyOptions } from "./memory/config.ts";
10
+
11
+ async function readOptions(): Promise<string> {
12
+ const reader = Bun.stdin.stream().getReader(), chunks: Uint8Array[] = []; let bytes = 0;
13
+ try {
14
+ for (;;) {
15
+ const next = await reader.read(); if (next.done) break;
16
+ bytes += next.value.byteLength;
17
+ // Native input is at most64KiB; leave bounded room for fixed host options and policy literals.
18
+ if (bytes > HOOK_INPUT_BYTES + 32 * 1024) throw new Error("input bound");
19
+ chunks.push(next.value);
20
+ }
21
+ return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
22
+ } finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
23
+ }
24
+
25
+ // Fixed package helper, launched and reaped by each native hook; never registered or resident.
26
+ if (import.meta.main) {
27
+ let owned = false;
28
+ const nativeUsage = { rssKB: 0, rawRssKB: 0 };
29
+ const stop = (): never => {
30
+ // This leader is still alive in the detached group created by hookWorker.
31
+ // Native Git uses a separate owned async supervisor with its own native watchdog;
32
+ // a blocked Git cannot prevent this worker's JS deadline from firing.
33
+ if (owned) try { process.kill(-process.pid, "SIGKILL"); } catch { /* No group exists in a direct, non-owned invocation. */ }
34
+ process.exit(0);
35
+ };
36
+ const emit = async (result: unknown) => {
37
+ const body = JSON.stringify({ ...(result as object), complete: true });
38
+ const rssKB = hookPeakRssKB() + nativeUsage.rssKB;
39
+ await Bun.stdout.write(body.slice(0, -1) + `,"rssKB":${rssKB}}\n`);
40
+ stop();
41
+ };
42
+ try {
43
+ const options = JSON.parse(await readOptions()) as BriefOptions & { current: string; event: string; deadlineAt: number; ownedGroup: boolean };
44
+ if (!Number.isFinite(options.deadlineAt) || options.ownedGroup !== true) process.exit(0);
45
+ owned = true;
46
+ const remaining = () => options.deadlineAt - (performance.timeOrigin + performance.now());
47
+ if (remaining() <= 0) stop();
48
+ setTimeout(stop, remaining()).unref();
49
+ const policy = createStandalonePolicy(configuredPolicyOptions(options.base!, options.cwd, options.policy)), event = options.event;
50
+ if (!(HOOK_EVENTS as readonly string[]).includes(event)) stop();
51
+ const expectedScope = hookCaptureScope(options.cwd, options.base!);
52
+ let project: string;
53
+ try { project = resolveAlias(options.base!, await resolveHookProjectAsync({ cwd: options.cwd, base: options.base!, scope: expectedScope, timeRemaining: remaining, maintainCache: event !== "SessionStart" }, nativeUsage)); }
54
+ catch {
55
+ if (expectedScope !== undefined) quarantineHookScope({ cwd: options.cwd, base: options.base!, scope: expectedScope });
56
+ project = stop();
57
+ }
58
+ let captured = false;
59
+ // Normal SessionStart keeps the parent's durable stage for later replay and spends
60
+ // its short-lived worker on context. An empty current name is mandatory full-tier recovery.
61
+ if (expectedScope !== undefined && (event !== "SessionStart" || !options.current)) {
62
+ const { CaptureStore } = await import("./memory/capture.ts");
63
+ let store: CaptureStore | undefined;
64
+ try {
65
+ const drained = await drainHookCaptures({ ...options, base: options.base!, project, expectedScope, timeRemaining: remaining }, (input, receipt) => {
66
+ store ??= new CaptureStore({ memoryDir: options.base!, project, busyTimeoutMs: 0, cwd: options.cwd });
67
+ return store.append(input, { receipt });
68
+ });
69
+ captured = drained.captured.includes(options.current);
70
+ } finally { store?.close(); }
71
+ }
72
+ if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
73
+ if (event === "SessionStart") {
74
+ const { sessionStartBrief } = await import("./session-brief.ts");
75
+ const result = await sessionStartBrief({ ...options, project,
76
+ timeRemaining: () => options.deadlineAt - (performance.timeOrigin + performance.now()) });
77
+ const valid = await policy.screen({ context: result.context });
78
+ if (hookCaptureScope(options.cwd, options.base!) !== expectedScope) stop();
79
+ await emit({ ...result, captured, valid });
80
+ } else await emit({ context: "", captured, valid: true });
81
+ } catch { if (owned) stop(); process.exitCode = 1; }
82
+ }