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