@staix/agent-mem 0.6.0 → 0.8.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 +12 -2
- package/README.md +143 -18
- package/package.json +1 -1
- package/src/cli.ts +537 -39
- package/src/commands-table.ts +60 -16
- package/src/hook-spool.ts +24 -2
- package/src/hooks.ts +9 -5
- package/src/index.ts +20 -0
- package/src/ingest.ts +3 -2
- package/src/mcp-memory.ts +168 -33
- package/src/mcp.ts +43 -7
- package/src/memory/aliases.ts +4 -0
- package/src/memory/bridge.ts +8 -2
- package/src/memory/candidate-pending.ts +119 -0
- package/src/memory/candidates.ts +228 -47
- package/src/memory/capacity-split.ts +76 -0
- package/src/memory/capacity.ts +139 -11
- package/src/memory/capture-input.ts +8 -3
- package/src/memory/capture.ts +30 -5
- package/src/memory/catalog.ts +43 -10
- package/src/memory/commands.ts +98 -44
- package/src/memory/config.ts +167 -0
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -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 +415 -0
- package/src/memory/editor.ts +58 -0
- package/src/memory/eval-fixture.ts +145 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval-set.json +443 -0
- package/src/memory/eval.ts +152 -0
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -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/metrics.ts +2 -2
- 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 +12 -1
- package/src/memory/policy.ts +44 -13
- package/src/memory/private-spans.ts +32 -0
- package/src/memory/project-export.ts +157 -0
- package/src/memory/project-import.ts +147 -0
- 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/store.ts +344 -35
- package/src/memory/sync-cadence.ts +149 -0
- package/src/memory/sync-screen.ts +86 -0
- package/src/memory/sync.ts +91 -11
- package/src/memory/usage.ts +35 -0
- package/src/memory/use-telemetry.ts +51 -0
- package/src/memory/work-lists.ts +10 -4
- package/src/session-brief-worker.ts +3 -2
- package/src/session-brief.ts +74 -15
- package/src/setup-owned.ts +120 -0
- package/src/setup-toml.ts +296 -0
- package/src/setup.ts +327 -0
package/src/memory/store.ts
CHANGED
|
@@ -2,21 +2,23 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
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";
|
|
9
10
|
import { machineId, type Machine } from "./machine.ts";
|
|
11
|
+
import { assertProjectActive } from "./lifecycle.ts";
|
|
10
12
|
export { machineId, type Machine } from "./machine.ts";
|
|
11
13
|
import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, captureSearch } from "./search-fts.ts";
|
|
12
|
-
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";
|
|
13
15
|
import { readCaptureConfig, type CaptureRow } from "./capture.ts";
|
|
14
16
|
import { assertSharedV1Op } from "./ops-contract.ts";
|
|
15
|
-
import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
|
|
16
|
-
import { GenerationIndex, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
17
|
-
import { legacyEntries, type LegacyCheckpoint } from "./bridge.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";
|
|
18
|
+
import { GenerationIndex, coalesceGenerationSource, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
|
|
19
|
+
import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
|
|
18
20
|
import { readMigrationSources } from "./migrate.ts";
|
|
19
|
-
import type { PruneStorePort } from "./prune.ts";
|
|
21
|
+
import type { PruneSourceRow, PruneStorePort } from "./prune.ts";
|
|
20
22
|
import { isDeepStrictEqual } from "node:util";
|
|
21
23
|
import { registerReaderLease } from "./reader-lease.ts";
|
|
22
24
|
|
|
@@ -142,6 +144,20 @@ export interface View {
|
|
|
142
144
|
local: boolean;
|
|
143
145
|
}
|
|
144
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
|
+
}
|
|
145
161
|
export interface GoalView {
|
|
146
162
|
id: string; at: string; machine: string; project: string; scope: Scope; title: string; milestones: string[]; dependsOn: string[];
|
|
147
163
|
state: GoalState; links: { task: string; at: string; machine: string }[];
|
|
@@ -169,8 +185,12 @@ export type PromotionScreen = (v: View) => "promote" | "keep" | "skip";
|
|
|
169
185
|
* went) wait for a process that has one; a person's explicit promote goes through.
|
|
170
186
|
*/
|
|
171
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";
|
|
172
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`. */
|
|
173
191
|
reconcile?: { canonical: string; aliases: readonly string[] };
|
|
192
|
+
/** Person-only project import may initialize configured alias shards; read-only reconciliation still never creates them. */
|
|
193
|
+
createAliases?: boolean;
|
|
174
194
|
}
|
|
175
195
|
|
|
176
196
|
const MIGRATION_BOOTSTRAP = Symbol("migration index bootstrap");
|
|
@@ -267,6 +287,11 @@ export class MemoryStore {
|
|
|
267
287
|
return this.generationIndex.port(this.machine.id);
|
|
268
288
|
}
|
|
269
289
|
|
|
290
|
+
/** Borrowed sync targets: the opened canonical shard plus its user overlay, never person-local aliases. Do not close these handles. */
|
|
291
|
+
openedSyncTargets(): readonly MemoryStore[] {
|
|
292
|
+
return [this, ...(this.userOverlay ? [this.userOverlay] : [])];
|
|
293
|
+
}
|
|
294
|
+
|
|
270
295
|
/** Read-only project views, bounded before bodies or hashes; never promotes or authors a prune. */
|
|
271
296
|
vaultEntries(project: string): VaultEntry[] {
|
|
272
297
|
if (!project.trim() || this.profile.sharded && !this.profile.shardUser && this.profile.shardKey !== project) throw new Error("vault project does not match the bound store");
|
|
@@ -336,15 +361,69 @@ export class MemoryStore {
|
|
|
336
361
|
|
|
337
362
|
/** A synchronous host-authorized mutation whose reads must not implicitly promote unrelated notes. */
|
|
338
363
|
withDeferredPromotions<T>(work: () => T): T {
|
|
339
|
-
const previous = this.promoting;
|
|
340
|
-
|
|
341
|
-
try { return work(); } finally {
|
|
364
|
+
const previous = this.readStores().map(store => [store, store.promoting] as const);
|
|
365
|
+
for (const [store] of previous) store.promoting = true;
|
|
366
|
+
try { return work(); } finally { for (const [store, promoting] of previous) store.promoting = promoting; }
|
|
342
367
|
}
|
|
343
368
|
|
|
344
369
|
private withMutation<T>(work: () => T): T {
|
|
345
370
|
return this.mutationDb.transaction(work).immediate();
|
|
346
371
|
}
|
|
347
372
|
|
|
373
|
+
/** Synchronous exact-target work under every opened shard's existing writer fence. No read-time promotions. */
|
|
374
|
+
withCurrentEntries<T>(ids: readonly string[], project: string, work: (entries: readonly View[]) => T): T {
|
|
375
|
+
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");
|
|
376
|
+
return this.withStoreFences(stores => {
|
|
377
|
+
assertProjectActive(this.profile.home, project);
|
|
378
|
+
for (const store of stores) store.ensureFresh();
|
|
379
|
+
const entries = ids.map(id => {
|
|
380
|
+
const view = this.get(id, project);
|
|
381
|
+
if (!view || view.id !== id || view.supersededBy) throw new Error("exact entry changed or unavailable");
|
|
382
|
+
return view;
|
|
383
|
+
});
|
|
384
|
+
return work(entries);
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Exact original transport placement; View.local describes tier, not a verified publication choice.
|
|
389
|
+
* ponytail: a legacy (shared-v1) home reads its whole snapshot under a 32 MiB byte budget on every call and refuses past it, so consolidation and its acceptance are unavailable for a legacy home that large. Upgrade: migrate the home to a generation (its placements come from the indexed `generation_rows`), or keep a placement index for legacy homes.
|
|
390
|
+
*/
|
|
391
|
+
entryPlacements(ids: readonly string[], project: string): ReadonlyMap<string, "local" | "synced"> {
|
|
392
|
+
if (!ids.length || ids.length > 256 || new Set(ids).size !== ids.length || ids.some(id => !/^[a-f0-9]{32}$/.test(id)) || !project.trim()) throw Error("invalid placement batch");
|
|
393
|
+
return this.withStoreFences(stores => {
|
|
394
|
+
const wanted = new Set(ids), found = new Map<string, "local" | "synced">(), reader = new SegmentReader(32 * 1024 * 1024);
|
|
395
|
+
const add = (id: string, placement: "local" | "synced") => { if (wanted.has(id) && (placement === "synced" || !found.has(id))) found.set(id, placement); };
|
|
396
|
+
assertProjectActive(this.profile.home, project);
|
|
397
|
+
for (const store of stores) {
|
|
398
|
+
if (store.generationIndex) {
|
|
399
|
+
store.generationIndex.refresh(0, reader);
|
|
400
|
+
const rows = store.db.query("SELECT id,local FROM generation_rows WHERE id IN (SELECT value FROM json_each(?))").all(JSON.stringify(ids)) as { id: string; local: number }[];
|
|
401
|
+
for (const row of rows) add(row.id, row.local ? "local" : "synced");
|
|
402
|
+
} else {
|
|
403
|
+
// Strict bounded verification includes locations, never guesses transport from tier or View.local.
|
|
404
|
+
const presence = new Map<string, PruneSourceRow>();
|
|
405
|
+
for (const segment of readLegacySnapshot(store.dir, { reader })) for (const row of legacyEntries([segment], { strict: true })) {
|
|
406
|
+
if (row.op === "note" && wanted.has(row.id)) {
|
|
407
|
+
const located = coalesceGenerationSource(presence.get(row.id), row, segment);
|
|
408
|
+
presence.set(row.id, located); add(row.id, located.placement);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
store.ensureFresh(); // Refresh only after the strict bounded source read succeeds.
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
for (const id of ids) { const view = this.get(id, project); if (!view || view.id !== id || view.project !== project || !found.has(id)) throw Error("entry placement unavailable"); }
|
|
415
|
+
return found;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
private withStoreFences<T>(work: (stores: readonly MemoryStore[]) => T): T {
|
|
419
|
+
const unique = new Map<string, MemoryStore>();
|
|
420
|
+
for (const store of this.readStores()) { const dir = realPath(store.dir); if (!unique.has(dir)) unique.set(dir, store); }
|
|
421
|
+
const stores = [...unique.keys()].sort().map(dir => unique.get(dir)!);
|
|
422
|
+
const lock = (index: number): T => index === stores.length ? work(stores) : stores[index]!.withMutation(() => lock(index + 1));
|
|
423
|
+
// Older/raw writers that bypass mutation-lock.db retain the existing mutation.ts ceiling.
|
|
424
|
+
return this.withDeferredPromotions(() => lock(0));
|
|
425
|
+
}
|
|
426
|
+
|
|
348
427
|
/** Serialize current-store writers, screen one normalized body, and expose a one-shot append of that body. */
|
|
349
428
|
withAcceptedNote<T>(note: NoteInput, work: (append: () => string) => T): T {
|
|
350
429
|
if (this.userOverlay && note.scope === "shared") return this.userOverlay.withAcceptedNote(note, work);
|
|
@@ -352,8 +431,8 @@ export class MemoryStore {
|
|
|
352
431
|
// Ordinary writes may defer indexing, but accepting an exact target revision must have a current index.
|
|
353
432
|
this.db.transaction(() => this.refresh()).immediate();
|
|
354
433
|
const at = new Date(this.now()).toISOString();
|
|
355
|
-
const normalized = structuredClone({ ...note, text: note.text.slice(0, MAX_TEXT),
|
|
356
|
-
...(note.title ? { title: note.title.slice(0, MAX_TITLE) } : {}),
|
|
434
|
+
const normalized = structuredClone({ ...note, text: stripPrivateSpans(note.text).slice(0, MAX_TEXT),
|
|
435
|
+
...(note.title ? { title: stripPrivateSpans(note.title).slice(0, MAX_TITLE) } : {}),
|
|
357
436
|
...(note.tier === "short" && !note.expires ? { expires: new Date(Date.parse(at) + this.ttl).toISOString() } : {}) });
|
|
358
437
|
const body = { schema: MEMORY_SCHEMA, at, machine: this.machine.id, ...normalized } as const;
|
|
359
438
|
const view: View = { ...normalized, id: entryId(body), at, machine: body.machine, title: normalized.title ?? null,
|
|
@@ -379,7 +458,9 @@ export class MemoryStore {
|
|
|
379
458
|
|
|
380
459
|
/** Append one entry to this machine's long-term (synced) or local segment and index it. Returns its id. */
|
|
381
460
|
private append(entry: NoteInput | OpInput | GoalInput, local: boolean, at = new Date(this.now()).toISOString(), machine = this.machine.id): string {
|
|
382
|
-
|
|
461
|
+
// `<private>` spans are never stored, whoever writes (note, goal, promotion): dropped before the cap, the hash and the log line.
|
|
462
|
+
const capped = entry.op === "note" ? { ...entry, text: stripPrivateSpans(entry.text).slice(0, MAX_TEXT), ...(entry.title ? { title: stripPrivateSpans(entry.title).slice(0, MAX_TITLE) } : {}) }
|
|
463
|
+
: entry.op === "goal" ? stripPrivateDeep(entry) : entry;
|
|
383
464
|
const body = { schema: MEMORY_SCHEMA, at, machine, ...capped } as Omit<Entry, "id">;
|
|
384
465
|
return this.appendBody(body, local);
|
|
385
466
|
}
|
|
@@ -413,18 +494,23 @@ export class MemoryStore {
|
|
|
413
494
|
* An entry from another store with its own time (`ahub memory import claude-mem`): the same source row always gives
|
|
414
495
|
* the same id, so a second import writes nothing. Returns the id and whether it was new.
|
|
415
496
|
*/
|
|
416
|
-
importNote(n: NoteInput, at: string): { id: string; added: boolean } {
|
|
497
|
+
importNote(n: NoteInput, at: string, identity: { machine?: string; expectedId?: string } = {}): { id: string; added: boolean } {
|
|
417
498
|
if (this.bootstrapOnly) throw new Error("migration bootstrap cannot import memory");
|
|
418
|
-
if (this.userOverlay && n.scope === "shared") return this.userOverlay.importNote(n, at);
|
|
499
|
+
if (this.userOverlay && n.scope === "shared") return this.userOverlay.importNote(n, at, identity);
|
|
500
|
+
if (this.profile.sharded && !this.profile.shardUser && n.scope === "project" && n.project !== this.profile.shardKey) {
|
|
501
|
+
const owner = this.aliasStores.find(store => store.profile.shardKey === n.project);
|
|
502
|
+
if (owner) return owner.importNote(n, at, identity);
|
|
503
|
+
}
|
|
419
504
|
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");
|
|
420
505
|
assertSharedV1Op(n.op);
|
|
421
|
-
// A fixed machine: the same observation imported on two machines is one entry once they sync.
|
|
422
|
-
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">;
|
|
506
|
+
// A fixed machine: the same observation imported on two machines is one entry once they sync. A project import preserves the exported identity instead.
|
|
507
|
+
const body = { schema: MEMORY_SCHEMA, at, machine: identity.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">;
|
|
423
508
|
const id = entryId(body);
|
|
509
|
+
if (identity.expectedId !== undefined && identity.expectedId !== id) throw new Error("project import entry identity changed");
|
|
424
510
|
return this.withMutation(() => {
|
|
425
511
|
this.generationIndex?.assertAppendable(n.tier === "short", this.machine.id);
|
|
426
512
|
// The source tuple also recognizes legacy ids whose nodes depended on the checkout or extractor version.
|
|
427
|
-
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;
|
|
513
|
+
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;
|
|
428
514
|
if (known) return { id: known.id, added: false };
|
|
429
515
|
appendFileSync(join(this.dir, n.tier === "short" ? "local" : "log", `${this.machine.id}.jsonl`), JSON.stringify({ ...body, id }) + "\n", { mode: 0o600 });
|
|
430
516
|
return { id, added: true };
|
|
@@ -435,11 +521,19 @@ export class MemoryStore {
|
|
|
435
521
|
private op(op: OpEntry["op"], target: View, actor: string, by?: string): string {
|
|
436
522
|
return this.append({ op, target: target.id, actor, ...(by ? { by } : {}) }, target.local);
|
|
437
523
|
}
|
|
438
|
-
forget
|
|
524
|
+
/** Complete existing forget expansion, including superseded legacy-import copies. Reads author no ops. */
|
|
525
|
+
forgetTargets(id: string, project?: string): View[] {
|
|
526
|
+
const v = this.get(id, project); return v ? this.forgetGroup(v) : [];
|
|
527
|
+
}
|
|
528
|
+
private forgetGroup(v: View): View[] {
|
|
529
|
+
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];
|
|
530
|
+
}
|
|
531
|
+
forget(id: string, actor: string, project?: string, exactOnly = false): View | undefined {
|
|
439
532
|
const v = this.get(id, project);
|
|
440
533
|
if (!v) return undefined;
|
|
441
|
-
|
|
442
|
-
|
|
534
|
+
const group = this.forgetGroup(v);
|
|
535
|
+
if (exactOnly && (group.length !== 1 || group[0]?.id !== id)) throw Error("forget expands beyond the reviewed exact entry");
|
|
536
|
+
// Ordinary person forget retains its legacy-import copy expansion; reviewed decay is exact-only.
|
|
443
537
|
for (const member of group) this.op("forget", member, actor);
|
|
444
538
|
return v;
|
|
445
539
|
}
|
|
@@ -486,7 +580,17 @@ export class MemoryStore {
|
|
|
486
580
|
}
|
|
487
581
|
/** A short-term note kept short-term: it expires like any other and is never promoted (its demote entry stays local). */
|
|
488
582
|
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
|
|
489
|
-
|
|
583
|
+
/** The short-tier deadline to display before a person confirms a demotion. */
|
|
584
|
+
shortExpiry(): string { return this.until(); }
|
|
585
|
+
/** A confirmed deadline may be older than a fresh one, but cannot extend the configured TTL beyond one hour. */
|
|
586
|
+
assertDemotionExpiry(expiresAt: string): void {
|
|
587
|
+
const at = Date.parse(expiresAt), now = this.now();
|
|
588
|
+
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");
|
|
589
|
+
}
|
|
590
|
+
demote(id: string, actor: string, project?: string, expiresAt?: string): View | undefined {
|
|
591
|
+
if (expiresAt !== undefined) this.assertDemotionExpiry(expiresAt);
|
|
592
|
+
const v = this.get(id, project); if (v?.tier === "long") this.op("demote", v, actor, expiresAt ?? this.until()); return v;
|
|
593
|
+
}
|
|
490
594
|
supersede(oldId: string, newId: string, actor: string, project?: string): boolean {
|
|
491
595
|
const a = this.get(oldId, project), b = this.get(newId, project);
|
|
492
596
|
if (!a || !b || a.id === b.id) return false;
|
|
@@ -734,18 +838,18 @@ export class MemoryStore {
|
|
|
734
838
|
return nest(this.readStores());
|
|
735
839
|
}
|
|
736
840
|
|
|
737
|
-
private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean): View[] {
|
|
738
|
-
const now = this.now(), selecting = selection !== undefined || expiredOnly;
|
|
739
|
-
const pruned = this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined;
|
|
740
|
-
if (this.generationIndex) this.generationIndex.pruned = new Set((this.db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id));
|
|
841
|
+
private viewRows(expired: boolean, selection: (Filter & { ids?: string[] }) | undefined, expiredOnly: boolean, projection?: RecallViewProjection): View[] {
|
|
842
|
+
const now = projection?.now ?? this.now(), selecting = selection !== undefined || expiredOnly;
|
|
843
|
+
const pruned = projection?.pruned ?? (this.generationIndex ? new Set((this.readRows("SELECT id FROM generation_targets") as { id: string }[]).map(row => row.id)) : undefined);
|
|
844
|
+
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));
|
|
741
845
|
// Relations may cross projects and promoted copies: fold their small metadata globally, never filtered bodies.
|
|
742
|
-
const rows = this.readRows(selecting
|
|
846
|
+
const rows = projection?.rows ?? this.readRows(selecting
|
|
743
847
|
? "SELECT id, at, tier, kind, task, expires, supersedes, promoted_from FROM entries"
|
|
744
848
|
: "SELECT * FROM entries") as Record<string, string | null>[];
|
|
745
|
-
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 }[];
|
|
746
|
-
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;
|
|
849
|
+
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 }[];
|
|
850
|
+
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;
|
|
747
851
|
const born = realms ? new Set([...realms.values()].filter(row => row.own).map(row => row.id)) : undefined;
|
|
748
|
-
const forgotten = new Set(ops.filter((o) => o.op === "forget").map((o) => o.target));
|
|
852
|
+
const forgotten = new Set([...ops.filter((o) => o.op === "forget").map((o) => o.target), ...(projection?.forgotten ?? [])]);
|
|
749
853
|
// Deadlines were fixed when the entries were written, so every process (whatever its own TTL) prunes the same rows.
|
|
750
854
|
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();
|
|
751
855
|
const demoted = new Map(ops.filter((o) => o.op === "demote").map((o) => [o.target, deadline(o)]));
|
|
@@ -851,7 +955,7 @@ export class MemoryStore {
|
|
|
851
955
|
for (const r of candidates) {
|
|
852
956
|
const id = r.id!;
|
|
853
957
|
// A forgotten entry is gone, and so is its promoted copy's source or copy: forgetting either forgets both.
|
|
854
|
-
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id)) continue;
|
|
958
|
+
if (pruned?.has(id) || forgotten.has(id) || (r.promoted_from && forgotten.has(r.promoted_from)) || promoted.has(id) && !projection?.includePromoted) continue;
|
|
855
959
|
const tier: Tier = demoted.has(id) ? "short" : r.tier as Tier;
|
|
856
960
|
const expiresAt = expiry(r);
|
|
857
961
|
if (!expired && expiresAt !== null && Date.parse(expiresAt) <= now) continue;
|
|
@@ -909,6 +1013,21 @@ export class MemoryStore {
|
|
|
909
1013
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, f.limit ?? 200);
|
|
910
1014
|
}
|
|
911
1015
|
|
|
1016
|
+
/** The verified authored log rows behind the current export selection, including legacy absent/null optional fields. */
|
|
1017
|
+
portableNotes(project: string): NoteEntry[] {
|
|
1018
|
+
const selected = this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true });
|
|
1019
|
+
const wanted = new Set(selected.map(view => view.id)), authored = new Map<string, NoteEntry>();
|
|
1020
|
+
for (const store of this.readStores()) {
|
|
1021
|
+
const rows = store.generationIndex ? store.generationIndex.snapshot(false).rows.map(item => item.row) : legacyEntries(readLegacySnapshot(store.dir));
|
|
1022
|
+
for (const row of rows) if (row.op === "note" && wanted.has(row.id) && !authored.has(row.id)) authored.set(row.id, row);
|
|
1023
|
+
}
|
|
1024
|
+
return selected.map(view => {
|
|
1025
|
+
const row = authored.get(view.id);
|
|
1026
|
+
if (!row) throw new Error("exported note's authored row is unavailable");
|
|
1027
|
+
return row;
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
|
|
912
1031
|
/** Exact-project body pages for policy-aware callers. Relations still fold over complete metadata. */
|
|
913
1032
|
readPage(window: ReadWindow & { query?: string; includeSuperseded?: boolean }): { rows: View[]; next?: ReadRef } {
|
|
914
1033
|
this.ensureFresh(); this.prune();
|
|
@@ -1003,6 +1122,191 @@ export class MemoryStore {
|
|
|
1003
1122
|
});
|
|
1004
1123
|
}
|
|
1005
1124
|
|
|
1125
|
+
/** Metadata-only revision of the opened read set, never a note body or an unopened project shard. */
|
|
1126
|
+
recallRevision(): string { return this.recallFingerprint(true); }
|
|
1127
|
+
private recallFingerprint(databases: boolean): string {
|
|
1128
|
+
const entries: unknown[] = [];
|
|
1129
|
+
const stamp = (file: string, directory = false) => {
|
|
1130
|
+
try {
|
|
1131
|
+
const stat = lstatSync(file, { bigint: true });
|
|
1132
|
+
if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw Error("recall input path unavailable");
|
|
1133
|
+
entries.push([file, String(stat.dev), String(stat.ino), ...(directory ? [] : [String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)])]);
|
|
1134
|
+
return true;
|
|
1135
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { entries.push([file, "absent"]); return false; } throw error; }
|
|
1136
|
+
};
|
|
1137
|
+
const roots = new Set<string>(), fixed = new Set<string>();
|
|
1138
|
+
for (const store of this.readStores()) {
|
|
1139
|
+
roots.add(store.dir);
|
|
1140
|
+
for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
|
|
1141
|
+
for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
|
|
1142
|
+
if (store.generationIndex) {
|
|
1143
|
+
const legacy = store.profile.legacyHome ?? store.profile.home; roots.add(legacy);
|
|
1144
|
+
if (store.profile.legacyMainHome) roots.add(store.profile.legacyMainHome);
|
|
1145
|
+
if (!store.profile.sharded && store.profile.generation) {
|
|
1146
|
+
const dir = generationDir(legacy, store.profile.generation), pointer = join(dir, "bridge-snapshot.json"); fixed.add(pointer);
|
|
1147
|
+
// Only the small sealed pointer is parsed to locate its active archive; JSONL bodies are never read here.
|
|
1148
|
+
const head = readProfileJSON(pointer, 4096) as { commit?: unknown } | undefined;
|
|
1149
|
+
if (head !== undefined) {
|
|
1150
|
+
if (typeof head.commit !== "string" || !/^[a-f0-9]{40,64}$/.test(head.commit)) throw Error("recall bridge pointer unavailable");
|
|
1151
|
+
const archive = join(dir, "bridge-snapshots", head.commit); roots.add(archive); fixed.add(join(archive, "snapshot.json"));
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (databases) entries.push([store.dir, store.db.query("PRAGMA data_version").get(), store.db.query("SELECT total_changes() AS n").get()]);
|
|
1156
|
+
}
|
|
1157
|
+
for (const root of [...roots].sort()) {
|
|
1158
|
+
if (!stamp(root, true)) continue;
|
|
1159
|
+
fixed.add(join(root, "machine.json"));
|
|
1160
|
+
for (const folder of ["log", "local"]) {
|
|
1161
|
+
const dir = join(root, folder); if (!stamp(dir, true)) continue;
|
|
1162
|
+
for (const name of readdirSync(dir).filter(name => name.endsWith(".jsonl")).sort()) stamp(join(dir, name));
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
for (const file of [...fixed].sort()) stamp(file);
|
|
1166
|
+
return hashJSON(entries);
|
|
1167
|
+
}
|
|
1168
|
+
private prepareRecallRead(stores: readonly MemoryStore[]): void {
|
|
1169
|
+
for (const store of stores) {
|
|
1170
|
+
const active = readSelector(store.profile.home);
|
|
1171
|
+
if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
|
|
1172
|
+
if (store.profile.sharded) {
|
|
1173
|
+
const selected = readShardedSelection(store.profile.home);
|
|
1174
|
+
if (selected.generation !== store.profile.generation || selected.selectorRevision !== store.profile.selectorRevision) throw Error("recall shard binding changed");
|
|
1175
|
+
} else if ((readProfileJSON(join(store.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
|
|
1176
|
+
|| (active.selector?.kind ?? "shared-v1") !== store.profile.kind || store.profile.kind === "generation" && active.selector?.generation !== store.profile.generation) throw Error("recall profile changed");
|
|
1177
|
+
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");
|
|
1178
|
+
if (store.generationIndex) store.generationIndex.refresh();
|
|
1179
|
+
else if (store.stale || store.shape() !== store.seen) {
|
|
1180
|
+
store.stale = false; store.refresh();
|
|
1181
|
+
if (store.stale || store.shape() !== store.seen) throw Error("recall refresh unavailable");
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
/** Strict preparation once; callers release the fences before awaiting policy or optional overlays. */
|
|
1186
|
+
beginRecallRead(): () => void {
|
|
1187
|
+
return this.withStoreFences(stores => {
|
|
1188
|
+
const before = this.recallFingerprint(false);
|
|
1189
|
+
this.prepareRecallRead(stores);
|
|
1190
|
+
for (const store of stores) store.prune(); // readPage performs this too; settle its derived writes before the revision.
|
|
1191
|
+
const captured = this.recallRevision();
|
|
1192
|
+
if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
|
|
1193
|
+
return () => { if (this.recallRevision() !== captured) throw Error("recall inputs changed"); };
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
/** One lifecycle fold for a bounded exact-project set of useful intent-brief genres. */
|
|
1197
|
+
intentSnapshot(project: string, limit = 5000): { rows: View[]; truncated: boolean; assertCurrent(): void } {
|
|
1198
|
+
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");
|
|
1199
|
+
return this.withStoreFences(stores => {
|
|
1200
|
+
const before = this.recallFingerprint(false);
|
|
1201
|
+
assertProjectActive(this.profile.home, project); this.prepareRecallRead(stores);
|
|
1202
|
+
const revision = this.recallRevision(), ids = new Map<string, { id: string; at: string }>();
|
|
1203
|
+
if (this.recallFingerprint(false) !== before) throw Error("recall inputs changed during preparation");
|
|
1204
|
+
for (const store of stores) {
|
|
1205
|
+
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 }[];
|
|
1206
|
+
for (const row of rows) ids.set(row.id, row);
|
|
1207
|
+
}
|
|
1208
|
+
const refs = [...ids.values()].sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)), selected = refs.slice(0, limit);
|
|
1209
|
+
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))) : [];
|
|
1210
|
+
const order = new Map(selected.map((row, index) => [row.id, index])); rows.sort((a, b) => order.get(a.id)! - order.get(b.id)!);
|
|
1211
|
+
const assertCurrent = () => { if (this.recallRevision() !== revision) throw Error("recall inputs changed"); };
|
|
1212
|
+
assertCurrent(); return { rows, truncated: refs.length > limit, assertCurrent };
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/** The same injected clock used by validity, expiry and projection; callers must not substitute wall time. */
|
|
1217
|
+
readNow(): number { return this.now(); }
|
|
1218
|
+
|
|
1219
|
+
/** Person historical recall over verified retained rows. Current removals always override past state. */
|
|
1220
|
+
recallState(project: string, options: RecallStateOptions = {}): RetainedRecallState {
|
|
1221
|
+
if ((options.history || options.asOf !== undefined) && options.person !== true) throw Error("historical recall requires authenticated person intent from the host");
|
|
1222
|
+
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");
|
|
1223
|
+
const maximumRows = options.maxRows ?? 50_000, maximumBytes = options.maxBytes ?? 32 * 1024 * 1024, present = this.now();
|
|
1224
|
+
if (!Number.isSafeInteger(maximumRows) || maximumRows < 1 || maximumRows > 50_000 || !Number.isSafeInteger(maximumBytes) || maximumBytes < 1 || maximumBytes > 32 * 1024 * 1024) throw Error("invalid retained recall budget");
|
|
1225
|
+
let time = present;
|
|
1226
|
+
if (options.asOf !== undefined) {
|
|
1227
|
+
const value = /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(options.asOf) ? `${options.asOf}T00:00:00.000Z` : options.asOf;
|
|
1228
|
+
time = Date.parse(value);
|
|
1229
|
+
if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
|
|
1230
|
+
}
|
|
1231
|
+
const frontier = () => {
|
|
1232
|
+
const active = readSelector(this.profile.home);
|
|
1233
|
+
if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
|
|
1234
|
+
if (this.profile.sharded) {
|
|
1235
|
+
const shards = readShardedSelection(this.profile.home);
|
|
1236
|
+
if (shards.generation !== this.profile.generation || shards.selectorRevision !== this.profile.selectorRevision) throw Error("recall shard binding changed");
|
|
1237
|
+
} else if ((readProfileJSON(join(this.profile.home, "shards.json")) as { phase?: unknown } | undefined)?.phase === "active"
|
|
1238
|
+
|| (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");
|
|
1239
|
+
assertProjectActive(this.profile.home, project);
|
|
1240
|
+
};
|
|
1241
|
+
return this.withStoreFences(() => {
|
|
1242
|
+
frontier();
|
|
1243
|
+
const source = new Map<string, Entry>(), realm = new Map<string, { id: string; own: number; legacy: number }>(), pruned = new Set<string>();
|
|
1244
|
+
const reader = new SegmentReader(maximumBytes); let records = 0, bytes = 0, malformed = 0, incomplete = false;
|
|
1245
|
+
const add = (row: Entry, own: boolean, legacy: boolean) => {
|
|
1246
|
+
const { id, ...body } = row;
|
|
1247
|
+
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");
|
|
1248
|
+
const canonical = canonicalJSON(row); records++; bytes += Buffer.byteLength(canonical);
|
|
1249
|
+
if (records > maximumRows || bytes > maximumBytes) throw Error("retained recall budget exceeded");
|
|
1250
|
+
const previous = source.get(id); if (previous && canonicalJSON(previous) !== canonical) throw Error("retained recall identity conflict");
|
|
1251
|
+
source.set(id, row); realm.set(id, { id, own: Number(own), legacy: Number(legacy) });
|
|
1252
|
+
};
|
|
1253
|
+
// Same bound read handles as ordinary views. Never opens an unopened project's private shard.
|
|
1254
|
+
for (const store of this.readStores()) {
|
|
1255
|
+
if (store.generationIndex) {
|
|
1256
|
+
store.generationIndex.refresh(0, reader);
|
|
1257
|
+
store.db.transaction(() => {
|
|
1258
|
+
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 };
|
|
1259
|
+
if (size.n > maximumRows - records || size.bytes > maximumBytes - bytes) throw Error("retained recall budget exceeded");
|
|
1260
|
+
const diagnostics = store.generationIndex!.recallDiagnostics(); malformed += diagnostics.malformed; incomplete ||= diagnostics.incomplete;
|
|
1261
|
+
const state = store.generationIndex!.snapshot(false);
|
|
1262
|
+
for (const item of state.rows) add(item.row, item.own === true, item.legacy ?? item.own !== true);
|
|
1263
|
+
for (const id of state.pruned ?? []) pruned.add(id);
|
|
1264
|
+
}).deferred();
|
|
1265
|
+
} else {
|
|
1266
|
+
const segments = readLegacySnapshot(store.dir, { reader });
|
|
1267
|
+
incomplete ||= segments.some(segment => segment.tailBytes > 0);
|
|
1268
|
+
for (const row of legacyEntries(segments, { onMalformed: () => { malformed++; } })) add(row, false, true);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
frontier();
|
|
1272
|
+
const all = [...source.values()].sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
1273
|
+
const notes = all.filter((row): row is NoteEntry => row.op === "note");
|
|
1274
|
+
const removed = new Set(pruned), forgottenAt = new Map<string, string>();
|
|
1275
|
+
for (const row of all) if (row.op === "forget") { removed.add(row.target); if (!forgottenAt.has(row.target)) forgottenAt.set(row.target, row.at); }
|
|
1276
|
+
// Forgetting either end removes known promotion ancestry, in linear bounded graph work.
|
|
1277
|
+
const ancestry = new Map<string, string[]>();
|
|
1278
|
+
for (const note of notes) if (note.promotedFrom) {
|
|
1279
|
+
ancestry.set(note.id, [...ancestry.get(note.id) ?? [], note.promotedFrom]);
|
|
1280
|
+
ancestry.set(note.promotedFrom, [...ancestry.get(note.promotedFrom) ?? [], note.id]);
|
|
1281
|
+
}
|
|
1282
|
+
const queue = [...removed];
|
|
1283
|
+
for (let index = 0; index < queue.length; index++) {
|
|
1284
|
+
const id = queue[index]!, at = forgottenAt.get(id);
|
|
1285
|
+
for (const next of ancestry.get(id) ?? []) {
|
|
1286
|
+
const fresh = !removed.has(next);
|
|
1287
|
+
if (fresh) { removed.add(next); queue.push(next); }
|
|
1288
|
+
if (at && !forgottenAt.has(next)) { forgottenAt.set(next, at); if (!fresh) queue.push(next); }
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const visible = (note: NoteEntry) => this.inScope(note.project, project) || note.scope === "shared";
|
|
1292
|
+
const dated = notes.filter(note => options.asOf === undefined || Date.parse(note.at) <= time), retained = new Map(dated.filter(visible).map(note => [note.id, note]));
|
|
1293
|
+
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,
|
|
1294
|
+
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 }));
|
|
1295
|
+
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 }));
|
|
1296
|
+
const views = this.viewRows(options.history === true, undefined, false, { now: time, rows, ops, ...(this.generationIndex ? { realms: realm } : {}), pruned, forgotten: removed, includePromoted: options.history === true })
|
|
1297
|
+
.filter(view => (this.inScope(view.project, project) || view.scope === "shared") && (options.history === true || !view.supersededBy));
|
|
1298
|
+
const forgotten = [...forgottenAt].flatMap(([id, at]) => retained.has(id) ? [{ id, at }] : []).sort((a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id));
|
|
1299
|
+
const relations: RecallRelation[] = [];
|
|
1300
|
+
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 }); };
|
|
1301
|
+
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"); }
|
|
1302
|
+
for (const op of ops) if (op.op === "supersede" && op.by) link(op.target, op.by, op.at, "supersede");
|
|
1303
|
+
const notices = (options.asOf !== undefined || options.history === true) && views.some(view => view.promotedFrom)
|
|
1304
|
+
? ["promotion time unknown: tier before promotion cannot be recovered"] : [];
|
|
1305
|
+
frontier();
|
|
1306
|
+
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 } : {}) };
|
|
1307
|
+
});
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1006
1310
|
/** One entry by id or unique id prefix; with `project`, only one that project may read (its own or shared). */
|
|
1007
1311
|
get(id: string, project?: string): View | undefined {
|
|
1008
1312
|
id = id.replace(/^mem:/, ""); // briefs and recall print `mem:<id>`
|
|
@@ -1048,10 +1352,10 @@ export class MemoryStore {
|
|
|
1048
1352
|
}
|
|
1049
1353
|
|
|
1050
1354
|
/** The neighbourhood of one node for the dashboard: up to `hops` (1 or 2) steps, at most `cap` nodes. */
|
|
1051
|
-
graph(project: string, start: Node, hops = 1, cap = 200): Graph {
|
|
1355
|
+
graph(project: string, start: Node, hops = 1, cap = 200, projection?: readonly View[]): Graph {
|
|
1052
1356
|
this.ensureFresh();
|
|
1053
1357
|
if (start.startsWith("mem:") && this.generationIndex?.pruned.has(start.slice(4))) return { nodes: [], edges: [] };
|
|
1054
|
-
const live = new Map(this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }).map((v) => [`mem:${v.id}`, v]));
|
|
1358
|
+
const live = new Map((projection ?? this.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })).map((v) => [`mem:${v.id}`, v]));
|
|
1055
1359
|
const nodes = new Map<Node, Graph["nodes"][number]>(), edges = new Map<string, Graph["edges"][number]>();
|
|
1056
1360
|
const goals = new Map(this.goals(project).map((g) => [`goal:${g.id}`, g]));
|
|
1057
1361
|
const suggestions = this.suggestions().filter((x) => x.status !== "rejected");
|
|
@@ -1098,6 +1402,8 @@ export class MemoryStore {
|
|
|
1098
1402
|
return ids;
|
|
1099
1403
|
}
|
|
1100
1404
|
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));
|
|
1405
|
+
// Retained expired rows may no longer have derived edges; only their verified authored about nodes supply them.
|
|
1406
|
+
if (projection) for (const view of projection) if (view.about.includes(id) && !ids.includes(`mem:${view.id}`)) ids.push(`mem:${view.id}`);
|
|
1101
1407
|
for (const m of ids) { const v2 = live.get(m)!; link(m, id, v2.kind === "fail" ? "rules_out" : "about"); }
|
|
1102
1408
|
return [...ids, ...parts];
|
|
1103
1409
|
};
|
|
@@ -1244,6 +1550,8 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1244
1550
|
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");
|
|
1245
1551
|
const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
|
|
1246
1552
|
assertCuratedShardPaths(home, opts);
|
|
1553
|
+
// #15 T5: the one gate every recall path shares, checked before a shard can be created. The user shard has no lifecycle.
|
|
1554
|
+
if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
|
|
1247
1555
|
const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
|
|
1248
1556
|
const missing = !existsSync(join(dir, "generation.json"));
|
|
1249
1557
|
if (missing) initializeShard(home, opts);
|
|
@@ -1252,12 +1560,13 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
|
|
|
1252
1560
|
const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
|
|
1253
1561
|
const aliasStores: MemoryStore[] = [];
|
|
1254
1562
|
try {
|
|
1255
|
-
// #27:
|
|
1563
|
+
// #27: read-only reconciliation never creates an alias shard. A person-only project import explicitly can.
|
|
1256
1564
|
if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
|
|
1257
1565
|
for (const alias of reconcile.aliases) {
|
|
1258
|
-
if (alias === opts.project || !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
|
|
1566
|
+
if (alias === opts.project || !opts.createAliases && !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
|
|
1259
1567
|
assertCuratedShardPaths(home, { project: alias });
|
|
1260
|
-
aliasStores.push(
|
|
1568
|
+
aliasStores.push(opts.createAliases ? openShardStore({ ...plain, project: alias })
|
|
1569
|
+
: new MemoryStore(plain, { token: MIGRATION_BOOTSTRAP, profile: resolveShardProfile(home, { project: alias }), authoring: true }));
|
|
1261
1570
|
}
|
|
1262
1571
|
}
|
|
1263
1572
|
return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
|