@staix/agent-mem 0.20.2 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +4 -3
  3. package/package.json +2 -2
  4. package/src/cli.ts +8 -1
  5. package/src/commands-table.ts +2 -1
  6. package/src/hook-spool.ts +45 -3
  7. package/src/index.ts +25 -2
  8. package/src/memory/bridge.ts +66 -18
  9. package/src/memory/candidates.ts +71 -9
  10. package/src/memory/generation-index.ts +95 -18
  11. package/src/memory/generation-sync.ts +3 -0
  12. package/src/memory/generation.ts +33 -15
  13. package/src/memory/platform.ts +38 -3
  14. package/src/memory/policy.ts +28 -15
  15. package/src/memory/prune-publisher-lineage.ts +50 -12
  16. package/src/memory/public-prune-advance.ts +11 -5
  17. package/src/memory/public-prune-inputs.ts +17 -16
  18. package/src/memory/shard-sync-applications.ts +153 -0
  19. package/src/memory/shard-sync-format.ts +15 -0
  20. package/src/memory/shard-sync-graph.ts +6 -4
  21. package/src/memory/shard-sync-history.ts +238 -0
  22. package/src/memory/shard-sync.ts +67 -151
  23. package/src/memory/shard.ts +32 -6
  24. package/src/memory/store.ts +14 -1
  25. package/src/memory/sync-binding.ts +63 -0
  26. package/src/memory/sync-placement-active.ts +75 -0
  27. package/src/memory/sync-placement-cache.ts +32 -0
  28. package/src/memory/sync-placement-capture.ts +292 -0
  29. package/src/memory/sync-placement-dependencies.ts +255 -0
  30. package/src/memory/sync-placement-format.ts +182 -0
  31. package/src/memory/sync-placement-git.ts +95 -0
  32. package/src/memory/sync-placement-guard.ts +98 -0
  33. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  34. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  35. package/src/memory/sync-placement-inspection.ts +236 -0
  36. package/src/memory/sync-placement-journal-format.ts +114 -0
  37. package/src/memory/sync-placement-journal-storage.ts +513 -0
  38. package/src/memory/sync-placement-journal.ts +153 -0
  39. package/src/memory/sync-placement-materialize.ts +149 -0
  40. package/src/memory/sync-placement-plan.ts +171 -0
  41. package/src/memory/sync-placement-projection.ts +79 -0
  42. package/src/memory/sync-placement-publication-plan.ts +189 -0
  43. package/src/memory/sync-placement-publication-storage.ts +87 -0
  44. package/src/memory/sync-placement-transport.ts +489 -0
  45. package/src/memory/sync-policy-inspection.ts +126 -0
  46. package/src/memory/sync-screen.ts +128 -33
  47. package/src/memory/sync.ts +20 -64
  48. package/src/setup.ts +69 -14
  49. package/src/ui/inbox-cache.ts +219 -0
  50. package/src/ui/inbox-worker-version.ts +14 -0
  51. package/src/ui/inbox-worker.ts +32 -0
  52. package/src/ui/inbox.ts +281 -0
  53. package/src/ui/panel.js +7 -3
  54. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,281 @@
