@staix/agent-mem 0.20.3 → 0.22.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 +30 -3
- package/package.json +2 -2
- package/src/cli.ts +36 -3
- package/src/commands-table.ts +3 -0
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +31 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/commands.ts +3 -2
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-activation.ts +4 -4
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +66 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-cadence.ts +2 -1
- package/src/memory/sync-placement-abandon.ts +142 -0
- package/src/memory/sync-placement-activation-locks.ts +28 -0
- package/src/memory/sync-placement-activation-path.ts +2 -0
- package/src/memory/sync-placement-activation-proof.ts +68 -0
- package/src/memory/sync-placement-activation-storage.ts +167 -0
- package/src/memory/sync-placement-activation.ts +185 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +373 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-effective-writers.ts +84 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +104 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +240 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +557 -0
- package/src/memory/sync-placement-journal.ts +160 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-prerequisites.ts +50 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +500 -0
- package/src/memory/sync-placement-writer-history.ts +278 -0
- package/src/memory/sync-placement-writer-renewal.ts +179 -0
- package/src/memory/sync-placement-writer-revision-format.ts +122 -0
- package/src/memory/sync-placement-writer-roots.ts +167 -0
- package/src/memory/sync-placement-writers.ts +135 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +19 -64
- package/src/setup.ts +101 -14
- package/src/sync-placement-writer-context.ts +136 -0
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
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.22.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -32,7 +32,7 @@ From a checkout, install development dependencies with `bun install` and run `bu
|
|
|
32
32
|
|
|
33
33
|
## Setup / plugin
|
|
34
34
|
|
|
35
|
-
For Orca-managed Claude and Codex homes, setup writes the personal source that Orca copies into new sessions. Claude hooks go to `$HOME/.claude/settings.json
|
|
35
|
+
For Orca-managed Claude and Codex homes, setup writes the personal source that Orca copies into new sessions. Claude hooks go to `$HOME/.claude/settings.json` and its MCP entry to `$HOME/.claude.json`, because Orca syncs the personal `mcpServers` over each profile's copy (#243); the profile copies stay untouched. Codex MCP and hooks go to `$HOME/.codex/config.toml` and `hooks.json`. Detection requires an exact managed-home shape under `ORCA_USER_DATA_PATH` (default `~/Library/Application Support/orca`); a similar directory elsewhere is unchanged. Preview lists each redirect and its reason. Start a new Orca session after applying. Existing matching managed-copy receipts migrate on apply; undo restores the files and bytes recorded for that installation. Codex hooks still require the person's native `/hooks` review; setup never grants trust or writes `trusted_hash`.
|
|
36
36
|
|
|
37
37
|
`amem setup agy` registers MCP only in the current git repository's `.agents/mcp_config.json`, with an explicit absolute repository cwd and a private receipt per repository. `setup all` excludes agy; run it separately in each repository. Setup refuses tracked config files and JSONC, and warns when the file is not ignored. Restart Antigravity after applying, use one workspace per session, and ensure no global, plugin or running server is also named `amem`. No global configuration, ingest command or hook is installed. Undo removes `.agents` only when setup created it and it is still empty; pre-existing or nonempty directories remain. Older receipts still restore their configuration without directory cleanup. See the [Antigravity contract and capture limitation](docs/antigravity.md).
|
|
38
38
|
|
|
@@ -157,7 +157,7 @@ Acceptance normalizes its note once, including timestamp, content ID and short-t
|
|
|
157
157
|
|
|
158
158
|
## Console (acon)
|
|
159
159
|
|
|
160
|
-
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (
|
|
160
|
+
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (`@staix/agent-console` 0.1.0, contract api 1). It adds a Memory area with the panels Review, Recap, Search, Timeline, Work lists, Goals and Capacity, puts pending candidates (group `candidate`) and a capture capacity proposal (group `alert`) in the console inbox, and answers the console palette with memory search hits.
|
|
161
161
|
|
|
162
162
|
```sh
|
|
163
163
|
amem setup console # preview
|
|
@@ -169,6 +169,7 @@ The manifest holds the absolute path and sha256 of `src/ui/plugin.ts` (of the `-
|
|
|
169
169
|
|
|
170
170
|
- Reads are screened. Search, Timeline and Work lists call the in-process `amem_search`, `amem_timeline` and `amem_work_state_read` handlers for the chosen project and forward every option the tool declares, so console search returns exactly the `amem_search` set for the same query. Goals and the review queue pass the configured policy; a candidate that fails it, or whose target fails it, is not shown (the panel counts it and points to `amem review walk`). The console never shows unscreened content, also not to the person. It runs in no repository, so a repository's `.amem/config` narrowing does not apply; the machine's `amem.config.json` does.
|
|
171
171
|
- Recap computes the current SessionStart brief only on its own panel snapshots, using machine policy. Curated and work inputs use bounded private scratch copies; capture is read-only, and no live reader lease is taken. It shows the full context, coverage, partial status, token upper bound and generation time; it is not a stored copy of a prior session's injected brief. The console has no repository binding, so the repository section is absent. No recap or new capture field is stored in the memory home.
|
|
172
|
+
- Review shows the candidate kind, proposal, correction diff, stored evidence references, target-note quotes with dates and similar memories. Source sessions, proposing agents and times seen are not recorded by the candidate schema; the panel labels them unavailable rather than inferring them from capture data. An explicitly unavailable project is refused instead of falling back to another project.
|
|
172
173
|
- Review actions are the CLI's. Accept runs the same acceptance as `amem review accept` and writes the same note, edit-then-accept is `review walk`'s edit (the person's own note, then the candidate closed), reject is `review reject`. The console stamps the person as the actor (peer `person`). A privacy-screen hit refuses with a named reason and writes nothing. agent-mem keeps no reject reason, so a reason typed in the console is not recorded, and the reply says so.
|
|
173
174
|
- Undo is offered for 10 seconds and stays bound to what that console just did: undoing an accept forgets the accepted note (an append-only forget op; a note a correction replaced shows again), undoing a reject makes the candidate pending again. A promotion has no undo. `merge_into` is not offered: the library has no merge operation.
|
|
174
175
|
- Destructive forms (`gc`, `capacity_apply`, `project_forget`, `decay_apply`, `prune`) answer `needsConfirm` with the preview and `changes[]` and never write on the first call. The console's single-use confirm token carries the fingerprint back; the plugin recomputes the preview and writes only when it still matches. `prune` needs a generation home, as `amem prune` does.
|
|
@@ -554,3 +555,29 @@ Private cursors under `hook-transcripts/` store only a session hash, file identi
|
|
|
554
555
|
The follow-up bound is a benchmark parameter, not a production retry loop. Backlogs advance on later Stop/SessionEnd calls of the same session. Anything unread after its last hook remains uncaptured, so a cold install on a long session keeps only what its remaining hooks can scan; committed rows are not lost.
|
|
555
556
|
|
|
556
557
|
Search gives these events the existing 1.25 conversation weight and session grouping. The brief selects up to two newest screened main-session Recap/Compact rows first, then fills from the existing chronological rows, under the same record, byte and token caps. With no summaries the output is unchanged. See [verification evidence](docs/bench/transcript-summaries-201.md).
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
## Initial sync placement activation
|
|
561
|
+
|
|
562
|
+
Available from 0.22.0. 0.21.0 contains the placement protocol but not this command.
|
|
563
|
+
|
|
564
|
+
A person can activate per-row outbound placement on one whole memory home. It is forward-only, and never runs migrations, changes privacy policy or raises a reader floor automatically. Real-home activation still needs the person's separate authorization.
|
|
565
|
+
|
|
566
|
+
1. Preview and complete the existing shard and public-prune migrations with `amem migrate --shards --dry-run` and `amem migrate --public-prune-view --dry-run` (then their separately reviewed `--apply` steps).
|
|
567
|
+
2. On a 0.22 installation, have the person append the existing `home` floor at 0.22.0 in `formats.json`, preserving prior records, and close cooperating memory reader handles. This is recommended at activation and required before the first writer renewal, so old 0.21 readers refuse before opening a renewable home.
|
|
568
|
+
3. Declare every local amem installation that writes this home, including an agent-hub library installation even if setup has no receipt for it. The CLI and current setup-receipt roots are mandatory members.
|
|
569
|
+
|
|
570
|
+
```sh
|
|
571
|
+
amem migrate --sync-placement --dry-run --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
|
|
572
|
+
amem migrate --sync-placement --apply --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
|
|
573
|
+
# Only for an interrupted initial activation, using its retained frozen inputs:
|
|
574
|
+
amem migrate --sync-placement --resume --json
|
|
575
|
+
# If inputs changed and BOTH marker files still have their original v2 bytes:
|
|
576
|
+
amem migrate --sync-placement --abandon --json
|
|
577
|
+
```
|
|
578
|
+
|
|
579
|
+
Dry-run shows root versions/pins and safe policy counts, never row text or patterns, and writes nothing. Initial writer errors name the root category and cause, with upgrade/setup instructions. If a pre-marker interruption retained an intent and its inputs changed, person `--abandon` removes only its owned initial metadata so a fresh preview/apply can follow. It cannot remove a v3 marker, active fence or journal history. After apply, choose the private remote on the first v3 sync (`amem sync --remote <private-git-url>`); ordinary and cadence sync then read the immutable root locator and measure the installs afresh. Allowed originals can publish while declined originals remain in the local append-only history, with counts in the sync receipt. No writer roots need to be repeated on sync.
|
|
580
|
+
|
|
581
|
+
On an active home, the same dry-run/apply commands explicitly renew the writer inventory on its existing epoch (#282). Declare the complete new 0.22.0-or-later roots after updating host pins and managed setup receipts. Ordinary and cadence sync then locate the new inventory and measure it afresh; original activation data, holds and prior receipts are unchanged. Pending publication or source-recovery work must first be finished with the inventoried install, for example after `dot ai rollback amem` or restoring the host's previous pin.
|
|
582
|
+
|
|
583
|
+
An interrupted renewal resumes only its exact intent. Before commit append starts, person `--abandon` can discard its unpublished metadata; after the first commit-log byte, only forward resume is supported. Unpublished intent staging requires explicit `--abandon`, then a fresh dry-run; it is never cleaned automatically. Compatible ordinary reads remain available while only renewal metadata is pending. After a crash, a retained `profile.lock` still blocks new reader admission until person `--resume` or, before commit append, `--abandon`. Never edit activation files, downgrade markers or start another epoch to discard a hold. See the [renewal contract](docs/contracts.md#same-epoch-writer-inventory-renewal-282). Real-home renewal and release remain separately authorized person operations.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"bun": ">=1.4.2"
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
|
-
"@staix/agent-console": "
|
|
15
|
+
"@staix/agent-console": "0.1.3",
|
|
16
16
|
"@types/bun": "^1.3.0",
|
|
17
17
|
"typescript": "^5.8.0",
|
|
18
18
|
"@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
|
package/src/cli.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { assertFormatFloor } from "./memory/formats.ts";
|
|
2
|
+
import { placementRuntimeRefusal, readPlacementRuntimeMarkers } from "./memory/sync-placement-guard.ts";
|
|
1
3
|
import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
|
|
2
4
|
import { AgentUsageError, agentCliRead } from "./cli-agent-reads.ts";
|
|
3
5
|
import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
|
|
@@ -18,6 +20,7 @@ import type { IngestKind, IngestReceipt } from "./ingest.ts";
|
|
|
18
20
|
import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
|
|
19
21
|
import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
|
|
20
22
|
import { isAbsolute, join } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
21
24
|
import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
|
|
22
25
|
import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
|
|
23
26
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
@@ -376,6 +379,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
376
379
|
}
|
|
377
380
|
const { definition, options, rawArgs: args } = parsed;
|
|
378
381
|
const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
|
|
382
|
+
if (parsed.path[0] === "migrate" && options["--sync-placement"] === true) {
|
|
383
|
+
const modes = ["--dry-run","--apply","--resume","--abandon","--deactivate"].filter(key=>options[key]===true);
|
|
384
|
+
if (modes.length>1 || parsed.positionals.length || ["--shards","--public-prune-view","--generation","--deactivate"].some(key=>options[key]!==undefined))
|
|
385
|
+
throw new AgentUsageError("Sync placement is a home-wide forward-only migration; use only --sync-placement with --dry-run, --apply, --resume or pre-marker --abandon and the writer roots, never combine migration families or deactivate.");
|
|
386
|
+
if (actor === "agent") throw new AgentUsageError("Sync placement activation requires a person; ask the person to run amem migrate --sync-placement --dry-run and review the home-wide forward-only transition.");
|
|
387
|
+
} else if (parsed.path[0] === "migrate" && (options["--writer-root"] !== undefined||options["--abandon"]!==undefined)) {
|
|
388
|
+
throw new AgentUsageError("--writer-root and --abandon apply only to --sync-placement; run amem migrate --sync-placement --dry-run to review the inventory.");
|
|
389
|
+
}
|
|
379
390
|
if (parsed.path[0] === "setup") {
|
|
380
391
|
if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true
|
|
381
392
|
|| options["--adopt-current"] === true && (options["--apply"] !== true || parsed.positionals[0] === "console")) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--root <abs path>] [--apply [--adopt-current]|--undo]");
|
|
@@ -470,9 +481,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
470
481
|
}
|
|
471
482
|
if ((options["--history"] === true || options["--as-of"] !== undefined) && (options["--all-projects"] === true || options["--tier"] === "repo")) throw Error("historical recall selects this active project's curated store; cross-project/repo history is unavailable");
|
|
472
483
|
if (sub === "migrate") {
|
|
473
|
-
const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
|
|
484
|
+
const modes = ["--apply", "--dry-run", "--resume", "--abandon", "--deactivate"].filter(key => options[key] === true);
|
|
474
485
|
if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
|
|
475
486
|
const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
|
|
487
|
+
if (options["--sync-placement"] === true) {
|
|
488
|
+
const { activateSyncPlacement } = await import("./memory/sync-placement-activation.ts");
|
|
489
|
+
const result = await activateSyncPlacement({ memoryDir:memoryHome(),mode:options["--abandon"]?"abandon":mode as "dry-run"|"apply"|"resume",
|
|
490
|
+
writerRoots:repeated(argv,"--writer-root"),ownRoot:fileURLToPath(new URL("..",import.meta.url)),
|
|
491
|
+
authorize:()=>cliAttribution()?.actor==="person" });
|
|
492
|
+
if (options["--json"] === true) return JSON.stringify(result,null,2);
|
|
493
|
+
// Metadata only: the source reader and locator are never serialized into this receipt.
|
|
494
|
+
return `sync placement: ${result.mode}; ${result.active?"active":"inactive"}; home-wide, forward-only (no deactivate)\n${JSON.stringify(result,null,2)}`;
|
|
495
|
+
}
|
|
476
496
|
const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
|
|
477
497
|
if (options["--public-prune-view"] === true) {
|
|
478
498
|
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");
|
|
@@ -1035,6 +1055,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1035
1055
|
// configured remote, nothing when the schedule or the failure backoff says it is not due (`--force` skips that), and it stops a running git step after 30 seconds.
|
|
1036
1056
|
const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome();
|
|
1037
1057
|
if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due");
|
|
1058
|
+
if (sub === "sync") { assertFormatFloor(syncHome, "sync"); const refusal = placementRuntimeRefusal(syncHome); if (refusal) throw new Error("amem sync: " + refusal); }
|
|
1038
1059
|
if (ifDue) {
|
|
1039
1060
|
if (options["--remote"] === undefined && !remoteConfigured(syncHome)) return options["--json"] ? JSON.stringify({ ok: true, skipped: "no sync remote configured" }) : "no sync remote configured: nothing to do";
|
|
1040
1061
|
const state = usableState(readSyncState(syncHome), Date.now());
|
|
@@ -1059,15 +1080,21 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1059
1080
|
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
1060
1081
|
const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
|
|
1061
1082
|
let output: string;
|
|
1083
|
+
let placementWriters: import("./sync-placement-writer-context.ts").PlacementWriterContext | undefined;
|
|
1062
1084
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
1063
1085
|
try {
|
|
1086
|
+
if (sub === "sync" && (readPlacementRuntimeMarkers(syncHome).profile as { publicationFormat?:string } | undefined)?.publicationFormat === "agent-mem.sync-placement/v1") {
|
|
1087
|
+
const { activePlacementWriterContext } = await import("./sync-placement-writer-context.ts");
|
|
1088
|
+
placementWriters = activePlacementWriterContext(syncHome,fileURLToPath(new URL("..",import.meta.url)));
|
|
1089
|
+
}
|
|
1064
1090
|
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),
|
|
1065
|
-
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
|
|
1091
|
+
sync: { ...(placementWriters ? { placementAttestation:placementWriters.attest } : {}), screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
1066
1092
|
onBridgeStatus: status => { receipt.bridgeStatus = status; },
|
|
1067
1093
|
// #188: ids-only listing of nearby machine-local rows; policy-refused rows are neither listed nor counted.
|
|
1068
1094
|
forgetNearby: note => forgetNearby({ base: memoryHome(), project: note.project, text: note.text, title: note.title, screen: value => configured.screenStandalone(value) }) }));
|
|
1069
1095
|
} catch (error) {
|
|
1070
1096
|
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
1097
|
+
if (sub === "sync" && placementWriters?.lastRefusal) throw Error(`${placementWriters.lastRefusal.message} ${(error as Error).message}`);
|
|
1071
1098
|
throw error;
|
|
1072
1099
|
}
|
|
1073
1100
|
finally {
|
|
@@ -1079,7 +1106,13 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
|
|
|
1079
1106
|
if (sub === "sync") {
|
|
1080
1107
|
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
1081
1108
|
let failure: string | undefined;
|
|
1082
|
-
if (options["--json"]) { try {
|
|
1109
|
+
if (options["--json"]) { try {
|
|
1110
|
+
const result = JSON.parse(output) as { ok?: unknown; error?: unknown };
|
|
1111
|
+
if (result.ok === false) {
|
|
1112
|
+
failure = typeof result.error === "string" ? result.error : "sync failed";
|
|
1113
|
+
if (placementWriters?.lastRefusal) { result.error = `${placementWriters.lastRefusal.message} ${failure}`; output = JSON.stringify(result,null,2); }
|
|
1114
|
+
}
|
|
1115
|
+
} catch { /* not a sync result */ } }
|
|
1083
1116
|
stampSync(failure, stop?.cause());
|
|
1084
1117
|
}
|
|
1085
1118
|
if (captureImport) {
|
package/src/commands-table.ts
CHANGED
|
@@ -116,6 +116,9 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
116
116
|
"--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
|
|
117
117
|
"--shards": flag("Migrate the curated home into project and user shards"),
|
|
118
118
|
"--public-prune-view": flag("Enable the forward-only v2 public prune view on an active split"),
|
|
119
|
+
"--sync-placement": flag("Person-only forward-only placement activation on this memory home; no automatic migration or renewal"),
|
|
120
|
+
"--abandon": flag("Person-only: discard an interrupted sync-placement attempt only before either v3 marker is installed"),
|
|
121
|
+
"--writer-root": { description: "Repeatable absolute amem install root in the complete local writer inventory", value: "path" },
|
|
119
122
|
} },
|
|
120
123
|
status: { description: "Report shard migration and pending old-layout appends", options: json },
|
|
121
124
|
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/hook-spool.ts
CHANGED
|
@@ -450,6 +450,37 @@ function retireClaim(base: string, file: OwnedFile, preserve: boolean, batch?: Q
|
|
|
450
450
|
const RETIRE_AFTER_MS = 7 * 24 * 3600_000, MISSING_CWD_AFTER_MS = 3600_000;
|
|
451
451
|
interface MaintenanceBudget { files: number; bytes: number }
|
|
452
452
|
const maintenanceBudget = (): MaintenanceBudget => ({ files: DRAIN_FILES, bytes: DRAIN_BYTES });
|
|
453
|
+
/** Unpublished private files are not captures. Inspect at most four entries within this publication's maintenance allowance. */
|
|
454
|
+
function cleanStaleWriting(writing: string, budget: MaintenanceBudget): void {
|
|
455
|
+
// ponytail: only the first four directory entries are inspected; young or unremovable entries can starve later stale files.
|
|
456
|
+
// Upgrade: rotate the bounded scan start with an owned maintenance cursor, like retirementCursor.
|
|
457
|
+
const reader = opendirSync(writing);
|
|
458
|
+
try {
|
|
459
|
+
for (let scanned = 0; scanned < 4 && budget.files > 0; scanned++) {
|
|
460
|
+
const entry = reader.readSync(); if (!entry) break;
|
|
461
|
+
budget.files--;
|
|
462
|
+
const canonical = entry.name.replace(/\.writing-[a-f0-9]{24}$/, ""), match = namePattern.exec(canonical);
|
|
463
|
+
if (!match || match[1] !== canonical) continue;
|
|
464
|
+
let file: OwnedFile | undefined;
|
|
465
|
+
try {
|
|
466
|
+
file = openOwned(join(writing, entry.name));
|
|
467
|
+
if (typeof process.getuid !== "function" || file.initial.uid !== process.getuid() || file.initial.mode & 0o077
|
|
468
|
+
|| Date.now() - file.initial.mtimeMs < MISSING_CWD_AFTER_MS || file.initial.size > budget.bytes) continue;
|
|
469
|
+
budget.bytes -= file.initial.size;
|
|
470
|
+
const current = fstatSync(file.fd);
|
|
471
|
+
if (current.uid === process.getuid() && !(current.mode & 0o077) && owns(file)) rmSync(file.file);
|
|
472
|
+
} catch { /* Partial, replaced or unsafe evidence stays private; never acknowledge it as a capture. */ }
|
|
473
|
+
finally { if (file) closeSync(file.fd); }
|
|
474
|
+
}
|
|
475
|
+
} finally { reader.closeSync(); }
|
|
476
|
+
}
|
|
477
|
+
/** A writer can change its own size/mtime before a failed write, but never owns a replacement or a shared inode. */
|
|
478
|
+
function ownsWriting(file: OwnedFile): boolean {
|
|
479
|
+
const named = lstatSync(file.file, { throwIfNoEntry: false }), current = fstatSync(file.fd), uid = process.getuid?.();
|
|
480
|
+
return !!named && named.isFile() && !named.isSymbolicLink() && named.nlink === 1 && current.nlink === 1
|
|
481
|
+
&& uid !== undefined && current.uid === uid && !(current.mode & 0o077)
|
|
482
|
+
&& named.dev === file.initial.dev && named.ino === file.initial.ino && current.dev === file.initial.dev && current.ino === file.initial.ino;
|
|
483
|
+
}
|
|
453
484
|
/** Private numeric maintenance cursor: each invocation inspects a different bounded slice. */
|
|
454
485
|
function retirementCursor(base: string, capacity: boolean, length: number, pressure = false): { start: number; save(n: number): void } {
|
|
455
486
|
const dir = join(directory(base), ".maintenance"); privateDirectory(dir);
|
|
@@ -680,6 +711,7 @@ function publishCapture(event: string, capture: NormalizedCaptureFields, options
|
|
|
680
711
|
const dir = directory(base), writing = join(dir, ".writing"); privateDirectory(dir); privateDirectory(writing);
|
|
681
712
|
let destination = dir, deferred = false, backlog = false;
|
|
682
713
|
const budget = maintenanceBudget(), incomingBytes = Buffer.byteLength(bytes), cwdHash = hash(cwd);
|
|
714
|
+
if (event !== "SessionStart") cleanStaleWriting(writing, budget);
|
|
683
715
|
const withinShare = (rows: ReturnType<typeof entries>, maximum: number) => rows.filter(row => namePattern.exec(row.name)?.[2] === cwdHash).length < maximum / 4;
|
|
684
716
|
const name = `${v3 ? PREFIX_V3 : PREFIX}${cwdHash}-${scope ?? "0".repeat(64)}-${nonce}.json`;
|
|
685
717
|
let activeInventory: ReturnType<typeof entries> | undefined, reserveInventory: ReturnType<typeof entries> | undefined;
|
|
@@ -739,9 +771,19 @@ function publishCapture(event: string, capture: NormalizedCaptureFields, options
|
|
|
739
771
|
deferred = true;
|
|
740
772
|
}
|
|
741
773
|
}
|
|
742
|
-
const temporary = join(writing, name);
|
|
743
|
-
|
|
744
|
-
|
|
774
|
+
const temporary = join(writing, `${name}.writing-${randomBytes(12).toString("hex")}`);
|
|
775
|
+
let fd: number | undefined, created: OwnedFile | undefined;
|
|
776
|
+
try {
|
|
777
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
778
|
+
created = { file: temporary, fd, initial: fstatSync(fd) };
|
|
779
|
+
if (!ownsWriting(created)) throw new Error("Hook publication refused: private temporary ownership unavailable. Retry the hook; inspect spool permissions and ownership if it repeats.");
|
|
780
|
+
writeFileSync(fd, bytes);
|
|
781
|
+
if (!ownsWriting(created) || !owns({ ...created, initial: fstatSync(fd) })) throw new Error("Hook publication refused: private temporary changed before publication. Retry the hook; inspect competing writers if it repeats.");
|
|
782
|
+
renameSync(temporary, join(destination, name));
|
|
783
|
+
} finally {
|
|
784
|
+
try { if (created && ownsWriting(created)) rmSync(temporary); }
|
|
785
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
786
|
+
}
|
|
745
787
|
return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
|
|
746
788
|
}
|
|
747
789
|
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,21 @@ export * from "./memory/sync.ts";
|
|
|
17
17
|
export * from "./memory/import.ts";
|
|
18
18
|
export * from "./memory/project.ts";
|
|
19
19
|
export * from "./memory/home.ts";
|
|
20
|
-
export
|
|
20
|
+
export {
|
|
21
|
+
canonicalPath, realPath, DENY_SEGMENTS, DENY_NAMES, isDenied, memoryGitArgv, memoryGitEnv, memoryGitSync,
|
|
22
|
+
withGitTimeout, processSignature, processLiveness,
|
|
23
|
+
} from "./memory/platform.ts";
|
|
24
|
+
export type { Liveness } from "./memory/platform.ts";
|
|
21
25
|
export * from "./memory/commands.ts";
|
|
22
|
-
export
|
|
26
|
+
export {
|
|
27
|
+
CANDIDATE_DB_BUSY_TIMEOUT_MS, ACCEPTED_CONSOLIDATION_PREFIX, CANDIDATE_RETENTION, CONSOLIDATION_REVIEW_LIMIT,
|
|
28
|
+
ConsolidationReviewLimit, COUNTER_RETENTION, candidateInputId, acceptedCorrectionText, goalPromotionLinked,
|
|
29
|
+
candidateTargetRevision, CandidateQueueFull, CandidateStore,
|
|
30
|
+
} from "./memory/candidates.ts";
|
|
31
|
+
export type {
|
|
32
|
+
CandidateVerdict, CandidateTarget, CandidateInput, CandidateStatus, Candidate, ConsolidationReview, CandidateFilter,
|
|
33
|
+
CounterKey, CounterValues, Counter, CandidateAcceptance, CandidateAcceptanceResult,
|
|
34
|
+
} from "./memory/candidates.ts";
|
|
23
35
|
export * from "./memory/ops-contract.ts";
|
|
24
36
|
export * from "./memory/generation.ts";
|
|
25
37
|
export * from "./memory/migrate.ts";
|
|
@@ -49,3 +61,20 @@ export * from "./memory/consolidate.ts";
|
|
|
49
61
|
export * from "./memory/consolidation-clusters.ts";
|
|
50
62
|
export * from "./memory/consolidation-model.ts";
|
|
51
63
|
export * from "./memory/evidence.ts";
|
|
64
|
+
|
|
65
|
+
export * from "./memory/sync-policy-inspection.ts";
|
|
66
|
+
export * from "./memory/sync-placement-inspection.ts";
|
|
67
|
+
export { syncOutboundContent } from "./memory/sync-screen.ts";
|
|
68
|
+
|
|
69
|
+
export * from "./memory/sync-placement-format.ts";
|
|
70
|
+
export * from "./memory/sync-placement-plan.ts";
|
|
71
|
+
export * from "./memory/sync-placement-journal-format.ts";
|
|
72
|
+
export * from "./memory/sync-placement-projection.ts";
|
|
73
|
+
export * from "./memory/sync-placement-journal.ts";
|
|
74
|
+
export type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
|
|
75
|
+
export { measureSyncPlacementWriters, SyncPlacementWriterError } from "./memory/sync-placement-writers.ts";
|
|
76
|
+
export type { MeasuredSyncPlacementWriterRoot, SyncPlacementWriterMeasurement, SyncPlacementWriterErrorCode } from "./memory/sync-placement-writers.ts";
|
|
77
|
+
export { readSyncPlacementWriterRoots } from "./memory/sync-placement-writer-roots.ts";
|
|
78
|
+
export { readEffectiveSyncPlacementWriters, readEffectiveSyncPlacementWriterRoots } from "./memory/sync-placement-effective-writers.ts";
|
|
79
|
+
export type { EffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
|
|
80
|
+
export { readSetupWriterRoots, SetupWriterRootsError } from "./setup.ts";
|
package/src/memory/bridge.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, type Hash } from "node:crypto";
|
|
2
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";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, opendirSync, unlinkSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { memoryGitArgv, memoryGitEnv, realPath } from "./platform.ts";
|
|
@@ -51,7 +51,39 @@ const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: big
|
|
|
51
51
|
export class SegmentReader {
|
|
52
52
|
private readonly prefixes = new Map<string, PrefixState>();
|
|
53
53
|
private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
|
|
54
|
-
|
|
54
|
+
private remainingSourceBytes: number;
|
|
55
|
+
private remainingRows: number;
|
|
56
|
+
private remainingFiles: number;
|
|
57
|
+
private sourceBytes = 0;
|
|
58
|
+
private sourceRows = 0;
|
|
59
|
+
/** Optional inspection limits do not change ordinary incremental-reader semantics. */
|
|
60
|
+
readonly sourceByteLimit?: number;
|
|
61
|
+
readonly sourceFileLimit?: number;
|
|
62
|
+
constructor(private remainingBytes = Infinity, limits: { maxSourceBytes?: number; maxRows?: number } = {}) {
|
|
63
|
+
for (const value of [remainingBytes, limits.maxSourceBytes ?? Infinity, limits.maxRows ?? Infinity]) if (value !== Infinity && (!Number.isSafeInteger(value) || value < 0)) failure();
|
|
64
|
+
this.sourceByteLimit = limits.maxSourceBytes;
|
|
65
|
+
this.remainingSourceBytes = limits.maxSourceBytes ?? Infinity;
|
|
66
|
+
this.remainingRows = limits.maxRows ?? Infinity;
|
|
67
|
+
this.sourceFileLimit = limits.maxRows;
|
|
68
|
+
this.remainingFiles = limits.maxRows ?? Infinity;
|
|
69
|
+
}
|
|
70
|
+
inspectionMetrics(): { sourceBytes: number; rows: number } { return { sourceBytes: this.sourceBytes, rows: this.sourceRows }; }
|
|
71
|
+
/** Inspection counts directory entries and archived segment names before their arrays/maps can grow.
|
|
72
|
+
* ponytail: metadata entries share maxRows (public inspection: 20000), including empty files.
|
|
73
|
+
* Upgrade with a separately budgeted catalog proof if valid profiles need a larger metadata frontier.
|
|
74
|
+
*/
|
|
75
|
+
reserveSourceFiles(count: number): void {
|
|
76
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > this.remainingFiles) throw new SegmentReadBudgetError();
|
|
77
|
+
this.remainingFiles -= count;
|
|
78
|
+
}
|
|
79
|
+
listFiles(dir: string): string[] {
|
|
80
|
+
if (this.sourceFileLimit === undefined) return readdirSync(dir).sort();
|
|
81
|
+
const reader = opendirSync(dir), names: string[] = [];
|
|
82
|
+
try {
|
|
83
|
+
for (;;) { const entry = reader.readSync(); if (!entry) break; this.reserveSourceFiles(1); names.push(entry.name); }
|
|
84
|
+
} finally { reader.closeSync(); }
|
|
85
|
+
return names.sort();
|
|
86
|
+
}
|
|
55
87
|
metrics(): SegmentReadMetrics { return { ...this.measured }; }
|
|
56
88
|
resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
|
|
57
89
|
read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
|
|
@@ -60,6 +92,8 @@ export class SegmentReader {
|
|
|
60
92
|
const stamp = statStamp(before), size = Number(before.size);
|
|
61
93
|
if (prior && size < prior.offset) failure();
|
|
62
94
|
if (!full && prior?.stamp === stamp) return { bytes: Buffer.alloc(0), start: prior.offset, offset: prior.offset, hash: prior.hash, tailBytes: size - prior.offset, stamp };
|
|
95
|
+
if (size > this.remainingSourceBytes) throw new SegmentReadBudgetError();
|
|
96
|
+
this.remainingSourceBytes -= size; this.sourceBytes += size;
|
|
63
97
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
98
|
try {
|
|
65
99
|
const opened = fstatSync(fd, { bigint: true });
|
|
@@ -73,6 +107,11 @@ export class SegmentReader {
|
|
|
73
107
|
const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
|
|
74
108
|
if (prior && end < prior.offset) failure();
|
|
75
109
|
if (!read(end).equals(bytes) || fstatSync(fd, { bigint: true }).size < opened.size) failure();
|
|
110
|
+
// Count physical nonblank source rows before any canonical source map can grow.
|
|
111
|
+
if (this.remainingRows !== Infinity) for (const line of completeLines(bytes)) if (line.toString("utf8").trim()) {
|
|
112
|
+
if (this.remainingRows < 1) throw new SegmentReadBudgetError();
|
|
113
|
+
this.remainingRows--; this.sourceRows++;
|
|
114
|
+
}
|
|
76
115
|
const after = lstatSync(path, { bigint: true });
|
|
77
116
|
if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino) failure();
|
|
78
117
|
const cached = immutable ? undefined : this.prefixes.get(path);
|
|
@@ -116,7 +155,7 @@ function checkpointMap(rows: readonly LegacyCheckpoint[] = []): Map<string, Lega
|
|
|
116
155
|
return map;
|
|
117
156
|
}
|
|
118
157
|
|
|
119
|
-
function checkedEntry(value: unknown): Entry {
|
|
158
|
+
export function checkedEntry(value: unknown): Entry {
|
|
120
159
|
if (!value || typeof value !== "object" || Array.isArray(value)) malformed();
|
|
121
160
|
const row = value as Record<string, unknown>;
|
|
122
161
|
const operation = row.op;
|
|
@@ -141,7 +180,7 @@ function checkedEntry(value: unknown): Entry {
|
|
|
141
180
|
return row as unknown as Entry;
|
|
142
181
|
}
|
|
143
182
|
|
|
144
|
-
function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
183
|
+
export function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
145
184
|
let start = 0;
|
|
146
185
|
while (start < bytes.length) { const end = bytes.indexOf(10, start); if (end < 0) failure(); yield bytes.subarray(start, end); start = end + 1; }
|
|
147
186
|
}
|
|
@@ -198,7 +237,7 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
198
237
|
for (const folder of ["log", "local"]) {
|
|
199
238
|
const dir = join(root, folder);
|
|
200
239
|
if (!directory(dir, true)) continue;
|
|
201
|
-
for (const name of readdirSync(dir).sort()) {
|
|
240
|
+
for (const name of options.reader ? options.reader.listFiles(dir) : readdirSync(dir).sort()) {
|
|
202
241
|
if (!name.endsWith(".jsonl")) continue;
|
|
203
242
|
const path = `${folder}/${name}`;
|
|
204
243
|
if (!segmentPath(path)) failure();
|
|
@@ -212,11 +251,12 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
212
251
|
}
|
|
213
252
|
if ((options.commit || options.previousCommit) && !options.generation || options.previousCommit && !commitId(options.previousCommit)) failure();
|
|
214
253
|
if (options.generation !== undefined) {
|
|
215
|
-
const commit = options.commit ?? legacyHead(root, options.generation);
|
|
254
|
+
const commit = options.commit ?? legacyHead(root, options.generation, options.reader?.sourceByteLimit, options.reader?.sourceFileLimit);
|
|
216
255
|
if (commit) {
|
|
217
|
-
const manifest = archived(root, options.generation, commit);
|
|
256
|
+
const manifest = archived(root, options.generation, commit, options.reader?.sourceByteLimit, options.reader?.sourceFileLimit);
|
|
257
|
+
options.reader?.reserveSourceFiles(manifest.segments.length);
|
|
218
258
|
directory(join(archiveDir(root, options.generation, commit), "log"));
|
|
219
|
-
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();
|
|
259
|
+
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, options.reader?.sourceByteLimit)) failure();
|
|
220
260
|
for (const segment of manifest.segments) {
|
|
221
261
|
const reader = options.reader ?? new SegmentReader(), incremental = options.incremental && !limits;
|
|
222
262
|
const data = reader.read(join(archiveDir(root, options.generation, commit), segment.path), incremental ? checkpoints.get(`${remoteSource}:${segment.path}`) : undefined, !incremental, true);
|
|
@@ -239,15 +279,21 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
239
279
|
return result;
|
|
240
280
|
}
|
|
241
281
|
|
|
242
|
-
function capturedIdentity(path: string): string {
|
|
282
|
+
function capturedIdentity(path: string, maximum?: number): string {
|
|
243
283
|
const before = lstatSync(path);
|
|
244
284
|
if (before.isSymbolicLink() || !before.isFile()) failure();
|
|
245
285
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
246
286
|
try {
|
|
247
287
|
const stat = fstatSync(fd);
|
|
248
|
-
if (
|
|
288
|
+
if (maximum !== undefined && (before.size > maximum || stat.size > maximum)) throw new SegmentReadBudgetError();
|
|
289
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > (maximum ?? 4096)) failure();
|
|
290
|
+
if (maximum !== undefined && (stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs)) failure();
|
|
249
291
|
const bytes = Buffer.alloc(stat.size);
|
|
250
292
|
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) failure();
|
|
293
|
+
if (maximum !== undefined) {
|
|
294
|
+
const after = fstatSync(fd), named = lstatSync(path);
|
|
295
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || named.isSymbolicLink() || named.dev !== stat.dev || named.ino !== stat.ino) failure();
|
|
296
|
+
}
|
|
251
297
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
252
298
|
} finally { closeSync(fd); }
|
|
253
299
|
}
|
|
@@ -266,14 +312,14 @@ function cachedGit(cache: string, args: string[], allowMissing = false): Buffer
|
|
|
266
312
|
return run.stdout;
|
|
267
313
|
}
|
|
268
314
|
/** Last explicitly fetched legacy main, not a claim about the current remote state. Never fetches or writes. */
|
|
269
|
-
function head(homeM: string, generation: string): string | undefined {
|
|
315
|
+
function head(homeM: string, generation: string, maximumArchiveBytes?: number, maximumArchiveSegments?: number): string | undefined {
|
|
270
316
|
const root = realPath(homeM); directory(root);
|
|
271
317
|
const dir = fixedGeneration(root, generation);
|
|
272
318
|
if (!directory(dir, true)) return undefined;
|
|
273
319
|
const active = readProfileJSON(join(dir, "bridge-snapshot.json"), 4096) as { schema?: unknown; generation?: unknown; commit?: unknown } | undefined;
|
|
274
320
|
if (!active) return undefined;
|
|
275
321
|
if (active.schema !== "agent-mem.legacy-head/v1" || active.generation !== generation || typeof active.commit !== "string" || !commitId(active.commit)) failure();
|
|
276
|
-
archived(root, generation, active.commit);
|
|
322
|
+
archived(root, generation, active.commit, maximumArchiveBytes, maximumArchiveSegments);
|
|
277
323
|
return active.commit;
|
|
278
324
|
}
|
|
279
325
|
|
|
@@ -281,8 +327,8 @@ function head(homeM: string, generation: string): string | undefined {
|
|
|
281
327
|
export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
|
|
282
328
|
try { return snapshot(homeM, options); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
|
|
283
329
|
}
|
|
284
|
-
export function legacyHead(homeM: string, generation: string): string | undefined {
|
|
285
|
-
try { return head(homeM, generation); } catch { return failure(); }
|
|
330
|
+
export function legacyHead(homeM: string, generation: string, maximumArchiveBytes?: number, maximumArchiveSegments?: number): string | undefined {
|
|
331
|
+
try { return head(homeM, generation, maximumArchiveBytes, maximumArchiveSegments); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
|
|
286
332
|
}
|
|
287
333
|
|
|
288
334
|
|
|
@@ -301,9 +347,11 @@ function archiveDir(root: string, generation: string, commit: string): string {
|
|
|
301
347
|
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
302
348
|
directory(dir); const target = join(dir, commit); directory(target); return target;
|
|
303
349
|
}
|
|
304
|
-
function archived(root: string, generation: string, commit: string): LegacyArchive {
|
|
305
|
-
const
|
|
350
|
+
function archived(root: string, generation: string, commit: string, maximumBytes?: number, maximumSegments?: number): LegacyArchive {
|
|
351
|
+
const file = join(archiveDir(root, generation, commit), "snapshot.json");
|
|
352
|
+
const value = (maximumBytes === undefined ? readProfileJSON(file, 32 * 1024 * 1024) : JSON.parse(capturedIdentity(file, Math.min(maximumBytes, 32 * 1024 * 1024)))) as LegacyArchive | undefined;
|
|
306
353
|
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();
|
|
354
|
+
if (maximumSegments !== undefined && value.segments.length > maximumSegments) throw new SegmentReadBudgetError();
|
|
307
355
|
const paths = new Set<string>();
|
|
308
356
|
for (const segment of value.segments) {
|
|
309
357
|
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();
|
|
@@ -311,9 +359,9 @@ function archived(root: string, generation: string, commit: string): LegacyArchi
|
|
|
311
359
|
}
|
|
312
360
|
return value;
|
|
313
361
|
}
|
|
314
|
-
function archivedAncestor(root: string, generation: string, previous: string, commit: string): boolean {
|
|
362
|
+
function archivedAncestor(root: string, generation: string, previous: string, commit: string, maximumBytes?: number): boolean {
|
|
315
363
|
const visited = new Set<string>();
|
|
316
|
-
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor).previousCommit) {
|
|
364
|
+
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor, maximumBytes).previousCommit) {
|
|
317
365
|
if (cursor === previous) return true;
|
|
318
366
|
visited.add(cursor);
|
|
319
367
|
}
|