@staix/agent-mem 0.20.2 → 0.21.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +8 -1
  5. package/src/commands-table.ts +2 -1
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +25 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/generation-index.ts +95 -18
  11. package/src/memory/generation-sync.ts +3 -0
  12. package/src/memory/generation.ts +33 -15
  13. package/src/memory/platform.ts +38 -3
  14. package/src/memory/policy.ts +28 -15
  15. package/src/memory/prune-publisher-lineage.ts +50 -12
  16. package/src/memory/public-prune-advance.ts +11 -5
  17. package/src/memory/public-prune-inputs.ts +17 -16
  18. package/src/memory/shard-sync-applications.ts +153 -0
  19. package/src/memory/shard-sync-format.ts +15 -0
  20. package/src/memory/shard-sync-graph.ts +6 -4
  21. package/src/memory/shard-sync-history.ts +238 -0
  22. package/src/memory/shard-sync.ts +67 -151
  23. package/src/memory/shard.ts +32 -6
  24. package/src/memory/store.ts +14 -1
  25. package/src/memory/sync-binding.ts +63 -0
  26. package/src/memory/sync-placement-active.ts +75 -0
  27. package/src/memory/sync-placement-cache.ts +32 -0
  28. package/src/memory/sync-placement-capture.ts +292 -0
  29. package/src/memory/sync-placement-dependencies.ts +255 -0
  30. package/src/memory/sync-placement-format.ts +182 -0
  31. package/src/memory/sync-placement-git.ts +95 -0
  32. package/src/memory/sync-placement-guard.ts +98 -0
  33. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  34. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  35. package/src/memory/sync-placement-inspection.ts +236 -0
  36. package/src/memory/sync-placement-journal-format.ts +114 -0
  37. package/src/memory/sync-placement-journal-storage.ts +513 -0
  38. package/src/memory/sync-placement-journal.ts +153 -0
  39. package/src/memory/sync-placement-materialize.ts +149 -0
  40. package/src/memory/sync-placement-plan.ts +171 -0
  41. package/src/memory/sync-placement-projection.ts +79 -0
  42. package/src/memory/sync-placement-publication-plan.ts +189 -0
  43. package/src/memory/sync-placement-publication-storage.ts +87 -0
  44. package/src/memory/sync-placement-transport.ts +489 -0
  45. package/src/memory/sync-policy-inspection.ts +126 -0
  46. package/src/memory/sync-screen.ts +128 -33
  47. package/src/memory/sync.ts +20 -64
  48. package/src/setup.ts +69 -14
  49. package/src/ui/inbox-cache.ts +219 -0
  50. package/src/ui/inbox-worker-version.ts +14 -0
  51. package/src/ui/inbox-worker.ts +32 -0
  52. package/src/ui/inbox.ts +281 -0
  53. package/src/ui/panel.js +7 -3
  54. package/src/ui/plugin.ts +26 -53
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.20.2",
3
+ "version": "0.21.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.2. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.21.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.20.2",
3
+ "version": "0.21.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 } 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";
@@ -250,6 +252,8 @@ function markedSetupApplyError(parsed: ReturnType<typeof parseCommand>, error: u
250
252
  cause = "setup_receipt_version_unsupported"; next = "Run setup or undo with the amem release that wrote the receipt."; partial = false;
251
253
  } else if (text === "setup receipt was written from an Orca Claude profile; rerun setup from that Orca profile") {
252
254
  cause = "setup_receipt_orca_profile"; next = "The receipt was written from an Orca Claude profile; rerun setup from that Orca profile."; partial = false;
255
+ } else if (text === "setup receipt is stale: its managed entries were replaced at another amem install root; rerun with --adopt-current to adopt the current wiring") {
256
+ cause = "setup_receipt_stale"; next = "The receipt's managed entries were replaced at another amem install root outside setup; rerun with --apply --adopt-current to keep the receipt aside and adopt the current wiring, which --undo then restores."; partial = false;
253
257
  } else if (/^setup-owned .+ changed; refusing to overwrite it$/.test(text) || text === "Pi amem-session extension already exists and is not this setup's file"
254
258
  || text === "previous-runtime hook settings are customized; review hook configuration before retrying setup"
255
259
  || /^setup receipt (?:does not match this installation|target mismatch|invalid|directory ownership invalid)$/.test(text)) {
@@ -375,7 +379,8 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
375
379
  const { definition, options, rawArgs: args } = parsed;
376
380
  const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
377
381
  if (parsed.path[0] === "setup") {
378
- if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--root <abs path>] [--apply|--undo]");
382
+ if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true
383
+ || 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]");
379
384
  if (parsed.positionals[0] === "console") {
380
385
  const { consoleSetup, consoleSetupText } = await import("./setup-console.ts");
381
386
  const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview";
@@ -387,6 +392,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
387
392
  return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
388
393
  env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
389
394
  ...(typeof options["--root"] === "string" ? { root: options["--root"] } : {}),
395
+ ...(options["--adopt-current"] === true ? { adoptCurrent: true } : {}),
390
396
  ...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
391
397
  });
