@staix/agent-mem 0.12.0 → 0.14.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.
Files changed (43) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +70 -44
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +105 -0
  5. package/src/cli.ts +219 -56
  6. package/src/commands-table.ts +10 -9
  7. package/src/index.ts +1 -0
  8. package/src/ingest.ts +21 -4
  9. package/src/mcp.ts +8 -8
  10. package/src/memory/candidates.ts +15 -13
  11. package/src/memory/capacity-split.ts +4 -1
  12. package/src/memory/capacity.ts +48 -4
  13. package/src/memory/catalog.ts +4 -4
  14. package/src/memory/cli-attribution.ts +23 -0
  15. package/src/memory/commands.ts +63 -11
  16. package/src/memory/consolidate.ts +3 -2
  17. package/src/memory/decay-review.ts +4 -4
  18. package/src/memory/doctor.ts +36 -7
  19. package/src/memory/generation-index.ts +56 -11
  20. package/src/memory/generation.ts +73 -7
  21. package/src/memory/metrics.ts +20 -8
  22. package/src/memory/migrate.ts +31 -0
  23. package/src/memory/mutation.ts +24 -4
  24. package/src/memory/note-add.ts +4 -4
  25. package/src/memory/prune-publisher-lineage.ts +139 -0
  26. package/src/memory/prune.ts +28 -1
  27. package/src/memory/public-prune-activation.ts +260 -0
  28. package/src/memory/public-prune-advance.ts +149 -0
  29. package/src/memory/public-prune-input-chain.ts +73 -0
  30. package/src/memory/public-prune-inputs.ts +455 -0
  31. package/src/memory/public-prune-view.ts +139 -0
  32. package/src/memory/public-prune-witness.ts +168 -0
  33. package/src/memory/reader-lease.ts +46 -34
  34. package/src/memory/shard-migration.ts +42 -3
  35. package/src/memory/shard-sync-format.ts +40 -2
  36. package/src/memory/shard-sync-graph.ts +87 -0
  37. package/src/memory/shard-sync.ts +341 -53
  38. package/src/memory/shard.ts +50 -9
  39. package/src/memory/store.ts +51 -12
  40. package/src/memory/style.ts +3 -0
  41. package/src/memory/sync.ts +141 -5
  42. package/src/memory/vault.ts +31 -0
  43. package/src/setup.ts +59 -31
@@ -1,7 +1,8 @@
1
+ import { assertNoPublicPruneIntent, readPublicPruneView } from "./public-prune-view.ts";
1
2
  import { createHash, randomBytes } from "node:crypto";
2
3
  import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
- import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, type BoundProfile, type GenerationSeal } from "./generation.ts";
5
+ import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
5
6
  import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
6
7
 
7
8
  /**
@@ -80,16 +81,53 @@ export function captureShardDir(base: string, key: string, machine: string): str
80
81
  }
81
82
 
82
83
  export const SHARDED_SCHEMA = "agent-mem.shards/v1";
83
- export interface ShardedSelection { schema: typeof SHARDED_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
84
+ export const SHARDED_V2_SCHEMA = "agent-mem.shards/v2";
85
+ export const SHARDED_V2_FIELDS = ["schema", "generation", "phase", "selectorRevision", "sourceGeneration", "sources", "machine", "legacyCommit", "deadLeasesRemoved"] as const;
86
+ export interface ShardedSelection { schema: typeof SHARDED_SCHEMA | typeof SHARDED_V2_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
84
87
 
85
88
  export function userShardDir(base: string): string {
86
89
  return join(base, "user");
87
90
  }
88
91
 
92
+ type ShardedJournal = Record<string, unknown> & ShardedSelection;
93
+ function shardJournal(memoryDir: string): ShardedJournal {
94
+ const value = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
95
+ if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
96
+ if (value.schema === SHARDED_V2_SCHEMA && Object.keys(value).some(key => !(SHARDED_V2_FIELDS as readonly string[]).includes(key))) throw new Error("unsupported curated shard view");
97
+ return value as ShardedJournal;
98
+ }
99
+ function compatibleShardAdvance(before: ShardedJournal, after: ShardedJournal): boolean {
100
+ if (before.schema !== SHARDED_V2_SCHEMA || after.schema !== SHARDED_V2_SCHEMA) return false;
101
+ const { selectorRevision: _old, ...old } = before, { selectorRevision: _next, ...next } = after;
102
+ return canonicalJSON(old) === canonicalJSON(next);
103
+ }
104
+ function shardSnapshot(memoryDir: string, deadline: number) {
105
+ assertNoPublicPruneIntent(memoryDir, deadline);
106
+ let journal = shardJournal(memoryDir);
107
+ for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
108
+ let selector: ReturnType<typeof readSelector>;
109
+ try { selector = readSelector(memoryDir, deadline); }
110
+ catch (error) {
111
+ // The writer's spent wait budget or unavailable proof is never restarted here.
112
+ if (error instanceof Error && error.message === "public prune view unavailable") throw error;
113
+ const current = shardJournal(memoryDir);
114
+ if (current.selectorRevision === journal.selectorRevision || !compatibleShardAdvance(journal, current)) throw error;
115
+ journal = current; continue;
116
+ }
117
+ const current = shardJournal(memoryDir);
118
+ if (canonicalJSON(current) !== canonicalJSON(journal)) {
119
+ if (!compatibleShardAdvance(journal, current)) throw new Error("curated shard selector changed");
120
+ journal = current; continue;
121
+ }
122
+ if ((journal.schema === SHARDED_V2_SCHEMA) !== (selector.selector?.schema === "agent-mem.profile/v2")) throw new Error("curated shard selector changed");
123
+ if (journal.schema === SHARDED_V2_SCHEMA && (selector.revision !== journal.selectorRevision || selector.selector?.generation !== journal.generation)) continue;
124
+ return { journal, selector };
125
+ }
126
+ throw new Error("curated shard selector changed");
127
+ }
89
128
  export function readShardedSelection(memoryDir: string): ShardedSelection {
90
- const value = readProfileJSON(join(memoryDir, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
91
- if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
92
- return { schema: SHARDED_SCHEMA, generation: value.generation, phase: "active", selectorRevision: value.selectorRevision };
129
+ const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
130
+ return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
93
131
  }
94
132
 
95
133
  /** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
@@ -107,14 +145,14 @@ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: str
107
145
  check(dir, true);
108
146
  for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
109
147
  for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "legacy/machine.json", "legacy-main/machine.json", "prune-translations.jsonl"]) check(join(dir, name), false);
110
- for (const database of ["index.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
148
+ for (const database of ["index.db", "index-v2.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
111
149
  }
112
150
 
113
151
  /** Open one already-sealed shard without consulting the legacy home or the catalog. */
