@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.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/memory/work-lists.ts
CHANGED
|
@@ -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 "./
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 === "
|
|
143
|
-
this.db.run("CREATE
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
308
|
-
|
|
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
|
-
|
|
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 })
|
|
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
|
+
}
|