392
398
  }
@@ -1031,6 +1037,7 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
1031
1037
  // 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.
1032
1038
  const ifDue = sub === "sync" && options["--if-due"] === true, syncHome = memoryHome();
1033
1039
  if (options["--force"] === true && !ifDue) throw new Error("--force only applies with sync --if-due");
1040
+ if (sub === "sync") { assertFormatFloor(syncHome, "sync"); const refusal = placementRuntimeRefusal(syncHome); if (refusal) throw new Error("amem sync: " + refusal); }
1034
1041
  if (ifDue) {
1035
1042
  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";
1036
1043
  const state = usableState(readSyncState(syncHome), Date.now());
@@ -26,7 +26,8 @@ export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
26
26
  export const COMMANDS: Record<string, CommandDefinition> = {
27
27
  consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after explicit acceptance", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
28
28
  setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs, or the acon console plugin manifest (console); apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration"),
29
- "--root": { description: "Register this absolute install root (such as <install>/releases/<version>/, resolved to its real path) instead of the running one", value: "path" } }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
29
+ "--root": { description: "Register this absolute install root (such as <install>/releases/<version>/, resolved to its real path) instead of the running one", value: "path" },
30
+ "--adopt-current": flag("With --apply: keep a stale setup receipt aside (its entries were replaced at another install root) and adopt the current wiring, which --undo restores") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
30
31
  mcp: { description: "Run the project-bound MCP stdio server for this session" },
31
32
  manifest: { description: "Describe this install: package name, version, MCP server and tools (read-only ones marked), commands", options: json },
32
33
  hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
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,14 @@ 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";
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { assertFormatFloor } from "./formats.ts";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { createHash } from "node:crypto";
4
- import { chmodSync, mkdirSync } from "node:fs";
4
+ import { chmodSync, lstatSync, mkdirSync } from "node:fs";
5
5
  import { join } from "node:path";
6
6
  import { CANDIDATE_SHOWN_GRACE_MS, hasShownColumn } from "./candidate-pending.ts";
7
7
  import { oneLine } from "./note-add.ts";
@@ -130,6 +130,72 @@ function retryLocked(work: () => void): void {
130
130
  }
131
131
  }
132
132
 
