@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.
Files changed (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +30 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +36 -3
  5. package/src/commands-table.ts +3 -0
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +31 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/commands.ts +3 -2
  11. package/src/memory/generation-index.ts +95 -18
  12. package/src/memory/generation-sync.ts +3 -0
  13. package/src/memory/generation.ts +33 -15
  14. package/src/memory/platform.ts +38 -3
  15. package/src/memory/policy.ts +28 -15
  16. package/src/memory/prune-publisher-lineage.ts +50 -12
  17. package/src/memory/public-prune-activation.ts +4 -4
  18. package/src/memory/public-prune-advance.ts +11 -5
  19. package/src/memory/public-prune-inputs.ts +17 -16
  20. package/src/memory/shard-sync-applications.ts +153 -0
  21. package/src/memory/shard-sync-format.ts +15 -0
  22. package/src/memory/shard-sync-graph.ts +6 -4
  23. package/src/memory/shard-sync-history.ts +238 -0
  24. package/src/memory/shard-sync.ts +66 -151
  25. package/src/memory/shard.ts +32 -6
  26. package/src/memory/store.ts +14 -1
  27. package/src/memory/sync-binding.ts +63 -0
  28. package/src/memory/sync-cadence.ts +2 -1
  29. package/src/memory/sync-placement-abandon.ts +142 -0
  30. package/src/memory/sync-placement-activation-locks.ts +28 -0
  31. package/src/memory/sync-placement-activation-path.ts +2 -0
  32. package/src/memory/sync-placement-activation-proof.ts +68 -0
  33. package/src/memory/sync-placement-activation-storage.ts +167 -0
  34. package/src/memory/sync-placement-activation.ts +185 -0
  35. package/src/memory/sync-placement-active.ts +75 -0
  36. package/src/memory/sync-placement-cache.ts +32 -0
  37. package/src/memory/sync-placement-capture.ts +373 -0
  38. package/src/memory/sync-placement-dependencies.ts +255 -0
  39. package/src/memory/sync-placement-effective-writers.ts +84 -0
  40. package/src/memory/sync-placement-format.ts +182 -0
  41. package/src/memory/sync-placement-git.ts +95 -0
  42. package/src/memory/sync-placement-guard.ts +104 -0
  43. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  44. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  45. package/src/memory/sync-placement-inspection.ts +240 -0
  46. package/src/memory/sync-placement-journal-format.ts +114 -0
  47. package/src/memory/sync-placement-journal-storage.ts +557 -0
  48. package/src/memory/sync-placement-journal.ts +160 -0
  49. package/src/memory/sync-placement-materialize.ts +149 -0
  50. package/src/memory/sync-placement-plan.ts +171 -0
  51. package/src/memory/sync-placement-prerequisites.ts +50 -0
  52. package/src/memory/sync-placement-projection.ts +79 -0
  53. package/src/memory/sync-placement-publication-plan.ts +189 -0
  54. package/src/memory/sync-placement-publication-storage.ts +87 -0
  55. package/src/memory/sync-placement-transport.ts +500 -0
  56. package/src/memory/sync-placement-writer-history.ts +278 -0
  57. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  58. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  59. package/src/memory/sync-placement-writer-roots.ts +167 -0
  60. package/src/memory/sync-placement-writers.ts +135 -0
  61. package/src/memory/sync-policy-inspection.ts +126 -0
  62. package/src/memory/sync-screen.ts +128 -33
  63. package/src/memory/sync.ts +19 -64
  64. package/src/setup.ts +101 -14
  65. package/src/sync-placement-writer-context.ts +136 -0
  66. package/src/ui/inbox-cache.ts +219 -0
  67. package/src/ui/inbox-worker-version.ts +14 -0
  68. package/src/ui/inbox-worker.ts +32 -0
  69. package/src/ui/inbox.ts +281 -0
  70. package/src/ui/panel.js +7 -3
  71. package/src/ui/plugin.ts +26 -53
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.20.3",
3
+ "version": "0.22.0",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
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.20.3. Requires Bun 1.4.2 or later; no runtime dependencies.
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`, while its MCP entry stays in the profile's `.claude.json`. 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`.
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` (STAIxBWLB/agent-console, 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.
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.20.3",
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": "github:STAIxBWLB/agent-console#952e962",
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 { const result = JSON.parse(output) as { ok?: unknown; error?: unknown }; if (result.ok === false) failure = typeof result.error === "string" ? result.error : "sync failed"; } catch { /* not a sync result */ } }
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) {
@@ -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
- try { writeFileSync(temporary, bytes, { mode: 0o600, flag: "wx" }); renameSync(temporary, join(destination, name)); }
744
- finally { rmSync(temporary, { force: true }); }
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 * from "./memory/platform.ts";
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 * from "./memory/candidates.ts";
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";
@@ -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
- constructor(private remainingBytes = Infinity) { if (remainingBytes !== Infinity && (!Number.isSafeInteger(remainingBytes) || remainingBytes < 0)) failure(); }
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 (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > 4096) failure();
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 value = readProfileJSON(join(archiveDir(root, generation, commit), "snapshot.json"), 32 * 1024 * 1024) as LegacyArchive | undefined;
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
  }