1
+ /** Compact inbox computation. Run in the background worker, never on the console request loop. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, opendirSync, readSync } from "node:fs";
4
+ import { join, relative } from "node:path";
5
+ import { withPendingCandidates, type Candidate, type CandidateInput } from "../memory/candidates.ts";
6
+ import { capacityReport } from "../memory/capacity.ts";
7
+ import { configuredPolicy, configuredPolicyOptions } from "../memory/config.ts";
8
+ import { openCuratedForRead } from "../memory/curated-read.ts";
9
+ import { lifecycleResolver } from "../memory/lifecycle.ts";
10
+ import { memoryPolicySnapshot, type StandalonePolicy } from "../memory/policy.ts";
11
+ import { DENY_NAMES, DENY_SEGMENTS } from "../memory/platform.ts";
12
+ import { aliasedKeys, effectiveAliases } from "../memory/shard.ts";
13
+ import type { MemoryStore } from "../memory/store.ts";
14
+ import { checkedEntry } from "../memory/bridge.ts";
15
+ import { readProfileJSON, readSelector, generationDir } from "../memory/generation.ts";
16
+ import { shardDir } from "../memory/shard.ts";
17
+ import { readPublicPruneViewForActivation } from "../memory/public-prune-view.ts";
18
+
19
+ export interface InboxItem {
20
+ id: string; group: "candidate" | "alert"; kind: string; title: string; detail?: string; project?: string; at: string;
21
+ chip?: { text: string; tone: "ok" | "warn" | "err" | "neutral" };
22
+ verbs: { id: string; label: string; primary?: boolean; reason?: "optional" | "required" }[];
23
+ link: { panel: string; params?: Record<string, string> };
24
+ }
25
+ export interface InboxDefaults { names: string[]; segments: string[] }
26
+ export function inboxPolicyDefaults(): InboxDefaults {
27
+ const defaults = memoryPolicySnapshot({ names: DENY_NAMES, segments: DENY_SEGMENTS });
28
+ if (!defaults || !Array.isArray(defaults.names) || !Array.isArray(defaults.segments)
29
+ || !defaults.names.every(value => typeof value === "string") || !defaults.segments.every(value => typeof value === "string")) throw Error("inbox policy is unavailable");
30
+ return defaults;
31
+ }
32
+ export function inboxPolicyVersion(base: string): string {
33
+ return createHash("sha256").update(JSON.stringify({ options: configuredPolicyOptions(base), defaults: inboxPolicyDefaults() })).digest("hex");
34
+ }
35
+ export const INBOX_MAX_ITEMS = 50, INBOX_MAX_BYTES = 64 * 1024;
36
+ export const CAPACITY_CACHE_MS = 5 * 60_000;
37
+ const oneLine = (text: string, max = 120) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
38
+ export function candidateText(input: CandidateInput): string {
39
+ switch (input.kind) {
40
+ case "memory": return input.title ? `${input.title}: ${input.text}` : input.text;
41
+ case "fail": return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nRetry when: ${input.retryWhen}`;
42
+ case "correction": return `${input.proposedText}\n\nReason: ${input.reason}`;
43
+ case "decay": return input.reason;
44
+ case "promotion": return `Promote mem:${input.target.id} to long-term for goal:${input.goalId}`;
45
+ case "consolidation": return `${input.action}: ${input.text}`;
46
+ case "model-answer": return input.text;
47
+ }
48
+ }
49
+ export const candidateTargets = (input: CandidateInput) => "targets" in input ? input.targets : "target" in input && input.target ? [input.target] : [];
50
+ /** Shared with Review: the candidate, every target, and the promotion goal must pass current policy. */
51
+ export function visibleCandidate(candidate: Candidate, project: string, store: MemoryStore | undefined, policy: StandalonePolicy, onExpiry?: (at: number) => void): boolean {
52
+ if (!policy.screenStandalone(candidate)) return false;
53
+ const input = candidate.input, targets = candidateTargets(input);
54
+ if (!targets.length && input.kind !== "promotion") return true;
55
+ if (!store) return false;
56
+ let expiresAt: number | undefined;
57
+ for (const target of targets) {
58
+ const view = store.get(target.id, project);
59
+ if (!view || view.project !== project || !policy.screenStandalone(view)) return false;
60
+ const at = view.expiresAt ? Date.parse(view.expiresAt) : Number.NaN;
61
+ if (Number.isFinite(at) && at > Date.now()) expiresAt = expiresAt === undefined ? at : Math.min(expiresAt, at);
62
+ }
63
+ if (input.kind === "promotion") { const goal = store.goalOf(input.goalId, project); if (!goal || !policy.screenStandalone(goal)) return false; }
64
+ if (expiresAt !== undefined) onExpiry?.(expiresAt);
65
+ return true;
66
+ }
67
+ export interface CapacityMemo { policy: string; until: number; item: InboxItem | null; captureConfig: string }
68
+ export function inboxCaptureConfigVersion(base: string): string { return JSON.stringify(state(base, "capture-config.json")); }
69
+ export interface InboxComputation { items: InboxItem[]; more: number; policy: string; capacity: CapacityMemo; expiresAt: number | null }
70
+ const order = (a: InboxItem, b: InboxItem) => Number(b.group === "alert") - Number(a.group === "alert") || b.at.localeCompare(a.at) || a.id.localeCompare(b.id);
71
+ /** Only row-rendered fields cross the worker boundary; details stay in the item's panel. */
72
+ export function computeInbox(base: string, previous?: CapacityMemo, beforeProject?: (project: string) => void): InboxComputation {
73
+ const revision = inboxPolicyVersion(base), policy = configuredPolicy(base), active = lifecycleResolver(base), aliases = effectiveAliases(base);
74
+ const items: InboxItem[] = []; let count = 0, expiresAt: number | null = null;
75
+ const add = (item: InboxItem) => {
76
+ if (!policy.screenStandalone(item)) return;
77
+ count++; items.push(item); items.sort(order); if (items.length > INBOX_MAX_ITEMS) items.pop();
78
+ };
79
+ withPendingCandidates(base, reader => {
80
+ expiresAt = reader.nextExpiry();
81
+ const projects = [...new Set(reader.projects().map(project => aliases.get(project) ?? project))].filter(project => active(project) === "active").sort();
82
+ const reuseShared = !existsSync(join(base, "shards.json")) && aliases.size === 0;
83
+ let shared: MemoryStore | undefined, sharedOpened = false;
84
+ try {
85
+ for (const project of projects) {
86
+ let store: MemoryStore | undefined, opened = false;
87
+ try {
88
+ for (const candidate of reader.list([project, ...aliasedKeys(base, project)])) {
89
+ if (!policy.screenStandalone(candidate)) continue;
90
+ if (!opened && (candidateTargets(candidate.input).length || candidate.input.kind === "promotion")) {
91
+ beforeProject?.(project);
92
+ if (reuseShared) {
93
+ if (!sharedOpened) { shared = openCuratedForRead(base, project); sharedOpened = true; }
94
+ store = shared;
95
+ } else store = openCuratedForRead(base, project);
96
+ opened = true;
97
+ }
98
+ if (!visibleCandidate(candidate, project, store, policy, at => { expiresAt = expiresAt === null ? at : Math.min(expiresAt, at); })) continue;
99
+ const kind = candidate.input.kind;
100
+ add({ id: candidate.id, group: "candidate", kind, title: oneLine(candidateText(candidate.input)), project, at: candidate.createdAt,
101
+ verbs: [...kind !== "decay" && kind !== "model-answer" ? [{ id: "accept", label: "Accept", primary: true }] : [], { id: "reject", label: "Reject", reason: "optional" }],
102
+ link: { panel: "review", params: { project, id: candidate.id } } });
103
+ }
104
+ } finally { if (!reuseShared) store?.close(); }
105
+ }
106
+ } finally { shared?.close(); }
107
+ });
108
+ let capacity = previous;
109
+ const captureConfig = inboxCaptureConfigVersion(base);
110
+ // ponytail: resource advice is cached for at most five minutes. The Capacity panel/action still computes fresh
111
+ // advice; incremental capture counters and bounded asynchronous native readings are the upgrade path.
112
+ if (!capacity || capacity.policy !== revision || capacity.captureConfig !== captureConfig || Date.now() >= capacity.until) {
113
+ let item: InboxItem | null = null;
114
+ if (existsSync(base)) {
115
+ const report = capacityReport({ memoryDir: base });
116
+ if (policy.screenStandalone(report) && (report.current.capMb !== report.proposal.capMb || report.current.retentionDays !== report.proposal.retentionDays)) {
117
+ item = { id: "capacity:proposal", group: "alert", kind: "capacity", title: oneLine(`Capture capacity: ${report.current.capMb} MB / ${report.current.retentionDays} d, proposed ${report.proposal.capMb} MB / ${report.proposal.retentionDays} d`),
118
+ detail: oneLine(report.proposal.reason, 160), at: new Date().toISOString(), chip: { text: "proposal", tone: "warn" },
119
+ verbs: [{ id: "capacity_apply", label: "Apply..." }], link: { panel: "capacity" } };
120
+ }
121
+ }
122
+ capacity = { policy: revision, until: Date.now() + CAPACITY_CACHE_MS, item, captureConfig };
123
+ }
124
+ if (capacity.item) add(capacity.item);
125
+ const bounded = boundedInboxRows(items, count);
126
+ if (inboxPolicyVersion(base) !== revision) throw Error("inbox policy changed");
127
+ return { ...bounded, policy: revision, capacity, expiresAt };
128
+ }
129
+
130
+ /** totalCount counts real visible capacity/candidate rows, never the synthetic overflow hint. */
131
+ export function boundedInboxRows(input: readonly InboxItem[], totalCount: number): { items: InboxItem[]; more: number } {
132
+ const previous = input.find(item => item.id === "inbox:more"), real = input.filter(item => item.id !== "inbox:more").sort(order);
133
+ if (!Number.isSafeInteger(totalCount) || totalCount < real.length) throw Error("inbox overflow count is invalid");
134
+ const at = previous?.at ?? new Date().toISOString();
135
+ const make = () => {
136
+ const more = totalCount - real.length;
137
+ const hint: InboxItem = { id: "inbox:more", group: "alert", kind: "overflow", title: `${more} more in Review`, at, verbs: [], link: { panel: "review" } };
138
+ const capacity = real.filter(item => item.id === "capacity:proposal"), other = real.filter(item => item.id !== "capacity:proposal");
139
+ return { items: [...capacity, ...(more ? [hint] : []), ...other], more };
140
+ };
141
+ while (real.length > INBOX_MAX_ITEMS || totalCount > real.length && real.length >= INBOX_MAX_ITEMS) real.pop();
142
+ let result = make();
143
+ while ((result.items.length > INBOX_MAX_ITEMS || Buffer.byteLength(JSON.stringify(result)) > INBOX_MAX_BYTES - 512) && real.length) { real.pop(); result = make(); }
144
+ if (result.items.length > INBOX_MAX_ITEMS || Buffer.byteLength(JSON.stringify(result)) > INBOX_MAX_BYTES - 512) throw Error("inbox rows exceed their bound");
145
+ return result;
146
+ }
147
+
148
+ export interface InboxSourceState {
149
+ kind: "missing" | "file" | "directory";
150
+ category: "fixed" | "candidate" | "log" | "metadata";
151
+ dev?: string; ino?: string; mode?: string; size?: number; mtimeNs?: string; ctimeNs?: string; names?: string[];
152
+ }
153
+ export interface InboxSources { paths: string[]; roots: string[]; states: Record<string, InboxSourceState>; version: string }
154
+ const MAX_SOURCES = 16_384, MAX_TAIL_BYTES = 256 * 1024;
155
+ const fixedFiles = ["amem.config.json", "capture-config.json", "formats.json", "profile.json", "shards.json", "aliases.jsonl", "lifecycle.jsonl", "profile.lock", "public-prune-view.intent.json", "candidates.db", "candidates.db-wal"];
156
+ const logDirectory = (value: string) => /(?:^|\/)(?:log|local)$/.test(value);
157
+ const evidenceDirectory = (value: string) => /(?:^|\/)(?:public-prune-objects|public-prune-inputs|public-prune-input-sets|bridge-snapshots)(?:\/|$)/.test(value);
158
+ function validPath(value: string): void {
159
+ if (typeof value !== "string" || value.startsWith("/") || Buffer.byteLength(value) > 4096 || value !== "" && value.split("/").some(part => !part || part === "." || part === "..")) throw Error("inbox source inventory is invalid");
160
+ }
161
+ function category(value: string): InboxSourceState["category"] {
162
+ return value === "candidates.db" || value === "candidates.db-wal" ? "candidate" : fixedFiles.includes(value) || value === "" ? "fixed"
163
+ : evidenceDirectory(value) ? "metadata" : /(?:^|\/)(?:log|local)\/[^/]+[.]jsonl$/.test(value) ? "log" : "metadata";
164
+ }
165
+ function state(base: string, path: string): InboxSourceState {
166
+ validPath(path); const stat = lstatSync(join(base, path), { bigint: true, throwIfNoEntry: false }), type = category(path);
167
+ if (!stat || path === "candidates.db-wal" && stat.size === 0n) return { kind: "missing", category: type };
168
+ if (stat.isSymbolicLink() || !stat.isDirectory() && !stat.isFile()) throw Error("inbox source is unavailable");
169
+ return { kind: stat.isDirectory() ? "directory" : "file", category: type, dev: String(stat.dev), ino: String(stat.ino), mode: String(stat.mode),
170
+ ...(stat.isFile() ? { size: Number(stat.size), mtimeNs: String(stat.mtimeNs), ctimeNs: String(stat.ctimeNs) } : {}) };
171
+ }
172
+ function snapshotSources(base: string, roots: readonly string[], budget = { left: MAX_SOURCES }): InboxSources {
173
+ if (roots.length > MAX_SOURCES || new Set(roots).size !== roots.length) throw Error("inbox source inventory exceeds its bound");
174
+ const states: Record<string, InboxSourceState> = Object.create(null), traversed = new Set<string>(); let visited = 0;
175
+ const record = (path: string) => {
176
+ if (!Object.hasOwn(states, path)) { if (++visited > MAX_SOURCES) throw Error("inbox source inventory exceeds its bound"); states[path] = state(base, path); }
177
+ return states[path]!;
178
+ };
179
+ const visit = (path: string, recursive = false) => {
180
+ validPath(path); const parts = path.split("/"); for (let index = 1; index < parts.length; index++) record(parts.slice(0, index).join("/"));
181
+ const current = record(path);
182
+ if (current.kind !== "directory" || traversed.has(path) || !logDirectory(path) && !evidenceDirectory(path) && !recursive) return;
183
+ traversed.add(path);
184
+ const iterator = opendirSync(join(base, path)), names: string[] = [];
185
+ try { for (;;) { const entry = iterator.readSync(); if (!entry) break; if (--budget.left < 0) throw Error("inbox source inventory exceeds its bound");
186
+ if (!recursive && logDirectory(path) && !entry.name.endsWith(".jsonl")) continue;
187
+ names.push(entry.name); visit(path ? `${path}/${entry.name}` : entry.name, recursive || evidenceDirectory(path));
188
+ } } finally { iterator.closeSync(); }
189
+ current.names = names.sort();
190
+ };
191
+ visit(""); for (const file of fixedFiles) visit(file); for (const root of roots) { validPath(root); visit(root); }
192
+ const paths = Object.keys(states).sort(), ordered: Record<string, InboxSourceState> = Object.create(null); for (const path of paths) ordered[path] = states[path]!;
193
+ return { paths, roots: [...roots].sort(), states: ordered, version: createHash("sha256").update(JSON.stringify(ordered)).digest("hex") };
194
+ }
195
+ export function inboxSources(base: string, roots: readonly string[] = []): InboxSources { return snapshotSources(base, roots); }
196
+ /** Compatibility helper for existing metadata-only fixtures; production uses inboxSourceChange with the complete frame. */
197
+ export function inboxSourceVersion(base: string, paths: readonly string[]): string {
198
+ return snapshotSources(base, paths.filter(path => path !== "" && !fixedFiles.includes(path))).version;
199
+ }
200
+ function plainNoteTail(base: string, path: string, before: InboxSourceState | undefined, after: InboxSourceState, budget: { left: number }): boolean {
201
+ const offset = before?.kind === "file" ? before.size! : 0;
202
+ if (after.kind !== "file" || before && before.kind !== "missing" && (before.kind !== "file" || before.dev !== after.dev || before.ino !== after.ino || before.mode !== after.mode)
203
+ || !Number.isSafeInteger(offset) || !Number.isSafeInteger(after.size) || after.size! <= offset || after.size! - offset > budget.left) return false;
204
+ const length = after.size! - offset, file = join(base, path), fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
205
+ try {
206
+ const opened = fstatSync(fd, { bigint: true });
207
+ if (String(opened.dev) !== after.dev || String(opened.ino) !== after.ino || Number(opened.size) !== after.size || String(opened.mtimeNs) !== after.mtimeNs || String(opened.ctimeNs) !== after.ctimeNs) return false;
208
+ const bytes = Buffer.alloc(length); let at = 0; while (at < length) { const count = readSync(fd, bytes, at, length - at, offset + at); if (!count) return false; at += count; }
209
+ budget.left -= length;
210
+ const current = fstatSync(fd, { bigint: true }); if (String(current.ctimeNs) !== after.ctimeNs || Number(current.size) !== after.size || bytes.at(-1) !== 10) return false;
211
+ for (const line of new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes).split("\n").slice(0, -1)) {
212
+ const row = checkedEntry(JSON.parse(line));
213
+ if (row.op !== "note" || row.supersedes !== undefined || row.promotedFrom !== undefined) return false;
214
+ }
215
+ return true;
216
+ } catch { return false; } finally { closeSync(fd); }
217
+ }
218
+ /** Classifies only prior screened data for stale serving, never admits a new body.
219
+ * ponytail: large-file append classification trusts the repository's append-only writer invariant. A same-user
220
+ * raw prefix rewrite plus plain-note suffix is outside that ceiling; a core append-revision proof is the upgrade.
221
+ * Only the complete new tail (256KiB total) is read on the request loop; the worker fully revalidates sources. */
222
+ export function inboxSourceChange(base: string, previous: InboxSources): { sources: InboxSources; changed: boolean; hard: boolean } {
223
+ const sources = snapshotSources(base, previous.roots); if (sources.version === previous.version) return { sources, changed: false, hard: false };
224
+ let hard = false; const budget = { left: MAX_TAIL_BYTES }, changes = new Set([...previous.paths, ...sources.paths]);
225
+ for (const path of changes) {
226
+ const before = previous.states[path], after = sources.states[path]; if (JSON.stringify(before) === JSON.stringify(after)) continue;
227
+ if (after?.category === "candidate" && (path === "candidates.db-wal" && (after.kind === "missing" || after.kind === "file") && (!before || before.kind === "missing" || before.kind === "file")
228
+ || (!before || before.kind === "missing") && after.kind === "file"
229
+ || before?.kind === "file" && after.kind === "file" && before.dev === after.dev && before.ino === after.ino && before.mode === after.mode && after.size! >= before.size!)) continue;
230
+ if (after?.category === "log" && plainNoteTail(base, path, before, after, budget)) continue;
231
+ // A log namespace may grow only when every added file is itself a verified plain-note tail.
232
+ if (before?.kind === "directory" && after?.kind === "directory" && logDirectory(path) && before.dev === after.dev && before.ino === after.ino && before.mode === after.mode
233
+ && (before.names ?? []).every(name => after.names?.includes(name))) continue;
234
+ hard = true;
235
+ }
236
+ return { sources, changed: true, hard };
237
+ }
238
+ function projectRoots(base: string, project: string): string[] {
239
+ const roots = new Set<string>(), selected = readSelector(base).selector;
240
+ const view = selected?.schema === "agent-mem.profile/v2" || selected?.schema === "agent-mem.profile/v3"
241
+ ? readPublicPruneViewForActivation(base, selected.pruneInputSetHash, selected.generation) : undefined;
242
+ const add = (value: string) => { const path = relative(base, value); validPath(path); roots.add(path); };
243
+ add(join(base, "machine.json"));
244
+ const raw = (dir: string) => { add(dir); for (const folder of ["log", "local"]) add(join(dir, folder)); };
245
+ const metadata = (dir: string) => { for (const name of ["project.json", "generation.json", "generation-seal.json", "migration-sources.json", "machine.json", "prune-translations.jsonl"]) add(join(dir, name)); };
246
+ const bridge = (dir: string, generation: string) => {
247
+ const pointer = join(dir, "generations", generation, "bridge-snapshot.json"); add(pointer);
248
+ const value = readProfileJSON(pointer, 4096) as { commit?: unknown } | undefined;
249
+ if (value?.commit !== undefined) { if (typeof value.commit !== "string" || value.commit.length !== 40 || !/^[a-f0-9]+$/.test(value.commit)) throw Error("inbox bridge source is invalid"); add(join(dir, "generations", generation, "bridge-snapshots", value.commit)); }
250
+ };
251
+ if (existsSync(join(base, "shards.json"))) {
252
+ const keys = [project, ...aliasedKeys(base, project)];
253
+ for (const { dir, key } of [{ dir: join(base, "user"), key: "user" }, ...keys.map(key => ({ dir: shardDir(base, key), key: `project:${key}` }))]) {
254
+ raw(dir); metadata(dir);
255
+ for (const legacy of [join(dir, "legacy"), join(dir, "legacy-main")]) { raw(legacy); add(join(legacy, "machine.json")); }
256
+ if (view?.realms[key]) for (const folder of ["public-prune-objects", "public-prune-inputs", "public-prune-input-sets"]) add(join(dir, folder));
257
+ }
258
+ } else {
259
+ raw(base);
260
+ if (selected?.kind === "generation" && selected.generation) { const dir = generationDir(base, selected.generation); raw(dir); metadata(dir); bridge(base, selected.generation); }
261
+ }
262
+ if (selected?.schema === "agent-mem.profile/v2" || selected?.schema === "agent-mem.profile/v3") add(join(base, "public-prune-input-sets", `${selected.pruneInputSetHash}.json`));
263
+ if (selected?.schema === "agent-mem.profile/v3") {
264
+ add(join(base, "sync-placement", "active.json"));
265
+ for (const name of ["epoch.json", "activation.json"]) add(join(base, "sync-placement", "epochs", selected.placementHead, name));
266
+ }
267
+ return [...roots].sort();
268
+ }
269
+ export function createInboxSourceTracker(base: string) {
270
+ const initial = snapshotSources(base, []), frames: InboxSources[] = [], roots = new Set<string>(), projects = new Set<string>(), budget = { left: MAX_SOURCES };
271
+ return { beforeProject(project: string) {
272
+ if (projects.has(project)) return; projects.add(project); const added = projectRoots(base, project).filter(path => !roots.has(path));
273
+ for (const path of added) roots.add(path); if (added.length) frames.push(snapshotSources(base, added, budget));
274
+ }, before(): InboxSources {
275
+ const states: Record<string, InboxSourceState> = Object.assign(Object.create(null), initial.states);
276
+ for (const frame of frames) for (const path of frame.paths) if (!Object.hasOwn(states, path)) states[path] = frame.states[path]!;
277
+ const paths = Object.keys(states).sort(); if (paths.length > MAX_SOURCES) throw Error("inbox source inventory exceeds its bound");
278
+ const ordered: Record<string, InboxSourceState> = Object.create(null); for (const path of paths) ordered[path] = states[path]!;
279
+ return { paths, roots: [...roots].sort(), states: ordered, version: createHash("sha256").update(JSON.stringify(ordered)).digest("hex") };
280
+ }, after() { return snapshotSources(base, [...roots]); } };
281
+ }
package/src/ui/panel.js CHANGED
@@ -6,16 +6,17 @@ export default (host) => {
6
6
  host.mount.append(root);
7
7
  let panel = "review", params = {}, project = null;
8
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 });
9
+ const btn = (text, onclick, cls = "btn") => ui.button(text, onclick, { ...(cls.includes("primary") ? { tone: "primary" } : cls.includes("danger") ? { tone: "danger" } : {}) });
10
10
  const day = (iso) => (typeof iso === "string" ? iso.slice(0, 16).replace("T", " ") : "");
