@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.
@@ -0,0 +1,179 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { appendFileSync, existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, type BoundProfile, type GenerationSeal } from "./generation.ts";
5
+
6
+ /**
7
+ * #15 T1: the per-project shard layout under the user home. One shard per project key at `projects/<key-hash>/`;
8
+ * the machine scope (capture tier, spool, per-machine config) lives one level down at `machine/<id>/`, so the
9
+ * layout already matches the #15 scope table (machine scope never syncs) while D1's `projects/<key-hash>/` stays
10
+ * the shard root the later phases put the curated logs and index into. Each shard holds its own locks (its own
11
+ * sqlite files), so two projects never contend.
12
+ *
13
+ * Identity (D2): the project key is `memoryProjectKey` (the normalized origin URL), unchanged; a worktree, a
14
+ * submodule and the main checkout share the repository's key, and two unrelated same-named repositories without a
15
+ * remote get distinct path-hash keys, flagged `local` in the shard's `project.json` and in the catalog. A
16
+ * person-reviewed alias op maps a renamed, moved or claude-mem-imported key onto the canonical one; aliases are
17
+ * append-only ops in `aliases.jsonl`, resolved one hop, and never merged automatically on a name match.
18
+ */
19
+
20
+ export const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
21
+
22
+ /** The directory name of one project's shard: a hash, since keys are URLs and path-hash forms. */
23
+ export function shardKeyHash(key: string): string {
24
+ return createHash("sha256").update(key).digest("hex").slice(0, 16);
25
+ }
26
+
27
+ /** A key in the no-remote path-hash form (`chain@hash8`) is this machine's only; the catalog flags it. */
28
+ export const isLocalKey = (key: string): boolean => /.+@[0-9a-f]{8}$/.test(key);
29
+
30
+ export function shardDir(base: string, key: string): string {
31
+ return join(base, "projects", shardKeyHash(key));
32
+ }
33
+
34
+ /** The machine scope of one shard: capture.db, the spool and the per-machine config. */
35
+ export function shardMachineDir(base: string, key: string, machine: string): string {
36
+ return join(shardDir(base, key), "machine", machine);
37
+ }
38
+
39
+ export interface ShardDescriptor { schema: typeof SHARD_SCHEMA; key: string; local: boolean }
40
+
41
+ /**
42
+ * The shard's own record of its key (the directory name is a hash). Created once, race-safe: every writer links
43
+ * its own temp file into place and the winner's content stands, exactly like `machine.json`.
44
+ */
45
+ export function ensureShardDescriptor(base: string, key: string): ShardDescriptor {
46
+ const dir = shardDir(base, key);
47
+ const file = join(dir, "project.json");
48
+ const read = (): ShardDescriptor | undefined => {
49
+ try {
50
+ const d = JSON.parse(readFileSync(file, "utf8")) as ShardDescriptor;
51
+ if (d?.schema === SHARD_SCHEMA && d.key === key && typeof d.local === "boolean") return d;
52
+ } catch { /* none, or unreadable */ }
53
+ return undefined;
54
+ };
55
+ const found = read();
56
+ if (found) return found;
57
+ const descriptor: ShardDescriptor = { schema: SHARD_SCHEMA, key, local: isLocalKey(key) };
58
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
59
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
60
+ writeFileSync(tmp, JSON.stringify(descriptor) + "\n", { mode: 0o600 });
61
+ try { linkSync(tmp, file); } catch { /* another process created it: its content wins */ } finally { rmSync(tmp, { force: true }); }
62
+ const won = read();
63
+ if (!won) throw new Error(`${file} holds no valid shard descriptor; remove it and a new one is made`);
64
+ return won;
65
+ }
66
+
67
+ /** Every shard's descriptor, for the catalog. A shard without a readable descriptor is skipped, never fatal. */
68
+ export function readShardDescriptor(base: string, hash: string): ShardDescriptor | undefined {
69
+ try {
70
+ const d = JSON.parse(readFileSync(join(base, "projects", hash, "project.json"), "utf8")) as ShardDescriptor;
71
+ if (d?.schema === SHARD_SCHEMA && typeof d.key === "string" && typeof d.local === "boolean") return d;
72
+ } catch { /* absent or unreadable */ }
73
+ return undefined;
74
+ }
75
+
76
+ export interface AliasOp { schema: typeof SHARD_SCHEMA; op: "alias"; at: string; machine: string; alias: string; key: string; actor: string }
77
+
78
+ export function aliasLogPath(base: string): string {
79
+ return join(base, "aliases.jsonl");
80
+ }
81
+
82
+ /**
83
+ * One person-reviewed alias op, appended to `aliases.jsonl`. An alias never chains (its target must not itself be
84
+ * an alias), and a machine that races the append keeps both ops: the latest one wins at read time.
85
+ */
86
+ export function appendAlias(base: string, input: { alias: string; key: string; actor: string; machine: string; at?: string }): AliasOp {
87
+ const alias = input.alias.trim(), key = input.key.trim();
88
+ if (!alias || !key) throw new Error("an alias needs both keys");
89
+ if (alias === key) throw new Error("an alias cannot target itself");
90
+ if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
91
+ // The other direction too, using only the latest op per alias: a target abandoned by a later retarget is free again.
92
+ const currentTargets = new Set([...new Map(listAliases(base).map((op) => [op.alias, op.key])).values()]);
93
+ if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
94
+ const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
95
+ mkdirSync(base, { recursive: true, mode: 0o700 });
96
+ appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
97
+ return op;
98
+ }
99
+
100
+ /** Every alias op in order; a malformed line is skipped, never fatal. */
101
+ export function listAliases(base: string): AliasOp[] {
102
+ let text = "";
103
+ try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
104
+ const ops: AliasOp[] = [];
105
+ for (const line of text.split("\n").filter(Boolean)) {
106
+ try {
107
+ const op = JSON.parse(line) as AliasOp;
108
+ if (op?.schema === SHARD_SCHEMA && op.op === "alias" && typeof op.alias === "string" && typeof op.key === "string") ops.push(op);
109
+ } catch { /* a malformed line is skipped */ }
110
+ }
111
+ return ops;
112
+ }
113
+
114
+ /** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). */
115
+ export function resolveAlias(base: string, key: string): string {
116
+ const ops = listAliases(base).filter((op) => op.alias === key);
117
+ return ops.length ? ops[ops.length - 1]!.key : key;
118
+ }
119
+
120
+ /** `memoryBase(home)` plus the shard of `key` after alias resolution. */
121
+ export function captureShardDir(base: string, key: string, machine: string): string {
122
+ return shardMachineDir(base, resolveAlias(base, key), machine);
123
+ }
124
+
125
+ export const SHARDED_SCHEMA = "agent-mem.shards/v1";
126
+ export interface ShardedSelection { schema: typeof SHARDED_SCHEMA; generation: string; phase: "active"; selectorRevision: string }
127
+
128
+ export function userShardDir(base: string): string {
129
+ return join(base, "user");
130
+ }
131
+
132
+ export function readShardedSelection(memoryDir: string): ShardedSelection {
133
+ const value = readProfileJSON(join(memoryDir, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
134
+ if (!value || value.schema !== SHARDED_SCHEMA || !validGeneration(value.generation) || value.phase !== "active" || typeof value.selectorRevision !== "string") throw new Error("no active curated shard migration");
135
+ return { schema: SHARDED_SCHEMA, generation: value.generation, phase: "active", selectorRevision: value.selectorRevision };
136
+ }
137
+
138
+ /** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
139
+ * ponytail: same-user concurrent parent replacement needs descriptor-relative opens; fixed preflight does not claim that fence.
140
+ */
141
+ export function assertCuratedShardPaths(memoryDir: string, opts: { project?: string; user?: boolean }): void {
142
+ const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
143
+ const check = (path: string, directory: boolean) => {
144
+ try {
145
+ const stat = lstatSync(path);
146
+ if (stat.isSymbolicLink() || (directory ? !stat.isDirectory() : !stat.isFile())) throw new Error("curated shard path is not a regular owned path");
147
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
148
+ };
149
+ if (opts.user !== true) check(join(memoryDir, "projects"), true);
150
+ check(dir, true);
151
+ for (const folder of ["log", "local", "legacy", "legacy/log", "legacy/local", "legacy-main", "legacy-main/log", "legacy-main/local"]) check(join(dir, folder), true);
152
+ 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);
153
+ for (const database of ["index.db", "search.db", "mutation-lock.db"]) for (const suffix of ["", "-wal", "-shm", "-journal"]) check(join(dir, database + suffix), false);
154
+ }
155
+
156
+ /** Open one already-sealed shard without consulting the legacy home or the catalog. */
157
+ export function resolveShardProfile(memoryDir: string, opts: { project?: string; user?: boolean }): BoundProfile {
158
+ if (opts.user === true && opts.project !== undefined || opts.user !== true && !opts.project) throw new Error("choose exactly one project or the user shard");
159
+ assertCuratedShardPaths(memoryDir, opts);
160
+ const selection = readShardedSelection(memoryDir), generation = selection.generation;
161
+ const dir = opts.user === true ? userShardDir(memoryDir) : shardDir(memoryDir, opts.project!);
162
+ if (opts.project !== undefined && readShardDescriptor(memoryDir, shardKeyHash(opts.project))?.key !== opts.project) throw new Error("curated shard project identity mismatch");
163
+ const expected = generationDescriptor(generation);
164
+ if (canonicalJSON(readProfileJSON(join(dir, "generation.json"), 4096)) !== canonicalJSON(expected)) throw new Error("curated shard generation metadata mismatch");
165
+ const sources = readProfileJSON(join(dir, "migration-sources.json")) as { schema?: unknown; sources?: unknown; legacyCommit?: unknown } | undefined;
166
+ const seal = readProfileJSON(join(dir, "generation-seal.json"), 4096) as GenerationSeal | undefined;
167
+ if (!sources || sources.schema !== "agent-mem.migration-sources/v1" || !Array.isArray(sources.sources)
168
+ || !seal || seal.schema !== "agent-mem.generation-seal/v1" || seal.generation !== generation
169
+ || seal.descriptorHash !== hashJSON(expected) || seal.sourceDigest !== hashJSON({ sources: sources.sources, legacyCommit: sources.legacyCommit })) throw new Error("curated shard source seal mismatch");
170
+ const rootMachine = readProfileJSON(join(memoryDir, "machine.json"), 4096) as { id?: unknown } | undefined;
171
+ const shardMachine = readProfileJSON(join(dir, "machine.json"), 4096) as { id?: unknown } | undefined;
172
+ if (typeof rootMachine?.id !== "string" || !/^[a-f0-9]{16}$/.test(rootMachine.id) || rootMachine.id !== shardMachine?.id) throw new Error("curated shard machine identity mismatch");
173
+ const selector = readSelector(memoryDir);
174
+ if (selector.revision !== selection.selectorRevision) throw new Error("curated shard selector changed; resume or inspect migration state");
175
+ const legacyMainHome = join(dir, "legacy-main");
176
+ const hasLegacyMain = existsSync(legacyMainHome) || sources.sources.some(source => !!source && typeof source === "object" && "source" in source && source.source === "legacy-main");
177
+ return { kind: "generation", home: memoryDir, dir, generation, selectorRevision: selector.revision,
178
+ legacyHome: join(dir, "legacy"), ...(hasLegacyMain ? { legacyMainHome } : {}), sharded: true, ...(opts.user === true ? { shardUser: true } : { shardKey: opts.project! }) };
179
+ }