114
152
  export function resolveShardProfile(memoryDir: string, opts: { project?: string; user?: boolean }): BoundProfile {
115
153
  if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
116
154
  assertCuratedShardPaths(memoryDir, opts);
117
- const selection = readShardedSelection(memoryDir), generation = selection.generation;
155
+ const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS, initial = shardSnapshot(memoryDir, deadline), generation = initial.journal.generation;
118
156
  const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
119
157
  if (opts.project !== undefined && readShardDescriptor(memoryDir, shardKeyHash(opts.project))?.key !== opts.project) throw new Error("curated shard project identity mismatch");
120
158
  const expected = generationDescriptor(generation);
@@ -127,10 +165,13 @@ export function resolveShardProfile(memoryDir: string, opts: { project?: string;
127
165
  const rootMachine = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown } | undefined;
128
166
  const shardMachine = readProfileJSON(join(dir, "machine.json"), 4096) as { id?: unknown } | undefined;
129
167
  if (typeof rootMachine?.id !== "string" || !/^[a-f0-9]{16}$/.test(rootMachine.id) || rootMachine.id !== shardMachine?.id) throw new Error("curated shard machine identity mismatch");
130
- const selector = readSelector(memoryDir);
131
- if (selector.revision !== selection.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
168
+ const final = shardSnapshot(memoryDir, deadline), selector = final.selector;
169
+ if (canonicalJSON(final.journal) !== canonicalJSON(initial.journal) && !compatibleShardAdvance(initial.journal, final.journal)
170
+ || selector.revision !== final.journal.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
171
+ if (selector.selector?.schema === "agent-mem.profile/v2") readPublicPruneView(memoryDir, selector.selector.pruneInputSetHash, generation, deadline);
132
172
  const legacyMainHome = join(dir, "legacy-main");
133
173
  const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
134
174
  return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
175
+ ...(selector.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selector.selector.foreignPruneFormat, pruneInputSetHash: selector.selector.pruneInputSetHash } : {}),
135
176
  legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
136
177
  }
@@ -1,3 +1,6 @@
1
+ import { readPublicPruneView } from "./public-prune-view.ts";
2
+ import { readShardLegacySnapshot } from "./shard-bridge.ts";
3
+ import { cliAttribution } from "./cli-attribution.ts";
1
4
  import { Database } from "bun:sqlite";
2
5
  import { createHash } from "node:crypto";
3
6
  import { appendFileSync, chmodSync, closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
@@ -16,13 +19,13 @@ import { readWindowSql, readRef, type ReadWindow, type ReadRef, SearchIndex, cap
16
19
  import { assertCuratedShardPaths, ensureShardDescriptor, readShardDescriptor, readShardedSelection, aliasedKeys, resolveAlias, resolveShardProfile, shardDir, shardKeyHash, shardMachineDir, userShardDir } from "./shard.ts";
17
20
  import { readCaptureConfig, type CaptureRow } from "./capture.ts";
18
21
  import { assertSharedV1Op } from "./ops-contract.ts";
19
- import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow } from "./generation.ts";
22
+ import { generationDescriptor, generationDir, fsyncDirectory, canonicalJSON, hashJSON, readProfileJSON, resolveProfile, writeProfileJSON, readSelector, type BoundProfile, type ProfileInput, validateVaultLinkRow, vaultLinkId, type VaultLink, type VaultLinkRow, rebindPublicPruneProfile } from "./generation.ts";
20
23
  import { GenerationIndex, coalesceGenerationSource, explicitExpirySnapshot, GENERATION_TABLES } from "./generation-index.ts";
21
24
  import { legacyEntries, readLegacySnapshot, SegmentReader, type LegacyCheckpoint } from "./bridge.ts";
22
25
  import { readMigrationSources } from "./migrate.ts";
23
26
  import type { PruneSourceRow, PruneStorePort } from "./prune.ts";
24
27
  import { isDeepStrictEqual } from "node:util";
25
- import { registerReaderLease } from "./reader-lease.ts";
28
+ import { registerReaderLease, type ReaderLease } from "./reader-lease.ts";
26
29
 
27
30
  /**
28
31
  * #258: the hub's own memory. Append-only JSONL logs are the truth: `log/<machine>.jsonl` holds long-term entries and
@@ -193,7 +196,7 @@ export interface StoreOptions { home?: string; memoryDir?: string; ttlMs?: numbe
193
196
  lifecycle?: "enforce" | "ignore";
194
197
  /** #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`. */
195
198
  reconcile?: { canonical: string; aliases: readonly string[] };
196
- /** Person-only project import may initialize configured alias shards; read-only reconciliation still never creates them. */
199
+ /** Explicit project import may initialize configured alias shards; read-only reconciliation still never creates them. */
197
200
  createAliases?: boolean;
198
201
  }
199
202
 
@@ -214,7 +217,7 @@ export class MemoryStore {
214
217
  /** #13: the derived FTS5 copy of the curated rows, and the memory home base for capture shard resolution (#15 T1). */
215
218
  private readonly searchIndex: SearchIndex;
216
219
  private readonly memoryBaseDir: string;
217
- private releaseReaderLease?: () => void;
220
+ private releaseReaderLease?: ReaderLease;
218
221
  private readonly userOverlay?: MemoryStore;
219
222
  /** #27: the other keys' shards of a reconciled read (curated shards); shared-v1 reads them from its own index. */
220
223
  private readonly aliasStores: MemoryStore[];
@@ -247,10 +250,16 @@ export class MemoryStore {
247
250
  this.screen = opts.screen;
248
251
  this.deferPromotions = opts.deferPromotions ?? false;
249
252
  // Announce before opening derived handles or reading rows; failed admission leaks no database handles.
250
- if (!bootstrap) this.releaseReaderLease = registerReaderLease(this.profile.home);
253
+ if (!bootstrap || bootstrap.authoring === true) this.releaseReaderLease = registerReaderLease(this.profile.home, this.profile.sharded ? {scope:"shard"} : {});
251
254
  let openedDb: Database | undefined, openedMutation: Database | undefined, openedSearch: SearchIndex | undefined;
252
255
  try {
253
- const db = openedDb = new Database(join(this.dir, "index.db"), { create: true });
256
+ const indexFile = join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db");
257
+ if (this.profile.foreignPruneFormat) for (const suffix of ["", "-wal", "-shm", "-journal"]) {
258
+ const stat = lstatSync(indexFile + suffix, {throwIfNoEntry:false});
259
+ if (stat && (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1 || stat.uid !== process.getuid?.())) throw new Error("v2 index path unavailable");
260
+ }
261
+ const db = openedDb = new Database(indexFile, { create: true });
262
+ if (this.profile.foreignPruneFormat) chmodSync(indexFile, 0o600);
254
263
  db.run(`PRAGMA busy_timeout = ${MEMORY_DB_BUSY_TIMEOUT_MS}`);
255
264
  db.run("PRAGMA journal_mode = WAL");
256
265
  if ((db.query("PRAGMA user_version").get() as { user_version: number }).user_version !== INDEX_VERSION) {
@@ -271,7 +280,7 @@ export class MemoryStore {
271
280
  // One derived FTS copy per profile: a generation handle and a shared-v1 handle on one home never resync-fight.
272
281
  this.searchIndex = openedSearch = new SearchIndex(this.profile.sharded ? join(this.dir, "search.db") : join(home, this.profile.kind === "generation" ? `search-${this.profile.generation}.db` : "search.db"));
273
282
  this.memoryBaseDir = home;
274
- try { chmodSync(join(this.dir, "index.db"), 0o600); } catch { /* best effort */ }
283
+ try { chmodSync(join(this.dir, this.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), 0o600); } catch { /* best effort */ }
275
284
  if (this.profile.kind === "generation") this.generationIndex = new GenerationIndex({ profile: this.profile, db, now: this.now,
276
285
  apply: (line, source) => this.apply(line, source), withMutation: work => this.withMutation(work),
277
286
  ...(bootstrap ? { limits: bootstrap.limits } : {}), ...(bootstrap?.commit ? { commit: bootstrap.commit } : {}) });
@@ -589,7 +598,7 @@ export class MemoryStore {
589
598
  let applied = 0;
590
599
  if (apply) for (const group of groups) for (const head of group.heads) {
591
600
  if (head === group.target) continue;
592
- if (!this.supersede(head, group.target, "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
601
+ if (!this.supersede(head, group.target, cliAttribution()?.actor ?? "user", project)) throw new Error("memory dedupe replacement refused; rerun to inspect current relations");
593
602
  applied++;
594
603
  }
595
604
  return { groups, applied };
@@ -1002,6 +1011,8 @@ export class MemoryStore {
1002
1011
 
1003
1012
  /** Index deferred writes before any note, goal or suggestion read. */
1004
1013
  private ensureFresh(): void {
1014
+ this.releaseReaderLease?.verify();
1015
+ if (!this.bootstrapOnly) rebindPublicPruneProfile(this.profile);
1005
1016
  this.userOverlay?.ensureFresh();
1006
1017
  for (const store of this.aliasStores) store.ensureFresh();
1007
1018
  if (this.generationIndex) { this.generationIndex.refresh(); return; }
@@ -1163,6 +1174,7 @@ export class MemoryStore {
1163
1174
  };
1164
1175
  const roots = new Set<string>(), fixed = new Set<string>();
1165
1176
  for (const store of this.readStores()) {
1177
+ if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
1166
1178
  roots.add(store.dir);
1167
1179
  for (const name of ["profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "machine.json"]) fixed.add(join(store.profile.home, name));
1168
1180
  for (const name of ["generation.json", "generation-seal.json", "migration-sources.json", "machine.json"]) fixed.add(join(store.dir, name));
@@ -1194,6 +1206,7 @@ export class MemoryStore {
1194
1206
  }
1195
1207
  private prepareRecallRead(stores: readonly MemoryStore[]): void {
1196
1208
  for (const store of stores) {
1209
+ if (!store.bootstrapOnly) rebindPublicPruneProfile(store.profile);
1197
1210
  const active = readSelector(store.profile.home);
1198
1211
  if (active.revision !== store.profile.selectorRevision) throw Error("recall profile changed");
1199
1212
  if (store.profile.sharded) {
@@ -1256,6 +1269,7 @@ export class MemoryStore {
1256
1269
  if (!Number.isFinite(time) || new Date(time).toISOString() !== value || time > present) throw Error("as-of must be a past canonical UTC date or timestamp");
1257
1270
  }
1258
1271
  const frontier = () => {
1272
+ if (!this.bootstrapOnly) for (const store of this.readStores()) rebindPublicPruneProfile(store.profile);
1259
1273
  const active = readSelector(this.profile.home);
1260
1274
  if (active.revision !== this.profile.selectorRevision) throw Error("recall profile changed or is not active");
1261
1275
  if (this.profile.sharded) {
@@ -1552,6 +1566,18 @@ export function buildGenerationIndex(profile: BoundProfile, sources: readonly Le
1552
1566
  finally { store.close(); }
1553
1567
  }
1554
1568
 
1569
+ /** Trusted activation bootstrap; caller holds profile + realm mutation fences. */
1570
+ export function buildPublicPruneIndex(profile: BoundProfile): {targets:string[];sourceHash:string} {
1571
+ const sources = readShardLegacySnapshot(profile, {}).map(({bytes:_bytes,local:_local,...prefix}) => prefix);
1572
+ const store = new MemoryStore({deferPromotions:true}, {token:MIGRATION_BOOTSTRAP, profile, limits:sources});
1573
+ try {
1574
+ const before = store.derivedTables(); store.rebuild(); const tables = store.derivedTables();
1575
+ if (!isDeepStrictEqual(before, tables)) throw new Error("public prune view rebuild differs from incremental index");
1576
+ return { targets: (tables.generation_targets as {id:string}[]).map(row=>row.id).sort(),
1577
+ sourceHash: hashJSON([tables.generation_rows, tables.generation_prunes, tables.generation_segments, tables.generation_state]) };
1578
+ } finally {store.close();}
1579
+ }
1580
+
1555
1581
  function initializeShard(home: string, opts: { project?: string; user?: boolean }): void {
1556
1582
  const selection = readShardedSelection(home), generation = selection.generation;
1557
1583
  const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!), machine = machineId(home);
@@ -1564,7 +1590,9 @@ function initializeShard(home: string, opts: { project?: string; user?: boolean
1564
1590
  }
1565
1591
  const legacyHome = join(dir, "legacy"), sources = readMigrationSources(legacyHome);
1566
1592
  writeProfileJSON(join(dir, "migration-sources.json"), { schema: "agent-mem.migration-sources/v1", sources });
1593
+ const selected = readSelector(home).selector;
1567
1594
  const profile: BoundProfile = { kind: "generation", home, dir, generation, selectorRevision: selection.selectorRevision,
1595
+ ...(selected?.schema === "agent-mem.profile/v2" ? {foreignPruneFormat: selected.foreignPruneFormat, pruneInputSetHash: selected.pruneInputSetHash} : {}),
1568
1596
  legacyHome, legacyMainHome: join(dir, "legacy-main"), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
1569
1597
  buildGenerationIndex(profile, sources);
1570
1598
  writeProfileJSON(join(dir, "generation-seal.json"), { schema: "agent-mem.generation-seal/v1", generation,
@@ -1577,17 +1605,27 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1577
1605
  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");
1578
1606
  const home = opts.memoryDir ?? (opts.home === undefined ? memoryHome() : join(opts.home, "memory"));
1579
1607
  assertCuratedShardPaths(home, opts);
1580
- // #15 T5: the one gate every recall path shares, checked before a shard can be created. The user shard has no lifecycle.
1608
+ // Lifecycle refusal must remain before admission creates any lease/fence file.
1581
1609
  if (opts.user !== true && opts.lifecycle !== "ignore") assertProjectActive(home, opts.project!);
1610
+ readShardedSelection(home); // validate the floor before any lazy shard initialization
1611
+ const admission = registerReaderLease(home, {scope:"shard"});
1612
+ try {
1582
1613
  const dir = opts.user === true ? userShardDir(home) : shardDir(home, opts.project!);
1583
1614
  const missing = !existsSync(join(dir, "generation.json"));
1584
- if (missing) initializeShard(home, opts);
1615
+ if (missing) {
1616
+ const selected = readSelector(home).selector;
1617
+ if (selected?.schema === "agent-mem.profile/v2") {
1618
+ const view = readPublicPruneView(home, selected.pruneInputSetHash, selected.generation), key = opts.user ? "user" : `project:${opts.project}`;
1619
+ if (view.realms[key] || ["generation-seal.json", "migration-sources.json", "machine.json"].some(name => existsSync(join(dir, name)))) throw new Error("v2 generation input unavailable; reader cannot repair committed inputs");
1620
+ }
1621
+ initializeShard(home, opts);
1622
+ }
1585
1623
  const profile = resolveShardProfile(home, opts);
1586
1624
  const { reconcile, ...plain } = opts;
1587
1625
  const userOverlay = opts.user === true ? undefined : openShardStore({ ...plain, project: undefined, user: true });
1588
1626
  const aliasStores: MemoryStore[] = [];
1589
1627
  try {
1590
- // #27: read-only reconciliation never creates an alias shard. A person-only project import explicitly can.
1628
+ // #27: read-only reconciliation never creates an alias shard. An explicit project import can.
1591
1629
  if (opts.user !== true && reconcile && reconcile.canonical === opts.project) {
1592
1630
  for (const alias of reconcile.aliases) {
1593
1631
  if (alias === opts.project || !opts.createAliases && !existsSync(join(shardDir(home, alias), "generation.json"))) continue;
@@ -1598,9 +1636,10 @@ export function openShardStore(opts: StoreOptions & { project?: string; user?: b
1598
1636
  }
1599
1637
  return new MemoryStore(opts, { token: MIGRATION_BOOTSTRAP, profile, authoring: true, userOverlay, aliasStores });
1600
1638
  } catch (error) { for (const store of aliasStores) store.close(); userOverlay?.close(); throw error; }
1639
+ } finally { admission(); }
1601
1640
  }
1602
1641
 
1603
- /** Person-only migration receipt; this inspector reads raw rows without opening a store or writing any index. */
1642
+ /** Administrative migration receipt; this inspector reads raw rows without opening a store or writing any index. */
1604
1643
  export function summarizeRawExpiry(rawLines: string[], now: number, tailBytes: number): import("./migrate.ts").MigrationCounts {
1605
1644
  const bytes = Buffer.from(rawLines.filter(Boolean).join("\n") + (rawLines.length ? "\n" : ""));
1606
1645
  let malformed = 0;
@@ -23,6 +23,9 @@ export function style(color: boolean): Style {
23
23
  }
24
24
  export const PLAIN: Style = style(false);
25
25
 
26
+ /** Data stays on one human-output row and cannot inject terminal or bidirectional controls. */
27
+ export const singleLine = (text: string): string => text.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, " ");
28
+
26
29
  /** Status label, padded so the columns line up; the word is always present. */
27
30
  export function label(s: Style, state: "ok" | "attention" | "skipped" | "unavailable" | "error"): string {
28
31
  const word = { ok: "OK", attention: "ATTENTION", skipped: "SKIPPED", unavailable: "UNAVAILABLE", error: "ERROR" }[state].padEnd(11);
@@ -1,5 +1,7 @@
1
- import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs";
2
- import { join } from "node:path";
1
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync, writeSync, type BigIntStats } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { rebindPublicPruneProfile } from "./generation.ts";
3
5
  import { memoryGitArgv, memoryGitEnv, processLiveness, processSignature } from "./platform.ts";
4
6
  import { acquireMutation } from "./mutation.ts";
5
7
  import type { MemoryStore } from "./store.ts";
@@ -12,7 +14,9 @@ import { outboundScreen, screenOutboundRows, screenOwnSegment, type RowScreen }
12
14
  * machine's history never conflicts. Pull, then push; fail-open: a failure is a result with one reason, never a throw.
13
15
  */
14
16
  export type Visibility = "public" | "private" | "unknown";
15
- export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string }
17
+ export interface SyncResult { ok: boolean; pushed: boolean; entries: number; warning?: string; error?: string;
18
+ pruneApplication?: {transport:"durable"|"unconfirmed";proofComplete:number;projectionActive:number|null;nativeApplied:number|null;held:number;phase:"preflight"|"prepared"|"active"|"complete"};
19
+ }
16
20
 
17
21
  const GITIGNORE = "/*\n!/.gitignore\n!/log/\n/log/*\n!/log/*.jsonl\n";
18
22
  const STEP_MS = 60_000;
@@ -62,13 +66,19 @@ async function git(dir: string, args: string[], signal?: AbortSignal, ms = STEP_
62
66
 
63
67
  export { git as syncGit };
64
68
 
69
+ /** Shared hardening for text history queries and bounded binary object reads. */
70
+ function spawnGenerationGit(dir: string, args: string[], group: boolean) {
71
+ // The shell receives only positional arguments; its umask is child-local.
72
+ return Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) });
73
+ }
74
+
65
75
  /** Generation-only process isolation; shared-v1 above retains its pre-P9 spawn and environment contract. */
66
76
  export async function generationSyncGit(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS): Promise<{ code: number; out: string; err: string }> {
67
77
  if (signal?.aborted) return { code: -1, out: "", err: "stopped" };
68
78
  // A fixed child-only umask keeps Git-created metadata private without changing the daemon's process umask.
69
79
  // Args are positional shell parameters, never interpolated or evaluated as shell code.
70
80
  const group = signal !== undefined; // see `git` above: own process group when the caller has a deadline
71
- const p = Bun.spawn(["/bin/sh", "-c", 'umask 077; exec "$@"', "amem-git", ...memoryGitArgv(["-C", dir, ...args], { credentials: true })], { stdout: "pipe", stderr: "pipe", stdin: "ignore", env: memoryGitEnv(process.env, { repositoryIsolation: true }), ...(group ? { detached: true } : {}) });
81
+ const p = spawnGenerationGit(dir, args, group);
72
82
  const send = (name: "SIGTERM" | "SIGKILL") => { if (group) { try { process.kill(-p.pid, name); return; } catch { /* no group left: fall through to the pid */ } } p.kill(name); };
73
83
  const unregister = group ? register(p.pid) : () => {};
74
84
  // SIGTERM first, so git removes its lock files; SIGKILL only if it is still there after a grace period.
@@ -89,6 +99,53 @@ export async function generationSyncGit(dir: string, args: string[], signal?: Ab
89
99
  }
90
100
 
91
101
 
102
+ /** Exact Git object bytes. Bounds apply only here, never to full text history audits. */
103
+ export async function generationSyncGitBytes(dir: string, args: string[], signal?: AbortSignal, ms = STEP_MS, maxBytes = 2 * 1024 * 1024): Promise<{ code: number; out: Uint8Array; err: string }> {
104
+ const empty = () => new Uint8Array(0);
105
+ if (signal?.aborted) return { code: -1, out: empty(), err: "stopped" };
106
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || !Number.isFinite(ms) || ms <= 0) return { code: -1, out: empty(), err: "invalid git output bound or deadline" };
107
+ // A bound or deadline can stop this call even without a caller signal, so always own its group.
108
+ const p = spawnGenerationGit(dir, args, true), unregister = register(p.pid);
109
+ const send = (name: "SIGTERM" | "SIGKILL") => {
110
+ try { process.kill(-p.pid, name); } catch { try { p.kill(name); } catch { /* already exited */ } }
111
+ };
112
+ let hard: ReturnType<typeof setTimeout> | undefined, stopped = false, overflow = false;
113
+ const kill = () => { stopped = true; send("SIGTERM"); hard ??= setTimeout(() => send("SIGKILL"), 2_000); };
114
+ const timer = setTimeout(kill, ms);
115
+ signal?.addEventListener("abort", kill, { once: true });
116
+ // Drain both pipes concurrently, including after overflow. Waiting on exit before draining
117
+ // deadlocks a full pipe; cancelling a reader can leave inherited child pipes unaccounted for.
118
+ const collect = async (stream: ReadableStream<Uint8Array>, limit: number, refuse: boolean): Promise<Buffer> => {
119
+ const reader = stream.getReader(), chunks: Buffer[] = []; let size = 0;
120
+ try {
121
+ for (;;) {
122
+ const part = await reader.read(); if (part.done) break;
123
+ const bytes = part.value, remaining = limit - size;
124
+ if (refuse && bytes.byteLength > remaining) { overflow = true; chunks.length = 0; kill(); }
125
+ if (!refuse || !overflow) {
126
+ const keep = Math.min(remaining, bytes.byteLength);
127
+ if (keep) { chunks.push(Buffer.from(bytes.subarray(0, keep))); size += keep; }
128
+ }
129
+ }
130
+ return Buffer.concat(chunks);
131
+ } finally { reader.releaseLock(); }
132
+ };
133
+ const operations = [p.exited, collect(p.stdout, maxBytes, true), collect(p.stderr, 65_536, false)] as const;
134
+ try {
135
+ const [code, out, err] = await Promise.all(operations);
136
+ return overflow ? { code: -1, out: empty(), err: "git output exceeds byte bound" }
137
+ : stopped ? { code: -1, out: empty(), err: "stopped" } : { code, out, err: err.toString("utf8").trim() };
138
+ } catch {
139
+ kill(); await Promise.allSettled(operations);
140
+ return { code: -1, out: empty(), err: "git output unavailable" };
141
+ } finally {
142
+ // Reap descendants even if Git exited first while they retained its pipe handles.
143
+ if (stopped) send("SIGKILL");
144
+ unregister(); clearTimeout(timer); if (hard) clearTimeout(hard); signal?.removeEventListener("abort", kill);
145
+ }
146
+ }
147
+
148
+
92
149
  /**
93
150
  * The `owner/repo` of a remote URL whose host is EXACTLY `github.com` (case-insensitive) over `https://`, `ssh://git@github.com/` or the scp form
94
151
  * `[git@]github.com:`; anything else is undefined, so it is `unknown` and never asked. An unanchored match let `https://notgithub.com/acme/notes`,
@@ -161,18 +218,97 @@ export interface SyncOptions {
161
218
  visibility?: (url: string) => Promise<Visibility>;
162
219
  /** An extra screen over each outbound row's text, on top of the machine's configured policy (the CLI passes the repository-narrowed policy). */
163
220
  screen?: RowScreen;
221
+ /** Host-only policy binding stamp, never authorization or a substitute for screening. */
222
+ policyVersion?: () => string;
164
223
  /** A stopping hub aborts: the running git step is killed and the sync ends without touching the store. */
165
224
  signal?: AbortSignal;
166
225
  }
167
226
 
227
+ /** Metadata-only sharded sync epoch. Hash complete source bytes, never notes or unopened shards. */
228
+ export function shardSyncBinding(store: MemoryStore): string { return captureShardSyncBinding(store, true); }
229
+ /** Selection bytes may change only after this sync commits; source/policy identity remains pinned across that commit. */
230
+ export function shardSyncSourceBinding(store: MemoryStore): string { return captureShardSyncBinding(store, false); }
231
+ function captureShardSyncBinding(store: MemoryStore, selection: boolean): string {
232
+ const unavailable = (): never => { throw new Error("sharded sync binding unavailable"); };
233
+ if (!store.profile.sharded) return unavailable();
234
+ const uid = process.getuid?.(); if (uid === undefined) return unavailable();
235
+ const same = (a: BigIntStats, b: BigIntStats) => a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && a.ctimeNs === b.ctimeNs;
236
+ const owned = (stat: BigIntStats, directory: boolean, privateMode = true) => {
237
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1n)
238
+ || stat.uid !== BigInt(uid) || privateMode && (stat.mode & 0o077n) !== 0n) unavailable();
239
+ };
240
+ const home = store.profile.home, digest = createHash("sha256"), directories = new Map<string, BigIntStats>(), checks: (() => void)[] = [];
241
+ const emit = (value: unknown) => { digest.update(JSON.stringify(value) + "\n"); };
242
+ const directory = (dir: string): void => {
243
+ if (directories.has(dir)) return;
244
+ const rel = relative(home, dir); if (rel === ".." || rel.startsWith(".." + sep)) unavailable();
245
+ if (dir !== home) directory(dirname(dir));
246
+ const before = lstatSync(dir, { bigint: true }); owned(before, true); directories.set(dir, before);
247
+ emit(["directory", dir, String(before.dev), String(before.ino)]);
248
+ checks.push(() => { const now = lstatSync(dir, { bigint: true }); owned(now, true); if (!same(before, now)) unavailable(); });
249
+ };
250
+ // Policy config keeps its established file modes inside this already-private owned home. Its complete bytes/inode still bind the epoch.
251
+ const source = (file: string, privateMode = true): void => {
252
+ directory(dirname(file));
253
+ let before: BigIntStats;
254
+ try { before = lstatSync(file, { bigint: true }); }
255
+ catch (error) {
256
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
257
+ emit(["source", file, "absent"]);
258
+ checks.push(() => { if (lstatSync(file, { bigint: true, throwIfNoEntry: false }) !== undefined) unavailable(); }); return;
259
+ }
260
+ owned(before, false, privateMode);
261
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
262
+ try {
263
+ const opened = fstatSync(fd, { bigint: true }); owned(opened, false, privateMode); if (!same(before, opened)) unavailable();
264
+ const content = createHash("sha256"), buffer = Buffer.allocUnsafe(65_536); let bytes = 0n;
265
+ for (;;) { const n = readSync(fd, buffer, 0, buffer.length, null); if (!n) break; content.update(buffer.subarray(0, n)); bytes += BigInt(n); }
266
+ const after = fstatSync(fd, { bigint: true }); owned(after, false, privateMode);
267
+ if (bytes !== before.size || !same(before, after) || !same(before, lstatSync(file, { bigint: true }))) unavailable();
268
+ emit(["source", file, "present", String(before.dev), String(before.ino), String(before.size), String(before.mtimeNs), String(before.ctimeNs), content.digest("hex")]);
269
+ } finally { closeSync(fd); }
270
+ checks.push(() => { const now = lstatSync(file, { bigint: true }); owned(now, false, privateMode); if (!same(before, now)) unavailable(); });
271
+ };
272
+ try {
273
+ directory(home);
274
+ for (const name of [...(selection ? ["profile.json", "shards.json"] : []), "aliases.jsonl", "amem.config.json", "machine.json"]) source(join(home, name), name !== "amem.config.json");
275
+ const targets = [...store.openedSyncTargets()].sort((a, b) => a.dir.localeCompare(b.dir));
276
+ for (const target of targets) {
277
+ directory(target.dir);
278
+ emit(["target", target.dir, target.profile.generation ?? null, target.machine.id, target.profile.shardUser ?? false, target.profile.shardKey ?? null]);
279
+ for (const name of ["project.json", "generation.json", "generation-seal.json", "machine.json"]) source(join(target.dir, name));
280
+ }
281
+ for (const check of checks) check();
282
+ return digest.digest("hex");
283
+ } catch { return unavailable(); }
284
+ }
285
+
168
286
  export async function syncMemory(store: MemoryStore, opts: SyncOptions = {}): Promise<SyncResult> {
287
+ if (store.profile.sharded) {
288
+ try {
289
+ // Refresh only through the existing proved monotone profile rebind before pinning this attempt.
290
+ for (const target of store.openedSyncTargets()) rebindPublicPruneProfile(target.profile);
291
+ const binding = shardSyncBinding(store), policyVersion = opts.policyVersion?.();
292
+ const assertBinding = () => {
293
+ if (opts.policyVersion?.() !== policyVersion || shardSyncBinding(store) !== binding) throw new Error("binding changed");
294
+ };
295
+ assertBinding();
296
+ const screen = outboundScreen(store.profile.home, opts.screen);
297
+ const pass: RowScreen = row => { assertBinding(); const accepted = screen(row); assertBinding(); if (typeof accepted !== "boolean") throw new Error("invalid screen result"); return accepted; };
298
+ for (const target of store.openedSyncTargets()) {
299
+ const screened = screenOutboundRows(target, pass); assertBinding();
300
+ if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
301
+ }
302
+ const transport = await import("./shard-sync.ts"); assertBinding();
303
+ return transport.syncShardedMemory(store, opts);
304
+ } catch { return { ok: false, pushed: false, entries: 0, error: "sharded sync binding or policy unavailable or changed" }; }
305
+ }
169
306
  // F4: before anything is staged or pushed, this machine's own long-term notes pass the person's configured policy (every profile: single log, generation, shards).
170
307
  const pass = outboundScreen(store.profile.home, opts.screen);
171
308
  for (const target of store.openedSyncTargets()) {
172
309
  const screened = target.profile.kind === "generation" ? screenOutboundRows(target, pass) : screenOwnSegment(target.dir, target.machine.id, pass);
173
310
  if (!screened.ok) return { ok: false, pushed: false, entries: 0, error: screened.reason };
174
311
  }
175
- if (store.profile.sharded) return (await import("./shard-sync.ts")).syncShardedMemory(store, opts);
176
312
  if (store.profile.kind === "generation") return syncGenerationMemory(store, opts);
177
313
  const dir = store.dir;
178
314
  const fail = (error: string): SyncResult => ({ ok: false, pushed: false, entries: 0, error });
@@ -1,6 +1,7 @@
1
1
  import { validVaultPath } from "./generation.ts";
2
2
  import { createStandalonePolicy, type MemoryPolicy } from "./policy.ts";
3
3
  import type { MemoryStore, View, VaultEntry } from "./store.ts";
4
+ import { label, PLAIN, singleLine, type Style } from "./style.ts";
4
5
 
5
6
  export interface VaultSelection { project: string; policy: MemoryPolicy; limit?: number }
6
7
  export type { VaultEntry } from "./store.ts";
@@ -29,6 +30,36 @@ export async function vaultCandidates(store: MemoryStore, options: VaultSelectio
29
30
  .sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id)).slice(0, options.limit ?? 50);
30
31
  }
31
32
 
33
+ /** Human-readable list of screened vault candidates; raw note text is intentionally omitted. */
34
+ export function vaultCandidatesText(rows: readonly View[], s: Style = PLAIN): string {
35
+ return [
36
+ `amem vault-candidates: ${rows.length ? s.attention("attention") : s.ok("complete")}`,
37
+ `${label(s, rows.length ? "attention" : "skipped")} candidates: ${rows.length}`,
38
+ ...rows.map(row => {
39
+ const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
40
+ return ` ${label(s, "attention")} ${singleLine(row.project)}: ${row.id} ${row.kind} ${title}`;
41
+ }),
42
+ `Next action: ${rows.length ? "open the source repository, then review a candidate with amem show <id> before recording it in your vault" : "none; no screened durable candidates are available"}`,
43
+ ].join("\n");
44
+ }
45
+
46
+ /** Human-readable cross-project list, preserving project grouping and omitting note bodies. */
47
+ export function vaultCandidatesAllText(result: {
48
+ crossedProjects: boolean;
49
+ projects: readonly { project: string; candidates: readonly View[] }[];
50
+ }, s: Style = PLAIN): string {
51
+ const count = result.projects.reduce((sum, group) => sum + group.candidates.length, 0);
52
+ return [
53
+ `amem vault-candidates: ${count ? s.attention("attention") : s.ok("complete")}${result.crossedProjects ? " (across projects)" : ""}`,
54
+ `${label(s, count ? "attention" : "skipped")} candidates: ${count}`,
55
+ ...result.projects.flatMap(group => group.candidates.map(row => {
56
+ const title = singleLine(row.title ?? "(untitled)").slice(0, 120);
57
+ return ` ${label(s, "attention")} ${singleLine(group.project)}: ${row.id} ${row.kind} ${title}`;
58
+ })),
59
+ `Next action: ${count ? (result.crossedProjects ? "open the candidate's source repository, then review it with amem show <id> before recording it in your vault" : "open the source repository, then review a candidate with amem show <id> before recording it in your vault") : "none; no screened durable candidates are available"}`,
60
+ ].join("\n");
61
+ }
62
+
32
63
  /** Person-facing hosts authenticate intent before calling; no MCP vault-link tool is provided. */
33
64
  export async function recordVaultLink(store: MemoryStore, id: string, path: string, options: VaultSelection): Promise<string> {
34
65
  const policy = selection(options);