11
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
12
  const act = (input) => host.action({ ...input, ...(project ? { project } : {}) });
13
13
 
14
14
  const picker = (s) => {
15
15
  const list = s.projects ?? [];
16
+ if (s.error && !s.project && list.length === 1) return btn(`Choose project ${list[0]}`, () => go(panel, { project: list[0] }));
16
17
  if (list.length < 2) return list.length ? h("p", { class: "amem-sub", text: `Project ${list[0]}` }) : null;
17
18
  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
+ [...(!s.project ? [h("option", { value: "", text: "Choose an active project", disabled: true, selected: true })] : []), ...list.map((key) => { const o = h("option", { value: key, text: key }); if (key === s.project) o.selected = true; return o; })]);
19
20
  return h("label", { class: "amem-bar" }, h("span", { text: "Project" }), select);
20
21
  };
21
22
 
@@ -33,7 +34,10 @@ export default (host) => {
33
34
  h("p", { class: "amem-sub", text: `Reason: ${r.diff.reason}` })) });
34
35
  const evidence = [...(r.evidence ?? []).map((ref) => ({ title: ref, sub: "evidence reference" })),
35
36
  ...(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: `Evidence (${evidence.length})`, open: evidence.length > 0, body: h("div", {},
38
+ ui.rows(evidence, (e) => ui.row(e)) ?? ui.empty("No evidence attached", "This kind of candidate carries no references."),
39
+ ...(r.targets ?? []).filter((t) => typeof t.text === "string").map((t) => h("blockquote", {}, h("pre", { class: "amem-pre", text: t.text }))),
40
+ h("p", { class: "amem-sub", text: "Source session, proposing agent and times seen are not recorded in the candidate store." })) });
37
41
  sections.push({ title: `Similar memories (${r.overlaps.length})`, open: r.overlaps.length > 0,
38
42
  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
43
  if (r.edit !== null) {
package/src/ui/plugin.ts CHANGED
@@ -4,6 +4,9 @@
4
4
  * Review writes run the same library calls as `amem review accept|reject` and `review walk`'s edit, stamped as the person.
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
+ import { InboxCache } from "./inbox-cache.ts";
8
+ import { candidateText, candidateTargets as targetsOf, visibleCandidate as visible } from "./inbox.ts";
9
+ export { candidateText } from "./inbox.ts";
7
10
  import { existsSync, readFileSync } from "node:fs";
8
11
  import { join } from "node:path";
9
12
  import { createMemoryMcp } from "../mcp-memory.ts";
@@ -35,14 +38,9 @@ type ActionResult =
35
38
  | ({ ok: true; message?: string; undo?: { action: string } & Args } & Args)
36
39
  | { ok: false; error: string }
37
40
  | { 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
41
  interface Provider {
45
42
  assets: { js: string; css?: string };
43
+ close(): Promise<void>;
46
44
  snapshot(input: { after: number; panel: string } & Args): Promise<Args>;
47
45
  action(input: { action: string; confirmed?: true; fingerprint?: string } & Args): Promise<ActionResult>;
48
46
  }
@@ -60,25 +58,12 @@ const sha = (v: unknown) => createHash("sha256").update(JSON.stringify(v)).diges
60
58
  const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e)).split("\n")[0]!;
61
59
  const oneLine = (text: string, max = 160) => { const flat = text.replace(/\s+/g, " ").trim(); return flat.length > max ? `${flat.slice(0, max - 3)}...` : flat; };
62
60
 
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
61
  /** The text `review walk`'s edit opens for a memory or fail candidate. */
76
62
  function editText(input: CandidateInput): string | null {
77
63
  if (input.kind === "fail") return `Approach: ${input.approach}\nSymptom: ${input.symptom}\nCause: ${input.cause}\nEvidence: ${input.evidenceRefs.join(", ")}\nRetry when: ${input.retryWhen}\n`;
78
64
  if (input.kind !== "memory") return null;
79
65
  return input.noteKind === "fail" && !hasFailLabel(input.text) ? `Approach: ${input.text}\nSymptom: \nCause: \nEvidence: \nRetry when: \n` : input.text;
80
66
  }
81
- const targetsOf = (input: CandidateInput) => "targets" in input ? input.targets : "target" in input && input.target ? [input.target] : [];
82
67
 
83
68
  /** The CLI's curatedStore on an explicit home (src/cli.ts keeps its own copy; cli.ts is not a library module). */
84
69
  function openCurated(base: string, project: string, opts: StoreOptions, reconciled = true): MemoryStore {
@@ -154,7 +139,8 @@ export const plugin = {
154
139
  const pick = (args: Args, list: string[]) => {
155
140
  const asked = str(args.project), key = asked ? resolveAlias(base, asked) : undefined;
156
141
  if (key && !list.includes(key) && existsSync(base) && exists(key)) list.push(key);
157
- if (key && list.includes(key)) chosen = key;
142
+ if (key && !list.includes(key)) return undefined;
143
+ if (key) chosen = key;
158
144
  return chosen && list.includes(chosen) ? chosen : list[0];
159
145
  };
160
146
 
@@ -176,16 +162,6 @@ export const plugin = {
176
162
  try { return await mcp.tools[tool](forward(tool, args)); } finally { mcp.close(); }
177
163
  }
178
164
 
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
165
  const projectOf = new Map<string, string>();
190
166
  async function pending(project: string, detail: boolean) {
191
167
  if (!hasCandidates()) return { rows: [], hidden: 0 };
@@ -218,26 +194,7 @@ export const plugin = {
218
194
  if (!await policy().screen(report)) return { withheld: true as const };
219
195
  return { report, applyNeeded: report.current.capMb !== report.proposal.capMb || report.current.retentionDays !== report.proposal.retentionDays };
220
196
  }
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
- }
197
+ const inboxCache = new InboxCache(base, { log: ctx.log });
241
198
 
242
199
  function searchHit(hit: Args, project: string, q: string) {
243
200
  const view = hit.view as View | undefined;
@@ -440,10 +397,16 @@ export const plugin = {
440
397
 
441
398
  return {
442
399
  assets: { js: asset("./panel.js"), css: asset("./panel.css") },
400
+ close: () => inboxCache.close(),
443
401
  async snapshot(input) {
444
402
  const args = input as Args;
445
- if (input.panel === "inbox") return { items: await inbox() };
403
+ if (input.panel === "inbox") {
404
+ const snapshot = await inboxCache.snapshot();
405
+ for (const item of snapshot.items) if (item.group === "candidate" && item.link.params?.project) projectOf.set(item.id, item.link.params.project);
406
+ return { ...snapshot };
407
+ }
446
408
  const list = projects(), project = pick(args, list);
409
+ if (str(args.project) && !project) return { projects: list, project: null, error: "The requested memory project is unavailable; choose an active project from the project selector." };
447
410
  if (input.panel === "search") {
448
411
  const q = str(args.q);
449
412
  if (!q || !project) return { hits: [], total: 0 };
@@ -461,6 +424,17 @@ export const plugin = {
461
424
  }
462
425
  },
463
426
  async action(input) {
427
+ const result = await performAction(input);
428
+ if (result.ok) {
429
+ const id = str(input.id);
430
+ const hard = ["capacity_apply", "project_forget", "prune", "decay_apply", "accept_undo"].includes(input.action);
431
+ const removes = ["accept", "reject", "edit_accept"].includes(input.action);
432
+ inboxCache.invalidate({ hard, capacity: hard || input.action === "gc", ...(removes && id ? { removeIds: [id] } : {}), ...(input.action === "reject_undo" ? { added: 1 } : {}) });
433
+ }
434
+ return result;
435
+ },
436
+ };
437
+ async function performAction(input: { action: string; confirmed?: true; fingerprint?: string } & Args): Promise<ActionResult> {
464
438
  const args = input as Args;
465
439
  try {
466
440
  switch (input.action) {
@@ -499,8 +473,7 @@ export const plugin = {
499
473
  ctx.log(`amem: ${input.action} failed: ${errorText(e)}`);
500
474
  return { ok: false, error: errorText(e) };
501
475
  }
502
- },
503
- };
476
+ }
504
477
  },
505
478
  };
506
479