@staix/agent-mem 0.10.0 → 0.12.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 +6 -2
- package/package.json +1 -1
- package/src/cli.ts +26 -10
- package/src/commands-table.ts +2 -2
- package/src/index.ts +1 -0
- package/src/mcp-memory.ts +6 -2
- package/src/mcp.ts +1 -1
- package/src/memory/candidates.ts +44 -1
- package/src/memory/commands.ts +14 -4
- package/src/memory/consolidate.ts +9 -6
- package/src/memory/consolidation-clusters.ts +12 -7
- package/src/memory/doctor.ts +41 -7
- package/src/memory/eval-fixture.ts +7 -3
- package/src/memory/eval.ts +15 -6
- package/src/memory/external-import.ts +8 -1
- package/src/memory/platform.ts +8 -8
- package/src/memory/project-import.ts +31 -14
- package/src/memory/search-fts.ts +15 -6
- package/src/memory/search-score.ts +66 -0
- package/src/memory/status-text.ts +47 -0
- package/src/memory/store.ts +40 -13
- package/src/memory/style.ts +37 -0
- package/src/setup-toml.ts +5 -5
- package/src/setup.ts +76 -14
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.12.0. Requires Bun 1.4.2 or later; no runtime dependencies.
|
|
6
6
|
|
|
7
7
|
## Quick start
|
|
8
8
|
|
|
@@ -114,7 +114,7 @@ A repository may add `.amem/config` (same shape, only `denyPaths`, `piiPatterns`
|
|
|
114
114
|
|
|
115
115
|
`amem note add [--kind decision|finding|contract|fail] [--tier short|long] [--scope project|shared] [--title T] [text... | -]` writes one curated note as the person (agent-mem#40 F6). The text is the arguments, `-` to read it from the terminal until end of input, or nothing to open `$VISUAL` (an empty one is skipped) or `$EDITOR` on a private temporary file (the command is run without a shell: an existing path with spaces works as is, otherwise quotes and backslashes split it, and Ctrl-C reaches the editor while it runs). `-` reads the text you type on the terminal until end of input: `note add` needs a terminal on stdin, so it cannot read a pipe, and that differs from a literal reading of "from stdin" in the issue. A `fail` note takes its five fields instead of free text: `--approach`, `--symptom`, `--cause`, `--evidence` (repeatable) and `--retry-when`. All five are required, and the command names the ones that are missing; with none given it opens a labelled template in the editor. The configured policy screens the title and text first, and a refusal never echoes them. Like every ordinary command it needs a terminal and refuses an agent session.
|
|
116
116
|
|
|
117
|
-
The SessionStart brief carries one line when candidates wait, for example `2 candidates pending, oldest expires in 27 days` (agent-mem#40 F7). It holds numbers only, is read-only (the hook never marks anything shown), comes out of the brief's own byte budget, and is left out when it does not fit. `amem status` adds a `candidates` object (or `unavailable` when `candidates.db` exists but cannot be read, never a false zero) and a warning when any is within 7 days of expiry. `amem review walk` needs a terminal on both stdin and stdout (it refuses when stdout is redirected to a file or a pipe, because nobody would see what it asks about). It prints each pending candidate, oldest first, and only then asks accept, edit, reject, skip or quit: accept goes through the normal policy, edit opens the text in your editor and writes it as your own note (a candidate proposed as a fail note opens the labelled template), and what it prints has control characters removed. Nothing expires unseen: a candidate counts as shown only when `review walk` has printed it (the plain `review list` prints no content and runs in scripts, and the brief line cannot know you read it, so neither marks anything). A candidate is kept past its 30 days until it has been shown, and then for 7 more days. When 500 unseen candidates wait, a new one is refused with a named error (an MCP tool reports a fixed "the review queue is full, ask the person to run `amem review`" reply) instead of dropping an unseen one.
|
|
117
|
+
The SessionStart brief carries one line when candidates wait, for example `2 candidates pending, oldest expires in 27 days` (agent-mem#40 F7). It holds numbers only, is read-only (the hook never marks anything shown), comes out of the brief's own byte budget, and is left out when it does not fit. `amem status --json` adds a `candidates` object (bare `amem status` shows the same as a labelled row and next action) (or `unavailable` when `candidates.db` exists but cannot be read, never a false zero) and a warning when any is within 7 days of expiry. `amem review walk` needs a terminal on both stdin and stdout (it refuses when stdout is redirected to a file or a pipe, because nobody would see what it asks about). It prints each pending candidate, oldest first, and only then asks accept, edit, reject, skip or quit: accept goes through the normal policy, edit opens the text in your editor and writes it as your own note (a candidate proposed as a fail note opens the labelled template), and what it prints has control characters removed. Nothing expires unseen: a candidate counts as shown only when `review walk` has printed it (the plain `review list` prints no content and runs in scripts, and the brief line cannot know you read it, so neither marks anything). A candidate is kept past its 30 days until it has been shown, and then for 7 more days. When 500 unseen candidates wait, a new one is refused with a named error (an MCP tool reports a fixed "the review queue is full, ask the person to run `amem review`" reply) instead of dropping an unseen one.
|
|
118
118
|
|
|
119
119
|
## Candidate review
|
|
120
120
|
|
|
@@ -213,6 +213,8 @@ Project identity (#15 D2) keeps `memoryProjectKey`: a worktree, a submodule and
|
|
|
213
213
|
|
|
214
214
|
`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 against ranked `store.search` and reports recall@k, hit@k and MRR. The ordinary curated project `amem search` path now uses that ranked search; `--filter` selects the legacy substring behavior. 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`.
|
|
215
215
|
|
|
216
|
+
External retrieval measurements for MCP, library search and a plain BM25 baseline are recorded in [LongMemEval S/M](docs/bench/longmemeval.md). The full datasets stay outside the repository; CI runs only synthetic harness fixtures.
|
|
217
|
+
|
|
216
218
|
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.
|
|
217
219
|
|
|
218
220
|
## Repo scope (#15 T4)
|
|
@@ -288,6 +290,8 @@ Successful native project identities have a private, bounded derived cache (128
|
|
|
288
290
|
|
|
289
291
|
## Doctor and the sync cadence
|
|
290
292
|
|
|
293
|
+
`amem doctor`, `amem status` and `amem setup` print a readable report: an overall state, one labelled row per check (`OK`, `ATTENTION`, `SKIPPED`, `UNAVAILABLE`) and the next actions. Color appears only on an interactive terminal and is turned off by `NO_COLOR` or `TERM=dumb`. Add `--json` for the machine-readable object (for `status` and `setup` this was the default before #125).
|
|
294
|
+
|
|
291
295
|
`amem doctor` prints one read-only report of this project's memory: duplicate and stale notes, pending candidates, capture freshness and hook registration for each CLI (a CLI with no hook is `manual`), the SessionStart brief's token cost, whether the index equals a rebuild of the logs, reader leases and the policy in effect. `amem doctor --json` is a stable machine-readable form (`amem.doctor/v1`). It writes nothing into the memory home (every check runs on a temporary copy of the logs) and creates nothing in a home you never used.
|
|
292
296
|
|
|
293
297
|
Intake runs at the end of each Codex, Kimi, Pi and Qwen session once `amem setup <cli> --apply` has registered it. Sync is off unless you ask for it: amem registers no sync hook and runs nothing between sessions. To sync on your own schedule, call `amem sync --if-due` from your shell (for example an EXIT trap in `~/.zshrc`; it needs a terminal). It syncs at most hourly, backs off 15 minutes doubling to a day after failures, stops after 30 seconds, and does nothing without a remote set by `amem sync --remote <private git url>`. `--force` skips the schedule and the backoff.
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -43,11 +43,14 @@ import { appendGitattributes, appendRepoLine, boundRepoRoot, planGitattributes,
|
|
|
43
43
|
import type { Visibility } from "./memory/sync.ts";
|
|
44
44
|
import { applySplit, previewSplit } from "./memory/capacity-split.ts";
|
|
45
45
|
import { evalText, parseKs } from "./memory/eval.ts";
|
|
46
|
+
import { unrankedScoreDetails } from "./memory/search-score.ts";
|
|
46
47
|
import { builtinSet, evalSeeded, evalStore, FIXTURE_PATH, readEvalSet } from "./memory/eval-fixture.ts";
|
|
47
48
|
import { markShown, pendingSummary } from "./memory/candidate-pending.ts";
|
|
48
49
|
import { buildNote, FAIL_TEMPLATE, hasFailLabel, noteAdd, parseFailTemplate, type FailFields } from "./memory/note-add.ts";
|
|
49
50
|
import { runEditor } from "./memory/editor.ts";
|
|
50
51
|
import { doctorReport, doctorText } from "./memory/doctor.ts";
|
|
52
|
+
import { statusText, type StatusView } from "./memory/status-text.ts";
|
|
53
|
+
import { colorEnabled, style, type Style } from "./memory/style.ts";
|
|
51
54
|
import { endLikeSignal, nextSyncAt, readSyncState, recordSyncAttempt, remoteConfigured, syncDue, stopSignal, syncOutcome, usableState, SYNC_IF_DUE_DEADLINE_MS, type StopCause } from "./memory/sync-cadence.ts";
|
|
52
55
|
|
|
53
56
|
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`";
|
|
@@ -199,7 +202,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
199
202
|
const { runSetup } = await import("./setup.ts");
|
|
200
203
|
return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
|
|
201
204
|
env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
|
|
202
|
-
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}),
|
|
205
|
+
...(context.setupConfirm ? { confirm: context.setupConfirm } : {}), json: options["--json"] === true, style: outputStyle(context),
|
|
203
206
|
});
|
|
204
207
|
}
|
|
205
208
|
const goal = parsed.path[0] === "goal", review = parsed.path[0] === "review", sub = goal || review ? parsed.path[1]! : parsed.path[0]!;
|
|
@@ -286,7 +289,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
286
289
|
// F1: say where the memory lives and which configuration is in effect. The migration fields keep their names and shape; `home` and `config` are added.
|
|
287
290
|
const where = resolveMemoryHome(), loaded = loadAmemConfig(where.home, repoConfigRoot(policyCwd));
|
|
288
291
|
const { config, source } = loaded;
|
|
289
|
-
|
|
292
|
+
const status = { home: { path: where.home, source: where.source },
|
|
290
293
|
config: { machine: source.machine, repo: source.repo, denyPaths: config.denyPaths.length, piiPatterns: config.piiPatterns.length,
|
|
291
294
|
...(config.briefBytes !== undefined ? { briefBytes: config.briefBytes } : {}), ...(config.excerptBytes !== undefined ? { excerptBytes: config.excerptBytes } : {}),
|
|
292
295
|
...(config.capturePaths !== undefined ? { capturePaths: config.capturePaths } : {}), ...(Object.keys(config.capture).length ? { capture: config.capture } : {}) },
|
|
@@ -294,14 +297,15 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
294
297
|
candidates: (() => { const bound = boundProject(), summary = pendingSummary(where.home, [bound, ...aliasedKeys(where.home, bound)], Date.now(), true);
|
|
295
298
|
if (summary === null) return { unavailable: "candidates.db exists but cannot be read" };
|
|
296
299
|
return { ...summary, ...(summary.dueWithin7Days ? { warning: `${summary.dueWithin7Days} candidate${summary.dueWithin7Days === 1 ? "" : "s"} within 7 days of expiry: run \`amem review walk\`` } : {}) }; })(),
|
|
297
|
-
...shardMigrationStatus(where.home) }
|
|
300
|
+
...shardMigrationStatus(where.home) };
|
|
301
|
+
return options["--json"] ? JSON.stringify(status, null, 2) : statusText(status as unknown as StatusView, outputStyle(context));
|
|
298
302
|
}
|
|
299
303
|
if (sub === "doctor") {
|
|
300
304
|
// F15: one read-only report; see src/memory/doctor.ts for exactly what "read-only" covers.
|
|
301
305
|
const where = resolveMemoryHome(), project = boundProject();
|
|
302
306
|
const report = await doctorReport({ home: where.home, homeSource: where.source, cwd: policyCwd, project, aliases: aliasedKeys(where.home, project), now: Date.now(),
|
|
303
307
|
openCurated: base => curatedStoreAt(base, project, { deferPromotions: true }) });
|
|
304
|
-
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report);
|
|
308
|
+
return options["--json"] ? JSON.stringify(report, null, 2) : doctorText(report, outputStyle(context));
|
|
305
309
|
}
|
|
306
310
|
if (sub === "prune") {
|
|
307
311
|
const applying = options["--apply"] === true;
|
|
@@ -573,7 +577,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
573
577
|
try {
|
|
574
578
|
const result = await importProject(store, { from: options["--from"], project: key, confirm: options["--confirm"] === true,
|
|
575
579
|
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)}`;
|
|
580
|
+
const counts = `sources ${JSON.stringify(result.provenance.source)}, peers ${JSON.stringify(result.provenance.peer)}, scopes ${JSON.stringify(result.provenance.scope)}, machines ${JSON.stringify(result.provenance.machine)}`;
|
|
577
581
|
const summary = result.applied ? `imported ${result.added} of ${result.read} curated notes into ${result.project} with their original ids`
|
|
578
582
|
: `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
583
|
return options["--json"] ? JSON.stringify(result, null, 2)
|
|
@@ -589,6 +593,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
589
593
|
return options["--json"] ? JSON.stringify(written, null, 2) : `${op === "forget" ? "forgot" : op === "archive" ? "archived" : "unarchived"} ${written.key}${requested !== written.key ? ` (alias ${requested})` : ""}`;
|
|
590
594
|
}
|
|
591
595
|
if (sub === "search" && options["--all-projects"] === true) {
|
|
596
|
+
if (options["--explain"] === true) throw new Error("--explain applies to current-project ranked search, not --all-projects");
|
|
592
597
|
if (options["--filter"] === true) throw new Error("--filter applies to project-local search, not --all-projects");
|
|
593
598
|
const words = parsed.positionals.join(" ");
|
|
594
599
|
if (!words.trim()) throw new Error("usage: amem search --all-projects <words> [--limit N] [--include-archived] [--json]");
|
|
@@ -598,6 +603,11 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
598
603
|
return [`crossed projects: searched ${result.projects.length} (${result.projects.join(", ")})`, ...(result.hits.length ? result.hits.map(one) : ["(nothing)"])].join("\n");
|
|
599
604
|
}
|
|
600
605
|
if (sub === "search" && options["--include-archived"] === true) throw new Error("--include-archived only applies with --all-projects");
|
|
606
|
+
if (sub === "search" && options["--explain"] === true) {
|
|
607
|
+
if (options["--filter"] === true) throw new Error("--explain cannot be combined with --filter");
|
|
608
|
+
if (options["--history"] === true || options["--as-of"] !== undefined) throw new Error("--explain cannot be combined with --history or --as-of");
|
|
609
|
+
if (!parsed.positionals.join(" ").trim()) throw new Error("--explain requires a non-empty search query");
|
|
610
|
+
}
|
|
601
611
|
if (sub === "alias") {
|
|
602
612
|
if (parsed.path[1] === "add") {
|
|
603
613
|
const [from, to] = parsed.positionals;
|
|
@@ -686,10 +696,12 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
686
696
|
const kind = options["--kind"], since = options["--since"], limit = options["--limit"] !== undefined ? Number(options["--limit"]) : 50;
|
|
687
697
|
const rows = read.views.filter(row => options["--tier"] !== "short" && (kind === undefined || row.kind === kind) && (typeof since !== "string" || Date.parse(row.at) >= Date.parse(since))
|
|
688
698
|
&& words.every(word => `${row.title ?? ""}\n${row.text}\n${row.about.join(" ")}`.toLowerCase().includes(word))).slice(0, limit);
|
|
689
|
-
|
|
699
|
+
const repoDetails = explainSearch ? rows.map(row => ({ ...row, scoreDetails: unrankedScoreDetails("repo", null) })) : rows;
|
|
700
|
+
if (options["--json"]) { try { const base = JSON.parse(output) as unknown[]; return JSON.stringify([...base, ...repoDetails], null, 2); } catch { return output; } }
|
|
690
701
|
const line = (row: RepoView) => `repo:${row.id.slice(0, 8)} ${row.at.slice(0, 16).replace("T", " ")} repo ${row.kind.padEnd(8)} ${`${row.title ? `${row.title}: ` : ""}${row.text}`.replace(/\s+/g, " ").slice(0, 100)}`;
|
|
691
702
|
const note = read.state === "unsafe" ? [`(repo scope unavailable: ${read.reason})`] : read.skipped.malformed + read.skipped.mismatched + read.skipped.withheld ? [`(${read.skipped.malformed + read.skipped.mismatched} repo lines skipped as invalid, ${read.skipped.withheld} withheld by the screen)`] : [];
|
|
692
|
-
|
|
703
|
+
const lines = explainSearch ? rows.flatMap(row => [line(row), `score: ${JSON.stringify(unrankedScoreDetails("repo", null))}`]) : rows.map(line);
|
|
704
|
+
return [output, ...(rows.length ? ["repo scope (.amem/log.jsonl):", ...lines] : []), ...note].join("\n");
|
|
693
705
|
};
|
|
694
706
|
const crosses = definition.policyFree === true;
|
|
695
707
|
const cwd = context.cwd ?? process.cwd();
|
|
@@ -732,13 +744,14 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
732
744
|
if (args[index] === "--capture-days") { captureArgs.add(index); captureArgs.add(index + 1); }
|
|
733
745
|
else if (args[index]?.startsWith("--capture-days=")) captureArgs.add(index);
|
|
734
746
|
}
|
|
735
|
-
// --filter
|
|
747
|
+
// --filter and --explain select standalone CLI behavior; hosts keep their existing raw command arguments.
|
|
736
748
|
const legacySearch = options["--filter"] === true;
|
|
737
|
-
const
|
|
749
|
+
const explainSearch = sub === "search" && options["--explain"] === true;
|
|
750
|
+
const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
|
|
738
751
|
let output: string;
|
|
739
752
|
const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
|
|
740
753
|
try {
|
|
741
|
-
output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, rankedSearch: !legacySearch, 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),
|
|
754
|
+
output = await withRepoRows(await rawMemoryCommand(syncArgs, cwd, { person: true, rankedSearch: !legacySearch, ...(explainSearch ? { explainSearch: 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),
|
|
742
755
|
sync: { screen: row => configured.screenStandalone(row), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
|
|
743
756
|
onBridgeStatus: status => { receipt.bridgeStatus = status; } }));
|
|
744
757
|
} catch (error) {
|
|
@@ -767,6 +780,9 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
|
|
|
767
780
|
? `${output}\n(legacy malformed lines skipped: ${receipt.bridgeStatus.skippedRows})` : output;
|
|
768
781
|
}
|
|
769
782
|
|
|
783
|
+
/** Color for human output only on an interactive stdout without NO_COLOR or TERM=dumb (#125). */
|
|
784
|
+
function outputStyle(context: CliContext): Style { return style(colorEnabled(context.env ?? process.env, context.stdoutTTY ?? process.stdout.isTTY)); }
|
|
785
|
+
|
|
770
786
|
export function formatCliError(error: unknown): string { return error instanceof DecayApplyError ? error.render() : `amem: ${(error as Error).message}`; }
|
|
771
787
|
|
|
772
788
|
/** A completed write means rendered output, never evidence of host delivery or citation. */
|
package/src/commands-table.ts
CHANGED
|
@@ -25,7 +25,7 @@ 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
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)") } },
|
|
28
|
-
setup: { description: "Preview reversible MCP setup and supported capture hooks 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", "qwen", "jcode", "agy", "all"] }] },
|
|
28
|
+
setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs; apply and undo are person-only", options: { ...json, "--apply": flag("Apply after terminal confirmation"), "--undo": flag("Restore original configuration after confirmation") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "agy", "all"] }] },
|
|
29
29
|
mcp: { description: "Run the project-bound MCP stdio server for this session" },
|
|
30
30
|
hook: { description: "Run a fail-open, context-only Claude Code hook", args: [{ name: "event", values: [...HOOK_EVENTS] }] },
|
|
31
31
|
ingest: { description: "Incrementally capture native session transcripts without a watcher", options: { ...json, "--since": text("ISO date or time") }, args: [{ name: "kind", values: ["codex", "kimi", "pi", "qwen"] }] },
|
|
@@ -33,7 +33,7 @@ export const COMMANDS: Record<string, CommandDefinition> = {
|
|
|
33
33
|
"--all-projects": flag("List every active project's candidates; the reply says it crossed projects"), "--project": text("List one named project's candidates") } },
|
|
34
34
|
list: { description: "List memory", options: { ...filters, ...history } },
|
|
35
35
|
search: { description: "Rank current curated project notes; history/as-of and empty queries retain newest-first filtering", options: { ...filters, ...history,
|
|
36
|
-
"--filter": flag("Current-project curated notes: match every word as a substring, newest first; not with --all-projects"), "--all-projects": flag("Search every active project; the reply says it crossed projects"),
|
|
36
|
+
"--filter": flag("Current-project curated notes: match every word as a substring, newest first; not with --all-projects"), "--explain": flag("Include the score factors for ranked project-local results"), "--all-projects": flag("Search every active project; the reply says it crossed projects"),
|
|
37
37
|
"--include-archived": flag("With --all-projects, also read archived projects") }, args: [{ name: "words", variadic: true }] },
|
|
38
38
|
brief: { description: "Read intent/path memory: fails first, then decisions and contracts; untrusted data within a UTF-8 byte cap", options: { ...json,
|
|
39
39
|
"--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)") } },
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./memory/store.ts";
|
|
2
|
+
export type { ScoreFactors } from "./memory/search-score.ts";
|
|
2
3
|
export * from "./memory/capture.ts";
|
|
3
4
|
export { fts5Available, ftsTerms, SearchIndex, captureSearch } from "./memory/search-fts.ts";
|
|
4
5
|
export type { CuratedDoc, RankedRef } from "./memory/search-fts.ts";
|
package/src/mcp-memory.ts
CHANGED
|
@@ -187,8 +187,9 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
187
187
|
} finally { index.close(); }
|
|
188
188
|
},
|
|
189
189
|
async amem_search(args) {
|
|
190
|
-
bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
|
|
190
|
+
bound(args, ["query", "limit", "since", "includeCapture", "explain"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
|
|
191
191
|
if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
|
|
192
|
+
if (args.explain !== undefined && typeof args.explain !== "boolean") unavailable();
|
|
192
193
|
if (!await currentPolicy().screen({ query })) unavailable();
|
|
193
194
|
// ponytail: scan all matches into private disposable disk state, keeping one body page in JS.
|
|
194
195
|
// Work scales with matches; a policy-bound cache is the upgrade if this reaches the unchanged watchdog.
|
|
@@ -219,7 +220,10 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
219
220
|
const repoNotes = (await repoRows()).filter(view => (!since || Date.parse(view.at) >= Date.parse(since)) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
|
|
220
221
|
for (const view of repoNotes) append({ source: "repo", evidence: false, view });
|
|
221
222
|
if (batch.length) index.append(batch);
|
|
222
|
-
const selected = index.best(query, limit, store.readNow(), useFts).map(({ value, score }) =>
|
|
223
|
+
const selected = index.best(query, limit, store.readNow(), useFts, args.explain === true).map(({ value, score, scoreDetails }) => {
|
|
224
|
+
const hit = { ...value, score };
|
|
225
|
+
return scoreDetails ? { ...hit, scoreDetails: { source: value.source, ...scoreDetails } } : hit;
|
|
226
|
+
});
|
|
223
227
|
const probe = stale(), result = { engine: useFts ? "fts5" : "substring", hits: selected.map(hit => hit.source === "curated" && "view" in hit && hit.view ? { ...hit, view: decorate(hit.view as View, probe) } : hit) };
|
|
224
228
|
guard(); return observed(result, selected.flatMap(hit => hit.source === "curated" && "view" in hit && hit.view ? [hit.view.id] : []), selected.map(hit => "view" in hit ? hit.view : hit.capture));
|
|
225
229
|
} finally { const position = closers.indexOf(releaseIndex); if (position >= 0) closers.splice(position, 1); releaseIndex(); }
|
package/src/mcp.ts
CHANGED
|
@@ -9,7 +9,7 @@ const tool = <Name extends string>(name: Name, description: string, inputSchema:
|
|
|
9
9
|
|
|
10
10
|
/** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */
|
|
11
11
|
export const MCP_TOOLS = [
|
|
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"])),
|
|
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" }, explain: { type: "boolean" } }, ["query"])),
|
|
13
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"])),
|
|
14
14
|
tool("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object({ id: string(80) }, ["id"])),
|
|
15
15
|
tool("amem_timeline", "Read screened memory near an entry or time in this project.", object({ id: string(80), since: string(40), limit })),
|
package/src/memory/candidates.ts
CHANGED
|
@@ -13,6 +13,15 @@ export const CANDIDATE_DB_BUSY_TIMEOUT_MS = 3000;
|
|
|
13
13
|
/** First line of every note written by accepting a consolidation candidate; `consolidationClusters` reads it to avoid proposing summaries of its own summaries. */
|
|
14
14
|
export const ACCEPTED_CONSOLIDATION_PREFIX = "Accepted consolidation candidate: ";
|
|
15
15
|
export const CANDIDATE_RETENTION = { count: 500, ageMs: 30 * 86400_000 } as const;
|
|
16
|
+
export const CONSOLIDATION_REVIEW_LIMIT = 10_000;
|
|
17
|
+
export class ConsolidationReviewLimit extends Error {
|
|
18
|
+
readonly code = "consolidation_review_limit";
|
|
19
|
+
readonly limit = CONSOLIDATION_REVIEW_LIMIT;
|
|
20
|
+
constructor(readonly project: string, readonly count: number) {
|
|
21
|
+
super(`consolidation review cap reached: project ${project}, count ${count}, limit ${CONSOLIDATION_REVIEW_LIMIT}; no reviewed identities evicted`);
|
|
22
|
+
this.name = "ConsolidationReviewLimit";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
16
25
|
export const COUNTER_RETENTION = { count: 10_000, ageMs: 90 * 86400_000 } as const;
|
|
17
26
|
export type CandidateVerdict = "public" | "private" | "pii" | "unknown";
|
|
18
27
|
export interface CandidateTarget { id: string; revision: string }
|
|
@@ -27,6 +36,7 @@ export type CandidateInput = { project: string } & (
|
|
|
27
36
|
);
|
|
28
37
|
export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
|
|
29
38
|
export interface Candidate { id: string; createdAt: string; status: CandidateStatus; input: CandidateInput; acceptedNoteId: string | null }
|
|
39
|
+
export interface ConsolidationReview { status: Exclude<CandidateStatus, "pending">; acceptedNoteId: string | null }
|
|
30
40
|
export interface CandidateFilter { project: string; status?: CandidateStatus | "all"; kind?: CandidateInput["kind"]; limit?: number }
|
|
31
41
|
export interface CounterKey { entryId: string; project: string; task?: string | null; turn?: string | null; session?: string | null; deliveryId: string }
|
|
32
42
|
export interface CounterValues { rendered?: number | null; delivered?: number | null; cited?: number | null }
|
|
@@ -146,13 +156,27 @@ export class CandidateStore {
|
|
|
146
156
|
// F7 / AC4: when a candidate was first shown to the person. In place and nullable: an older database gains it here, and a reader without it treats every row as unshown.
|
|
147
157
|
// Several processes open this store at once (every `amem mcp` builds it at startup), so the check and the ALTER are one immediate transaction: the loser waits, then sees the column.
|
|
148
158
|
if (!hasShownColumn(this.db)) this.db.transaction(() => { if (!hasShownColumn(this.db)) this.db.run("ALTER TABLE candidates ADD COLUMN shown_at INTEGER"); }).immediate();
|
|
159
|
+
this.db.run("CREATE TABLE IF NOT EXISTS consolidation_reviews (id TEXT PRIMARY KEY, project TEXT NOT NULL, status TEXT NOT NULL, note_id TEXT)");
|
|
149
160
|
this.db.run("CREATE TABLE IF NOT EXISTS counters (id TEXT PRIMARY KEY, project TEXT NOT NULL, payload TEXT NOT NULL, at INTEGER NOT NULL, rendered INTEGER, delivered INTEGER, cited INTEGER)");
|
|
150
161
|
this.prune();
|
|
151
162
|
}
|
|
152
163
|
close(): void { this.db.close(); }
|
|
164
|
+
/** Keep only identity and disposition after payload retention, never source text or target lists. */
|
|
165
|
+
private retainConsolidationReviews(): void {
|
|
166
|
+
const overflow = this.db.query(`SELECT project, count(*) AS n FROM consolidation_reviews GROUP BY project HAVING count(*) > ?
|
|
167
|
+
UNION ALL SELECT c.project, count(*) + (SELECT count(*) FROM consolidation_reviews WHERE project = c.project) AS n FROM candidates c LEFT JOIN consolidation_reviews r ON r.id = c.id
|
|
168
|
+
WHERE c.kind = 'consolidation' AND c.status IN ('accepting', 'accepted', 'rejected') AND r.id IS NULL
|
|
169
|
+
GROUP BY c.project HAVING count(*) + (SELECT count(*) FROM consolidation_reviews WHERE project = c.project) > ? LIMIT 1`)
|
|
170
|
+
.get(CONSOLIDATION_REVIEW_LIMIT, CONSOLIDATION_REVIEW_LIMIT) as { project: string; n: number } | null;
|
|
171
|
+
if (overflow) throw new ConsolidationReviewLimit(overflow.project, overflow.n);
|
|
172
|
+
this.db.run(`INSERT INTO consolidation_reviews (id, project, status, note_id)
|
|
173
|
+
SELECT id, project, status, note_id FROM candidates WHERE kind = 'consolidation' AND status IN ('accepting', 'accepted', 'rejected')
|
|
174
|
+
ON CONFLICT(id) DO UPDATE SET status = excluded.status, note_id = excluded.note_id`);
|
|
175
|
+
}
|
|
153
176
|
private prune(): void {
|
|
154
177
|
this.db.transaction(() => {
|
|
155
178
|
const now = this.now();
|
|
179
|
+
this.retainConsolidationReviews();
|
|
156
180
|
// 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
|
|
157
181
|
// 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.
|
|
158
182
|
this.db.query("DELETE FROM candidates WHERE at <= ? AND (status IN ('accepted', 'rejected') OR (shown_at IS NOT NULL AND shown_at <= ?))").run(now - CANDIDATE_RETENTION.ageMs, now - CANDIDATE_SHOWN_GRACE_MS);
|
|
@@ -174,10 +198,19 @@ export class CandidateStore {
|
|
|
174
198
|
// Prune, count, insert and prune again are ONE immediate transaction: concurrent proposers (several agents) cannot all pass the count and then let the cap evict an unseen candidate.
|
|
175
199
|
return this.db.transaction((): string => {
|
|
176
200
|
this.prune();
|
|
201
|
+
if (input.kind === "consolidation" && this.reviewedConsolidation(id, input.project)) return id;
|
|
177
202
|
// AC4: a new candidate never pushes an unshown one out. With the cap's worth waiting the person has to review first, and the refusal says so (a named error an MCP tool can map).
|
|
178
203
|
const waiting = (this.db.query("SELECT COUNT(*) AS n FROM candidates WHERE project = ? AND status IN ('pending', 'accepting') AND shown_at IS NULL").get(input.project) as { n: number }).n;
|
|
179
204
|
const known = this.db.query("SELECT 1 FROM candidates WHERE id = ?").get(id);
|
|
180
205
|
if (!known && waiting >= CANDIDATE_RETENTION.count) throw new CandidateQueueFull(waiting);
|
|
206
|
+
if (!known && input.kind === "consolidation") {
|
|
207
|
+
const allocated = this.db.query(`SELECT (SELECT count(*) FROM consolidation_reviews WHERE project = ?) +
|
|
208
|
+
(SELECT count(*) FROM candidates c WHERE project = ? AND kind = 'consolidation' AND status IN ('pending', 'accepting')
|
|
209
|
+
AND NOT EXISTS (SELECT 1 FROM consolidation_reviews r WHERE r.id = c.id)) AS n`).get(input.project, input.project) as { n: number };
|
|
210
|
+
// ponytail: 10,000 lasting review identities per project; refuse new proposals instead of forgetting a person's decision.
|
|
211
|
+
// Upgrade with an explicit person-audited receipt archive before increasing this bounded sidecar capacity.
|
|
212
|
+
if (allocated.n >= CONSOLIDATION_REVIEW_LIMIT) throw new ConsolidationReviewLimit(input.project, allocated.n);
|
|
213
|
+
}
|
|
181
214
|
this.db.query("INSERT OR IGNORE INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, input.project, input.kind, payload, this.now());
|
|
182
215
|
this.prune();
|
|
183
216
|
return id;
|
|
@@ -189,6 +222,11 @@ export class CandidateStore {
|
|
|
189
222
|
const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(project)) as Parameters<CandidateStore["decode"]>[0] | null;
|
|
190
223
|
return row ? this.decode(row) : undefined;
|
|
191
224
|
}
|
|
225
|
+
reviewedConsolidation(id: string, project: string): ConsolidationReview | undefined {
|
|
226
|
+
if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
|
|
227
|
+
const row = this.db.query("SELECT status, note_id FROM consolidation_reviews WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(project)) as { status: ConsolidationReview["status"]; note_id: string | null } | null;
|
|
228
|
+
return row ? { status: row.status, acceptedNoteId: row.note_id } : undefined;
|
|
229
|
+
}
|
|
192
230
|
list(filter: CandidateFilter): Candidate[] {
|
|
193
231
|
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");
|
|
194
232
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
@@ -221,6 +259,7 @@ export class CandidateStore {
|
|
|
221
259
|
if (!candidate) throw new Error("candidate unavailable");
|
|
222
260
|
if (candidate.status === "accepted" || candidate.status === "accepting") throw new Error("candidate acceptance already started; inspect its ordinary note before any further action");
|
|
223
261
|
this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'pending'").run(id, this.scope(context.project));
|
|
262
|
+
if (candidate.input.kind === "consolidation") this.retainConsolidationReviews();
|
|
224
263
|
}).immediate();
|
|
225
264
|
}
|
|
226
265
|
accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
@@ -321,7 +360,10 @@ export class CandidateStore {
|
|
|
321
360
|
// report every confirmed effect; recovery requires person inspection before any replay.
|
|
322
361
|
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
362
|
}
|
|
324
|
-
this.db.
|
|
363
|
+
this.db.transaction(() => {
|
|
364
|
+
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));
|
|
365
|
+
this.db.query("UPDATE consolidation_reviews SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, candidate.id, this.scope(context.project));
|
|
366
|
+
}).immediate();
|
|
325
367
|
return { candidateId: candidate.id, noteId, alreadyAccepted: false };
|
|
326
368
|
});
|
|
327
369
|
});
|
|
@@ -379,6 +421,7 @@ export class CandidateStore {
|
|
|
379
421
|
if (current.status !== "pending") throw Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
380
422
|
before?.();
|
|
381
423
|
this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(project));
|
|
424
|
+
if (current.input.kind === "consolidation") this.retainConsolidationReviews();
|
|
382
425
|
return undefined;
|
|
383
426
|
}).immediate();
|
|
384
427
|
}
|
package/src/memory/commands.ts
CHANGED
|
@@ -17,6 +17,8 @@ export interface MemoryCommandOptions {
|
|
|
17
17
|
person?: boolean;
|
|
18
18
|
/** Standalone person CLI opt-in. Hosts omit this to retain their screened substring recall. */
|
|
19
19
|
rankedSearch?: boolean;
|
|
20
|
+
/** Standalone person CLI opt-in; requires rankedSearch and person intent. */
|
|
21
|
+
explainSearch?: boolean;
|
|
20
22
|
/** Defaults to amem's own names. */
|
|
21
23
|
names?: CommandNames;
|
|
22
24
|
/** Startup-bound canonical project supplied by a host; no model-facing selector. */
|
|
@@ -70,6 +72,10 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
70
72
|
const names = opts.names ?? AMEM_NAMES, usage = memoryUsage(names.memory);
|
|
71
73
|
if (args.some(arg => arg === "--history" || arg === "--as-of" || arg.startsWith("--history=") || arg.startsWith("--as-of=")) && opts.person !== true) fail("historical recall requires authenticated person intent from the host");
|
|
72
74
|
if (opts.rankedSearch && opts.person !== true) fail("ranked CLI search requires person intent");
|
|
75
|
+
if (opts.explainSearch && (opts.person !== true || opts.rankedSearch !== true)) fail("score explanation requires ranked search and person intent");
|
|
76
|
+
if (opts.explainSearch && sub !== "search") fail("score explanation applies only to search");
|
|
77
|
+
if (args.includes("--explain") && (opts.person !== true || opts.rankedSearch !== true || opts.explainSearch !== true)) fail("score explanation requires ranked search and person intent");
|
|
78
|
+
for (let index = rest.indexOf("--explain"); index >= 0; index = rest.indexOf("--explain")) rest.splice(index, 1);
|
|
73
79
|
const store = opts.store ?? opts.open();
|
|
74
80
|
const actor = "user";
|
|
75
81
|
const project = opts.project ?? memoryProjectKey(cwd);
|
|
@@ -93,12 +99,14 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
93
99
|
if (limit && !(Number(limit) > 0)) fail("--limit takes a positive number");
|
|
94
100
|
const q = sub === "search" ? rest.join(" ") : undefined;
|
|
95
101
|
const ranked = opts.rankedSearch && q?.trim() && !history && asOf === undefined;
|
|
102
|
+
if (opts.explainSearch && (!ranked || !q?.trim())) fail("--explain requires a non-empty current-project ranked search without --history or --as-of");
|
|
96
103
|
const state = recallRows(store, project, { person: opts.person, history, asOf, query: ranked ? undefined : q, limit: Number.MAX_SAFE_INTEGER });
|
|
97
104
|
// Retained history has its own snapshot; the current index cannot rank it. Empty queries keep list semantics.
|
|
98
105
|
// ponytail: fetch up to the current curated-view count before display caps, so metadata/readable exclusions consume no slots.
|
|
99
106
|
// Upgrade to paged ranked views if this full-pool person read reaches the R3 ceiling.
|
|
100
|
-
const
|
|
101
|
-
|
|
107
|
+
const rankedResult = ranked ? store.withDeferredPromotions(() => store.search(q!, { project, includeCapture: false, limit: Math.max(1, state.views.length), ...(opts.explainSearch ? { explain: true } : {}) })) : undefined;
|
|
108
|
+
const scoreDetails = opts.explainSearch ? new Map(rankedResult?.hits.flatMap(hit => hit.view && hit.scoreDetails ? [[hit.view.id, hit.scoreDetails] as const] : []) ?? []) : undefined;
|
|
109
|
+
const candidates = rankedResult ? rankedResult.hits.flatMap(hit => hit.view ? [hit.view] : []) : state.views;
|
|
102
110
|
const all = candidates.filter(view => (!tier || view.tier === tier) && (!kind || view.kind === kind) && (!peer || view.peer === peer) && (!machine || view.machine === machine) && (!since || Date.parse(view.at) >= Date.parse(since)));
|
|
103
111
|
const readable = opts.readable(all), rows = readable.slice(0, limit ? Number(limit) : 50);
|
|
104
112
|
ready(rows);
|
|
@@ -108,10 +116,12 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
|
|
|
108
116
|
const result = { at: state.at, entries: rows.map(decorated), forgotten, relations: state.relations.filter(edge => publicIds.has(edge.from) && publicIds.has(edge.to)), incomplete: emitted.incomplete || readable.length > rows.length || forgotten.length < allForgotten.length, notices: emitted.notices };
|
|
109
117
|
return json ? JSON.stringify(result, null, 2) : [`retained memory as of ${state.at}`, ...result.notices, ...rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}${view.expiresAt && Date.parse(view.expiresAt) <= Date.parse(state.at) ? ` (expired at ${view.expiresAt})` : ""}`), ...forgotten.map(row => `forgotten ${row.id} at ${row.at} (no retained text shown)`), ...(result.incomplete ? ["(retained state is incomplete)"] : [])].join("\n");
|
|
110
118
|
}
|
|
111
|
-
if (json) return JSON.stringify(rows, null, 2);
|
|
119
|
+
if (json) return JSON.stringify(opts.explainSearch ? rows.map(view => ({ ...view, ...(scoreDetails?.has(view.id) ? { scoreDetails: scoreDetails.get(view.id) } : {}) })) : rows, null, 2);
|
|
112
120
|
const counted = opts.readable(store.withDeferredPromotions(() => store.list({ project, limit: Number.MAX_SAFE_INTEGER, includeSuperseded: true })));
|
|
113
121
|
const withheld = all.length - readable.length;
|
|
114
|
-
return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ?
|
|
122
|
+
return [`memory for ${project} on machine ${store.machine.label}: ${counted.filter(view => view.tier === "long").length} long-term, ${counted.filter(view => view.tier === "short").length} short-term`, ...(rows.length ? opts.explainSearch
|
|
123
|
+
? rows.flatMap(view => [`${stale(view) ? "(stale?) " : ""}${one(view)}`, ...(scoreDetails?.has(view.id) ? [`score: ${JSON.stringify(scoreDetails.get(view.id))}`] : [])])
|
|
124
|
+
: rows.map(view => `${stale(view) ? "(stale?) " : ""}${one(view)}`) : ["(nothing)"]),
|
|
115
125
|
...(withheld ? [`(${withheld} withheld: they match a PII pattern; the dashboard shows them)`] : [])].join("\n");
|
|
116
126
|
}
|
|
117
127
|
case "show": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CandidateStore, candidateInputId, candidateTargetRevision, type CandidateInput } from "./candidates.ts";
|
|
1
|
+
import { CandidateStore, ConsolidationReviewLimit, candidateInputId, candidateTargetRevision, type CandidateInput } from "./candidates.ts";
|
|
2
2
|
import { consolidationClusters } from "./consolidation-clusters.ts";
|
|
3
3
|
import { memoryPolicySnapshot, type StandalonePolicy } from "./policy.ts";
|
|
4
4
|
import { recallViewIsLive } from "./recall.ts";
|
|
@@ -20,7 +20,10 @@ export interface ConsolidationReport {
|
|
|
20
20
|
model?: ModelConsolidationReport;
|
|
21
21
|
}
|
|
22
22
|
export class ConsolidationProposalError extends Error {
|
|
23
|
-
constructor(readonly proposed: string[]
|
|
23
|
+
constructor(readonly proposed: string[], readonly refusal?: ConsolidationReviewLimit) {
|
|
24
|
+
super(`consolidation proposal interrupted; candidates already queued: ${proposed.join(", ") || "none"}; no curated notes changed${refusal ? `; ${refusal.message}` : ""}`);
|
|
25
|
+
this.name = "ConsolidationProposalError";
|
|
26
|
+
}
|
|
24
27
|
}
|
|
25
28
|
const modelAnswer = (row: View) => row.peer === "amem-model" || row.text.startsWith("Model answer:");
|
|
26
29
|
/** On-demand candidate writes only. Every source is screened once under one policy and source revision. */
|
|
@@ -67,12 +70,12 @@ export async function proposeConsolidation(store: MemoryStore, candidates: Candi
|
|
|
67
70
|
// put() is INSERT OR IGNORE: a candidate the person already accepted or rejected keeps its status, so it is neither written again nor reported as queued.
|
|
68
71
|
for (const input of prepared) {
|
|
69
72
|
check();
|
|
70
|
-
const known = candidates.get(
|
|
71
|
-
if (known && known.status !== "pending") { report.alreadyReviewed++; continue; }
|
|
72
|
-
|
|
73
|
+
const id = candidateInputId(input), known = candidates.get(id, project);
|
|
74
|
+
if (known && known.status !== "pending" || candidates.reviewedConsolidation(id, project)) { report.alreadyReviewed++; continue; }
|
|
75
|
+
candidates.put(input, "public");
|
|
73
76
|
report.candidates.push({ id, action: input.action, sources: input.targets.map(target => `mem:${target.id}`) });
|
|
74
77
|
}
|
|
75
78
|
if (options.model !== undefined) report.model = await proposeModelConsolidations(store, candidates, synced, options.model, { project, actor: options.actor, check, policy });
|
|
76
|
-
} catch { throw new ConsolidationProposalError(report.candidates.map(row => row.id)); }
|
|
79
|
+
} catch (error) { throw new ConsolidationProposalError(report.candidates.map(row => row.id), error instanceof ConsolidationReviewLimit ? error : undefined); }
|
|
77
80
|
return report;
|
|
78
81
|
}
|
|
@@ -8,9 +8,14 @@ export interface ConsolidationCluster { action: ConsolidationAction; sources: Vi
|
|
|
8
8
|
export const CONSOLIDATION_SOURCE_LIMIT = 256, CONSOLIDATION_PROPOSAL_LIMIT = 32;
|
|
9
9
|
const words = (row: View) => new Set((`${row.title ?? ""} ${row.text}`.toLowerCase().match(/[\p{L}\p{N}_]{3,}/gu) ?? []).filter(word => !["the", "and", "for", "with", "from", "this", "that", "approach", "symptom", "cause", "evidence", "retry", "when"].includes(word)).slice(0, 128));
|
|
10
10
|
/** A note written by accepting a consolidation candidate. Proposing over one would be a summary of summaries. */
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
const
|
|
11
|
+
export function isAcceptedConsolidation(row: Pick<View, "text">): boolean {
|
|
12
|
+
const end = row.text.indexOf("\n\n"); if (end < 0) return false;
|
|
13
|
+
const lines = row.text.slice(0, end).split("\n");
|
|
14
|
+
return lines.length === 3 && lines[0]!.startsWith(ACCEPTED_CONSOLIDATION_PREFIX)
|
|
15
|
+
&& /^[a-f0-9]{64}$/.test(lines[0]!.slice(ACCEPTED_CONSOLIDATION_PREFIX.length))
|
|
16
|
+
&& /^Action: (?:merge|supersession|recurring-fix)$/.test(lines[1]!)
|
|
17
|
+
&& /^Sources: mem:[a-f0-9]{32}(?:, mem:[a-f0-9]{32}){0,7}$/.test(lines[2]!);
|
|
18
|
+
}
|
|
14
19
|
const paths = (row: View) => row.about.filter(node => node.startsWith("path:"));
|
|
15
20
|
const sharedPath = (a: View, b: View) => paths(a).some(node => paths(b).includes(node));
|
|
16
21
|
const overlap = (a: Set<string>, b: Set<string>) => [...a].filter(word => b.has(word)).length / Math.max(1, Math.min(a.size, b.size));
|
|
@@ -18,7 +23,7 @@ const overlap = (a: Set<string>, b: Set<string>) => [...a].filter(word => b.has(
|
|
|
18
23
|
/** Bounded deterministic proposals over already-screened rows. Never mutates a note or runs a model. */
|
|
19
24
|
export function consolidationClusters(input: readonly View[], project: string, now: number): { clusters: ConsolidationCluster[]; truncated: boolean; engine: "fts5" | "substring" } {
|
|
20
25
|
if (!project.trim() || !Number.isFinite(now)) throw Error("consolidation context unavailable");
|
|
21
|
-
const eligible = input.filter(row => row.project === project && recallViewIsLive(row, now))
|
|
26
|
+
const eligible = input.filter(row => row.project === project && recallViewIsLive(row, now) && !isAcceptedConsolidation(row))
|
|
22
27
|
.sort((a, b) => b.at.localeCompare(a.at) || a.id.localeCompare(b.id));
|
|
23
28
|
const rows = [...new Map(eligible.map(row => [row.id, row])).values()].slice(0, CONSOLIDATION_SOURCE_LIMIT);
|
|
24
29
|
const index = new SearchIndex(":memory:"), clusters: ConsolidationCluster[] = [], seen = new Set<string>();
|
|
@@ -36,15 +41,15 @@ export function consolidationClusters(input: readonly View[], project: string, n
|
|
|
36
41
|
for (let i = 0; i < rows.length; i++) {
|
|
37
42
|
const row = rows[i]!, candidates = index.available ? new Set(index.query([...terms.get(row.id)!].slice(0, 32).join(" "), rows.length, project).map(hit => hit.ref)) : undefined;
|
|
38
43
|
for (const peer of rows.slice(i + 1)) {
|
|
39
|
-
if (row.kind !== peer.kind
|
|
44
|
+
if (row.kind !== peer.kind) continue;
|
|
40
45
|
const lexical = overlap(terms.get(row.id)!, terms.get(peer.id)!);
|
|
41
46
|
if (row.text === peer.text) add("supersession", [row, peer], "Identical text; review one replacement for both originals.");
|
|
42
47
|
else if ((!candidates || candidates.has(peer.id)) && lexical >= 0.5 && sharedPath(row, peer)) add("merge", [row, peer], "Shared path and overlapping indexed terms; no equivalence is assumed.");
|
|
43
48
|
}
|
|
44
49
|
// A retained supersession chain is context, never a reason to resurrect an old body.
|
|
45
|
-
if (row.supersedes.length >= 2
|
|
50
|
+
if (row.supersedes.length >= 2) add("merge", [row], "Current head already replaces multiple originals; review a chain summary.");
|
|
46
51
|
}
|
|
47
|
-
const failRows = rows.filter(row => row.kind === "fail"
|
|
52
|
+
const failRows = rows.filter(row => row.kind === "fail"), decisions = rows.filter(row => row.kind === "decision" || row.kind === "contract");
|
|
48
53
|
for (let i = 0; i < failRows.length; i++) for (const peer of failRows.slice(i + 1)) {
|
|
49
54
|
const fail = failRows[i]!;
|
|
50
55
|
if (!sharedPath(fail, peer) || overlap(terms.get(fail.id)!, terms.get(peer.id)!) < 0.5) continue;
|
package/src/memory/doctor.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { readSelector } from "./generation.ts";
|
|
|
10
10
|
import { processLiveness, withGitTimeout } from "./platform.ts";
|
|
11
11
|
import { endLikeSignal, STOP_SIGNALS } from "./sync-cadence.ts";
|
|
12
12
|
import { boundRepoRoot } from "./repo-scope.ts";
|
|
13
|
+
import { facts, label, PLAIN, type Style } from "./style.ts";
|
|
13
14
|
import { shardDir } from "./shard.ts";
|
|
14
15
|
import type { MemoryStore, View } from "./store.ts";
|
|
15
16
|
import { nextSyncAt, readSyncState, remoteConfigured, syncDue, usableState } from "./sync-cadence.ts";
|
|
@@ -403,13 +404,46 @@ async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
|
|
|
403
404
|
return { schema: DOCTOR_SCHEMA, status: worst(Object.values(sections).map(section => section.status)), home: { path: options.home, source: options.homeSource }, project: options.project, sections };
|
|
404
405
|
}
|
|
405
406
|
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
407
|
+
/** Section order and one-line descriptions for the person-facing report (#125). */
|
|
408
|
+
const SECTION_TITLE: Record<keyof DoctorReport["sections"], string> = {
|
|
409
|
+
duplicates: "duplicate notes", stale: "stale path references", candidates: "review candidates", capture: "capture per CLI", brief: "session brief",
|
|
410
|
+
index: "index equals rebuild", readers: "reader leases", policy: "policy in effect", cadence: "ingest and sync cadence",
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
function sectionDetail(name: string, detail: Record<string, unknown>, s: Style): string[] {
|
|
414
|
+
if (name === "capture" && detail.clis && typeof detail.clis === "object") {
|
|
415
|
+
return Object.entries(detail.clis as Record<string, Record<string, unknown>>).map(([cli, row]) => {
|
|
416
|
+
const capture = String(row.capture), mark = capture === "fresh" ? s.ok(capture) : capture === "stale" ? s.attention(capture) : s.dim(capture);
|
|
417
|
+
return `${cli.padEnd(7)} hook ${row.hook}${row.setup ? ` (${row.setup})` : ""}, capture ${mark}${row.lastCaptureAt ? ` (last ${row.lastCaptureAt})` : ""}, ${row.rows} rows`;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
if (name === "cadence") {
|
|
421
|
+
const sync = (detail.sync ?? {}) as Record<string, unknown>, ingest = (detail.ingest ?? {}) as Record<string, string>;
|
|
422
|
+
return [`ingest ${Object.entries(ingest).map(([cli, how]) => `${cli} ${how}`).join(", ")}`,
|
|
423
|
+
`sync remote ${sync.remote}${sync.remote === "configured" ? `, last ok ${sync.lastOkAt ?? "never"}, failures ${sync.failures}${sync.due ? ", due" : ""}` : ""}`];
|
|
424
|
+
}
|
|
425
|
+
if (name === "brief" && typeof detail.tokens === "number") return [`${detail.tokens} of ${detail.budget} tokens${detail.coverage ? `, coverage ${facts(detail.coverage as Record<string, unknown>) || "complete"}` : ""}`];
|
|
426
|
+
const { examples, ...rest } = detail;
|
|
427
|
+
const line = facts(rest);
|
|
428
|
+
return [...(line ? [line] : []), ...(Array.isArray(examples) && examples.length ? [`examples ${examples.map(item => Array.isArray(item) ? item.join("=") : String(item)).join(", ")}`] : [])];
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The person-facing report: overall state, one labelled row per section, then the next actions. `amem doctor --json` keeps the full report. */
|
|
432
|
+
export function doctorText(report: DoctorReport, s: Style = PLAIN): string {
|
|
433
|
+
const entries = Object.entries(report.sections) as [keyof DoctorReport["sections"], Section & { hint?: string; reason?: string }][];
|
|
434
|
+
const count = (status: SectionStatus) => entries.filter(([, section]) => section.status === status).length;
|
|
435
|
+
const summary = facts({ attention: count("attention"), unavailable: count("unavailable"), skipped: count("skipped") });
|
|
436
|
+
const lines = [`${s.bold("amem doctor:")} ${report.status === "ok" ? s.ok(report.status) : report.status === "attention" ? s.attention(report.status) : s.error(report.status)}${summary ? ` (${summary})` : ` (all ${entries.length} checks ok)`}`,
|
|
437
|
+
`home ${report.home.path} ${s.dim(`(${report.home.source})`)}`, `project ${report.project}`, ""];
|
|
438
|
+
const actions: string[] = [];
|
|
439
|
+
for (const [name, section] of entries) {
|
|
440
|
+
const { status, hint, reason, ...detail } = section;
|
|
441
|
+
lines.push(`${label(s, status)} ${name}: ${s.dim(SECTION_TITLE[name])}`);
|
|
442
|
+
for (const line of sectionDetail(name, detail, s)) lines.push(` ${line}`);
|
|
443
|
+
if (reason) lines.push(` ${s.dim(`reason: ${reason}`)}`);
|
|
444
|
+
if (hint) actions.push(`${name}: ${hint}`);
|
|
445
|
+
if (name === "capture" && detail.clis) for (const [cli, row] of Object.entries(detail.clis as Record<string, { hint?: string }>)) if (row.hint) actions.push(`capture ${cli}: ${row.hint}`);
|
|
413
446
|
}
|
|
447
|
+
lines.push("", actions.length ? "Next actions:" : "Next actions: none", ...actions.map(action => ` - ${action}`));
|
|
414
448
|
return lines.join("\n");
|
|
415
449
|
}
|