@staix/agent-mem 0.1.1 → 0.3.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 +71 -1
- package/package.json +4 -2
- package/src/cli.ts +97 -6
- package/src/commands-table.ts +16 -0
- package/src/index.ts +5 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/candidates.ts +196 -0
- package/src/memory/commands.ts +9 -2
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/mutation.ts +22 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +235 -36
- package/src/memory/sync.ts +31 -3
package/src/memory/store.ts
CHANGED
|
@@ -3,7 +3,14 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
3
3
|
import { appendFileSync, chmodSync, closeSync, existsSync, fstatSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
5
|
import { isDenied } from "./platform.ts";
|
|
6
|
+
import { mutationDatabase } from "./mutation.ts";
|
|
6
7
|
import { memoryHome } from "./home.ts";
|
|
8
|
+
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
9
|
+
import { generationDescriptor, generationDir, canonicalJSON, readProfileJSON, resolveProfile, type BoundProfile, type ProfileInput } from "./generation.ts";
|
|
10
|
+
import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
11
|
+
import { legacyEntries, type LegacyCheckpoint } from "./bridge.ts";
|
|
12
|
+
import type { PruneStorePort } from "./prune.ts";
|
|
13
|
+
import { isDeepStrictEqual } from "node:util";
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
|
|
@@ -173,18 +180,34 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
173
180
|
* `screen`: applied to every promotion. Without one, automatic promotions (a settlement, a held note whose replacement
|
|
174
181
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
175
182
|
*/
|
|
176
|
-
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen
|
|
183
|
+
export interface StoreOptions { home?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
|
|
184
|
+
}
|
|
177
185
|
|
|
186
|
+
const MIGRATION_BOOTSTRAP = Symbol("migration index bootstrap");
|
|
187
|
+
interface BootstrapBinding { token: typeof MIGRATION_BOOTSTRAP; profile: BoundProfile; limits: readonly LegacyCheckpoint[]; commit?: string }
|
|
178
188
|
export class MemoryStore {
|
|
179
189
|
readonly dir: string;
|
|
190
|
+
readonly profile: BoundProfile;
|
|
191
|
+
private readonly generationIndex?: GenerationIndex;
|
|
192
|
+
private readonly bootstrapOnly: boolean;
|
|
180
193
|
readonly machine: Machine;
|
|
181
194
|
private readonly db: Database;
|
|
195
|
+
private readonly mutationDb: Database;
|
|
182
196
|
private readonly ttl: number;
|
|
183
197
|
private readonly now: () => number;
|
|
184
198
|
private readonly log: (line: string) => void;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
199
|
+
private readonly deferPromotions: boolean;
|
|
200
|
+
|
|
201
|
+
constructor(opts: StoreOptions = {}, bootstrap?: BootstrapBinding) {
|
|
202
|
+
if (bootstrap && bootstrap.token !== MIGRATION_BOOTSTRAP) throw new Error("invalid migration bootstrap");
|
|
203
|
+
this.bootstrapOnly = bootstrap !== undefined;
|
|
204
|
+
const home = opts.home === undefined ? memoryHome() : join(opts.home, "memory");
|
|
205
|
+
this.profile = bootstrap?.profile ?? resolveProfile(home, opts.profile);
|
|
206
|
+
if (bootstrap && (this.profile.kind !== "generation" || !this.profile.generation
|
|
207
|
+
|| this.profile.dir !== generationDir(this.profile.home, this.profile.generation)
|
|
208
|
+
|| canonicalJSON(readProfileJSON(join(this.profile.dir, "generation.json"))) !== canonicalJSON(generationDescriptor(this.profile.generation)))) throw new Error("invalid migration profile binding");
|
|
209
|
+
// Shared-v1 preserves its historical lexical dir API; profile.home is still canonical for selector identity.
|
|
210
|
+
this.dir = this.profile.kind === "shared-v1" ? home : this.profile.dir;
|
|
188
211
|
mkdirSync(join(this.dir, "log"), { recursive: true, mode: 0o700 });
|
|
189
212
|
mkdirSync(join(this.dir, "local"), { recursive: true, mode: 0o700 });
|
|
190
213
|
chmodSync(this.dir, 0o700); // mkdir's mode applies only when it creates the directory
|
|
@@ -193,6 +216,7 @@ export class MemoryStore {
|
|
|
193
216
|
this.now = opts.now ?? Date.now;
|
|
194
217
|
this.log = opts.log ?? (() => {});
|
|
195
218
|
this.screen = opts.screen;
|
|
219
|
+
this.deferPromotions = opts.deferPromotions ?? false;
|
|
196
220
|
const db = new Database(join(this.dir, "index.db"), { create: true });
|
|
197
221
|
db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
|
|
198
222
|
db.run("PRAGMA journal_mode = WAL");
|
|
@@ -210,11 +234,66 @@ export class MemoryStore {
|
|
|
210
234
|
db.run("CREATE TABLE IF NOT EXISTS edges (src TEXT NOT NULL, dst TEXT NOT NULL, PRIMARY KEY (src, dst))");
|
|
211
235
|
db.run("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
|
|
212
236
|
this.db = db;
|
|
237
|
+
this.mutationDb = mutationDatabase(this.dir);
|
|
213
238
|
try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
|
|
239
|
+
if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
|
|
240
|
+
apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
|
|
241
|
+
...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
|
|
214
242
|
this.refresh();
|
|
215
243
|
}
|
|
216
244
|
|
|
217
|
-
|
|
245
|
+
/** The host must authenticate person intent before authorizing plan/apply through this port. */
|
|
246
|
+
prunePort(): PruneStorePort {
|
|
247
|
+
if (this.bootstrapOnly || !this.generationIndex) throw new Error("durable prune requires an active generation handle");
|
|
248
|
+
return this.generationIndex.port(this.machine.id);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Full derived receipt for golden replay; raw debug data, never model evidence. */
|
|
252
|
+
derivedTables(): Record<string, unknown[]> {
|
|
253
|
+
const tables = this.db.query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").all() as { name: string }[];
|
|
254
|
+
return Object.fromEntries(tables.map(({ name }) => [name, (this.db.query(`SELECT * FROM "${name.replaceAll('"', '""')}"`).all() as unknown[]).sort((a, b) => canonicalJSON(a).localeCompare(canonicalJSON(b)))]));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
close(): void { this.db.close(); this.mutationDb.close(); }
|
|
258
|
+
|
|
259
|
+
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
260
|
+
withDeferredPromotions<T>(work: () => T): T {
|
|
261
|
+
const previous = this.promoting;
|
|
262
|
+
this.promoting = true;
|
|
263
|
+
try { return work(); } finally { this.promoting = previous; }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private withMutation<T>(work: () => T): T {
|
|
267
|
+
return this.mutationDb.transaction(work).immediate();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
|
|
271
|
+
withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
|
|
272
|
+
try { return this.withMutation(() => {
|
|
273
|
+
// Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
|
|
274
|
+
this.db.transaction(() => this.refresh()).immediate();
|
|
275
|
+
const at = new Date(this.now()).toISOString();
|
|
276
|
+
const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
|
|
277
|
+
...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
|
|
278
|
+
...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
|
|
279
|
+
const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
|
|
280
|
+
const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
|
|
281
|
+
task: normalized.task ?? null, about: [...normalized.about], supersededBy: null,
|
|
282
|
+
supersedes: normalized.supersedes ? [normalized.supersedes] : [], promotedFrom: normalized.promotedFrom ?? null,
|
|
283
|
+
expiresAt: normalized.expires ?? null, local: normalized.tier === "short" };
|
|
284
|
+
if (this.screen?.(view) !== "promote") throw new Error("candidate refused by current promotion screen");
|
|
285
|
+
let appended = false;
|
|
286
|
+
return work(() => {
|
|
287
|
+
if (appended) throw new Error("accepted note already appended");
|
|
288
|
+
appended = true;
|
|
289
|
+
return this.appendBody(body, normalized.tier === "short");
|
|
290
|
+
});
|
|
291
|
+
}); } catch (error) {
|
|
292
|
+
// A callback can fail after a durable JSONL append; ensure the derived index catches up.
|
|
293
|
+
this.stale = true;
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
218
297
|
|
|
219
298
|
/** When a short-term entry written now leaves the index. */
|
|
220
299
|
private until(): string { return new Date(this.now() + this.ttl).toISOString(); }
|
|
@@ -223,12 +302,21 @@ export class MemoryStore {
|
|
|
223
302
|
private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
|
|
224
303
|
const capped = entry.op === "note" ? { ...entry, text: entry.text.slice(0, MAX_TEXT), ...(entry.title ? { title: entry.title.slice(0, MAX_TITLE) } : {}) } : entry;
|
|
225
304
|
const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
305
|
+
return this.appendBody(body, local);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private appendBody(body: Omit<Entry, "id">, local: boolean): string {
|
|
309
|
+
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot author memory");
|
|
310
|
+
assertSharedV1Op(body.op);
|
|
311
|
+
// Lock the authoritative log independently: a busy derived index must never prevent an ordinary append.
|
|
312
|
+
return this.withMutation(() => {
|
|
313
|
+
this.generationIndex?.assertAppendable(local, this.machine.id);
|
|
314
|
+
const id = entryId(body);
|
|
315
|
+
appendFileSync(join(this.dir, local ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
316
|
+
// The line is durable even if indexing fails; the next refresh catches up from the authoritative log.
|
|
317
|
+
try { this.refresh(); } catch (e) { this.stale = true; this.log(`hub memory index not updated yet: ${(e as Error).message}`); }
|
|
318
|
+
return id;
|
|
319
|
+
});
|
|
232
320
|
}
|
|
233
321
|
|
|
234
322
|
/** A note: short-term ones stay on this machine, long-term ones go to the synced log. The caller has screened it. */
|
|
@@ -241,14 +329,19 @@ export class MemoryStore {
|
|
|
241
329
|
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
242
330
|
*/
|
|
243
331
|
importNote(n: NoteInput, at: string): { id: string; added: boolean } {
|
|
332
|
+
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
333
|
+
assertSharedV1Op(n.op);
|
|
244
334
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
245
335
|
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: n.text.slice(0, MAX_TEXT), ...(n.title ? { title: n.title.slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
246
336
|
const id = entryId(body);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
337
|
+
return this.withMutation(() => {
|
|
338
|
+
this.generationIndex?.assertAppendable(n.tier === "short", this.machine.id);
|
|
339
|
+
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
340
|
+
const known = this.db.query("SELECT id FROM entries WHERE id = ? OR (source = 'claude-mem' AND project = ? AND at = ? AND text = ?)").get(id, n.project, at, n.text.slice(0, MAX_TEXT)) as { id: string } | null;
|
|
341
|
+
if (known) return { id: known.id, added: false };
|
|
342
|
+
appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
343
|
+
return { id, added: true };
|
|
344
|
+
});
|
|
252
345
|
}
|
|
253
346
|
|
|
254
347
|
/** A person's or the hub's op on an entry; it goes where the entry lives, so forgetting a long-term entry syncs. */
|
|
@@ -326,6 +419,7 @@ export class MemoryStore {
|
|
|
326
419
|
*/
|
|
327
420
|
promote(id: string, actor: string, project?: string, auto = false): string | undefined {
|
|
328
421
|
const v = this.get(id, project);
|
|
422
|
+
if (v && this.generationIndex && !this.generationIndex.own(v.id)) return undefined;
|
|
329
423
|
if (!v || v.tier !== "short") return v?.id;
|
|
330
424
|
if (v.supersededBy) return undefined;
|
|
331
425
|
// Screened here, whatever asked: a note saved under an older policy must not reach the synced log under a newer one.
|
|
@@ -354,11 +448,15 @@ export class MemoryStore {
|
|
|
354
448
|
}
|
|
355
449
|
private promoteSettled(task: string, actor: string): number {
|
|
356
450
|
// Only the notes held for promotion (no deadline): one saved short-term after the settlement stays short-term.
|
|
357
|
-
const moving = this.views().filter((v) => v.task === task && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind));
|
|
451
|
+
const moving = this.views().filter((v) => (!this.generationIndex || this.generationIndex.own(v.id)) && v.task === task && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind));
|
|
358
452
|
return moving.filter((v) => this.promote(v.id, actor, undefined, true) !== undefined).length;
|
|
359
453
|
}
|
|
360
454
|
/** Whether a task was ever settled, for the hub's reconciliation of approvals a crash cut off before their settle entry. */
|
|
361
|
-
settled(task: string): boolean {
|
|
455
|
+
settled(task: string): boolean {
|
|
456
|
+
return !!this.db.query(this.generationIndex
|
|
457
|
+
? "SELECT 1 FROM ops JOIN generation_rows ON generation_rows.id=ops.id WHERE op='settle' AND target=? AND own=1"
|
|
458
|
+
: "SELECT 1 FROM ops WHERE op = 'settle' AND target = ?").get(task);
|
|
459
|
+
}
|
|
362
460
|
/** Whether any entry was saved against the task. */
|
|
363
461
|
hasTask(task: string): boolean { return !!this.db.query("SELECT 1 FROM entries WHERE task = ?").get(task); }
|
|
364
462
|
private promoting = false;
|
|
@@ -368,12 +466,15 @@ export class MemoryStore {
|
|
|
368
466
|
* settlement a crash cut short, or a replacement that was forgotten or expired).
|
|
369
467
|
*/
|
|
370
468
|
private promoteHeld(): void {
|
|
371
|
-
if (this.promoting) return;
|
|
469
|
+
if (this.promoting || this.deferPromotions) return;
|
|
372
470
|
this.promoting = true;
|
|
373
471
|
try {
|
|
374
|
-
const settled = new Set((this.db.query(
|
|
472
|
+
const settled = new Set((this.db.query(this.generationIndex
|
|
473
|
+
? "SELECT target FROM ops JOIN generation_rows ON generation_rows.id=ops.id WHERE op='settle' AND own=1"
|
|
474
|
+
: "SELECT target FROM ops WHERE op = 'settle'").all() as { target: string }[]).map((r) => r.target));
|
|
375
475
|
if (!settled.size) return;
|
|
376
476
|
for (const v of this.views()) {
|
|
477
|
+
if (this.generationIndex && !this.generationIndex.own(v.id)) continue; // The bridge never auto-promotes legacy inputs.
|
|
377
478
|
if (v.task && settled.has(v.task) && v.tier === "short" && v.local && !v.supersededBy && v.expiresAt === null && PROMOTED_KINDS.includes(v.kind)) {
|
|
378
479
|
try { this.promote(v.id, "hub", undefined, true); } catch { /* fail-open: tried again at the next read */ }
|
|
379
480
|
}
|
|
@@ -383,6 +484,7 @@ export class MemoryStore {
|
|
|
383
484
|
|
|
384
485
|
/** Index what the segments gained since the last read. A line still being written (no newline yet) waits for the next read. */
|
|
385
486
|
refresh(attempt = 0): void {
|
|
487
|
+
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
386
488
|
const files = [...this.segments("log"), ...this.segments("local")];
|
|
387
489
|
const seen = this.shape(files); // taken before reading: a line written meanwhile changes it again
|
|
388
490
|
// One statement, one snapshot: an offset and a head from two different commits would look like a rewrite.
|
|
@@ -444,6 +546,7 @@ export class MemoryStore {
|
|
|
444
546
|
|
|
445
547
|
/** Drop the index and read every segment again: the same rows as the incremental index. */
|
|
446
548
|
rebuild(): void {
|
|
549
|
+
if (this.generationIndex) { this.generationIndex.rebuild(); return; }
|
|
447
550
|
// One transaction: no reader sees the index empty, and no other process commits an offset in between.
|
|
448
551
|
this.db.transaction(() => { for (const t of ["segments", "heads", "entries", "ops", "edges", "goals"]) this.db.run(`DELETE FROM ${t}`); this.refresh(); }).immediate();
|
|
449
552
|
}
|
|
@@ -487,18 +590,39 @@ export class MemoryStore {
|
|
|
487
590
|
|
|
488
591
|
/**
|
|
489
592
|
* Every live note with its derived state; an expired one is left out as soon as its deadline passes, before any
|
|
490
|
-
* prune.
|
|
593
|
+
* prune. Lifecycle relations still fold globally; SQL selects full rows before View/JSON materialization.
|
|
594
|
+
* ponytail: lifecycle metadata is still scanned on every read; measure before adding a revision cache.
|
|
491
595
|
*/
|
|
492
|
-
private views(expired = false): View[] {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
596
|
+
private views(expired = false, selection?: Filter, expiredOnly = false): View[] {
|
|
597
|
+
// Metadata, ops and candidate bodies must describe one WAL snapshot, even if another process indexes a copy.
|
|
598
|
+
return this.db.transaction(() => this.viewRows(expired, selection, expiredOnly)).deferred();
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
private viewRows(expired: boolean, selection: Filter | undefined, expiredOnly: boolean): View[] {
|
|
602
|
+
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
603
|
+
const pruned = this.generationIndex ? new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)) : undefined;
|
|
604
|
+
if (this.generationIndex && pruned) this.generationIndex.pruned = pruned;
|
|
605
|
+
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
606
|
+
const rows = this.db.query(selecting
|
|
607
|
+
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
608
|
+
: "SELECT * FROM entries").all() as Record<string, string | null>[];
|
|
609
|
+
const ops = this.db.query("SELECT id, op, at, target, by FROM ops ORDER BY at, id").all() as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
610
|
+
const realms = this.generationIndex ? new Map((this.db.query("SELECT id,own,legacy FROM generation_rows").all() as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
|
|
611
|
+
const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
|
|
496
612
|
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
497
613
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
498
614
|
const deadline = (o: { at: string; by: string | null }) => o.by && !Number.isNaN(Date.parse(o.by)) ? o.by : new Date(Date.parse(o.at) + this.ttl).toISOString();
|
|
499
615
|
const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
|
|
500
616
|
const settled = new Map<string, string>();
|
|
501
|
-
|
|
617
|
+
const settlementKey = (id: string, task: string) => born ? `${born.has(id)}:${task}` : task;
|
|
618
|
+
for (const o of ops) if (o.op === "settle") {
|
|
619
|
+
const source = realms?.get(o.id);
|
|
620
|
+
const keys = !realms ? [o.target] : [
|
|
621
|
+
...(source?.own ? [`true:${o.target}`] : []),
|
|
622
|
+
...(source?.legacy || !source?.own ? [`false:${o.target}`] : []),
|
|
623
|
+
];
|
|
624
|
+
for (const key of keys) if (!settled.has(key)) settled.set(key, deadline(o));
|
|
625
|
+
}
|
|
502
626
|
const superseded = new Map<string, string>(), supersededAt = new Map<string, string>();
|
|
503
627
|
// Relations from ops and from notes' own `supersedes` fold together by write time (then id): the latest intent wins.
|
|
504
628
|
const relate = (old: string, newer: string, when: string) => { if (!(when < (supersededAt.get(old) ?? ""))) { superseded.set(old, newer); supersededAt.set(old, when); } };
|
|
@@ -511,15 +635,17 @@ export class MemoryStore {
|
|
|
511
635
|
// A decision, contract or fail of a settled task not yet copied to long-term waits for its promotion, never expires
|
|
512
636
|
// first; a note saved short-term after the settlement carries its own deadline and stays short-term.
|
|
513
637
|
// A superseded one is held too: if its replacement goes, it shows again, and a rebuild must find it the same way.
|
|
514
|
-
|
|
638
|
+
const taskDeadline = r.task ? settled.get(settlementKey(id, r.task)) : undefined;
|
|
639
|
+
if (r.tier === "short" && !demoted.has(id) && !r.expires && taskDeadline && PROMOTED_KINDS.includes(r.kind as Kind)) return null;
|
|
515
640
|
// A deadline written into the note (no task, a late note, an explicit short horizon) is its own, whatever the task does.
|
|
516
|
-
return demoted.get(id) ?? r.expires ?? (r.task ?
|
|
641
|
+
return demoted.get(id) ?? r.expires ?? (r.task ? taskDeadline ?? new Date(Date.parse(r.at!) + UNSETTLED_MS).toISOString() : new Date(Date.parse(r.at!) + this.ttl).toISOString());
|
|
517
642
|
};
|
|
518
643
|
// A newer entry that was forgotten, expired or pruned no longer replaces anything: the older one shows again. A
|
|
519
644
|
// promoted source lives on as its copy.
|
|
520
645
|
const byId = new Map(rows.map((r) => [r.id!, r]));
|
|
521
646
|
const copyOf = new Map(rows.filter((r) => r.promoted_from).map((r) => [r.promoted_from!, r.id!]));
|
|
522
647
|
const gone = (id: string): boolean => {
|
|
648
|
+
if (pruned?.has(id)) return true;
|
|
523
649
|
const r = byId.get(id);
|
|
524
650
|
if (!r || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from))) return true;
|
|
525
651
|
const copy = copyOf.get(id);
|
|
@@ -550,11 +676,40 @@ export class MemoryStore {
|
|
|
550
676
|
}
|
|
551
677
|
const supersedes = new Map<string, string[]>(); // the reverse: what each newer entry replaced
|
|
552
678
|
for (const [old, newer] of superseded) supersedes.set(newer, [...(supersedes.get(newer) ?? []), old]);
|
|
679
|
+
let candidates = rows;
|
|
680
|
+
if (selecting) {
|
|
681
|
+
const hidden: string[] = [], expiredIds: string[] = [];
|
|
682
|
+
for (const r of rows) {
|
|
683
|
+
const id = r.id!;
|
|
684
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) { hidden.push(id); continue; }
|
|
685
|
+
const at = expiry(r);
|
|
686
|
+
if (at !== null && Date.parse(at) <= now) expiredIds.push(id);
|
|
687
|
+
}
|
|
688
|
+
const where: string[] = [], bind: string[] = [];
|
|
689
|
+
const ids = (values: Iterable<string>, exclude: boolean) => {
|
|
690
|
+
const entries = [...values];
|
|
691
|
+
if (entries.length || !exclude) { where.push(`id ${exclude ? "NOT IN" : "IN"} (SELECT value FROM json_each(?))`); bind.push(JSON.stringify(entries)); }
|
|
692
|
+
};
|
|
693
|
+
ids(hidden, true);
|
|
694
|
+
if (expiredOnly) ids(expiredIds, false); else if (!expired) ids(expiredIds, true);
|
|
695
|
+
if (selection) {
|
|
696
|
+
if (selection.project) { where.push("(project = ? OR scope = 'shared')"); bind.push(selection.project); }
|
|
697
|
+
if (selection.tier) {
|
|
698
|
+
if (demoted.size) { where.push("CASE WHEN id IN (SELECT value FROM json_each(?)) THEN 'short' ELSE tier END = ?"); bind.push(JSON.stringify([...demoted.keys()]), selection.tier); }
|
|
699
|
+
else { where.push("tier = ?"); bind.push(selection.tier); }
|
|
700
|
+
}
|
|
701
|
+
for (const key of ["kind", "peer", "machine"] as const) if (selection[key]) { where.push(`${key} = ?`); bind.push(selection[key]!); }
|
|
702
|
+
if (!selection.includeSuperseded) ids(superseded.keys(), true);
|
|
703
|
+
}
|
|
704
|
+
// No SQL LIMIT: a host may screen the complete matching set before its own caps or visible counts.
|
|
705
|
+
// Date parsing, Unicode query matching, locale order and slice semantics stay in JavaScript.
|
|
706
|
+
candidates = this.db.query(`SELECT * FROM entries${where.length ? ` WHERE ${where.join(" AND ")}` : ""}`).all(...bind) as Record<string, string | null>[];
|
|
707
|
+
}
|
|
553
708
|
const out: View[] = [];
|
|
554
|
-
for (const r of
|
|
709
|
+
for (const r of candidates) {
|
|
555
710
|
const id = r.id!;
|
|
556
711
|
// A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
|
|
557
|
-
if (forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
712
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
558
713
|
const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
|
|
559
714
|
const expiresAt = expiry(r);
|
|
560
715
|
if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
|
|
@@ -570,8 +725,9 @@ export class MemoryStore {
|
|
|
570
725
|
* the clock passes it once more. A durable prune entry in the log would close that gap.
|
|
571
726
|
*/
|
|
572
727
|
prune(): number {
|
|
728
|
+
if (this.generationIndex) return 0; // Generation raw rows remain authoritative; only explicit durable prune masks them.
|
|
573
729
|
const now = this.now();
|
|
574
|
-
const expired = this.views(true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
|
|
730
|
+
const expired = this.views(true, undefined, true).filter((v) => v.expiresAt !== null && Date.parse(v.expiresAt) <= now);
|
|
575
731
|
// A copy that expires (it was demoted) takes its whole hidden promotion ancestry with it, or an ancestor would reappear.
|
|
576
732
|
const parent = new Map((this.db.query("SELECT id, promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string; promoted_from: string }[]).map((r) => [r.id, r.promoted_from]));
|
|
577
733
|
const gone = new Set<string>();
|
|
@@ -583,6 +739,7 @@ export class MemoryStore {
|
|
|
583
739
|
|
|
584
740
|
/** Index deferred writes before any note, goal or suggestion read. */
|
|
585
741
|
private ensureFresh(): void {
|
|
742
|
+
if (this.generationIndex) { this.generationIndex.refresh(); return; }
|
|
586
743
|
// Another process (a CLI, another project's hub, a sync) may have written since this one last read the segments.
|
|
587
744
|
if (this.stale || this.shape() !== this.seen) try { this.stale = false; this.refresh(); } catch { this.stale = true; /* still busy: the next read tries again */ }
|
|
588
745
|
}
|
|
@@ -591,9 +748,9 @@ export class MemoryStore {
|
|
|
591
748
|
* What a read sees: rows that expired and promotions that became due while the store sat idle are settled first, so a
|
|
592
749
|
* read, and the index after it, never differ from a rebuild.
|
|
593
750
|
*/
|
|
594
|
-
private live(): View[] {
|
|
751
|
+
private live(selection?: Filter): View[] {
|
|
595
752
|
this.ensureFresh();
|
|
596
|
-
this.prune(); this.promoteHeld(); return this.views();
|
|
753
|
+
this.prune(); this.promoteHeld(); return this.views(false, selection);
|
|
597
754
|
}
|
|
598
755
|
/** A write whose index update failed: the next read refreshes first. */
|
|
599
756
|
private stale = false;
|
|
@@ -601,7 +758,7 @@ export class MemoryStore {
|
|
|
601
758
|
/** Entries visible from `project` (its own, and `shared` ones of any project), newest first, filtered. */
|
|
602
759
|
list(f: Filter = {}): View[] {
|
|
603
760
|
const q = f.q?.toLowerCase().split(/\s+/).filter(Boolean) ?? [];
|
|
604
|
-
return this.live().filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
|
|
761
|
+
return this.live(f).filter((v) => (!f.project || v.project === f.project || v.scope === "shared") && (!f.tier || v.tier === f.tier) && (!f.kind || v.kind === f.kind)
|
|
605
762
|
&& (!f.peer || v.peer === f.peer) && (!f.machine || v.machine === f.machine) && (!f.since || Date.parse(v.at) >= Date.parse(f.since)) && (f.includeSuperseded || !v.supersededBy)
|
|
606
763
|
&& q.every((w) => `${v.title ?? ""}\n${v.text}\n${v.about.join(" ")}`.toLowerCase().includes(w)))
|
|
607
764
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
|
|
@@ -612,12 +769,13 @@ export class MemoryStore {
|
|
|
612
769
|
id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
|
|
613
770
|
const all = this.live().filter((v) => !project || v.project === project || v.scope === "shared");
|
|
614
771
|
const pick = <T extends { id: string }>(rows: T[]) => rows.find((v) => v.id === id) ?? (id.length >= 6 ? (() => { const m = rows.filter((v) => v.id.startsWith(id)); return m.length === 1 ? m[0] : undefined; })() : undefined);
|
|
772
|
+
if (this.generationIndex?.pruned.has(id)) return undefined;
|
|
615
773
|
const found = pick(all);
|
|
616
774
|
if (found) return found;
|
|
617
775
|
// A short-term id an agent kept after its task was approved names the promoted copy now, here or on another machine
|
|
618
776
|
// that has only the synced copy (which names its source).
|
|
619
777
|
let at = pick(this.db.query("SELECT id FROM entries UNION SELECT promoted_from FROM entries WHERE promoted_from IS NOT NULL").all() as { id: string }[])?.id;
|
|
620
|
-
for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) at = copy;
|
|
778
|
+
for (let copy = at; copy; copy = (this.db.query("SELECT id FROM entries WHERE promoted_from = ?").get(copy) as { id: string } | null)?.id) { if (this.generationIndex?.pruned.has(copy)) return undefined; at = copy; }
|
|
621
779
|
return at ? all.find((v) => v.id === at) : undefined;
|
|
622
780
|
}
|
|
623
781
|
|
|
@@ -652,12 +810,15 @@ export class MemoryStore {
|
|
|
652
810
|
|
|
653
811
|
/** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
|
|
654
812
|
graph(project: string, start: Node, hops = 1, cap = 200): Graph {
|
|
813
|
+
this.ensureFresh();
|
|
814
|
+
if (start.startsWith("mem:") && this.generationIndex?.pruned.has(start.slice(4))) return { nodes: [], edges: [] };
|
|
655
815
|
const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
|
|
656
816
|
const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
|
|
657
817
|
const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
|
|
658
818
|
const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
|
|
659
819
|
const taskNode = (key: string) => `task:${key}`; // a link's task key is the task node's own (id and proposal time)
|
|
660
820
|
const node = (id: Node) => {
|
|
821
|
+
if (id.startsWith("mem:") && this.generationIndex?.pruned.has(id.slice(4))) return false;
|
|
661
822
|
if (nodes.has(id) || nodes.size >= cap) return nodes.has(id);
|
|
662
823
|
const v = live.get(id), g = goals.get(id);
|
|
663
824
|
nodes.set(id, v ? { id, type: "memory", label: (v.title ?? v.text).slice(0, 60), kind: v.kind, tier: v.tier } : g ? { id, type: "goal", label: g.title.slice(0, 60), kind: g.state } : { id, type: id.slice(0, id.indexOf(":")), label: id.slice(id.indexOf(":") + 1) });
|
|
@@ -792,10 +953,18 @@ export class MemoryStore {
|
|
|
792
953
|
});
|
|
793
954
|
}
|
|
794
955
|
|
|
956
|
+
/** Person-local diagnostic receipt; malformed legacy bodies are never included. */
|
|
957
|
+
bridgeStatus(): { skippedRows: number } | undefined {
|
|
958
|
+
return this.generationIndex ? { skippedRows: this.generationIndex.skippedRows() } : undefined;
|
|
959
|
+
}
|
|
960
|
+
|
|
795
961
|
/** What the segments hold, for `ahub memory` and the dashboard header. */
|
|
796
962
|
counts(project?: string): { short: number; long: number; segments: number; machine: Machine } {
|
|
797
963
|
const all = this.list({ ...(project ? { project } : {}), limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
|
|
798
|
-
|
|
964
|
+
const segments = this.generationIndex
|
|
965
|
+
? (this.db.query("SELECT COUNT(*) AS n FROM generation_segments").get() as { n: number }).n
|
|
966
|
+
: this.segments("log").length + this.segments("local").length;
|
|
967
|
+
return { short: all.filter((v) => v.tier === "short").length, long: all.filter((v) => v.tier === "long").length, segments, machine: this.machine };
|
|
799
968
|
}
|
|
800
969
|
|
|
801
970
|
/** The index's rows in a fixed order: what a rebuild must reproduce. */
|
|
@@ -803,3 +972,33 @@ export class MemoryStore {
|
|
|
803
972
|
return JSON.stringify(["entries", "ops", "edges", "goals"].map((t) => this.db.query(`SELECT * FROM ${t} ORDER BY 1, 2`).all()));
|
|
804
973
|
}
|
|
805
974
|
}
|
|
975
|
+
|
|
976
|
+
/** Migration builds only its generation-owned index over exact captured source prefixes. */
|
|
977
|
+
export function buildGenerationIndex(profile: BoundProfile, sources: readonly LegacyCheckpoint[], legacyCommit?: string): void {
|
|
978
|
+
const store = new MemoryStore({ deferPromotions: true }, { token: MIGRATION_BOOTSTRAP, profile, limits: sources, ...(legacyCommit ? { commit: legacyCommit } : {}) });
|
|
979
|
+
try { const before = store.derivedTables(); store.rebuild(); if (!isDeepStrictEqual(before, store.derivedTables())) throw new Error("generation rebuild differs from incremental index"); }
|
|
980
|
+
finally { store.close(); }
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */
|
|
984
|
+
export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
|
|
985
|
+
const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
|
|
986
|
+
let malformed = 0;
|
|
987
|
+
const rows = legacyEntries([{ source: "legacy-local", path: "log/inspection.jsonl", bytes, offset: bytes.length, hash: createHash("sha256").update(bytes).digest("hex"), local: false, tailBytes: 0 }], { onMalformed: () => malformed++ });
|
|
988
|
+
const byOp: Record<string, number> = {}, byTier: Record<string, number> = {};
|
|
989
|
+
for (const row of rows) { byOp[row.op] = (byOp[row.op] ?? 0) + 1; if (row.op === "note") byTier[row.tier] = (byTier[row.tier] ?? 0) + 1; }
|
|
990
|
+
// Explicit proof eligibility is shared with prune planning; implicit TTL is reported but never used as a prune proof.
|
|
991
|
+
const located = rows.map(row => ({ row, placement: "local" as const, publicWitness: false }));
|
|
992
|
+
const explicit = explicitExpirySnapshot(located).filter(e => Date.parse(e.expiredAt) <= now);
|
|
993
|
+
const proved = new Map(explicitExpirySnapshot(located).map(e => [e.targetId, e.expiredAt]));
|
|
994
|
+
const settled = new Set(rows.flatMap(e => e.op === "settle" ? [e.target] : []));
|
|
995
|
+
const expiredRows = rows.filter(row => {
|
|
996
|
+
if (row.op !== "note") return false;
|
|
997
|
+
const fixed = proved.get(row.id);
|
|
998
|
+
if (fixed) return Date.parse(fixed) <= now;
|
|
999
|
+
if (row.tier !== "short" || row.task && settled.has(row.task) && PROMOTED_KINDS.includes(row.kind)) return false;
|
|
1000
|
+
// Diagnostic only, at the CLI's default TTL. Such derived expiry is never a durable-prune witness.
|
|
1001
|
+
return Date.parse(row.at) + (row.task ? UNSETTLED_MS : DEFAULT_TTL_MS) <= now;
|
|
1002
|
+
}).length;
|
|
1003
|
+
return { validRows: rows.length, expiredRows, eligibleRows: explicit.length, skippedRows: malformed, tailBytes, byOp, byTier };
|
|
1004
|
+
}
|
package/src/memory/sync.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
|
|
4
|
+
import { acquireMutation } from "./mutation.ts";
|
|
4
5
|
import type { MemoryStore } from "./store.ts";
|
|
6
|
+
import { syncGenerationMemory } from "./generation-sync.ts";
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* #258 phase 2: hub memory between a person's machines through a git remote they configure. The memory directory is a
|
|
@@ -28,6 +30,26 @@ async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_
|
|
|
28
30
|
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
export { git as syncGit };
|
|
34
|
+
|
|
35
|
+
/** Generation-only process isolation; shared-v1 above retains its pre-P9 spawn and environment contract. */
|
|
36
|
+
export async function generationSyncGit(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
|
|
37
|
+
if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
|
|
38
|
+
// A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
|
|
39
|
+
// Args are positional shell parameters, never interpolated or evaluated as shell code.
|
|
40
|
+
const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }) });
|
|
41
|
+
// SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
|
|
42
|
+
let hard: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
const kill = () => { p.kill("SIGTERM"); hard ??= setTimeout(() => p.kill("SIGKILL"), 2_000); };
|
|
44
|
+
const timer = setTimeout(kill, ms);
|
|
45
|
+
signal?.addEventListener("abort", kill, { once: true });
|
|
46
|
+
try {
|
|
47
|
+
const [code, out, err] = await Promise.all([p.exited, new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
48
|
+
return { code, out: out.trim(), err: err.trim() };
|
|
49
|
+
} finally { clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
31
53
|
/** GitHub remotes are asked through `gh`; any other host, or a `gh` that cannot answer, is unknown. */
|
|
32
54
|
export async function remoteVisibility(url: string): Promise<Visibility> {
|
|
33
55
|
const repo = /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/.exec(url)?.[1];
|
|
@@ -71,6 +93,7 @@ export interface SyncOptions {
|
|
|
71
93
|
}
|
|
72
94
|
|
|
73
95
|
export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
|
|
96
|
+
if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
|
|
74
97
|
const dir = store.dir;
|
|
75
98
|
const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
|
|
76
99
|
if (opts.signal?.aborted) return fail("stopped");
|
|
@@ -78,7 +101,10 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
78
101
|
if (!opts.remote && !existsSync(join(dir, ".git"))) return fail(noRemote); // never set up: nothing to do, nothing created
|
|
79
102
|
const release = lock(dir);
|
|
80
103
|
if (!release) return fail("another sync is running on this machine");
|
|
104
|
+
let releaseMutation: (() => void) | undefined;
|
|
81
105
|
try {
|
|
106
|
+
releaseMutation = acquireMutation(dir);
|
|
107
|
+
if (!releaseMutation) return fail("another memory mutation is running on this machine");
|
|
82
108
|
// Holding sync.lock, no other git runs in this repository: what a killed one left behind is stale.
|
|
83
109
|
rmSync(join(dir, ".git", "index.lock"), { force: true });
|
|
84
110
|
if (existsSync(join(dir, ".git", "MERGE_HEAD"))) await git(dir, ["merge", "--abort"]);
|
|
@@ -136,9 +162,11 @@ export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Pr
|
|
|
136
162
|
pushed = true;
|
|
137
163
|
}
|
|
138
164
|
if (opts.signal?.aborted) return fail("stopped");
|
|
139
|
-
store.
|
|
140
|
-
|
|
165
|
+
return store.withDeferredPromotions(() => {
|
|
166
|
+
store.refresh();
|
|
167
|
+
return { ok: true, pushed, entries: store.counts().long, ...(warning ? { warning } : {}) };
|
|
168
|
+
});
|
|
141
169
|
} catch (e) {
|
|
142
170
|
return fail((e as Error).message.slice(0, 200));
|
|
143
|
-
} finally { release(); }
|
|
171
|
+
} finally { try { releaseMutation?.(); } finally { release(); } }
|
|
144
172
|
}
|