@staix/agent-mem 0.20.3 → 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 (52) 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 +3 -0
  5. package/src/hook-spool.ts +45 -3
  6. package/src/index.ts +25 -2
  7. package/src/memory/bridge.ts +66 -18
  8. package/src/memory/candidates.ts +71 -9
  9. package/src/memory/generation-index.ts +95 -18
  10. package/src/memory/generation-sync.ts +3 -0
  11. package/src/memory/generation.ts +33 -15
  12. package/src/memory/platform.ts +38 -3
  13. package/src/memory/policy.ts +28 -15
  14. package/src/memory/prune-publisher-lineage.ts +50 -12
  15. package/src/memory/public-prune-advance.ts +11 -5
  16. package/src/memory/public-prune-inputs.ts +17 -16
  17. package/src/memory/shard-sync-applications.ts +153 -0
  18. package/src/memory/shard-sync-format.ts +15 -0
  19. package/src/memory/shard-sync-graph.ts +6 -4
  20. package/src/memory/shard-sync-history.ts +238 -0
  21. package/src/memory/shard-sync.ts +67 -151
  22. package/src/memory/shard.ts +32 -6
  23. package/src/memory/store.ts +14 -1
  24. package/src/memory/sync-binding.ts +63 -0
  25. package/src/memory/sync-placement-active.ts +75 -0
  26. package/src/memory/sync-placement-cache.ts +32 -0
  27. package/src/memory/sync-placement-capture.ts +292 -0
  28. package/src/memory/sync-placement-dependencies.ts +255 -0
  29. package/src/memory/sync-placement-format.ts +182 -0
  30. package/src/memory/sync-placement-git.ts +95 -0
  31. package/src/memory/sync-placement-guard.ts +98 -0
  32. package/src/memory/sync-placement-inspection-evidence.ts +257 -0
  33. package/src/memory/sync-placement-inspection-pending.ts +66 -0
  34. package/src/memory/sync-placement-inspection.ts +236 -0
  35. package/src/memory/sync-placement-journal-format.ts +114 -0
  36. package/src/memory/sync-placement-journal-storage.ts +513 -0
  37. package/src/memory/sync-placement-journal.ts +153 -0
  38. package/src/memory/sync-placement-materialize.ts +149 -0
  39. package/src/memory/sync-placement-plan.ts +171 -0
  40. package/src/memory/sync-placement-projection.ts +79 -0
  41. package/src/memory/sync-placement-publication-plan.ts +189 -0
  42. package/src/memory/sync-placement-publication-storage.ts +87 -0
  43. package/src/memory/sync-placement-transport.ts +489 -0
  44. package/src/memory/sync-policy-inspection.ts +126 -0
  45. package/src/memory/sync-screen.ts +128 -33
  46. package/src/memory/sync.ts +20 -64
  47. package/src/ui/inbox-cache.ts +219 -0
  48. package/src/ui/inbox-worker-version.ts +14 -0
  49. package/src/ui/inbox-worker.ts +32 -0
  50. package/src/ui/inbox.ts +281 -0
  51. package/src/ui/panel.js +7 -3
  52. package/src/ui/plugin.ts +26 -53
