@staix/agent-mem 0.2.0 → 0.3.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/README.md +48 -1
- package/package.json +4 -2
- package/src/cli.ts +66 -4
- package/src/commands-table.ts +8 -0
- package/src/index.ts +4 -0
- package/src/memory/bridge.ts +376 -0
- package/src/memory/commands.ts +6 -1
- package/src/memory/generation-index.ts +245 -0
- package/src/memory/generation-sync.ts +248 -0
- package/src/memory/generation.ts +143 -0
- package/src/memory/migrate.ts +216 -0
- package/src/memory/ops-contract.ts +159 -0
- package/src/memory/platform.ts +8 -1
- package/src/memory/prune.ts +259 -0
- package/src/memory/store.ts +124 -15
- package/src/memory/sync.ts +22 -0
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.3.1. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -149,6 +149,53 @@ The root entry needs Bun (it loads `bun:sqlite`). Node hosts that need only the
|
|
|
149
149
|
|
|
150
150
|
The entry point exports the store, types and identity helpers, sync and claude-mem import, project/home helpers, raw memory/goal command handlers, and platform helpers. Hosts inject their own privacy policy through `StoreOptions.screen`; CLI wrappers can supply screened reads and import policy through the raw command options.
|
|
151
151
|
|
|
152
|
+
## Contracts
|
|
153
|
+
|
|
154
|
+
Shared segments retain the `agent-hub.memory/v1` profile. `OP_CONTRACTS` and `V1_OPS` keep the shared-v1 operation contract; both shared append paths refuse unknown or truth-changing operations. The separate `GENERATION_OP_CONTRACTS` enables only `prune` after an explicit generation selection. `correct`, `lift`, `consolidate` and `group` remain blocked. Reader announcements cannot authorize a new operation or relax generation isolation.
|
|
155
|
+
|
|
156
|
+
The shared-v1 contract suites run the published 0.1.1 reader and compare all six v1 derived tables after replay and rebuild; generation fixtures cover their expanded derived state separately. Privacy and evidence matrices test the core's actual callbacks and sidecar boundaries; model-facing reads, PII turns, staleness checks and capture remain the host's responsibility. Direct person reads remain unscreened. Expiry after a wall-clock rollback retains the existing C05 ceiling: a rebuild can restore a previously expired row until its deadline passes again. A generation can persist exact-entry prune; shared-v1 and unpruned entries retain their existing expiry limitations.
|
|
157
|
+
|
|
158
|
+
See [the contract and coverage tables](https://github.com/STAIxBWLB/agent-mem/blob/main/docs/contracts.md) for the fixtures, host-owned rows and declared ceilings. These are automated synthetic checks, not proof of the separate multi-machine or managed-agent live legs.
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
## Generation migration and exact-entry prune
|
|
162
|
+
|
|
163
|
+
Available from 0.3.0. Do not migrate a memory home that agent-hub also uses until agent-hub pins a generation-capable agent-mem and binds the selected profile (rollout order in [#9](https://github.com/STAIxBWLB/agent-mem/issues/9)); until then a running hub keeps writing shared v1 and the person's notes would split.
|
|
164
|
+
|
|
165
|
+
Shared v1 remains the default until a person explicitly migrates the memory home. A generation uses `M/generations/G/` and a separate Git repository/ref (`refs/heads/amem/gen/G`); legacy directories and `main` are unchanged. The one-way readonly bridge consumes complete validated legacy prefixes without using the old index or writing legacy notes. Old programs continue using their legacy view, and generation-created notes never flow back to it.
|
|
166
|
+
|
|
167
|
+
Complete malformed legacy lines (invalid JSON, unknown operations or mismatched content IDs) are skipped and counted, matching shared-v1 handling. Migration dry-run/apply and the person's human CLI status report the skipped count without printing the bodies; JSON list output stays an array of notes. Prefix rewrites, truncation and non-fast-forward legacy history still refuse access. A generation never promotes a bridged note or applies its own settlement to one; only generation-born notes receive generation settlements and promotions.
|
|
168
|
+
|
|
169
|
+
Generation bridge reads use plain-file snapshots captured during explicit legacy sync and never invoke Git. Unchanged segments use metadata checkpoints without reading or hashing the data, including after reopen. A seeded live handle hashes only the appended suffix, while comparing existing prefix bytes on changed input; the first changed read by a cold handle and an explicit rebuild verify the full prefix. These are read-path bounds, not a latency or speedup promise.
|
|
170
|
+
|
|
171
|
+
The following interfaces are for a person's terminal and an already initialized synthetic memory home. They do not authorize real-data migration. Use the same current project directory throughout planning and apply:
|
|
172
|
+
|
|
173
|
+
```sh
|
|
174
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --dry-run
|
|
175
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate
|
|
176
|
+
# If interrupted, resume the recorded generation rather than creating another:
|
|
177
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --resume
|
|
178
|
+
umask 077
|
|
179
|
+
AMEM_HOME=/path/to/synthetic-memory amem prune --cutoff 2026-10-01T00:00:00.000Z --dry-run > prune-plan.json
|
|
180
|
+
AMEM_HOME=/path/to/synthetic-memory amem prune --apply --plan prune-plan.json
|
|
181
|
+
# Explicitly select shared v1 again while preserving both histories:
|
|
182
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --deactivate
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
All migrate/prune modes, including dry-run, refuse agent markers and require stdin TTY. `--generation <32-hex-G>` selects an explicitly named generation during migration/join; it is never discovered and activated from a remote automatically. Invalid active metadata blocks access rather than silently opening shared v1. Existing handles remain bound to the profile they opened; a selector change does not restart or rebind a running hub.
|
|
186
|
+
|
|
187
|
+
Explicit `--deactivate` is the recovery path even when the selected generation is corrupt or missing. It reads the selector under the home-wide profile lock, selects shared v1 and retains all generation and legacy data. Normal reads still refuse corrupt active state. Shared-v1 sync keeps its pre-generation Git invocation and caller environment; generation-only sync adds repository-environment isolation and a private child-process umask.
|
|
188
|
+
|
|
189
|
+
A prune plan is a person-local JSON receipt containing exact target/basis hashes, selector/project/policy revisions, fixed row bytes, local counts and a seal. Apply commits that same receipt after revalidation; it is not an authorization token. Maximums are 256 KiB per plan, 16 rows, 256 targets, and 32 targets/16 KiB per row. Larger automatic selections report `hasMore`; another plan handles remaining targets. Keep redirected plans private and out of source control, model context and sync.
|
|
190
|
+
|
|
191
|
+
Only explicit serialized expiry is eligible: a note's `expires`, or an exact demote/settle row's `by`. Implicit backstop or instance-TTL deadlines cannot prove prune eligibility. Exact pruned IDs stay unavailable after clock rollback, including get/alias/graph-start paths. A different later promotion or replacement ID remains an independent note; this is not text/family removal, log erasure or storage reclamation. Unpruned expiry still follows the clock.
|
|
192
|
+
|
|
193
|
+
Standalone CLI prune is local-only, even over a synced target. No `--policy-free` escape enables public prune. A later policy-aware host integration must authenticate intent and attest the exact public payload and current policy revision; only verified public target/basis provenance may sync. Local masks, plans, journals, caches and sidecars never sync. Generation sync refuses public or unknown remote visibility. An interrupted apply resumes the same durable row IDs and exact partial tail; it never rewrites complete lines.
|
|
194
|
+
|
|
195
|
+
Candidate/counter sidecars are not migrated. `amem review` currently refuses a selected generation rather than using an unbound legacy sidecar. A profile-aware host integration must handle that context explicitly.
|
|
196
|
+
|
|
197
|
+
Real migration remains blocked until the separately reviewed generation-capable amem release and exact-pinned agent-hub integration are installed and their running stores prove the same home/G/selector revision. Synthetic cross-writes and exact prune readbacks precede separately confirmed real migration; known local handles must be quiesced/closed and reopened through the supported session-preserving lifecycle. Release, deployment, real migration, host evidence/capture receipts and real multi-machine sync are not established by these source changes. Deactivation omits generation-only notes/prunes from the v1 view and is not a lossless rollback.
|
|
198
|
+
|
|
152
199
|
## Development
|
|
153
200
|
|
|
154
201
|
```sh
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@types/bun": "^1.3.0",
|
|
16
|
-
"typescript": "^5.8.0"
|
|
16
|
+
"typescript": "^5.8.0",
|
|
17
|
+
"@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
|
|
18
|
+
"@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0"
|
|
17
19
|
},
|
|
18
20
|
"exports": {
|
|
19
21
|
".": "./src/index.ts",
|
package/src/cli.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, readSync } from "node:fs";
|
|
2
2
|
import { MemoryStore, looksSecret } from "./memory/store.ts";
|
|
3
3
|
import { rawMemoryCommand, rawGoalCommand } from "./memory/commands.ts";
|
|
4
4
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
@@ -6,11 +6,50 @@ import { CandidateStore, type CandidateFilter } from "./memory/candidates.ts";
|
|
|
6
6
|
|
|
7
7
|
import { commandHelp, parseCommand } from "./commands-table.ts";
|
|
8
8
|
import { completionScript } from "./completion.ts";
|
|
9
|
+
import { memoryHome } from "./memory/home.ts";
|
|
10
|
+
import { resolveProfile } from "./memory/generation.ts";
|
|
11
|
+
import { migrate } from "./memory/migrate.ts";
|
|
12
|
+
import { applyPrune, planPrune, PRUNE_PLAN_MAX_BYTES } from "./memory/prune.ts";
|
|
9
13
|
|
|
10
14
|
export const PERSON_ONLY = "amem is for the person; agents read memory through the hub's hub_memory_* tools";
|
|
11
15
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
12
16
|
export interface CliContext { cwd?: string; env?: Readonly<Record<string, string | undefined>>; stdinTTY?: boolean }
|
|
13
17
|
|
|
18
|
+
/** Narrow descriptor seam for deterministic race fixtures; the CLI always uses the native implementation. */
|
|
19
|
+
export interface PrunePlanFileIO {
|
|
20
|
+
open(path: string, flags: number): number;
|
|
21
|
+
stat(fd: number): { size: number; isFile(): boolean };
|
|
22
|
+
read(fd: number, buffer: Buffer, offset: number, length: number, position: number): number;
|
|
23
|
+
close(fd: number): void;
|
|
24
|
+
}
|
|
25
|
+
const planFileIO: PrunePlanFileIO = {
|
|
26
|
+
open: (path, flags) => openSync(path, flags), stat: fd => fstatSync(fd),
|
|
27
|
+
read: (fd, buffer, offset, length, position) => readSync(fd, buffer, offset, length, position), close: fd => closeSync(fd),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Open once: pathname replacement cannot redirect the read and growth cannot exceed the fixed byte budget. */
|
|
31
|
+
export function readPrunePlanFile(path: string, io: PrunePlanFileIO = planFileIO): string {
|
|
32
|
+
try {
|
|
33
|
+
const fd = io.open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
34
|
+
try {
|
|
35
|
+
const before = io.stat(fd);
|
|
36
|
+
if (!before.isFile() || !Number.isSafeInteger(before.size) || before.size < 0 || before.size > PRUNE_PLAN_MAX_BYTES) throw new Error();
|
|
37
|
+
const data = Buffer.alloc(PRUNE_PLAN_MAX_BYTES + 1);
|
|
38
|
+
let length = 0;
|
|
39
|
+
while (length < data.length) {
|
|
40
|
+
const n = io.read(fd, data, length, data.length - length, length);
|
|
41
|
+
if (!Number.isSafeInteger(n) || n < 0 || n > data.length - length) throw new Error();
|
|
42
|
+
if (n === 0) break;
|
|
43
|
+
length += n;
|
|
44
|
+
}
|
|
45
|
+
if (length > PRUNE_PLAN_MAX_BYTES) throw new Error();
|
|
46
|
+
const after = io.stat(fd);
|
|
47
|
+
if (!after.isFile() || after.size !== before.size || length !== before.size) throw new Error();
|
|
48
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(data.subarray(0, length));
|
|
49
|
+
} finally { io.close(fd); }
|
|
50
|
+
} catch { throw new Error("prune plan unavailable or oversized"); }
|
|
51
|
+
}
|
|
52
|
+
|
|
14
53
|
/** Honest native-shell defaults, matching every marker detectCliIdentity treats as non-console (invalid included). */
|
|
15
54
|
export function agentMarked(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
16
55
|
return ["AGENTHUB_PEER_ID", "CLAUDECODE", "CLAUDE_CODE_SESSION_ID", "CODEX_THREAD_ID"].some(key => env[key] !== undefined);
|
|
@@ -40,7 +79,27 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
40
79
|
if (definition.policyFree && !policyFree) {
|
|
41
80
|
throw new Error(review ? "review accept requires --policy-free; use ahub memory to apply the host's privacy policy" : `${sub} requires --policy-free; use ahub memory ${sub} to apply the host's privacy policy`);
|
|
42
81
|
}
|
|
82
|
+
if (sub === "migrate") {
|
|
83
|
+
const modes = ["--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
84
|
+
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
85
|
+
const mode = options["--dry-run"] ? "dry-run" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "apply";
|
|
86
|
+
return JSON.stringify(await migrate({ memoryDir: memoryHome(), mode, ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true }), null, 2);
|
|
87
|
+
}
|
|
88
|
+
if (sub === "prune") {
|
|
89
|
+
const applying = options["--apply"] === true;
|
|
90
|
+
if (applying ? options["--dry-run"] || options["--cutoff"] || !options["--plan"] : !options["--dry-run"] || !options["--cutoff"] || options["--plan"]) throw new Error("usage: amem prune --cutoff <UTC> --dry-run | --apply --plan <file>");
|
|
91
|
+
let input: string | undefined;
|
|
92
|
+
if (applying) {
|
|
93
|
+
input = readPrunePlanFile(String(options["--plan"]));
|
|
94
|
+
}
|
|
95
|
+
const store = new MemoryStore({ deferPromotions: true });
|
|
96
|
+
try {
|
|
97
|
+
const auth = { project: memoryProjectKey(context.cwd ?? process.cwd()), actor: "person" as const, authorize: () => true };
|
|
98
|
+
return JSON.stringify(applying ? applyPrune(store.prunePort(), input, auth) : planPrune(store.prunePort(), { cutoff: String(options["--cutoff"]) }, auth), null, 2);
|
|
99
|
+
} finally { store.close(); }
|
|
100
|
+
}
|
|
43
101
|
if (review) {
|
|
102
|
+
if (resolveProfile(memoryHome()).kind === "generation") throw new Error("candidate review is not yet integrated with this generation; use the profile-aware host after its integration");
|
|
44
103
|
const id = parsed.positionals[0];
|
|
45
104
|
if (sub !== "list" && (!id || !/^[a-f0-9]{64}$/.test(id))) throw new Error("review accept/reject requires a full 64-character candidate id");
|
|
46
105
|
const project = memoryProjectKey(context.cwd ?? process.cwd()), candidates = new CandidateStore();
|
|
@@ -74,9 +133,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
74
133
|
const open = () => new MemoryStore({ screen: note => !crosses ? "skip" : looksSecret(`${note.title ?? ""}\n${note.text}`) ? "keep" : "promote" });
|
|
75
134
|
const cwd = context.cwd ?? process.cwd();
|
|
76
135
|
// A person's reads are unscreened. Writes crossing the boundary remain secret-screened without host policy.
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
136
|
+
if (goal) return rawGoalCommand(args, cwd, { open, clean: (title, milestones) => !mutating || !looksSecret(`${title}\n${milestones.join("\n")}`) });
|
|
137
|
+
const receipt: { bridgeStatus?: { skippedRows: number } } = {};
|
|
138
|
+
const output = await rawMemoryCommand(args, cwd, { open, readable: notes => notes, importPolicy: () => () => false,
|
|
139
|
+
onBridgeStatus: status => { receipt.bridgeStatus = status; } });
|
|
140
|
+
return !options["--json"] && receipt.bridgeStatus
|
|
141
|
+
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
80
142
|
}
|
|
81
143
|
|
|
82
144
|
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
package/src/commands-table.ts
CHANGED
|
@@ -55,6 +55,14 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
55
55
|
accept: { description: "Accept one candidate through the current memory policy", options: policy, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
|
|
56
56
|
reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
|
|
57
57
|
} },
|
|
58
|
+
migrate: { description: "Select a person-approved generation or deactivate it", mutates: true, options: {
|
|
59
|
+
...json, "--dry-run": flag("Read-only migration receipt"), "--generation": text("Explicit 32-hex generation ID"),
|
|
60
|
+
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
61
|
+
} },
|
|
62
|
+
prune: { description: "Plan or apply exact-entry durable expiry in a generation", mutates: true, options: {
|
|
63
|
+
...json, "--cutoff": text("Canonical UTC expiry cutoff"), "--dry-run": flag("Print a local sealed plan without writes"),
|
|
64
|
+
"--apply": flag("Apply the exact local plan"), "--plan": { description: "Sealed local plan JSON file", value: "path" },
|
|
65
|
+
} },
|
|
58
66
|
completion: { description: "Print static shell completion", args: [{ name: "shell", values: ["zsh", "bash", "fish"] }] },
|
|
59
67
|
help: { description: "Show help" }, version: { description: "Show version" },
|
|
60
68
|
};
|
package/src/index.ts
CHANGED
|
@@ -6,3 +6,7 @@ export * from "./memory/home.ts";
|
|
|
6
6
|
export * from "./memory/platform.ts";
|
|
7
7
|
export * from "./memory/commands.ts";
|
|
8
8
|
export * from "./memory/candidates.ts";
|
|
9
|
+
export * from "./memory/ops-contract.ts";
|
|
10
|
+
export * from "./memory/generation.ts";
|
|
11
|
+
export * from "./memory/migrate.ts";
|
|
12
|
+
export * from "./memory/prune.ts";
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash, randomBytes, type Hash } from "node:crypto";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join } from "node:path";
|
|
5
|
+
import { isDeepStrictEqual } from "node:util";
|
|
6
|
+
import { memoryGitArgv, memoryGitEnv, realPath } from "./platform.ts";
|
|
7
|
+
import { canonicalJSON, fsyncDirectory, readProfileJSON, writeProfileJSON } from "./generation.ts";
|
|
8
|
+
import { OP_CONTRACTS } from "./ops-contract.ts";
|
|
9
|
+
import { entryId, type Entry } from "./store.ts";
|
|
10
|
+
|
|
11
|
+
export interface LegacyCheckpoint { source: string; path: string; offset: number; hash: string; commit?: string; tailBytes?: number; stamp?: string }
|
|
12
|
+
export interface LegacySegment extends LegacyCheckpoint { bytes: Buffer; local: boolean; tailBytes: number; start?: number }
|
|
13
|
+
export interface LegacySnapshotOptions {
|
|
14
|
+
generation?: string;
|
|
15
|
+
commit?: string;
|
|
16
|
+
previousCommit?: string;
|
|
17
|
+
/** Count skipped complete legacy lines; authoritative generation callers use legacyEntries strict mode. */
|
|
18
|
+
onMalformed?: () => void;
|
|
19
|
+
checkpoints?: readonly LegacyCheckpoint[];
|
|
20
|
+
reader?: SegmentReader;
|
|
21
|
+
incremental?: boolean;
|
|
22
|
+
/** A migration replay reads exactly these captured complete prefixes, ignoring later suffixes/new segments. */
|
|
23
|
+
limits?: readonly LegacyCheckpoint[];
|
|
24
|
+
}
|
|
25
|
+
const diskSource = "legacy-local", remoteSource = "legacy-main";
|
|
26
|
+
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
27
|
+
class LegacyInputError extends Error {
|
|
28
|
+
constructor(readonly malformed = false) { super("legacy bridge input changed or is invalid; person inspection required"); }
|
|
29
|
+
}
|
|
30
|
+
function failure(): never { throw new LegacyInputError(); }
|
|
31
|
+
function malformed(): never { throw new LegacyInputError(true); }
|
|
32
|
+
const key = (row: LegacyCheckpoint) => `${row.source}:${row.path}`;
|
|
33
|
+
const segmentPath = (path: string) => /^(log|local)\/[A-Za-z0-9][A-Za-z0-9._-]*\.jsonl$/.test(path);
|
|
34
|
+
const commitId = (value: string) => /^[a-f0-9]{40}$/.test(value);
|
|
35
|
+
|
|
36
|
+
function directory(path: string, optional = false): boolean {
|
|
37
|
+
try { const stat = lstatSync(path); if (stat.isSymbolicLink() || !stat.isDirectory()) failure(); return true; }
|
|
38
|
+
catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SegmentReadMetrics { bytesRead: number; bytesHashed: number; coldChanged: number }
|
|
42
|
+
interface PrefixState { offset: number; hash: string; hasher: Hash; bytes: Buffer }
|
|
43
|
+
type SegmentBytes = Pick<LegacySegment, "bytes" | "offset" | "hash" | "tailBytes" | "stamp" | "start">;
|
|
44
|
+
const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: bigint | number; mtimeNs?: bigint; ctimeNs?: bigint }) => JSON.stringify([String(stat.dev), String(stat.ino), String(stat.size), String(stat.mtimeNs), String(stat.ctimeNs)]);
|
|
45
|
+
|
|
46
|
+
/** Handle-local SHA state: unchanged persisted metadata costs no data read or hashing, including reopen. */
|
|
47
|
+
export class SegmentReader {
|
|
48
|
+
private readonly prefixes = new Map<string, PrefixState>();
|
|
49
|
+
private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
|
|
50
|
+
metrics(): SegmentReadMetrics { return { ...this.measured }; }
|
|
51
|
+
resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
|
|
52
|
+
read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
|
|
53
|
+
const before = lstatSync(path, { bigint: true });
|
|
54
|
+
if (before.isSymbolicLink() || !before.isFile() || !Number.isSafeInteger(Number(before.size))) failure();
|
|
55
|
+
const stamp = statStamp(before), size = Number(before.size);
|
|
56
|
+
if (prior && size < prior.offset) failure();
|
|
57
|
+
if (!full && prior?.stamp === stamp) return { bytes: Buffer.alloc(0), start: prior.offset, offset: prior.offset, hash: prior.hash, tailBytes: size - prior.offset, stamp };
|
|
58
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
59
|
+
try {
|
|
60
|
+
const opened = fstatSync(fd, { bigint: true });
|
|
61
|
+
if (statStamp(opened) !== stamp) failure();
|
|
62
|
+
const read = (length: number): Buffer => {
|
|
63
|
+
const bytes = Buffer.alloc(length); let at = 0;
|
|
64
|
+
while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; }
|
|
65
|
+
return bytes;
|
|
66
|
+
};
|
|
67
|
+
const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
|
|
68
|
+
if (prior && end < prior.offset) failure();
|
|
69
|
+
if (!read(end).equals(bytes) || fstatSync(fd, { bigint: true }).size < opened.size) failure();
|
|
70
|
+
const after = lstatSync(path, { bigint: true });
|
|
71
|
+
if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino) failure();
|
|
72
|
+
const cached = immutable ? undefined : this.prefixes.get(path);
|
|
73
|
+
let hasher: Hash;
|
|
74
|
+
if (!full && prior && cached && cached.offset === prior.offset && cached.hash === prior.hash) {
|
|
75
|
+
// Changed live input still reads/compares the old bytes, so rewrite-plus-growth cannot masquerade as append.
|
|
76
|
+
// ponytail: the generation counterpart of C02: O(segment) I/O per changed refresh (13 MB at 20K entries, p50 247 ms,
|
|
77
|
+
// docs/bench/p9-generation-read.md). Upgrade, measured first: a per-block hash chain or a tail window plus inode/size check.
|
|
78
|
+
if (!cached.bytes.equals(bytes.subarray(0, prior.offset))) failure();
|
|
79
|
+
hasher = cached.hasher.copy(); const suffix = bytes.subarray(prior.offset); hasher.update(suffix); this.measured.bytesHashed += suffix.length;
|
|
80
|
+
} else {
|
|
81
|
+
// ponytail: a cold changed segment verifies/seeds its whole prefix once (O(segment)); unchanged reopens are O(metadata).
|
|
82
|
+
// Serialize SHA state or retain per-block receipts only if measured cold-change cost warrants that additional format.
|
|
83
|
+
hasher = createHash("sha256");
|
|
84
|
+
if (prior) {
|
|
85
|
+
hasher.update(bytes.subarray(0, prior.offset)); this.measured.bytesHashed += prior.offset;
|
|
86
|
+
if (hasher.copy().digest("hex") !== prior.hash) failure();
|
|
87
|
+
if (!full) this.measured.coldChanged++;
|
|
88
|
+
hasher.update(bytes.subarray(prior.offset)); this.measured.bytesHashed += bytes.length - prior.offset;
|
|
89
|
+
} else { hasher.update(bytes); this.measured.bytesHashed += bytes.length; }
|
|
90
|
+
}
|
|
91
|
+
const hash = hasher.copy().digest("hex");
|
|
92
|
+
if (!immutable) this.prefixes.set(path, { offset: end, hash, hasher, bytes: Buffer.from(bytes) });
|
|
93
|
+
const start = !full && prior ? prior.offset : 0;
|
|
94
|
+
// A concurrent append is outside the captured length; retain the earlier stamp so the next read notices it.
|
|
95
|
+
return { bytes: Buffer.from(bytes.subarray(start)), ...(start ? { start } : {}), offset: end, hash, tailBytes: size - end, stamp };
|
|
96
|
+
} finally { closeSync(fd); }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function checkpointMap(rows: readonly LegacyCheckpoint[] = []): Map<string, LegacyCheckpoint> {
|
|
101
|
+
const map = new Map<string, LegacyCheckpoint>();
|
|
102
|
+
for (const row of rows) {
|
|
103
|
+
if (![diskSource, remoteSource].includes(row.source) || !segmentPath(row.path) || row.source === remoteSource && !row.path.startsWith("log/")
|
|
104
|
+
|| !Number.isSafeInteger(row.offset) || row.offset < 0 || !/^[a-f0-9]{64}$/.test(row.hash)
|
|
105
|
+
|| row.tailBytes !== undefined && (!Number.isSafeInteger(row.tailBytes) || row.tailBytes < 0)
|
|
106
|
+
|| row.stamp !== undefined && (typeof row.stamp !== "string" || row.stamp.length > 1024)
|
|
107
|
+
|| row.source === remoteSource && (!row.commit || !commitId(row.commit)) || row.source === diskSource && row.commit !== undefined || map.has(key(row))) failure();
|
|
108
|
+
map.set(key(row), row);
|
|
109
|
+
}
|
|
110
|
+
return map;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function checkedEntry(value: unknown): Entry {
|
|
114
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) malformed();
|
|
115
|
+
const row = value as Record<string, unknown>;
|
|
116
|
+
const operation = row.op;
|
|
117
|
+
if (typeof operation !== "string") return malformed();
|
|
118
|
+
if (!Object.hasOwn(OP_CONTRACTS, operation)) return malformed();
|
|
119
|
+
const contract = OP_CONTRACTS[operation]!;
|
|
120
|
+
const schema = contract.payloadSchema;
|
|
121
|
+
if (!contract.implemented || contract.status !== "v1-equal" || !schema) malformed();
|
|
122
|
+
if (row.schema !== "agent-hub.memory/v1" || typeof row.id !== "string" || !/^[a-f0-9]{32}$/.test(row.id)) malformed();
|
|
123
|
+
const { id, ...body } = row;
|
|
124
|
+
if (entryId(body as unknown as Omit<Entry, "id">) !== id) malformed();
|
|
125
|
+
for (const name of schema.required) if (row[name] === undefined && !schema.legacyOptional.includes(name)) malformed();
|
|
126
|
+
for (const [name, field] of Object.entries(schema.fields)) {
|
|
127
|
+
const value = row[name];
|
|
128
|
+
if (value === undefined || value === null && (!schema.required.includes(name) || schema.legacyOptional.includes(name))) continue;
|
|
129
|
+
if (field.type === "string-array") { if (!Array.isArray(value) || !value.every(item => typeof item === "string")) malformed(); continue; }
|
|
130
|
+
if (typeof value !== "string" || field.maxLength !== undefined && value.length > field.maxLength || field.values && !field.values.includes(value)) malformed();
|
|
131
|
+
if (field.format === "id" && !/^[a-f0-9]{32}$/.test(value)) malformed();
|
|
132
|
+
if (field.format === "utc" && Number.isNaN(Date.parse(value))) malformed();
|
|
133
|
+
}
|
|
134
|
+
if (typeof row.at !== "string" || new Date(row.at).toISOString() !== row.at) malformed();
|
|
135
|
+
return row as unknown as Entry;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
139
|
+
let start = 0;
|
|
140
|
+
while (start < bytes.length) { const end = bytes.indexOf(10, start); if (end < 0) failure(); yield bytes.subarray(start, end); start = end + 1; }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Preserve original IDs; row order and JSON key order never produce duplicate evidence. */
|
|
144
|
+
export function legacyEntries(segments: readonly LegacySegment[], options: { strict?: boolean; onMalformed?: () => void } = {}): Entry[] {
|
|
145
|
+
const rows = new Map<string, Entry>();
|
|
146
|
+
for (const segment of segments) {
|
|
147
|
+
if (segment.bytes.length !== segment.offset || digest(segment.bytes) !== segment.hash || segment.offset && segment.bytes.at(-1) !== 10) failure();
|
|
148
|
+
for (const bytes of completeLines(segment.bytes)) {
|
|
149
|
+
let row: Entry;
|
|
150
|
+
try {
|
|
151
|
+
let line: string;
|
|
152
|
+
try { line = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { malformed(); }
|
|
153
|
+
if (!line!.trim()) continue;
|
|
154
|
+
let value: unknown; try { value = JSON.parse(line!); } catch { malformed(); }
|
|
155
|
+
row = checkedEntry(value);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if (!(error instanceof LegacyInputError) || !error.malformed || options.strict) throw error;
|
|
158
|
+
options.onMalformed?.();
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const prior = rows.get(row.id);
|
|
162
|
+
if (prior && !isDeepStrictEqual(JSON.parse(JSON.stringify(prior)), JSON.parse(JSON.stringify(row)))) failure();
|
|
163
|
+
rows.set(row.id, row);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return [...rows.values()];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Raw authoritative inputs only: never constructs a legacy store, opens its index or performs network I/O. */
|
|
170
|
+
function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[] {
|
|
171
|
+
const root = realPath(homeM);
|
|
172
|
+
directory(root);
|
|
173
|
+
const checkpoints = checkpointMap(options.checkpoints), limits = options.limits === undefined ? undefined : checkpointMap(options.limits);
|
|
174
|
+
const result: LegacySegment[] = [];
|
|
175
|
+
const accept = (source: string, path: string, complete: Buffer, commit?: string, tailBytes = 0, stamp?: string) => {
|
|
176
|
+
if (!segmentPath(path)) failure();
|
|
177
|
+
const identity = `${source}:${path}`, prior = checkpoints.get(identity), limit = limits?.get(identity);
|
|
178
|
+
if (limits && !limit) return;
|
|
179
|
+
const verify = (receipt: LegacyCheckpoint) => {
|
|
180
|
+
if (receipt.offset > complete.length || receipt.offset > 0 && complete[receipt.offset - 1] !== 10 || digest(complete.subarray(0, receipt.offset)) !== receipt.hash) failure();
|
|
181
|
+
};
|
|
182
|
+
if (prior) verify(prior);
|
|
183
|
+
if (limit) { verify(limit); if (prior && limit.offset < prior.offset || limit.commit !== commit) failure(); }
|
|
184
|
+
const bytes = Buffer.from(limit ? complete.subarray(0, limit.offset) : complete);
|
|
185
|
+
result.push({ source, path, bytes, offset: bytes.length, hash: digest(bytes), local: path.startsWith("local/"), tailBytes: limit ? limit.tailBytes ?? 0 : tailBytes, ...((limit ? limit.stamp : stamp) ? { stamp: limit ? limit.stamp : stamp } : {}), ...(commit ? { commit } : {}) });
|
|
186
|
+
};
|
|
187
|
+
// Identity is read without invoking machineId(), which can initialize or repair local state.
|
|
188
|
+
let machine: unknown;
|
|
189
|
+
try { machine = JSON.parse(capturedIdentity(join(root, "machine.json"))); } catch { failure(); }
|
|
190
|
+
const machineId = (machine as { id?: unknown } | undefined)?.id;
|
|
191
|
+
if (typeof machineId !== "string" || !/^[a-f0-9]{16}$/.test(machineId)) failure();
|
|
192
|
+
for (const folder of ["log", "local"]) {
|
|
193
|
+
const dir = join(root, folder);
|
|
194
|
+
if (!directory(dir, true)) continue;
|
|
195
|
+
for (const name of readdirSync(dir).sort()) {
|
|
196
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
197
|
+
const path = `${folder}/${name}`;
|
|
198
|
+
if (!segmentPath(path)) failure();
|
|
199
|
+
if (folder === "local" && name !== `${machineId}.jsonl`) continue;
|
|
200
|
+
const reader = options.reader ?? new SegmentReader();
|
|
201
|
+
const incremental = options.incremental && !limits;
|
|
202
|
+
const data = reader.read(join(dir, name), incremental ? checkpoints.get(`${diskSource}:${path}`) : undefined, !incremental);
|
|
203
|
+
if (incremental) result.push({ ...data, source: diskSource, path, local: folder === "local" });
|
|
204
|
+
else accept(diskSource, path, data.bytes, undefined, data.tailBytes, data.stamp);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if ((options.commit || options.previousCommit) && !options.generation || options.previousCommit && !commitId(options.previousCommit)) failure();
|
|
208
|
+
if (options.generation !== undefined) {
|
|
209
|
+
const commit = options.commit ?? legacyHead(root, options.generation);
|
|
210
|
+
if (commit) {
|
|
211
|
+
const manifest = archived(root, options.generation, commit);
|
|
212
|
+
directory(join(archiveDir(root, options.generation, commit), "log"));
|
|
213
|
+
for (const prior of new Set([...(options.previousCommit ? [options.previousCommit] : []), ...[...checkpoints.values()].filter(row => row.source === remoteSource).map(row => row.commit!)])) if (!archivedAncestor(root, options.generation, prior, commit)) failure();
|
|
214
|
+
for (const segment of manifest.segments) {
|
|
215
|
+
const reader = options.reader ?? new SegmentReader(), incremental = options.incremental && !limits;
|
|
216
|
+
const data = reader.read(join(archiveDir(root, options.generation, commit), segment.path), incremental ? checkpoints.get(`${remoteSource}:${segment.path}`) : undefined, !incremental, true);
|
|
217
|
+
if (data.offset !== segment.offset || data.hash !== segment.hash || data.tailBytes) failure();
|
|
218
|
+
if (incremental) result.push({ ...data, source: remoteSource, path: segment.path, local: false, tailBytes: segment.tailBytes, commit });
|
|
219
|
+
else accept(remoteSource, segment.path, data.bytes, commit, segment.tailBytes, data.stamp);
|
|
220
|
+
}
|
|
221
|
+
} else if (options.previousCommit) failure();
|
|
222
|
+
}
|
|
223
|
+
const returned = new Set(result.map(key));
|
|
224
|
+
for (const receipt of [...checkpoints.values(), ...(limits?.values() ?? [])]) if (!returned.has(key(receipt))) failure();
|
|
225
|
+
// Checkpointed complete prefixes were already validated; their full hashes prove unchanged bytes.
|
|
226
|
+
// Only newly completed suffixes need parsing on an incremental read.
|
|
227
|
+
const suffixes = result.map(segment => {
|
|
228
|
+
const start = checkpoints.get(key(segment))?.offset ?? 0;
|
|
229
|
+
const bytes = segment.bytes.subarray(Math.max(0, start - (segment.start ?? 0)));
|
|
230
|
+
return { ...segment, bytes, offset: bytes.length, hash: digest(bytes) };
|
|
231
|
+
});
|
|
232
|
+
legacyEntries(suffixes, { onMalformed: options.onMalformed });
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function capturedIdentity(path: string): string {
|
|
237
|
+
const before = lstatSync(path);
|
|
238
|
+
if (before.isSymbolicLink() || !before.isFile()) failure();
|
|
239
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
240
|
+
try {
|
|
241
|
+
const stat = fstatSync(fd);
|
|
242
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) failure();
|
|
243
|
+
const bytes = Buffer.alloc(stat.size);
|
|
244
|
+
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) failure();
|
|
245
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
246
|
+
} finally { closeSync(fd); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
function cacheDirectory(root: string, generation: string): string | undefined {
|
|
251
|
+
if (!/^[a-f0-9]{32}$/.test(generation)) failure();
|
|
252
|
+
const cache = join(root, "generations", generation, "bridge.git");
|
|
253
|
+
for (const path of [join(root, "generations"), join(root, "generations", generation), cache]) if (!directory(path, true)) return undefined;
|
|
254
|
+
return cache;
|
|
255
|
+
}
|
|
256
|
+
function cachedGit(cache: string, args: string[], allowMissing = false): Buffer | undefined {
|
|
257
|
+
const argv = memoryGitArgv(["--git-dir", cache, ...args]);
|
|
258
|
+
const run = spawnSync(argv[0]!, argv.slice(1), { env: memoryGitEnv({ ...process.env, GIT_OPTIONAL_LOCKS: "0" }, { repositoryIsolation: true }), encoding: "buffer", timeout: 10_000, maxBuffer: 256 * 1024 * 1024 });
|
|
259
|
+
if (run.error || run.signal || run.status !== 0) { if (allowMissing && !run.error && !run.signal && run.status === 1) return undefined; failure(); }
|
|
260
|
+
return run.stdout;
|
|
261
|
+
}
|
|
262
|
+
/** Last explicitly fetched legacy main, not a claim about the current remote state. Never fetches or writes. */
|
|
263
|
+
function head(homeM: string, generation: string): string | undefined {
|
|
264
|
+
const root = realPath(homeM); directory(root);
|
|
265
|
+
const dir = fixedGeneration(root, generation);
|
|
266
|
+
if (!directory(dir, true)) return undefined;
|
|
267
|
+
const active = readProfileJSON(join(dir, "bridge-snapshot.json"), 4096) as { schema?: unknown; generation?: unknown; commit?: unknown } | undefined;
|
|
268
|
+
if (!active) return undefined;
|
|
269
|
+
if (active.schema !== "agent-mem.legacy-head/v1" || active.generation !== generation || typeof active.commit !== "string" || !commitId(active.commit)) failure();
|
|
270
|
+
archived(root, generation, active.commit);
|
|
271
|
+
return active.commit;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
|
|
276
|
+
try { return snapshot(homeM, options); } catch { return failure(); }
|
|
277
|
+
}
|
|
278
|
+
export function legacyHead(homeM: string, generation: string): string | undefined {
|
|
279
|
+
try { return head(homeM, generation); } catch { return failure(); }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
// ponytail: immutable historical snapshots remain while migration/resume and prior-commit receipts can reference them.
|
|
284
|
+
// Disk use grows with sync history; add explicit journal-aware GC or content-addressed reuse if measurements justify it.
|
|
285
|
+
interface ArchivedSegment { path: string; offset: number; hash: string; tailBytes: number }
|
|
286
|
+
interface LegacyArchive { schema: "agent-mem.legacy-snapshot/v1"; generation: string; commit: string; previousCommit?: string; segments: ArchivedSegment[] }
|
|
287
|
+
function fixedGeneration(root: string, generation: string): string {
|
|
288
|
+
if (!/^[a-f0-9]{32}$/.test(generation)) failure();
|
|
289
|
+
const parent = join(root, "generations");
|
|
290
|
+
if (directory(parent, true)) directory(join(parent, generation), true);
|
|
291
|
+
return join(parent, generation);
|
|
292
|
+
}
|
|
293
|
+
function archiveDir(root: string, generation: string, commit: string): string {
|
|
294
|
+
if (!commitId(commit)) failure();
|
|
295
|
+
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
296
|
+
directory(dir); const target = join(dir, commit); directory(target); return target;
|
|
297
|
+
}
|
|
298
|
+
function archived(root: string, generation: string, commit: string): LegacyArchive {
|
|
299
|
+
const value = readProfileJSON(join(archiveDir(root, generation, commit), "snapshot.json"), 32 * 1024 * 1024) as LegacyArchive | undefined;
|
|
300
|
+
if (!value || value.schema !== "agent-mem.legacy-snapshot/v1" || value.generation !== generation || value.commit !== commit || value.previousCommit !== undefined && !commitId(value.previousCommit) || !Array.isArray(value.segments)) failure();
|
|
301
|
+
const paths = new Set<string>();
|
|
302
|
+
for (const segment of value.segments) {
|
|
303
|
+
if (!segment || !segmentPath(segment.path) || !segment.path.startsWith("log/") || paths.has(segment.path) || !Number.isSafeInteger(segment.offset) || segment.offset < 0 || !/^[a-f0-9]{64}$/.test(segment.hash) || !Number.isSafeInteger(segment.tailBytes) || segment.tailBytes < 0) failure();
|
|
304
|
+
paths.add(segment.path);
|
|
305
|
+
}
|
|
306
|
+
return value;
|
|
307
|
+
}
|
|
308
|
+
function archivedAncestor(root: string, generation: string, previous: string, commit: string): boolean {
|
|
309
|
+
const visited = new Set<string>();
|
|
310
|
+
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor).previousCommit) {
|
|
311
|
+
if (cursor === previous) return true;
|
|
312
|
+
visited.add(cursor);
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Explicit sync-only Git capture. Ordinary readLegacySnapshot/legacyHead never call Git. */
|
|
318
|
+
export function readLegacyGitSnapshot(homeM: string, generation: string, commit: string, previousCommit?: string): LegacySegment[] {
|
|
319
|
+
try {
|
|
320
|
+
const root = realPath(homeM), cache = cacheDirectory(root, generation);
|
|
321
|
+
if (!cache || !commitId(commit) || previousCommit !== undefined && !commitId(previousCommit)) failure();
|
|
322
|
+
if (cachedGit(cache, ["cat-file", "-t", commit])?.toString("utf8").trim() !== "commit") failure();
|
|
323
|
+
if (previousCommit && previousCommit !== commit) cachedGit(cache, ["merge-base", "--is-ancestor", previousCommit, commit]);
|
|
324
|
+
const result: LegacySegment[] = [];
|
|
325
|
+
for (const record of cachedGit(cache, ["ls-tree", "-rz", "--full-tree", commit, "--", "log"])!.toString("utf8").split("\0").filter(Boolean)) {
|
|
326
|
+
const match = /^([0-7]{6}) blob ([a-f0-9]{40})\t(.+)$/.exec(record);
|
|
327
|
+
if (!match || match[1] !== "100644" && match[1] !== "100755" || !segmentPath(match[3]!) || !match[3]!.startsWith("log/")) return failure();
|
|
328
|
+
const blob = cachedGit(cache, ["cat-file", "blob", match[2]!])!, end = blob.lastIndexOf(10) + 1, bytes = blob.subarray(0, end);
|
|
329
|
+
result.push({ source: remoteSource, path: match[3]!, offset: end, hash: digest(bytes), bytes, local: false, tailBytes: blob.length - end, commit });
|
|
330
|
+
}
|
|
331
|
+
if (previousCommit) for (const old of archived(root, generation, previousCommit).segments) {
|
|
332
|
+
const next = result.find(segment => segment.path === old.path);
|
|
333
|
+
if (!next || next.offset < old.offset || digest(next.bytes.subarray(0, old.offset)) !== old.hash) failure();
|
|
334
|
+
}
|
|
335
|
+
legacyEntries(result); return result;
|
|
336
|
+
} catch { return failure(); }
|
|
337
|
+
}
|
|
338
|
+
function immutable(path: string, bytes: Buffer): void {
|
|
339
|
+
const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(16).toString("hex")}.tmp`);
|
|
340
|
+
const fd = openSync(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 0o600);
|
|
341
|
+
try {
|
|
342
|
+
try { let at = 0; while (at < bytes.length) { const wrote = writeSync(fd, bytes, at, bytes.length - at); if (wrote <= 0) failure(); at += wrote; } fsyncSync(fd); } finally { closeSync(fd); }
|
|
343
|
+
try { linkSync(temporary, path); }
|
|
344
|
+
catch (error) {
|
|
345
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
346
|
+
const before = lstatSync(path); if (!before.isFile() || before.isSymbolicLink()) failure();
|
|
347
|
+
const existing = openSync(path, constants.O_RDWR | constants.O_NOFOLLOW);
|
|
348
|
+
try {
|
|
349
|
+
const stat = fstatSync(existing);
|
|
350
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || !readFileSync(existing).equals(bytes)) failure();
|
|
351
|
+
// Matching bytes after an interrupted capture still need a durable-data fence before activation.
|
|
352
|
+
fsyncSync(existing);
|
|
353
|
+
} finally { closeSync(existing); }
|
|
354
|
+
}
|
|
355
|
+
fsyncDirectory(dirname(path));
|
|
356
|
+
} finally { try { unlinkSync(temporary); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } }
|
|
357
|
+
}
|
|
358
|
+
/** Under the generation mutation fence, publish immutable cached blobs only after explicit sync's fixed-ref CAS. */
|
|
359
|
+
export function captureLegacyMain(homeM: string, generation: string, commit: string, previousCommit?: string): void {
|
|
360
|
+
try {
|
|
361
|
+
const root = realPath(homeM), current = legacyHead(root, generation), cache = cacheDirectory(root, generation);
|
|
362
|
+
if (!cache || previousCommit !== current && current !== commit) failure();
|
|
363
|
+
if (cachedGit(cache, ["rev-parse", "--verify", "refs/remotes/legacy/main^{commit}"])?.toString("utf8").trim() !== commit) failure();
|
|
364
|
+
const segments = readLegacyGitSnapshot(root, generation, commit, current);
|
|
365
|
+
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
366
|
+
for (const target of [dir, join(dir, commit), join(dir, commit, "log")]) {
|
|
367
|
+
if (!directory(target, true)) mkdirSync(target, { mode: 0o700 });
|
|
368
|
+
}
|
|
369
|
+
for (const segment of segments) immutable(join(dir, commit, segment.path), segment.bytes);
|
|
370
|
+
const prior = current === commit ? archived(root, generation, commit).previousCommit : current;
|
|
371
|
+
const manifest: LegacyArchive = { schema: "agent-mem.legacy-snapshot/v1", generation, commit, ...(prior ? { previousCommit: prior } : {}), segments: segments.map(({ path, offset, hash, tailBytes }) => ({ path, offset, hash, tailBytes })) };
|
|
372
|
+
immutable(join(dir, commit, "snapshot.json"), Buffer.from(canonicalJSON(manifest) + "\n"));
|
|
373
|
+
for (const target of [join(dir, commit, "log"), join(dir, commit), dir]) fsyncDirectory(target);
|
|
374
|
+
writeProfileJSON(join(fixedGeneration(root, generation), "bridge-snapshot.json"), { schema: "agent-mem.legacy-head/v1", generation, commit });
|
|
375
|
+
} catch { return failure(); }
|
|
376
|
+
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface MemoryCommandOptions {
|
|
|
9
9
|
open: () => MemoryStore;
|
|
10
10
|
readable: (notes: View[]) => View[];
|
|
11
11
|
importPolicy: () => (text: string) => boolean;
|
|
12
|
+
/** Explicit person-status receipt; model-facing hosts omit this callback. */
|
|
13
|
+
onBridgeStatus?: (status: { skippedRows: number } | undefined) => void;
|
|
12
14
|
}
|
|
13
15
|
export interface GoalCommandOptions {
|
|
14
16
|
store?: MemoryStore;
|
|
@@ -129,7 +131,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
129
131
|
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`; }
|
|
130
132
|
default: return fail(MEMORY_USAGE);
|
|
131
133
|
}
|
|
132
|
-
} finally {
|
|
134
|
+
} finally {
|
|
135
|
+
try { opts.onBridgeStatus?.(store.bridgeStatus()); }
|
|
136
|
+
finally { if (!opts.store) store.close(); }
|
|
137
|
+
}
|
|
133
138
|
}
|
|
134
139
|
|
|
135
140
|
const goalOne = (g: GoalView, me: string) => `${g.id.slice(0, 8)} ${g.state.padEnd(7)} ${g.title}${g.scope === "shared" ? " [shared]" : ""} ${leaseText(g, me)}`;
|