@staix/agent-mem 0.13.0 → 0.14.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/.claude-plugin/plugin.json +1 -1
- package/README.md +21 -2
- package/package.json +3 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +1 -0
- package/src/memory/catalog.ts +4 -4
- package/src/memory/commands.ts +4 -0
- package/src/memory/doctor.ts +13 -3
- package/src/memory/generation-index.ts +56 -11
- package/src/memory/generation.ts +73 -7
- package/src/memory/migrate.ts +1 -0
- package/src/memory/mutation.ts +24 -4
- package/src/memory/prune-publisher-lineage.ts +139 -0
- package/src/memory/public-prune-activation.ts +260 -0
- package/src/memory/public-prune-advance.ts +149 -0
- package/src/memory/public-prune-input-chain.ts +73 -0
- package/src/memory/public-prune-inputs.ts +455 -0
- package/src/memory/public-prune-view.ts +139 -0
- package/src/memory/public-prune-witness.ts +168 -0
- package/src/memory/reader-lease.ts +46 -34
- package/src/memory/shard-migration.ts +4 -3
- package/src/memory/shard-sync-format.ts +40 -2
- package/src/memory/shard-sync-graph.ts +87 -0
- package/src/memory/shard-sync.ts +341 -53
- package/src/memory/shard.ts +50 -9
- package/src/memory/store.ts +46 -8
- package/src/memory/sync.ts +141 -5
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.14.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -300,7 +300,7 @@ Prune apply still requires a sealed JSON plan. Save `amem prune --dry-run --cuto
|
|
|
300
300
|
|
|
301
301
|
Intake runs at the end of each Codex, Kimi, Pi and Qwen session once `amem setup <cli> --apply` has registered it. Sync is off unless you ask for it: amem registers no sync hook and runs nothing between sessions. To sync on a schedule you have authorized, call `amem sync --if-due` from the chosen shell or scheduler; it has no TTY gate. This release registers no sync schedule or hook, and the configured privacy and remote-visibility checks still apply. It syncs at most hourly, backs off 15 minutes doubling to a day after failures, stops after 30 seconds, and does nothing without a remote set by `amem sync --remote <private git url>`. `--force` skips the schedule and the backoff.
|
|
302
302
|
|
|
303
|
-
|
|
303
|
+
V1 split-home sync durably holds verified foreign-generation prunes with a "held, not applied" warning. The opt-in v2 path below applies eligible complete same-realm public prunes across generations after durable proof and root-map commitment. Unsupported historical lineage and multi-realm translations remain held; targets can remain visible and agent-mem#15 AC4 stays open (see [the sync contract](docs/contracts.md#per-shard-private-sync-15-t3)).
|
|
304
304
|
|
|
305
305
|
## Native transcript intake
|
|
306
306
|
|
|
@@ -481,3 +481,22 @@ Run `amem consolidate [--project <current-key>]` (another project is refused) to
|
|
|
481
481
|
The CLI runs the deterministic pass only. A library host may explicitly enable an injected model callback for the person; no provider is built in. It has batch, byte, deadline and persistent daily limits. Model answers remain marked machine-local candidates that cannot be accepted; `amem note add` is the separate path for an independently authored note. A non-answer is never saved.
|
|
482
482
|
|
|
483
483
|
Agents use `amem_evidence` with a question to get cited, live curated entries, not an answer. The complete JSON bundle is byte-capped (`maxBytes`, default 4096, range 256..8192); excerpted entries retain an exact `mem:` reference for fetching the full note. Capture, repository-only rows, pending candidates and model answers are excluded. amem makes no model call for this tool. Rendering a citation in the bundle records only `rendered` after successful output, never `delivered` or `cited`.
|
|
484
|
+
|
|
485
|
+
### Public prune view and v2 sync (opt-in)
|
|
486
|
+
|
|
487
|
+
On an active curated split, the person can preview and enable the forward-only v2 reader floor. This keeps the receiving generation and native logs/seals, builds a separate `index-v2.db`, and preserves every native/local mask. Once activated, explicit sync uses the v2 transport and can install complete generation-neutral public-prune proof; historical hold receipts alone never authorize a mask.
|
|
488
|
+
|
|
489
|
+
```sh
|
|
490
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --dry-run
|
|
491
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --apply
|
|
492
|
+
# Finish an interrupted activation, using its exact recorded forward state.
|
|
493
|
+
AMEM_HOME=/path/to/synthetic-memory amem migrate --public-prune-view --resume
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
Dry-run writes nothing and reports mask building as deferred. Close cooperating reader handles for initial v1-to-v2 activation or its resume, and install compatible hosts first: older readers reject the new markers and transport tip. Initial activation preserves all native/local masks and currently performs ten old/new comparison rebuilds per realm across five verification passes, plus snapshot/index work. Deactivation is refused because v1 would omit foreign masks. Unannounced historical readers can still inspect unchanged old bytes; this is logical masking, not erasure.
|
|
497
|
+
|
|
498
|
+
The first explicit v2 `amem sync` primes a private create-once publisher boundary for each selected shard/current machine. Only complete own prune rows appended after that boundary can receive new `post-seal` lineage; all earlier rows and unsupported historical lineage stay ineligible. Sync never infers a native-generation claim or rewrites native seals/rows. Both machines need compatible v2 readers and explicit sync. Stable exact project/user refs make a one-way v1-to-v2 history transition; returning a ref to v1 is refused.
|
|
499
|
+
|
|
500
|
+
Sync validates all selected history and complete raw Git proof, and checks each realm's aggregate input/archive capacity before atomic publication. It then fsyncs immutable inputs and commits the root map before ordinary/native imports, preventing an unmasked first import. Already-v2 map advances preserve generation/format and monotone ancestry, use a forward-only intent and rebind existing readers without requiring them to close. A reader waits at most 3000 ms for a v2 writer; an unresolved intent, incompatible marker pair or missing committed proof reports unavailable. The same `--resume` command completes an interrupted advance forward. Read and capture never activate, prime a boundary or repair a pending operation.
|
|
501
|
+
|
|
502
|
+
`SyncResult.pruneApplication` reports transport durability, complete-proof count, neutral projection activity, native application, held count and phase separately. Transport durability alone does not establish completed local application. Historical multi-realm translations and older unsupported lineage retain "held, not applied" warnings, so agent-mem#15 AC4 remains open. The [full contract](docs/generation-neutral-prune-witness.md) states the owned-source integrity and resource ceilings; this source behavior does not establish release or live-host verification.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
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",
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"@staix/agent-mem-v02": "npm:@staix/agent-mem@0.2.0",
|
|
19
19
|
"@staix/agent-mem-pre-vault": "npm:@staix/agent-mem@0.3.1",
|
|
20
20
|
"@staix/agent-mem-v011": "npm:@staix/agent-mem@0.11.0",
|
|
21
|
-
"@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0"
|
|
21
|
+
"@staix/agent-mem-v012": "npm:@staix/agent-mem@0.12.0",
|
|
22
|
+
"@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0"
|
|
22
23
|
},
|
|
23
24
|
"exports": {
|
|
24
25
|
".": "./src/index.ts",
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
|
|
1
2
|
import { agentCliRead } from "./cli-agent-reads.ts";
|
|
2
3
|
import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -344,6 +345,12 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
344
345
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
345
346
|
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
346
347
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
348
|
+
if (options["--public-prune-view"] === true) {
|
|
349
|
+
if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
|
|
350
|
+
if (actor === "agent" && mode !== "dry-run") administrative({ command: "migrate --public-prune-view", mode });
|
|
351
|
+
const result = await activatePublicPruneView({ memoryDir: memoryHome(), mode: mode as "dry-run" | "apply" | "resume", authorizedPerson: true, tty: true });
|
|
352
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `public prune view: ${result.mode}; generation ${result.generation}; mask build ${result.maskBuildCheck}; forward-only, no deactivation`;
|
|
353
|
+
}
|
|
347
354
|
const shardRequested = options["--shards"] === true || (mode === "resume" && shardStatePresent());
|
|
348
355
|
if (shardRequested) {
|
|
349
356
|
if (mode === "deactivate") throw new Error("--deactivate is not a shard migration mode");
|
|
@@ -882,7 +889,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
882
889
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
883
890
|
try {
|
|
884
891
|
output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
|
|
885
|
-
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
892
|
+
sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
886
893
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
887
894
|
} catch (error) {
|
|
888
895
|
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
package/src/commands-table.ts
CHANGED
|
@@ -107,6 +107,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
107
107
|
...json, "--apply": flag("Apply the previewed migration"), "--dry-run": flag("Read-only migration receipt (default; kept as an alias)"), "--generation": text("Explicit 32-hex generation ID"),
|
|
108
108
|
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
109
109
|
"--shards": flag("Migrate the curated home into project and user shards"),
|
|
110
|
+
"--public-prune-view": flag("Enable the forward-only v2 public prune view on an active split"),
|
|
110
111
|
} },
|
|
111
112
|
status: { description: "Report shard migration and pending old-layout appends", options: json },
|
|
112
113
|
doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
|
package/src/memory/catalog.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { chmodSync, existsSync, lstatSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { memoryBase } from "./home.ts";
|
|
5
|
-
import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA } from "./shard.ts";
|
|
5
|
+
import { effectiveAliases, readShardDescriptor, SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
|
|
6
6
|
import { readProfileJSON, validGeneration } from "./generation.ts";
|
|
7
7
|
import { captureShardStats, readCaptureConfig } from "./capture.ts";
|
|
8
8
|
import { openShardStore } from "./store.ts";
|
|
@@ -36,7 +36,7 @@ const fromRow = (r: CatalogRow): CatalogProject => ({ hash: r.hash, key: r.key,
|
|
|
36
36
|
function capturedSelection(base: string): boolean {
|
|
37
37
|
const value = readProfileJSON(join(base, "shards.json")) as { schema?: unknown; generation?: unknown; phase?: unknown; selectorRevision?: unknown } | undefined;
|
|
38
38
|
if (value === undefined) return false;
|
|
39
|
-
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");
|
|
39
|
+
if (!value || (value.schema !== SHARDED_SCHEMA && value.schema !== SHARDED_V2_SCHEMA) || !validGeneration(value.generation) || (value.phase !== "captured" && value.phase !== "active") || typeof value.selectorRevision !== "string") throw new Error("curated shard selection is unavailable or invalid");
|
|
40
40
|
return value.phase === "captured";
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -48,10 +48,10 @@ function curatedStats(base: string, hash: string, key: string, captured: boolean
|
|
|
48
48
|
if (!existsSync(join(dir, "generation.json")) && !existsSync(join(dir, "generation-seal.json"))) return { entries: 0, lastAt: null };
|
|
49
49
|
const store = openShardStore({ memoryDir: base, project: key, deferPromotions: true, lifecycle: "ignore" });
|
|
50
50
|
try {
|
|
51
|
-
const db = new Database(join(dir, "index.db"), { readonly: true });
|
|
51
|
+
const db = new Database(join(dir, store.profile.foreignPruneFormat ? "index-v2.db" : "index.db"), { readonly: true });
|
|
52
52
|
try {
|
|
53
53
|
db.run("PRAGMA busy_timeout = 0");
|
|
54
|
-
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 };
|
|
54
|
+
const row = db.query("SELECT COUNT(*) AS n, MAX(at) AS last FROM entries WHERE scope = 'project' AND project = ?" + (store.profile.foreignPruneFormat ? " AND id NOT IN (SELECT id FROM generation_targets)" : "")).get(key) as { n: number; last: string | null };
|
|
55
55
|
return { entries: row.n, lastAt: row.last };
|
|
56
56
|
} finally { db.close(); }
|
|
57
57
|
} finally { store.close(); }
|
package/src/memory/commands.ts
CHANGED
|
@@ -197,6 +197,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
197
197
|
const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
|
|
198
198
|
if (json) return JSON.stringify(r, null, 2);
|
|
199
199
|
if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
|
|
200
|
+
if (r.pruneApplication) {
|
|
201
|
+
const a = r.pruneApplication;
|
|
202
|
+
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
203
|
+
}
|
|
200
204
|
return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
|
|
201
205
|
}
|
|
202
206
|
case "dedupe": {
|
package/src/memory/doctor.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
1
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
5
|
import { sessionStartBrief } from "../session-brief.ts";
|
|
6
6
|
import { pendingSummary } from "./candidate-pending.ts";
|
|
@@ -87,8 +87,10 @@ class TooBig extends Error {}
|
|
|
87
87
|
function copyTree(from: string, to: string, budget: { bytes: number }, skipNames: ReadonlySet<string> = new Set()): void {
|
|
88
88
|
mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
89
89
|
for (const name of readdirSync(from)) {
|
|
90
|
-
|
|
90
|
+
const proofObject = basename(from) === "public-prune-objects" && /^[a-f0-9]{40}\.git$/.exec(name)?.[0] === name;
|
|
91
|
+
if (SKIP.test(name) && !proofObject || skipNames.has(name)) continue;
|
|
91
92
|
const source = join(from, name), stat = lstatSync(source);
|
|
93
|
+
if (proofObject && !stat.isFile()) throw new Error("a public prune object is not a plain file");
|
|
92
94
|
if (stat.isSymbolicLink()) throw new Error("the memory home holds a link where a plain file or directory is expected");
|
|
93
95
|
if (stat.isDirectory()) copyTree(source, join(to, name), budget, skipNames);
|
|
94
96
|
else if (stat.isFile()) { budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig(); copyFileSync(source, join(to, name)); }
|
|
@@ -103,6 +105,14 @@ function copyCurated(options: DoctorOptions, layout: Layout, scratch: string, bu
|
|
|
103
105
|
const stat = statSync(join(home, name)); budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
104
106
|
copyFileSync(join(home, name), join(scratch, name));
|
|
105
107
|
}
|
|
108
|
+
const selected = readSelector(home).selector;
|
|
109
|
+
if (selected?.schema === "agent-mem.profile/v2") {
|
|
110
|
+
const name = `${selected.pruneInputSetHash}.json`, source = join(home, "public-prune-input-sets", name), stat = lstatSync(source);
|
|
111
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error("the committed public prune view is not a plain file");
|
|
112
|
+
budget.bytes += stat.size; if (budget.bytes > COPY_LIMIT_BYTES) throw new TooBig();
|
|
113
|
+
const directory = join(scratch, "public-prune-input-sets"); mkdirSync(directory, { mode: 0o700 });
|
|
114
|
+
copyFileSync(source, join(directory, name)); chmodSync(join(directory, name), 0o600);
|
|
115
|
+
}
|
|
106
116
|
if (layout === "split") {
|
|
107
117
|
for (const key of [options.project, ...options.aliases]) {
|
|
108
118
|
const dir = shardDir(home, key);
|
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createPublicPruneReplayCache, type PublicPruneProjection } from "./public-prune-inputs.ts";
|
|
2
|
+
import { readPublicPruneView, readPublicPruneViewForActivation } from "./public-prune-view.ts";
|
|
3
|
+
import { SHARDED_SCHEMA, SHARDED_V2_SCHEMA } from "./shard.ts";
|
|
2
4
|
import { readShardLegacySnapshot } from "./shard-bridge.ts";
|
|
3
5
|
import type { Database } from "bun:sqlite";
|
|
4
6
|
import { createHash } from "node:crypto";
|
|
5
7
|
import { closeSync, constants, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
6
8
|
import { join } from "node:path";
|
|
7
|
-
import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink } from "./generation.ts";
|
|
9
|
+
import { canonicalJSON, hashJSON, fsyncDirectory, readProfileJSON, selectorRevision, writeProfileJSON, type BoundProfile, validateVaultLinkRow, type VaultLink, rebindPublicPruneProfile } from "./generation.ts";
|
|
8
10
|
import { legacyEntries, legacyHead, readLegacySnapshot, SegmentReader, type LegacyCheckpoint, type LegacySegment } from "./bridge.ts";
|
|
9
11
|
import { reducePruneRows, validatePruneRow, type PrunePlan, type PruneRow, type PruneSnapshot, type PruneSourceRow, type PruneStorePort } from "./prune.ts";
|
|
10
12
|
import { PROMOTED_KINDS, type Entry } from "./store.ts";
|
|
11
13
|
|
|
14
|
+
const FOREIGN_TABLES = ["generation_foreign_inputs", "generation_foreign_rows", "generation_foreign_targets"] as const;
|
|
12
15
|
const digest = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
|
13
16
|
const fail = (): never => { throw new Error("generation input unavailable or changed; person inspection required"); };
|
|
17
|
+
class PublicPruneViewChanged extends Error { constructor() { super("public prune view changed repeatedly; retry the read"); } }
|
|
14
18
|
export const GENERATION_TABLES = ["generation_vault_links", "generation_rows", "generation_prunes", "generation_targets", "generation_segments", "generation_state"] as const;
|
|
15
19
|
export interface GenerationIndexOptions {
|
|
16
20
|
profile: BoundProfile; db: Database; now: () => number;
|
|
@@ -65,9 +69,17 @@ export class GenerationIndex {
|
|
|
65
69
|
private fullCapture = false;
|
|
66
70
|
private retainedTail = false;
|
|
67
71
|
private replayProvenance = false;
|
|
72
|
+
private foreignStamp?: string;
|
|
73
|
+
private foreignCache?: { set: string; load: () => PublicPruneProjection };
|
|
68
74
|
constructor(private readonly options: GenerationIndexOptions) {
|
|
69
75
|
this.profile = options.profile;
|
|
70
76
|
const db = options.db;
|
|
77
|
+
if (this.profile.foreignPruneFormat) {
|
|
78
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_inputs (id TEXT PRIMARY KEY)");
|
|
79
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_rows (id TEXT PRIMARY KEY, hash TEXT NOT NULL)");
|
|
80
|
+
db.run("CREATE TABLE IF NOT EXISTS generation_foreign_targets (id TEXT PRIMARY KEY)");
|
|
81
|
+
this.replayProvenance = true; // persistent SQLite never supplies foreign membership
|
|
82
|
+
}
|
|
71
83
|
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)");
|
|
72
84
|
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; }
|
|
73
85
|
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)");
|
|
@@ -93,9 +105,14 @@ export class GenerationIndex {
|
|
|
93
105
|
/** Authoring handles cannot read or append a partially resumed split; migration index bootstraps use captured limits. */
|
|
94
106
|
private shardFrontier(): string | undefined {
|
|
95
107
|
if (!this.profile.sharded || this.options.limits !== undefined) return undefined;
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
108
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
109
|
+
rebindPublicPruneProfile(this.profile);
|
|
110
|
+
const value = readProfileJSON(join(this.profile.home, "shards.json")) as { schema?: unknown; phase?: unknown; generation?: unknown; selectorRevision?: unknown } | undefined;
|
|
111
|
+
if (selectorRevision(this.profile.home) !== this.profile.selectorRevision) { if (this.profile.foreignPruneFormat) continue; return fail(); }
|
|
112
|
+
if (!value || value.schema !== (this.profile.foreignPruneFormat ? SHARDED_V2_SCHEMA : SHARDED_SCHEMA) || value.phase !== "active" || value.generation !== this.profile.generation || value.selectorRevision !== this.profile.selectorRevision) return fail();
|
|
113
|
+
return hashJSON(value);
|
|
114
|
+
}
|
|
115
|
+
return fail();
|
|
99
116
|
}
|
|
100
117
|
private capture(reader: SegmentReader = this.reader): { segments: LegacySegment[]; commit?: string } {
|
|
101
118
|
const shardBefore = this.shardFrontier();
|
|
@@ -119,7 +136,7 @@ export class GenerationIndex {
|
|
|
119
136
|
const segment = own.find(s => s.path === old.path);
|
|
120
137
|
if (!segment || segment.offset < old.offset) fail();
|
|
121
138
|
}
|
|
122
|
-
if (this.shardFrontier() !== shardBefore) return fail();
|
|
139
|
+
if (this.shardFrontier() !== shardBefore) { if (this.profile.foreignPruneFormat) throw new PublicPruneViewChanged(); return fail(); }
|
|
123
140
|
return { segments: [...legacy, ...own], ...(commit ? { commit } : {}) };
|
|
124
141
|
}
|
|
125
142
|
private index(captured: { segments: LegacySegment[]; commit?: string }): void {
|
|
@@ -161,7 +178,35 @@ export class GenerationIndex {
|
|
|
161
178
|
const reduced = reducePruneRows(prunes, snapshot, this.profile.generation!);
|
|
162
179
|
db.run("DELETE FROM generation_targets");
|
|
163
180
|
for (const id of [...reduced.targets].sort()) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
|
|
164
|
-
this.
|
|
181
|
+
this.applyForeign(true);
|
|
182
|
+
this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
|
|
183
|
+
}
|
|
184
|
+
private applyForeign(validateRows = false): void {
|
|
185
|
+
if (!this.profile.foreignPruneFormat) return;
|
|
186
|
+
const profile = this.profile;
|
|
187
|
+
if (!profile.pruneInputSetHash || !profile.generation || !profile.sharded) return fail();
|
|
188
|
+
const view = (this.options.limits !== undefined ? readPublicPruneViewForActivation : readPublicPruneView)(profile.home, profile.pruneInputSetHash, profile.generation);
|
|
189
|
+
const key = profile.shardUser ? "user" : `project:${profile.shardKey}`, set = view.realms[key];
|
|
190
|
+
const db = this.options.db;
|
|
191
|
+
if (!set) {
|
|
192
|
+
if ((db.query("SELECT COUNT(*) AS n FROM generation_foreign_inputs").get() as {n:number}).n) return fail();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!this.foreignCache || this.foreignCache.set !== set) this.foreignCache = { set, load: createPublicPruneReplayCache({ dir: profile.dir,
|
|
196
|
+
realm: profile.shardUser ? { scope: "user" } : { scope: "project", key: profile.shardKey! }, receivingGeneration: profile.generation, inputSetHash: set }) };
|
|
197
|
+
const proof = this.foreignCache.load();
|
|
198
|
+
if (validateRows || this.foreignStamp !== set) for (const item of db.query("SELECT id,line FROM generation_rows").all() as {id:string;line:string}[]) {
|
|
199
|
+
const expected = proof.rowHashes.get(item.id); if (expected && hashJSON(JSON.parse(item.line)) !== expected) return fail();
|
|
200
|
+
}
|
|
201
|
+
if (validateRows || this.foreignStamp !== set) {
|
|
202
|
+
for (const table of FOREIGN_TABLES) db.run(`DELETE FROM ${table}`);
|
|
203
|
+
for (const id of proof.inputs) db.query("INSERT INTO generation_foreign_inputs VALUES (?)").run(id);
|
|
204
|
+
for (const [id, hash] of proof.rowHashes) db.query("INSERT INTO generation_foreign_rows VALUES (?,?)").run(id, hash);
|
|
205
|
+
for (const id of proof.targets) db.query("INSERT INTO generation_foreign_targets VALUES (?)").run(id);
|
|
206
|
+
this.foreignStamp = set;
|
|
207
|
+
}
|
|
208
|
+
const current = new Set((db.query("SELECT id FROM generation_targets").all() as {id:string}[]).map(row => row.id));
|
|
209
|
+
for (const id of proof.targets) if (!current.has(id)) db.query("INSERT INTO generation_targets VALUES (?)").run(id);
|
|
165
210
|
}
|
|
166
211
|
private recordSkipped(segment: LegacySegment): void {
|
|
167
212
|
this.options.db.query("INSERT INTO generation_state VALUES (?,'1') ON CONFLICT(key) DO UPDATE SET value=CAST(CAST(value AS INTEGER)+1 AS TEXT)").run(`skipped:${segment.source}:${segment.path}`);
|
|
@@ -183,12 +228,12 @@ export class GenerationIndex {
|
|
|
183
228
|
const before = this.frontier(), db = this.options.db;
|
|
184
229
|
let input: ReturnType<GenerationIndex["capture"]>;
|
|
185
230
|
try { input = this.capture(reader); }
|
|
186
|
-
catch (error) { if (attempt < 5 && this.frontier() !== before) return this.refresh(attempt + 1, reader); throw error; }
|
|
231
|
+
catch (error) { if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.refresh(attempt + 1, reader); throw error; }
|
|
187
232
|
if (this.frontier() !== before) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
|
|
188
233
|
const known = db.query("SELECT source,path,offset,hash,commit_id,stamp FROM generation_segments").all() as { source: string; path: string; offset: number; hash: string; commit_id: string | null; stamp: string | null }[];
|
|
189
234
|
const currentCommit = (db.query("SELECT value FROM generation_state WHERE key='legacyCommit'").get() as { value: string } | null)?.value;
|
|
190
235
|
const unchanged = known.length === input.segments.length && currentCommit === input.commit && input.segments.every(segment => known.some(row => row.source === segment.source && row.path === segment.path && row.offset === segment.offset && row.hash === segment.hash && row.commit_id === (segment.commit ?? null) && row.stamp === (segment.stamp ?? null)));
|
|
191
|
-
if (unchanged) { this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
|
|
236
|
+
if (unchanged) { if (this.profile.foreignPruneFormat) db.transaction(() => this.applyForeign()).deferred(); this.retainedTail = input.segments.some(segment => segment.tailBytes > 0); this.pruned = new Set((db.query("SELECT id FROM generation_targets").all() as { id: string }[]).map(row => row.id)); return; }
|
|
192
237
|
const committed = db.transaction(() => { if (this.frontier() !== before) return false; this.index(input); return true; }).immediate();
|
|
193
238
|
if (!committed) { if (attempt < 5) return this.refresh(attempt + 1, reader); return fail(); }
|
|
194
239
|
this.retainedTail = input.segments.some(segment => segment.tailBytes > 0);
|
|
@@ -197,11 +242,11 @@ export class GenerationIndex {
|
|
|
197
242
|
const before = this.frontier();
|
|
198
243
|
let input: ReturnType<GenerationIndex["capture"]>;
|
|
199
244
|
try { this.fullCapture = true; input = this.capture(reader); }
|
|
200
|
-
catch (error) { this.fullCapture = false; if (attempt < 5 && this.frontier() !== before) return this.rebuild(attempt + 1, reader); throw error; }
|
|
245
|
+
catch (error) { this.fullCapture = false; if (attempt < 5 && (error instanceof PublicPruneViewChanged || this.frontier() !== before)) return this.rebuild(attempt + 1, reader); throw error; }
|
|
201
246
|
finally { this.fullCapture = false; }
|
|
202
247
|
const committed = this.options.db.transaction(() => {
|
|
203
248
|
if (this.frontier() !== before) return false;
|
|
204
|
-
for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES]) this.options.db.run(`DELETE FROM ${table}`);
|
|
249
|
+
for (const table of ["entries", "ops", "edges", "goals", "segments", "heads", ...GENERATION_TABLES, ...(this.profile.foreignPruneFormat ? FOREIGN_TABLES : [])]) this.options.db.run(`DELETE FROM ${table}`);
|
|
205
250
|
this.index(input); return true;
|
|
206
251
|
}).immediate();
|
|
207
252
|
if (!committed) { if (attempt < 5) return this.rebuild(attempt + 1, reader); return fail(); }
|
package/src/memory/generation.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
|
+
import { assertNoPublicPruneIntent, readPublicPruneView, waitForPublicPruneWriter, PUBLIC_PRUNE_INTENT } from "./public-prune-view.ts";
|
|
1
2
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
3
|
import { types } from "node:util";
|
|
3
4
|
import { closeSync, existsSync, fsyncSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
5
|
import { readerAdmissionDatabase } from "./mutation.ts";
|
|
5
6
|
import { dirname, join } from "node:path";
|
|
6
7
|
import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
|
|
8
|
+
import { shardDir, userShardDir } from "./shard.ts";
|
|
9
|
+
import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
|
|
7
10
|
|
|
8
11
|
export interface BoundProfile {
|
|
9
12
|
kind: "shared-v1" | "generation";
|
|
@@ -12,6 +15,9 @@ export interface BoundProfile {
|
|
|
12
15
|
dir: string;
|
|
13
16
|
generation?: string;
|
|
14
17
|
selectorRevision: string;
|
|
18
|
+
/** Explicit v2 projection only, never inferred from an archive or caller flag. */
|
|
19
|
+
foreignPruneFormat?: "agent-mem.public-prune-witness/v1";
|
|
20
|
+
pruneInputSetHash?: string;
|
|
15
21
|
/** Sharded profiles read only their own legacy input; the root home remains the machine/selector identity. */
|
|
16
22
|
legacyHome?: string;
|
|
17
23
|
/** Public legacy witnesses stay distinct from same-machine private bridge input. */
|
|
@@ -21,7 +27,8 @@ export interface BoundProfile {
|
|
|
21
27
|
shardUser?: boolean;
|
|
22
28
|
}
|
|
23
29
|
export type ProfileInput = "shared-v1" | { generation: string };
|
|
24
|
-
export
|
|
30
|
+
export type ProfileSelector = { schema: "agent-mem.profile/v1"; kind: "shared-v1" | "generation"; generation?: string }
|
|
31
|
+
| { schema: "agent-mem.profile/v2"; kind: "generation"; generation: string; foreignPruneFormat: "agent-mem.public-prune-witness/v1"; pruneInputSetHash: string };
|
|
25
32
|
export interface GenerationDescriptor { schema: "agent-mem.generation/v1"; generation: string; legacySchema: "agent-hub.memory/v1" }
|
|
26
33
|
export interface GenerationSeal { schema: "agent-mem.generation-seal/v1"; generation: string; descriptorHash: string; sourceDigest: string }
|
|
27
34
|
export const PROFILE_LOCK_TIMEOUT_MS = 3000;
|
|
@@ -44,16 +51,69 @@ export function readProfileJSON(path: string, limit = LOCAL_METADATA_MAX_BYTES):
|
|
|
44
51
|
} catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; return unavailable(); }
|
|
45
52
|
}
|
|
46
53
|
function keys(value: object, allowed: string[]): boolean { return Object.keys(value).every(key => allowed.includes(key)); }
|
|
47
|
-
|
|
54
|
+
function selectorSnapshot(memoryDir: string, deadline: number): { selector?: ProfileSelector; revision: string } {
|
|
55
|
+
assertNoPublicPruneIntent(memoryDir, deadline);
|
|
48
56
|
const value = readProfileJSON(join(memoryDir, "profile.json"));
|
|
49
57
|
if (value === undefined) return { revision: "absent" };
|
|
50
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
58
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return unavailable();
|
|
51
59
|
const selector = value as ProfileSelector;
|
|
52
|
-
if (selector.schema
|
|
53
|
-
|
|
60
|
+
if (selector.schema === "agent-mem.profile/v2") {
|
|
61
|
+
if (Object.keys(selector).length !== 5 || !keys(selector, ["schema", "kind", "generation", "foreignPruneFormat", "pruneInputSetHash"])
|
|
62
|
+
|| selector.kind !== "generation" || !validGeneration(selector.generation) || selector.generation.length !== 32
|
|
63
|
+
|| selector.foreignPruneFormat !== "agent-mem.public-prune-witness/v1" || typeof selector.pruneInputSetHash !== "string"
|
|
64
|
+
|| selector.pruneInputSetHash.length !== 64 || !/^[a-f0-9]+$/.test(selector.pruneInputSetHash)) return unavailable();
|
|
65
|
+
const shard = readProfileJSON(join(memoryDir, "shards.json")) as Record<string, unknown> | undefined;
|
|
66
|
+
if (!shard || shard.schema !== "agent-mem.shards/v2" || shard.phase !== "active" || shard.generation !== selector.generation || shard.selectorRevision !== hashJSON(selector)) return unavailable();
|
|
67
|
+
readPublicPruneView(memoryDir, selector.pruneInputSetHash, selector.generation, deadline);
|
|
68
|
+
} else if (selector.schema !== "agent-mem.profile/v1" || !keys(selector, ["schema", "kind", "generation"])
|
|
69
|
+
|| !["shared-v1", "generation"].includes(selector.kind) || (selector.kind === "generation" ? !validGeneration(selector.generation) : selector.generation !== undefined)) return unavailable();
|
|
70
|
+
else if ((readProfileJSON(join(memoryDir, "shards.json")) as {schema?:unknown}|undefined)?.schema === "agent-mem.shards/v2") return unavailable();
|
|
54
71
|
return { selector, revision: hashJSON(selector) };
|
|
55
72
|
}
|
|
73
|
+
|
|
74
|
+
/** A v2 writer can replace its two markers between individual reads. Retry its
|
|
75
|
+
* bounded completed publication rather than exposing a transient mixed pair. */
|
|
76
|
+
export function readSelector(memoryDir: string, absoluteDeadline?: number): { selector?: ProfileSelector; revision: string } {
|
|
77
|
+
const deadline = Math.min(absoluteDeadline ?? Infinity, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
|
|
78
|
+
for (let attempt = 0; attempt < 6 && Date.now() <= deadline; attempt++) {
|
|
79
|
+
try {
|
|
80
|
+
const result = selectorSnapshot(memoryDir, deadline); assertNoPublicPruneIntent(memoryDir, deadline);
|
|
81
|
+
const current = readProfileJSON(join(memoryDir, "profile.json"));
|
|
82
|
+
if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
|
|
85
|
+
if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
|
|
86
|
+
waitForPublicPruneWriter(memoryDir, deadline);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return unavailable();
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
export const selectorRevision = (memoryDir: string): string => readSelector(memoryDir).revision;
|
|
93
|
+
/** Refresh the same shared profile object only across proved monotone same-generation v2 views.
|
|
94
|
+
* Unopened realms contribute set metadata only; their descriptors and bodies stay unopened. */
|
|
95
|
+
export function rebindPublicPruneProfile(profile: BoundProfile): void {
|
|
96
|
+
if (!profile.foreignPruneFormat) return;
|
|
97
|
+
for (let attempt = 0; attempt < 6; attempt++) {
|
|
98
|
+
const current = readSelector(profile.home), selector = current.selector;
|
|
99
|
+
if (current.revision === profile.selectorRevision) return;
|
|
100
|
+
if (!profile.sharded || profile.kind !== "generation" || !profile.generation || !profile.pruneInputSetHash
|
|
101
|
+
|| selector?.schema !== "agent-mem.profile/v2" || selector.generation !== profile.generation || selector.foreignPruneFormat !== profile.foreignPruneFormat) return unavailable();
|
|
102
|
+
const previous = readPublicPruneView(profile.home, profile.pruneInputSetHash, profile.generation);
|
|
103
|
+
const next = readPublicPruneView(profile.home, selector.pruneInputSetHash, profile.generation);
|
|
104
|
+
if (!Object.keys(previous.realms).every(key => Object.hasOwn(next.realms, key))) return unavailable();
|
|
105
|
+
for (const [key, hash] of Object.entries(next.realms)) {
|
|
106
|
+
const old = previous.realms[key]; if (old === hash) continue;
|
|
107
|
+
const dir = key === "user" ? userShardDir(profile.home) : shardDir(profile.home, key.slice(8));
|
|
108
|
+
const chain = readPublicPruneInputSetChain({ dir, receivingGeneration: profile.generation, inputSetHash: hash });
|
|
109
|
+
if (old !== undefined && !chain.commitments.includes(old)) return unavailable();
|
|
110
|
+
}
|
|
111
|
+
if (readSelector(profile.home).revision !== current.revision) continue;
|
|
112
|
+
profile.selectorRevision = current.revision; profile.pruneInputSetHash = selector.pruneInputSetHash;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
return unavailable();
|
|
116
|
+
}
|
|
57
117
|
export function generationDescriptor(generation: string): GenerationDescriptor {
|
|
58
118
|
if (!validGeneration(generation)) return unavailable();
|
|
59
119
|
return { schema: "agent-mem.generation/v1", generation, legacySchema: "agent-hub.memory/v1" };
|
|
@@ -83,8 +143,9 @@ export function validateGeneration(memoryDir: string, generation: string): strin
|
|
|
83
143
|
export function resolveProfile(memoryDir: string, explicit?: ProfileInput): BoundProfile {
|
|
84
144
|
const home = existsSync(memoryDir) ? realPath(memoryDir) : canonicalPath(memoryDir), selected = readSelector(home);
|
|
85
145
|
if (explicit !== undefined && explicit !== "shared-v1" && (!explicit || typeof explicit !== "object" || !keys(explicit, ["generation"]) || !validGeneration(explicit.generation))) return unavailable();
|
|
146
|
+
if (selected.selector?.schema === "agent-mem.profile/v2" && explicit !== undefined) return unavailable();
|
|
86
147
|
const generation = explicit === "shared-v1" ? undefined : explicit?.generation ?? (selected.selector?.kind === "generation" ? selected.selector.generation : undefined);
|
|
87
|
-
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision }
|
|
148
|
+
return generation ? { kind: "generation", home, dir: validateGeneration(home, generation), generation, selectorRevision: selected.revision, ...(selected.selector?.schema === "agent-mem.profile/v2" ? { foreignPruneFormat: selected.selector.foreignPruneFormat, pruneInputSetHash: selected.selector.pruneInputSetHash } : {}) }
|
|
88
149
|
: { kind: "shared-v1", home, dir: home, selectorRevision: selected.revision };
|
|
89
150
|
}
|
|
90
151
|
|
|
@@ -142,7 +203,12 @@ export async function withProfileLock<T>(memoryDir: string, work: () => T | Prom
|
|
|
142
203
|
await new Promise(resolve => setTimeout(resolve, Math.min(25, Math.max(1, deadline - Date.now()))));
|
|
143
204
|
continue;
|
|
144
205
|
}
|
|
145
|
-
try {
|
|
206
|
+
try {
|
|
207
|
+
const result = work();
|
|
208
|
+
// A synchronous publisher must release its lock before a queued sync reader can block this event loop.
|
|
209
|
+
if (result !== null && (typeof result === "object" || typeof result === "function") && typeof (result as PromiseLike<T>).then === "function") return await result;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
146
212
|
finally {
|
|
147
213
|
// A live owner's token cannot be reclaimed; do not remove a file another actor replaced.
|
|
148
214
|
if (existsSync(file) && !lstatSync(file).isSymbolicLink() && readFileSync(file, "utf8") === owner) { unlinkSync(file); fsyncDirectory(memoryDir); }
|
package/src/memory/migrate.ts
CHANGED
|
@@ -105,6 +105,7 @@ function refuseShardSelection(memoryDir: string): void {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
export async function migrate(options: MigrateOptions): Promise<MigrationResult> {
|
|
108
|
+
if (readSelector(options.memoryDir).selector?.schema === "agent-mem.profile/v2") throw new Error("public prune view is forward-only; deactivation is unavailable");
|
|
108
109
|
if (options.authorizedPerson !== true || options.tty !== true) throw new Error("migration requires an authorized person in a terminal");
|
|
109
110
|
if (!["dry-run", "apply", "resume", "deactivate"].includes(options.mode) || options.generation !== undefined && !validGeneration(options.generation)) throw new Error("invalid migration request");
|
|
110
111
|
const memoryDir = existsSync(options.memoryDir) ? realPath(options.memoryDir) : canonicalPath(options.memoryDir);
|
package/src/memory/mutation.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
2
|
import { chmodSync, lstatSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import { realPath } from "./platform.ts";
|
|
4
5
|
|
|
5
6
|
export const MUTATION_BUSY_TIMEOUT_MS = 3000;
|
|
6
7
|
// ponytail: older hubs (before consuming this agent-mem), raw/legacy log writers and any segment editor
|
|
@@ -14,12 +15,31 @@ export function mutationDatabase(dir: string, busyTimeoutMs = MUTATION_BUSY_TIME
|
|
|
14
15
|
return db;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
declare const mutationBrand: unique symbol;
|
|
19
|
+
/** Callable for compatibility; authority exists only in the module-private live registry. */
|
|
20
|
+
export interface MutationHandle { (): void; readonly [mutationBrand]: true }
|
|
21
|
+
const mutations = new WeakMap<MutationHandle, { dir: string; dev: number; ino: number; db: Database; active: boolean }>();
|
|
22
|
+
export function assertMutationHandle(handle: MutationHandle, dir: string): void {
|
|
23
|
+
const state = mutations.get(handle), stat = lstatSync(dir);
|
|
24
|
+
if (!state || !state.active || !state.db.inTransaction || stat.isSymbolicLink() || !stat.isDirectory()
|
|
25
|
+
|| realPath(dir) !== state.dir || stat.dev !== state.dev || stat.ino !== state.ino) throw new Error("mutation handle unavailable or directory changed");
|
|
26
|
+
}
|
|
27
|
+
/** Sync holds a separate connection across awaits; an unrelated call cannot bypass it. */
|
|
28
|
+
export function acquireMutation(dir: string): MutationHandle | undefined {
|
|
29
|
+
const stat = lstatSync(dir);
|
|
30
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error("mutation directory unavailable");
|
|
31
|
+
const canonical = realPath(dir), db = mutationDatabase(canonical);
|
|
20
32
|
try { db.run("BEGIN IMMEDIATE"); }
|
|
21
33
|
catch { db.close(); return undefined; }
|
|
22
|
-
|
|
34
|
+
const release = (() => {
|
|
35
|
+
const state = mutations.get(release)!;
|
|
36
|
+
if (!state.active) return;
|
|
37
|
+
state.active = false;
|
|
38
|
+
try { db.run("ROLLBACK"); } finally { db.close(); }
|
|
39
|
+
}) as MutationHandle;
|
|
40
|
+
mutations.set(release, { dir: canonical, dev: stat.dev, ino: stat.ino, db, active: true });
|
|
41
|
+
try { assertMutationHandle(release, dir); } catch (error) { release(); throw error; }
|
|
42
|
+
return release;
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
/** Short local admission fence, never held across sync/network or data mutations. */
|