@staix/agent-mem 0.16.0 → 0.17.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.16.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.17.0. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -151,6 +151,23 @@ Open a review-only `MemoryStore` with `deferPromotions: true` to prevent unrelat
151
151
 
152
152
  Acceptance normalizes its note once, including timestamp, content ID and short-term expiry, then screens and appends that exact body. The final target-revision check and correction appends hold the same machine-local `mutation-lock.db` writer transaction used by this version's ordinary memory, import and sync writers. This lock is independent of the derived index, so an ordinary write still persists when SQLite is busy. External or older writers that edit segments without this lock do not participate in serialization. The logs remain authoritative after an interrupted acceptance; the next read refreshes the derived index.
153
153
 
154
+ ## Console (acon)
155
+
156
+ `@staix/agent-mem/ui-plugin` is a plugin for the local agent console `acon` (STAIxBWLB/agent-console, contract api 1). It adds a Memory area with the panels Review, Search, Timeline, Work lists, Goals and Capacity, puts pending candidates (group `candidate`) and a capture capacity proposal (group `alert`) in the console inbox, and answers the console palette with memory search hits.
157
+
158
+ ```sh
159
+ amem setup console # preview
160
+ amem setup console --apply # write $AGENTHUB_HOME/console/plugins/amem.json
161
+ amem setup console --undo # remove it (only when it points at this install)
162
+ ```
163
+
164
+ The manifest holds the absolute path and sha256 of `src/ui/plugin.ts`, in a 0700 directory and a 0600 file, as acon requires. acon refuses a module whose hash changed, so run `amem setup console --apply` again after upgrading amem. `setup all` does not include it.
165
+
166
+ - Reads are screened. Search, Timeline and Work lists call the in-process `amem_search`, `amem_timeline` and `amem_work_state_read` handlers for the chosen project and forward every option the tool declares, so console search returns exactly the `amem_search` set for the same query. Goals and the review queue pass the configured policy; a candidate that fails it, or whose target fails it, is not shown (the panel counts it and points to `amem review walk`). The console never shows unscreened content, also not to the person. It runs in no repository, so a repository's `.amem/config` narrowing does not apply; the machine's `amem.config.json` does.
167
+ - Review actions are the CLI's. Accept runs the same acceptance as `amem review accept` and writes the same note, edit-then-accept is `review walk`'s edit (the person's own note, then the candidate closed), reject is `review reject`. The console stamps the person as the actor (peer `person`). A privacy-screen hit refuses with a named reason and writes nothing. agent-mem keeps no reject reason, so a reason typed in the console is not recorded, and the reply says so.
168
+ - Undo is offered for 10 seconds and stays bound to what that console just did: undoing an accept forgets the accepted note (an append-only forget op; a note a correction replaced shows again), undoing a reject makes the candidate pending again. A promotion has no undo. `merge_into` is not offered: the library has no merge operation.
169
+ - Destructive forms (`gc`, `capacity_apply`, `project_forget`, `decay_apply`, `prune`) answer `needsConfirm` with the preview and `changes[]` and never write on the first call. The console's single-use confirm token carries the fingerprint back; the plugin recomputes the preview and writes only when it still matches. `prune` needs a generation home, as `amem prune` does.
170
+
154
171
  ## Shell completion
155
172
 
156
173
  Generate completion for `zsh`, `bash`, or `fish` with `amem completion <shell>`. Generation prints a script to stdout; it does not install files or edit shell configuration. The following commands configure shell completion.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "bun": ">=1.4.2"
13
13
  },
14
14
  "devDependencies": {
15
+ "@staix/agent-console": "github:STAIxBWLB/agent-console#952e962",
15
16
  "@types/bun": "^1.3.0",
16
17
  "typescript": "^5.8.0",
17
18
  "@staix/agent-mem-v1": "npm:@staix/agent-mem@0.1.1",
@@ -24,7 +25,8 @@
24
25
  },
25
26
  "exports": {
26
27
  ".": "./src/index.ts",
27
- "./platform": "./src/memory/platform.ts"
28
+ "./platform": "./src/memory/platform.ts",
29
+ "./ui-plugin": "./src/ui/plugin.ts"
28
30
  },
29
31
  "bin": {
30
32
  "amem": "bin/amem.js"
package/src/cli.ts CHANGED
@@ -271,7 +271,12 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
271
271
  const { definition, options, rawArgs: args } = parsed;
272
272
  const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
273
273
  if (parsed.path[0] === "setup") {
274
- if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all [--apply|--undo]");
274
+ if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--apply|--undo]");
275
+ if (parsed.positionals[0] === "console") {
276
+ const { consoleSetup, consoleSetupText } = await import("./setup-console.ts");
277
+ const mode = options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", result = consoleSetup(mode, context.env ?? process.env);
278
+ return options["--json"] === true ? JSON.stringify(result, null, 2) : consoleSetupText(result, mode);
279
+ }
275
280
  const { runSetup } = await import("./setup.ts");
276
281
  return runSetup(parsed.positionals[0]!, options["--apply"] === true ? "apply" : options["--undo"] === true ? "undo" : "preview", {
277
282
  env: context.env ?? process.env, stdinTTY: context.stdinTTY ?? process.stdin.isTTY, cwd: context.cwd ?? process.cwd(),
@@ -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 explicit acceptance", 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 use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all"] }] },
28
+ setup: { description: "Preview reversible MCP setup and supported capture hooks for native coding CLIs, or the acon console plugin manifest (console); apply and undo use explicit flags", options: { ...json, "--apply": flag("Apply the previewed setup"), "--undo": flag("Restore the original configuration") }, args: [{ name: "target", values: ["claude", "codex", "kimi", "pi", "qwen", "jcode", "opencode", "gemini", "agy", "all", "console"] }] },
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"] }] },
@@ -264,6 +264,38 @@ export class CandidateStore {
264
264
  if (candidate.input.kind === "consolidation") this.retainConsolidationReviews();
265
265
  }).immediate();
266
266
  }
267
+ /**
268
+ * Undo of a reject: the rejected candidate is pending again. Only a rejected one; an accepted one is undone by forgetting its note.
269
+ * Retention deletes a decided candidate older than 30 days on the next open, so a caller offering undo passes the candidate it rejected as
270
+ * `restore`: when the row is already gone it is written back as pending with its original time, after the same identity and hash checks as a read.
271
+ */
272
+ reopen(id: string, context: { project: string; actor: "person" | "agent"; restore?: Candidate }): void {
273
+ validActor(context.actor);
274
+ if (!candidateId(id) || !text(context.project, 400)) throw new Error("invalid candidate identity");
275
+ this.db.transaction(() => {
276
+ // No prune here: it would delete the very row this undoes.
277
+ const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(context.project)) as Parameters<CandidateStore["decode"]>[0] | null;
278
+ const candidate = row ? this.decode(row) : undefined, restore = context.restore;
279
+ if (candidate) {
280
+ if (candidate.status !== "rejected") throw new Error("only a rejected candidate can be reopened");
281
+ this.db.query("UPDATE candidates SET status = 'pending' WHERE id = ? AND status = 'rejected'").run(id);
282
+ } else {
283
+ if (!restore || restore.id !== id || !(JSON.parse(this.scope(context.project)) as string[]).includes(restore.input.project)) throw new Error("candidate unavailable");
284
+ validate(restore.input);
285
+ if (hash({ schema: "agent-mem.candidate/v1", input: restore.input }) !== id) throw new Error("candidate content hash mismatch");
286
+ const at = Date.parse(restore.createdAt);
287
+ if (!Number.isFinite(at)) throw new Error("invalid candidate time");
288
+ this.db.query("INSERT INTO candidates (id, project, kind, payload, at, status) VALUES (?, ?, ?, ?, ?, 'pending')").run(id, restore.input.project, restore.input.kind, canonical(restore.input), at);
289
+ }
290
+ const input = candidate?.input ?? restore!.input;
291
+ // The lasting review identity would make a later proposal of the same consolidation a no-op; the decision is withdrawn.
292
+ if (input.kind === "consolidation") this.db.query("DELETE FROM consolidation_reviews WHERE id = ?").run(id);
293
+ }).immediate();
294
+ }
295
+ /** Projects with candidates waiting for review, as stored (an alias key is not resolved here). */
296
+ projects(): string[] {
297
+ return (this.db.query("SELECT DISTINCT project FROM candidates WHERE status IN ('pending', 'accepting') ORDER BY project").all() as { project: string }[]).map(row => row.project);
298
+ }
267
299
  accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
