@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -3
- package/package.json +2 -2
- package/src/cli.ts +8 -1
- package/src/commands-table.ts +2 -1
- package/src/hook-spool.ts +45 -3
- package/src/index.ts +25 -2
- package/src/memory/bridge.ts +66 -18
- package/src/memory/candidates.ts +71 -9
- package/src/memory/generation-index.ts +95 -18
- package/src/memory/generation-sync.ts +3 -0
- package/src/memory/generation.ts +33 -15
- package/src/memory/platform.ts +38 -3
- package/src/memory/policy.ts +28 -15
- package/src/memory/prune-publisher-lineage.ts +50 -12
- package/src/memory/public-prune-advance.ts +11 -5
- package/src/memory/public-prune-inputs.ts +17 -16
- package/src/memory/shard-sync-applications.ts +153 -0
- package/src/memory/shard-sync-format.ts +15 -0
- package/src/memory/shard-sync-graph.ts +6 -4
- package/src/memory/shard-sync-history.ts +238 -0
- package/src/memory/shard-sync.ts +67 -151
- package/src/memory/shard.ts +32 -6
- package/src/memory/store.ts +14 -1
- package/src/memory/sync-binding.ts +63 -0
- package/src/memory/sync-placement-active.ts +75 -0
- package/src/memory/sync-placement-cache.ts +32 -0
- package/src/memory/sync-placement-capture.ts +292 -0
- package/src/memory/sync-placement-dependencies.ts +255 -0
- package/src/memory/sync-placement-format.ts +182 -0
- package/src/memory/sync-placement-git.ts +95 -0
- package/src/memory/sync-placement-guard.ts +98 -0
- package/src/memory/sync-placement-inspection-evidence.ts +257 -0
- package/src/memory/sync-placement-inspection-pending.ts +66 -0
- package/src/memory/sync-placement-inspection.ts +236 -0
- package/src/memory/sync-placement-journal-format.ts +114 -0
- package/src/memory/sync-placement-journal-storage.ts +513 -0
- package/src/memory/sync-placement-journal.ts +153 -0
- package/src/memory/sync-placement-materialize.ts +149 -0
- package/src/memory/sync-placement-plan.ts +171 -0
- package/src/memory/sync-placement-projection.ts +79 -0
- package/src/memory/sync-placement-publication-plan.ts +189 -0
- package/src/memory/sync-placement-publication-storage.ts +87 -0
- package/src/memory/sync-placement-transport.ts +489 -0
- package/src/memory/sync-policy-inspection.ts +126 -0
- package/src/memory/sync-screen.ts +128 -33
- package/src/memory/sync.ts +20 -64
- package/src/setup.ts +69 -14
- package/src/ui/inbox-cache.ts +219 -0
- package/src/ui/inbox-worker-version.ts +14 -0
- package/src/ui/inbox-worker.ts +32 -0
- package/src/ui/inbox.ts +281 -0
- package/src/ui/panel.js +7 -3
- package/src/ui/plugin.ts +26 -53
package/src/ui/inbox.ts
ADDED
|
@@ -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") =>
|
|
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:
|
|
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))
|
|
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
|
-
|
|
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")
|
|
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
|
|