@staix/agent-mem 0.7.0 → 0.8.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 +10 -2
- package/package.json +1 -1
- package/src/cli.ts +72 -11
- package/src/commands-table.ts +14 -8
- package/src/index.ts +6 -0
- package/src/mcp-memory.ts +28 -1
- package/src/mcp.ts +1 -0
- package/src/memory/candidates.ts +86 -2
- package/src/memory/consolidate.ts +78 -0
- package/src/memory/consolidation-clusters.ts +56 -0
- package/src/memory/consolidation-model.ts +97 -0
- package/src/memory/doctor.ts +11 -8
- package/src/memory/eval-fixture.ts +8 -0
- package/src/memory/eval-set-ko.json +90 -0
- package/src/memory/eval.ts +5 -3
- package/src/memory/evidence.ts +76 -0
- package/src/memory/external-import.ts +267 -0
- package/src/memory/platform.ts +12 -1
- package/src/memory/project-export.ts +57 -22
- package/src/memory/project-import.ts +147 -0
- package/src/memory/store.ts +63 -9
- package/src/memory/sync-cadence.ts +15 -5
- package/src/memory/sync.ts +16 -4
- package/src/setup-toml.ts +5 -0
- package/src/setup.ts +6 -6
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.8.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -203,7 +203,7 @@ Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and
|
|
|
203
203
|
|
|
204
204
|
`store.search(query, filter)` searches the curated tier and the current project's capture shard with SQLite FTS5 and BM25 plus tier/recency weighting: natural multi-word queries match partial term sets (the substring filter required every word), and a curated row outranks the same text in the capture tier. Prefix terms match inflections and Korean particles in either direction by retaining the original query term and adding a common-particle-stripped alternative. The FTS copy of the curated rows lives in its own derived `search.db` (`index.db`'s table set is a frozen contract and gains nothing); it is written from the same apply path as the index, and a prune, crash or rebuild is detected and resynced, so the incremental FTS copy equals a full resync. Where FTS5 is unavailable the engine falls back to the substring filter and reports `engine: "substring"`; capture recall needs FTS5 and is skipped in the fallback. The library entry exports `CaptureStore`, `captureSearch`, `readCaptureConfig`, `writeCaptureConfig`, `SearchIndex`, `fts5Available`, the shard helpers (`resolveAlias`, `appendAlias`, `listAliases`, `shardKeyHash`), the catalog (`rebuildCatalog`, `readCatalog`, `scanCatalog`) and the capacity advisor (`capacityReport`).
|
|
205
205
|
|
|
206
|
-
`amem eval [--set
|
|
206
|
+
`amem eval [--set ko|<file>] [--k 1,5,10] [--json]` measures recall with no model and no network (agent-mem#40 F13): it replays a question set and reports recall@k, hit@k and MRR. It scores the ranked `store.search` (BM25 with tier and recency weights), which is what the MCP `amem_search` tool and hub recall rank with and what #13 AC4 measured. It is not the `amem search` command: that one is a substring filter (every word must appear, newest first), so `amem search lighthouse beacon` can print nothing for a note that `amem eval` finds. Whether `amem search` should use the ranked search is a separate question. Without `--set` it scores the built-in synthetic set (26 invented harbour entries, long and short tier, written a day apart, and 32 questions, shipped in `src/memory/eval-set.json`), which is seeded into a throwaway store, so it never reads or writes your memory home and gives the same numbers on every machine. A set of your own either carries its own `entries` (self-contained, named by `key`) or, without entries, names real entry ids (or a prefix of 8 or more hex characters) and is scored against this project's store; each name is resolved like `amem show` does (a promoted short-term id finds its long-term copy, and a superseded id stands for the entry that replaced it, since search returns only live entries), and a prefix that matches more than one live entry is refused. `mem:<id>` as a brief prints it is accepted. Questions whose expected entry is not in the store score 0 in `all` and are left out of `answerable`, and `answerable` is scored over the entries the store holds, so a ranking problem and a coverage problem stay apart (`n/a` when no question is answerable). `--set ko` scores the second built-in set instead: 37 invented Korean entries and 38 questions (particle variants, spaced and unspaced compounds, mixed Korean-English terms, Latin abbreviations, short and long tier), with its baseline recorded in `docs/contracts.md` (agent-mem#62). Set format: `docs/contracts.md#recall-evaluation-f13`.
|
|
207
207
|
|
|
208
208
|
The R7 capacity advisor reports this machine's load and proposes capture bounds: `amem capacity [--json]` shows capture size and growth, disk, memory, the rolling `metrics.jsonl` readings and the proposal with its binding constraint; `amem capacity apply [--cap-mb N] [--retention-days N]` saves a preview, and only a matching later `--confirm` writes the home default config. Applying is person-only (agent markers refused, a terminal required); an agent may read the proposal through the library, never apply it. Under the 2026-10-17 owner decision (item 2) the proposal is the largest cap and retention that fit, and it may be above the current values: the cap is the disk-share bound itself (default 2% of free space, `--disk-share`), and retention is the share divided by the measured daily growth, up to a 365-day ceiling (a `ponytail:` constant, a person setting is the upgrade path). A hook p95 over 150 ms or hook RSS over 100 MB halves the proposed cap, and search RSS over 200 MB halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`, `R3 search RSS`, `R2 hook cost`. A proposal is never written by the advisor, and `apply` still takes `--cap-mb` and `--retention-days` to override it. Benchmark samples stay in the measuring script's synthetic home; only real hook and search invocations feed the advisor's rolling metrics.
|
|
209
209
|
|
|
@@ -450,3 +450,11 @@ Use `amem brief --for "repair the writer" --paths src/writer.ts` for a byte-capp
|
|
|
450
450
|
Current recall omits superseded and expired entries. `amem show <id> --history` shows retained versions, `amem graph <id> --history` follows retained history, and `amem search writer --as-of 2026-10-20 --history` reads retained state at UTC midnight. Current forget/prune removals still hide the text at every past date. Promotion event time is not serialized, so affected history reports `promotion time unknown: tier before promotion cannot be recovered`. `(stale?)` is only a bounded path-metadata hint. See `docs/contracts.md` for retention and history limits.
|
|
451
451
|
|
|
452
452
|
The library exports `buildIntentBrief`, `planGuardNodes`, `matchingPlanFails` and nullable usage callbacks. Emitted output may record rendered counts; delivered and cited counts need explicit host observations. Missing evidence stays unknown. Agent-hub adoption is a separate follow-up after a published library release.
|
|
453
|
+
|
|
454
|
+
## Consolidation and evidence
|
|
455
|
+
|
|
456
|
+
Run `amem consolidate [--project <current-key>]` (another project is refused) to queue deterministic proposals for `amem review`. It compares a bounded project-local window using indexed text overlap, shared paths and current supersession-chain metadata. It does not edit curated notes. The person reviews each merge, replacement or recurring-failure summary, and acceptance rechecks every source revision. A recurring-failure summary preserves its source failures and decisions. Large source quotations are counted as too large instead of silently shortened into a proposed note.
|
|
457
|
+
|
|
458
|
+
The CLI runs the deterministic pass only. A library host may explicitly enable an injected model callback for the person; no provider is built in. It has batch, byte, deadline and persistent daily limits. Model answers remain marked machine-local candidates that cannot be accepted; `amem note add` is the separate path for an independently authored note. A non-answer is never saved.
|
|
459
|
+
|
|
460
|
+
Agents use `amem_evidence` with a question to get cited, live curated entries, not an answer. The complete JSON bundle is byte-capped (`maxBytes`, default 4096, range 256..8192); excerpted entries retain an exact `mem:` reference for fetching the full note. Capture, repository-only rows, pending candidates and model answers are excluded. amem makes no model call for this tool. Rendering a citation in the bundle records only `rendered` after successful output, never `delivered` or `cited`.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import type { Writable } from "node:stream";
|
|
3
3
|
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
|
+
import { proposeConsolidation } from "./memory/consolidate.ts";
|
|
4
5
|
import { createIntentBriefSource } from "./memory/recall.ts";
|
|
5
6
|
import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
|
|
6
7
|
import { recordMemoryUse } from "./memory/use-telemetry.ts";
|
|
@@ -33,19 +34,21 @@ import { readCatalogFresh } from "./memory/catalog.ts";
|
|
|
33
34
|
import { appendLifecycle, assertProjectActive, projectState } from "./memory/lifecycle.ts";
|
|
34
35
|
import { gcText, runGc } from "./memory/gc.ts";
|
|
35
36
|
import { usageReport, usageText } from "./memory/usage.ts";
|
|
36
|
-
import { exportProject } from "./memory/project-export.ts";
|
|
37
|
+
import { exportProject, type ProjectExportFormat } from "./memory/project-export.ts";
|
|
38
|
+
import { importProject } from "./memory/project-import.ts";
|
|
39
|
+
import { importExternalMemory, type ExternalMemorySource } from "./memory/external-import.ts";
|
|
37
40
|
import { searchAllProjects, vaultCandidatesAll } from "./memory/cross-project.ts";
|
|
38
41
|
import { requireShard } from "./memory/curated-read.ts";
|
|
39
42
|
import { appendGitattributes, appendRepoLine, boundRepoRoot, planGitattributes, planRepoPromotion, readRepoScope, repoLogIgnored, repoLogPath, repoReadPolicy, repoVisibility, type RepoView } from "./memory/repo-scope.ts";
|
|
40
43
|
import type { Visibility } from "./memory/sync.ts";
|
|
41
44
|
import { applySplit, previewSplit } from "./memory/capacity-split.ts";
|
|
42
45
|
import { evalText, parseKs } from "./memory/eval.ts";
|
|
43
|
-
import { evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
|
|
46
|
+
import { builtinSet, evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
|
|
44
47
|
import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
45
48
|
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
46
49
|
import { runEditor } from "./memory/editor.ts";
|
|
47
50
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
48
|
-
import { nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
51
|
+
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
49
52
|
|
|
50
53
|
export const PERSON_ONLY = "amem is for the person: an agent reads memory through its `amem mcp` server (see `amem --help`); the person reviews what agents propose with `amem review`";
|
|
51
54
|
const VERSION = (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version;
|
|
@@ -67,6 +70,8 @@ export interface CliContext {
|
|
|
67
70
|
edit?: (initial: string) => string | Promise<string>;
|
|
68
71
|
/** Test seam: the `sync --if-due` deadline in milliseconds (the CLI always uses 30 seconds). */
|
|
69
72
|
syncDeadlineMs?: number;
|
|
73
|
+
/** Called with the signal name when a person's signal ended `sync --if-due`, after its handlers are removed. `main` re-raises it once the output is written; absent, nothing happens. */
|
|
74
|
+
onPersonStop?: (signal: string) => void;
|
|
70
75
|
stdin?: () => string;
|
|
71
76
|
}
|
|
72
77
|
|
|
@@ -227,6 +232,23 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
227
232
|
} catch { return false; } finally { try { candidates?.close(); } catch { /* optional telemetry cannot fail emitted output */ } }
|
|
228
233
|
} });
|
|
229
234
|
};
|
|
235
|
+
if (sub === "consolidate") {
|
|
236
|
+
const requested = typeof options["--project"] === "string" ? options["--project"] as string : boundProject();
|
|
237
|
+
if (!requested.trim() || requested.length > 400 || requested.includes("\0")) throw Error("invalid consolidation project");
|
|
238
|
+
const base = memoryHome(), project = resolveAlias(base, requested), aliases = aliasedKeys(base, project);
|
|
239
|
+
// The policy below is narrowed by the repository around the working directory, which is the bound project's. Another project's own repository policy cannot be resolved from here, so screening it with this one would apply the wrong narrowing.
|
|
240
|
+
if (project !== boundProject()) throw Error("consolidate --project must name the current project; run it from the other project's directory to consolidate that one");
|
|
241
|
+
assertProjectActive(base, project);
|
|
242
|
+
const store = curatedStore(project, { deferPromotions: true, screen: () => "keep" });
|
|
243
|
+
let candidates: CandidateStore | undefined;
|
|
244
|
+
try {
|
|
245
|
+
const binding = commandBinding(store.profile.home, boundProject()), policyKey = () => JSON.stringify(configuredPolicyOptions(base, policyCwd));
|
|
246
|
+
const assertCurrent = () => { binding(); assertProjectActive(base, project); if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n")) throw Error("consolidation project binding changed"); };
|
|
247
|
+
candidates = new CandidateStore({ memoryDir: base, reconcile: { canonical: project, aliases } });
|
|
248
|
+
const report = await proposeConsolidation(store, candidates, { project, actor: "person", policy: policyOf, policyVersion: policyKey, assertCurrent });
|
|
249
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : `${report.candidates.length} consolidation candidates queued for amem review; no curated notes changed${report.alreadyReviewed ? `; ${report.alreadyReviewed} already reviewed` : ""}${report.truncated ? "; scan truncated" : ""}${report.oversized ? `; ${report.oversized} proposals too large` : ""}${report.localExcluded ? `; ${report.localExcluded} local sources excluded` : ""}`;
|
|
250
|
+
} finally { candidates?.close(); store.close(); }
|
|
251
|
+
}
|
|
230
252
|
if (sub === "brief") {
|
|
231
253
|
const intent = options["--for"]; if (typeof intent !== "string" || !intent.trim() || intent.length > 1000 || parsed.positionals.length) throw Error("usage: amem brief --for <intent> [--paths <path>] [--symbols <symbol>] [--bytes N] [--json]");
|
|
232
254
|
const rawBytes = options["--bytes"];
|
|
@@ -382,6 +404,8 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
382
404
|
: `promotion. Goal goal:${input.goalId}, target mem:${input.target.id} changed or is unavailable; reject or skip`;
|
|
383
405
|
} finally { viewStore.close(); }
|
|
384
406
|
}
|
|
407
|
+
if (input.kind === "model-answer") return `Model answer (not evidence; cannot accept, author an independent note with amem note add). ${input.text}`;
|
|
408
|
+
if (input.kind === "consolidation") return `${input.action}. Sources: ${input.targets.map(target => `mem:${target.id}`).join(", ")}. ${input.text}`;
|
|
385
409
|
return input.kind === "memory" ? `${input.title ? `${input.title}: ` : ""}${input.text}` : input.kind === "fail" ? `fail. Approach: ${input.approach} Symptom: ${input.symptom} Cause: ${input.cause} Retry when: ${input.retryWhen}`
|
|
386
410
|
: input.kind === "correction" ? `correction. ${input.proposedText} Reason: ${input.reason}` : `decay. ${input.reason}`; };
|
|
387
411
|
const step = async (args: string[]) => { try { await runCli(args, context); return true; } catch (error) { lines.push(` ${(error as Error).message.split("\n")[0]}`); done.failed++; return false; } };
|
|
@@ -435,6 +459,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
435
459
|
if (!await policyOf().screen(candidate.input)) throw new Error("candidate refused by the current standalone privacy policy (secrets, personal data, deny paths and the patterns in amem.config.json)");
|
|
436
460
|
assertCurrent();
|
|
437
461
|
const result = candidates.accept(id!, { project, actor: "person", verdict: "public", store,
|
|
462
|
+
policyVersion: () => JSON.stringify(configuredPolicyOptions(realPath(store.profile.home), policyCwd)),
|
|
438
463
|
policy: (proposal, target, note, goal) => { assertCurrent(); return policyOf().screenStandalone({ ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) }); },
|
|
439
464
|
});
|
|
440
465
|
return options["--json"] ? JSON.stringify(result, null, 2) : `accepted candidate ${id} as ${candidate.input.kind === "promotion" ? "promoted" : "ordinary"} note ${result.noteId}`;
|
|
@@ -457,14 +482,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
457
482
|
// F13: read-only. A set with entries is scored against a throwaway store seeded with them (touches no memory home); one without scores the bound project's real entries.
|
|
458
483
|
const ks = parseKs(typeof options["--k"] === "string" ? options["--k"] : undefined);
|
|
459
484
|
const file = typeof options["--set"] === "string" ? options["--set"] : undefined;
|
|
460
|
-
const
|
|
485
|
+
const builtin = file === undefined ? undefined : builtinSet(file);
|
|
486
|
+
const set = readEvalSet(builtin ?? file ?? FIXTURE_PATH);
|
|
461
487
|
let report;
|
|
462
488
|
if (set.entries?.length) report = evalSeeded(set, ks);
|
|
463
489
|
else {
|
|
464
490
|
const project = boundProject(), store = curatedStore(project, { deferPromotions: true });
|
|
465
491
|
try { report = evalStore(store, project, set, ks); } finally { store.close(); }
|
|
466
492
|
}
|
|
467
|
-
return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, file ?? "built-in fixture");
|
|
493
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : evalText(report, builtin ? `built-in fixture ${file}` : file ?? "built-in fixture");
|
|
468
494
|
}
|
|
469
495
|
if (sub === "capacity") {
|
|
470
496
|
const share = options["--disk-share"];
|
|
@@ -522,14 +548,37 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
522
548
|
const result = runGc(memoryHome(), { dryRun: options["--confirm"] !== true, ...(batches !== undefined ? { maxBatchesPerShard: batches } : {}) });
|
|
523
549
|
return options["--json"] ? JSON.stringify(result, null, 2) : `${result.dryRun ? "preview (nothing evicted; add --confirm):\n" : ""}${gcText(result)}`;
|
|
524
550
|
}
|
|
551
|
+
if (sub === "import" && ["claude-auto", "codex"].includes(parsed.path[1] ?? "")) {
|
|
552
|
+
for (const flag of ["--db", "--limit", "--restart"]) if (options[flag] !== undefined) throw new Error(`${flag} only applies to import claude-mem`);
|
|
553
|
+
const source = parsed.path[1] as ExternalMemorySource, policy = policyOf();
|
|
554
|
+
const result = await importExternalMemory({ base: memoryHome(), project: boundProject(), source, cwd: policyCwd, env: context.env ?? process.env,
|
|
555
|
+
...(typeof options["--from"] === "string" ? { from: options["--from"] } : {}), policy: value => policy.screenStandalone(value) });
|
|
556
|
+
return options["--json"] ? JSON.stringify(result, null, 2)
|
|
557
|
+
: `imported ${result.added} ${source} candidates from ${result.files} files (${result.read} records, ${result.pending.count} pending); skipped ${result.skipped.rules} rule files, ${result.skipped.policy} policy/secret records, ${result.skipped.invalid} invalid records and ${result.skipped.truncated} oversized inputs${result.queueFull ? "; candidate queue is full" : result.limitReached ? "; per-run candidate cap reached" : ""}`;
|
|
558
|
+
}
|
|
525
559
|
if (sub === "project") {
|
|
526
560
|
const op = parsed.path[1]!, [requested] = parsed.positionals;
|
|
527
561
|
if (!requested) throw new Error(`usage: amem project ${op} <key>`);
|
|
528
562
|
if (op === "export") {
|
|
529
563
|
if (typeof options["--to"] !== "string") throw new Error("usage: amem project export <key> --to <file>");
|
|
564
|
+
const format = (options["--format"] ?? "json") as ProjectExportFormat;
|
|
530
565
|
// The bundle leaves the home, so it is screened by the configured policy (secrets, personal data, the configured patterns and deny paths).
|
|
531
|
-
const result = await exportProject(memoryHome(), requested, { to: options["--to"], policy: policyOf() });
|
|
532
|
-
return options["--json"] ? JSON.stringify(result, null, 2) : `exported ${result.project} to ${result.file}: ${result.counts.capture} capture rows, ${result.counts.curated} notes, ${result.counts.goals} goals, ${result.counts.workLists} work lists${result.withheld ? `; ${result.withheld} withheld by the screen` : ""}`;
|
|
566
|
+
const result = await exportProject(memoryHome(), requested, { to: options["--to"], policy: policyOf(), format });
|
|
567
|
+
return options["--json"] ? JSON.stringify(result, null, 2) : `exported ${result.project} to ${result.file} as ${result.format}: ${result.counts.capture} capture rows, ${result.counts.curated} notes, ${result.counts.goals} goals, ${result.counts.workLists} work lists${result.withheld ? `; ${result.withheld} withheld by the screen` : ""}`;
|
|
568
|
+
}
|
|
569
|
+
if (op === "import") {
|
|
570
|
+
if (typeof options["--from"] !== "string") throw new Error("usage: amem project import <key> --from <file.jsonl> [--confirm] [--allow-shared]");
|
|
571
|
+
const key = resolveAlias(memoryHome(), requested), policy = policyOf();
|
|
572
|
+
const store = curatedStore(key, { screen: note => policy.screenStandalone(note) ? "promote" : "keep", createAliases: true });
|
|
573
|
+
try {
|
|
574
|
+
const result = await importProject(store, { from: options["--from"], project: key, confirm: options["--confirm"] === true,
|
|
575
|
+
allowShared: options["--allow-shared"] === true, policy: note => policy.screenStandalone(note) });
|
|
576
|
+
const counts = `sources ${JSON.stringify(result.provenance.source)}, peers ${JSON.stringify(result.provenance.peer)}, scopes ${JSON.stringify(result.provenance.scope)}`;
|
|
577
|
+
const summary = result.applied ? `imported ${result.added} of ${result.read} curated notes into ${result.project} with their original ids`
|
|
578
|
+
: `preview: ${result.wouldAdd} of ${result.read} curated notes can be imported into ${result.project} with their original ids (re-run with --confirm to write)`;
|
|
579
|
+
return options["--json"] ? JSON.stringify(result, null, 2)
|
|
580
|
+
: `${summary}; ${counts}; skipped ${result.skipped.invalid} invalid, ${result.skipped.policy} policy/secret and ${result.skipped.other} non-curated records. ${result.warnings.join(" ")}`;
|
|
581
|
+
} finally { store.close(); }
|
|
533
582
|
}
|
|
534
583
|
const key = requireShard(memoryHome(), requested);
|
|
535
584
|
if (op === "forget" && options["--confirm"] !== true) {
|
|
@@ -682,8 +731,16 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
682
731
|
output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, project: boundProject(), open, readable: notes => notes, stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
|
|
683
732
|
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
684
733
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
685
|
-
} catch (error) {
|
|
686
|
-
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (sub === "sync") stampSync((error as Error).message, stop?.cause());
|
|
736
|
+
throw error;
|
|
737
|
+
}
|
|
738
|
+
finally {
|
|
739
|
+
stop?.dispose();
|
|
740
|
+
// A run its person ended ends like that signal (130, 143, 129, 131), as `amem doctor` does, also when `--json` reports it as a result instead of an error: the caller decides when.
|
|
741
|
+
const ended = stop?.cause() === "person" ? stop.signalName() : undefined;
|
|
742
|
+
if (ended !== undefined) context.onPersonStop?.(ended);
|
|
743
|
+
}
|
|
687
744
|
if (sub === "sync") {
|
|
688
745
|
// `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
|
|
689
746
|
let failure: string | undefined;
|
|
@@ -704,10 +761,14 @@ export function formatCliError(error: unknown): string { return error instanceof
|
|
|
704
761
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
|
705
762
|
export async function writeCliOutput(text: string, output: Writable = process.stdout): Promise<void> { await writeConfirmed(output, text); }
|
|
706
763
|
export async function main(argv = process.argv.slice(2)): Promise<void> {
|
|
764
|
+
let personStop: string | undefined;
|
|
707
765
|
try {
|
|
708
|
-
const receipts: CliOutputReceipt[] = [], output = await runCli(argv, { onOutputReady: receipt => receipts.push(receipt) });
|
|
766
|
+
const receipts: CliOutputReceipt[] = [], output = await runCli(argv, { onOutputReady: receipt => receipts.push(receipt), onPersonStop: name => { personStop ??= name; } });
|
|
709
767
|
if (output) { await writeCliOutput(output + "\n"); for (const receipt of receipts) { try { receipt.rendered(); } catch { /* telemetry stays optional */ } } }
|
|
710
|
-
} catch (error) {
|
|
768
|
+
} catch (error) {
|
|
769
|
+
console.error(formatCliError(error)); process.exitCode = (error as { exitCode?: number }).exitCode ?? 1;
|
|
770
|
+
}
|
|
771
|
+
if (personStop !== undefined) endLikeSignal(personStop); // after the report, with amem's own handlers already removed
|
|
711
772
|
}
|
|
712
773
|
|
|
713
774
|
if (import.meta.main) await main();
|
package/src/commands-table.ts
CHANGED
|
@@ -24,6 +24,7 @@ const filters = { ...json, "--tier": { description: "Memory tier", value: ["shor
|
|
|
24
24
|
export const GLOBAL_OPTIONS = { "--help": flag("Show help") };
|
|
25
25
|
export const ROOT_OPTIONS = { "--version": flag("Show version"), ...json };
|
|
26
26
|
export const COMMANDS: Record<string, CommandDefinition> = {
|
|
27
|
+
consolidate: { description: "Queue deterministic project-local consolidation candidates; curated notes change only after person review", mutates: true, options: { ...json, "--project": text("The current project's key; another project is refused (run from its directory)") } },
|
|
27
28
|
setup: { description: "Preview reversible MCP and hook setup for native coding CLIs; apply and undo are person-only", options: { "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "all"] }] },
|
|
28
29
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
29
30
|
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
@@ -37,13 +38,14 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
37
38
|
"--for": text("Explicit intent"), "--paths": text("Repeatable named path"), "--symbols": text("Repeatable named symbol"), "--bytes": text("Complete brief-body byte cap (1..8192, default 2048)") } },
|
|
38
39
|
projects: { description: "List the project shards of this memory home (#15 D3)", options: { ...json, "--all": flag("Include forgotten projects (archived ones are always listed)") } },
|
|
39
40
|
usage: { description: "Totals and the largest project shards", options: { ...json, "--limit": text("Largest shards to list (1..100)"), "--all": flag("Include forgotten projects") } },
|
|
40
|
-
eval: { description: "Replay a local question set against the ranked store.search (the ranking MCP and hub recall use, not the `search` command's substring filter) and report recall@k and MRR (no model, no network; the built-in synthetic set without --set)", options: { ...json,
|
|
41
|
-
"--set": { description: "
|
|
41
|
+
eval: { description: "Replay a local question set against the ranked store.search (the ranking MCP and hub recall use, not the `search` command's substring filter) and report recall@k and MRR (no model, no network; the built-in synthetic set without --set, `--set ko` for the built-in Korean one)", options: { ...json,
|
|
42
|
+
"--set": { description: "`ko` (the built-in Korean set) or a question set file (amem.eval/v1); with entries it is self-contained, without them its ids name entries of this project", value: "path" }, "--k": text("Cutoffs, comma separated (default 1,5,10; each 1..100)") } },
|
|
42
43
|
gc: { description: "Apply each shard's retention and cap across shards in bounded batches (preview without --confirm)", mutates: "confirm", options: {
|
|
43
44
|
...json, "--confirm": flag("Evict now; without it, preview only"), "--batches": text("evict() windows per shard in this run (1..1000, default 1)") } },
|
|
44
|
-
project: { description: "Per-project lifecycle and export (#15 D3): one project only", options: json, commands: {
|
|
45
|
-
export: { description: "Write one project's capture, curated notes, goals and work lists to a new
|
|
45
|
+
project: { description: "Per-project lifecycle and portable import/export (#15 D3, F17): one project only", options: json, commands: {
|
|
46
|
+
export: { description: "Write one project's capture, curated notes, goals and work lists to a new JSON, JSONL or Markdown file (never overwrites)", options: { "--to": { description: "Destination file, outside the memory home", value: "path" }, "--format": { description: "Portable format (default json)", value: ["json", "jsonl", "markdown"] }, ...policy },
|
|
46
47
|
args: [{ name: "key" }] },
|
|
48
|
+
import: { description: "Preview or re-import curated notes from a JSONL project export with their original entry ids", options: { "--from": { description: "JSONL project export", value: "path" }, "--confirm": flag("Write after the preview; without it, preview only"), "--allow-shared": flag("Explicitly permit shared-scope notes from this file"), ...policy }, args: [{ name: "key" }], mutates: "confirm", policyFree: true },
|
|
47
49
|
archive: { description: "Take a cold project out of recall (append-only op; unarchive brings it back)", args: [{ name: "key" }], mutates: true },
|
|
48
50
|
unarchive: { description: "Bring an archived project back into recall (append-only op)", args: [{ name: "key" }], mutates: true },
|
|
49
51
|
forget: { description: "Forget one project for good (append-only op; preview without --confirm)", options: { "--confirm": flag("Record the forget after the preview") }, args: [{ name: "key" }], mutates: "confirm" },
|
|
@@ -75,7 +77,11 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
75
77
|
"--if-due": flag("Sync only when due (hourly after a success, 15 minutes doubling to a day after failures); stops after 30 seconds"), "--force": flag("With --if-due: ignore the schedule and the backoff") }, mutates: true, policyFree: true },
|
|
76
78
|
machine: { description: "Show or label this machine", options: { ...json, "--label": text("Machine label") }, mutates: "label" },
|
|
77
79
|
import: { description: "Import external memory", options: { ...json, ...policy, "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") }, mutates: true, policyFree: true,
|
|
78
|
-
commands: {
|
|
80
|
+
commands: {
|
|
81
|
+
"claude-mem": { description: "Import claude-mem observations", options: { "--db": { description: "SQLite database file", value: "path" }, "--limit": text("Maximum observations"), "--restart": flag("Restart import cursor") } },
|
|
82
|
+
"claude-auto": { description: "Import Claude Code auto memory as review candidates; CLAUDE.md and AGENTS.md stay excluded", options: { "--from": { description: "Claude projects directory (default ~/.claude/projects)", value: "path" } } },
|
|
83
|
+
codex: { description: "Import Codex memories as review candidates; originals stay unchanged", options: { "--from": { description: "Codex memories directory (default ~/.codex/memories)", value: "path" } } },
|
|
84
|
+
} },
|
|
79
85
|
goal: { description: "Long-term goals", options: json, defaultCommand: "list", commands: {
|
|
80
86
|
list: { description: "List goals", options: { "--all": flag("Include completed goals") } },
|
|
81
87
|
show: { description: "Show a goal", args: [{ name: "id" }] },
|
|
@@ -88,11 +94,11 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
88
94
|
review: { description: "Review machine-local non-evidentiary candidates", options: json, defaultCommand: "list", commands: {
|
|
89
95
|
list: { description: "List review candidates", options: {
|
|
90
96
|
"--status": { description: "Candidate status", value: ["pending", "accepting", "accepted", "rejected", "all"] },
|
|
91
|
-
"--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] }, "--limit": text("Maximum candidates (1..500)"),
|
|
97
|
+
"--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] }, "--limit": text("Maximum candidates (1..500)"),
|
|
92
98
|
} },
|
|
93
|
-
accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Require the selected candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
|
|
99
|
+
accept: { description: "Accept one candidate through the current memory policy", options: { ...policy, "--kind": { description: "Require the selected candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } }, args: [{ name: "full-candidate-id" }], mutates: true, policyFree: true },
|
|
94
100
|
reject: { description: "Reject one candidate", args: [{ name: "full-candidate-id" }], mutates: true },
|
|
95
|
-
walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion"] } } },
|
|
101
|
+
walk: { description: "Walk the pending candidates one by one: accept, edit (as a new note), reject, skip or quit", mutates: true, options: { "--kind": { description: "Candidate kind", value: ["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"] } } },
|
|
96
102
|
} },
|
|
97
103
|
decay: { description: "Preview old live long-term notes unprotected by open goals; apply only after person review", mutates: "apply", options: {
|
|
98
104
|
...json, "--days": text("Age in whole days (default 90)"), "--apply": flag("Show the exact revision preview and ask before applying"), "--forget": flag("Explicitly forget instead of demoting; confirmation names this action") }, args: [{ name: "id", variadic: true }] },
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,8 @@ export * from "./memory/lifecycle.ts";
|
|
|
9
9
|
export * from "./memory/usage.ts";
|
|
10
10
|
export * from "./memory/gc.ts";
|
|
11
11
|
export * from "./memory/project-export.ts";
|
|
12
|
+
export * from "./memory/project-import.ts";
|
|
13
|
+
export * from "./memory/external-import.ts";
|
|
12
14
|
export * from "./memory/cross-project.ts";
|
|
13
15
|
export * from "./memory/sync.ts";
|
|
14
16
|
export * from "./memory/import.ts";
|
|
@@ -41,3 +43,7 @@ export * from "./memory/recall-staleness.ts";
|
|
|
41
43
|
export * from "./memory/intent-brief.ts";
|
|
42
44
|
export * from "./memory/plan-guard.ts";
|
|
43
45
|
export * from "./memory/use-telemetry.ts";
|
|
46
|
+
export * from "./memory/consolidate.ts";
|
|
47
|
+
export * from "./memory/consolidation-clusters.ts";
|
|
48
|
+
export * from "./memory/consolidation-model.ts";
|
|
49
|
+
export * from "./memory/evidence.ts";
|
package/src/mcp-memory.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { packEvidence } from "./memory/evidence.ts";
|
|
2
3
|
import { randomUUID } from "node:crypto";
|
|
3
4
|
import { buildIntentBrief } from "./memory/intent-brief.ts";
|
|
4
5
|
import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
|
|
@@ -152,6 +153,32 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
152
153
|
return { id: candidates.put(input, "public") };
|
|
153
154
|
};
|
|
154
155
|
const tools: McpHandlers = {
|
|
156
|
+
async amem_evidence(args) {
|
|
157
|
+
bound(args, ["question", "limit", "maxBytes"]); guard();
|
|
158
|
+
const question = textOf(args, "question")!, cap = args.limit ?? 10, maxBytes = args.maxBytes ?? 4096;
|
|
159
|
+
if (question.length > 1000 || !Number.isSafeInteger(cap) || Number(cap) < 1 || Number(cap) > 20 || !Number.isSafeInteger(maxBytes) || Number(maxBytes) < 256 || Number(maxBytes) > 8192) unavailable();
|
|
160
|
+
if (!await currentPolicy().screen({ question })) unavailable(); guard();
|
|
161
|
+
const index = new SearchIndex(":memory:");
|
|
162
|
+
try {
|
|
163
|
+
const words = question.toLowerCase().split(/\s+/).filter(Boolean);
|
|
164
|
+
const notes = await readPermitted(window => store.readPage({ ...window, ...(index.available ? { query: question } : {}) }), {}, SEARCH_CANDIDATES,
|
|
165
|
+
view => view.project === project && recallViewIsLive(view, store.readNow()) && view.peer !== "amem-model" && !view.text.startsWith("Model answer:")
|
|
166
|
+
&& (index.available || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
167
|
+
if (index.available) {
|
|
168
|
+
index.resync(() => notes.map(view => ({ id: view.id, at: view.at, project, tier: view.tier, scope: view.scope, body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })));
|
|
169
|
+
if (index.needsResync(notes.length)) unavailable();
|
|
170
|
+
}
|
|
171
|
+
const ranks = new Map((index.available ? index.query(question, SEARCH_CANDIDATES, project) : []).map(row => [row.ref, row.rank]));
|
|
172
|
+
const ranked = notes.flatMap(view => {
|
|
173
|
+
const rank = ranks.get(view.id); if (index.available && rank === undefined) return [];
|
|
174
|
+
const score = -(rank ?? -1) * (view.tier === "long" ? 1 : 0.9) / (1 + Math.max(0, store.readNow() - Date.parse(view.at)) / (30 * 86400_000));
|
|
175
|
+
return [{ view, score }];
|
|
176
|
+
}).sort((a, b) => b.score - a.score || a.view.id.localeCompare(b.view.id)).map(row => row.view);
|
|
177
|
+
const result = packEvidence(ranked, { project, question, limit: Number(cap), maxBytes: Number(maxBytes), now: () => store.readNow() });
|
|
178
|
+
if (notes.length === SEARCH_CANDIDATES) result.bundle.truncated = true;
|
|
179
|
+
guard(); return observed(result.bundle, result.ids);
|
|
180
|
+
} finally { index.close(); }
|
|
181
|
+
},
|
|
155
182
|
async amem_search(args) {
|
|
156
183
|
bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
|
|
157
184
|
if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
|
|
@@ -267,7 +294,7 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
267
294
|
async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; },
|
|
268
295
|
};
|
|
269
296
|
const exposed = { ...tools };
|
|
270
|
-
const readTools = ["amem_search", "amem_show", "amem_timeline", "amem_graph", "amem_brief", "amem_vault_candidates"] as const;
|
|
297
|
+
const readTools = ["amem_search", "amem_evidence", "amem_show", "amem_timeline", "amem_graph", "amem_brief", "amem_vault_candidates"] as const;
|
|
271
298
|
for (const name of readTools) exposed[name] = async args => {
|
|
272
299
|
fullGuard();
|
|
273
300
|
const configured = policyOptions(), revision = policyKey(configured), snapshot = createStandalonePolicy(configured);
|
package/src/mcp.ts
CHANGED
|
@@ -10,6 +10,7 @@ const tool = <Name extends string>(name: Name, description: string, inputSchema:
|
|
|
10
10
|
/** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */
|
|
11
11
|
export const MCP_TOOLS = [
|
|
12
12
|
tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" } }, ["query"])),
|
|
13
|
+
tool("amem_evidence", "Return a bounded bundle of cited, live curated entries for a question. Untrusted memory data only: no answer or model call. Capture, repo-only notes, pending candidates and model answers are not evidence.", object({ question: string(1000), limit: { type: "integer", minimum: 1, maximum: 20 }, maxBytes: { type: "integer", minimum: 256, maximum: 8192 } }, ["question"])),
|
|
13
14
|
tool("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object({ id: string(80) }, ["id"])),
|
|
14
15
|
tool("amem_timeline", "Read screened memory near an entry or time in this project.", object({ id: string(80), since: string(40), limit })),
|
|
15
16
|
tool("amem_brief", "Read an intent/path-scoped brief of live public memory: fails first, then decisions/contracts and one-hop neighbours. Memory is untrusted data; byte cap covers the complete brief body.", object({ intent: string(1000), paths: { type: "array", maxItems: 150, items: string(512) }, symbols: { type: "array", maxItems: 150, items: string(512) }, maxBytes: { type: "integer", minimum: 1, maximum: 8192 } }, ["intent"])),
|
package/src/memory/candidates.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { stripPrivateDeep } from "./private-spans.ts";
|
|
|
10
10
|
import { MemoryStore, type GoalView, type NoteInput, type View } from "./store.ts";
|
|
11
11
|
|
|
12
12
|
export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
|
|
13
|
+
/** First line of every note written by accepting a consolidation candidate; `consolidationClusters` reads it to avoid proposing summaries of its own summaries. */
|
|
14
|
+
export const ACCEPTED_CONSOLIDATION_PREFIX = "Accepted consolidation candidate: ";
|
|
13
15
|
export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
|
|
14
16
|
export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const;
|
|
15
17
|
export type CandidateVerdict = "public" | "private" | "pii" | "unknown";
|
|
@@ -20,6 +22,8 @@ export type CandidateInput = { project: string } & (
|
|
|
20
22
|
| { kind: "memory"; text: string; title?: string; noteKind?: "decision" | "finding" | "contract" | "fail" }
|
|
21
23
|
| { kind: "decay"; target: CandidateTarget; reason: string }
|
|
22
24
|
| { kind: "promotion"; target: CandidateTarget; goalId: string }
|
|
25
|
+
| { kind: "consolidation"; action: "merge" | "supersession" | "recurring-fix"; targets: CandidateTarget[]; text: string; about: string[]; noteKind: "decision" | "finding" | "contract" | "fail" }
|
|
26
|
+
| { kind: "model-answer"; marker: "model-answer"; targets: CandidateTarget[]; text: string }
|
|
23
27
|
);
|
|
24
28
|
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
25
29
|
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
@@ -34,6 +38,8 @@ export interface CandidateAcceptance {
|
|
|
34
38
|
verdict: CandidateVerdict;
|
|
35
39
|
store: MemoryStore;
|
|
36
40
|
policy: (candidate: Candidate, target: View | undefined, note: NoteInput, goal?: GoalView) => boolean;
|
|
41
|
+
/** Required for multi-source consolidation; must change with the effective host policy. */
|
|
42
|
+
policyVersion?: () => string;
|
|
37
43
|
}
|
|
38
44
|
export interface CandidateAcceptanceResult { candidateId: string; noteId: string; alreadyAccepted: boolean }
|
|
39
45
|
|
|
@@ -59,6 +65,13 @@ function validate(input: CandidateInput): void {
|
|
|
59
65
|
if (!text(input.text, 3000) || input.title !== undefined && !text(input.title, 200) || input.noteKind !== undefined && !["decision", "finding", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid memory candidate");
|
|
60
66
|
} else if (input.kind === "promotion") {
|
|
61
67
|
if (!validTarget(input.target) || !entryId(input.goalId)) throw new Error("invalid promotion candidate");
|
|
68
|
+
} else if (input.kind === "consolidation") {
|
|
69
|
+
if (!["merge", "supersession", "recurring-fix"].includes(input.action) || !Array.isArray(input.targets) || input.targets.length < 1 || input.targets.length > 8 || !input.targets.every(validTarget)
|
|
70
|
+
|| new Set(input.targets.map(target => target.id)).size !== input.targets.length || !text(input.text, 3000) || !["decision", "finding", "contract", "fail"].includes(input.noteKind)
|
|
71
|
+
|| !Array.isArray(input.about) || input.about.length > 32 || !input.about.every(node => text(node, 400))) throw Error("invalid consolidation candidate");
|
|
72
|
+
} else if (input.kind === "model-answer") {
|
|
73
|
+
if (input.marker !== "model-answer" || !text(input.text, 3000) || !input.text.startsWith("Model answer:\n") || !input.text.slice("Model answer:\n".length).trim() || !Array.isArray(input.targets) || input.targets.length < 1 || input.targets.length > 8 || !input.targets.every(validTarget)
|
|
74
|
+
|| new Set(input.targets.map(target => target.id)).size !== input.targets.length) throw Error("invalid model answer candidate");
|
|
62
75
|
} else if (input.kind === "decay") {
|
|
63
76
|
if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
|
|
64
77
|
} else throw new Error("invalid candidate kind");
|
|
@@ -177,7 +190,7 @@ export class CandidateStore {
|
|
|
177
190
|
return row ? this.decode(row) : undefined;
|
|
178
191
|
}
|
|
179
192
|
list(filter: CandidateFilter): Candidate[] {
|
|
180
|
-
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory", "promotion"].includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
193
|
+
if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory", "promotion", "consolidation", "model-answer"].includes(filter.kind)) throw new Error("invalid candidate filter");
|
|
181
194
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
182
195
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
183
196
|
this.prune();
|
|
@@ -221,8 +234,10 @@ export class CandidateStore {
|
|
|
221
234
|
if (candidate.status === "accepted") return { candidateId: id, noteId: candidate.acceptedNoteId!, alreadyAccepted: true };
|
|
222
235
|
if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
223
236
|
const data = candidate.input;
|
|
237
|
+
if (data.kind === "model-answer") throw Error("model answers cannot become curated memory; use amem note add to author an independent note");
|
|
224
238
|
if (data.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
|
|
225
239
|
if (data.kind === "promotion") return this.acceptPromotion(candidate, data, context);
|
|
240
|
+
if (data.kind === "consolidation") return this.acceptConsolidation(candidate, data, context);
|
|
226
241
|
const execute = () => {
|
|
227
242
|
const resolve = () => {
|
|
228
243
|
const target = data.kind === "memory" ? undefined : data.target;
|
|
@@ -260,6 +275,57 @@ export class CandidateStore {
|
|
|
260
275
|
if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
|
|
261
276
|
return context.store.withCurrentEntries([target.id], context.project, execute);
|
|
262
277
|
}
|
|
278
|
+
private acceptConsolidation(candidate: Candidate, data: Extract<CandidateInput, { kind: "consolidation" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
279
|
+
if (typeof context.policyVersion !== "function") throw Error("consolidation requires a policy revision guard");
|
|
280
|
+
const boundStore = context.store, boundProject = context.project, boundPolicy = context.policy;
|
|
281
|
+
const policyVersion = context.policyVersion(); if (typeof policyVersion !== "string") throw Error("consolidation policy revision unavailable");
|
|
282
|
+
return context.store.withCurrentEntries(data.targets.map(target => target.id), context.project, () => {
|
|
283
|
+
const sourcePlacements = context.store.entryPlacements(data.targets.map(target => target.id), context.project);
|
|
284
|
+
if (data.targets.some(target => sourcePlacements.get(target.id) !== "synced")) throw Error("consolidation requires verified synced source placement");
|
|
285
|
+
const policyCurrent = () => {
|
|
286
|
+
if (context.actor !== "person" || context.store !== boundStore || context.project !== boundProject || context.policy !== boundPolicy) throw Error("consolidation acceptance context changed");
|
|
287
|
+
if (context.policyVersion!() !== policyVersion) throw Error("consolidation policy changed");
|
|
288
|
+
};
|
|
289
|
+
// One placement proof per locked section: `sourcePlacements` (read once inside withCurrentEntries, whose fences stay held) serves every check before the append section;
|
|
290
|
+
// the append section reads one fresh proof. Each proof re-reads the legacy snapshot (up to 32 MiB), so four per acceptance was the cost this removes.
|
|
291
|
+
const check = (fresh = false) => {
|
|
292
|
+
policyCurrent();
|
|
293
|
+
const current = data.targets.map(target => {
|
|
294
|
+
const row = context.store.get(target.id, context.project);
|
|
295
|
+
if (!row || row.id !== target.id || row.project !== context.project || row.supersededBy || candidateTargetRevision(row) !== target.revision) throw Error("consolidation source revision is stale");
|
|
296
|
+
return row;
|
|
297
|
+
});
|
|
298
|
+
const placements = fresh ? context.store.entryPlacements(data.targets.map(target => target.id), context.project) : sourcePlacements;
|
|
299
|
+
if (current.some(row => row.scope !== current[0]!.scope || placements.get(row.id) !== placements.get(current[0]!.id))) throw Error("consolidation sources must have the same placement and scope");
|
|
300
|
+
if (data.targets.some(target => placements.get(target.id) !== sourcePlacements.get(target.id))) throw Error("consolidation source placement changed");
|
|
301
|
+
policyCurrent(); // The proof scan can be long; fence policy changes after it, not only before it.
|
|
302
|
+
return current;
|
|
303
|
+
};
|
|
304
|
+
const sources = check(), first = sources[0]!;
|
|
305
|
+
const note: NoteInput = { op: "note", project: context.project, scope: first.scope, tier: "long", kind: data.noteKind, peer: "person", source: "person", about: [...data.about],
|
|
306
|
+
text: `${ACCEPTED_CONSOLIDATION_PREFIX}${candidate.id}\nAction: ${data.action}\nSources: ${data.targets.map(target => `mem:${target.id}`).join(", ")}\n\n${data.text}` };
|
|
307
|
+
if (note.text.length > 4000) throw Error("accepted consolidation exceeds the v1 note limit");
|
|
308
|
+
for (const source of sources) if (context.policy(structuredClone(candidate), structuredClone(source), structuredClone(note)) !== true) throw Error("consolidation refused by current host policy");
|
|
309
|
+
check();
|
|
310
|
+
return context.store.withAcceptedNote(note, append => {
|
|
311
|
+
check(true);
|
|
312
|
+
const replay = this.beginAcceptance(candidate.id, context.project, policyCurrent); if (replay) return { candidateId: candidate.id, noteId: replay, alreadyAccepted: true };
|
|
313
|
+
const noteId = append(), applied: string[] = [];
|
|
314
|
+
try {
|
|
315
|
+
if (data.action !== "recurring-fix") for (const target of data.targets) {
|
|
316
|
+
if (!context.store.supersede(target.id, noteId, "person", context.project)) throw Error("supersession refused");
|
|
317
|
+
applied.push(target.id);
|
|
318
|
+
}
|
|
319
|
+
} catch {
|
|
320
|
+
// ponytail: v1 appends cannot form an atomic multi-source replacement. Keep 'accepting' and
|
|
321
|
+
// report every confirmed effect; recovery requires person inspection before any replay.
|
|
322
|
+
throw Error(`consolidation interrupted: inspect note ${noteId}; superseded ${applied.join(", ") || "none"}; remaining or uncertain ${data.targets.filter(target => !applied.includes(target.id)).map(target => target.id).join(", ")}`);
|
|
323
|
+
}
|
|
324
|
+
this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project));
|
|
325
|
+
return { candidateId: candidate.id, noteId, alreadyAccepted: false };
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
263
329
|
private acceptPromotion(candidate: Candidate, data: Extract<CandidateInput, { kind: "promotion" }>, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
264
330
|
const resolve = () => {
|
|
265
331
|
const target = context.store.get(data.target.id, context.project);
|
|
@@ -288,12 +354,30 @@ export class CandidateStore {
|
|
|
288
354
|
return { candidateId: candidate.id, noteId, alreadyAccepted: false };
|
|
289
355
|
});
|
|
290
356
|
}
|
|
291
|
-
|
|
357
|
+
/** Reserve before the optional callback, including failed attempts. No table is created on capture/MCP reads. */
|
|
358
|
+
reserveConsolidationModel(inputBytes: number): boolean {
|
|
359
|
+
if (!Number.isSafeInteger(inputBytes) || inputBytes < 1 || inputBytes > 8192) throw Error("invalid model input budget");
|
|
360
|
+
const at = this.now(); if (!Number.isFinite(at)) throw Error("model budget clock unavailable");
|
|
361
|
+
const day = new Date(at).toISOString().slice(0, 10);
|
|
362
|
+
return this.db.transaction(() => {
|
|
363
|
+
this.db.run("CREATE TABLE IF NOT EXISTS consolidation_budget (day TEXT PRIMARY KEY, calls INTEGER NOT NULL, input_bytes INTEGER NOT NULL)");
|
|
364
|
+
const latest = this.db.query("SELECT MAX(day) AS day FROM consolidation_budget").get() as { day: string | null };
|
|
365
|
+
if (latest.day && latest.day > day) return false;
|
|
366
|
+
const row = this.db.query("SELECT calls,input_bytes FROM consolidation_budget WHERE day = ?").get(day) as { calls: number; input_bytes: number } | null;
|
|
367
|
+
if (row && (!Number.isSafeInteger(row.calls) || row.calls < 0 || !Number.isSafeInteger(row.input_bytes) || row.input_bytes < 0)) throw Error("model budget state unavailable");
|
|
368
|
+
if (row && (row.calls >= 4 || row.input_bytes + inputBytes > 32768)) return false;
|
|
369
|
+
this.db.query("INSERT INTO consolidation_budget (day,calls,input_bytes) VALUES (?,1,?) ON CONFLICT(day) DO UPDATE SET calls = calls + 1, input_bytes = input_bytes + excluded.input_bytes").run(day, inputBytes);
|
|
370
|
+
this.db.query("DELETE FROM consolidation_budget WHERE day < ?").run(new Date(at - 30 * 86400_000).toISOString().slice(0, 10));
|
|
371
|
+
return true;
|
|
372
|
+
}).immediate();
|
|
373
|
+
}
|
|
374
|
+
private beginAcceptance(id: string, project: string, before?: () => void): string | undefined {
|
|
292
375
|
return this.db.transaction(() => {
|
|
293
376
|
const current = this.get(id, project);
|
|
294
377
|
if (!current) throw Error("candidate unavailable");
|
|
295
378
|
if (current.status === "accepted") return current.acceptedNoteId!;
|
|
296
379
|
if (current.status !== "pending") throw Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
380
|
+
before?.();
|
|
297
381
|
this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(project));
|
|
298
382
|
return undefined;
|
|
299
383
|
}).immediate();
|