268
300
  validActor(context.actor); publicOnly(context.verdict);
269
301
  if (realPath(context.store.profile.home) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
package/src/memory/gc.ts CHANGED
@@ -14,7 +14,8 @@ import { lifecycleResolver } from "./lifecycle.ts";
14
14
  * or rewritten (#13 AC5, #15 D7). An archived project is still bounded (cold shards shrink first); a forgotten one is skipped, never opened.
15
15
  */
16
16
 
17
- export interface GcShardPlan { key: string; state: "active" | "archived"; rows: number; bytes: number; capMb: number; retentionDays: number; expiredRows: number; overCap: boolean }
17
+ /** `trimRows`: unexpired rows, oldest first, that must go for the shard to fit its cap once the expired ones are gone. `evict()` removes them in whole batches. */
18
+ export interface GcShardPlan { key: string; state: "active" | "archived"; rows: number; bytes: number; capMb: number; retentionDays: number; expiredRows: number; overCap: boolean; trimRows: number }
18
19
  export interface GcShardResult { key: string; expired: number; trimmed: number; batches: number; remainingOverCap: boolean }
19
20
  export interface GcSkip { key: string; reason: string }
20
21
  export interface GcResult { dryRun: boolean; shards: GcShardResult[]; skipped: GcSkip[] }
@@ -26,15 +27,16 @@ export function planGc(base: string, opts: { now?: () => number } = {}): GcShard
26
27
  for (const shard of walkShards(base)) {
27
28
  const state = stateOf(shard.key);
28
29
  if (state === "forgotten") continue;
29
- let rows = 0, bytes = 0, expiredRows = 0, capMb = 0, retentionDays = 0;
30
+ let rows = 0, bytes = 0, expiredRows = 0, capMb = 0, retentionDays = 0, trimRows = 0;
30
31
  for (const dir of shard.machineDirs) {
31
32
  const config = readCaptureConfig(dir, base);
32
33
  capMb = config.capMb; retentionDays = config.retentionDays;
33
- const stats = captureShardStats(dir);
34
+ const stats = captureShardStats(dir), cutoff = new Date(now() - config.retentionDays * 24 * 3600_000).toISOString();
34
35
  rows += stats.rows; bytes += stats.bytes;
35
- expiredRows += expiredIn(dir, new Date(now() - config.retentionDays * 24 * 3600_000).toISOString());
36
+ expiredRows += expiredIn(dir, cutoff);
37
+ trimRows += trimIn(dir, cutoff, config.capMb * 1024 * 1024);
36
38
  }
37
- out.push({ key: shard.key, state, rows, bytes, capMb, retentionDays, expiredRows, overCap: capMb > 0 && bytes > capMb * 1024 * 1024 });
39
+ out.push({ key: shard.key, state, rows, bytes, capMb, retentionDays, expiredRows, overCap: capMb > 0 && bytes > capMb * 1024 * 1024, trimRows });
38
40
  }
39
41
  return out.sort((a, b) => a.key.localeCompare(b.key));
40
42
  }
@@ -48,6 +50,21 @@ function expiredIn(dir: string, cutoff: string): number {
48
50
  } catch { return 0; }
49
51
  }
50
52
 
