@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/package.json +2 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +2 -1
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +67 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +98 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/setup.ts +69 -14
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
|
|
4
4
|
|
|
5
|
-
Version: 0.
|
|
5
|
+
Version: 0.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
|
|
35
|
+
For Orca-managed Claude and Codex homes, setup writes the personal source that Orca copies into new sessions. Claude hooks go to `$HOME/.claude/settings.json` and its MCP entry to `$HOME/.claude.json`, because Orca syncs the personal `mcpServers` over each profile's copy (#243); the profile copies stay untouched. Codex MCP and hooks go to `$HOME/.codex/config.toml` and `hooks.json`. Detection requires an exact managed-home shape under `ORCA_USER_DATA_PATH` (default `~/Library/Application Support/orca`); a similar directory elsewhere is unchanged. Preview lists each redirect and its reason. Start a new Orca session after applying. Existing matching managed-copy receipts migrate on apply; undo restores the files and bytes recorded for that installation. Codex hooks still require the person's native `/hooks` review; setup never grants trust or writes `trusted_hash`.
|
|
36
36
|
|
|
37
37
|
`amem setup agy` registers MCP only in the current git repository's `.agents/mcp_config.json`, with an explicit absolute repository cwd and a private receipt per repository. `setup all` excludes agy; run it separately in each repository. Setup refuses tracked config files and JSONC, and warns when the file is not ignored. Restart Antigravity after applying, use one workspace per session, and ensure no global, plugin or running server is also named `amem`. No global configuration, ingest command or hook is installed. Undo removes `.agents` only when setup created it and it is still empty; pre-existing or nonempty directories remain. Older receipts still restore their configuration without directory cleanup. See the [Antigravity contract and capture limitation](docs/antigravity.md).
|
|
38
38
|
|
|
@@ -157,7 +157,7 @@ Acceptance normalizes its note once, including timestamp, content ID and short-t
|
|
|
157
157
|
|
|
158
158
|
## Console (acon)
|
|
159
159
|
|
|
160
|
-
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (
|
|
160
|
+
`@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (`@staix/agent-console` 0.1.0, contract api 1). It adds a Memory area with the panels Review, Recap, Search, Timeline, Work lists, Goals and Capacity, puts pending candidates (group `candidate`) and a capture capacity proposal (group `alert`) in the console inbox, and answers the console palette with memory search hits.
|
|
161
161
|
|
|
162
162
|
```sh
|
|
163
163
|
amem setup console # preview
|
|
@@ -169,6 +169,7 @@ The manifest holds the absolute path and sha256 of `src/ui/plugin.ts` (of the `-
|
|
|
169
169
|
|
|
170
170
|
- Reads are screened. Search, Timeline and Work lists call the in-process `amem_search`, `amem_timeline` and `amem_work_state_read` handlers for the chosen project and forward every option the tool declares, so console search returns exactly the `amem_search` set for the same query. Goals and the review queue pass the configured policy; a candidate that fails it, or whose target fails it, is not shown (the panel counts it and points to `amem review walk`). The console never shows unscreened content, also not to the person. It runs in no repository, so a repository's `.amem/config` narrowing does not apply; the machine's `amem.config.json` does.
|
|
171
171
|
- Recap computes the current SessionStart brief only on its own panel snapshots, using machine policy. Curated and work inputs use bounded private scratch copies; capture is read-only, and no live reader lease is taken. It shows the full context, coverage, partial status, token upper bound and generation time; it is not a stored copy of a prior session's injected brief. The console has no repository binding, so the repository section is absent. No recap or new capture field is stored in the memory home.
|
|
172
|
+
- Review shows the candidate kind, proposal, correction diff, stored evidence references, target-note quotes with dates and similar memories. Source sessions, proposing agents and times seen are not recorded by the candidate schema; the panel labels them unavailable rather than inferring them from capture data. An explicitly unavailable project is refused instead of falling back to another project.
|
|
172
173
|
- Review actions are the CLI's. Accept runs the same acceptance as `amem review accept` and writes the same note, edit-then-accept is `review walk`'s edit (the person's own note, then the candidate closed), reject is `review reject`. The console stamps the person as the actor (peer `person`). A privacy-screen hit refuses with a named reason and writes nothing. agent-mem keeps no reject reason, so a reason typed in the console is not recorded, and the reply says so.
|
|
173
174
|
- Undo is offered for 10 seconds and stays bound to what that console just did: undoing an accept forgets the accepted note (an append-only forget op; a note a correction replaced shows again), undoing a reject makes the candidate pending again. A promotion has no undo. `merge_into` is not offered: the library has no merge operation.
|
|
174
175
|
- Destructive forms (`gc`, `capacity_apply`, `project_forget`, `decay_apply`, `prune`) answer `needsConfirm` with the preview and `changes[]` and never write on the first call. The console's single-use confirm token carries the fingerprint back; the plugin recomputes the preview and writes only when it still matches. `prune` needs a generation home, as `amem prune` does.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@staix/agent-mem",
|
|
3
|
-
"version": "0.
|
|
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": "
|
|
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
|
|
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());
|
package/src/commands-table.ts
CHANGED
|
@@ -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" }
|
|
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
|
-
|
|
744
|
-
|
|
774
|
+
const temporary = join(writing, `${name}.writing-${randomBytes(12).toString("hex")}`);
|
|
775
|
+
let fd: number | undefined, created: OwnedFile | undefined;
|
|
776
|
+
try {
|
|
777
|
+
fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
|
|
778
|
+
created = { file: temporary, fd, initial: fstatSync(fd) };
|
|
779
|
+
if (!ownsWriting(created)) throw new Error("Hook publication refused: private temporary ownership unavailable. Retry the hook; inspect spool permissions and ownership if it repeats.");
|
|
780
|
+
writeFileSync(fd, bytes);
|
|
781
|
+
if (!ownsWriting(created) || !owns({ ...created, initial: fstatSync(fd) })) throw new Error("Hook publication refused: private temporary changed before publication. Retry the hook; inspect competing writers if it repeats.");
|
|
782
|
+
renameSync(temporary, join(destination, name));
|
|
783
|
+
} finally {
|
|
784
|
+
try { if (created && ownsWriting(created)) rmSync(temporary); }
|
|
785
|
+
finally { if (fd !== undefined) closeSync(fd); }
|
|
786
|
+
}
|
|
745
787
|
return { name, cwd, receipt: hash(bytes), ...(deferred ? { deferred: true as const } : {}), ...(backlog ? { backlog: true as const } : {}) };
|
|
746
788
|
}
|
|
747
789
|
|
package/src/index.ts
CHANGED
|
@@ -17,9 +17,21 @@ export * from "./memory/sync.ts";
|
|
|
17
17
|
export * from "./memory/import.ts";
|
|
18
18
|
export * from "./memory/project.ts";
|
|
19
19
|
export * from "./memory/home.ts";
|
|
20
|
-
export
|
|
20
|
+
export {
|
|
21
|
+
canonicalPath, realPath, DENY_SEGMENTS, DENY_NAMES, isDenied, memoryGitArgv, memoryGitEnv, memoryGitSync,
|
|
22
|
+
withGitTimeout, processSignature, processLiveness,
|
|
23
|
+
} from "./memory/platform.ts";
|
|
24
|
+
export type { Liveness } from "./memory/platform.ts";
|
|
21
25
|
export * from "./memory/commands.ts";
|
|
22
|
-
export
|
|
26
|
+
export {
|
|
27
|
+
CANDIDATE_DB_BUSY_TIMEOUT_MS, ACCEPTED_CONSOLIDATION_PREFIX, CANDIDATE_RETENTION, CONSOLIDATION_REVIEW_LIMIT,
|
|
28
|
+
ConsolidationReviewLimit, COUNTER_RETENTION, candidateInputId, acceptedCorrectionText, goalPromotionLinked,
|
|
29
|
+
candidateTargetRevision, CandidateQueueFull, CandidateStore,
|
|
30
|
+
} from "./memory/candidates.ts";
|
|
31
|
+
export type {
|
|
32
|
+
CandidateVerdict, CandidateTarget, CandidateInput, CandidateStatus, Candidate, ConsolidationReview, CandidateFilter,
|
|
33
|
+
CounterKey, CounterValues, Counter, CandidateAcceptance, CandidateAcceptanceResult,
|
|
34
|
+
} from "./memory/candidates.ts";
|
|
23
35
|
export * from "./memory/ops-contract.ts";
|
|
24
36
|
export * from "./memory/generation.ts";
|
|
25
37
|
export * from "./memory/migrate.ts";
|
|
@@ -49,3 +61,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";
|
package/src/memory/bridge.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, type Hash } from "node:crypto";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, unlinkSync, writeSync } from "node:fs";
|
|
3
|
+
import { closeSync, constants, fstatSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, opendirSync, unlinkSync, writeSync } from "node:fs";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { memoryGitArgv, memoryGitEnv, realPath } from "./platform.ts";
|
|
@@ -51,7 +51,39 @@ const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: big
|
|
|
51
51
|
export class SegmentReader {
|
|
52
52
|
private readonly prefixes = new Map<string, PrefixState>();
|
|
53
53
|
private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
|
|
54
|
-
|
|
54
|
+
private remainingSourceBytes: number;
|
|
55
|
+
private remainingRows: number;
|
|
56
|
+
private remainingFiles: number;
|
|
57
|
+
private sourceBytes = 0;
|
|
58
|
+
private sourceRows = 0;
|
|
59
|
+
/** Optional inspection limits do not change ordinary incremental-reader semantics. */
|
|
60
|
+
readonly sourceByteLimit?: number;
|
|
61
|
+
readonly sourceFileLimit?: number;
|
|
62
|
+
constructor(private remainingBytes = Infinity, limits: { maxSourceBytes?: number; maxRows?: number } = {}) {
|
|
63
|
+
for (const value of [remainingBytes, limits.maxSourceBytes ?? Infinity, limits.maxRows ?? Infinity]) if (value !== Infinity && (!Number.isSafeInteger(value) || value < 0)) failure();
|
|
64
|
+
this.sourceByteLimit = limits.maxSourceBytes;
|
|
65
|
+
this.remainingSourceBytes = limits.maxSourceBytes ?? Infinity;
|
|
66
|
+
this.remainingRows = limits.maxRows ?? Infinity;
|
|
67
|
+
this.sourceFileLimit = limits.maxRows;
|
|
68
|
+
this.remainingFiles = limits.maxRows ?? Infinity;
|
|
69
|
+
}
|
|
70
|
+
inspectionMetrics(): { sourceBytes: number; rows: number } { return { sourceBytes: this.sourceBytes, rows: this.sourceRows }; }
|
|
71
|
+
/** Inspection counts directory entries and archived segment names before their arrays/maps can grow.
|
|
72
|
+
* ponytail: metadata entries share maxRows (public inspection: 20000), including empty files.
|
|
73
|
+
* Upgrade with a separately budgeted catalog proof if valid profiles need a larger metadata frontier.
|
|
74
|
+
*/
|
|
75
|
+
reserveSourceFiles(count: number): void {
|
|
76
|
+
if (!Number.isSafeInteger(count) || count < 0 || count > this.remainingFiles) throw new SegmentReadBudgetError();
|
|
77
|
+
this.remainingFiles -= count;
|
|
78
|
+
}
|
|
79
|
+
listFiles(dir: string): string[] {
|
|
80
|
+
if (this.sourceFileLimit === undefined) return readdirSync(dir).sort();
|
|
81
|
+
const reader = opendirSync(dir), names: string[] = [];
|
|
82
|
+
try {
|
|
83
|
+
for (;;) { const entry = reader.readSync(); if (!entry) break; this.reserveSourceFiles(1); names.push(entry.name); }
|
|
84
|
+
} finally { reader.closeSync(); }
|
|
85
|
+
return names.sort();
|
|
86
|
+
}
|
|
55
87
|
metrics(): SegmentReadMetrics { return { ...this.measured }; }
|
|
56
88
|
resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
|
|
57
89
|
read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
|
|
@@ -60,6 +92,8 @@ export class SegmentReader {
|
|
|
60
92
|
const stamp = statStamp(before), size = Number(before.size);
|
|
61
93
|
if (prior && size < prior.offset) failure();
|
|
62
94
|
if (!full && prior?.stamp === stamp) return { bytes: Buffer.alloc(0), start: prior.offset, offset: prior.offset, hash: prior.hash, tailBytes: size - prior.offset, stamp };
|
|
95
|
+
if (size > this.remainingSourceBytes) throw new SegmentReadBudgetError();
|
|
96
|
+
this.remainingSourceBytes -= size; this.sourceBytes += size;
|
|
63
97
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
98
|
try {
|
|
65
99
|
const opened = fstatSync(fd, { bigint: true });
|
|
@@ -73,6 +107,11 @@ export class SegmentReader {
|
|
|
73
107
|
const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
|
|
74
108
|
if (prior && end < prior.offset) failure();
|
|
75
109
|
if (!read(end).equals(bytes) || fstatSync(fd, { bigint: true }).size < opened.size) failure();
|
|
110
|
+
// Count physical nonblank source rows before any canonical source map can grow.
|
|
111
|
+
if (this.remainingRows !== Infinity) for (const line of completeLines(bytes)) if (line.toString("utf8").trim()) {
|
|
112
|
+
if (this.remainingRows < 1) throw new SegmentReadBudgetError();
|
|
113
|
+
this.remainingRows--; this.sourceRows++;
|
|
114
|
+
}
|
|
76
115
|
const after = lstatSync(path, { bigint: true });
|
|
77
116
|
if (after.isSymbolicLink() || after.dev !== opened.dev || after.ino !== opened.ino) failure();
|
|
78
117
|
const cached = immutable ? undefined : this.prefixes.get(path);
|
|
@@ -116,7 +155,7 @@ function checkpointMap(rows: readonly LegacyCheckpoint[] = []): Map<string, Lega
|
|
|
116
155
|
return map;
|
|
117
156
|
}
|
|
118
157
|
|
|
119
|
-
function checkedEntry(value: unknown): Entry {
|
|
158
|
+
export function checkedEntry(value: unknown): Entry {
|
|
120
159
|
if (!value || typeof value !== "object" || Array.isArray(value)) malformed();
|
|
121
160
|
const row = value as Record<string, unknown>;
|
|
122
161
|
const operation = row.op;
|
|
@@ -141,7 +180,7 @@ function checkedEntry(value: unknown): Entry {
|
|
|
141
180
|
return row as unknown as Entry;
|
|
142
181
|
}
|
|
143
182
|
|
|
144
|
-
function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
183
|
+
export function* completeLines(bytes: Buffer): Generator<Buffer> {
|
|
145
184
|
let start = 0;
|
|
146
185
|
while (start < bytes.length) { const end = bytes.indexOf(10, start); if (end < 0) failure(); yield bytes.subarray(start, end); start = end + 1; }
|
|
147
186
|
}
|
|
@@ -198,7 +237,7 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
198
237
|
for (const folder of ["log", "local"]) {
|
|
199
238
|
const dir = join(root, folder);
|
|
200
239
|
if (!directory(dir, true)) continue;
|
|
201
|
-
for (const name of readdirSync(dir).sort()) {
|
|
240
|
+
for (const name of options.reader ? options.reader.listFiles(dir) : readdirSync(dir).sort()) {
|
|
202
241
|
if (!name.endsWith(".jsonl")) continue;
|
|
203
242
|
const path = `${folder}/${name}`;
|
|
204
243
|
if (!segmentPath(path)) failure();
|
|
@@ -212,11 +251,12 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
212
251
|
}
|
|
213
252
|
if ((options.commit || options.previousCommit) && !options.generation || options.previousCommit && !commitId(options.previousCommit)) failure();
|
|
214
253
|
if (options.generation !== undefined) {
|
|
215
|
-
const commit = options.commit ?? legacyHead(root, options.generation);
|
|
254
|
+
const commit = options.commit ?? legacyHead(root, options.generation, options.reader?.sourceByteLimit, options.reader?.sourceFileLimit);
|
|
216
255
|
if (commit) {
|
|
217
|
-
const manifest = archived(root, options.generation, commit);
|
|
256
|
+
const manifest = archived(root, options.generation, commit, options.reader?.sourceByteLimit, options.reader?.sourceFileLimit);
|
|
257
|
+
options.reader?.reserveSourceFiles(manifest.segments.length);
|
|
218
258
|
directory(join(archiveDir(root, options.generation, commit), "log"));
|
|
219
|
-
for (const prior of new Set([...(options.previousCommit ? [options.previousCommit] : []), ...[...checkpoints.values()].filter(row => row.source === remoteSource).map(row => row.commit!)])) if (!archivedAncestor(root, options.generation, prior, commit)) failure();
|
|
259
|
+
for (const prior of new Set([...(options.previousCommit ? [options.previousCommit] : []), ...[...checkpoints.values()].filter(row => row.source === remoteSource).map(row => row.commit!)])) if (!archivedAncestor(root, options.generation, prior, commit, options.reader?.sourceByteLimit)) failure();
|
|
220
260
|
for (const segment of manifest.segments) {
|
|
221
261
|
const reader = options.reader ?? new SegmentReader(), incremental = options.incremental && !limits;
|
|
222
262
|
const data = reader.read(join(archiveDir(root, options.generation, commit), segment.path), incremental ? checkpoints.get(`${remoteSource}:${segment.path}`) : undefined, !incremental, true);
|
|
@@ -239,15 +279,21 @@ function snapshot(homeM: string, options: LegacySnapshotOptions): LegacySegment[
|
|
|
239
279
|
return result;
|
|
240
280
|
}
|
|
241
281
|
|
|
242
|
-
function capturedIdentity(path: string): string {
|
|
282
|
+
function capturedIdentity(path: string, maximum?: number): string {
|
|
243
283
|
const before = lstatSync(path);
|
|
244
284
|
if (before.isSymbolicLink() || !before.isFile()) failure();
|
|
245
285
|
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
246
286
|
try {
|
|
247
287
|
const stat = fstatSync(fd);
|
|
248
|
-
if (
|
|
288
|
+
if (maximum !== undefined && (before.size > maximum || stat.size > maximum)) throw new SegmentReadBudgetError();
|
|
289
|
+
if (stat.dev !== before.dev || stat.ino !== before.ino || stat.size > (maximum ?? 4096)) failure();
|
|
290
|
+
if (maximum !== undefined && (stat.size !== before.size || stat.mtimeMs !== before.mtimeMs || stat.ctimeMs !== before.ctimeMs)) failure();
|
|
249
291
|
const bytes = Buffer.alloc(stat.size);
|
|
250
292
|
if (readSync(fd, bytes, 0, bytes.length, 0) !== bytes.length) failure();
|
|
293
|
+
if (maximum !== undefined) {
|
|
294
|
+
const after = fstatSync(fd), named = lstatSync(path);
|
|
295
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs || named.isSymbolicLink() || named.dev !== stat.dev || named.ino !== stat.ino) failure();
|
|
296
|
+
}
|
|
251
297
|
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
252
298
|
} finally { closeSync(fd); }
|
|
253
299
|
}
|
|
@@ -266,14 +312,14 @@ function cachedGit(cache: string, args: string[], allowMissing = false): Buffer
|
|
|
266
312
|
return run.stdout;
|
|
267
313
|
}
|
|
268
314
|
/** Last explicitly fetched legacy main, not a claim about the current remote state. Never fetches or writes. */
|
|
269
|
-
function head(homeM: string, generation: string): string | undefined {
|
|
315
|
+
function head(homeM: string, generation: string, maximumArchiveBytes?: number, maximumArchiveSegments?: number): string | undefined {
|
|
270
316
|
const root = realPath(homeM); directory(root);
|
|
271
317
|
const dir = fixedGeneration(root, generation);
|
|
272
318
|
if (!directory(dir, true)) return undefined;
|
|
273
319
|
const active = readProfileJSON(join(dir, "bridge-snapshot.json"), 4096) as { schema?: unknown; generation?: unknown; commit?: unknown } | undefined;
|
|
274
320
|
if (!active) return undefined;
|
|
275
321
|
if (active.schema !== "agent-mem.legacy-head/v1" || active.generation !== generation || typeof active.commit !== "string" || !commitId(active.commit)) failure();
|
|
276
|
-
archived(root, generation, active.commit);
|
|
322
|
+
archived(root, generation, active.commit, maximumArchiveBytes, maximumArchiveSegments);
|
|
277
323
|
return active.commit;
|
|
278
324
|
}
|
|
279
325
|
|
|
@@ -281,8 +327,8 @@ function head(homeM: string, generation: string): string | undefined {
|
|
|
281
327
|
export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
|
|
282
328
|
try { return snapshot(homeM, options); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
|
|
283
329
|
}
|
|
284
|
-
export function legacyHead(homeM: string, generation: string): string | undefined {
|
|
285
|
-
try { return head(homeM, generation); } catch { return failure(); }
|
|
330
|
+
export function legacyHead(homeM: string, generation: string, maximumArchiveBytes?: number, maximumArchiveSegments?: number): string | undefined {
|
|
331
|
+
try { return head(homeM, generation, maximumArchiveBytes, maximumArchiveSegments); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
|
|
286
332
|
}
|
|
287
333
|
|
|
288
334
|
|
|
@@ -301,9 +347,11 @@ function archiveDir(root: string, generation: string, commit: string): string {
|
|
|
301
347
|
const dir = join(fixedGeneration(root, generation), "bridge-snapshots");
|
|
302
348
|
directory(dir); const target = join(dir, commit); directory(target); return target;
|
|
303
349
|
}
|
|
304
|
-
function archived(root: string, generation: string, commit: string): LegacyArchive {
|
|
305
|
-
const
|
|
350
|
+
function archived(root: string, generation: string, commit: string, maximumBytes?: number, maximumSegments?: number): LegacyArchive {
|
|
351
|
+
const file = join(archiveDir(root, generation, commit), "snapshot.json");
|
|
352
|
+
const value = (maximumBytes === undefined ? readProfileJSON(file, 32 * 1024 * 1024) : JSON.parse(capturedIdentity(file, Math.min(maximumBytes, 32 * 1024 * 1024)))) as LegacyArchive | undefined;
|
|
306
353
|
if (!value || value.schema !== "agent-mem.legacy-snapshot/v1" || value.generation !== generation || value.commit !== commit || value.previousCommit !== undefined && !commitId(value.previousCommit) || !Array.isArray(value.segments)) failure();
|
|
354
|
+
if (maximumSegments !== undefined && value.segments.length > maximumSegments) throw new SegmentReadBudgetError();
|
|
307
355
|
const paths = new Set<string>();
|
|
308
356
|
for (const segment of value.segments) {
|
|
309
357
|
if (!segment || !segmentPath(segment.path) || !segment.path.startsWith("log/") || paths.has(segment.path) || !Number.isSafeInteger(segment.offset) || segment.offset < 0 || !/^[a-f0-9]{64}$/.test(segment.hash) || !Number.isSafeInteger(segment.tailBytes) || segment.tailBytes < 0) failure();
|
|
@@ -311,9 +359,9 @@ function archived(root: string, generation: string, commit: string): LegacyArchi
|
|
|
311
359
|
}
|
|
312
360
|
return value;
|
|
313
361
|
}
|
|
314
|
-
function archivedAncestor(root: string, generation: string, previous: string, commit: string): boolean {
|
|
362
|
+
function archivedAncestor(root: string, generation: string, previous: string, commit: string, maximumBytes?: number): boolean {
|
|
315
363
|
const visited = new Set<string>();
|
|
316
|
-
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor).previousCommit) {
|
|
364
|
+
for (let cursor: string | undefined = commit; cursor && !visited.has(cursor); cursor = archived(root, generation, cursor, maximumBytes).previousCommit) {
|
|
317
365
|
if (cursor === previous) return true;
|
|
318
366
|
visited.add(cursor);
|
|
319
367
|
}
|
package/src/memory/candidates.ts
CHANGED
|
@@ -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(
|
|
188
|
-
this.db.query(`DELETE FROM candidates WHERE rowid IN (SELECT rowid FROM (SELECT rowid, row_number() OVER (PARTITION BY project ORDER BY
|
|
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:
|
|
196
|
-
|
|
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.
|