@staix/agent-mem 0.3.1 → 0.4.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.
@@ -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 { listAliases, 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,64 @@ 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 = [...new Map(listAliases(opts.base).map(op => [op.alias, op.key]))]
31
+ .filter(([, key]) => key === project).map(([alias]) => alias).sort();
32
+ const result: WorkStateImportResult = { read: 0, added: 0, skipped: { invalid: 0, policy: 0 }, more: false, cursor: 0 };
33
+ if (!aliases.length) return result;
34
+ const db = new Database(file, { readonly: true });
35
+ let store: WorkListStore | undefined;
36
+ let state: string | undefined, key = "", cursor = 0;
37
+ try {
38
+ db.run(`PRAGMA busy_timeout = ${CLAUDE_MEM_BUSY_TIMEOUT_MS}`);
39
+ if (!db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'work_state_entries'").get()) return result;
40
+ const columns = new Set((db.query("PRAGMA table_info(work_state_entries)").all() as { name: string }[]).map(row => row.name));
41
+ const origin = columns.has("origin_device_id") && columns.has("origin_local_id");
42
+ const sourceMachine = createHash("sha256").update(realPath(file)).digest("hex");
43
+ store = new WorkListStore({ base: opts.base, project, policy: opts.policy });
44
+ state = join(store.dir, "import-state.json");
45
+ key = createHash("sha256").update(JSON.stringify([sourceMachine, aliases, project])).digest("hex");
46
+ cursor = opts.restart ? 0 : readCursors(state)[key] ?? 0;
47
+ const where = `project IN (${aliases.map(() => "?").join(",")})`;
48
+ const max = db.query(`SELECT MAX(id) AS id FROM work_state_entries WHERE ${where}`).get(...aliases) as { id: number | null };
49
+ if ((max.id ?? 0) < cursor) cursor = 0;
50
+ 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 ?`)
51
+ .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 }[];
52
+ result.read = rows.length; result.more = rows.length === limit;
53
+ for (const row of rows) {
54
+ try {
55
+ let fields: WorkListFields;
56
+ try { fields = JSON.parse(row.fields) as WorkListFields; } catch { result.skipped.invalid++; cursor = row.id; continue; }
57
+ const synced = typeof row.origin_device_id === "string" && !!row.origin_device_id && typeof row.origin_local_id === "string" && !!row.origin_local_id;
58
+ 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 });
59
+ if (!written.already) result.added++;
60
+ } catch (error) {
61
+ if (error instanceof Error && error.message === "invalid work-list input") result.skipped.invalid++;
62
+ else if (error instanceof Error && error.message === "work-list privacy policy refused the write") result.skipped.policy++;
63
+ else throw error; // Do not advance a receipt conflict or a failed append.
64
+ }
65
+ cursor = row.id;
66
+ }
67
+ } finally {
68
+ db.close();
69
+ result.cursor = cursor;
70
+ if (state) writeCursor(state, key, cursor, opts.log ?? console.error);
71
+ store?.close();
72
+ }
73
+ return result;
74
+ }
15
75
 
16
76
  /** Machine-local progress is disposable; malformed/unreadable state simply replays the idempotent import. */
17
77
  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 });
@@ -0,0 +1,120 @@
1
+ import { posix } from "node:path";
2
+ import { types } from "node:util";
3
+ import { looksSecret } from "./screen.ts";
4
+ import { isDenied } from "./platform.ts";
5
+ import { nodePath, pathCandidates } from "./project.ts";
6
+
7
+ /** True means the complete value is public. Hosts may narrow, never replace, standalone policy. */
8
+ export interface MemoryPolicy { screen(value: unknown): Promise<boolean> }
9
+ export interface StandalonePolicyOptions {
10
+ denyPaths?: readonly string[];
11
+ /** Case-insensitive literal substrings, not executable regular expressions. */
12
+ piiPatterns?: readonly string[];
13
+ host?: MemoryPolicy;
14
+ }
15
+
16
+ const MAX_DEPTH = 16, MAX_NODES = 4096, MAX_BYTES = 256 * 1024, MAX_STRING_BYTES = 16 * 1024;
17
+ const fold = (text: string) => text.normalize("NFKC").replace(/\p{Default_Ignorable_Code_Point}/gu, "").toLowerCase().replace(/\\/g, "/");
18
+ const flags = new Set(["private", "pii", "privateturn", "isprivate", "ispii"]);
19
+
20
+ /** Inspect descriptors, not getters/toJSON, and build the only value a host is allowed to see. */
21
+ function snapshot(value: unknown): { value: unknown; texts: string[] } | undefined {
22
+ let nodes = 0, bytes = 0;
23
+ const seen = new Set<object>(), texts: string[] = [];
24
+ const add = (text: string) => {
25
+ const length = Buffer.byteLength(text);
26
+ if (length > MAX_STRING_BYTES || (bytes += length) > MAX_BYTES) throw new Error("policy bound");
27
+ texts.push(text);
28
+ };
29
+ const visit = (input: unknown, depth: number): unknown => {
30
+ if (depth > MAX_DEPTH || ++nodes > MAX_NODES) throw new Error("policy bound");
31
+ if (input === null || typeof input === "boolean") { bytes += 5; return input; }
32
+ if (typeof input === "number") { if (!Number.isFinite(input)) throw new Error("policy shape"); bytes += 24; return input; }
33
+ if (typeof input === "string") { add(input); return input; }
34
+ if (!input || typeof input !== "object" || types.isProxy(input) || seen.has(input)) throw new Error("policy shape");
35
+ const array = Array.isArray(input), prototype = Object.getPrototypeOf(input);
36
+ if (array ? prototype !== Array.prototype : prototype !== Object.prototype && prototype !== null) throw new Error("policy shape");
37
+ if (Object.getOwnPropertySymbols(input).length) throw new Error("policy shape");
38
+ seen.add(input);
39
+ try {
40
+ const descriptors = Object.getOwnPropertyDescriptors(input);
41
+ if (array) {
42
+ const length = descriptors.length?.value;
43
+ if (!Number.isSafeInteger(length) || length > MAX_NODES || Object.keys(descriptors).length !== length + 1) throw new Error("policy shape");
44
+ const output: unknown[] = [];
45
+ for (let index = 0; index < length; index++) {
46
+ const descriptor = descriptors[String(index)];
47
+ if (!descriptor || !("value" in descriptor) || !descriptor.enumerable) throw new Error("policy shape");
48
+ output.push(visit(descriptor.value, depth + 1));
49
+ }
50
+ return output;
51
+ }
52
+ const output: Record<string, unknown> = Object.create(null);
53
+ for (const [key, descriptor] of Object.entries(descriptors)) {
54
+ if (!("value" in descriptor) || !descriptor.enumerable) throw new Error("policy shape");
55
+ add(key);
56
+ const flag = fold(key).replace(/[_-]/g, "");
57
+ if (flags.has(flag) && descriptor.value !== false) throw new Error("private value");
58
+ const item = visit(descriptor.value, depth + 1);
59
+ // A secret may be split between a metadata key and its scalar value.
60
+ if (typeof item === "string") add(`${key}: ${item}`);
61
+ output[key] = item;
62
+ }
63
+ return output;
64
+ } finally { seen.delete(input); }
65
+ };
66
+ try { const copy = visit(value, 0); return bytes <= MAX_BYTES ? { value: copy, texts } : undefined; }
67
+ catch { return undefined; }
68
+ }
69
+
70
+ function literals(value: unknown, label: string): string[] {
71
+ if (value === undefined) return [];
72
+ if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype || value.length > 64 || Object.getOwnPropertySymbols(value).length) throw new Error(`invalid ${label}`);
73
+ const descriptors = Object.getOwnPropertyDescriptors(value), result: string[] = [];
74
+ if (Object.keys(descriptors).length !== value.length + 1) throw new Error(`invalid ${label}`);
75
+ for (let i = 0; i < value.length; i++) {
76
+ const item = descriptors[String(i)];
77
+ if (!item || !("value" in item) || typeof item.value !== "string" || !item.value.trim() || Buffer.byteLength(item.value) > 256 || /[\x00-\x1f\x7f]/.test(item.value)) throw new Error(`invalid ${label}`);
78
+ const normalized = fold(item.value);
79
+ if (!normalized.trim()) throw new Error(`invalid ${label}`);
80
+ result.push(normalized);
81
+ }
82
+ return [...new Set(result)];
83
+ }
84
+
85
+ function defaultPii(text: string): boolean {
86
+ if (/\b(?:\d{6}-\d{7}|\d{3}-\d{2}-\d{4})\b/.test(text)) return true;
87
+ // Bound each email candidate before matching; arbitrary user regexes are never run.
88
+ for (const token of text.split(/[\s<>()\[\]{}",;:]+/)) if (token.includes("@")) {
89
+ if (token.length > 320 || /[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/i.test(token)) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ /** No filesystem, model, network or storage work occurs while constructing or screening this policy. */
95
+ export function createStandalonePolicy(options: StandalonePolicyOptions = {}): MemoryPolicy {
96
+ if (!options || typeof options !== "object" || types.isProxy(options) || Object.getPrototypeOf(options) !== Object.prototype || Object.getOwnPropertySymbols(options).length) throw new Error("invalid standalone policy options");
97
+ const descriptors = Object.getOwnPropertyDescriptors(options);
98
+ for (const [key, descriptor] of Object.entries(descriptors)) if (!["denyPaths", "piiPatterns", "host"].includes(key) || !("value" in descriptor)) throw new Error("invalid standalone policy options");
99
+ const deny = literals(descriptors.denyPaths?.value, "deny paths").map(path => posix.normalize(path));
100
+ const pii = literals(descriptors.piiPatterns?.value, "PII patterns");
101
+ const host = descriptors.host?.value as MemoryPolicy | undefined;
102
+ if (host !== undefined && (!host || typeof host.screen !== "function")) throw new Error("invalid host policy");
103
+ return {
104
+ async screen(value: unknown): Promise<boolean> {
105
+ try {
106
+ const complete = snapshot(value);
107
+ if (!complete) return false;
108
+ for (const text of complete.texts) {
109
+ if (looksSecret(text) || looksSecret(text.replace(/["']/g, ""))) return false;
110
+ const normalized = fold(text);
111
+ if (defaultPii(normalized) || pii.some(pattern => normalized.includes(pattern))) return false;
112
+ const candidates = normalized.includes("/") || /\.[a-z]/.test(normalized) ? pathCandidates(normalized) : [];
113
+ const paths = [normalized, nodePath(normalized), ...candidates, ...normalized.split(/[\s"'`(){}\[\],;:=<>]+/)];
114
+ for (const path of paths) if (path && isDenied(posix.normalize(path), deny)) return false;
115
+ }
116
+ return host === undefined || await host.screen(complete.value) === true;
117
+ } catch { return false; }
118
+ },
119
+ };
120
+ }
@@ -38,7 +38,8 @@ export function projectChain(cwd: string): string[] {
38
38
  /**
39
39
  * #258: the name hub memory files a project's entries under. The project's `origin` remote, normalized to host and
40
40
  * path (`github.com/owner/repo`), is the same on every machine and unique; without one, the git chain plus a hash of
41
- * the directory keeps two unrelated repositories that share a name apart (that key is this machine's only).
41
+ * the main working root keeps two unrelated repositories that share a name apart while the main checkout, a
42
+ * subdirectory and a worktree resolve to one key (that key is this machine's only, and root-invoked keys stay stable).
42
43
  */
43
44
  export function memoryProjectKey(cwd: string): string {
44
45
  const url = memoryGitSync(["-C", cwd, "remote", "get-url", "origin"], { encoding: "utf8" }).stdout?.trim() ?? "";
@@ -56,6 +57,26 @@ export function memoryProjectKey(cwd: string): string {
56
57
  // GitHub serves one repository under every form, and its owner and repository names ignore case.
57
58
  return host === "github.com" ? `${host}/${m[1].replace(/^~\/?/, "").toLowerCase()}` : `${host}/${m[1]}`;
58
59
  }
60
+ // No usable remote: hash the main working root, not the cwd or git dir. Root-invoked keys therefore keep their
61
+ // pre-#15 value while the main checkout, a subdirectory and a worktree resolve to ONE key (#15 D2/AC2). A bare
62
+ // repository has no working root: its own directory is the stable identity for it and its worktrees.
63
+ const common = memoryGitSync(["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).stdout?.trim() ?? "";
64
+ if (common) {
65
+ let realCommon = common;
66
+ try { realCommon = realPath(common); } catch { /* as given */ }
67
+ const bare = (memoryGitSync(["--git-dir", realCommon, "rev-parse", "--is-bare-repository"], { encoding: "utf8" }).stdout?.trim() ?? "") === "true";
68
+ let root = realCommon;
69
+ if (!bare) {
70
+ const configured = memoryGitSync(["--git-dir", realCommon, "config", "--path", "core.worktree"], { encoding: "utf8" }).stdout?.trim() ?? "";
71
+ const top = memoryGitSync(["-C", cwd, "rev-parse", "--show-toplevel"], { encoding: "utf8" }).stdout?.trim() ?? "";
72
+ // Only an actual `.git` directory makes its parent the working root. A separate git dir shares its parent
73
+ // with unrelated repositories, so the real worktree root (or, as a last resort, the unique git dir) is safer.
74
+ root = configured ? resolve(realCommon, configured) : basename(realCommon) === ".git" ? dirname(realCommon) : top || realCommon;
75
+ }
76
+ let realRoot = root;
77
+ try { realRoot = realPath(root); } catch { /* as given */ }
78
+ return `${projectChain(realRoot).join("/")}@${createHash("sha256").update(realRoot).digest("hex").slice(0, 8)}`;
79
+ }
59
80
  let real = cwd;
60
81
  try { real = realPath(cwd); } catch { /* as given */ }
61
82
  return `${projectChain(cwd).join("/")}@${createHash("sha256").update(real).digest("hex").slice(0, 8)}`;