53
+ /** Each machine directory has its own cap (evict() runs per store): the unexpired rows, oldest first, that must go to fit it. Read-only. */
54
+ function trimIn(dir: string, cutoff: string, capBytes: number): number {
55
+ if (capBytes <= 0 || !existsSync(join(dir, "capture.db"))) return 0;
56
+ try {
57
+ const db = new Database(join(dir, "capture.db"), { readonly: true });
58
+ try {
59
+ db.run("PRAGMA busy_timeout = 3000");
60
+ const kept = db.query("SELECT bytes FROM rows WHERE at >= ? ORDER BY at, id").all(cutoff) as { bytes: number }[];
61
+ let left = kept.reduce((sum, row) => sum + row.bytes, 0), n = 0;
62
+ for (const row of kept) { if (left <= capBytes) break; left -= row.bytes; n++; }
63
+ return n;
64
+ } finally { db.close(); }
65
+ } catch { return 0; }
66
+ }
67
+
51
68
  export function runGc(base: string, opts: GcOptions = {}): GcResult {
52
69
  const maxBatches = opts.maxBatchesPerShard ?? 1;
53
70
  if (!Number.isSafeInteger(maxBatches) || maxBatches < 1 || maxBatches > 1000) throw new Error("maxBatchesPerShard takes a number from 1 to 1000");
@@ -0,0 +1,50 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { isDeepStrictEqual } from "node:util";
7
+
8
+ export interface ConsoleSetup { file: string; module: string; change: "add" | "replace" | "unchanged" | "remove" | "none"; reason?: string }
9
+
10
+ /**
11
+ * `amem setup console`: the acon plugin manifest `{api, tool, module, sha256}` at `$AGENTHUB_HOME/console/plugins/amem.json`, in a 0700 directory
12
+ * and a 0600 file, which is what agent-console's scanPlugins accepts. Preview writes nothing; undo removes only a manifest that points at this install.
13
+ */
14
+ export function consoleSetup(mode: "preview" | "apply" | "undo", env: Readonly<Record<string, string | undefined>> = process.env,
15
+ module = fileURLToPath(new URL("./ui/plugin.ts", import.meta.url))): ConsoleSetup {
16
+ const family = env.AGENTHUB_HOME?.trim() ? resolve(env.AGENTHUB_HOME) : join(env.HOME?.trim() || homedir(), ".agenthub");
17
+ const dir = join(family, "console", "plugins"), file = join(dir, "amem.json");
18
+ // acon refuses a manifest directory or file that is a link, another kind of entry or another user's; so does setup, before writing anything.
19
+ for (const [path, kind] of [[dir, "directory"], [file, "file"]] as const) {
20
+ const info = lstatSync(path, { throwIfNoEntry: false });
21
+ if (!info) continue;
22
+ if (kind === "directory" ? !info.isDirectory() : !info.isFile()) throw Error(`${path}: acon needs a real ${kind}, not a link or another entry`);
23
+ if (typeof process.getuid === "function" && info.uid !== process.getuid()) throw Error(`${path} is owned by another user; acon would refuse it`);
24
+ }
25
+ const before = lstatSync(file, { throwIfNoEntry: false }) ? JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown> : undefined;
26
+ if (mode === "undo") {
27
+ if (!before) return { file, module, change: "none", reason: "no console manifest" };
28
+ if (before.module !== module) throw Error(`${file} points at another amem install (${String(before.module)}); left as is`);
29
+ unlinkSync(file);
30
+ return { file, module, change: "remove" };
31
+ }
32
+ const next = { api: 1, tool: "amem", module, sha256: createHash("sha256").update(readFileSync(module)).digest("hex") };
33
+ const mode7 = (path: string, want: number) => (lstatSync(path, { throwIfNoEntry: false })?.mode ?? want) & 0o777;
34
+ const modesOk = mode7(dir, 0o700) === 0o700 && mode7(file, 0o600) === 0o600;
35
+ if (isDeepStrictEqual(before, next) && modesOk) return { file, module, change: "unchanged" };
36
+ const change = before ? "replace" : "add";
37
+ if (mode === "apply") {
38
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
39
+ chmodSync(dir, 0o700);
40
+ const temp = `${file}.${process.pid}.tmp`;
41
+ writeFileSync(temp, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
42
+ renameSync(temp, file);
43
+ chmodSync(file, 0o600);
44
+ }
45
+ return { file, module, change, ...(modesOk ? {} : { reason: "permissions: plugin directory 0700, manifest 0600" }) };
46
+ }
47
+
48
+ export const consoleSetupText = (result: ConsoleSetup, mode: "preview" | "apply" | "undo"): string =>
49
+ `console plugin: ${mode === "preview" && result.change !== "unchanged" && result.change !== "none" ? `would ${result.change}` : result.change} ${result.file}${result.change === "remove" || result.change === "none" ? "" : ` -> ${result.module}`}${result.reason ? ` (${result.reason})` : ""}`
50
+ + (mode === "preview" && result.change !== "unchanged" ? "\nPreview only; add --apply to write it, --undo to remove it. Re-run after upgrading amem: acon refuses a module whose hash changed." : "");
@@ -0,0 +1,15 @@
1
+ .amem { display: flex; flex-direction: column; gap: var(--acon-space-3); min-width: 0; }
2
+ .amem > .btn, .amem-card > .btn { align-self: flex-start; }
3
+ .amem-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--acon-space-2); }
4
+ .amem-bar input[type="search"] { flex: 1; min-width: 0; max-width: 480px; }
5
+ .amem-inline { display: inline-flex; flex-wrap: wrap; gap: var(--acon-space-2); }
6
+ .amem-input { min-height: 32px; min-width: 0; max-width: 100%; padding: 0 10px; border: 1px solid var(--acon-line-2); border-radius: var(--acon-radius-2); background: var(--acon-surface); color: var(--acon-text); font: inherit; }
7
+ .amem-text { width: 100%; min-height: 10em; padding: var(--acon-space-2) 10px; font: var(--acon-text-sm)/1.5 var(--acon-font-mono); resize: vertical; }
8
+ .amem-form { display: flex; flex-direction: column; gap: var(--acon-space-2); }
9
+ .amem-form .btn { align-self: flex-start; }
10
+ .amem-sub { margin: 0; color: var(--acon-text-2); overflow-wrap: anywhere; }
11
+ .amem-card { display: flex; flex-direction: column; gap: var(--acon-space-2); }
12
+ .amem-card h3 { margin: 0; font-size: var(--acon-text-lg); }
13
+ .amem-diff { display: flex; flex-direction: column; gap: var(--acon-space-1); }
14
+ .amem-pre { margin: 0 0 var(--acon-space-2); padding: var(--acon-space-3); max-height: 40vh; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; background: var(--acon-surface-2); border: 1px solid var(--acon-line); border-radius: var(--acon-radius-2); font: var(--acon-text-sm)/1.5 var(--acon-font-mono); }
15
+ @media (max-width: 600px) { .amem-inline { width: 100%; } .amem-inline .amem-input { flex: 1; } }
@@ -0,0 +1,129 @@
1
+ // amem console panels (acon api 1). Runs under the shell's strict CSP and renders data as text through host.ui only.
2
+ export default (host) => {
3
+ const { ui } = host;
4
+ const { h } = ui;
5
+ const root = h("div", { class: "amem" });
6
+ host.mount.append(root);
7
+ let panel = "review", params = {}, project = null;
8
+ const go = (p, extra = {}) => host.navigate(host.tool, p, { ...(project ? { project } : {}), ...extra });
9
+ const btn = (text, onclick, cls = "btn") => h("button", { type: "button", class: cls, text, onclick });
10
+ const day = (iso) => (typeof iso === "string" ? iso.slice(0, 16).replace("T", " ") : "");
11
+ const line = (text, max = 120) => { const flat = String(text ?? "").replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
12
+ const act = (input) => host.action({ ...input, ...(project ? { project } : {}) });
13
+
14
+ const picker = (s) => {
15
+ const list = s.projects ?? [];
16
+ if (list.length < 2) return list.length ? h("p", { class: "amem-sub", text: `Project ${list[0]}` }) : null;
17
+ const select = h("select", { name: "project", class: "amem-input", "aria-label": "Project", onchange: () => { project = select.value; go(panel); } },
18
+ list.map((key) => { const o = h("option", { value: key, text: key }); if (key === s.project) o.selected = true; return o; }));
19
+ return h("label", { class: "amem-bar" }, h("span", { text: "Project" }), select);
20
+ };
21
+
22
+ const reviewDetail = (r) => {
23
+ const reason = h("input", { type: "text", name: "reason", class: "amem-input", placeholder: "Reason (optional)", "aria-label": "Reject reason (optional)" });
24
+ const actions = [
25
+ r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null,
26
+ h("form", { class: "amem-inline", onsubmit: (e) => { e.preventDefault(); act({ action: "reject", id: r.id, ...(reason.value.trim() ? { reason: reason.value.trim() } : {}) }); } },
27
+ reason, h("button", { type: "submit", class: "btn", text: "Reject" })),
28
+ ].filter(Boolean);
29
+ const sections = [];
30
+ if (r.diff) sections.push({ title: "Now and proposed", open: true, body: h("div", { class: "amem-diff" },
31
+ h("strong", { text: "Now" }), h("pre", { class: "amem-pre", text: r.diff.now ?? "(the current note is not visible)" }),
32
+ h("strong", { text: "Proposed" }), h("pre", { class: "amem-pre", text: r.diff.proposed }),
33
+ h("p", { class: "amem-sub", text: `Reason: ${r.diff.reason}` })) });
34
+ const evidence = [...(r.evidence ?? []).map((ref) => ({ title: ref, sub: "evidence reference" })),
35
+ ...(r.targets ?? []).map((t) => ({ title: t.text ? line(t.title ?? t.text) : `mem:${t.id} (not visible)`, sub: t.at ? `mem:${t.id.slice(0, 8)} · ${t.kind} · ${day(t.at)}` : "" }))];
36
+ sections.push({ title: `Evidence (${evidence.length})`, open: evidence.length > 0, body: ui.rows(evidence, (e) => ui.row(e)) ?? ui.empty("No evidence attached", "This kind of candidate carries no references.") });
37
+ sections.push({ title: `Similar memories (${r.overlaps.length})`, open: r.overlaps.length > 0,
38
+ body: ui.rows(r.overlaps, (o) => ui.row({ title: o.title ?? "Untitled note", sub: `mem:${o.id.slice(0, 8)} · overlap ${o.score.toFixed(2)}` })) ?? ui.empty("No similar memories") });
39
+ if (r.edit !== null) {
40
+ const text = h("textarea", { name: "edit", class: "amem-input amem-text", "aria-label": "Edited text" });
41
+ text.value = r.edit;
42
+ sections.push({ title: "Edit, then accept", body: h("form", { class: "amem-form", onsubmit: (e) => { e.preventDefault(); act({ action: "edit_accept", id: r.id, text: text.value }); } },
43
+ text, h("button", { type: "submit", class: "btn", text: "Save edit and accept" })) });
44
+ }
45
+ return ui.detail({ kind: `${r.kind} candidate`, title: line(r.text, 90), state: { text: "pending", tone: "warn" }, whoseMove: "you", actions, summary: r.text,
46
+ provenance: [{ label: "Proposed", value: day(r.createdAt), at: r.createdAt }, { label: "Project", value: project ?? "" }, ...(r.noteKind ? [{ label: "Note kind", value: r.noteKind }] : [])],
47
+ sections, footer: [r.id] });
48
+ };
49
+
50
+ const views = {
51
+ review(s) {
52
+ const rows = s.rows ?? [], picked = rows.find((r) => r.id === s.selected);
53
+ const hidden = s.hidden ? ui.notice(`${s.hidden} candidate${s.hidden === 1 ? " is" : "s are"} withheld by the privacy screen; review ${s.hidden === 1 ? "it" : "them"} with amem review walk.`, "warn") : null;
54
+ if (picked) return [btn("Back to the queue", () => go("review")), reviewDetail(picked)];
55
+ return [hidden, ui.rows(rows, (r) => ui.row({ title: line(r.text), sub: `${r.kind} · ${day(r.createdAt)}${r.overlaps.length ? ` · ${r.overlaps.length} similar` : ""}`,
56
+ onOpen: () => go("review", { id: r.id }),
57
+ verbs: [r.acceptable ? btn("Accept", () => act({ action: "accept", id: r.id }), "btn primary") : null, btn("Reject", () => act({ action: "reject", id: r.id }))].filter(Boolean) }))
58
+ ?? ui.empty("No candidates waiting", "Agents propose memories with amem_remember; they wait here for your decision.")];
59
+ },
60
+ find(s) {
61
+ const q = h("input", { type: "search", name: "q", class: "amem-input", value: s.q ?? "", placeholder: "Search memory", "aria-label": "Search memory" });
62
+ q.value = s.q ?? "";
63
+ const form = h("form", { class: "amem-bar", role: "search", onsubmit: (e) => { e.preventDefault(); go("find", q.value.trim() ? { q: q.value.trim() } : {}); } }, q, h("button", { type: "submit", class: "btn", text: "Search" }));
64
+ const hits = s.hits ?? [];
65
+ return [form, s.q ? ui.rows(hits, (x) => {
66
+ const v = x.view, c = x.capture;
67
+ return ui.row({ title: line(v ? v.title ?? v.text : c?.excerpt), sub: [x.source, v?.kind ?? c?.event, day(v?.at ?? c?.at), v?.stale ? "stale" : ""].filter(Boolean).join(" · ") });
68
+ }) ?? ui.empty("Nothing found", "Search uses the same screened index as amem_search.") : ui.empty("Search this project's memory", "Results are screened exactly like amem_search.")];
69
+ },
70
+ timeline(s) {
71
+ return [ui.rows(s.entries ?? [], (x) => {
72
+ const c = x.capture;
73
+ return c ? ui.row({ title: line(c.excerpt), sub: `capture · ${c.event} · ${day(c.at)}` }) : ui.row({ title: line(x.title ?? x.text), sub: `${x.kind} · ${x.tier} · ${x.peer} · ${day(x.at)}` });
74
+ }) ?? ui.empty("Nothing on the timeline yet")];
75
+ },
76
+ work(s) {
77
+ const lists = s.lists ?? [];
78
+ return lists.length ? lists.map((l) => h("section", { class: "card amem-card" }, h("h3", { text: l.list }),
79
+ h("p", { class: "amem-sub", text: Object.entries(l.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") }),
80
+ ui.rows(l.items ?? [], (i) => ui.row({ title: i.task, sub: Object.entries(i.fields ?? {}).map(([k, v]) => `${k}: ${v}`).join(" · ") })) ?? ui.empty("No open items")))
81
+ : [ui.empty("No open work lists", "Agents write them with amem_work_state_write.")];
82
+ },
83
+ goals(s) {
84
+ return [ui.rows(s.goals ?? [], (g) => ui.row({ title: g.title, sub: [g.milestones.length ? `${g.milestones.length} milestones` : "", g.links.length ? `${g.links.length} linked tasks` : "", day(g.at)].filter(Boolean).join(" · "),
85
+ chip: { text: g.state, tone: g.state === "done" ? "ok" : g.state === "blocked" ? "err" : "neutral" } })) ?? ui.empty("No goals", "Create one with amem goal add.")];
86
+ },
87
+ capacity(s) {
88
+ if (s.withheld) return [ui.notice("The capacity report is withheld by the privacy screen.", "warn")];
89
+ const r = s.report;
90
+ const out = [];
91
+ if (r) {
92
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Capture" }),
93
+ h("dl", { class: "ui-provenance" },
94
+ h("dt", { text: "Rows" }), h("dd", { text: `${r.capture.rows} in ${r.capture.shards} shards, ${(r.capture.bytes / 1048576).toFixed(1)} MB` }),
95
+ h("dt", { text: "Growth" }), h("dd", { text: `${Math.round(r.capture.rowsPerDay)} rows/day` }),
96
+ h("dt", { text: "Disk free" }), h("dd", { text: `${(r.disk.freeBytes / 1073741824).toFixed(1)} GB` }),
97
+ h("dt", { text: "Current" }), h("dd", { text: `${r.current.capMb} MB, ${r.current.retentionDays} days` }),
98
+ h("dt", { text: "Proposed" }), h("dd", { text: `${r.proposal.capMb} MB, ${r.proposal.retentionDays} days` })),
99
+ h("p", { class: "amem-sub", text: r.proposal.reason }),
100
+ s.applyNeeded ? btn("Apply proposal...", () => host.action({ action: "capacity_apply" }), "btn primary") : ui.chip("current settings match the proposal", "ok")));
101
+ } else out.push(ui.empty("No memory home yet"));
102
+ const cutoff = () => new Date().toISOString().replace(/\.\d{3}Z$/, ".000Z");
103
+ out.push(h("section", { class: "card amem-card" }, h("h3", { text: "Maintenance" }),
104
+ h("p", { class: "amem-sub", text: "Each action shows what it will change and runs only after you confirm." }),
105
+ h("div", { class: "amem-bar" },
106
+ btn("Evict expired capture...", () => host.action({ action: "gc" })),
107
+ project ? btn("Demote old notes...", () => act({ action: "decay_apply" })) : null,
108
+ project ? btn("Prune expired notes...", () => act({ action: "prune", cutoff: cutoff() })) : null,
109
+ project ? btn("Forget this project...", () => act({ action: "project_forget" }), "btn danger") : null)));
110
+ return out;
111
+ },
112
+ };
113
+
114
+ host.onSnapshot((s) => {
115
+ project = s.project ?? null;
116
+ // A poll re-renders the view: carry typed values and focus over by field name.
117
+ const values = new Map([...root.querySelectorAll("input[name], textarea[name]")].map((el) => [el.name, el.value]));
118
+ const focused = root.contains(document.activeElement) ? document.activeElement.getAttribute("name") : null;
119
+ const body = s.error ? [ui.notice(s.error, "err")] : !s.projects?.length && panel !== "capacity" ? [ui.empty("No memory yet", "Projects appear once an agent captures or proposes memory.")] : views[panel]?.(s) ?? [ui.empty("Unknown panel")];
120
+ root.replaceChildren(...[picker(s), ...body].filter(Boolean));
121
+ for (const el of root.querySelectorAll("input[name], textarea[name]")) if (values.has(el.name) && el.name !== "q") el.value = values.get(el.name);
122
+ if (focused) root.querySelector(`[name="${focused}"]`)?.focus();
123
+ });
124
+ return {
125
+ show(id, p = {}) { panel = id; params = p; root.replaceChildren(); },
126
+ hide() {},
127
+ destroy() { root.remove(); },
128
+ };
129
+ };
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Console plugin for acon (STAIxBWLB/agent-console contract api 1; UX per STAIxBWLB/vault-mem#48), agent-mem#158.
3
+ * Reads go through the screened MCP read handlers in process, so the console sees what an MCP client of that project sees.
4
+ * Review writes run the same library calls as `amem review accept|reject` and `review walk`'s edit, stamped as the person.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { createMemoryMcp } from "../mcp-memory.ts";
10
+ import { MCP_TOOLS } from "../mcp.ts";
11
+ import { CandidateStore, type Candidate, type CandidateInput } from "../memory/candidates.ts";
12
+ import { candidateOverlapsBatch } from "../memory/candidate-overlaps.ts";
13
+ import { capacityReport } from "../memory/capacity.ts";
14
+ import { readCaptureConfigFile, writeCaptureConfigFile } from "../memory/capture.ts";
15
+ import { readCatalogFresh } from "../memory/catalog.ts";
16
+ import { configuredPolicy, configuredPolicyOptions } from "../memory/config.ts";
17
+ import { openCuratedForRead, requireShard } from "../memory/curated-read.ts";
18
+ import { applyDecay, assertExactDecayForget, decayPreview, DECAY_DEFAULT_DAYS, readDecayCounters } from "../memory/decay-review.ts";
19
+ import { gcText, planGc, runGc } from "../memory/gc.ts";
20
+ import { memoryHome } from "../memory/home.ts";
21
+ import { appendLifecycle, assertProjectActive, projectState } from "../memory/lifecycle.ts";
22
+ import { hasFailLabel, noteAdd, parseFailTemplate } from "../memory/note-add.ts";
23
+ import type { StandalonePolicy } from "../memory/policy.ts";
24
+ import { applyPrune, planPrune, pruneText } from "../memory/prune.ts";
25
+ import { aliasedKeys, readShardedSelection, resolveAlias } from "../memory/shard.ts";
26
+ import { machineId, MemoryStore, openShardStore, type StoreOptions, type View } from "../memory/store.ts";
27
+
28
+ // The contract types this module uses, copied from agent-console src/contract.ts (api 1, additive only): src/ takes no
29
+ // package imports. test/ui-plugin.test.ts type-checks the plugin against the real contract and runs its conformance suite.
30
+ type Args = Record<string, unknown>;
31
+ interface Change { op: "create" | "modify" | "delete" | "log"; target: string; note?: string }
32
+ type ActionResult =
33
+ | ({ ok: true; message?: string; undo?: { action: string } & Args } & Args)
34
+ | { ok: false; error: string }
35
+ | { ok: false; needsConfirm: true; preview: string[]; fingerprint: string; verb?: string; changes?: Change[]; warnings?: string[] };
36
+ interface InboxItem {
37
+ id: string; group: "candidate" | "alert"; kind: string; title: string; detail?: string; project?: string; at: string;
38
+ chip?: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
39
+ verbs: { id: string; label: string; primary?: boolean; reason?: "optional" | "required" }[];
40
+ link: { panel: string; params?: Record<string, string> };
41
+ }
42
+ interface Provider {
43
+ assets: { js: string; css?: string };
44
+ snapshot(input: { after: number; panel: string } & Args): Promise<Args>;
45
+ action(input: { action: string; confirmed?: true; fingerprint?: string } & Args): Promise<ActionResult>;
46
+ }
47
+ type Preview = { verb: string; preview: string[]; changes: Change[]; warnings?: string[] };
48
+ type Found = { id: string; project: string; candidate: Candidate };
49
+
50
+ const asset = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
51
+ const VERSION = (JSON.parse(asset("../../package.json")) as { version: string }).version;
52
+ /** agent-mem#143: actions from the console are the person's; the console is never an agent session's surface. */
53
+ const ACTOR = "person" as const;
54
+ const UNDO_MS = 60_000, PLAN_MS = 120_000, PLAN_MAX = 16;
55
+ const ACTIONS = ["accept", "reject", "edit_accept", "accept_undo", "reject_undo", "gc", "capacity_apply", "project_forget", "decay_apply", "prune"];
56
+ const str = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v : undefined;
57
+ const sha = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
58
+ const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)).split("\n")[0]!;
59
+ const oneLine = (text: string, max = 160) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
60
+
61
+ /** What a candidate proposes, as text. */
62
+ export function candidateText(input: CandidateInput): string {
63
+ switch (input.kind) {
64
+ case "memory": return input.title ? `${input.title}: ${input.text}` : input.text;
65
+ case "fail": return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nRetry when: ${input.retryWhen}`;
66
+ case "correction": return `${input.proposedText}\n\nReason: ${input.reason}`;
67
+ case "decay": return input.reason;
68
+ case "promotion": return `Promote mem:${input.target.id} to long-term for goal:${input.goalId}`;
69
+ case "consolidation": return `${input.action}: ${input.text}`;
70
+ case "model-answer": return input.text;
71
+ }
72
+ }
73
+ /** The text `review walk`'s edit opens for a memory or fail candidate. */
74
+ function editText(input: CandidateInput): string | null {
75
+ if (input.kind === "fail") return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`;
76
+ if (input.kind !== "memory") return null;
77
+ return input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
78
+ }
79
+ const targetsOf = (input: CandidateInput) => "targets" in input ? input.targets : "target" in input && input.target ? [input.target] : [];
80
+
81
+ /** The CLI's curatedStore on an explicit home (src/cli.ts keeps its own copy; cli.ts is not a library module). */
82
+ function openCurated(base: string, project: string, opts: StoreOptions, reconciled = true): MemoryStore {
83
+ assertProjectActive(base, project);
84
+ const aliases = reconciled ? aliasedKeys(base, project) : [];
85
+ const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
86
+ if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); }
87
+ return new MemoryStore({ ...withAliases, memoryDir: base });
88
+ }
89
+
90
+ /** A refusal that names which part of the screen declined, never the text. */
91
+ function refusal(value: unknown, policy: StandalonePolicy): ActionResult {
92
+ let personal = false;
93
+ try { personal = policy.declinesPersonal(JSON.stringify(value)); } catch { /* unreadable: reported as unscreenable */ }
94
+ return { ok: false, error: `Refused by the privacy screen: ${personal ? "personal data, a configured PII pattern or a deny path" : "a likely secret, or a value the screen cannot read"}. Nothing was written.` };
95
+ }
96
+
97
+ export const plugin = {
98
+ manifest: {
99
+ api: 1 as const, tool: "amem", version: VERSION, title: "Memory", icon: "memory", pollMs: 15_000,
100
+ panels: [
101
+ { id: "review", title: "Review", order: 1 },
102
+ { id: "find", title: "Search", order: 2 },
103
+ { id: "timeline", title: "Timeline", order: 3 },
104
+ { id: "work", title: "Work lists", order: 4 },
105
+ { id: "goals", title: "Goals", order: 5 },
106
+ { id: "capacity", title: "Capacity", order: 6 },
107
+ ],
108
+ commands: [
109
+ { id: "gc", title: "Evict expired capture rows", hint: "amem gc", action: "gc" },
110
+ { id: "capacity-apply", title: "Apply the capture capacity proposal", hint: "amem capacity apply", action: "capacity_apply" },
111
+ ],
112
+ capabilities: { actions: ACTIONS, confirm: true, multiProject: true, inbox: true, search: true },
113
+ },
114
+ async open(ctx: { home: string; log(line: string): void }): Promise<Provider> {
115
+ const base = memoryHome({ ...process.env, AGENTHUB_HOME: ctx.home });
116
+ // The machine policy of this home. The console runs in no repository, so a repository's `.amem/config` narrowing does not apply here.
117
+ const policy = () => configuredPolicy(base);
118
+ const policyVersion = () => JSON.stringify(configuredPolicyOptions(base));
119
+ const hasCandidates = () => existsSync(join(base, "candidates.db"));
120
+ const withCandidates = <T>(project: string | undefined, run: (store: CandidateStore) => T): T => {
121
+ const aliases = project ? aliasedKeys(base, project) : [];
122
+ const store = new CandidateStore({ memoryDir: base, ...(project && aliases.length ? { reconcile: { canonical: project, aliases } } : {}) });
123
+ try { return run(store); } finally { store.close(); }
124
+ };
125
+
126
+ /** Projects with notes in an unsplit (shared-v1 or generation) home: that store is one file set for every project, with no shard per project. */
127
+ function sharedStoreProjects(): string[] {
128
+ if (existsSync(join(base, "shards.json")) || !["log", "local", "generations"].some(dir => existsSync(join(base, dir)))) return [];
129
+ const store = new MemoryStore({ memoryDir: base, deferPromotions: true });
130
+ // ponytail: reads every live note on each snapshot; keep a per-index-revision cache if a large shared home makes polls slow.
131
+ try { return [...new Set(store.list({ limit: Number.MAX_SAFE_INTEGER }).map(view => view.project))]; } finally { store.close(); }
132
+ }
133
+ // ponytail: the catalog scan walks every shard on each snapshot; cache it by shard mtimes if polls get slow on a large home.
134
+ function projects(): string[] {
135
+ if (!existsSync(base)) return [];
136
+ const keys = new Set<string>(), waiting = new Set<string>();
137
+ try { for (const p of readCatalogFresh(base)) keys.add(p.key); } catch (e) { ctx.log(`amem: project catalog unavailable: ${errorText(e)}`); }
138
+ try { for (const key of sharedStoreProjects()) keys.add(resolveAlias(base, key)); } catch (e) { ctx.log(`amem: shared store projects unavailable: ${errorText(e)}`); }
139
+ if (hasCandidates()) for (const key of withCandidates(undefined, store => store.projects())) { const canonical = resolveAlias(base, key); keys.add(canonical); waiting.add(canonical); }
140
+ return [...keys].filter(key => projectState(base, key) === "active").sort((a, b) => Number(waiting.has(b)) - Number(waiting.has(a)) || a.localeCompare(b));
141
+ }
142
+ // The shell's tabs navigate without params, so the project chosen on one panel is kept for the others and the palette.
143
+ // ponytail: one choice per provider; fine for H0's single person, per session once a console serves several.
144
+ let chosen: string | undefined;
145
+ /** An active project whose curated store holds a note of its own, also when no listing source named it. */
146
+ const exists = (key: string) => {
147
+ if (projectState(base, key) !== "active") return false;
148
+ const store = openCuratedForRead(base, key);
149
+ try { return !!store?.list({ project: key, limit: 1 }).some(view => view.project === key); } finally { store?.close(); }
150
+ };
151
+ const pick = (args: Args, list: string[]) => {
152
+ const asked = str(args.project), key = asked ? resolveAlias(base, asked) : undefined;
153
+ if (key && !list.includes(key) && existsSync(base) && exists(key)) list.push(key);
154
+ if (key && list.includes(key)) chosen = key;
155
+ return chosen && list.includes(chosen) ? chosen : list[0];
156
+ };
157
+
158
+ /** Every option the MCP tool declares is forwarded; nothing is hand-picked. The handler validates and refuses as it does for MCP. */
159
+ const forward = (tool: "amem_search" | "amem_timeline" | "amem_work_state_read", args: Args): Args => {
160
+ const schema = MCP_TOOLS.find(t => t.name === tool)!.inputSchema as { properties: Record<string, { type?: string }> };
161
+ const out: Args = {};
162
+ for (const [key, spec] of Object.entries(schema.properties)) {
163
+ let value = args[key];
164
+ if (value === undefined || value === "") continue;
165
+ if (spec.type === "integer" && typeof value === "string" && /^[0-9]+$/.test(value)) value = Number(value);
166
+ if (spec.type === "boolean" && (value === "true" || value === "false")) value = value === "true";
167
+ out[key] = value;
168
+ }
169
+ return out;
170
+ };
171
+ async function read(project: string, tool: "amem_search" | "amem_timeline" | "amem_work_state_read", args: Args): Promise<unknown> {
172
+ const mcp = createMemoryMcp({ cwd: base, base, project });
173
+ try { return await mcp.tools[tool](forward(tool, args)); } finally { mcp.close(); }
174
+ }
175
+
176
+ /** The CLI's rule for agent-marked review, applied to the person here: the console shows only what passes the screen, targets included. */
177
+ const visible = (candidate: Candidate, project: string, store: MemoryStore | undefined, p: StandalonePolicy): boolean => {
178
+ if (!p.screenStandalone(candidate)) return false;
179
+ const input = candidate.input, targets = targetsOf(input);
180
+ if (!targets.length && input.kind !== "promotion") return true;
181
+ if (!store) return false;
182
+ if (targets.some(t => { const view = store.get(t.id, project); return !view || view.project !== project || !p.screenStandalone(view); })) return false;
183
+ if (input.kind === "promotion") { const goal = store.goalOf(input.goalId, project); if (!goal || !p.screenStandalone(goal)) return false; }
184
+ return true;
185
+ };
186
+ const projectOf = new Map<string, string>();
187
+ async function pending(project: string, detail: boolean) {
188
+ if (!hasCandidates()) return { rows: [], hidden: 0 };
189
+ const p = policy(), store = openCuratedForRead(base, project);
190
+ try {
191
+ const all = withCandidates(project, c => c.list({ project, status: "pending" }));
192
+ const shown = all.filter(c => visible(c, project, store, p));
193
+ for (const c of shown) projectOf.set(c.id, project);
194
+ const overlaps = detail && store ? await candidateOverlapsBatch(shown, store, { project, policy, policyVersion }) : [];
195
+ const rows = shown.map((c, index) => {
196
+ const input = c.input;
197
+ const targets = targetsOf(input).map(t => { const v = store?.get(t.id, project); return v ? { id: v.id, at: v.at, kind: v.kind, title: v.title, text: v.text } : { id: t.id }; });
198
+ return {
199
+ id: c.id, kind: input.kind, createdAt: c.createdAt, text: candidateText(input),
200
+ noteKind: "noteKind" in input ? input.noteKind ?? null : null,
201
+ diff: input.kind === "correction" ? { now: (targets[0] as { text?: string } | undefined)?.text ?? null, proposed: input.proposedText, reason: input.reason } : null,
202
+ evidence: input.kind === "fail" ? input.evidenceRefs : [],
203
+ targets, overlaps: overlaps[index] ?? [],
204
+ acceptable: input.kind !== "decay" && input.kind !== "model-answer",
205
+ edit: editText(input),
206
+ };
207
+ });
208
+ return { rows, hidden: all.length - shown.length };
209
+ } finally { store?.close(); }
210
+ }
211
+
212
+ async function capacity() {
213
+ if (!existsSync(base)) return undefined;
214
+ const report = capacityReport({ memoryDir: base });
215
+ if (!await policy().screen(report)) return { withheld: true as const };
216
+ return { report, applyNeeded: report.current.capMb !== report.proposal.capMb || report.current.retentionDays !== report.proposal.retentionDays };
217
+ }
218
+ // The shell keys snooze and oldest-first order on `at`: an alert keeps the time it was first seen.
219
+ const firstSeen = new Map<string, string>();
220
+ const since = (id: string) => { const at = firstSeen.get(id) ?? new Date().toISOString(); firstSeen.set(id, at); return at; };
221
+ async function inbox(): Promise<InboxItem[]> {
222
+ const items: InboxItem[] = [];
223
+ for (const project of projects()) {
224
+ for (const row of (await pending(project, false)).rows) items.push({
225
+ id: row.id, group: "candidate", kind: row.kind, title: oneLine(row.text, 120), detail: `${row.kind} candidate in ${project}`, project, at: row.createdAt,
226
+ verbs: [...row.acceptable ? [{ id: "accept", label: "Accept", primary: true }] : [], { id: "reject", label: "Reject", reason: "optional" as const }],
227
+ link: { panel: "review", params: { project, id: row.id } },
228
+ });
229
+ }
230
+ const cap = await capacity();
231
+ if (cap && "report" in cap && cap.applyNeeded) {
232
+ const { current, proposal } = cap.report;
233
+ items.push({ id: "capacity:proposal", group: "alert", kind: "capacity", title: `Capture capacity: ${current.capMb} MB / ${current.retentionDays} d, proposed ${proposal.capMb} MB / ${proposal.retentionDays} d`,
234
+ detail: proposal.reason, at: since("capacity:proposal"), chip: { text: "proposal", tone: "warn" }, verbs: [{ id: "capacity_apply", label: "Apply..." }], link: { panel: "capacity" } });
235
+ } else firstSeen.delete("capacity:proposal");
236
+ return items;
237
+ }
238
+
239
+ function searchHit(hit: Args, project: string, q: string) {
240
+ const view = hit.view as View | undefined;
241
+ const capture = hit.capture as { id: number; at: string; event: string; excerpt: string } | undefined;
242
+ const id = view ? (hit.source === "repo" ? `repo:${view.id}` : view.id) : `capture:${capture?.id}`;
243
+ return { id, kind: view ? `${String(hit.source)} ${view.kind}` : `capture ${capture?.event ?? ""}`, title: oneLine(view ? view.title ?? view.text : capture?.excerpt ?? "", 100),
244
+ ...(view?.title ? { detail: oneLine(view.text, 120) } : {}), ...(view?.at ?? capture?.at ? { at: view?.at ?? capture?.at } : {}), link: { panel: "find", params: { project, q } } };
245
+ }
246
+
247
+ async function panel(name: string, args: Args, project: string): Promise<Args> {
248
+ switch (name) {
249
+ case "review": return { ...(await pending(project, true)), selected: str(args.id) ?? null };
250
+ case "find": {
251
+ const q = str(args.q);
252
+ if (!q) return { q: "", hits: [] };
253
+ return { q, ...(await read(project, "amem_search", { ...args, query: q }) as Args) };
254
+ }
255
+ case "timeline": return { entries: await read(project, "amem_timeline", args) };
256
+ case "work": return { lists: await read(project, "amem_work_state_read", args) };
257
+ case "goals": {
258
+ const store = openCuratedForRead(base, project), p = policy();
259
+ try {
260
+ return { goals: (store?.goals(project) ?? []).filter(goal => p.screenStandalone(goal))
261
+ .map(g => ({ id: g.id, at: g.at, title: g.title, state: g.state, milestones: g.milestones, dependsOn: g.dependsOn, links: g.links.map(l => l.task) })) };
262
+ } finally { store?.close(); }
263
+ }
264
+ default: return {};
265
+ }
266
+ }
267
+
268
+ // Undo stays bound to what this console just did: an accepted or edited note to forget, a rejection to withdraw.
269
+ // A rejected candidate is held here too: retention may delete it before the undo arrives, and reopen() writes it back from this copy.
270
+ const undo = new Map<string, { kind: "accept" | "reject"; project: string; noteId?: string; reopen?: boolean; candidate?: Candidate; until: number }>();
271
+ // Previewed prune plans by seal, for the console's confirm window (60 s) plus slack; bounded by the console's own confirm limit.
272
+ const prunePlans = new Map<string, { plan: ReturnType<typeof planPrune>; project: string; cutoff: string; until: number }>();
273
+ function candidateFor(args: Args): Found | string {
274
+ const id = str(args.id);
275
+ if (!id || !/^[a-f0-9]{64}$/.test(id)) return "a full 64-character candidate id is required";
276
+ if (!hasCandidates()) return "candidate unavailable";
277
+ const asked = str(args.project);
278
+ const project = asked ? resolveAlias(base, asked) : projectOf.get(id) ?? projects().find(p => withCandidates(p, c => c.get(id, p)));
279
+ const candidate = project ? withCandidates(project, c => c.get(id, project)) : undefined;
280
+ return project && candidate ? { id, project, candidate } : "candidate unavailable";
281
+ }
282
+ /** The console never acts on a candidate it would not show. */
283
+ function hiddenReason(found: Found): string | undefined {
284
+ if (found.candidate.status !== "pending") return `candidate is ${found.candidate.status}, not pending`;
285
+ const store = openCuratedForRead(base, found.project);
286
+ try { return visible(found.candidate, found.project, store, policy()) ? undefined : "this candidate is withheld by the privacy screen; review it with amem review walk"; }
287
+ finally { store?.close(); }
288
+ }
289
+
290
+ async function accept({ id, project, candidate }: Found): Promise<ActionResult> {
291
+ const p = policy();
292
+ if (candidate.input.kind === "decay") return { ok: false, error: "decay candidates are listing-only; use amem demote or amem forget after review" };
293
+ // `amem review accept`: the current policy screens the proposal, then acceptance screens the note it writes with its target and goal.
294
+ if (!await p.screen(candidate.input)) return refusal(candidate.input, p);
295
+ let declined: unknown;
296
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => policy().screenStandalone(note) ? "promote" : "keep" });
297
+ try {
298
+ const result = withCandidates(project, c => c.accept(id, { project, actor: ACTOR, verdict: "public", store, policyVersion,
299
+ policy: (proposal, target, note, goal) => {
300
+ const value = { ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) };
301
+ if (policy().screenStandalone(value)) return true;
302
+ declined = value; return false;
303
+ } }));
304
+ // A promotion hides its short-term source; forgetting the copy would not bring that back, so it gets no undo.
305
+ const reversible = candidate.input.kind !== "promotion" && !result.alreadyAccepted;
306
+ if (reversible) undo.set(id, { kind: "accept", project, noteId: result.noteId, until: Date.now() + UNDO_MS });
307
+ return { ok: true, message: `Accepted as note ${result.noteId.slice(0, 8)}`, noteId: result.noteId, ...(reversible ? { undo: { action: "accept_undo", id, project } } : {}) };
308
+ } catch (e) {
309
+ if (declined !== undefined) return refusal(declined, p);
310
+ return { ok: false, error: errorText(e) };
311
+ } finally { store.close(); }
312
+ }
313
+ function editAccept({ id, project, candidate }: Found, text: string | undefined): ActionResult {
314
+ const input = candidate.input;
315
+ if (input.kind !== "memory" && input.kind !== "fail") return { ok: false, error: "edit is for memory and fail candidates; accept or reject this one" };
316
+ if (!text) return { ok: false, error: "the edited text is required" };
317
+ const p = policy(), asFail = input.kind === "fail" || input.noteKind === "fail";
318
+ const title = input.kind === "memory" && input.title ? { title: input.title } : {};
319
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => p.screenStandalone(note) ? "promote" : "keep" });
320
+ let noteId: string;
321
+ try {
322
+ // `review walk`'s edit: the person's own long-term note, then the candidate is closed.
323
+ noteId = noteAdd(store, project, asFail ? { kind: "fail", tier: "long", scope: "project", fail: parseFailTemplate(text), ...title }
324
+ : { kind: input.kind === "memory" ? input.noteKind ?? "finding" : "finding", tier: "long", scope: "project", text, ...title }, p).id;
325
+ } catch (e) {
326
+ return /privacy policy/.test(errorText(e)) ? refusal({ text, ...title }, p) : { ok: false, error: errorText(e) };
327
+ } finally { store.close(); }
328
+ withCandidates(project, c => c.reject(id, { project, actor: ACTOR, verdict: "public" }));
329
+ undo.set(id, { kind: "accept", project, noteId, reopen: true, candidate, until: Date.now() + UNDO_MS });
330
+ return { ok: true, message: `Saved your edit as note ${noteId.slice(0, 8)}; the candidate is closed`, noteId, undo: { action: "accept_undo", id, project } };
331
+ }
332
+ function undoFor(args: Args, kind: "accept" | "reject") {
333
+ const id = str(args.id) ?? "", entry = undo.get(id);
334
+ if (!entry || entry.kind !== kind) return undefined;
335
+ undo.delete(id);
336
+ return entry.until >= Date.now() ? { id, ...entry } : undefined;
337
+ }
338
+
339
+ /** Destructive forms: the first call previews; a write needs the console's confirmation with the same fingerprint, recomputed here. */
340
+ const gate = (input: Args, fingerprint: string, view: Preview): ActionResult | undefined =>
341
+ input.confirmed === true && input.fingerprint === fingerprint ? undefined : { ok: false, needsConfirm: true, fingerprint, ...view };
342
+ function projectArg(args: Args): string {
343
+ const asked = str(args.project);
344
+ if (!asked) throw new Error("project is required");
345
+ const key = resolveAlias(base, asked);
346
+ assertProjectActive(base, key);
347
+ return key;
348
+ }
349
+ async function destructive(input: Args): Promise<ActionResult> {
350
+ if (!existsSync(base)) return { ok: false, error: "no memory home yet" };
351
+ switch (input.action) {
352
+ case "gc": {
353
+ // gc both expires rows and trims the oldest unexpired rows of a shard over its cap: the preview names both.
354
+ const plan = planGc(base), acting = plan.filter(s => s.expiredRows > 0 || s.trimRows > 0);
355
+ const blocked = gate(input, sha(plan), { verb: "Evict capture rows",
356
+ preview: acting.length ? acting.map(s => `${s.key}: ${s.expiredRows} expired row(s)${s.trimRows ? `, ${s.trimRows} oldest unexpired row(s) over the ${s.capMb} MB cap` : ""}`) : ["nothing to evict"],
357
+ changes: acting.map(s => ({ op: "delete", target: `capture rows of ${s.key}`, note: [s.expiredRows ? `${s.expiredRows} expired` : "", s.trimRows ? `${s.trimRows} trimmed to fit ${s.capMb} MB` : ""].filter(Boolean).join(", ") })),
358
+ ...(acting.some(s => s.trimRows) ? { warnings: ["Trimming removes unexpired capture rows, oldest first, in whole batches of 256; one run may stop before the shard fits."] } : {}) });
359
+ return blocked ?? { ok: true, message: gcText(runGc(base)).split("\n").slice(0, 3).join("; ") };
360
+ }
361
+ case "capacity_apply": {
362
+ const report = capacityReport({ memoryDir: base });
363
+ if (!await policy().screen(report)) return refusal(report, policy());
364
+ const file = join(base, "capture-config.json"), current = readCaptureConfigFile(file);
365
+ const target = { capMb: report.proposal.capMb, retentionDays: report.proposal.retentionDays };
366
+ // `amem capacity apply`: the CLI's preview file is replaced by the console's confirm token, bound to the same values.
367
+ const blocked = gate(input, sha({ file, current, target }), { verb: "Apply capacity", changes: [{ op: existsSync(file) ? "modify" : "create", target: file }],
368
+ preview: [`cap_mb: ${current.capMb ?? "default"} -> ${target.capMb}`, `retention_days: ${current.retentionDays ?? "default"} -> ${target.retentionDays}`, `reason: ${report.proposal.reason}`] });
369
+ if (blocked) return blocked;
370
+ const written = writeCaptureConfigFile(file, target);
371
+ return { ok: true, message: `Capture capacity set to ${written.capMb} MB, ${written.retentionDays} days` };
372
+ }
373
+ case "project_forget": {
374
+ const key = requireShard(base, projectArg(input)), state = projectState(base, key), shard = readCatalogFresh(base).find(p => p.key === key);
375
+ const blocked = gate(input, sha({ key, state }), { verb: "Forget project", changes: [{ op: "log", target: "lifecycle.jsonl", note: `forget ${key}` }],
376
+ preview: [`forget ${key} (now ${state}; capture ${shard?.captureRows ?? 0} rows)`, "Forget is an append-only record that takes the project out of recall and every listing for good. Nothing is erased."],
377
+ warnings: ["This cannot be undone."] });
378
+ if (blocked) return blocked;
379
+ appendLifecycle(base, { op: "forget", key, actor: ACTOR, machine: machineId(base).id });
380
+ return { ok: true, message: `Forgot ${key}` };
381
+ }
382
+ case "decay_apply": {
383
+ const project = projectArg(input), forget = input.forget === true || input.forget === "true";
384
+ const days = input.days === undefined ? DECAY_DEFAULT_DAYS : Number(input.days);
385
+ if (!Number.isSafeInteger(days) || days < 1) return { ok: false, error: "days must be a positive whole number" };
386
+ const aliases = aliasedKeys(base, project);
387
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => policy().screenStandalone(note) ? "promote" : "keep" });
388
+ try {
389
+ const current = { days, aliases, counters: () => readDecayCounters(base, [project, ...aliases]), screen: (view: View) => policy().screenStandalone(view) };
390
+ const plan = decayPreview(store, project, current), action = forget ? "forget" : "demote";
391
+ const blocked = gate(input, sha({ project, action, days, targets: plan.entries.map(e => e.target) }), {
392
+ verb: forget ? "Forget old notes" : "Demote old notes",
393
+ preview: [`${action}: ${plan.entries.length} notes older than ${days} days in ${project}${plan.truncated ? " (truncated)" : ""}; citation history unknown`,
394
+ ...plan.entries.map(e => `mem:${e.target.id} ${e.view.at.slice(0, 10)} ${e.view.kind}: ${oneLine(e.view.title ?? e.view.text, 80)}`)],
395
+ changes: plan.entries.map(e => ({ op: forget ? "delete" : "modify", target: `mem:${e.target.id}`, note: forget ? "forget" : `short-term, expires ${plan.demoteExpiresAt}` })),
396
+ ...(plan.withheld ? { warnings: [`${plan.withheld} notes withheld by the privacy screen are left alone`] } : {}) });
397
+ if (blocked) return blocked;
398
+ if (!plan.entries.length) return { ok: true, message: "No eligible notes" };
399
+ if (forget) assertExactDecayForget(store, plan.entries.map(e => e.target.id), project);
400
+ const result = applyDecay(store, plan, { ...current, actor: ACTOR, confirmed: true, forget });
401
+ return { ok: true, message: `${result.action}: ${result.ids.length} notes` };
402
+ } finally { store.close(); }
403
+ }
404
+ case "prune": {
405
+ const project = projectArg(input), cutoff = str(input.cutoff);
406
+ if (!cutoff) return { ok: false, error: "cutoff is required (UTC time)" };
407
+ const store = openCurated(base, project, { deferPromotions: true }, false);
408
+ try {
409
+ const auth = { project, actor: ACTOR, authorize: () => true }, port = store.prunePort();
410
+ // A plan carries its authoring time inside the seal, so a new plan never matches the previewed one. The confirmed call applies
411
+ // the very plan the person saw; applyPrune rechecks it against the current home and refuses a stale one.
412
+ const kept = typeof input.fingerprint === "string" ? prunePlans.get(input.fingerprint) : undefined;
413
+ for (const [seal, entry] of prunePlans) if (entry.until < Date.now()) prunePlans.delete(seal);
414
+ if (input.confirmed === true && kept && kept.until >= Date.now() && kept.project === project && kept.cutoff === cutoff) {
415
+ prunePlans.delete(kept.plan.seal);
416
+ return { ok: true, message: `Pruned: ${applyPrune(port, JSON.stringify(kept.plan), auth).completed.length} rows` };
417
+ }
418
+ const plan = planPrune(port, { cutoff }, auth);
419
+ if (prunePlans.size >= PLAN_MAX) prunePlans.delete(prunePlans.keys().next().value!);
420
+ prunePlans.set(plan.seal, { plan, project, cutoff, until: Date.now() + PLAN_MS });
421
+ return { ok: false, needsConfirm: true, fingerprint: plan.seal, verb: "Prune expired notes", preview: pruneText(plan).split("\n"),
422
+ changes: plan.rows.map(r => ({ op: "log", target: `${r.placement} prune row`, note: `${r.payload.targets.length} targets` })) };
423
+ } finally { store.close(); }
424
+ }
425
+ }
426
+ return { ok: false, error: `unknown action ${String(input.action)}` };
427
+ }
428
+
429
+ return {
430
+ assets: { js: asset("./panel.js"), css: asset("./panel.css") },
431
+ async snapshot(input) {
432
+ const args = input as Args;
433
+ if (input.panel === "inbox") return { items: await inbox() };
434
+ const list = projects(), project = pick(args, list);
435
+ if (input.panel === "search") {
436
+ const q = str(args.q);
437
+ if (!q || !project) return { hits: [], total: 0 };
438
+ const limit = Math.min(10, Math.max(1, Number(args.limit) || 10));
439
+ const result = await read(project, "amem_search", { query: q, limit }) as { hits: Args[] };
440
+ const hits = result.hits.map(hit => searchHit(hit, project, q));
441
+ return { hits, total: hits.length };
442
+ }
443
+ if (input.panel === "capacity") return { projects: list, project: project ?? null, ...(await capacity() ?? { report: null }) };
444
+ if (!project) return { projects: list, project: null };
445
+ try { return { projects: list, project, ...(await panel(input.panel, args, project)) }; }
446
+ catch (e) {
447
+ ctx.log(`amem: ${input.panel} for ${project} failed: ${errorText(e)}`);
448
+ return { projects: list, project, error: "Memory is unavailable for this project right now; details are in the terminal." };
449
+ }
450
+ },
451
+ async action(input) {
452
+ const args = input as Args;
453
+ try {
454
+ switch (input.action) {
455
+ case "accept": case "reject": case "edit_accept": {
456
+ const found = candidateFor(args);
457
+ if (typeof found === "string") return { ok: false, error: found };
458
+ const hidden = hiddenReason(found);
459
+ if (hidden) return { ok: false, error: hidden };
460
+ if (input.action === "accept") return await accept(found);
461
+ if (input.action === "edit_accept") return editAccept(found, str(args.text));
462
+ withCandidates(found.project, c => c.reject(found.id, { project: found.project, actor: ACTOR, verdict: "public" }));
463
+ undo.set(found.id, { kind: "reject", project: found.project, candidate: found.candidate, until: Date.now() + UNDO_MS });
464
+ // agent-mem stores no reject reason (no new storage, #158 non-goals); the reply says so instead of dropping it silently.
465
+ return { ok: true, message: str(args.reason) ? "Rejected; agent-mem keeps no reject reason, so it was not recorded" : "Rejected", undo: { action: "reject_undo", id: found.id, project: found.project } };
466
+ }
467
+ case "reject_undo": {
468
+ const entry = undoFor(args, "reject");
469
+ if (!entry) return { ok: false, error: "nothing to undo for this candidate" };
470
+ withCandidates(entry.project, c => c.reopen(entry.id, { project: entry.project, actor: ACTOR, ...(entry.candidate ? { restore: entry.candidate } : {}) }));
471
+ return { ok: true, message: "Reject undone; the candidate is pending again" };
472
+ }
473
+ case "accept_undo": {
474
+ const entry = undoFor(args, "accept");
475
+ if (!entry?.noteId) return { ok: false, error: "nothing to undo for this candidate" };
476
+ // Append-only: a forget op hides the note, and a note it replaced shows again. Nothing is deleted.
477
+ const store = openCurated(base, entry.project, { deferPromotions: true });
478
+ try { if (!store.forget(entry.noteId, ACTOR, entry.project, true)) return { ok: false, error: "the accepted note is no longer available" }; }
479
+ finally { store.close(); }
480
+ if (entry.reopen) withCandidates(entry.project, c => c.reopen(entry.id, { project: entry.project, actor: ACTOR, ...(entry.candidate ? { restore: entry.candidate } : {}) }));
481
+ return { ok: true, message: entry.reopen ? "Undone; the note is forgotten and the candidate is pending again" : "Undone; the accepted note is forgotten" };
482
+ }
483
+ case "gc": case "capacity_apply": case "project_forget": case "decay_apply": case "prune": return await destructive(args);
484
+ default: return { ok: false, error: `unknown action ${input.action}` };
485
+ }
486
+ } catch (e) {
487
+ ctx.log(`amem: ${input.action} failed: ${errorText(e)}`);
488
+ return { ok: false, error: errorText(e) };
489
+ }
490
+ },
491
+ };
492
+ },
493
+ };
494
+
495
+ export default plugin;