@staix/agent-mem 0.4.0 → 0.5.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/README.md +6 -4
- package/package.json +1 -1
- package/src/cli.ts +17 -7
- package/src/commands-table.ts +1 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capture.ts +123 -26
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +44 -10
- package/src/memory/store.ts +104 -33
- package/src/memory/work-lists.ts +138 -50
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";
|
|
@@ -11,13 +11,12 @@ import { ensureShardDescriptor, resolveAlias, shardDir } from "./shard.ts";
|
|
|
11
11
|
import { machineId } from "./store.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,31 +47,29 @@ 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
|
-
|
|
69
|
-
|
|
70
|
-
if (x.at !== y.at) return x.at - y.at;
|
|
71
|
-
const groupX = x.source ? ["import", x.source.machine ?? "", x.source.source] : ["native", x.machine, ""];
|
|
72
|
-
const groupY = y.source ? ["import", y.source.machine ?? "", y.source.source] : ["native", y.machine, ""];
|
|
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
|
+
/** Derived only: timestamp, import/native origin, natural source id, machine, append position, row hash.
|
|
67
|
+
* Fixed-width safe integers and encoded strings let the persisted index supply the legacy replay order. */
|
|
68
|
+
function replayKey(row: WorkRow, position: number): string {
|
|
69
|
+
if (!Number.isSafeInteger(position) || position < 0) throw new Error("work index has an invalid append position");
|
|
70
|
+
const source = row.source;
|
|
71
|
+
return [String(row.at).padStart(16, "0"), source ? "0" : "1", lexicalKey(source ? source.machine ?? "" : row.machine),
|
|
72
|
+
lexicalKey(source?.source ?? ""), source ? sourceKey(source.id) : "", row.machine, String(position).padStart(16, "0"), row.id].join(" ");
|
|
76
73
|
}
|
|
77
74
|
|
|
78
75
|
/** Stream log bytes with bounded carry; neither a long history nor an invalid oversized row is loaded at once. */
|
|
@@ -112,12 +109,15 @@ export class WorkListStore {
|
|
|
112
109
|
private readonly machine: string;
|
|
113
110
|
private readonly policy: WorkListPolicy;
|
|
114
111
|
private readonly now: () => number;
|
|
112
|
+
private readonly readers = new Set<() => void>();
|
|
115
113
|
private closed = false;
|
|
114
|
+
/** #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
|
+
private readonly aliased: WorkListStore[] = [];
|
|
116
116
|
|
|
117
|
-
constructor(options: { base: string; project: string; policy: WorkListPolicy; now?: () => number }) {
|
|
117
|
+
constructor(options: { base: string; project: string; policy: WorkListPolicy; now?: () => number; aliases?: readonly string[]; exact?: boolean }) {
|
|
118
118
|
if (!bounded(options.project, 1000) || typeof options.policy !== "function") throw new Error("invalid work-list input");
|
|
119
119
|
const base = canonicalPath(options.base);
|
|
120
|
-
this.project = resolveAlias(base, options.project);
|
|
120
|
+
this.project = options.exact === true ? options.project : resolveAlias(base, options.project);
|
|
121
121
|
regular(join(base, "projects"), true); regular(shardDir(base, this.project), true);
|
|
122
122
|
ensureShardDescriptor(base, this.project);
|
|
123
123
|
this.dir = join(shardDir(base, this.project), "work");
|
|
@@ -134,12 +134,29 @@ export class WorkListStore {
|
|
|
134
134
|
// The index is disposable. An older derived schema is replayed without changing a log row.
|
|
135
135
|
const columns = this.db.query("PRAGMA table_info(work_ops)").all() as { name: string }[];
|
|
136
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)");
|
|
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
138
|
this.db.run("CREATE INDEX IF NOT EXISTS work_receipts ON work_ops(receipt)");
|
|
139
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(() => {
|
|
141
|
+
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 }));
|
|
148
|
+
}
|
|
140
149
|
}
|
|
141
150
|
|
|
142
|
-
close(): void {
|
|
151
|
+
close(): void {
|
|
152
|
+
if (this.closed) return;
|
|
153
|
+
this.closed = true;
|
|
154
|
+
let failure: unknown;
|
|
155
|
+
for (const close of [...this.readers, () => this.db.close(), () => this.mutation.close(), ...this.aliased.map(store => () => store.close())]) {
|
|
156
|
+
try { close(); } catch (error) { failure ??= error; }
|
|
157
|
+
}
|
|
158
|
+
if (failure !== undefined) throw failure;
|
|
159
|
+
}
|
|
143
160
|
private locked<T>(run: () => T): T {
|
|
144
161
|
if (this.closed) throw new Error("work-list store is closed");
|
|
145
162
|
return this.mutation.transaction(run).immediate();
|
|
@@ -156,10 +173,10 @@ export class WorkListStore {
|
|
|
156
173
|
}
|
|
157
174
|
private index(row: WorkRow, position: number): void {
|
|
158
175
|
const key = receipt(row);
|
|
159
|
-
if (key) for (const prior of this.db.query("SELECT payload FROM work_ops WHERE receipt = ?").
|
|
176
|
+
if (key) for (const prior of this.db.query("SELECT payload FROM work_ops WHERE receipt = ?").iterate(key) as Iterable<{ payload: string }>) {
|
|
160
177
|
if (importPayload(this.decode(prior.payload)) !== importPayload(row)) throw new Error("work log contains conflicting source receipts");
|
|
161
178
|
}
|
|
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);
|
|
179
|
+
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
180
|
}
|
|
164
181
|
private refresh(force = false): void {
|
|
165
182
|
const files = readdirSync(join(this.dir, "log")).sort();
|
|
@@ -174,6 +191,15 @@ export class WorkListStore {
|
|
|
174
191
|
if (old !== current.bytes) scan(join(this.dir, "log", file), (line, end) => { if (end > old) this.index(this.decode(line), end); });
|
|
175
192
|
this.db.query("INSERT OR REPLACE INTO work_heads (file, bytes, hash) VALUES (?, ?, ?)").run(file, current.bytes, current.hash);
|
|
176
193
|
}
|
|
194
|
+
// Old readers may rebuild/append this disposable index without the additive replay column. Backfill one
|
|
195
|
+
// row at a time, including on an otherwise unchanged refresh; no payload history is materialized.
|
|
196
|
+
const missing = this.db.query("SELECT id, payload, position FROM work_ops WHERE replay = '' LIMIT 1");
|
|
197
|
+
const update = this.db.query("UPDATE work_ops SET replay = ? WHERE id = ?");
|
|
198
|
+
for (;;) {
|
|
199
|
+
const old = missing.get() as { id: string; payload: string; position: number } | null;
|
|
200
|
+
if (!old) break;
|
|
201
|
+
update.run(replayKey(this.decode(old.payload), old.position), old.id);
|
|
202
|
+
}
|
|
177
203
|
}).immediate();
|
|
178
204
|
}
|
|
179
205
|
rebuild(): void { this.locked(() => this.refresh(true)); }
|
|
@@ -183,6 +209,69 @@ export class WorkListStore {
|
|
|
183
209
|
try { return await this.policy(copy) === true; } catch { return false; }
|
|
184
210
|
}
|
|
185
211
|
|
|
212
|
+
/** An ordered, stable read snapshot. The separate read-only connection never holds the mutation lock across
|
|
213
|
+
* a host policy await, so a concurrent append succeeds without changing this read's history frontier. */
|
|
214
|
+
private operations(): IterableIterator<IndexedRow> {
|
|
215
|
+
const state = this.locked(() => {
|
|
216
|
+
this.refresh();
|
|
217
|
+
regular(join(this.dir, "index.db"));
|
|
218
|
+
const db = new Database(join(this.dir, "index.db"), { readonly: true });
|
|
219
|
+
try {
|
|
220
|
+
db.run("PRAGMA busy_timeout = 3000"); db.run("PRAGMA cache_size = -2048"); db.run("PRAGMA mmap_size = 0");
|
|
221
|
+
db.run("BEGIN");
|
|
222
|
+
const rows = db.query("SELECT payload, position, replay FROM work_ops ORDER BY replay").iterate() as IterableIterator<{ payload: string; position: number; replay: string }>;
|
|
223
|
+
return { db, rows, first: rows.next() }; // Establish the snapshot before releasing the writer lock.
|
|
224
|
+
} catch (error) { db.close(); throw error; }
|
|
225
|
+
});
|
|
226
|
+
let first = true, closed = false;
|
|
227
|
+
const close = () => {
|
|
228
|
+
if (closed) return;
|
|
229
|
+
closed = true;
|
|
230
|
+
this.readers.delete(close);
|
|
231
|
+
try { state.rows.return?.(); } finally { try { state.db.run("ROLLBACK"); } finally { state.db.close(); } }
|
|
232
|
+
};
|
|
233
|
+
this.readers.add(close);
|
|
234
|
+
return {
|
|
235
|
+
next: () => {
|
|
236
|
+
if (closed) return { done: true, value: undefined };
|
|
237
|
+
try {
|
|
238
|
+
const next = first ? state.first : state.rows.next(); first = false;
|
|
239
|
+
if (next.done) { close(); return { done: true, value: undefined }; }
|
|
240
|
+
return { done: false, value: { row: this.decode(next.value.payload), position: next.value.position, order: next.value.replay } };
|
|
241
|
+
} catch (error) { close(); throw error; }
|
|
242
|
+
},
|
|
243
|
+
return: () => { close(); return { done: true, value: undefined }; },
|
|
244
|
+
[Symbol.iterator]() { return this; },
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** #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. */
|
|
249
|
+
private merged(): IterableIterator<IndexedRow> {
|
|
250
|
+
if (!this.aliased.length) return this.operations();
|
|
251
|
+
const cursors: IterableIterator<IndexedRow>[] = [];
|
|
252
|
+
try { for (const store of [this, ...this.aliased]) cursors.push(store.operations()); }
|
|
253
|
+
catch (error) { for (const cursor of cursors) cursor.return?.(); throw error; }
|
|
254
|
+
const heads = cursors.map(cursor => cursor.next());
|
|
255
|
+
const end = () => { for (const cursor of cursors) { try { cursor.return?.(); } catch { /* the first failure is the one reported */ } } };
|
|
256
|
+
return {
|
|
257
|
+
next: () => {
|
|
258
|
+
try {
|
|
259
|
+
let pick = -1;
|
|
260
|
+
for (let i = 0; i < heads.length; i++) {
|
|
261
|
+
const head = heads[i]!;
|
|
262
|
+
if (!head.done && (pick < 0 || head.value.order < (heads[pick]! as IteratorYieldResult<IndexedRow>).value.order)) pick = i;
|
|
263
|
+
}
|
|
264
|
+
if (pick < 0) { end(); return { done: true, value: undefined }; }
|
|
265
|
+
const out = heads[pick]! as IteratorYieldResult<IndexedRow>;
|
|
266
|
+
heads[pick] = cursors[pick]!.next();
|
|
267
|
+
return { done: false, value: out.value };
|
|
268
|
+
} catch (error) { end(); throw error; }
|
|
269
|
+
},
|
|
270
|
+
return: () => { end(); return { done: true, value: undefined }; },
|
|
271
|
+
[Symbol.iterator]() { return this; },
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
186
275
|
async write(list: string, input: WorkListFields, source?: WorkListSource): Promise<{ id: string; already: boolean }> {
|
|
187
276
|
if (!bounded(list, 300)) return invalid();
|
|
188
277
|
const fields = fieldsOf(input), origin = sourceOf(source);
|
|
@@ -217,24 +306,23 @@ export class WorkListStore {
|
|
|
217
306
|
|
|
218
307
|
async read(list?: string, includeClosed = false): Promise<WorkListView[]> {
|
|
219
308
|
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
|
-
});
|
|
309
|
+
const rows = this.merged();
|
|
228
310
|
const views = new Map<string, { fields: WorkListFields; items: Map<string, WorkListFields> }>();
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
311
|
+
// ponytail: memory scales with the current distinct lists/items/fields, not historical operations. A caller
|
|
312
|
+
// requesting an enormous current result still needs that result's memory; paged views are the upgrade path.
|
|
313
|
+
try {
|
|
314
|
+
for (const { row } of rows) {
|
|
315
|
+
const allowed = await this.permitted(row);
|
|
316
|
+
if (this.closed) throw new Error("work-list store is closed");
|
|
317
|
+
if (!allowed) continue;
|
|
318
|
+
if (list !== undefined && row.list !== list) continue;
|
|
319
|
+
let view = views.get(row.list); if (!view) { view = { fields: {}, items: new Map() }; views.set(row.list, view); }
|
|
320
|
+
const task = row.fields.task;
|
|
321
|
+
const target = typeof task === "string" ? view.items.get(task) ?? {} : view.fields;
|
|
322
|
+
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; }
|
|
324
|
+
}
|
|
325
|
+
} finally { rows.return?.(); }
|
|
238
326
|
return [...views].sort(([a], [b]) => a.localeCompare(b)).flatMap(([name, view]) => {
|
|
239
327
|
const closed = view.fields.status === "done";
|
|
240
328
|
return closed && !includeClosed ? [] : [{ list: name, fields: view.fields, items: [...view.items].sort(([a], [b]) => a.localeCompare(b)).map(([task, fields]) => ({ task, fields })), closed }];
|