133
+ type CandidateRow = { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null };
134
+ /** Canonical persisted decoding shared by the writer and its read-only inbox port. */
135
+ function decodeCandidate(row: CandidateRow, unknownKind: () => void): Candidate | undefined {
136
+ const input = JSON.parse(row.payload) as CandidateInput;
137
+ if (input && typeof input === "object" && !KINDS.includes(input.kind)) { unknownKind(); return undefined; }
138
+ validate(input);
139
+ if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
140
+ return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
141
+ }
142
+ const CANDIDATE_EXPIRED_SQL = "at <= ? AND (status IN ('accepted', 'rejected') OR (shown_at IS NOT NULL AND shown_at <= ?))";
143
+ const CANDIDATE_PRIORITY_SQL = "CASE WHEN status IN ('pending', 'accepting') AND shown_at IS NULL THEN 2 WHEN status IN ('pending', 'accepting') THEN 1 ELSE 0 END DESC, at DESC";
144
+ export interface PendingCandidateReader { projects(): string[]; list(projects: readonly string[]): Candidate[]; nextExpiry(): number | null }
145
+ /** Internal inbox read port. It neither creates/migrates a database nor prunes or stamps any row.
146
+ * The port is valid only during the synchronous callback. No immutable fallback may hide current WAL frames. */
147
+ export function withPendingCandidates<T>(memoryDir: string, run: (reader: PendingCandidateReader) => T): T {
148
+ assertFormatFloor(memoryDir, "candidates");
149
+ const file = join(memoryDir, "candidates.db"), stat = lstatSync(file, { throwIfNoEntry: false });
150
+ if (!stat) {
151
+ let closed = false;
152
+ const assertOpen = () => { if (closed) throw new Error("pending candidate reader is closed"); };
153
+ try { return run({ projects() { assertOpen(); return []; }, list(projects) {
154
+ assertOpen(); if (!Array.isArray(projects) || projects.some(project => !text(project, 400))) throw new Error("invalid candidate project scope"); return [];
155
+ }, nextExpiry() { assertOpen(); return null; } }); } finally { closed = true; }
156
+ }
157
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("pending candidates unavailable");
158
+ const db = new Database(file, { readonly: true }); let closed = false;
159
+ try {
160
+ const shown = hasShownColumn(db), now = Date.now();
161
+ const live = (scope: boolean) => `WITH raw AS (SELECT rowid AS candidate_rowid, id, project, kind, payload, at, status, note_id, ${shown ? "shown_at" : "NULL AS shown_at"} FROM candidates${scope ? " WHERE project IN (SELECT value FROM json_each(?))" : ""}),
162
+ live AS (SELECT * FROM raw WHERE NOT (${CANDIDATE_EXPIRED_SQL})),
163
+ retained AS (SELECT *, row_number() OVER (PARTITION BY project ORDER BY ${CANDIDATE_PRIORITY_SQL}, candidate_rowid DESC) AS retention_rank FROM live)`;
164
+ const assertOpen = () => { if (closed) throw new Error("pending candidate reader is closed"); };
165
+ return run({
166
+ nextExpiry() {
167
+ assertOpen(); if (!shown) return null;
168
+ const row = db.query(`${live(false)} SELECT min(expiry) AS expiry FROM (
169
+ SELECT max(at + ?, shown_at + ?) AS expiry FROM retained WHERE retention_rank <= ? AND status = 'pending'
170
+ AND shown_at IS NOT NULL AND kind IN (SELECT value FROM json_each(?))) WHERE expiry > ?`)
171
+ .get(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS, CANDIDATE_RETENTION.ageMs,
172
+ CANDIDATE_SHOWN_GRACE_MS, CANDIDATE_RETENTION.count, JSON.stringify(KINDS), now) as { expiry: number | null };
173
+ if (row.expiry !== null && (!Number.isFinite(row.expiry) || row.expiry <= now)) throw new Error("pending candidate expiry unavailable");
174
+ return row.expiry;
175
+ },
176
+ projects() {
177
+ assertOpen();
178
+ const rows = db.query(`${live(false)} SELECT DISTINCT project FROM retained WHERE retention_rank <= ? AND status = 'pending' AND kind IN (SELECT value FROM json_each(?)) ORDER BY project`)
179
+ .all(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS, CANDIDATE_RETENTION.count, JSON.stringify(KINDS)) as { project: string }[];
180
+ if (rows.some(row => !text(row.project, 400))) throw new Error("pending candidate project unavailable");
181
+ return rows.map(row => row.project);
182
+ },
183
+ list(projects) {
184
+ assertOpen();
185
+ if (!Array.isArray(projects) || projects.some(project => !text(project, 400))) throw new Error("invalid candidate project scope");
186
+ if (!projects.length) return [];
187
+ const rows = db.query(`${live(true)} SELECT * FROM retained WHERE retention_rank <= ? AND status = 'pending' AND kind IN (SELECT value FROM json_each(?)) ORDER BY at DESC, candidate_rowid DESC LIMIT ?`)
188
+ .all(JSON.stringify([...new Set(projects)]), now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS, CANDIDATE_RETENTION.count, JSON.stringify(KINDS), CANDIDATE_RETENTION.count) as (CandidateRow & { project: string })[];
189
+ return rows.flatMap(row => {
190
+ const candidate = decodeCandidate(row, () => {});
191
+ if (candidate && candidate.input.project !== row.project) throw new Error("candidate project binding mismatch");
192
+ return candidate ? [candidate] : [];
193
+ });
194
+ },
195
+ });
196
+ } finally { closed = true; db.close(); }
197
+ }
198
+
133
199
  /** The queue holds the cap's worth of candidates nobody has seen. Named so a caller (the MCP tool) can map it. */
