@staix/agent-mem 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmodSync, existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { memoryBase } from "./home.ts";
5
+ import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
6
+ import { readProfileJSON, validGeneration } from "./generation.ts";
7
+ import { captureShardStats, readCaptureConfig } from "./capture.ts";
8
+ import { openShardStore } from "./store.ts";
9
+
10
+ /**
11
+ * #15 D3 (storage part only): the user-level catalog of project shards. `catalog.db` in the memory home is derived
12
+ * only from the shards (`projects/* /project.json`, their `machine/<id>` capture stores and configs) and the
13
+ * append-only `aliases.jsonl`; it carries no truth of its own, so deleting it and rebuilding from the shards always
14
+ * reproduces the same rows. The D3 management commands (`amem projects/usage/gc/export/archive/forget`) are #15 T5
15
+ * and are deliberately not built here.
16
+ */
17
+
18
+ export interface CatalogProject {
19
+ hash: string; key: string; local: boolean; aliases: string[];
20
+ captureRows: number; captureBytes: number; captureLastAt: string | null;
21
+ curatedEntries: number | null; curatedLastAt: string | null; curatedAvailable: boolean;
22
+ capMb: number; retentionDays: number;
23
+ }
24
+
25
+ interface CatalogRow { hash: string; key: string; local: number; aliases: string; capture_rows: number; capture_bytes: number; capture_last_at: string | null; curated_entries: number | null; curated_last_at: string | null; cap_mb: number; retention_days: number }
26
+
27
+ const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key, local: r.local === 1, aliases: JSON.parse(r.aliases) as string[],
28
+ captureRows: r.capture_rows, captureBytes: r.capture_bytes, captureLastAt: r.capture_last_at, curatedEntries: r.curated_entries, curatedLastAt: r.curated_last_at, curatedAvailable: r.curated_entries !== null, capMb: r.cap_mb, retentionDays: r.retention_days });
29
+
30
+ /** Interrupted splits expose capture facts, but cannot authorize any curated store open or initialization. */
31
+ function capturedSelection(base: string): boolean {
32
+ const value = readProfileJSON(join(base, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
33
+ if (value === undefined) return false;
34
+ if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || (value.phase !== "captured" && value.phase !== "active") || typeof value.selectorRevision !== "string") throw new Error("curated shard selection is unavailable or invalid");
35
+ return value.phase === "captured";
36
+ }
37
+
38
+ /** Refresh the disposable curated index from its sealed logs before deriving an own-project receipt. */
39
+ function curatedStats(base: string, hash: string, key: string, captured: boolean): { entries: number | null; lastAt: string | null } {
40
+ if (captured) return { entries: null, lastAt: null };
41
+ const dir = join(base, "projects", hash);
42
+ // A capture-only descriptor is not permission to initialize a curated store while listing the catalog.
43
+ if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
44
+ const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true });
45
+ try {
46
+ const db = new Database(join(dir, "index.db"), { readonly: true });
47
+ try {
48
+ db.run("PRAGMA busy_timeout = 0");
49
+ const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?").get(key) as { n: number; last: string | null };
50
+ return { entries: row.n, lastAt: row.last };
51
+ } finally { db.close(); }
52
+ } finally { store.close(); }
53
+ }
54
+
55
+ /** Every readable shard with its machine-scope directories; the walk behind the catalog and the capacity advisor. */
56
+ export function walkShards(base: string): { hash: string; key: string; local: boolean; machineDirs: string[] }[] {
57
+ const projectsDir = join(base, "projects");
58
+ let hashes: string[] = [];
59
+ try { hashes = readdirSync(projectsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^[0-9a-f]{16}$/.test(e.name)).map((e) => e.name).sort(); } catch { /* no shards yet */ }
60
+ const out: { hash: string; key: string; local: boolean; machineDirs: string[] }[] = [];
61
+ for (const hash of hashes) {
62
+ const descriptor = readShardDescriptor(base, hash);
63
+ if (!descriptor) continue; // a half-created or corrupt shard is skipped, never fatal
64
+ const machineDirs: string[] = [];
65
+ try {
66
+ for (const entry of readdirSync(join(projectsDir, hash, "machine"), { withFileTypes: true })) if (entry.isDirectory()) machineDirs.push(join(projectsDir, hash, "machine", entry.name));
67
+ } catch { /* no machine scope yet */ }
68
+ out.push({ hash, key: descriptor.key, local: descriptor.local, machineDirs: machineDirs.sort() });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ /** What the shards and the alias log say, before anything is persisted. */
74
+ export function scanCatalog(base: string): CatalogProject[] {
75
+ const captured = capturedSelection(base);
76
+ // The latest op per alias (a removal included): an alias lists only under the key it currently points at, never under a past target or after `alias remove`.
77
+ const latest = effectiveAliases(base);
78
+ const out: CatalogProject[] = [];
79
+ for (const shard of walkShards(base)) {
80
+ let rows = 0, bytes = 0, lastAt: string | null = null;
81
+ for (const dir of shard.machineDirs) {
82
+ const stats = captureShardStats(dir);
83
+ rows += stats.rows;
84
+ bytes += stats.bytes;
85
+ if (stats.lastAt && (lastAt === null || stats.lastAt > lastAt)) lastAt = stats.lastAt;
86
+ }
87
+ const config = readCaptureConfig(shard.machineDirs[shard.machineDirs.length - 1] ?? join(base, "projects", shard.hash, "machine", "_"), base);
88
+ const curated = curatedStats(base, shard.hash, shard.key, captured);
89
+ out.push({ hash: shard.hash, key: shard.key, local: shard.local,
90
+ aliases: [...latest.entries()].filter(([, key]) => key === shard.key).map(([alias]) => alias).sort(),
91
+ captureRows: rows, captureBytes: bytes, captureLastAt: lastAt, curatedEntries: curated.entries, curatedLastAt: curated.lastAt, curatedAvailable: curated.entries !== null, capMb: config.capMb, retentionDays: config.retentionDays });
92
+ }
93
+ return out.sort((a, b) => a.key.localeCompare(b.key));
94
+ }
95
+
96
+ /** Rewrite `catalog.db` from the shards in one transaction: the same shards always reproduce the same rows. */
97
+ export function rebuildCatalog(opts: { home?: string } = {}): CatalogProject[] {
98
+ const base = memoryBase(opts.home);
99
+ const projects = scanCatalog(base);
100
+ const file = join(base, "catalog.db");
101
+ const db = new Database(file, { create: true });
102
+ try {
103
+ db.run("PRAGMA busy_timeout = 3000");
104
+ try { chmodSync(file, 0o600); } catch { /* best effort */ }
105
+ db.transaction(() => {
106
+ const columns = db.query("PRAGMA table_info(projects)").all() as { name: string; notnull: number }[];
107
+ // This database is disposable. Rebuild older derived schemas instead of coercing unavailable into zero.
108
+ if (columns.length && (!columns.some(column => column.name === "curated_entries" && column.notnull === 0) || !columns.some(column => column.name === "curated_last_at"))) db.run("DROP TABLE projects");
109
+ db.run(`CREATE TABLE IF NOT EXISTS projects (hash TEXT PRIMARY KEY, key TEXT NOT NULL, local INTEGER NOT NULL, aliases TEXT NOT NULL,
110
+ capture_rows INTEGER NOT NULL, capture_bytes INTEGER NOT NULL, capture_last_at TEXT, curated_entries INTEGER, curated_last_at TEXT, cap_mb REAL NOT NULL, retention_days REAL NOT NULL)`);
111
+ db.run("DELETE FROM projects");
112
+ for (const p of projects) db.query("INSERT INTO projects (hash, key, local, aliases, capture_rows, capture_bytes, capture_last_at, curated_entries, curated_last_at, cap_mb, retention_days) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
113
+ .run(p.hash, p.key, p.local ? 1 : 0, JSON.stringify(p.aliases), p.captureRows, p.captureBytes, p.captureLastAt, p.curatedEntries, p.curatedLastAt, p.capMb, p.retentionDays);
114
+ }).immediate();
115
+ } finally { db.close(); }
116
+ return projects;
117
+ }
118
+
119
+ /** The stored catalog, rebuilt when absent. Listing may lag the shards; `rebuildCatalog` is the fresh read. */
120
+ export function readCatalog(opts: { home?: string } = {}): CatalogProject[] {
121
+ const base = memoryBase(opts.home);
122
+ const file = join(base, "catalog.db");
123
+ if (!existsSync(file)) return rebuildCatalog(opts);
124
+ const captured = capturedSelection(base);
125
+ let db: Database | undefined;
126
+ try {
127
+ db = new Database(file, { readonly: true });
128
+ db.run("PRAGMA busy_timeout = 3000");
129
+ const columns = new Set((db.query("PRAGMA table_info(projects)").all() as { name: string }[]).map(column => column.name));
130
+ if (!columns.has("curated_entries") || !columns.has("curated_last_at")) throw new Error("catalog schema needs rebuild");
131
+ return (db.query("SELECT * FROM projects ORDER BY key").all() as CatalogRow[]).map(fromRow)
132
+ .map(project => captured ? { ...project, curatedEntries: null, curatedLastAt: null, curatedAvailable: false } : project);
133
+ } catch { return rebuildCatalog(opts); }
134
+ finally { db?.close(); }
135
+ }
@@ -4,6 +4,8 @@ import { importClaudeMem } from "./import.ts";
4
4
  import { memoryProjectKey, projectChain } from "./project.ts";
5
5
 
6
6
  export interface MemoryCommandOptions {
7
+ /** Startup-bound canonical project supplied by a host; no model-facing selector. */
8
+ project?: string;
7
9
  store?: MemoryStore;
8
10
  sync?: Omit<SyncOptions, "remote">;
9
11
  open: () => MemoryStore;
@@ -13,6 +15,7 @@ export interface MemoryCommandOptions {
13
15
  onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
14
16
  }
15
17
  export interface GoalCommandOptions {
18
+ project?: string;
16
19
  store?: MemoryStore;
17
20
  link?: (goal: string, task: string) => Promise<string>;
18
21
  open: () => MemoryStore;
@@ -40,7 +43,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
40
43
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
41
44
  const store = opts.store ?? opts.open();
42
45
  const actor = "user";
43
- const project = memoryProjectKey(cwd);
46
+ const project = opts.project ?? memoryProjectKey(cwd);
44
47
  const need = (v: View | undefined, id: string) => v ?? fail(`no memory entry ${id} (ahub memory list shows the ids)`);
45
48
  try {
46
49
  switch (sub) {
@@ -108,7 +111,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
108
111
  if (rest[0] !== "claude-mem" || rest.length !== 1) fail("usage: ahub memory import claude-mem [--db <path>] [--limit N] [--restart]");
109
112
  if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
110
113
  const isPii = opts.importPolicy();
111
- const key = memoryProjectKey(cwd);
114
+ const key = opts.project ?? memoryProjectKey(cwd);
112
115
  const nodes = (text: string) => extractNodes(text, { cwd, project: key, repo: key.startsWith("github.com/") ? key.slice("github.com/".length) : key, checkExists: false });
113
116
  const r = importClaudeMem(store, { project: key, chain: projectChain(cwd), nodes, restart, isPii, ...(db ? { db } : {}), ...(limit ? { limit: Number(limit) } : {}) });
114
117
  if (json) return JSON.stringify(r, null, 2);
@@ -125,7 +128,7 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
125
128
  }
126
129
  case "machine": {
127
130
  const label = flag("--label");
128
- const m = label ? setMachineLabel(store.dir, label) : store.machine;
131
+ const m = label ? setMachineLabel(store.profile.sharded ? store.profile.home : store.dir, label) : store.machine;
129
132
  return json ? JSON.stringify(m) : `this machine: ${m.id} (${m.label})`;
130
133
  }
131
134
  case "rebuild": { store.rebuild(); const c = store.counts(); return `rebuilt the index from ${c.segments} segment${c.segments === 1 ? "" : "s"}: ${c.long} long-term, ${c.short} short-term entries`; }
@@ -154,7 +157,7 @@ export async function rawGoalCommand(args: string[], cwd: string, opts: GoalComm
154
157
  const take = (name: string) => { const out: string[] = []; for (let i = rest.indexOf(name); i >= 0; i = rest.indexOf(name)) { out.push(rest[i + 1] ?? fail(`${name} needs a value`)); rest.splice(i, 2); } return out; };
155
158
  const sub = rest[0] && !rest[0].startsWith("--") ? rest.shift()! : "list";
156
159
  const store = opts.store ?? opts.open();
157
- const project = memoryProjectKey(cwd);
160
+ const project = opts.project ?? memoryProjectKey(cwd);
158
161
  const me = store.machine.id;
159
162
  // Only this project's long-term tasks and shared ones, like every other lookup an agent may run.
160
163
  const need = (id: string | undefined) => store.goalOf(id?.replace(/^goal:/, "") ?? fail(GOAL_USAGE), project) ?? fail(`no long-term task ${id} for this project (ahub goal list shows them)`);
@@ -1,15 +1,17 @@
1
+ import { SHARDED_SCHEMA } from "./shard.ts";
2
+ import { readShardLegacySnapshot } from "./shard-bridge.ts";
1
3
  import type { Database } from "bun:sqlite";
2
4
  import { createHash } from "node:crypto";
3
5
  import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
4
6
  import { join } from "node:path";
5
- import { canonicalJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile } from "./generation.ts";
7
+ import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
6
8
  import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
7
9
  import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
8
10
  import { PROMOTED_KINDS, type Entry } from "./store.ts";
9
11
 
10
12
  const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
11
13
  const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
12
- export const GENERATION_TABLES = ["generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
14
+ export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
13
15
  export interface GenerationIndexOptions {
14
16
  profile: BoundProfile; db: Database; now: () => number;
15
17
  apply: (line: string, source: string) => void;
@@ -43,6 +45,18 @@ export function explicitExpirySnapshot(rows: readonly PruneSourceRow[]): PruneSn
43
45
  });
44
46
  }
45
47
 
48
+ /** Exact source-presence reducer shared by indexing and migration proof validation. */
49
+ export function coalesceGenerationSource(prior: PruneSourceRow | undefined, row: Entry, source: { source: string; local: boolean }): PruneSourceRow {
50
+ if (prior && canonicalJSON(prior.row) !== canonicalJSON(row)) return fail();
51
+ const local = source.local || row.op === "settle" || row.op === "note" && row.tier === "short";
52
+ const publicWitness = !local && source.source !== "legacy-local";
53
+ const own = source.source === "generation";
54
+ return { row, placement: local && (!prior || prior.placement === "local") ? "local" : "synced",
55
+ publicWitness: !!prior?.publicWitness || publicWitness,
56
+ own: prior ? row.op === "note" ? prior.own === true && own : prior.own === true || own : own,
57
+ legacy: !!prior?.legacy || !own };
58
+ }
59
+
46
60
  /** Generation-owned derived state. Legacy files are inputs, never opened as a MemoryStore. */
47
61
  export class GenerationIndex {
48
62
  readonly profile: BoundProfile;
@@ -55,6 +69,7 @@ export class GenerationIndex {
55
69
  const db = options.db;
56
70
  db.run("CREATE TABLE IF NOT EXISTS generation_rows (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL, public INTEGER NOT NULL, own INTEGER NOT NULL, legacy INTEGER NOT NULL)");
57
71
  if (!(db.query("PRAGMA table_info(generation_rows)").all() as { name: string }[]).some(row => row.name === "legacy")) { db.run("ALTER TABLE generation_rows ADD COLUMN legacy INTEGER NOT NULL DEFAULT 0"); this.replayProvenance = true; }
72
+ db.run("CREATE TABLE IF NOT EXISTS generation_vault_links (id TEXT PRIMARY KEY, target TEXT NOT NULL, row_hash TEXT NOT NULL, project TEXT NOT NULL, path TEXT NOT NULL, at TEXT NOT NULL)");
58
73
  db.run("CREATE TABLE IF NOT EXISTS generation_prunes (id TEXT PRIMARY KEY, line TEXT NOT NULL, local INTEGER NOT NULL)");
59
74
  db.run("CREATE TABLE IF NOT EXISTS generation_targets (id TEXT PRIMARY KEY)");
60
75
  db.run("CREATE TABLE IF NOT EXISTS generation_segments (source TEXT NOT NULL, path TEXT NOT NULL, offset INTEGER NOT NULL, hash TEXT NOT NULL, commit_id TEXT, stamp TEXT, PRIMARY KEY(source,path))");
@@ -74,7 +89,15 @@ export class GenerationIndex {
74
89
  });
75
90
  });
76
91
  }
92
+ /** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
93
+ private shardFrontier(): string | undefined {
94
+ if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
95
+ const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
96
+ if (!value || value.schema !== SHARDED_SCHEMA || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
97
+ return hashJSON(value);
98
+ }
77
99
  private capture(): { segments: LegacySegment[]; commit?: string } {
100
+ const shardBefore = this.shardFrontier();
78
101
  const db = this.options.db;
79
102
  const previous = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments WHERE source != 'generation'").all() as (LegacyCheckpoint & { commit_id: string | null; stamp: string | null })[];
80
103
  const initial = readProfileJSON(join(this.profile.dir, "migration-sources.json")) as { sources?: LegacyCheckpoint[]; legacyCommit?: string } | undefined;
@@ -84,13 +107,18 @@ export class GenerationIndex {
84
107
  const persisted = new Set(previous.map(row => `${row.source}:${row.path}`));
85
108
  const completeFrontier = checkpoints.every(row => persisted.has(`${row.source}:${row.path}`));
86
109
  const prior = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value ?? initial?.legacyCommit;
87
- const commit = this.options.commit ?? legacyHead(this.profile.home, this.profile.generation!);
88
- const legacy = readLegacySnapshot(this.profile.home, { generation: this.profile.generation, ...(commit ? { commit } : {}), checkpoints, ...(prior ? { previousCommit: prior } : {}), ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier });
110
+ const legacyHome = this.profile.legacyHome ?? this.profile.home;
111
+ const generation = this.profile.sharded ? undefined : this.profile.generation;
112
+ const commit = this.options.commit ?? (generation ? legacyHead(legacyHome, generation) : undefined);
113
+ const legacy = this.profile.sharded
114
+ ? readShardLegacySnapshot(this.profile, { checkpoints, ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier })
115
+ : readLegacySnapshot(legacyHome, { ...(generation ? { generation } : {}), ...(commit ? { commit } : {}), checkpoints, ...(generation && prior ? { previousCommit: prior } : {}), ...(this.options.limits ? { limits: this.options.limits } : {}), reader: this.reader, incremental: !this.fullCapture && !this.options.limits && completeFrontier });
89
116
  const own = this.ownSegments();
90
117
  for (const old of db.query("SELECT path,offset,hash FROM generation_segments WHERE source='generation'").all() as { path: string; offset: number; hash: string }[]) {
91
118
  const segment = own.find(s => s.path === old.path);
92
119
  if (!segment || segment.offset < old.offset) fail();
93
120
  }
121
+ if (this.shardFrontier() !== shardBefore) return fail();
94
122
  return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
95
123
  }
96
124
  private index(captured: { segments: LegacySegment[]; commit?: string }): void {
@@ -107,19 +135,21 @@ export class GenerationIndex {
107
135
  db.query("INSERT OR IGNORE INTO generation_prunes VALUES (?,?,?)").run(row.id, canonicalJSON(row), Number(segment.local));
108
136
  continue;
109
137
  }
138
+ if (segment.source === "generation" && parsed && typeof parsed === "object" && (parsed as { op?: string }).op === "vault_link") {
139
+ const row = validateVaultLinkRow(parsed, this.profile.generation, segment.local ? "local" : "synced");
140
+ if (segment.path !== `local/${row.machine}.jsonl`) fail();
141
+ db.query("INSERT OR IGNORE INTO generation_vault_links VALUES (?,?,?,?,?,?)").run(row.id, row.target, row.rowHash, row.project, row.path, row.at);
142
+ continue;
143
+ }
110
144
  const bytes = Buffer.from(line + "\n");
111
145
  const row = legacyEntries([{ ...segment, bytes, offset: bytes.length, hash: digest(bytes) }], { strict: segment.source === "generation", onMalformed: () => this.recordSkipped(segment) })[0];
112
146
  if (!row) continue;
113
- const canonical = canonicalJSON(row), old = db.query("SELECT line,own,legacy FROM generation_rows WHERE id=?").get(row.id) as { line: string; own: number; legacy: number } | null;
147
+ const canonical = canonicalJSON(row), old = db.query("SELECT line,local,public,own,legacy FROM generation_rows WHERE id=?").get(row.id) as { line: string; local: number; public: number; own: number; legacy: number } | null;
114
148
  if (old && old.line !== canonical) fail();
115
149
  this.options.apply(line, segment.path);
116
- const local = segment.local || row.op === "settle" || row.op === "note" && row.tier === "short";
117
- const publicWitness = !local && segment.source !== "legacy-local";
118
- const own = segment.source === "generation", legacy = !own;
119
- // Notes retain legacy birth when duplicated; ops can independently occur in both birth realms.
120
- const ownPresence = old ? row.op === "note" ? !!old.own && own : !!old.own || own : own;
150
+ const presence = coalesceGenerationSource(old ? { row, placement: old.local ? "local" : "synced", publicWitness: !!old.public, own: !!old.own, legacy: !!old.legacy } : undefined, row, segment);
121
151
  db.query("INSERT INTO generation_rows VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET local=MIN(local,excluded.local), public=MAX(public,excluded.public), own=excluded.own, legacy=excluded.legacy")
122
- .run(row.id, canonical, Number(local), Number(publicWitness), Number(ownPresence), Number(!!old?.legacy || legacy));
152
+ .run(row.id, canonical, Number(presence.placement === "local"), Number(presence.publicWitness), Number(presence.own), Number(presence.legacy));
123
153
  }
124
154
  db.query("INSERT INTO generation_segments VALUES (?,?,?,?,?,?) ON CONFLICT(source,path) DO UPDATE SET offset=excluded.offset,hash=excluded.hash,commit_id=excluded.commit_id,stamp=excluded.stamp")
125
155
  .run(segment.source, segment.path, segment.offset, segment.hash, segment.commit ?? null, segment.stamp ?? null);
@@ -173,6 +203,18 @@ export class GenerationIndex {
173
203
  if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1); return fail(); }
174
204
  this.replayProvenance = false;
175
205
  }
206
+ vaultWitnesses(ids: readonly string[]): Map<string, string> {
207
+ if (ids.length > 5000) throw new Error("vault scan budget exceeded");
208
+ return new Map((this.options.db.query("SELECT id,line FROM generation_rows WHERE id IN (SELECT value FROM json_each(?))").all(JSON.stringify(ids)) as { id: string; line: string }[])
209
+ .map(row => [row.id, hashJSON(JSON.parse(row.line))]));
210
+ }
211
+ vaultLinks(project: string): VaultLink[] {
212
+ const rows = this.options.db.query("SELECT l.id,l.target,l.row_hash AS rowHash,l.project,l.path,l.at,r.line FROM generation_vault_links l JOIN generation_rows r ON r.id=l.target WHERE l.project=? ORDER BY l.at,l.id LIMIT 5001").all(project) as (VaultLink & { line: string })[];
213
+ if (rows.length > 5000) throw new Error("vault scan budget exceeded");
214
+ return rows
215
+ .filter(row => { const note = JSON.parse(row.line) as Entry; return note.op === "note" && note.project === row.project && hashJSON(note) === row.rowHash; })
216
+ .map(({ line: _line, ...row }) => row);
217
+ }
176
218
  own(id: string): boolean { return !!(this.options.db.query("SELECT own FROM generation_rows WHERE id=?").get(id) as { own: number } | null)?.own; }
177
219
  snapshot(refresh = true): PruneSnapshot {
178
220
  if (refresh) this.refresh();
@@ -191,6 +233,7 @@ export class GenerationIndex {
191
233
  private pendingPath(local: boolean, machine: string): string { return join(this.profile.dir, "prune-plans", `pending-${local ? "local" : "log"}-${machine}.json`); }
192
234
  /** Every ordinary writer must refuse to append unrelated JSON after a prune crash tail. */
193
235
  assertAppendable(local: boolean, machine: string): void {
236
+ this.shardFrontier();
194
237
  if (readProfileJSON(this.pendingPath(local, machine)) !== undefined) fail();
195
238
  const file = join(this.profile.dir, local ? "local" : "log", `${machine}.jsonl`);
196
239
  if (!existsSync(file)) return;
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
+ import { types } from "node:util";
2
3
  import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
- import { mutationDatabase } from "./mutation.ts";
4
+ import { readerAdmissionDatabase } from "./mutation.ts";
4
5
  import { dirname, join } from "node:path";
5
6
  import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
6
7
 
@@ -11,6 +12,13 @@ export interface BoundProfile {
11
12
  dir: string;
12
13
  generation?: string;
13
14
  selectorRevision: string;
15
+ /** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
16
+ legacyHome?: string;
17
+ /** Public legacy witnesses stay distinct from same-machine private bridge input. */
18
+ legacyMainHome?: string;
19
+ sharded?: boolean;
20
+ shardKey?: string;
21
+ shardUser?: boolean;
14
22
  }
15
23
  export type ProfileInput = "shared-v1" | { generation: string };
16
24
  export interface ProfileSelector { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
@@ -102,7 +110,7 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
102
110
  const deadline = Date.now() + PROFILE_LOCK_TIMEOUT_MS;
103
111
  for (;;) {
104
112
  let acquired = false;
105
- const fence = mutationDatabase(memoryDir);
113
+ const fence = readerAdmissionDatabase(memoryDir);
106
114
  let fenced = false;
107
115
  try {
108
116
  // Serialize the complete stale-owner probe/unlink/recreate sequence, then release before waiting on a live owner.
@@ -141,3 +149,34 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
141
149
  }
142
150
  }
143
151
  }
152
+
153
+
154
+ export interface VaultLinkRow {
155
+ schema: "agent-mem.vault-link/v1"; op: "vault_link"; generation: string;
156
+ id: string; at: string; machine: string; actor: "person"; placement: "local";
157
+ target: string; rowHash: string; project: string; path: string;
158
+ }
159
+ export interface VaultLink { id: string; target: string; rowHash: string; project: string; path: string; at: string }
160
+ const invalidVaultLink = (): never => { throw new Error("invalid vault link or unavailable exact entry"); };
161
+ export const vaultLinkId = (body: unknown): string => createHash("sha256").update(canonicalJSON(body)).digest("hex").slice(0, 32);
162
+
163
+ /** Metadata only: this path is never resolved, opened, copied or written by amem. */
164
+ export function validVaultPath(path: unknown): path is string {
165
+ return typeof path === "string" && path.length > 0 && Buffer.byteLength(path) <= 512 && path.endsWith(".md")
166
+ && !/[\\:\p{Cc}]/u.test(path) && !path.startsWith("/") && path.split("/").every(part => part !== "" && part !== "." && part !== "..");
167
+ }
168
+ export function validateVaultLinkRow(value: unknown, generation: string | undefined, placement: "local" | "synced"): VaultLinkRow {
169
+ if (!value || typeof value !== "object" || types.isProxy(value) || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype || Object.getOwnPropertySymbols(value).length) return invalidVaultLink();
170
+ const expected = ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"].sort();
171
+ const descriptors = Object.getOwnPropertyDescriptors(value);
172
+ if (canonicalJSON(Object.keys(descriptors).sort()) !== canonicalJSON(expected) || Object.values(descriptors).some(field => !("value" in field) || !field.enumerable || typeof field.value !== "string")) return invalidVaultLink();
173
+ const row = value as VaultLinkRow;
174
+ if (row.schema !== "agent-mem.vault-link/v1" || row.op !== "vault_link" || row.generation !== generation || !/^[a-f0-9]{32}$/.test(row.generation)
175
+ || row.placement !== "local" || placement !== "local" || row.actor !== "person" || !/^[a-f0-9]{16}$/.test(row.machine)
176
+ || !/^[a-f0-9]{32}$/.test(row.id) || !/^[a-f0-9]{32}$/.test(row.target) || !/^[a-f0-9]{64}$/.test(row.rowHash)
177
+ || !row.project.trim() || row.project.length > 400 || /\p{Cc}/u.test(row.project) || !validVaultPath(row.path)
178
+ || !Number.isFinite(Date.parse(row.at)) || new Date(row.at).toISOString() !== row.at || Buffer.byteLength(canonicalJSON(row)) > 4096) return invalidVaultLink();
179
+ const { id, ...body } = row;
180
+ if (id !== vaultLinkId(body)) return invalidVaultLink();
181
+ return row;
182
+ }
@@ -8,3 +8,8 @@ export function memoryHome(env: NodeJS.ProcessEnv = process.env): string {
8
8
  if (own) return canonicalPath(own);
9
9
  return join(canonicalPath(env.AGENTHUB_HOME?.trim() || join(homedir(), ".agenthub")), "memory");
10
10
  }
11
+
12
+ /** The store's own base convention: `StoreOptions.home` names the parent and gets `/memory` appended. */
13
+ export function memoryBase(home?: string): string {
14
+ return home === undefined ? memoryHome() : join(home, "memory");
15
+ }
@@ -5,6 +5,8 @@ import { realPath } from "./platform.ts";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { KINDS, looksSecret, type Kind, type MemoryStore } from "./store.ts";
8
+ import { aliasedKeys, resolveAlias } from "./shard.ts";
9
+ import { WorkListStore, type WorkListFields, type WorkListPolicy } from "./work-lists.ts";
8
10
 
9
11
  /** The claude-mem store is another program's: opened read-only, never written, its locks waited on briefly. */
10
12
  export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
@@ -12,6 +14,63 @@ export const CLAUDE_MEM_BUSY_TIMEOUT_MS = 3000;
12
14
  const MODEL_ANSWER = "ahub ask (model answer)";
13
15
 
14
16
  export interface ImportResult { read: number; added: number; skipped: { model: number; pii: number; secret: number }; more: boolean; cursor: number }
17
+ export interface WorkStateImportResult { read: number; added: number; skipped: { invalid: number; policy: number }; more: boolean; cursor: number }
18
+
19
+ /**
20
+ * Import only the source project names explicitly mapped by the person's D2 alias table. A basename or even
21
+ * an exact canonical-key match is not permission to adopt another program's project. Source receipts preserve
22
+ * synced device/id identity across database copies; unsynced rows are scoped to the source's real path.
23
+ */
24
+ export async function importClaudeMemWorkState(opts: { base: string; project: string; policy: WorkListPolicy; db?: string; limit?: number; restart?: boolean; log?: (line: string) => void }): Promise<WorkStateImportResult> {
25
+ const file = opts.db ?? join(homedir(), ".claude-mem", "claude-mem.db");
26
+ if (!existsSync(file)) throw new Error(`no claude-mem store at ${file}`);
27
+ const limit = opts.limit ?? 10_000;
28
+ if (!Number.isSafeInteger(limit) || limit <= 0) throw new Error("--limit takes a positive integer");
29
+ const project = resolveAlias(opts.base, opts.project);
30
+ const aliases = aliasedKeys(opts.base, project);
31
+ const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
32
+ if (!aliases.length) return result;
33
+ const db = new Database(file, { readonly: true });
34
+ let store: WorkListStore | undefined;
35
+ let state: string | undefined, key = "", cursor = 0;
36
+ try {
37
+ db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
38
+ if (!db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'work_state_entries'").get()) return result;
39
+ const columns = new Set((db.query("PRAGMA table_info(work_state_entries)").all() as { name: string }[]).map(row => row.name));
40
+ const origin = columns.has("origin_device_id") && columns.has("origin_local_id");
41
+ const sourceMachine = createHash("sha256").update(realPath(file)).digest("hex");
42
+ store = new WorkListStore({ base: opts.base, project, policy: opts.policy });
43
+ state = join(store.dir, "import-state.json");
44
+ key = createHash("sha256").update(JSON.stringify([sourceMachine, aliases, project])).digest("hex");
45
+ cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
46
+ const where = `project IN (${aliases.map(() => "?").join(",")})`;
47
+ const max = db.query(`SELECT MAX(id) AS id FROM work_state_entries WHERE ${where}`).get(...aliases) as { id: number | null };
48
+ if ((max.id ?? 0) < cursor) cursor = 0;
49
+ const rows = db.query(`SELECT id, list_name, fields, created_at_epoch${origin ? ", origin_device_id, origin_local_id" : ", NULL AS origin_device_id, NULL AS origin_local_id"} FROM work_state_entries WHERE ${where} AND id > ? ORDER BY id LIMIT ?`)
50
+ .all(...aliases, cursor, limit) as { id: number; list_name: string; fields: string; created_at_epoch: number; origin_device_id: string | null; origin_local_id: string | null }[];
51
+ result.read = rows.length; result.more = rows.length === limit;
52
+ for (const row of rows) {
53
+ try {
54
+ let fields: WorkListFields;
55
+ try { fields = JSON.parse(row.fields) as WorkListFields; } catch { result.skipped.invalid++; cursor = row.id; continue; }
56
+ const synced = typeof row.origin_device_id === "string" && !!row.origin_device_id && typeof row.origin_local_id === "string" && !!row.origin_local_id;
57
+ const written = await store.write(row.list_name, fields, { source: "claude-mem/work_state", id: synced ? row.origin_local_id! : String(row.id), machine: synced ? row.origin_device_id! : sourceMachine, at: row.created_at_epoch });
58
+ if (!written.already) result.added++;
59
+ } catch (error) {
60
+ if (error instanceof Error && error.message === "invalid work-list input") result.skipped.invalid++;
61
+ else if (error instanceof Error && error.message === "work-list privacy policy refused the write") result.skipped.policy++;
62
+ else throw error; // Do not advance a receipt conflict or a failed append.
63
+ }
64
+ cursor = row.id;
65
+ }
66
+ } finally {
67
+ db.close();
68
+ result.cursor = cursor;
69
+ if (state) writeCursor(state, key, cursor, opts.log ?? console.error);
70
+ store?.close();
71
+ }
72
+ return result;
73
+ }
15
74
 
16
75
  /** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
17
76
  function readCursors(file: string): Record<string, number> {
@@ -97,10 +97,17 @@ function writeSources(memoryDir: string, journal: MigrationJournal): void {
97
97
  { schema: "agent-mem.migration-sources/v1", sources: journal.sources, legacyCommit: journal.legacyCommit });
98
98
  }
99
99
 
100
+ function refuseShardSelection(memoryDir: string): void {
101
+ try { lstatSync(join(memoryDir, "shards.json")); }
102
+ catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; }
103
+ throw new Error("ordinary generation migration is unavailable after shard migration starts; inspect amem status and use amem migrate --shards --resume; the existing selector was retained");
104
+ }
105
+
100
106
  export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
101
107
  if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
102
108
  if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
103
109
  const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
110
+ refuseShardSelection(memoryDir);
104
111
  const selection = readSelector(memoryDir);
105
112
  // Explicit undo reads only the selector: broken/missing G must not make the escape back to v1 impossible.
106
113
  const selectedG = selection.selector?.kind === "generation" ? selection.selector.generation : undefined;
@@ -111,6 +118,7 @@ export async function migrate(options: MigrateOptions): Promise<MigrationResult>
111
118
  return { mode: options.mode, profile: initial, counts: await counts(memoryDir, options.generation ?? initial.generation, (options.now ?? Date.now)()), consequence };
112
119
  }
113
120
  return withProfileLock(memoryDir, async () => {
121
+ refuseShardSelection(memoryDir);
114
122
  const selected = readSelector(memoryDir);
115
123
  if (selected.revision !== initial.selectorRevision) return changed();
116
124
  if (options.mode === "deactivate") {
@@ -1,5 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
- import { chmodSync } from "node:fs";
2
+ import { chmodSync, lstatSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
 
5
5
  export const MUTATION_BUSY_TIMEOUT_MS = 3000;
@@ -20,3 +20,15 @@ export function acquireMutation(dir: string): (() => void) | undefined {
20
20
  catch { db.close(); return undefined; }
21
21
  return () => { try { db.run("ROLLBACK"); } finally { db.close(); } };
22
22
  }
23
+
24
+ /** Short local admission fence, never held across sync/network or data mutations. */
25
+ export function readerAdmissionDatabase(dir: string): Database {
26
+ const file = join(dir, "reader-admission.db");
27
+ for (const suffix of ["", "-wal", "-shm", "-journal"]) {
28
+ const stat = lstatSync(file + suffix, { throwIfNoEntry: false });
29
+ if (stat && (stat.isSymbolicLink() || !stat.isFile())) throw new Error("reader admission fence is not a regular file");
30
+ }
31
+ const db = new Database(file, { create: true });
32
+ try { chmodSync(file, 0o600); db.run(`PRAGMA busy_timeout = ${MUTATION_BUSY_TIMEOUT_MS}`); return db; }
33
+ catch (error) { db.close(); throw error; }
34
+ }
@@ -118,7 +118,7 @@ export interface GenerationOpContract extends Omit<OpContract, "implemented" | "
118
118
  implemented: true;
119
119
  generationOnly: true;
120
120
  targetSemantics: "exact-entry-v1";
121
- validator: "validatePruneRow";
121
+ validator: "validatePruneRow" | "validateVaultLinkRow";
122
122
  validation: readonly string[];
123
123
  payloadSchema: {
124
124
  version: 1;
@@ -156,4 +156,21 @@ export const PRUNE_GENERATION_CONTRACT: GenerationOpContract = Object.freeze<Gen
156
156
  crashBoundary: "fsync exact local sealed plan, mutation fence, revalidate proofs/policy, append+fsync complete row then refresh; per-row atomicity; retry skips durable IDs; incomplete own tail completes only from identical persisted-plan prefix; mismatched tail refuses",
157
157
  fixtures: ["test/generation-prune.test.ts#only explicit note expires or demote settle by values can prove expiry", "test/generation-prune.test.ts#exact-entry reducer waits for witnesses and cannot be reversed by clock or later deadlines", "test/generation-prune.test.ts#prune partitions private target or basis locally and public attestations bind only public rows", "test/generation-prune.test.ts#prune row shape hashes canonical time ordering placement and byte bounds are strict"],
158
158
  });
159
- export const GENERATION_OP_CONTRACTS: Readonly<Record<"prune", GenerationOpContract>> = Object.freeze({ prune: PRUNE_GENERATION_CONTRACT });
159
+ export const VAULT_LINK_GENERATION_CONTRACT: GenerationOpContract = Object.freeze<GenerationOpContract>({
160
+ status: "truth-changing", implemented: true, generationOnly: true, targetSemantics: "exact-entry-v1", validator: "validateVaultLinkRow",
161
+ validation: ["exact string-valued keys; no additional fields; canonical UTC and bounded metadata", "id is canonical complete payload hash excluding id", "bound generation and local authored machine segment must match", "projection requires exact full target id and authored row hash in the same project"],
162
+ payloadVersion: "agent-mem.vault-link/v1", required: ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"],
163
+ payloadSchema: { version: 1, required: ["schema", "op", "generation", "id", "at", "machine", "actor", "placement", "target", "rowHash", "project", "path"], additionalProperties: false, maxUtf8Bytes: 4096, fields: {
164
+ schema: { type: "string", values: ["agent-mem.vault-link/v1"] }, op: { type: "string", values: ["vault_link"] },
165
+ generation: generationId, id: generationId, at: canonicalUtc, machine: { type: "string", pattern: "^[a-f0-9]{16}$" },
166
+ actor: { type: "string", values: ["person"] }, placement: { type: "string", values: ["local"] }, target: generationId, rowHash: authoredHash,
167
+ project: { type: "string" }, path: { type: "string" },
168
+ } },
169
+ immutableTargets: "full exact note id and SHA-256 of the complete canonical authored row; project must match; no aliases, promotion ancestry or replacements",
170
+ placement: "generation local segment only; vault paths are machine-local metadata and never sync; shard migration carries the row to its exact target owner",
171
+ precedence: "append-only links suppress only their witnessed exact entry from future candidates; independent replacement ids survive; malformed or missing witnesses cannot suppress",
172
+ identity: "sha256(canonical complete vault-link payload excluding id), first 32 hex; generation, target id, full witness hash and path participate",
173
+ crashBoundary: "mutation fence and exact id/hash recheck; refuse an incomplete prior tail; append+fsync then derived refresh; complete rows replay, incomplete tails require person inspection",
174
+ fixtures: ["test/vault.test.ts#vault links refuse shared-v1 and bind exact generation entries, survive rebuild, and never touch v1 logs", "test/vault.test.ts#vault link validation rejects changed payload hashes, foreign generation and public placement", "test/vault.test.ts#vault links retain their exact target through curated shard migration"],
175
+ });
176
+ export const GENERATION_OP_CONTRACTS: Readonly<Record<"prune" | "vault_link", GenerationOpContract>> = Object.freeze({ prune: PRUNE_GENERATION_CONTRACT, vault_link: VAULT_LINK_GENERATION_CONTRACT });