@staix/agent-mem 0.16.0 → 0.18.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.
@@ -0,0 +1,507 @@
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 { sessionStartBrief } from "../session-brief.ts";
11
+ import { withBriefSnapshot } from "../memory/doctor.ts";
12
+ import { MCP_TOOLS } from "../mcp.ts";
13
+ import { CandidateStore, type Candidate, type CandidateInput } from "../memory/candidates.ts";
14
+ import { candidateOverlapsBatch } from "../memory/candidate-overlaps.ts";
15
+ import { capacityReport } from "../memory/capacity.ts";
16
+ import { readCaptureConfigFile, writeCaptureConfigFile } from "../memory/capture.ts";
17
+ import { readCatalogFresh } from "../memory/catalog.ts";
18
+ import { configuredPolicy, configuredPolicyOptions } from "../memory/config.ts";
19
+ import { openCuratedForRead, requireShard } from "../memory/curated-read.ts";
20
+ import { applyDecay, assertExactDecayForget, decayPreview, DECAY_DEFAULT_DAYS, readDecayCounters } from "../memory/decay-review.ts";
21
+ import { gcText, planGc, runGc } from "../memory/gc.ts";
22
+ import { memoryHome } from "../memory/home.ts";
23
+ import { appendLifecycle, assertProjectActive, projectState } from "../memory/lifecycle.ts";
24
+ import { hasFailLabel, noteAdd, parseFailTemplate } from "../memory/note-add.ts";
25
+ import type { StandalonePolicy } from "../memory/policy.ts";
26
+ import { applyPrune, planPrune, pruneText } from "../memory/prune.ts";
27
+ import { aliasedKeys, readShardedSelection, resolveAlias } from "../memory/shard.ts";
28
+ import { machineId, MemoryStore, openShardStore, type StoreOptions, type View } from "../memory/store.ts";
29
+
30
+ // The contract types this module uses, copied from agent-console src/contract.ts (api 1, additive only): src/ takes no
31
+ // package imports. test/ui-plugin.test.ts type-checks the plugin against the real contract and runs its conformance suite.
32
+ type Args = Record<string, unknown>;
33
+ interface Change { op: "create" | "modify" | "delete" | "log"; target: string; note?: string }
34
+ type ActionResult =
35
+ | ({ ok: true; message?: string; undo?: { action: string } & Args } & Args)
36
+ | { ok: false; error: string }
37
+ | { ok: false; needsConfirm: true; preview: string[]; fingerprint: string; verb?: string; changes?: Change[]; warnings?: string[] };
38
+ interface InboxItem {
39
+ id: string; group: "candidate" | "alert"; kind: string; title: string; detail?: string; project?: string; at: string;
40
+ chip?: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
41
+ verbs: { id: string; label: string; primary?: boolean; reason?: "optional" | "required" }[];
42
+ link: { panel: string; params?: Record<string, string> };
43
+ }
44
+ interface Provider {
45
+ assets: { js: string; css?: string };
46
+ snapshot(input: { after: number; panel: string } & Args): Promise<Args>;
47
+ action(input: { action: string; confirmed?: true; fingerprint?: string } & Args): Promise<ActionResult>;
48
+ }
49
+ type Preview = { verb: string; preview: string[]; changes: Change[]; warnings?: string[] };
50
+ type Found = { id: string; project: string; candidate: Candidate };
51
+
52
+ const asset = (name: string) => readFileSync(new URL(name, import.meta.url), "utf8");
53
+ const VERSION = (JSON.parse(asset("../../package.json")) as { version: string }).version;
54
+ /** agent-mem#143: actions from the console are the person's; the console is never an agent session's surface. */
55
+ const ACTOR = "person" as const;
56
+ const UNDO_MS = 60_000, PLAN_MS = 120_000, PLAN_MAX = 16;
57
+ const ACTIONS = ["accept", "reject", "edit_accept", "accept_undo", "reject_undo", "gc", "capacity_apply", "project_forget", "decay_apply", "prune"];
58
+ const str = (v: unknown): string | undefined => typeof v === "string" && v.trim() ? v : undefined;
59
+ const sha = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).digest("hex");
60
+ const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)).split("\n")[0]!;
61
+ const oneLine = (text: string, max = 160) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
62
+
63
+ /** What a candidate proposes, as text. */
64
+ export function candidateText(input: CandidateInput): string {
65
+ switch (input.kind) {
66
+ case "memory": return input.title ? `${input.title}: ${input.text}` : input.text;
67
+ case "fail": return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nRetry when: ${input.retryWhen}`;
68
+ case "correction": return `${input.proposedText}\n\nReason: ${input.reason}`;
69
+ case "decay": return input.reason;
70
+ case "promotion": return `Promote mem:${input.target.id} to long-term for goal:${input.goalId}`;
71
+ case "consolidation": return `${input.action}: ${input.text}`;
72
+ case "model-answer": return input.text;
73
+ }
74
+ }
75
+ /** The text `review walk`'s edit opens for a memory or fail candidate. */
76
+ function editText(input: CandidateInput): string | null {
77
+ if (input.kind === "fail") return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`;
78
+ if (input.kind !== "memory") return null;
79
+ return input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
80
+ }
81
+ const targetsOf = (input: CandidateInput) => "targets" in input ? input.targets : "target" in input && input.target ? [input.target] : [];
82
+
83
+ /** The CLI's curatedStore on an explicit home (src/cli.ts keeps its own copy; cli.ts is not a library module). */
84
+ function openCurated(base: string, project: string, opts: StoreOptions, reconciled = true): MemoryStore {
85
+ assertProjectActive(base, project);
86
+ const aliases = reconciled ? aliasedKeys(base, project) : [];
87
+ const withAliases: StoreOptions = aliases.length ? { ...opts, reconcile: { canonical: project, aliases } } : opts;
88
+ if (existsSync(join(base, "shards.json"))) { readShardedSelection(base); return openShardStore({ ...withAliases, memoryDir: base, project }); }
89
+ return new MemoryStore({ ...withAliases, memoryDir: base });
90
+ }
91
+
92
+ /** A refusal that names which part of the screen declined, never the text. */
93
+ function refusal(value: unknown, policy: StandalonePolicy): ActionResult {
94
+ let personal = false;
95
+ try { personal = policy.declinesPersonal(JSON.stringify(value)); } catch { /* unreadable: reported as unscreenable */ }
96
+ 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.` };
97
+ }
98
+
99
+ export const plugin = {
100
+ manifest: {
101
+ api: 1 as const, tool: "amem", version: VERSION, title: "Memory", icon: "memory", pollMs: 15_000,
102
+ panels: [
103
+ { id: "review", title: "Review", order: 1 },
104
+ { id: "recap", title: "Recap", order: 2 },
105
+ { id: "find", title: "Search", order: 3 },
106
+ { id: "timeline", title: "Timeline", order: 4 },
107
+ { id: "work", title: "Work lists", order: 5 },
108
+ { id: "goals", title: "Goals", order: 6 },
109
+ { id: "capacity", title: "Capacity", order: 7 },
110
+ ],
111
+ commands: [
112
+ { id: "gc", title: "Evict expired capture rows", hint: "amem gc", action: "gc" },
113
+ { id: "capacity-apply", title: "Apply the capture capacity proposal", hint: "amem capacity apply", action: "capacity_apply" },
114
+ ],
115
+ capabilities: { actions: ACTIONS, confirm: true, multiProject: true, inbox: true, search: true },
116
+ },
117
+ async open(ctx: { home: string; log(line: string): void }): Promise<Provider> {
118
+ const base = memoryHome({ ...process.env, AGENTHUB_HOME: ctx.home });
119
+ // The machine policy of this home. The console runs in no repository, so a repository's `.amem/config` narrowing does not apply here.
120
+ const policy = () => configuredPolicy(base);
121
+ const policyVersion = () => JSON.stringify(configuredPolicyOptions(base));
122
+ const hasCandidates = () => existsSync(join(base, "candidates.db"));
123
+ const withCandidates = <T>(project: string | undefined, run: (store: CandidateStore) => T): T => {
124
+ const aliases = project ? aliasedKeys(base, project) : [];
125
+ const store = new CandidateStore({ memoryDir: base, ...(project && aliases.length ? { reconcile: { canonical: project, aliases } } : {}) });
126
+ try { return run(store); } finally { store.close(); }
127
+ };
128
+
129
+ /** 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. */
130
+ function sharedStoreProjects(): string[] {
131
+ if (existsSync(join(base, "shards.json")) || !["log", "local", "generations"].some(dir => existsSync(join(base, dir)))) return [];
132
+ const store = new MemoryStore({ memoryDir: base, deferPromotions: true });
133
+ // ponytail: reads every live note on each snapshot; keep a per-index-revision cache if a large shared home makes polls slow.
134
+ try { return [...new Set(store.list({ limit: Number.MAX_SAFE_INTEGER }).map(view => view.project))]; } finally { store.close(); }
135
+ }
136
+ // ponytail: the catalog scan walks every shard on each snapshot; cache it by shard mtimes if polls get slow on a large home.
137
+ function projects(): string[] {
138
+ if (!existsSync(base)) return [];
139
+ const keys = new Set<string>(), waiting = new Set<string>();
140
+ try { for (const p of readCatalogFresh(base)) keys.add(p.key); } catch (e) { ctx.log(`amem: project catalog unavailable: ${errorText(e)}`); }
141
+ try { for (const key of sharedStoreProjects()) keys.add(resolveAlias(base, key)); } catch (e) { ctx.log(`amem: shared store projects unavailable: ${errorText(e)}`); }
142
+ if (hasCandidates()) for (const key of withCandidates(undefined, store => store.projects())) { const canonical = resolveAlias(base, key); keys.add(canonical); waiting.add(canonical); }
143
+ return [...keys].filter(key => projectState(base, key) === "active").sort((a, b) => Number(waiting.has(b)) - Number(waiting.has(a)) || a.localeCompare(b));
144
+ }
145
+ // The shell's tabs navigate without params, so the project chosen on one panel is kept for the others and the palette.
146
+ // ponytail: one choice per provider; fine for H0's single person, per session once a console serves several.
147
+ let chosen: string | undefined;
148
+ /** An active project whose curated store holds a note of its own, also when no listing source named it. */
149
+ const exists = (key: string) => {
150
+ if (projectState(base, key) !== "active") return false;
151
+ const store = openCuratedForRead(base, key);
152
+ try { return !!store?.list({ project: key, limit: 1 }).some(view => view.project === key); } finally { store?.close(); }
153
+ };
154
+ const pick = (args: Args, list: string[]) => {
155
+ const asked = str(args.project), key = asked ? resolveAlias(base, asked) : undefined;
156
+ if (key && !list.includes(key) && existsSync(base) && exists(key)) list.push(key);
157
+ if (key && list.includes(key)) chosen = key;
158
+ return chosen && list.includes(chosen) ? chosen : list[0];
159
+ };
160
+
161
+ /** Every option the MCP tool declares is forwarded; nothing is hand-picked. The handler validates and refuses as it does for MCP. */
162
+ const forward = (tool: "amem_search" | "amem_timeline" | "amem_work_state_read", args: Args): Args => {
163
+ const schema = MCP_TOOLS.find(t => t.name === tool)!.inputSchema as { properties: Record<string, { type?: string }> };
164
+ const out: Args = {};
165
+ for (const [key, spec] of Object.entries(schema.properties)) {
166
+ let value = args[key];
167
+ if (value === undefined || value === "") continue;
168
+ if (spec.type === "integer" && typeof value === "string" && /^[0-9]+$/.test(value)) value = Number(value);
169
+ if (spec.type === "boolean" && (value === "true" || value === "false")) value = value === "true";
170
+ out[key] = value;
171
+ }
172
+ return out;
173
+ };
174
+ async function read(project: string, tool: "amem_search" | "amem_timeline" | "amem_work_state_read", args: Args): Promise<unknown> {
175
+ const mcp = createMemoryMcp({ cwd: base, base, project });
176
+ try { return await mcp.tools[tool](forward(tool, args)); } finally { mcp.close(); }
177
+ }
178
+
179
+ /** The CLI's rule for agent-marked review, applied to the person here: the console shows only what passes the screen, targets included. */
180
+ const visible = (candidate: Candidate, project: string, store: MemoryStore | undefined, p: StandalonePolicy): boolean => {
181
+ if (!p.screenStandalone(candidate)) return false;
182
+ const input = candidate.input, targets = targetsOf(input);
183
+ if (!targets.length && input.kind !== "promotion") return true;
184
+ if (!store) return false;
185
+ if (targets.some(t => { const view = store.get(t.id, project); return !view || view.project !== project || !p.screenStandalone(view); })) return false;
186
+ if (input.kind === "promotion") { const goal = store.goalOf(input.goalId, project); if (!goal || !p.screenStandalone(goal)) return false; }
187
+ return true;
188
+ };
189
+ const projectOf = new Map<string, string>();
190
+ async function pending(project: string, detail: boolean) {
191
+ if (!hasCandidates()) return { rows: [], hidden: 0 };
192
+ const p = policy(), store = openCuratedForRead(base, project);
193
+ try {
194
+ const all = withCandidates(project, c => c.list({ project, status: "pending" }));
195
+ const shown = all.filter(c => visible(c, project, store, p));
196
+ for (const c of shown) projectOf.set(c.id, project);
197
+ const overlaps = detail && store ? await candidateOverlapsBatch(shown, store, { project, policy, policyVersion }) : [];
198
+ const rows = shown.map((c, index) => {
199
+ const input = c.input;
200
+ 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 }; });
201
+ return {
202
+ id: c.id, kind: input.kind, createdAt: c.createdAt, text: candidateText(input),
203
+ noteKind: "noteKind" in input ? input.noteKind ?? null : null,
204
+ diff: input.kind === "correction" ? { now: (targets[0] as { text?: string } | undefined)?.text ?? null, proposed: input.proposedText, reason: input.reason } : null,
205
+ evidence: input.kind === "fail" ? input.evidenceRefs : [],
206
+ targets, overlaps: overlaps[index] ?? [],
207
+ acceptable: input.kind !== "decay" && input.kind !== "model-answer",
208
+ edit: editText(input),
209
+ };
210
+ });
211
+ return { rows, hidden: all.length - shown.length };
212
+ } finally { store?.close(); }
213
+ }
214
+
215
+ async function capacity() {
216
+ if (!existsSync(base)) return undefined;
217
+ const report = capacityReport({ memoryDir: base });
218
+ if (!await policy().screen(report)) return { withheld: true as const };
219
+ return { report, applyNeeded: report.current.capMb !== report.proposal.capMb || report.current.retentionDays !== report.proposal.retentionDays };
220
+ }
221
+ // The shell keys snooze and oldest-first order on `at`: an alert keeps the time it was first seen.
222
+ const firstSeen = new Map<string, string>();
223
+ const since = (id: string) => { const at = firstSeen.get(id) ?? new Date().toISOString(); firstSeen.set(id, at); return at; };
224
+ async function inbox(): Promise<InboxItem[]> {
225
+ const items: InboxItem[] = [];
226
+ for (const project of projects()) {
227
+ for (const row of (await pending(project, false)).rows) items.push({
228
+ id: row.id, group: "candidate", kind: row.kind, title: oneLine(row.text, 120), detail: `${row.kind} candidate in ${project}`, project, at: row.createdAt,
229
+ verbs: [...row.acceptable ? [{ id: "accept", label: "Accept", primary: true }] : [], { id: "reject", label: "Reject", reason: "optional" as const }],
230
+ link: { panel: "review", params: { project, id: row.id } },
231
+ });
232
+ }
233
+ const cap = await capacity();
234
+ if (cap && "report" in cap && cap.applyNeeded) {
235
+ const { current, proposal } = cap.report;
236
+ 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`,
237
+ detail: proposal.reason, at: since("capacity:proposal"), chip: { text: "proposal", tone: "warn" }, verbs: [{ id: "capacity_apply", label: "Apply..." }], link: { panel: "capacity" } });
238
+ } else firstSeen.delete("capacity:proposal");
239
+ return items;
240
+ }
241
+
242
+ function searchHit(hit: Args, project: string, q: string) {
243
+ const view = hit.view as View | undefined;
244
+ const capture = hit.capture as { id: number; at: string; event: string; excerpt: string } | undefined;
245
+ const id = view ? (hit.source === "repo" ? `repo:${view.id}` : view.id) : `capture:${capture?.id}`;
246
+ return { id, kind: view ? `${String(hit.source)} ${view.kind}` : `capture ${capture?.event ?? ""}`, title: oneLine(view ? view.title ?? view.text : capture?.excerpt ?? "", 100),
247
+ ...(view?.title ? { detail: oneLine(view.text, 120) } : {}), ...(view?.at ?? capture?.at ? { at: view?.at ?? capture?.at } : {}), link: { panel: "find", params: { project, q } } };
248
+ }
249
+
250
+ async function panel(name: string, args: Args, project: string): Promise<Args> {
251
+ switch (name) {
252
+ case "review": return { ...(await pending(project, true)), selected: str(args.id) ?? null };
253
+ case "recap": {
254
+ const revision = policyVersion(), generatedAt = new Date().toISOString();
255
+ assertProjectActive(base, project);
256
+ const brief = await withBriefSnapshot(base, project, measureOnly => sessionStartBrief({ cwd: base, base, project,
257
+ repository: false, policy: configuredPolicyOptions(base), measureOnly }));
258
+ assertProjectActive(base, project);
259
+ if (policyVersion() !== revision) throw Error("recap policy changed");
260
+ return { brief, generatedAt, repoAbsent: true };
261
+ }
262
+ case "find": {
263
+ const q = str(args.q);
264
+ if (!q) return { q: "", hits: [] };
265
+ return { q, ...(await read(project, "amem_search", { ...args, query: q }) as Args) };
266
+ }
267
+ case "timeline": return { entries: await read(project, "amem_timeline", args) };
268
+ case "work": return { lists: await read(project, "amem_work_state_read", args) };
269
+ case "goals": {
270
+ const store = openCuratedForRead(base, project), p = policy();
271
+ try {
272
+ return { goals: (store?.goals(project) ?? []).filter(goal => p.screenStandalone(goal))
273
+ .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) })) };
274
+ } finally { store?.close(); }
275
+ }
276
+ default: return {};
277
+ }
278
+ }
279
+
280
+ // Undo stays bound to what this console just did: an accepted or edited note to forget, a rejection to withdraw.
281
+ // A rejected candidate is held here too: retention may delete it before the undo arrives, and reopen() writes it back from this copy.
282
+ const undo = new Map<string, { kind: "accept" | "reject"; project: string; noteId?: string; reopen?: boolean; candidate?: Candidate; until: number }>();
283
+ // Previewed prune plans by seal, for the console's confirm window (60 s) plus slack; bounded by the console's own confirm limit.
284
+ const prunePlans = new Map<string, { plan: ReturnType<typeof planPrune>; project: string; cutoff: string; until: number }>();
285
+ function candidateFor(args: Args): Found | string {
286
+ const id = str(args.id);
287
+ if (!id || !/^[a-f0-9]{64}$/.test(id)) return "a full 64-character candidate id is required";
288
+ if (!hasCandidates()) return "candidate unavailable";
289
+ const asked = str(args.project);
290
+ const project = asked ? resolveAlias(base, asked) : projectOf.get(id) ?? projects().find(p => withCandidates(p, c => c.get(id, p)));
291
+ const candidate = project ? withCandidates(project, c => c.get(id, project)) : undefined;
292
+ return project && candidate ? { id, project, candidate } : "candidate unavailable";
293
+ }
294
+ /** The console never acts on a candidate it would not show. */
295
+ function hiddenReason(found: Found): string | undefined {
296
+ if (found.candidate.status !== "pending") return `candidate is ${found.candidate.status}, not pending`;
297
+ const store = openCuratedForRead(base, found.project);
298
+ try { return visible(found.candidate, found.project, store, policy()) ? undefined : "this candidate is withheld by the privacy screen; review it with amem review walk"; }
299
+ finally { store?.close(); }
300
+ }
301
+
302
+ async function accept({ id, project, candidate }: Found): Promise<ActionResult> {
303
+ const p = policy();
304
+ if (candidate.input.kind === "decay") return { ok: false, error: "decay candidates are listing-only; use amem demote or amem forget after review" };
305
+ // `amem review accept`: the current policy screens the proposal, then acceptance screens the note it writes with its target and goal.
306
+ if (!await p.screen(candidate.input)) return refusal(candidate.input, p);
307
+ let declined: unknown;
308
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => policy().screenStandalone(note) ? "promote" : "keep" });
309
+ try {
310
+ const result = withCandidates(project, c => c.accept(id, { project, actor: ACTOR, verdict: "public", store, policyVersion,
311
+ policy: (proposal, target, note, goal) => {
312
+ const value = { ...proposal.input, ...(target ? { targetView: target } : {}), note, ...(goal ? { goalView: goal } : {}) };
313
+ if (policy().screenStandalone(value)) return true;
314
+ declined = value; return false;
315
+ } }));
316
+ // A promotion hides its short-term source; forgetting the copy would not bring that back, so it gets no undo.
317
+ const reversible = candidate.input.kind !== "promotion" && !result.alreadyAccepted;
318
+ if (reversible) undo.set(id, { kind: "accept", project, noteId: result.noteId, until: Date.now() + UNDO_MS });
319
+ return { ok: true, message: `Accepted as note ${result.noteId.slice(0, 8)}`, noteId: result.noteId, ...(reversible ? { undo: { action: "accept_undo", id, project } } : {}) };
320
+ } catch (e) {
321
+ if (declined !== undefined) return refusal(declined, p);
322
+ return { ok: false, error: errorText(e) };
323
+ } finally { store.close(); }
324
+ }
325
+ function editAccept({ id, project, candidate }: Found, text: string | undefined): ActionResult {
326
+ const input = candidate.input;
327
+ if (input.kind !== "memory" && input.kind !== "fail") return { ok: false, error: "edit is for memory and fail candidates; accept or reject this one" };
328
+ if (!text) return { ok: false, error: "the edited text is required" };
329
+ const p = policy(), asFail = input.kind === "fail" || input.noteKind === "fail";
330
+ const title = input.kind === "memory" && input.title ? { title: input.title } : {};
331
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => p.screenStandalone(note) ? "promote" : "keep" });
332
+ let noteId: string;
333
+ try {
334
+ // `review walk`'s edit: the person's own long-term note, then the candidate is closed.
335
+ noteId = noteAdd(store, project, asFail ? { kind: "fail", tier: "long", scope: "project", fail: parseFailTemplate(text), ...title }
336
+ : { kind: input.kind === "memory" ? input.noteKind ?? "finding" : "finding", tier: "long", scope: "project", text, ...title }, p).id;
337
+ } catch (e) {
338
+ return /privacy policy/.test(errorText(e)) ? refusal({ text, ...title }, p) : { ok: false, error: errorText(e) };
339
+ } finally { store.close(); }
340
+ withCandidates(project, c => c.reject(id, { project, actor: ACTOR, verdict: "public" }));
341
+ undo.set(id, { kind: "accept", project, noteId, reopen: true, candidate, until: Date.now() + UNDO_MS });
342
+ return { ok: true, message: `Saved your edit as note ${noteId.slice(0, 8)}; the candidate is closed`, noteId, undo: { action: "accept_undo", id, project } };
343
+ }
344
+ function undoFor(args: Args, kind: "accept" | "reject") {
345
+ const id = str(args.id) ?? "", entry = undo.get(id);
346
+ if (!entry || entry.kind !== kind) return undefined;
347
+ undo.delete(id);
348
+ return entry.until >= Date.now() ? { id, ...entry } : undefined;
349
+ }
350
+
351
+ /** Destructive forms: the first call previews; a write needs the console's confirmation with the same fingerprint, recomputed here. */
352
+ const gate = (input: Args, fingerprint: string, view: Preview): ActionResult | undefined =>
353
+ input.confirmed === true && input.fingerprint === fingerprint ? undefined : { ok: false, needsConfirm: true, fingerprint, ...view };
354
+ function projectArg(args: Args): string {
355
+ const asked = str(args.project);
356
+ if (!asked) throw new Error("project is required");
357
+ const key = resolveAlias(base, asked);
358
+ assertProjectActive(base, key);
359
+ return key;
360
+ }
361
+ async function destructive(input: Args): Promise<ActionResult> {
362
+ if (!existsSync(base)) return { ok: false, error: "no memory home yet" };
363
+ switch (input.action) {
364
+ case "gc": {
365
+ // gc both expires rows and trims the oldest unexpired rows of a shard over its cap: the preview names both.
366
+ const plan = planGc(base), acting = plan.filter(s => s.expiredRows > 0 || s.trimRows > 0);
367
+ const blocked = gate(input, sha(plan), { verb: "Evict capture rows",
368
+ 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"],
369
+ 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(", ") })),
370
+ ...(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."] } : {}) });
371
+ return blocked ?? { ok: true, message: gcText(runGc(base)).split("\n").slice(0, 3).join("; ") };
372
+ }
373
+ case "capacity_apply": {
374
+ const report = capacityReport({ memoryDir: base });
375
+ if (!await policy().screen(report)) return refusal(report, policy());
376
+ const file = join(base, "capture-config.json"), current = readCaptureConfigFile(file);
377
+ const target = { capMb: report.proposal.capMb, retentionDays: report.proposal.retentionDays };
378
+ // `amem capacity apply`: the CLI's preview file is replaced by the console's confirm token, bound to the same values.
379
+ const blocked = gate(input, sha({ file, current, target }), { verb: "Apply capacity", changes: [{ op: existsSync(file) ? "modify" : "create", target: file }],
380
+ preview: [`cap_mb: ${current.capMb ?? "default"} -> ${target.capMb}`, `retention_days: ${current.retentionDays ?? "default"} -> ${target.retentionDays}`, `reason: ${report.proposal.reason}`] });
381
+ if (blocked) return blocked;
382
+ const written = writeCaptureConfigFile(file, target);
383
+ return { ok: true, message: `Capture capacity set to ${written.capMb} MB, ${written.retentionDays} days` };
384
+ }
385
+ case "project_forget": {
386
+ const key = requireShard(base, projectArg(input)), state = projectState(base, key), shard = readCatalogFresh(base).find(p => p.key === key);
387
+ const blocked = gate(input, sha({ key, state }), { verb: "Forget project", changes: [{ op: "log", target: "lifecycle.jsonl", note: `forget ${key}` }],
388
+ 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."],
389
+ warnings: ["This cannot be undone."] });
390
+ if (blocked) return blocked;
391
+ appendLifecycle(base, { op: "forget", key, actor: ACTOR, machine: machineId(base).id });
392
+ return { ok: true, message: `Forgot ${key}` };
393
+ }
394
+ case "decay_apply": {
395
+ const project = projectArg(input), forget = input.forget === true || input.forget === "true";
396
+ const days = input.days === undefined ? DECAY_DEFAULT_DAYS : Number(input.days);
397
+ if (!Number.isSafeInteger(days) || days < 1) return { ok: false, error: "days must be a positive whole number" };
398
+ const aliases = aliasedKeys(base, project);
399
+ const store = openCurated(base, project, { deferPromotions: true, screen: note => policy().screenStandalone(note) ? "promote" : "keep" });
400
+ try {
401
+ const current = { days, aliases, counters: () => readDecayCounters(base, [project, ...aliases]), screen: (view: View) => policy().screenStandalone(view) };
402
+ const plan = decayPreview(store, project, current), action = forget ? "forget" : "demote";
403
+ const blocked = gate(input, sha({ project, action, days, targets: plan.entries.map(e => e.target) }), {
404
+ verb: forget ? "Forget old notes" : "Demote old notes",
405
+ preview: [`${action}: ${plan.entries.length} notes older than ${days} days in ${project}${plan.truncated ? " (truncated)" : ""}; citation history unknown`,
406
+ ...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)}`)],
407
+ changes: plan.entries.map(e => ({ op: forget ? "delete" : "modify", target: `mem:${e.target.id}`, note: forget ? "forget" : `short-term, expires ${plan.demoteExpiresAt}` })),
408
+ ...(plan.withheld ? { warnings: [`${plan.withheld} notes withheld by the privacy screen are left alone`] } : {}) });
409
+ if (blocked) return blocked;
410
+ if (!plan.entries.length) return { ok: true, message: "No eligible notes" };
411
+ if (forget) assertExactDecayForget(store, plan.entries.map(e => e.target.id), project);
412
+ const result = applyDecay(store, plan, { ...current, actor: ACTOR, confirmed: true, forget });
413
+ return { ok: true, message: `${result.action}: ${result.ids.length} notes` };
414
+ } finally { store.close(); }
415
+ }
416
+ case "prune": {
417
+ const project = projectArg(input), cutoff = str(input.cutoff);
418
+ if (!cutoff) return { ok: false, error: "cutoff is required (UTC time)" };
419
+ const store = openCurated(base, project, { deferPromotions: true }, false);
420
+ try {
421
+ const auth = { project, actor: ACTOR, authorize: () => true }, port = store.prunePort();
422
+ // A plan carries its authoring time inside the seal, so a new plan never matches the previewed one. The confirmed call applies
423
+ // the very plan the person saw; applyPrune rechecks it against the current home and refuses a stale one.
424
+ const kept = typeof input.fingerprint === "string" ? prunePlans.get(input.fingerprint) : undefined;
425
+ for (const [seal, entry] of prunePlans) if (entry.until < Date.now()) prunePlans.delete(seal);
426
+ if (input.confirmed === true && kept && kept.until >= Date.now() && kept.project === project && kept.cutoff === cutoff) {
427
+ prunePlans.delete(kept.plan.seal);
428
+ return { ok: true, message: `Pruned: ${applyPrune(port, JSON.stringify(kept.plan), auth).completed.length} rows` };
429
+ }
430
+ const plan = planPrune(port, { cutoff }, auth);
431
+ if (prunePlans.size >= PLAN_MAX) prunePlans.delete(prunePlans.keys().next().value!);
432
+ prunePlans.set(plan.seal, { plan, project, cutoff, until: Date.now() + PLAN_MS });
433
+ return { ok: false, needsConfirm: true, fingerprint: plan.seal, verb: "Prune expired notes", preview: pruneText(plan).split("\n"),
434
+ changes: plan.rows.map(r => ({ op: "log", target: `${r.placement} prune row`, note: `${r.payload.targets.length} targets` })) };
435
+ } finally { store.close(); }
436
+ }
437
+ }
438
+ return { ok: false, error: `unknown action ${String(input.action)}` };
439
+ }
440
+
441
+ return {
442
+ assets: { js: asset("./panel.js"), css: asset("./panel.css") },
443
+ async snapshot(input) {
444
+ const args = input as Args;
445
+ if (input.panel === "inbox") return { items: await inbox() };
446
+ const list = projects(), project = pick(args, list);
447
+ if (input.panel === "search") {
448
+ const q = str(args.q);
449
+ if (!q || !project) return { hits: [], total: 0 };
450
+ const limit = Math.min(10, Math.max(1, Number(args.limit) || 10));
451
+ const result = await read(project, "amem_search", { query: q, limit }) as { hits: Args[] };
452
+ const hits = result.hits.map(hit => searchHit(hit, project, q));
453
+ return { hits, total: hits.length };
454
+ }
455
+ if (input.panel === "capacity") return { projects: list, project: project ?? null, ...(await capacity() ?? { report: null }) };
456
+ if (!project) return { projects: list, project: null };
457
+ try { return { projects: list, project, ...(await panel(input.panel, args, project)) }; }
458
+ catch (e) {
459
+ ctx.log(`amem: ${input.panel} for ${project} failed: ${errorText(e)}`);
460
+ return { projects: list, project, error: "Memory is unavailable for this project right now; details are in the terminal." };
461
+ }
462
+ },
463
+ async action(input) {
464
+ const args = input as Args;
465
+ try {
466
+ switch (input.action) {
467
+ case "accept": case "reject": case "edit_accept": {
468
+ const found = candidateFor(args);
469
+ if (typeof found === "string") return { ok: false, error: found };
470
+ const hidden = hiddenReason(found);
471
+ if (hidden) return { ok: false, error: hidden };
472
+ if (input.action === "accept") return await accept(found);
473
+ if (input.action === "edit_accept") return editAccept(found, str(args.text));
474
+ withCandidates(found.project, c => c.reject(found.id, { project: found.project, actor: ACTOR, verdict: "public" }));
475
+ undo.set(found.id, { kind: "reject", project: found.project, candidate: found.candidate, until: Date.now() + UNDO_MS });
476
+ // agent-mem stores no reject reason (no new storage, #158 non-goals); the reply says so instead of dropping it silently.
477
+ 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 } };
478
+ }
479
+ case "reject_undo": {
480
+ const entry = undoFor(args, "reject");
481
+ if (!entry) return { ok: false, error: "nothing to undo for this candidate" };
482
+ withCandidates(entry.project, c => c.reopen(entry.id, { project: entry.project, actor: ACTOR, ...(entry.candidate ? { restore: entry.candidate } : {}) }));
483
+ return { ok: true, message: "Reject undone; the candidate is pending again" };
484
+ }
485
+ case "accept_undo": {
486
+ const entry = undoFor(args, "accept");
487
+ if (!entry?.noteId) return { ok: false, error: "nothing to undo for this candidate" };
488
+ // Append-only: a forget op hides the note, and a note it replaced shows again. Nothing is deleted.
489
+ const store = openCurated(base, entry.project, { deferPromotions: true });
490
+ try { if (!store.forget(entry.noteId, ACTOR, entry.project, true)) return { ok: false, error: "the accepted note is no longer available" }; }
491
+ finally { store.close(); }
492
+ if (entry.reopen) withCandidates(entry.project, c => c.reopen(entry.id, { project: entry.project, actor: ACTOR, ...(entry.candidate ? { restore: entry.candidate } : {}) }));
493
+ return { ok: true, message: entry.reopen ? "Undone; the note is forgotten and the candidate is pending again" : "Undone; the accepted note is forgotten" };
494
+ }
495
+ case "gc": case "capacity_apply": case "project_forget": case "decay_apply": case "prune": return await destructive(args);
496
+ default: return { ok: false, error: `unknown action ${input.action}` };
497
+ }
498
+ } catch (e) {
499
+ ctx.log(`amem: ${input.action} failed: ${errorText(e)}`);
500
+ return { ok: false, error: errorText(e) };
501
+ }
502
+ },
503
+ };
504
+ },
505
+ };
506
+
507
+ export default plugin;