@staix/agent-mem 0.5.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +13 -0
- package/.claude-plugin/plugin.json +15 -0
- package/README.md +186 -21
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +492 -39
- package/src/commands-table.ts +56 -15
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +566 -0
- package/src/hooks.ts +253 -0
- package/src/index.ts +18 -0
- package/src/ingest.ts +351 -0
- package/src/mcp-memory.ts +141 -33
- package/src/mcp.ts +42 -7
- package/src/memory/aliases.ts +87 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +144 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +162 -59
- package/src/memory/capture-input.ts +36 -0
- package/src/memory/capture.ts +153 -39
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/cross-project.ts +75 -0
- package/src/memory/curated-read.ts +29 -0
- package/src/memory/decay-review.ts +136 -0
- package/src/memory/doctor.ts +412 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +137 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +150 -0
- package/src/memory/gc.ts +97 -0
- package/src/memory/generation-index.ts +22 -17
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/goal-promotion.ts +71 -0
- package/src/memory/home.ts +15 -4
- package/src/memory/intent-brief.ts +89 -0
- package/src/memory/lifecycle-log.ts +54 -0
- package/src/memory/lifecycle.ts +61 -0
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/note-add.ts +100 -0
- package/src/memory/path-glob.ts +94 -0
- package/src/memory/plan-guard.ts +47 -0
- package/src/memory/platform.ts +9 -4
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +122 -0
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/recall-staleness.ts +56 -0
- package/src/memory/recall.ts +74 -0
- package/src/memory/repo-scope.ts +404 -0
- package/src/memory/shard-sync-format.ts +116 -0
- package/src/memory/shard-sync.ts +421 -0
- package/src/memory/shard.ts +3 -80
- package/src/memory/store.ts +286 -56
- package/src/memory/sync-cadence.ts +139 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +79 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +115 -38
- package/src/session-brief-worker.ts +82 -0
- package/src/session-brief.ts +261 -0
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +291 -0
- package/src/setup.ts +327 -0
package/src/memory/store.ts
CHANGED
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import { createHash
|
|
3
|
-
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync,
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { isAbsolute, join, normalize } from "node:path";
|
|
5
|
-
import { isDenied } from "./platform.ts";
|
|
5
|
+
import { isDenied, realPath } from "./platform.ts";
|
|
6
|
+
import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
|
|
6
7
|
import { looksSecret } from "./screen.ts";
|
|
7
8
|
import { mutationDatabase } from "./mutation.ts";
|
|
8
9
|
import { memoryHome } from "./home.ts";
|
|
10
|
+
import { machineId, type Machine } from "./machine.ts";
|
|
11
|
+
import { assertProjectActive } from "./lifecycle.ts";
|
|
12
|
+
export { machineId, type Machine } from "./machine.ts";
|
|
9
13
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
10
|
-
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
14
|
+
import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
|
|
11
15
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
12
16
|
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
13
|
-
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
17
|
+
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
14
18
|
import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
15
|
-
import { legacyEntries, type LegacyCheckpoint } from "./bridge.ts";
|
|
19
|
+
import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
|
|
16
20
|
import { readMigrationSources } from "./migrate.ts";
|
|
17
21
|
import type { PruneStorePort } from "./prune.ts";
|
|
18
22
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -124,33 +128,6 @@ const oneLine = (text: string): string => text.replace(/\s+/g, " ").trim().slice
|
|
|
124
128
|
/** A board task's key in hub memory: unique across `ahub reset --all`, which restarts the ids. */
|
|
125
129
|
export const taskKey = (project: string, task: { id: number; created: number }): string => `${project}#${task.id}@${task.created}`;
|
|
126
130
|
|
|
127
|
-
export interface Machine { id: string; label: string }
|
|
128
|
-
/** Random per `~/.agenthub`, with a person-given label; created on first use. */
|
|
129
|
-
export function machineId(dir: string): Machine {
|
|
130
|
-
const file = join(dir, "machine.json");
|
|
131
|
-
const read = (): Machine | undefined => {
|
|
132
|
-
try {
|
|
133
|
-
const m = JSON.parse(readFileSync(file, "utf8")) as Machine;
|
|
134
|
-
if (/^[0-9a-f]{16}$/.test(m?.id)) return { id: m.id, label: typeof m.label === "string" ? m.label : m.id };
|
|
135
|
-
} catch { /* none, or unreadable */ }
|
|
136
|
-
return undefined;
|
|
137
|
-
};
|
|
138
|
-
const found = read();
|
|
139
|
-
if (found) return found;
|
|
140
|
-
// Hubs of several projects can start at once on a fresh home: each writes its own temp file and links it into place,
|
|
141
|
-
// which fails if another got there first; then everyone reads the one identity that won.
|
|
142
|
-
const id = randomBytes(8).toString("hex");
|
|
143
|
-
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
144
|
-
const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
145
|
-
writeFileSync(tmp, JSON.stringify({ id, label: id }) + "\n", { mode: 0o600 });
|
|
146
|
-
try { linkSync(tmp, file); } catch { /* another process created it: its identity wins */ } finally { rmSync(tmp, { force: true }); }
|
|
147
|
-
// A file that is there but holds no valid id is never overwritten (another process may be reading it): the store
|
|
148
|
-
// fails to open, so the hub says hub memory is unavailable instead of filing notes under no machine.
|
|
149
|
-
const won = read();
|
|
150
|
-
if (!won) throw new Error(`${file} holds no valid machine id; remove it and a new one is made`);
|
|
151
|
-
return won;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
131
|
/** A person's name for this machine, shown beside its id. */
|
|
155
132
|
export function setMachineLabel(dir: string, label: string): Machine {
|
|
156
133
|
const m = machineId(dir);
|
|
@@ -167,6 +144,20 @@ export interface View {
|
|
|
167
144
|
local: boolean;
|
|
168
145
|
}
|
|
169
146
|
export interface VaultEntry { view: View; /** Full authored hash in generation profiles; empty for read-only shared-v1. */ rowHash: string }
|
|
147
|
+
export interface RecallStateOptions { person?: boolean; asOf?: string; history?: boolean; maxRows?: number; maxBytes?: number }
|
|
148
|
+
export interface RecallRelation { from: string; to: string; at: string; kind: "supersede" | "promote" }
|
|
149
|
+
export interface RetainedRecallState {
|
|
150
|
+
at: string; views: View[]; forgotten: { id: string; at: string }[]; relations: RecallRelation[];
|
|
151
|
+
malformed: number; incomplete: boolean; notices: string[];
|
|
152
|
+
/** Present only when retained source diagnostics independently make the result incomplete. */
|
|
153
|
+
sourceIncomplete?: true;
|
|
154
|
+
}
|
|
155
|
+
interface RecallViewProjection {
|
|
156
|
+
now: number; rows: Record<string, string | null>[];
|
|
157
|
+
ops: { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
158
|
+
realms?: Map<string, { id: string; own: number; legacy: number }>;
|
|
159
|
+
pruned: Set<string>; forgotten: Set<string>; includePromoted: boolean;
|
|
160
|
+
}
|
|
170
161
|
export interface GoalView {
|
|
171
162
|
id: string; at: string; machine: string; project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[];
|
|
172
163
|
state: GoalState; links: { task: string; at: string; machine: string }[];
|
|
@@ -194,6 +185,8 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
194
185
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
195
186
|
*/
|
|
196
187
|
export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: number; now?: () => number; log?: (line: string) => void; screen?: PromotionScreen; deferPromotions?: boolean; profile?: ProfileInput;
|
|
188
|
+
/** #15 T5: `openShardStore` refuses a project the person archived or forgot unless the caller says `ignore` (the catalog, `--include-archived` reads and export do, on purpose). */
|
|
189
|
+
lifecycle?: "enforce" | "ignore";
|
|
197
190
|
/** #27: read the `aliases` keys' curated rows as part of the `canonical` project (CLI and MCP only); the library default reads the raw key. Rows are presented under `canonical`. */
|
|
198
191
|
reconcile?: { canonical: string; aliases: readonly string[] };
|
|
199
192
|
}
|
|
@@ -292,6 +285,11 @@ export class MemoryStore {
|
|
|
292
285
|
return this.generationIndex.port(this.machine.id);
|
|
293
286
|
}
|
|
294
287
|
|
|
288
|
+
/** Borrowed sync targets: the opened canonical shard plus its user overlay, never person-local aliases. Do not close these handles. */
|
|
289
|
+
openedSyncTargets(): readonly MemoryStore[] {
|
|
290
|
+
return [this, ...(this.userOverlay ? [this.userOverlay] : [])];
|
|
291
|
+
}
|
|
292
|
+
|
|
295
293
|
/** Read-only project views, bounded before bodies or hashes; never promotes or authors a prune. */
|
|
296
294
|
vaultEntries(project: string): VaultEntry[] {
|
|
297
295
|
if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
|
|
@@ -361,15 +359,38 @@ export class MemoryStore {
|
|
|
361
359
|
|
|
362
360
|
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
363
361
|
withDeferredPromotions<T>(work: () => T): T {
|
|
364
|
-
const previous = this.promoting;
|
|
365
|
-
|
|
366
|
-
try { return work(); } finally {
|
|
362
|
+
const previous = this.readStores().map(store => [store, store.promoting] as const);
|
|
363
|
+
for (const [store] of previous) store.promoting = true;
|
|
364
|
+
try { return work(); } finally { for (const [store, promoting] of previous) store.promoting = promoting; }
|
|
367
365
|
}
|
|
368
366
|
|
|
369
367
|
private withMutation<T>(work: () => T): T {
|
|
370
368
|
return this.mutationDb.transaction(work).immediate();
|
|
371
369
|
}
|
|
372
370
|
|
|
371
|
+
/** Synchronous exact-target work under every opened shard's existing writer fence. No read-time promotions. */
|
|
372
|
+
withCurrentEntries<T>(ids: readonly string[], project: string, work: (entries: readonly View[]) => T): T {
|
|
373
|
+
if (!ids.length || ids.length > MAX_TARGETS || new Set(ids).size !== ids.length || ids.some(id => !/^[a-f0-9]{32}$/.test(id)) || !project.trim()) throw new Error("invalid exact-entry batch");
|
|
374
|
+
return this.withStoreFences(stores => {
|
|
375
|
+
assertProjectActive(this.profile.home, project);
|
|
376
|
+
for (const store of stores) store.ensureFresh();
|
|
377
|
+
const entries = ids.map(id => {
|
|
378
|
+
const view = this.get(id, project);
|
|
379
|
+
if (!view || view.id !== id || view.supersededBy) throw new Error("exact entry changed or unavailable");
|
|
380
|
+
return view;
|
|
381
|
+
});
|
|
382
|
+
return work(entries);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
private withStoreFences<T>(work: (stores: readonly MemoryStore[]) => T): T {
|
|
386
|
+
const unique = new Map<string, MemoryStore>();
|
|
387
|
+
for (const store of this.readStores()) { const dir = realPath(store.dir); if (!unique.has(dir)) unique.set(dir, store); }
|
|
388
|
+
const stores = [...unique.keys()].sort().map(dir => unique.get(dir)!);
|
|
389
|
+
const lock = (index: number): T => index === stores.length ? work(stores) : stores[index]!.withMutation(() => lock(index + 1));
|
|
390
|
+
// Older/raw writers that bypass mutation-lock.db retain the existing mutation.ts ceiling.
|
|
391
|
+
return this.withDeferredPromotions(() => lock(0));
|
|
392
|
+
}
|
|
393
|
+
|
|
373
394
|
/** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
|
|
374
395
|
withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
|
|
375
396
|
if (this.userOverlay && note.scope === "shared") return this.userOverlay.withAcceptedNote(note, work);
|
|
@@ -377,8 +398,8 @@ export class MemoryStore {
|
|
|
377
398
|
// Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
|
|
378
399
|
this.db.transaction(() => this.refresh()).immediate();
|
|
379
400
|
const at = new Date(this.now()).toISOString();
|
|
380
|
-
const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
|
|
381
|
-
...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
|
|
401
|
+
const normalized = structuredClone({ ...note, text: stripPrivateSpans(note.text).slice(0, MAX_TEXT),
|
|
402
|
+
...(note.title ? { title: stripPrivateSpans(note.title).slice(0, MAX_TITLE) } : {}),
|
|
382
403
|
...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
|
|
383
404
|
const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
|
|
384
405
|
const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
|
|
@@ -404,7 +425,9 @@ export class MemoryStore {
|
|
|
404
425
|
|
|
405
426
|
/** Append one entry to this machine's long-term (synced) or local segment and index it. Returns its id. */
|
|
406
427
|
private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
|
|
407
|
-
|
|
428
|
+
// `<private>` spans are never stored, whoever writes (note, goal, promotion): dropped before the cap, the hash and the log line.
|
|
429
|
+
const capped = entry.op === "note" ? { ...entry, text: stripPrivateSpans(entry.text).slice(0, MAX_TEXT), ...(entry.title ? { title: stripPrivateSpans(entry.title).slice(0, MAX_TITLE) } : {}) }
|
|
430
|
+
: entry.op === "goal" ? stripPrivateDeep(entry) : entry;
|
|
408
431
|
const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
|
|
409
432
|
return this.appendBody(body, local);
|
|
410
433
|
}
|
|
@@ -444,12 +467,12 @@ export class MemoryStore {
|
|
|
444
467
|
if (this.profile.sharded && (this.profile.shardUser ? n.scope !== "shared" : n.scope !== "project" || n.project !== this.profile.shardKey)) throw new Error("entry does not belong to this curated shard");
|
|
445
468
|
assertSharedV1Op(n.op);
|
|
446
469
|
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
447
|
-
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">;
|
|
470
|
+
const body = { schema: MEMORY_SCHEMA, at, machine: "claude-mem", ...n, text: stripPrivateSpans(n.text).slice(0, MAX_TEXT), ...(n.title ? { title: stripPrivateSpans(n.title).slice(0, MAX_TITLE) } : {}) } as Omit<Entry, "id">;
|
|
448
471
|
const id = entryId(body);
|
|
449
472
|
return this.withMutation(() => {
|
|
450
473
|
this.generationIndex?.assertAppendable(n.tier === "short", this.machine.id);
|
|
451
474
|
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
452
|
-
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;
|
|
475
|
+
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, stripPrivateSpans(n.text).slice(0, MAX_TEXT)) as { id: string } | null;
|
|
453
476
|
if (known) return { id: known.id, added: false };
|
|
454
477
|
appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
455
478
|
return { id, added: true };
|
|
@@ -460,11 +483,19 @@ export class MemoryStore {
|
|
|
460
483
|
private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
|
|
461
484
|
return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
|
|
462
485
|
}
|
|
463
|
-
forget
|
|
486
|
+
/** Complete existing forget expansion, including superseded legacy-import copies. Reads author no ops. */
|
|
487
|
+
forgetTargets(id: string, project?: string): View[] {
|
|
488
|
+
const v = this.get(id, project); return v ? this.forgetGroup(v) : [];
|
|
489
|
+
}
|
|
490
|
+
private forgetGroup(v: View): View[] {
|
|
491
|
+
return v.source === "claude-mem" ? this.live().filter(x => x.source === v.source && x.project === v.project && x.at === v.at && x.text === v.text) : [v];
|
|
492
|
+
}
|
|
493
|
+
forget(id: string, actor: string, project?: string, exactOnly = false): View | undefined {
|
|
464
494
|
const v = this.get(id, project);
|
|
465
495
|
if (!v) return undefined;
|
|
466
|
-
|
|
467
|
-
|
|
496
|
+
const group = this.forgetGroup(v);
|
|
497
|
+
if (exactOnly && (group.length !== 1 || group[0]?.id !== id)) throw Error("forget expands beyond the reviewed exact entry");
|
|
498
|
+
// Ordinary person forget retains its legacy-import copy expansion; reviewed decay is exact-only.
|
|
468
499
|
for (const member of group) this.op("forget", member, actor);
|
|
469
500
|
return v;
|
|
470
501
|
}
|
|
@@ -511,7 +542,17 @@ export class MemoryStore {
|
|
|
511
542
|
}
|
|
512
543
|
/** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
|
|
513
544
|
keepShort(id: string, actor: string): void { const v = this.get(id); if (v?.tier === "short" && v.local && v.expiresAt === null) this.op("demote", v, actor, this.until()); } // once: a deadline is never pushed out
|
|
514
|
-
|
|
545
|
+
/** The short-tier deadline to display before a person confirms a demotion. */
|
|
546
|
+
shortExpiry(): string { return this.until(); }
|
|
547
|
+
/** A confirmed deadline may be older than a fresh one, but cannot extend the configured TTL beyond one hour. */
|
|
548
|
+
assertDemotionExpiry(expiresAt: string): void {
|
|
549
|
+
const at = Date.parse(expiresAt), now = this.now();
|
|
550
|
+
if (!Number.isFinite(at) || new Date(at).toISOString() !== expiresAt || at <= now || at > now + this.ttl + 3600_000) throw Error("demotion expiry must be future UTC within the short-tier deadline plus one hour");
|
|
551
|
+
}
|
|
552
|
+
demote(id: string, actor: string, project?: string, expiresAt?: string): View | undefined {
|
|
553
|
+
if (expiresAt !== undefined) this.assertDemotionExpiry(expiresAt);
|
|
554
|
+
const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, expiresAt ?? this.until()); return v;
|
|
555
|
+
}
|
|
515
556
|
supersede(oldId: string, newId: string, actor: string, project?: string): boolean {
|
|
516
557
|
const a = this.get(oldId, project), b = this.get(newId, project);
|
|
517
558
|
if (!a || !b || a.id === b.id) return false;
|
|
@@ -759,18 +800,18 @@ export class MemoryStore {
|
|
|
759
800
|
return nest(this.readStores());
|
|
760
801
|
}
|
|
761
802
|
|
|
762
|
-
private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
|
|
763
|
-
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
764
|
-
const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
|
|
765
|
-
if (this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
|
|
803
|
+
private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean, projection?: RecallViewProjection): View[] {
|
|
804
|
+
const now = projection?.now ?? this.now(), selecting = selection !== undefined || expiredOnly;
|
|
805
|
+
const pruned = projection?.pruned ?? (this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined);
|
|
806
|
+
if (!projection && this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
|
|
766
807
|
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
767
|
-
const rows = this.readRows(selecting
|
|
808
|
+
const rows = projection?.rows ?? this.readRows(selecting
|
|
768
809
|
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
769
810
|
: "SELECT * FROM entries") as Record<string, string | null>[];
|
|
770
|
-
const ops = this.readRows("SELECT id, op, at, target, by FROM ops ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
771
|
-
const realms = this.generationIndex ? new Map((this.readRows("SELECT id,own,legacy FROM generation_rows") as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
|
|
811
|
+
const ops = projection?.ops ?? this.readRows("SELECT id, op, at, target, by FROM ops ORDER BY at, id") as { id: string; op: string; at: string; target: string; by: string | null }[];
|
|
812
|
+
const realms = projection ? projection.realms : this.generationIndex ? new Map((this.readRows("SELECT id,own,legacy FROM generation_rows") as { id: string; own: number; legacy: number }[]).map(row => [row.id, row])) : undefined;
|
|
772
813
|
const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
|
|
773
|
-
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
814
|
+
const forgotten = new Set([...ops.filter((o) => o.op === "forget").map((o) => o.target), ...(projection?.forgotten ?? [])]);
|
|
774
815
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
775
816
|
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();
|
|
776
817
|
const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
|
|
@@ -876,7 +917,7 @@ export class MemoryStore {
|
|
|
876
917
|
for (const r of candidates) {
|
|
877
918
|
const id = r.id!;
|
|
878
919
|
// A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
|
|
879
|
-
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
920
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id) && !projection?.includePromoted) continue;
|
|
880
921
|
const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
|
|
881
922
|
const expiresAt = expiry(r);
|
|
882
923
|
if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
|
|
@@ -1028,6 +1069,191 @@ export class MemoryStore {
|
|
|
1028
1069
|
});
|
|
1029
1070
|
}
|
|
1030
1071
|
|
|
1072
|
+
/** Metadata-only revision of the opened read set, never a note body or an unopened project shard. */
|
|
1073
|
+
recallRevision(): string { return this.recallFingerprint(true); }
|
|
1074
|
+
private recallFingerprint(databases: boolean): string {
|
|
1075
|
+
const entries: unknown[] = [];
|
|
1076
|
+
const stamp = (file: string, directory = false) => {
|
|
1077
|
+
try {
|
|
1078
|
+
const stat = lstatSync(file, { bigint: true });
|
|
1079
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw Error("recall input path unavailable");
|
|
1080
|
+
entries.push([file, String(stat.dev), String(stat.ino), ...(directory ? [] : [String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])]);
|
|
1081
|
+
return true;
|
|
1082
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { entries.push([file, "absent"]); return false; } throw error; }
|
|
1083
|
+
};
|
|
1084
|
+
const roots = new Set<string>(), fixed = new Set<string>();
|
|
1085
|
+
for (const store of this.readStores()) {
|
|
1086
|
+
roots.add(store.dir);
|
|
1087
|
+
for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
|
|
1088
|
+
for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
|
|
1089
|
+
if (store.generationIndex) {
|
|
1090
|
+
const legacy = store.profile.legacyHome ?? store.profile.home; roots.add(legacy);
|
|
1091
|
+
if (store.profile.legacyMainHome) roots.add(store.profile.legacyMainHome);
|
|
1092
|
+
if (!store.profile.sharded && store.profile.generation) {
|
|
1093
|
+
const dir = generationDir(legacy, store.profile.generation), pointer = join(dir, "bridge-snapshot.json"); fixed.add(pointer);
|
|
1094
|
+
// Only the small sealed pointer is parsed to locate its active archive; JSONL bodies are never read here.
|
|
1095
|
+
const head = readProfileJSON(pointer, 4096) as { commit?: unknown } | undefined;
|
|
1096
|
+
if (head !== undefined) {
|
|
1097
|
+
if (typeof head.commit !== "string" || !/^[a-f0-9]{40,64}$/.test(head.commit)) throw Error("recall bridge pointer unavailable");
|
|
1098
|
+
const archive = join(dir, "bridge-snapshots", head.commit); roots.add(archive); fixed.add(join(archive, "snapshot.json"));
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
if (databases) entries.push([store.dir, store.db.query("PRAGMA data_version").get(), store.db.query("SELECT total_changes() AS n").get()]);
|
|
1103
|
+
}
|
|
1104
|
+
for (const root of [...roots].sort()) {
|
|
1105
|
+
if (!stamp(root, true)) continue;
|
|
1106
|
+
fixed.add(join(root, "machine.json"));
|
|
1107
|
+
for (const folder of ["log", "local"]) {
|
|
1108
|
+
const dir = join(root, folder); if (!stamp(dir, true)) continue;
|
|
1109
|
+
for (const name of readdirSync(dir).filter(name => name.endsWith(".jsonl")).sort()) stamp(join(dir, name));
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
for (const file of [...fixed].sort()) stamp(file);
|
|
1113
|
+
return hashJSON(entries);
|
|
1114
|
+
}
|
|
1115
|
+
private prepareRecallRead(stores: readonly MemoryStore[]): void {
|
|
1116
|
+
for (const store of stores) {
|
|
1117
|
+
const active = readSelector(store.profile.home);
|
|
1118
|
+
if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
|
|
1119
|
+
if (store.profile.sharded) {
|
|
1120
|
+
const selected = readShardedSelection(store.profile.home);
|
|
1121
|
+
if (selected.generation !== store.profile.generation || selected.selectorRevision !== store.profile.selectorRevision) throw Error("recall shard binding changed");
|
|
1122
|
+
} else if ((readProfileJSON(join(store.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
|
|
1123
|
+
|| (active.selector?.kind ?? "shared-v1") !== store.profile.kind || store.profile.kind === "generation" && active.selector?.generation !== store.profile.generation) throw Error("recall profile changed");
|
|
1124
|
+
if (store.reconcile && (resolveAlias(store.profile.home, store.reconcile.canonical) !== store.reconcile.canonical || canonicalJSON(aliasedKeys(store.profile.home, store.reconcile.canonical).sort()) !== canonicalJSON([...store.reconcile.aliases].sort()))) throw Error("recall alias binding changed");
|
|
1125
|
+
if (store.generationIndex) store.generationIndex.refresh();
|
|
1126
|
+
else if (store.stale || store.shape() !== store.seen) {
|
|
1127
|
+
store.stale = false; store.refresh();
|
|
1128
|
+
if (store.stale || store.shape() !== store.seen) throw Error("recall refresh unavailable");
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
/** Strict preparation once; callers release the fences before awaiting policy or optional overlays. */
|
|
1133
|
+
beginRecallRead(): () => void {
|
|
1134
|
+
return this.withStoreFences(stores => {
|
|
1135
|
+
const before = this.recallFingerprint(false);
|
|
1136
|
+
this.prepareRecallRead(stores);
|
|
1137
|
+
for (const store of stores) store.prune(); // readPage performs this too; settle its derived writes before the revision.
|
|
1138
|
+
const captured = this.recallRevision();
|
|
1139
|
+
if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
|
|
1140
|
+
return () => { if (this.recallRevision() !== captured) throw Error("recall inputs changed"); };
|
|
1141
|
+
});
|
|
1142
|
+
}
|
|
1143
|
+
/** One lifecycle fold for a bounded exact-project set of useful intent-brief genres. */
|
|
1144
|
+
intentSnapshot(project: string, limit = 5000): { rows: View[]; truncated: boolean; assertCurrent(): void } {
|
|
1145
|
+
if (!project.trim() || project.length > 400 || !Number.isSafeInteger(limit) || limit < 1 || limit > 5000 || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw Error("invalid intent snapshot scope or limit");
|
|
1146
|
+
return this.withStoreFences(stores => {
|
|
1147
|
+
const before = this.recallFingerprint(false);
|
|
1148
|
+
assertProjectActive(this.profile.home, project); this.prepareRecallRead(stores);
|
|
1149
|
+
const revision = this.recallRevision(), ids = new Map<string, { id: string; at: string }>();
|
|
1150
|
+
if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
|
|
1151
|
+
for (const store of stores) {
|
|
1152
|
+
const rows = store.db.query("SELECT id,at FROM entries WHERE project IN (SELECT value FROM json_each(?)) AND kind IN ('fail','decision','contract') ORDER BY at DESC,id LIMIT ?").all(JSON.stringify(this.scopeKeys(project)), limit + 1) as { id: string; at: string }[];
|
|
1153
|
+
for (const row of rows) ids.set(row.id, row);
|
|
1154
|
+
}
|
|
1155
|
+
const refs = [...ids.values()].sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)), selected = refs.slice(0, limit);
|
|
1156
|
+
const rows = selected.length ? this.views(false, { project, ids: selected.map(row => row.id) }).filter(view => view.project === project && !view.supersededBy && Number.isFinite(Date.parse(view.at))) : [];
|
|
1157
|
+
const order = new Map(selected.map((row, index) => [row.id, index])); rows.sort((a, b) => order.get(a.id)! - order.get(b.id)!);
|
|
1158
|
+
const assertCurrent = () => { if (this.recallRevision() !== revision) throw Error("recall inputs changed"); };
|
|
1159
|
+
assertCurrent(); return { rows, truncated: refs.length > limit, assertCurrent };
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** The same injected clock used by validity, expiry and projection; callers must not substitute wall time. */
|
|
1164
|
+
readNow(): number { return this.now(); }
|
|
1165
|
+
|
|
1166
|
+
/** Person historical recall over verified retained rows. Current removals always override past state. */
|
|
1167
|
+
recallState(project: string, options: RecallStateOptions = {}): RetainedRecallState {
|
|
1168
|
+
if ((options.history || options.asOf !== undefined) && options.person !== true) throw Error("historical recall requires authenticated person intent from the host");
|
|
1169
|
+
if (!project.trim() || project.length > 400 || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw Error("recall project does not match the bound store");
|
|
1170
|
+
const maximumRows = options.maxRows ?? 50_000, maximumBytes = options.maxBytes ?? 32 * 1024 * 1024, present = this.now();
|
|
1171
|
+
if (!Number.isSafeInteger(maximumRows) || maximumRows < 1 || maximumRows > 50_000 || !Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > 32 * 1024 * 1024) throw Error("invalid retained recall budget");
|
|
1172
|
+
let time = present;
|
|
1173
|
+
if (options.asOf !== undefined) {
|
|
1174
|
+
const value = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(options.asOf) ? `${options.asOf}T00:00:00.000Z` : options.asOf;
|
|
1175
|
+
time = Date.parse(value);
|
|
1176
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
|
|
1177
|
+
}
|
|
1178
|
+
const frontier = () => {
|
|
1179
|
+
const active = readSelector(this.profile.home);
|
|
1180
|
+
if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
|
|
1181
|
+
if (this.profile.sharded) {
|
|
1182
|
+
const shards = readShardedSelection(this.profile.home);
|
|
1183
|
+
if (shards.generation !== this.profile.generation || shards.selectorRevision !== this.profile.selectorRevision) throw Error("recall shard binding changed");
|
|
1184
|
+
} else if ((readProfileJSON(join(this.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
|
|
1185
|
+
|| (active.selector?.kind ?? "shared-v1") !== this.profile.kind || this.profile.kind === "generation" && active.selector?.generation !== this.profile.generation) throw Error("recall profile changed or is not active");
|
|
1186
|
+
assertProjectActive(this.profile.home, project);
|
|
1187
|
+
};
|
|
1188
|
+
return this.withStoreFences(() => {
|
|
1189
|
+
frontier();
|
|
1190
|
+
const source = new Map<string, Entry>(), realm = new Map<string, { id: string; own: number; legacy: number }>(), pruned = new Set<string>();
|
|
1191
|
+
const reader = new SegmentReader(maximumBytes); let records = 0, bytes = 0, malformed = 0, incomplete = false;
|
|
1192
|
+
const add = (row: Entry, own: boolean, legacy: boolean) => {
|
|
1193
|
+
const { id, ...body } = row;
|
|
1194
|
+
if (row.schema !== MEMORY_SCHEMA || !/^[a-f0-9]{32}$/.test(id) || entryId(body as Omit<Entry, "id">) !== id) throw Error("retained recall row verification failed");
|
|
1195
|
+
const canonical = canonicalJSON(row); records++; bytes += Buffer.byteLength(canonical);
|
|
1196
|
+
if (records > maximumRows || bytes > maximumBytes) throw Error("retained recall budget exceeded");
|
|
1197
|
+
const previous = source.get(id); if (previous && canonicalJSON(previous) !== canonical) throw Error("retained recall identity conflict");
|
|
1198
|
+
source.set(id, row); realm.set(id, { id, own: Number(own), legacy: Number(legacy) });
|
|
1199
|
+
};
|
|
1200
|
+
// Same bound read handles as ordinary views. Never opens an unopened project's private shard.
|
|
1201
|
+
for (const store of this.readStores()) {
|
|
1202
|
+
if (store.generationIndex) {
|
|
1203
|
+
store.generationIndex.refresh(0, reader);
|
|
1204
|
+
store.db.transaction(() => {
|
|
1205
|
+
const size = store.db.query("SELECT COUNT(*) AS n, COALESCE(SUM(length(CAST(line AS BLOB))),0) AS bytes FROM generation_rows").get() as { n: number; bytes: number };
|
|
1206
|
+
if (size.n > maximumRows - records || size.bytes > maximumBytes - bytes) throw Error("retained recall budget exceeded");
|
|
1207
|
+
const diagnostics = store.generationIndex!.recallDiagnostics(); malformed += diagnostics.malformed; incomplete ||= diagnostics.incomplete;
|
|
1208
|
+
const state = store.generationIndex!.snapshot(false);
|
|
1209
|
+
for (const item of state.rows) add(item.row, item.own === true, item.legacy ?? item.own !== true);
|
|
1210
|
+
for (const id of state.pruned ?? []) pruned.add(id);
|
|
1211
|
+
}).deferred();
|
|
1212
|
+
} else {
|
|
1213
|
+
const segments = readLegacySnapshot(store.dir, { reader });
|
|
1214
|
+
incomplete ||= segments.some(segment => segment.tailBytes > 0);
|
|
1215
|
+
for (const row of legacyEntries(segments, { onMalformed: () => { malformed++; } })) add(row, false, true);
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
frontier();
|
|
1219
|
+
const all = [...source.values()].sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
1220
|
+
const notes = all.filter((row): row is NoteEntry => row.op === "note");
|
|
1221
|
+
const removed = new Set(pruned), forgottenAt = new Map<string, string>();
|
|
1222
|
+
for (const row of all) if (row.op === "forget") { removed.add(row.target); if (!forgottenAt.has(row.target)) forgottenAt.set(row.target, row.at); }
|
|
1223
|
+
// Forgetting either end removes known promotion ancestry, in linear bounded graph work.
|
|
1224
|
+
const ancestry = new Map<string, string[]>();
|
|
1225
|
+
for (const note of notes) if (note.promotedFrom) {
|
|
1226
|
+
ancestry.set(note.id, [...ancestry.get(note.id) ?? [], note.promotedFrom]);
|
|
1227
|
+
ancestry.set(note.promotedFrom, [...ancestry.get(note.promotedFrom) ?? [], note.id]);
|
|
1228
|
+
}
|
|
1229
|
+
const queue = [...removed];
|
|
1230
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1231
|
+
const id = queue[index]!, at = forgottenAt.get(id);
|
|
1232
|
+
for (const next of ancestry.get(id) ?? []) {
|
|
1233
|
+
const fresh = !removed.has(next);
|
|
1234
|
+
if (fresh) { removed.add(next); queue.push(next); }
|
|
1235
|
+
if (at && !forgottenAt.has(next)) { forgottenAt.set(next, at); if (!fresh) queue.push(next); }
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
const visible = (note: NoteEntry) => this.inScope(note.project, project) || note.scope === "shared";
|
|
1239
|
+
const dated = notes.filter(note => options.asOf === undefined || Date.parse(note.at) <= time), retained = new Map(dated.filter(visible).map(note => [note.id, note]));
|
|
1240
|
+
const rows = dated.map(note => ({ id: note.id, at: note.at, machine: note.machine, project: note.project, scope: note.scope, tier: note.tier, kind: note.kind, peer: note.peer, title: note.title ?? null, text: note.text,
|
|
1241
|
+
task: note.task ?? null, about: JSON.stringify(note.about), source: note.source ?? "hub", supersedes: note.supersedes ?? null, promoted_from: note.promotedFrom ?? null, expires: note.expires ?? null }));
|
|
1242
|
+
const ops = all.filter((row): row is OpEntry => row.op !== "note" && row.op !== "goal" && (options.asOf === undefined || Date.parse(row.at) <= time)).map(row => ({ id: row.id, op: row.op, at: row.at, target: row.target, by: row.by ?? null }));
|
|
1243
|
+
const views = this.viewRows(options.history === true, undefined, false, { now: time, rows, ops, ...(this.generationIndex ? { realms: realm } : {}), pruned, forgotten: removed, includePromoted: options.history === true })
|
|
1244
|
+
.filter(view => (this.inScope(view.project, project) || view.scope === "shared") && (options.history === true || !view.supersededBy));
|
|
1245
|
+
const forgotten = [...forgottenAt].flatMap(([id, at]) => retained.has(id) ? [{ id, at }] : []).sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
1246
|
+
const relations: RecallRelation[] = [];
|
|
1247
|
+
const link = (from: string, to: string, at: string, kind: RecallRelation["kind"]) => { if (retained.has(from) && retained.has(to) && !pruned.has(from) && !pruned.has(to)) relations.push({ from, to, at, kind }); };
|
|
1248
|
+
for (const note of dated) { if (note.supersedes) link(note.supersedes, note.id, note.at, "supersede"); if (note.promotedFrom) link(note.promotedFrom, note.id, note.at, "promote"); }
|
|
1249
|
+
for (const op of ops) if (op.op === "supersede" && op.by) link(op.target, op.by, op.at, "supersede");
|
|
1250
|
+
const notices = (options.asOf !== undefined || options.history === true) && views.some(view => view.promotedFrom)
|
|
1251
|
+
? ["promotion time unknown: tier before promotion cannot be recovered"] : [];
|
|
1252
|
+
frontier();
|
|
1253
|
+
return { at: new Date(time).toISOString(), views, forgotten, relations, malformed, notices, incomplete: incomplete || malformed > 0 || notices.length > 0, ...(incomplete || malformed > 0 ? { sourceIncomplete: true as const } : {}) };
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1031
1257
|
/** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
|
|
1032
1258
|
get(id: string, project?: string): View | undefined {
|
|
1033
1259
|
id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
|
|
@@ -1073,10 +1299,10 @@ export class MemoryStore {
|
|
|
1073
1299
|
}
|
|
1074
1300
|
|
|
1075
1301
|
/** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
|
|
1076
|
-
graph(project: string, start: Node, hops = 1, cap = 200): Graph {
|
|
1302
|
+
graph(project: string, start: Node, hops = 1, cap = 200, projection?: readonly View[]): Graph {
|
|
1077
1303
|
this.ensureFresh();
|
|
1078
1304
|
if (start.startsWith("mem:") && this.generationIndex?.pruned.has(start.slice(4))) return { nodes: [], edges: [] };
|
|
1079
|
-
const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
|
|
1305
|
+
const live = new Map((projection ?? this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).map((v) => [`mem:${v.id}`, v]));
|
|
1080
1306
|
const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
|
|
1081
1307
|
const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
|
|
1082
1308
|
const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
|
|
@@ -1123,6 +1349,8 @@ export class MemoryStore {
|
|
|
1123
1349
|
return ids;
|
|
1124
1350
|
}
|
|
1125
1351
|
const ids = (this.db.query("SELECT src FROM edges WHERE dst = ?").all(id) as { src: string }[]).map((r) => `mem:${r.src}`).filter((m) => live.has(m));
|
|
1352
|
+
// Retained expired rows may no longer have derived edges; only their verified authored about nodes supply them.
|
|
1353
|
+
if (projection) for (const view of projection) if (view.about.includes(id) && !ids.includes(`mem:${view.id}`)) ids.push(`mem:${view.id}`);
|
|
1126
1354
|
for (const m of ids) { const v2 = live.get(m)!; link(m, id, v2.kind === "fail" ? "rules_out" : "about"); }
|
|
1127
1355
|
return [...ids, ...parts];
|
|
1128
1356
|
};
|
|
@@ -1269,6 +1497,8 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1269
1497
|
if (opts.memoryDir !== undefined && (opts.home !== undefined || typeof opts.memoryDir !== "string" || !opts.memoryDir.trim())) throw new Error("choose a memory home or one explicit memory directory");
|
|
1270
1498
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1271
1499
|
assertCuratedShardPaths(home, opts);
|
|
1500
|
+
// #15 T5: the one gate every recall path shares, checked before a shard can be created. The user shard has no lifecycle.
|
|
1501
|
+
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
|
1272
1502
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
|
|
1273
1503
|
const missing = !existsSync(join(dir, "generation.json"));
|
|
1274
1504
|
if (missing) initializeShard(home, opts);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { generationDir, readSelector } from "./generation.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* F16, `amem sync --if-due`: the person's own shell (an EXIT trap) asks amem to sync when it is due; amem itself runs nothing between sessions and never
|
|
7
|
+
* registers a hook for it. Like every mutating command it needs a terminal, so an unattended scheduler (cron, launchd) is not a caller (a hook may not do network work: AGENTS.md, test/hooks-network.test.ts). The only state is one small stamp beside the logs.
|
|
8
|
+
* The stamp holds times and a failure count, never an error text (a git error can carry a remote URL).
|
|
9
|
+
*/
|
|
10
|
+
export interface SyncState { lastAttemptAt: string | null; lastOkAt: string | null; failures: number }
|
|
11
|
+
export const SYNC_STATE_FILE = "sync-state.json";
|
|
12
|
+
/** After a success, the next attempt is due this long after the last one. */
|
|
13
|
+
export const SYNC_INTERVAL_MS = 60 * 60_000;
|
|
14
|
+
/** After a failure the wait starts here and doubles with each further failure. */
|
|
15
|
+
export const SYNC_BACKOFF_BASE_MS = 15 * 60_000;
|
|
16
|
+
export const SYNC_BACKOFF_CAP_MS = 24 * 60 * 60_000;
|
|
17
|
+
const MAX_FAILURES = 30, MAX_BYTES = 4096;
|
|
18
|
+
const EMPTY: SyncState = { lastAttemptAt: null, lastOkAt: null, failures: 0 };
|
|
19
|
+
const iso = (value: unknown): string | null => typeof value === "string" && Number.isFinite(Date.parse(value)) && new Date(value).toISOString() === value ? value : null;
|
|
20
|
+
|
|
21
|
+
/** Never throws: a missing, oversized or malformed stamp is "no history", which makes a sync due. */
|
|
22
|
+
export function readSyncState(home: string): SyncState {
|
|
23
|
+
let fd: number | undefined;
|
|
24
|
+
try {
|
|
25
|
+
fd = openSync(join(home, SYNC_STATE_FILE), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
26
|
+
const stat = fstatSync(fd);
|
|
27
|
+
if (!stat.isFile() || stat.size > MAX_BYTES) return { ...EMPTY };
|
|
28
|
+
const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
29
|
+
const value = JSON.parse(buffer.subarray(0, size).toString("utf8")) as Record<string, unknown>;
|
|
30
|
+
const failures = Number.isSafeInteger(value.failures) && (value.failures as number) >= 0 ? Math.min(value.failures as number, MAX_FAILURES) : 0;
|
|
31
|
+
return { lastAttemptAt: iso(value.lastAttemptAt), lastOkAt: iso(value.lastOkAt), failures };
|
|
32
|
+
} catch { return { ...EMPTY }; }
|
|
33
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The wait after the last attempt: the plain interval after a success, 15 minutes doubling to a day after failures. */
|
|
37
|
+
export function syncWaitMs(failures: number): number {
|
|
38
|
+
return failures <= 0 ? SYNC_INTERVAL_MS : Math.min(SYNC_BACKOFF_BASE_MS * 2 ** (failures - 1), SYNC_BACKOFF_CAP_MS);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The earliest next attempt, or null when none was recorded (due now). */
|
|
42
|
+
export function nextSyncAt(state: SyncState): number | null {
|
|
43
|
+
return state.lastAttemptAt === null ? null : Date.parse(state.lastAttemptAt) + syncWaitMs(state.failures);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `force` (`--force`) ignores the schedule and the backoff. */
|
|
47
|
+
export function syncDue(state: SyncState, now: number, force = false): boolean {
|
|
48
|
+
const next = nextSyncAt(state);
|
|
49
|
+
return force || next === null || now >= next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `--if-due` stops a running git step after this long, so a shell that runs it on exit is held for a bounded time. */
|
|
53
|
+
export const SYNC_IF_DUE_DEADLINE_MS = 30_000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The stop signal of one `--if-due` run: the 30 second deadline, or any way the person ends it: Ctrl-C (SIGINT), SIGTERM, SIGHUP (the terminal closed, which is also how an
|
|
57
|
+
* EXIT trap's shell can go away) and SIGQUIT. git runs in its own process group while a stop signal is given (so the stop reaches its transport children), which also means a
|
|
58
|
+
* terminal's signals no longer reach git by themselves: amem turns each of them into the abort. `on`, not `once`: a second Ctrl-C must not fall back to the default action
|
|
59
|
+
* (which would end amem and strand git) while the first is still stopping git; the handlers stay until `dispose`. A process that dies some other way is covered by the exit
|
|
60
|
+
* handler in sync.ts, which signals every live git group. The emitter is a seam for tests; the CLI passes `process`.
|
|
61
|
+
*/
|
|
62
|
+
export const STOP_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"] as const;
|
|
63
|
+
export type StopCause = "person" | "deadline";
|
|
64
|
+
export function stopSignal(ms: number, emitter: { on(name: string, fn: () => void): unknown; off(name: string, fn: () => void): unknown }, names: readonly string[] = STOP_SIGNALS): { signal: AbortSignal; dispose: () => void; cause: () => StopCause | undefined } {
|
|
65
|
+
const controller = new AbortController(), deadline = AbortSignal.timeout(ms);
|
|
66
|
+
let cause: StopCause | undefined;
|
|
67
|
+
// The first thing that ends the run is its cause: a person who closes the terminal after the deadline already fired did not stop it.
|
|
68
|
+
const stop = () => { cause ??= "person"; controller.abort(); };
|
|
69
|
+
deadline.addEventListener("abort", () => { cause ??= "deadline"; }, { once: true });
|
|
70
|
+
for (const name of names) emitter.on(name, stop);
|
|
71
|
+
return { signal: AbortSignal.any([controller.signal, deadline]), dispose: () => { for (const name of names) emitter.off(name, stop); }, cause: () => cause };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The repository the active profile syncs through: a curated split keeps a private cache in `shard-sync/objects.git`, a generation home keeps its own `.git` inside the
|
|
76
|
+
* generation directory (the memory home's own `.git` is then only the legacy input), and shared-v1 keeps `.git` in the memory home. Undefined when the profile cannot be
|
|
77
|
+
* resolved (an unreadable selector): no remote is then reported rather than a guess.
|
|
78
|
+
*/
|
|
79
|
+
function syncRepository(home: string): string | undefined {
|
|
80
|
+
try {
|
|
81
|
+
if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
|
|
82
|
+
const { selector } = readSelector(home);
|
|
83
|
+
if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
|
|
84
|
+
return join(home, ".git", "config");
|
|
85
|
+
} catch { return undefined; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Whether the active profile has a sync remote configured, from its repository's own config file: no git process, and never the URL (it can carry a credential).
|
|
90
|
+
*/
|
|
91
|
+
export function remoteConfigured(home: string): boolean {
|
|
92
|
+
const file = syncRepository(home);
|
|
93
|
+
if (file === undefined) return false;
|
|
94
|
+
let fd: number | undefined;
|
|
95
|
+
try {
|
|
96
|
+
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
97
|
+
const stat = fstatSync(fd);
|
|
98
|
+
if (!stat.isFile() || stat.size > 65536) return false;
|
|
99
|
+
const buffer = Buffer.alloc(stat.size), size = readSync(fd, buffer, 0, buffer.length, 0);
|
|
100
|
+
return /^\s*\[remote "origin"\]/m.test(buffer.subarray(0, size).toString("utf8"));
|
|
101
|
+
} catch { return false; } // no such repository
|
|
102
|
+
finally { if (fd !== undefined) try { closeSync(fd); } catch { /* nothing to close */ } }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A stamp from the future (a clock set back, a hand edit) would block the sync until then plus the wait: it counts as no history, and so does a success it claims. */
|
|
106
|
+
export function usableState(state: SyncState, now: number): SyncState {
|
|
107
|
+
const future = (value: string | null) => value !== null && Date.parse(value) > now;
|
|
108
|
+
if (future(state.lastAttemptAt)) return { lastAttemptAt: null, lastOkAt: future(state.lastOkAt) ? null : state.lastOkAt, failures: 0 };
|
|
109
|
+
return future(state.lastOkAt) ? { ...state, lastOkAt: null } : state;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What a finished sync means for the schedule. `ok` clears the backoff. `local` is a refusal that the schedule cannot fix and that no remote ever saw, or a run its person ended (another sync or
|
|
114
|
+
* mutation holds the lock, no remote is configured, the configured policy declines a note, the remote is public, a usage error): it is not stamped, so it neither
|
|
115
|
+
* backs the schedule off nor makes a contended run look like a failed one. Everything else is a `failed` transport or git step, and an error this module does not
|
|
116
|
+
* recognise counts as failed (the safe side: it waits instead of retrying every minute).
|
|
117
|
+
*/
|
|
118
|
+
export type SyncOutcome = "ok" | "failed" | "local";
|
|
119
|
+
const LOCAL = /^(usage:|another (sync|memory mutation|generation|shard sync)|no (generation )?sync remote|the configured privacy policy|the sync remote is public|this (machine's|store's) long-term|a line of this machine's long-term|more than \d+ long-term)/;
|
|
120
|
+
export function syncOutcome(error: string | undefined, cause?: StopCause): SyncOutcome {
|
|
121
|
+
if (error === undefined) return "ok";
|
|
122
|
+
// A run its person ended (Ctrl-C, a closing terminal) is not a failure of the remote: the same Ctrl-C reads "fetch failed" or "stopped" depending on which step it hit, so the cause,
|
|
123
|
+
// not the text, decides. Only the deadline (a remote that did not answer in time) stays a failure to back off from.
|
|
124
|
+
if (cause === "person") return "local";
|
|
125
|
+
return LOCAL.test(error.replace(/^amem sync: /, "")) ? "local" : "failed";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Records one attempt. A stamp that cannot be written never fails the sync it describes. */
|
|
129
|
+
export function recordSyncAttempt(home: string, ok: boolean, at: number): SyncState {
|
|
130
|
+
const prior = readSyncState(home), when = new Date(at).toISOString();
|
|
131
|
+
const next: SyncState = { lastAttemptAt: when, lastOkAt: ok ? when : prior.lastOkAt, failures: ok ? 0 : Math.min(prior.failures + 1, MAX_FAILURES) };
|
|
132
|
+
const file = join(home, SYNC_STATE_FILE), temp = `${file}.${process.pid}.tmp`;
|
|
133
|
+
try {
|
|
134
|
+
mkdirSync(home, { recursive: true, mode: 0o700 });
|
|
135
|
+
writeFileSync(temp, JSON.stringify(next) + "\n", { mode: 0o600 });
|
|
136
|
+
renameSync(temp, file);
|
|
137
|
+
} catch { try { rmSync(temp, { force: true }); } catch { /* best effort */ } }
|
|
138
|
+
return next;
|
|
139
|
+
}
|