@staix/agent-mem 0.7.0 → 0.9.1
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/.claude-plugin/plugin.json +1 -1
- package/README.md +14 -4
- package/package.json +1 -1
- package/src/cli.ts +86 -17
- package/src/commands-table.ts +15 -9
- package/src/index.ts +6 -0
- package/src/mcp-memory.ts +28 -1
- package/src/mcp.ts +1 -0
- package/src/memory/candidates.ts +86 -2
- package/src/memory/capture.ts +1 -1
- package/src/memory/commands.ts +2 -2
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/doctor.ts +11 -8
- package/src/memory/eval-fixture.ts +8 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval.ts +5 -3
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/import.ts +91 -0
- package/src/memory/platform.ts +17 -2
- package/src/memory/policy.ts +29 -9
- package/src/memory/project-export.ts +57 -22
- package/src/memory/project-import.ts +147 -0
- package/src/memory/shard-sync-format.ts +3 -4
- package/src/memory/shard-sync.ts +135 -17
- package/src/memory/store.ts +63 -9
- package/src/memory/sync-cadence.ts +15 -5
- package/src/memory/sync.ts +16 -4
- package/src/setup-toml.ts +5 -0
- package/src/setup.ts +6 -6
|
@@ -11,14 +11,14 @@ import { aliasedKeys, shardDir } from "./shard.ts";
|
|
|
11
11
|
import { WorkListStore } from "./work-lists.ts";
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* #15 T5 / D3: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as
|
|
15
|
-
* written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
|
|
14
|
+
* #15 T5 / D3 and F17: `amem project export <key> --to <file>`. One project's capture rows, curated notes, goals and work lists as a portable JSON bundle,
|
|
15
|
+
* JSONL stream or Markdown document, written once to a new file with mode 0600. It changes no content file of the home: it opens the capture databases read-only and the curated store only to
|
|
16
16
|
* read, and only derived files (the curated `index.db`, `search.db`, SQLite sidecars) may refresh, as they do for any read. A destination inside the home is refused.
|
|
17
17
|
*
|
|
18
18
|
* The bundle leaves the home, so it is screened like sync and promotion are: a secret-looking value is withheld, and so is anything the host policy (when
|
|
19
19
|
* one is passed) declines. `withheld` counts the capture rows, notes and goals left out; a work list the policy declines is left out silently by its own store.
|
|
20
|
-
* A forgotten project cannot be exported (the person's decision stands); an archived one can.
|
|
21
|
-
*
|
|
20
|
+
* A forgotten project cannot be exported (the person's decision stands); an archived one can. A failure removes the partial file it created.
|
|
21
|
+
* ponytail: JSONL capture, goal and work-list rows stream, but curated authored rows are materialized to preserve ids. Add a bounded authored-row iterator before raising shard limits.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
24
|
export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
|
|
@@ -27,8 +27,10 @@ export const EXPORT_SCHEMA = "agent-mem.project-export/v1";
|
|
|
27
27
|
* the file in the gap between them, which is the only way to show that O_EXCL, not the check, keeps an export from replacing someone else's file.
|
|
28
28
|
*/
|
|
29
29
|
export const exportHooks: { afterCheck?: (file: string) => void } = {};
|
|
30
|
+
export type ProjectExportFormat = "json" | "jsonl" | "markdown";
|
|
30
31
|
export interface ExportCounts { capture: number; curated: number; goals: number; workLists: number }
|
|
31
|
-
export interface ExportResult { file: string; project: string; counts: ExportCounts; withheld: number }
|
|
32
|
+
export interface ExportResult { file: string; project: string; format: ProjectExportFormat; counts: ExportCounts; withheld: number }
|
|
33
|
+
type ExportSection = keyof ExportCounts;
|
|
32
34
|
const PAGE = 500;
|
|
33
35
|
|
|
34
36
|
interface CaptureRowRaw { id: number; at: string; project: string; agent: string; event: string; tool: string | null; paths: string; status: string | null; excerpt: string }
|
|
@@ -47,8 +49,25 @@ function destination(base: string, to: string): string {
|
|
|
47
49
|
return join(parentReal, name);
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
+
function markdownInline(text: string): string {
|
|
53
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function markdownRecord(section: ExportSection, value: unknown): string {
|
|
57
|
+
const row = value as Record<string, unknown>;
|
|
58
|
+
const body = section === "capture" ? row.excerpt : section === "curated" ? row.text : undefined;
|
|
59
|
+
const identity = typeof row.id === "string" ? ` ${row.id}` : "";
|
|
60
|
+
const metadata = { ...row };
|
|
61
|
+
if (section === "capture") delete metadata.excerpt;
|
|
62
|
+
if (section === "curated") delete metadata.text;
|
|
63
|
+
const lines = [`### ${section}${identity}`, "", ` ${JSON.stringify(metadata)}`, ""];
|
|
64
|
+
if (typeof body === "string") lines.push(...body.split(/\r\n|\r|\n/).map(line => ` ${line}`), "");
|
|
65
|
+
return `${lines.join("\n")}\n`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function exportProject(base: string, key: string, opts: { to: string; policy?: MemoryPolicy; format?: ProjectExportFormat }): Promise<ExportResult> {
|
|
69
|
+
const project = requireShard(base, key), format = opts.format ?? "json";
|
|
70
|
+
if (!["json", "jsonl", "markdown"].includes(format)) throw new Error("export format must be json, jsonl or markdown");
|
|
52
71
|
if (projectState(base, project) === "forgotten") throw new Error(`project ${project} is forgotten: it cannot be exported`);
|
|
53
72
|
const file = destination(base, opts.to), policy = opts.policy ?? createStandalonePolicy();
|
|
54
73
|
exportHooks.afterCheck?.(file);
|
|
@@ -59,10 +78,24 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
59
78
|
try { fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
60
79
|
catch { throw new Error("the destination exists; export never overwrites"); }
|
|
61
80
|
const out = (text: string) => { writeSync(fd, text); };
|
|
81
|
+
const exportedAt = new Date().toISOString();
|
|
82
|
+
let first = true, section: ExportSection = "capture";
|
|
83
|
+
const begin = (jsonPrefix: string, next: ExportSection, heading: string) => {
|
|
84
|
+
section = next; first = true;
|
|
85
|
+
if (format === "json") out(jsonPrefix);
|
|
86
|
+
else if (format === "markdown") out(`\n## ${heading}\n\n`);
|
|
87
|
+
};
|
|
88
|
+
const emit = (value: unknown) => {
|
|
89
|
+
if (format === "json") out(`${first ? "" : ","}${JSON.stringify(value)}`);
|
|
90
|
+
else if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, type: section, value })}\n`);
|
|
91
|
+
else out(markdownRecord(section, value));
|
|
92
|
+
first = false;
|
|
93
|
+
};
|
|
62
94
|
const stores: { close(): void }[] = [];
|
|
63
95
|
try {
|
|
64
|
-
|
|
65
|
-
|
|
96
|
+
if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, exportedAt, type: "header", aliases: aliasedKeys(base, project) })}\n`);
|
|
97
|
+
else if (format === "markdown") out(`# Project export ${markdownInline(JSON.stringify(project))}\n\nExported at ${exportedAt}\n`);
|
|
98
|
+
begin(`{"schema":${JSON.stringify(EXPORT_SCHEMA)},"project":${JSON.stringify(project)},"exportedAt":${JSON.stringify(exportedAt)},"capture":[`, "capture", "Capture");
|
|
66
99
|
const shard = walkShards(base).find(candidate => candidate.key === project);
|
|
67
100
|
for (const dir of shard?.machineDirs ?? []) {
|
|
68
101
|
const dbFile = join(dir, "capture.db");
|
|
@@ -76,40 +109,42 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
76
109
|
after = r.id;
|
|
77
110
|
const row = { at: r.at, agent: r.agent, event: r.event, tool: r.tool, paths: JSON.parse(r.paths) as string[], status: r.status, excerpt: r.excerpt };
|
|
78
111
|
if (!await allowed(row)) { withheld++; continue; }
|
|
79
|
-
|
|
112
|
+
emit(row); counts.capture++;
|
|
80
113
|
}
|
|
81
114
|
if (rows.length < PAGE) break;
|
|
82
115
|
}
|
|
83
116
|
} finally { db.close(); }
|
|
84
117
|
}
|
|
85
|
-
|
|
118
|
+
begin(`],"curated":[`, "curated", "Curated notes");
|
|
86
119
|
const store = openCuratedForRead(base, project);
|
|
87
120
|
if (store) stores.push(store);
|
|
88
121
|
// The project's own rows and those of a key aliased to it; another project's shared note is not this project's and stays out of its bundle.
|
|
89
122
|
const keys = new Set([project, ...aliasedKeys(base, project)]);
|
|
90
|
-
|
|
123
|
+
const authored = format === "jsonl" ? new Map((store?.portableNotes(project) ?? []).map(row => [row.id, row])) : undefined;
|
|
91
124
|
for (const view of store?.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true }) ?? []) {
|
|
92
125
|
if (!keys.has(view.project)) continue;
|
|
93
|
-
|
|
94
|
-
|
|
126
|
+
const value = format === "jsonl" ? authored!.get(view.id) : view;
|
|
127
|
+
if (!value) throw new Error("exported note's authored row is unavailable");
|
|
128
|
+
if (!await allowed(value)) { withheld++; continue; }
|
|
129
|
+
emit(value); counts.curated++;
|
|
95
130
|
}
|
|
96
|
-
|
|
97
|
-
first = true;
|
|
131
|
+
begin(`],"goals":[`, "goals", "Goals");
|
|
98
132
|
for (const goal of store?.goals(project) ?? []) {
|
|
99
133
|
if (!keys.has(goal.project)) continue;
|
|
100
134
|
if (!await allowed(goal)) { withheld++; continue; }
|
|
101
|
-
|
|
135
|
+
emit(goal); counts.goals++;
|
|
102
136
|
}
|
|
103
|
-
|
|
104
|
-
first = true;
|
|
137
|
+
begin(`],"workLists":[`, "workLists", "Work lists");
|
|
105
138
|
// The bundle holds what the project's own reads show: the canonical work log and that of every key aliased to it (the same reconciliation the CLI, MCP and brief use).
|
|
106
139
|
const workKeys = [project, ...aliasedKeys(base, project)];
|
|
107
140
|
if (workKeys.some(key => existsSync(join(shardDir(base, key), "work")))) {
|
|
108
141
|
const work = new WorkListStore({ base, project, policy: allowed, aliases: workKeys.slice(1) });
|
|
109
142
|
stores.push({ close: () => work.close() });
|
|
110
|
-
for (const list of await work.read(undefined, true)) {
|
|
143
|
+
for (const list of await work.read(undefined, true)) { emit(list); counts.workLists++; }
|
|
111
144
|
}
|
|
112
|
-
out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
|
|
145
|
+
if (format === "json") out(`],"counts":${JSON.stringify(counts)},"withheld":${withheld}}\n`);
|
|
146
|
+
else if (format === "jsonl") out(`${JSON.stringify({ schema: EXPORT_SCHEMA, project, type: "counts", counts, withheld })}\n`);
|
|
147
|
+
else out(`\n## Counts\n\n${JSON.stringify({ counts, withheld })}\n`);
|
|
113
148
|
} catch (error) {
|
|
114
149
|
try { closeSync(fd); } catch { /* already closed */ }
|
|
115
150
|
rmSync(file, { force: true }); // the file is ours: O_EXCL created it, so a partial bundle is removed, never left behind
|
|
@@ -118,5 +153,5 @@ export async function exportProject(base: string, key: string, opts: { to: strin
|
|
|
118
153
|
for (const store of stores) { try { store.close(); } catch { /* best effort */ } }
|
|
119
154
|
}
|
|
120
155
|
closeSync(fd);
|
|
121
|
-
return { file, project, counts, withheld };
|
|
156
|
+
return { file, project, format, counts, withheld };
|
|
122
157
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { constants, createReadStream, fstatSync, openSync } from "node:fs";
|
|
3
|
+
import { createInterface } from "node:readline";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { legacyEntries } from "./bridge.ts";
|
|
6
|
+
import { stripPrivateDeep, stripPrivateSpans } from "./private-spans.ts";
|
|
7
|
+
import { EXPORT_SCHEMA } from "./project-export.ts";
|
|
8
|
+
import { aliasedKeys } from "./shard.ts";
|
|
9
|
+
import { looksSecret, type MemoryStore, type NoteInput } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export const PROJECT_IMPORT_MAX_BYTES = 64 * 1024 * 1024;
|
|
12
|
+
export const PROJECT_IMPORT_MAX_LINE_BYTES = 64 * 1024;
|
|
13
|
+
export const PROJECT_IMPORT_FUTURE_SKEW_MS = 5 * 60 * 1000;
|
|
14
|
+
const NOTE_KEYS = new Set(["schema", "id", "at", "machine", "op", "project", "scope", "tier", "kind", "peer", "title", "text", "task", "about", "supersedes", "promotedFrom", "source", "expires"]);
|
|
15
|
+
const OPERATIONS_WARNING = "Supersede, demote, settle and forget operations are not exported; re-import restores authored note rows only.";
|
|
16
|
+
|
|
17
|
+
type ImportedNote = { id: string; at: string; machine: string; note: NoteInput };
|
|
18
|
+
type Provenance = { source: Record<string, number>; peer: Record<string, number>; scope: Record<string, number> };
|
|
19
|
+
export interface ProjectImportResult {
|
|
20
|
+
applied: boolean;
|
|
21
|
+
project: string;
|
|
22
|
+
read: number;
|
|
23
|
+
added: number;
|
|
24
|
+
wouldAdd: number;
|
|
25
|
+
skipped: { invalid: number; policy: number; other: number };
|
|
26
|
+
provenance: Provenance;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function importedNote(value: unknown, projects: readonly string[]): ImportedNote | undefined {
|
|
31
|
+
try {
|
|
32
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || !Object.keys(value).every(key => NOTE_KEYS.has(key))) return undefined;
|
|
33
|
+
const bytes = Buffer.from(`${JSON.stringify(value)}\n`);
|
|
34
|
+
const [row] = legacyEntries([{ source: "legacy-local", path: "log/project-import.jsonl", bytes, offset: bytes.length,
|
|
35
|
+
hash: createHash("sha256").update(bytes).digest("hex"), local: false, tailBytes: 0 }], { strict: true });
|
|
36
|
+
if (row?.op !== "note" || !projects.includes(row.project)) return undefined;
|
|
37
|
+
const { id, at, machine, ...rest } = row;
|
|
38
|
+
const note = Object.fromEntries(Object.entries(rest).filter(([key]) => key !== "schema")) as NoteInput;
|
|
39
|
+
return { id, at, machine, note };
|
|
40
|
+
} catch { return undefined; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function increment(counts: Map<string, number>, value: string | undefined): void {
|
|
44
|
+
const key = value ?? "unspecified";
|
|
45
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function provenance(notes: Iterable<ImportedNote>): Provenance {
|
|
49
|
+
const source = new Map<string, number>(), peer = new Map<string, number>(), scope = new Map<string, number>();
|
|
50
|
+
for (const { note } of notes) {
|
|
51
|
+
increment(source, note.source);
|
|
52
|
+
increment(peer, note.peer);
|
|
53
|
+
increment(scope, note.scope);
|
|
54
|
+
}
|
|
55
|
+
return { source: Object.fromEntries(source), peer: Object.fromEntries(peer), scope: Object.fromEntries(scope) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function unchangedAfterNormalization(note: NoteInput): boolean {
|
|
59
|
+
return stripPrivateSpans(note.text).slice(0, 4000) === note.text
|
|
60
|
+
&& (typeof note.title !== "string" || stripPrivateSpans(note.title).slice(0, 200) === note.title);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Preview by default; confirm applies only after every row, relationship, identity and policy decision has passed without a curated write. */
|
|
64
|
+
export async function importProject(store: MemoryStore, opts: {
|
|
65
|
+
from: string;
|
|
66
|
+
project: string;
|
|
67
|
+
confirm?: boolean;
|
|
68
|
+
allowShared?: boolean;
|
|
69
|
+
policy: (note: NoteInput) => boolean | Promise<boolean>;
|
|
70
|
+
}): Promise<ProjectImportResult> {
|
|
71
|
+
const fd = openSync(opts.from, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
72
|
+
const stream = createReadStream(opts.from, { fd, encoding: "utf8" });
|
|
73
|
+
const result: ProjectImportResult = { applied: opts.confirm === true, project: opts.project, read: 0, added: 0, wouldAdd: 0,
|
|
74
|
+
skipped: { invalid: 0, policy: 0, other: 0 }, provenance: { source: {}, peer: {}, scope: {} }, warnings: [OPERATIONS_WARNING] };
|
|
75
|
+
let header = false, bytes = 0;
|
|
76
|
+
let aliases: string[] = [];
|
|
77
|
+
const pending = new Map<string, ImportedNote>(), accepted: ImportedNote[] = [];
|
|
78
|
+
try {
|
|
79
|
+
const stat = fstatSync(fd);
|
|
80
|
+
if (!stat.isFile() || stat.size > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import needs a regular JSONL file of at most 64 MiB");
|
|
81
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
82
|
+
try {
|
|
83
|
+
for await (const line of lines) {
|
|
84
|
+
bytes += Buffer.byteLength(line, "utf8") + 1;
|
|
85
|
+
if (bytes > PROJECT_IMPORT_MAX_BYTES) throw new Error("project import exceeded 64 MiB while reading");
|
|
86
|
+
if (Buffer.byteLength(line, "utf8") > PROJECT_IMPORT_MAX_LINE_BYTES) { result.skipped.invalid++; continue; }
|
|
87
|
+
if (!line.trim()) continue;
|
|
88
|
+
let row: Record<string, unknown>;
|
|
89
|
+
try {
|
|
90
|
+
const parsed: unknown = JSON.parse(line);
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error();
|
|
92
|
+
row = parsed as Record<string, unknown>;
|
|
93
|
+
} catch { result.skipped.invalid++; continue; }
|
|
94
|
+
if (row.schema !== EXPORT_SCHEMA || row.project !== opts.project) throw new Error("project import schema or project mismatch");
|
|
95
|
+
if (row.type === "header") {
|
|
96
|
+
if (header) throw new Error("project import has more than one header");
|
|
97
|
+
if (!Array.isArray(row.aliases) || !row.aliases.every(alias => typeof alias === "string")) throw new Error("project import aliases are invalid");
|
|
98
|
+
aliases = [...row.aliases] as string[];
|
|
99
|
+
const available = aliasedKeys(store.profile.home, opts.project);
|
|
100
|
+
if (aliases.some(alias => !available.includes(alias))) throw new Error("project import requires the exported aliases in the target home");
|
|
101
|
+
header = true; continue;
|
|
102
|
+
}
|
|
103
|
+
if (!header) throw new Error("project import needs its header first");
|
|
104
|
+
if (row.type === "counts") continue;
|
|
105
|
+
if (row.type !== "curated") { result.skipped.other++; continue; }
|
|
106
|
+
result.read++;
|
|
107
|
+
const imported = importedNote(row.value, [opts.project, ...aliases]);
|
|
108
|
+
if (!imported || Date.parse(imported.at) > store.readNow() + PROJECT_IMPORT_FUTURE_SKEW_MS
|
|
109
|
+
|| imported.note.scope === "shared" && opts.allowShared !== true || !unchangedAfterNormalization(imported.note)) {
|
|
110
|
+
result.skipped.invalid++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const prior = pending.get(imported.id);
|
|
114
|
+
if (prior) {
|
|
115
|
+
if (!isDeepStrictEqual(prior, imported)) throw new Error("project import has conflicting rows for one entry id");
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
pending.set(imported.id, imported);
|
|
119
|
+
}
|
|
120
|
+
} finally { lines.close(); }
|
|
121
|
+
if (!header) throw new Error("project import has no header");
|
|
122
|
+
|
|
123
|
+
for (const imported of pending.values()) {
|
|
124
|
+
if (["supersedes", "promotedFrom"].some(field => {
|
|
125
|
+
const target = imported.note[field as "supersedes" | "promotedFrom"] as string | null | undefined;
|
|
126
|
+
return target !== undefined && target !== null && !pending.has(target);
|
|
127
|
+
})) {
|
|
128
|
+
result.skipped.invalid++;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const screened = stripPrivateDeep(imported.note);
|
|
132
|
+
if (looksSecret(JSON.stringify(screened)) || !await opts.policy(screened)) { result.skipped.policy++; continue; }
|
|
133
|
+
accepted.push(imported);
|
|
134
|
+
}
|
|
135
|
+
result.provenance = provenance(accepted);
|
|
136
|
+
const existing = new Set(store.portableNotes(opts.project).map(note => note.id));
|
|
137
|
+
result.wouldAdd = accepted.filter(note => !existing.has(note.id)).length;
|
|
138
|
+
if (opts.confirm === true) {
|
|
139
|
+
for (const imported of accepted) {
|
|
140
|
+
const written = store.importNote(imported.note, imported.at, { machine: imported.machine, expectedId: imported.id });
|
|
141
|
+
if (written.added) result.added++;
|
|
142
|
+
}
|
|
143
|
+
store.refresh();
|
|
144
|
+
}
|
|
145
|
+
return result;
|
|
146
|
+
} finally { stream.destroy(); }
|
|
147
|
+
}
|
|
@@ -102,10 +102,9 @@ export function decodeShardPack(path: string, bytes: Uint8Array, realm: ShardSyn
|
|
|
102
102
|
catch { throw new ShardSyncFormatError(label, "malformed pack row"); }
|
|
103
103
|
const row = publicRow(value, label);
|
|
104
104
|
if (hash(row.machine) !== match[1]) throw new ShardSyncFormatError(label, "pack author mismatch");
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
} else if (row.op === "note" || row.op === "goal") {
|
|
105
|
+
// Generation is preserved in the original prune payload. Acceptance here is structural,
|
|
106
|
+
// never permission to apply it: the engine proves each original generation and holds foreign rows.
|
|
107
|
+
if (row.op === "note" || row.op === "goal") {
|
|
109
108
|
if (realm.scope === "project" ? row.scope !== "project" || row.project !== realm.key : row.scope !== "shared") {
|
|
110
109
|
throw new ShardSyncFormatError(label, "row scope mismatch");
|
|
111
110
|
}
|
package/src/memory/shard-sync.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes, createHash } from "node:crypto";
|
|
2
|
-
import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, rmSync, writeSync } from "node:fs";
|
|
2
|
+
import { constants, closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, readSync, renameSync, rmSync, writeSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { canonicalJSON, fsyncDirectory, hashJSON, readProfileJSON } from "./generation.ts";
|
|
5
5
|
import { generationSyncLock } from "./generation-sync.ts";
|
|
@@ -14,8 +14,14 @@ import type { Entry, MemoryStore } from "./store.ts";
|
|
|
14
14
|
const OID = /^[a-f0-9]{40}$/, PACK = /^rows\/[a-f0-9]{64}\/[a-f0-9]{64}\.jsonl$/;
|
|
15
15
|
const digest = (bytes: string | Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
|
|
16
16
|
const zero = "0".repeat(40);
|
|
17
|
+
// The descriptor is at most 64 KiB and an original prune at most 16 KiB; keep receipt parsing bounded.
|
|
18
|
+
const HOLD_MAX_BYTES = 128 * 1024;
|
|
17
19
|
type PendingPrune = { plan: PrunePlan; row: PruneRow; line: string; offset: number; prefixHash: string };
|
|
18
|
-
type
|
|
20
|
+
type HoldSource = { ref: string; commit: string; pack: string; blob: string };
|
|
21
|
+
type HoldReceipt = { schema: "agent-mem.prune-hold/v1"; remote: string; realm: ShardSyncRealm; receivingGeneration: string; row: PruneRow; payloadHash: string; source: HoldSource };
|
|
22
|
+
type Hold = { file: string; bytes: Buffer };
|
|
23
|
+
type SyncPhase = "published" | "hold-written" | "hold-installed" | "imported";
|
|
24
|
+
type Target = { store: MemoryStore; realm: ShardSyncRealm; rows: Map<string, ShardSyncRow>; fetched: Set<string>; sources: PruneSourceRow[]; pending: PendingPrune[]; origins: Map<string, HoldSource>; holds: Hold[]; generation: string; local?: string; remote?: string; commit?: string };
|
|
19
25
|
class SyncRefusal extends Error {}
|
|
20
26
|
function refuse(reason: string): never { throw new SyncRefusal(reason); }
|
|
21
27
|
function directory(path: string): void {
|
|
@@ -146,8 +152,10 @@ function provePrunes(targets: readonly Target[]): void {
|
|
|
146
152
|
}
|
|
147
153
|
const snapshot: PruneSnapshot = { rows: [...sources.values()], expiries: [] };
|
|
148
154
|
const prunes = [...target.rows.values()].filter((row): row is PruneRow => row.op === "prune");
|
|
149
|
-
const
|
|
150
|
-
|
|
155
|
+
for (const generation of new Set(prunes.map(row => row.generation))) {
|
|
156
|
+
const proof = reducePruneRows(prunes.filter(row => row.generation === generation).map(row => ({ row, placement: "synced" as const })), snapshot, generation);
|
|
157
|
+
if (proof.invalid || proof.pending.length) refuse(`${shardSyncLabel(target.realm)}: prune lacks complete public witnesses`);
|
|
158
|
+
}
|
|
151
159
|
}
|
|
152
160
|
}
|
|
153
161
|
function installPack(store: MemoryStore, bytes: Buffer): void {
|
|
@@ -176,7 +184,7 @@ function importRows(target: Target): void {
|
|
|
176
184
|
for (const pending of target.pending) {
|
|
177
185
|
port.persistPrunePlan(pending.plan); port.appendPrune(pending.row, pending.line, pending.plan.seal); resumed.add(pending.row.id);
|
|
178
186
|
}
|
|
179
|
-
for (const row of target.rows.values()) if (row.op === "prune") {
|
|
187
|
+
for (const row of target.rows.values()) if (row.op === "prune" && row.generation === target.generation) {
|
|
180
188
|
if (resumed.has(row.id)) continue;
|
|
181
189
|
// A transport imports an already-authored person row without changing its author or generation.
|
|
182
190
|
// Native durable-plan append confirms fsync even for an already parsed id after a crash.
|
|
@@ -186,8 +194,104 @@ function importRows(target: Target): void {
|
|
|
186
194
|
port.refresh();
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/**
|
|
190
|
-
|
|
197
|
+
/** Receipts are private, immutable transport metadata, outside every native generation reducer.
|
|
198
|
+
* ponytail: foreign targets remain visible; an explicit cross-generation apply protocol must
|
|
199
|
+
* re-prove witnesses and establish a compatible reader floor before lifting this hold ceiling. */
|
|
200
|
+
const hasHoldPath = (file: string): boolean => lstatSync(file, { throwIfNoEntry: false }) !== undefined;
|
|
201
|
+
function privateHoldPath(file: string, folder: boolean): void {
|
|
202
|
+
if (folder) directory(file); else regular(file);
|
|
203
|
+
if (lstatSync(file).mode & 0o077) refuse("shard sync prune holds require private permissions");
|
|
204
|
+
}
|
|
205
|
+
function checkPendingHold(file: string, bytes: Buffer): void {
|
|
206
|
+
privateHoldPath(file, false);
|
|
207
|
+
if (lstatSync(file).size > bytes.length || !readFileSync(file).equals(bytes.subarray(0, lstatSync(file).size))) refuse("shard sync partial prune hold conflicts with verified receipt");
|
|
208
|
+
}
|
|
209
|
+
function installHold(root: string, hold: Hold, phase?: (phase: SyncPhase) => void): void {
|
|
210
|
+
const parts = hold.file.slice(root.length + 1).split("/"), name = parts.pop()!;
|
|
211
|
+
let folder = root;
|
|
212
|
+
for (const part of parts) {
|
|
213
|
+
const parent = folder; folder = join(folder, part);
|
|
214
|
+
if (!hasHoldPath(folder)) mkdirSync(folder, { mode: 0o700 });
|
|
215
|
+
privateHoldPath(folder, true); fsyncDirectory(parent);
|
|
216
|
+
}
|
|
217
|
+
const file = join(folder, name), pending = file + ".pending";
|
|
218
|
+
if (hasHoldPath(file)) {
|
|
219
|
+
privateHoldPath(file, false);
|
|
220
|
+
if (!readFileSync(file).equals(hold.bytes)) refuse("shard sync immutable prune hold conflict");
|
|
221
|
+
const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
222
|
+
fsyncDirectory(folder); return;
|
|
223
|
+
}
|
|
224
|
+
if (hasHoldPath(pending)) checkPendingHold(pending, hold.bytes);
|
|
225
|
+
const fd = openSync(pending, constants.O_WRONLY | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
226
|
+
try {
|
|
227
|
+
// Interrupted private staging bytes may be completed; committed receipts are never rewritten.
|
|
228
|
+
let offset = 0; while (offset < hold.bytes.length) { const n = writeSync(fd, hold.bytes, offset, hold.bytes.length - offset, offset); if (n <= 0) refuse("shard sync prune hold write unavailable"); offset += n; }
|
|
229
|
+
fsyncSync(fd);
|
|
230
|
+
} finally { closeSync(fd); }
|
|
231
|
+
phase?.("hold-written");
|
|
232
|
+
// The transport mutation lock serializes this create-once rename. Unlike hard-link installation,
|
|
233
|
+
// a crash cannot leave a committed receipt with a second link that later fails validation.
|
|
234
|
+
if (hasHoldPath(file)) refuse("shard sync prune hold changed concurrently");
|
|
235
|
+
renameSync(pending, file); fsyncDirectory(folder); phase?.("hold-installed");
|
|
236
|
+
}
|
|
237
|
+
function holdWarning(targets: readonly Target[], root: string): string | undefined {
|
|
238
|
+
const warnings: string[] = [];
|
|
239
|
+
for (const target of targets) if (target.holds.length) {
|
|
240
|
+
const rows = [...target.rows.values()].filter((row): row is PruneRow => row.op === "prune" && row.generation !== target.generation);
|
|
241
|
+
const ids = [...new Set(rows.flatMap(row => row.targets.map(item => item.id)))].sort(), shown = ids.slice(0, 10);
|
|
242
|
+
const location = target.holds[0]!.file.slice(root.length + 1).split("/").slice(0, -2).join("/");
|
|
243
|
+
warnings.push(`${shardSyncLabel(target.realm)}: ${rows.length} foreign-generation prune(s) held, not applied; targets ${shown.join(",")}${ids.length > shown.length ? ` (+${ids.length - shown.length} more)` : ""} may remain visible; receipts shard-sync/${location}; bring this machine to the origin generation, or prune the entry here as the person`);
|
|
244
|
+
}
|
|
245
|
+
return warnings.length ? warnings.join("; ") : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
type ProveHoldSource = (target: Target, receipt: HoldReceipt) => Promise<void>;
|
|
249
|
+
async function prepareHold(root: string, url: string, target: Target, row: PruneRow, proveSource: ProveHoldSource): Promise<Hold> {
|
|
250
|
+
const source = target.origins.get(row.id);
|
|
251
|
+
if (!source || !target.remote || !target.fetched.has(row.id)) refuse(`${shardSyncLabel(target.realm)}: foreign prune has no fetched provenance`);
|
|
252
|
+
const receipt: HoldReceipt = { schema: "agent-mem.prune-hold/v1", remote: digest(url), realm: target.realm,
|
|
253
|
+
receivingGeneration: target.generation, row, payloadHash: hashJSON(row), source };
|
|
254
|
+
const folders = ["prune-holds", receipt.remote, digest(shardSyncRef(target.realm)), target.generation, row.generation];
|
|
255
|
+
let folder = root;
|
|
256
|
+
for (const name of folders) { folder = join(folder, name); if (hasHoldPath(folder)) privateHoldPath(folder, true); }
|
|
257
|
+
const file = join(folder, `${row.id}-${receipt.payloadHash}.json`);
|
|
258
|
+
let selected = receipt;
|
|
259
|
+
if (hasHoldPath(file)) {
|
|
260
|
+
privateHoldPath(file, false);
|
|
261
|
+
const value = readProfileJSON(file, HOLD_MAX_BYTES) as HoldReceipt | undefined;
|
|
262
|
+
if (!value || !value.source || Object.keys(value.source).length !== 4 || !OID.test(value.source.commit)
|
|
263
|
+
|| !OID.test(value.source.blob) || !PACK.test(value.source.pack)
|
|
264
|
+
|| canonicalJSON({ ...value, source }) !== canonicalJSON(receipt)) refuse(`${shardSyncLabel(target.realm)}: invalid prune hold receipt`);
|
|
265
|
+
// A receipt is evidence of a hold, never application authority. Recheck its original
|
|
266
|
+
// immutable source against this invocation's fully audited selected remote history.
|
|
267
|
+
await proveSource(target, value);
|
|
268
|
+
selected = value;
|
|
269
|
+
}
|
|
270
|
+
const bytes = Buffer.from(canonicalJSON(selected) + "\n");
|
|
271
|
+
if (bytes.length > HOLD_MAX_BYTES) refuse("shard sync prune hold exceeds metadata bound");
|
|
272
|
+
if (hasHoldPath(file) && !readFileSync(file).equals(bytes)) refuse("shard sync prune hold is not canonical");
|
|
273
|
+
if (hasHoldPath(file + ".pending")) checkPendingHold(file + ".pending", bytes);
|
|
274
|
+
return { file, bytes };
|
|
275
|
+
}
|
|
276
|
+
async function prepareHolds(targets: readonly Target[], root: string, url: string, proveSource: ProveHoldSource): Promise<void> {
|
|
277
|
+
for (const target of targets) {
|
|
278
|
+
target.holds = [];
|
|
279
|
+
for (const row of target.rows.values()) if (row.op === "prune" && row.generation !== target.generation) {
|
|
280
|
+
target.holds.push(await prepareHold(root, url, target, row, proveSource));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
function mergePackOrigins(into: Map<string, HoldSource>, byRealm: Map<string, Map<string, HoldSource>>, rows: readonly ShardSyncRow[], source: HoldSource): void {
|
|
285
|
+
const first = byRealm.get(source.ref) ?? new Map<string, HoldSource>(); byRealm.set(source.ref, first);
|
|
286
|
+
// Oldest-first history keeps a receipt's source stable across tip advancement and partial-write retry.
|
|
287
|
+
for (const row of rows) {
|
|
288
|
+
if (!first.has(row.id)) first.set(row.id, source);
|
|
289
|
+
if (!into.has(row.id)) into.set(row.id, first.get(row.id)!);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Sync only borrowed canonical project/user targets. Internal error/phase observers are test-only, never CLI/SyncOptions ports. */
|
|
294
|
+
export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void, testOnlyPhase?: (phase: SyncPhase) => void): Promise<SyncResult> {
|
|
191
295
|
let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
|
|
192
296
|
const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
|
|
193
297
|
const fail = (error: string): SyncResult => ({ ok: false, pushed, entries: 0, error });
|
|
@@ -216,7 +320,7 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
216
320
|
targets = borrowed.map(opened => {
|
|
217
321
|
const profile = opened.profile;
|
|
218
322
|
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.shardUser && !profile.shardKey) refuse("shard sync target identity unavailable");
|
|
219
|
-
return { store: opened, realm: profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: profile.shardKey! }, rows: new Map<string, ShardSyncRow>(), fetched: new Set<string>(), sources: [], pending: [], generation: profile.generation };
|
|
323
|
+
return { store: opened, realm: profile.shardUser ? { scope: "user" as const } : { scope: "project" as const, key: profile.shardKey! }, rows: new Map<string, ShardSyncRow>(), fetched: new Set<string>(), sources: [], pending: [], origins: new Map<string, HoldSource>(), holds: [], generation: profile.generation };
|
|
220
324
|
});
|
|
221
325
|
if (!targets.length || targets.length > 2 || targets.some(target => target.store.profile.home !== store.profile.home)
|
|
222
326
|
|| new Set(targets.map(target => shardSyncRef(target.realm))).size !== targets.length
|
|
@@ -280,12 +384,13 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
280
384
|
if (identity !== object) refuse("shard sync public blob identity mismatch");
|
|
281
385
|
blobCache.set(object, bytes); return bytes;
|
|
282
386
|
};
|
|
283
|
-
type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer> };
|
|
387
|
+
type History = { rows: Map<string, ShardSyncRow>; files: Map<string, Buffer>; origins: Map<string, HoldSource> };
|
|
284
388
|
// Keep only requested tips. Per-commit full row maps would make append-only history quadratic in memory.
|
|
285
389
|
const histories = new Map<string, History>(), audited = new Set<string>();
|
|
390
|
+
const firstOrigins = new Map<string, Map<string, HoldSource>>();
|
|
286
391
|
const treeState = async (commit: string, target: Target): Promise<History> => {
|
|
287
392
|
const entries = (await required(["ls-tree", "-r", "-z", commit], "shard sync tree unavailable")).split("\0").filter(Boolean);
|
|
288
|
-
const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>();
|
|
393
|
+
const rows = new Map<string, ShardSyncRow>(), files = new Map<string, Buffer>(), origins = new Map<string, HoldSource>();
|
|
289
394
|
for (const entry of entries) {
|
|
290
395
|
const match = /^100644 blob ([a-f0-9]{40})\t(.+)$/.exec(entry);
|
|
291
396
|
if (!match || match[2] !== "shard.json" && !PACK.test(match[2]!)) refuse(`${shardSyncLabel(target.realm)}: forbidden historical tree entry`);
|
|
@@ -298,10 +403,11 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
298
403
|
if (!pack) { pack = decodeShardPack(path, bytes, target.realm, target.generation); decoded.set(key, pack); }
|
|
299
404
|
else if (!path.endsWith(`/${digest(bytes)}.jsonl`) || pack.some(row => path !== `rows/${digest(row.machine)}/${digest(bytes)}.jsonl`)) refuse(`${shardSyncLabel(target.realm)}: immutable pack path changed`);
|
|
300
405
|
mergeRows(rows, pack, target.realm);
|
|
406
|
+
mergePackOrigins(origins, firstOrigins, pack, { ref: shardSyncRef(target.realm), commit, pack: path, blob: object });
|
|
301
407
|
}
|
|
302
408
|
}
|
|
303
409
|
if (!files.has("shard.json")) refuse(`${shardSyncLabel(target.realm)}: historical descriptor missing`);
|
|
304
|
-
return { rows, files };
|
|
410
|
+
return { rows, files, origins };
|
|
305
411
|
};
|
|
306
412
|
const history = async (tip: string, target: Target): Promise<History> => {
|
|
307
413
|
const key = `${shardSyncRef(target.realm)}:${tip}`, cached = histories.get(key); if (cached) return cached;
|
|
@@ -345,7 +451,7 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
345
451
|
if (target.local) await required(["merge-base", "--is-ancestor", target.local, tip], `${shardSyncLabel(target.realm)}: remote history is not fast-forward`);
|
|
346
452
|
const remote = await history(tip, target); target.remote = tip;
|
|
347
453
|
mergeRows(target.rows, Array.from(remote.rows.values()), target.realm);
|
|
348
|
-
for (const row of remote.rows.values()) target.fetched.add(row.id);
|
|
454
|
+
for (const row of remote.rows.values()) { target.fetched.add(row.id); target.origins.set(row.id, remote.origins.get(row.id)!); }
|
|
349
455
|
}
|
|
350
456
|
};
|
|
351
457
|
const localTargets = () => {
|
|
@@ -385,10 +491,17 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
385
491
|
await history(target.commit, target);
|
|
386
492
|
}
|
|
387
493
|
};
|
|
388
|
-
|
|
494
|
+
const proveHoldSource: ProveHoldSource = async (target, value) => {
|
|
495
|
+
await required(["merge-base", "--is-ancestor", value.source.commit, target.remote!], "shard sync prune hold source is not remote history");
|
|
496
|
+
const original = await history(value.source.commit, target), bytes = original.files.get(value.source.pack);
|
|
497
|
+
if (!bytes || createHash("sha1").update(`blob ${bytes.length}\0`).update(bytes).digest("hex") !== value.source.blob
|
|
498
|
+
|| !decodeShardPack(value.source.pack, bytes, target.realm, target.generation).some(item => canonicalJSON(item) === canonicalJSON(value.row))
|
|
499
|
+
|| value.source.ref !== shardSyncRef(target.realm)) refuse(`${shardSyncLabel(target.realm)}: prune hold source mismatch`);
|
|
500
|
+
};
|
|
501
|
+
localTargets(); await fetchTargets(); localTargets(); provePrunes(targets); await prepareHolds(targets, root, url, proveHoldSource); check();
|
|
389
502
|
// Every selected realm, including its complete remote history/prunes, is preflighted before the first append or publication.
|
|
390
503
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
391
|
-
if (attempt) { await fetchTargets(); localTargets(); provePrunes(targets); check(); }
|
|
504
|
+
if (attempt) { await fetchTargets(); localTargets(); provePrunes(targets); await prepareHolds(targets, root, url, proveHoldSource); check(); }
|
|
392
505
|
await buildTargets(); check();
|
|
393
506
|
const sent = await git(["push", "--atomic", "--no-follow-tags", "-q", "origin", ...targets.map(target => `${target.commit!}:${shardSyncRef(target.realm)}`)]);
|
|
394
507
|
if (sent.code === 0) pushed = true;
|
|
@@ -396,13 +509,18 @@ export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions =
|
|
|
396
509
|
if (sent.code === 0) {
|
|
397
510
|
pushed = true;
|
|
398
511
|
// No local append before a successful atomic publication: a raced foreign prune can still refuse the entire retry safely.
|
|
399
|
-
localTargets(); provePrunes(targets); check();
|
|
512
|
+
localTargets(); provePrunes(targets); check(); testOnlyPhase?.("published");
|
|
513
|
+
// No selected ordinary/native imports can precede durable receipts for every foreign prune.
|
|
514
|
+
for (const target of targets) for (const hold of target.holds) installHold(root, hold, testOnlyPhase);
|
|
515
|
+
check();
|
|
400
516
|
for (const target of targets) {
|
|
401
517
|
assertCuratedShardPaths(target.store.profile.home, target.realm.scope === "user" ? { user: true } : { project: target.realm.key });
|
|
402
|
-
importRows(target);
|
|
518
|
+
importRows(target); testOnlyPhase?.("imported");
|
|
403
519
|
}
|
|
404
520
|
for (const target of targets) await required(["update-ref", "--no-deref", shardSyncRef(target.realm), target.commit!, target.local ?? zero], "shard sync cached ref changed concurrently");
|
|
405
|
-
|
|
521
|
+
const warning = holdWarning(targets, root);
|
|
522
|
+
return { ok: true, pushed, entries: targets.reduce((n, target) => n + [...target.rows.values()].filter(row => row.op === "note").length, 0),
|
|
523
|
+
...(warning ? { warning } : {}) };
|
|
406
524
|
}
|
|
407
525
|
if (opts.signal?.aborted) return fail("stopped");
|
|
408
526
|
}
|