134
200
  export class CandidateQueueFull extends Error {
135
201
  readonly code = "candidate_queue_full";
@@ -184,20 +250,16 @@ export class CandidateStore {
184
250
  this.retainConsolidationReviews();
185
251
  // Candidates (AC4, no candidate expires unshown): by age only a decided one, or a pending one that was shown and has had its grace period, goes. By count three tiers decide who is
186
252
  // kept: unshown pending first, then shown pending, then decided, so a cap eviction takes decided rows before a shown one (which is owed its grace) and shown ones before an unseen one.
187
- this.db.query("DELETE FROM candidates WHERE at <= ? AND (status IN ('accepted', 'rejected') OR (shown_at IS NOT NULL AND shown_at <= ?))").run(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS);
188
- this.db.query(`DELETE FROM candidates WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY CASE WHEN status IN ('pending', 'accepting') AND shown_at IS NULL THEN 2 WHEN status IN ('pending', 'accepting') THEN 1 ELSE 0 END DESC, at DESC, rowid DESC) AS rank FROM candidates) WHERE rank > ?)`).run(CANDIDATE_RETENTION.count);
253
+ this.db.query(`DELETE FROM candidates WHERE ${CANDIDATE_EXPIRED_SQL}`).run(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS);
254
+ this.db.query(`DELETE FROM candidates WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY ${CANDIDATE_PRIORITY_SQL}, rowid DESC) AS rank FROM candidates) WHERE rank > ?)`).run(CANDIDATE_RETENTION.count);
189
255
  this.db.query("DELETE FROM counters WHERE at <= ?").run(now - COUNTER_RETENTION.ageMs);
190
256
  this.db.query("DELETE FROM counters WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY at DESC, rowid DESC) AS rank FROM counters) WHERE rank > ?)").run(COUNTER_RETENTION.count);
191
257
  }).immediate();
192
258
  }
193
259
  /** #199: rows of a kind this version does not know (a newer writer's) are skipped and counted here, never thrown. */
194
260
  skippedUnknownKinds = 0;
195
- private decode(row: { id: string; payload: string; at: number; status: CandidateStatus; note_id: string | null }): Candidate | undefined {
196
- const input = JSON.parse(row.payload) as CandidateInput;
197
- if (input && typeof input === "object" && !KINDS.includes(input.kind)) { this.skippedUnknownKinds++; return undefined; }
198
- validate(input);
199
- if (hash({ schema: "agent-mem.candidate/v1", input }) !== row.id) throw new Error("candidate content hash mismatch");
200
- return { id: row.id, createdAt: new Date(row.at).toISOString(), status: row.status, input, acceptedNoteId: row.note_id };
261
+ private decode(row: CandidateRow): Candidate | undefined {
262
+ return decodeCandidate(row, () => { this.skippedUnknownKinds++; });
201
263
  }
202
264
  put(rawInput: CandidateInput, verdict: CandidateVerdict): string {
203
265
  // `<private>` spans are never stored: dropped before the input is validated, hashed (so the id) or written.