@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.
- package/README.md +76 -6
- package/package.json +4 -3
- package/src/cli.ts +126 -15
- package/src/commands-table.ts +16 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +194 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +22 -14
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +557 -0
- package/src/memory/catalog.ts +135 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +59 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +244 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +213 -0
- package/src/memory/store.ts +376 -67
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +331 -0
|
@@ -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
|
+
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -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
|
|
88
|
-
const
|
|
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
|
|
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(
|
|
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;
|
package/src/memory/generation.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
+
}
|
package/src/memory/home.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/memory/import.ts
CHANGED
|
@@ -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> {
|
package/src/memory/migrate.ts
CHANGED
|
@@ -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") {
|
package/src/memory/mutation.ts
CHANGED
|
@@ -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
|
|
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 });
|