@@ -0,0 +1,219 @@
1
+ import { fork } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolve } from "node:path";
5
+ import { configuredPolicy } from "../memory/config.ts";
6
+ import { processGroupLiveness, processSignatureAsync, realPath, type Liveness } from "../memory/platform.ts";
7
+ import { INBOX_MAX_BYTES, INBOX_MAX_ITEMS, boundedInboxRows, inboxPolicyDefaults, inboxPolicyVersion, inboxSourceChange, type CapacityMemo, type InboxComputation, type InboxItem, type InboxSources } from "./inbox.ts";
8
+ import { inboxWorkerHash } from "./inbox-worker-version.ts";
9
+
10
+ export const INBOX_WORKER_HASH = inboxWorkerHash();
11
+ export interface InboxJobResult { computation: InboxComputation; sources: InboxSources }
12
+ export interface InboxJob { result: Promise<InboxJobResult>; cancel(): Promise<void>; observeCleanup?(): Liveness }
13
+ export type InboxRunner = (base: string, capacity?: CapacityMemo) => InboxJob;
14
+ export interface InboxSnapshot { items: InboxItem[]; more: number; complete: boolean; refreshing: boolean; generatedAt?: string }
15
+ const WORK_MS = 30_000, REAP_MS = 2000, MIN_START_MS = 30_000, MAX_BACKOFF_MS = 300_000;
16
+ export class InboxCleanupError extends Error {
17
+ readonly group: number | undefined;
18
+ constructor(group?: number) {
19
+ const valid = group !== undefined && Number.isSafeInteger(group) && group > 0 ? group : undefined;
20
+ super(`inbox worker group ${valid ?? "unknown"} cleanup is unresolved`); this.name = "InboxCleanupError"; this.group = valid;
21
+ }
22
+ }
23
+ export class InboxDeadlineError extends InboxCleanupError { constructor(group?: number) { super(group); this.name = "InboxDeadlineError"; } }
24
+ export class InboxChangedError extends Error { constructor() { super("inbox sources changed"); this.name = "InboxChangedError"; } }
25
+ export class InboxRuntimeError extends Error { constructor(message: string) { super(message); this.name = "InboxRuntimeError"; } }
26
+ async function waitForGroupGone(group: number | undefined): Promise<void> {
27
+ if (group === undefined) return;
28
+ const until = performance.now() + REAP_MS;
29
+ for (;;) {
30
+ const state = processGroupLiveness(group);
31
+ if (state === "gone") return;
32
+ if (state === "unknown" || performance.now() >= until) throw new InboxCleanupError(group);
33
+ await new Promise(resolve => setTimeout(resolve, 20));
34
+ }
35
+ }
36
+ /** Native child handle plus verified detached group: no retired group is ever signalled. */
37
+ export const runInboxJob: InboxRunner = (base, capacity) => {
38
+ if (!existsSync(process.execPath)) throw new InboxRuntimeError("bun moved; restart acon serve");
39
+ if (!INBOX_WORKER_HASH || inboxWorkerHash() !== INBOX_WORKER_HASH) throw new InboxRuntimeError("inbox worker changed; restart acon serve");
40
+ const defaults = inboxPolicyDefaults();
41
+ const child = fork(fileURLToPath(new URL("./inbox-worker.ts", import.meta.url)), [], { execPath: process.execPath, execArgv: ["--smol"], detached: true,
42
+ stdio: ["ignore", "ignore", "ignore", "ipc"], serialization: "json", env: { ...process.env, AMEM_UI_WORKER_HASH: INBOX_WORKER_HASH } });
43
+ let exited = false, cleaned = false, ready = false, groupKnown = false, terminating = false, changed = false, workerMoved = false, payload: InboxJobResult | undefined;
44
+ let readyProbe: Promise<void> | undefined, force: ReturnType<typeof setTimeout> | undefined;
45
+ let fail!: (error: Error) => void, succeed!: (value: InboxJobResult) => void, endedResolve!: () => void, endedReject!: (error: Error) => void;
46
+ const ended = new Promise<void>((resolve, reject) => { endedResolve = resolve; endedReject = reject; }); void ended.catch(() => {});
47
+ const result = new Promise<InboxJobResult>((resolve, reject) => { succeed = resolve; fail = reject; });
48
+ const kill = (signal: NodeJS.Signals) => {
49
+ if (exited || child.exitCode !== null || child.signalCode !== null) return;
50
+ try { if (child.pid) process.kill(-child.pid, signal); } catch { child.kill(signal); }
51
+ };
52
+ const clear = () => { clearTimeout(deadline); if (force) clearTimeout(force); };
53
+ const clean = () => { cleaned = true; exited = true; clear(); endedResolve(); };
54
+ const observe = (): Liveness => {
55
+ if (cleaned) return "gone";
56
+ const state = child.pid === undefined ? "gone" : processGroupLiveness(child.pid);
57
+ if (state === "gone" && (groupKnown || exited || child.exitCode !== null || child.signalCode !== null || child.pid === undefined)) { clean(); return "gone"; }
58
+ return state === "gone" ? "unknown" : state;
59
+ };
60
+ const terminate = () => {
61
+ if (terminating || exited || cleaned) return;
62
+ terminating = true; kill("SIGTERM");
63
+ if (!exited) { force = setTimeout(() => kill("SIGKILL"), 250); force.unref(); }
64
+ };
65
+ const deadline = setTimeout(() => { terminate(); fail(new InboxDeadlineError(child.pid)); }, WORK_MS); deadline.unref();
66
+ child.on("message", (value: unknown) => {
67
+ const message = value as { type?: unknown; pid?: unknown; group?: unknown; signature?: unknown; workerHash?: unknown; computation?: InboxComputation; sources?: InboxSources };
68
+ if (message?.type === "ready" && !ready && !readyProbe) {
69
+ readyProbe = (async () => {
70
+ if (message.workerHash !== INBOX_WORKER_HASH) { workerMoved = true; terminate(); return; }
71
+ if (!child.pid || message.pid !== child.pid || message.group !== child.pid || typeof message.signature !== "string") { terminate(); return; }
72
+ groupKnown = true;
73
+ const signature = await processSignatureAsync(child.pid);
74
+ if (exited || terminating) return;
75
+ if (signature !== message.signature) { terminate(); return; }
76
+ ready = true; child.send({ type: "run", base, capacity, defaults });
77
+ })().catch(() => terminate());
78
+ } else if (ready && message?.type === "result" && message.computation && message.sources) payload = { computation: message.computation, sources: message.sources };
79
+ else if (ready && message?.type === "changed") changed = true;
80
+ else if (message?.type === "moved") workerMoved = true;
81
+ });
82
+ child.once("error", () => {
83
+ if (child.pid === undefined) { clean(); fail(Error("inbox worker unavailable")); } else terminate();
84
+ });
85
+ child.once("close", code => {
86
+ exited = true; clear();
87
+ if (cleaned) return;
88
+ // The proof budget starts here, not at TERM: unrelated event-loop work before close cannot consume it.
89
+ void waitForGroupGone(child.pid).then(() => {
90
+ clean();
91
+ if (code === 0 && payload && !terminating) succeed(payload);
92
+ else fail(workerMoved ? new InboxRuntimeError("inbox worker changed; restart acon serve") : changed ? new InboxChangedError() : Error("inbox worker unavailable"));
93
+ }).catch(() => { const error = new InboxCleanupError(child.pid); endedReject(error); fail(error); });
94
+ });
95
+ return { result, observeCleanup: observe, async cancel() {
96
+ if (observe() === "gone") { fail(Error("inbox worker cancelled")); return; }
97
+ terminate(); let timer: ReturnType<typeof setTimeout> | undefined;
98
+ try { await Promise.race([ended, new Promise<void>((resolve, reject) => { timer = setTimeout(() => {
99
+ if (observe() === "gone") { fail(Error("inbox worker cancelled")); resolve(); }
100
+ else { const error = new InboxCleanupError(child.pid); endedReject(error); fail(error); reject(error); }
101
+ }, REAP_MS + 1000); })]); fail(Error("inbox worker cancelled")); }
102
+ finally { if (timer) clearTimeout(timer); }
103
+ } };
104
+ };
105
+
106
+ export interface InboxInvalidation { hard?: boolean; capacity?: boolean; removeIds?: readonly string[]; added?: number }
107
+ /** Request-driven refresh only. Cooldown/backoff survive source changes and actions. */
108
+ export class InboxCache {
109
+ private readonly base: string; private readonly runner: InboxRunner; private readonly log: (line: string) => void;
110
+ private readonly now: () => number; private readonly clock: () => number;
111
+ private cache?: InboxJobResult; private capacity?: CapacityMemo; private observed?: InboxSources;
112
+ private task?: InboxJob; private blockedTask?: InboxJob; private blockedGroup?: number; private request?: Promise<InboxSnapshot>;
113
+ private generation = 0; private closed = false; private pending = true; private stale = false;
114
+ private failureCount = 0; private retryAt = 0; private lastStart?: number; private lastDuration = 0;
115
+ private error?: "failure" | "cleanup" | "runtime"; private errorDetail?: string;
116
+ private generatedAt?: string; private policy?: string; private capacityAt?: string; private overflowAt?: string;
117
+ private state = ""; private stateAt = 0;
118
+ constructor(base: string, options: { runner?: InboxRunner; log?: (line: string) => void; now?: () => number; monotonicNow?: () => number } = {}) {
119
+ const absolute = resolve(base); this.base = existsSync(absolute) ? realPath(absolute) : absolute;
120
+ this.runner = options.runner ?? runInboxJob; this.log = options.log ?? (() => {}); this.now = options.now ?? Date.now;
121
+ this.clock = options.monotonicNow ?? (options.now ? options.now : () => performance.now());
122
+ }
123
+ invalidate(input: boolean | InboxInvalidation = {}): void {
124
+ const change = typeof input === "boolean" ? { hard: input, capacity: input } : input;
125
+ this.generation++; this.pending = true; this.stale = true;
126
+ if (change.hard) { this.cache = undefined; this.observed = undefined; }
127
+ else if (this.cache && (change.removeIds?.length || change.added)) {
128
+ const old = this.cache.computation, actual = old.items.filter(item => item.id !== "inbox:more");
129
+ const removed = new Set(change.removeIds ?? []), shown = actual.filter(item => !removed.has(item.id));
130
+ const total = Math.max(shown.length, actual.length + old.more - removed.size + (change.added ?? 0));
131
+ const hint = old.items.find(item => item.id === "inbox:more");
132
+ Object.assign(old, boundedInboxRows(hint ? [...shown, hint] : shown, total));
133
+ }
134
+ if (change.capacity) this.capacity = undefined;
135
+ }
136
+ private allowed(): boolean {
137
+ return this.clock() >= Math.max(this.retryAt, this.lastStart === undefined ? 0 : this.lastStart + Math.max(MIN_START_MS, this.lastDuration));
138
+ }
139
+ private recover(): void {
140
+ if (!this.blockedTask) return;
141
+ const state = this.blockedTask.observeCleanup ? this.blockedTask.observeCleanup()
142
+ : this.blockedGroup === undefined ? "gone" : processGroupLiveness(this.blockedGroup);
143
+ if (state === "gone") { if (this.task === this.blockedTask) this.task = undefined; this.blockedTask = undefined; this.blockedGroup = undefined; this.error = undefined; }
144
+ }
145
+ private failed(error: unknown, job?: InboxJob): void {
146
+ if (error instanceof InboxChangedError) { this.pending = true; return; }
147
+ this.failureCount = Math.min(20, this.failureCount + 1);
148
+ this.retryAt = this.clock() + Math.min(MAX_BACKOFF_MS, 1000 * 2 ** Math.min(19, this.failureCount - 1));
149
+ this.pending = true; this.error = error instanceof InboxRuntimeError ? "runtime" : error instanceof InboxCleanupError ? "cleanup" : "failure";
150
+ if (error instanceof InboxCleanupError && job && error.group !== undefined) { this.blockedTask = job; this.blockedGroup = error.group; }
151
+ if (error instanceof InboxRuntimeError) { this.cache = undefined; this.errorDetail = error.message; }
152
+ if (!this.closed) try { this.log(error instanceof InboxRuntimeError ? error.message : this.error === "cleanup"
153
+ ? `amem: inbox group ${this.blockedGroup ?? "unknown"} cleanup unresolved; snapshots will observe recovery without signalling it.` : "amem: inbox refresh failed; bounded backoff applies"); } catch {}
154
+ }
155
+ private refresh(): void {
156
+ if (this.closed || this.task || this.blockedTask || !this.pending || !this.allowed()) return;
157
+ const generation = this.generation, started = this.clock(); this.lastStart = started; this.pending = false; this.error = undefined; this.errorDetail = undefined;
158
+ let job: InboxJob;
159
+ try { job = this.runner(this.base, this.capacity); } catch (error) { this.lastDuration = Math.max(0, this.clock() - started); this.failed(error); return; }
160
+ this.task = job;
161
+ void job.result.then(result => {
162
+ const revision = inboxPolicyVersion(this.base), computation = result.computation;
163
+ if (generation !== this.generation || computation.policy !== revision || inboxSourceChange(this.base, result.sources).changed) { this.pending = true; return; }
164
+ if (computation.expiresAt !== null && (!Number.isFinite(computation.expiresAt) || computation.expiresAt < 0)) throw Error("invalid inbox expiry");
165
+ if (computation.expiresAt !== null && this.now() >= computation.expiresAt) { this.invalidate({ hard: true }); return; }
166
+ if (computation.items.length > INBOX_MAX_ITEMS || Buffer.byteLength(JSON.stringify(computation.items)) > INBOX_MAX_BYTES || !Number.isSafeInteger(computation.more) || computation.more < 0) throw Error("invalid inbox result");
167
+ const screen = configuredPolicy(this.base);
168
+ if (computation.items.some(item => item.id !== "inbox:more" && !screen.screenStandalone(item))) throw Error("inbox result declined");
169
+ this.cache = structuredClone(result); this.observed = this.cache.sources; this.capacity = this.cache.computation.capacity;
170
+ const cap = this.cache.computation.items.find(item => item.id === "capacity:proposal");
171
+ if (cap) { this.capacityAt ??= cap.at; cap.at = this.capacityAt; } else this.capacityAt = undefined;
172
+ const overflow = this.cache.computation.items.find(item => item.id === "inbox:more");
173
+ if (overflow) { this.overflowAt ??= overflow.at; overflow.at = this.overflowAt; } else this.overflowAt = undefined;
174
+ this.policy = revision; this.generatedAt = new Date(this.now()).toISOString(); this.stale = false; this.pending = false;
175
+ this.failureCount = 0; this.retryAt = 0; this.error = undefined; this.errorDetail = undefined;
176
+ }).catch(error => this.failed(error, job)).finally(() => {
177
+ this.lastDuration = Math.max(0, this.clock() - started); if (this.task === job) this.task = undefined;
178
+ // No timer or completion callback spawns another process. A later snapshot services one pending rerun.
179
+ });
180
+ }
181
+ private status(): InboxSnapshot {
182
+ const kind = this.error || this.blockedTask ? "unavailable" : "refreshing";
183
+ if (this.state !== kind) { this.state = kind; this.stateAt = Math.max(this.now(), this.stateAt + 1); }
184
+ return { items: [{ id: `inbox:${kind}`, group: "alert", kind: "status", title: kind === "refreshing" ? "Memory inbox is refreshing" : "Memory inbox is unavailable",
185
+ detail: this.errorDetail ?? (kind === "refreshing" ? "Pending items will appear when the refresh finishes." : "Refresh is paused; it will retry after the cooldown."),
186
+ at: new Date(this.stateAt).toISOString(), verbs: [], link: { panel: "review" } }], more: 0, complete: false, refreshing: true };
187
+ }
188
+ snapshot(): Promise<InboxSnapshot> {
189
+ if (this.request) return this.request.then(value => structuredClone(value));
190
+ const request = Promise.resolve().then(() => {
191
+ if (this.closed) throw Error("inbox provider is closed");
192
+ try {
193
+ this.recover(); const revision = inboxPolicyVersion(this.base);
194
+ if (this.policy !== undefined && this.policy !== revision) this.invalidate({ hard: true, capacity: true });
195
+ this.policy = revision;
196
+ if (this.cache) {
197
+ const changed = inboxSourceChange(this.base, this.observed ?? this.cache.sources);
198
+ this.observed = changed.sources;
199
+ if (changed.changed) this.invalidate({ hard: changed.hard, capacity: changed.hard });
200
+ if (this.cache && this.cache.computation.expiresAt !== null && this.now() >= this.cache.computation.expiresAt) this.invalidate({ hard: true });
201
+ if (this.capacity && this.now() >= this.capacity.until && !this.stale) this.invalidate();
202
+ }
203
+ this.refresh();
204
+ if (this.cache && inboxPolicyVersion(this.base) !== revision) throw Error("inbox policy changed");
205
+ if (this.cache && (!this.stale || this.cache.computation.items.length)) {
206
+ this.state = this.stale ? "stale" : "ready";
207
+ return { items: structuredClone(this.cache.computation.items), more: this.cache.computation.more, complete: !this.stale, refreshing: this.stale, generatedAt: this.generatedAt };
208
+ }
209
+ return this.status();
210
+ } catch { this.invalidate({ hard: true, capacity: true }); this.error = "failure"; return this.status(); }
211
+ });
212
+ this.request = request; void request.finally(() => { if (this.request === request) this.request = undefined; }).catch(() => {});
213
+ return request.then(value => structuredClone(value));
214
+ }
215
+ async close(): Promise<void> {
216
+ this.closed = true; this.cache = undefined; this.capacity = undefined; this.generation++;
217
+ const job = this.task ?? this.blockedTask; if (job) await job.cancel(); this.blockedTask = undefined;
218
+ }
219
+ }
@@ -0,0 +1,14 @@
1
+ /** Compatibility pin for the on-disk worker/control code, not an authentication credential. */
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+
5
+ // ponytail: this pins the worker/control/policy modules and package metadata. Same-version edits to other
6
+ // dependencies retain the host's module-loader trust ceiling; a host-attested immutable package graph is the upgrade.
7
+ export function inboxWorkerHash(): string | undefined {
8
+ try {
9
+ const files = ["./inbox-worker.ts", "./inbox-cache.ts", "./inbox.ts", "./inbox-worker-version.ts", "../memory/candidates.ts", "../memory/policy.ts", "../memory/config.ts", "../memory/platform.ts", "../../package.json"];
10
+ const hash = createHash("sha256");
11
+ for (const file of files) hash.update(JSON.stringify([file, createHash("sha256").update(readFileSync(new URL(file, import.meta.url))).digest("hex")]));
12
+ return hash.digest("hex");
13
+ } catch { return undefined; }
14
+ }
@@ -0,0 +1,32 @@
1
+ /** One-shot, owned background process. Never imported by the console request handler. */
2
+ import { DENY_NAMES, DENY_SEGMENTS, processSignatureAsync, processGroupIdAsync } from "../memory/platform.ts";
3
+ import { inboxWorkerHash } from "./inbox-worker-version.ts";
4
+ import { memoryPolicySnapshot } from "../memory/policy.ts";
5
+ import { computeInbox, createInboxSourceTracker, type CapacityMemo, type InboxDefaults } from "./inbox.ts";
6
+
7
+ process.once("disconnect", () => process.exit());
8
+ // Keep the leader identity while synchronous owned children settle or the parent force-kills the group.
9
+ process.on("SIGTERM", () => {});
10
+ process.once("message", (message: unknown) => {
11
+ try {
12
+ const input = message as { type?: unknown; base?: unknown; capacity?: CapacityMemo; defaults?: InboxDefaults };
13
+ if (input.type !== "run" || typeof input.base !== "string") throw Error("invalid inbox job");
14
+ const defaults = memoryPolicySnapshot(input.defaults);
15
+ if (!defaults || !Array.isArray(defaults.names) || !Array.isArray(defaults.segments)
16
+ || !defaults.names.every(value => typeof value === "string") || !defaults.segments.every(value => typeof value === "string")) throw Error("invalid inbox policy");
17
+ DENY_NAMES.splice(0, DENY_NAMES.length, ...defaults.names); DENY_SEGMENTS.splice(0, DENY_SEGMENTS.length, ...defaults.segments);
18
+ const tracker = createInboxSourceTracker(input.base);
19
+ const computation = computeInbox(input.base, input.capacity, tracker.beforeProject), before = tracker.before(), after = tracker.after();
20
+ const result = before.version === after.version ? { type: "result", computation, sources: after } : { type: "changed" };
21
+ process.send?.(result, () => process.disconnect?.());
22
+ } catch {
23
+ // No source text, paths, literal patterns or caller error messages cross the error channel.
24
+ process.send?.({ type: "failed" }, () => process.disconnect?.());
25
+ }
26
+ });
27
+ const workerHash = inboxWorkerHash();
28
+ if (!workerHash || workerHash !== process.env.AMEM_UI_WORKER_HASH) {
29
+ process.send?.({ type: "moved" }, () => process.disconnect?.());
30
+ } else void Promise.all([processSignatureAsync(process.pid), processGroupIdAsync(process.pid)]).then(([signature, group]) => {
31
+ process.send?.({ type: "ready", pid: process.pid, signature, group, workerHash });
32
+ });
@@ -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) {