@staix/agent-mem 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ import { lstatSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { McpHandlers } from "./mcp.ts";
4
+ import { CandidateStore } from "./memory/candidates.ts";
5
+ import { capacityReport } from "./memory/capacity.ts";
6
+ import { CaptureStore } from "./memory/capture.ts";
7
+ import { readSelector } from "./memory/generation.ts";
8
+ import { memoryHome } from "./memory/home.ts";
9
+ import { canonicalPath, realPath } from "./memory/platform.ts";
10
+ import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
11
+ import { memoryProjectKey } from "./memory/project.ts";
12
+ import { registerReaderLease } from "./memory/reader-lease.ts";
13
+ import { type ReadRef, type ReadWindow, SearchIndex } from "./memory/search-fts.ts";
14
+ import { aliasedKeys, readShardedSelection, resolveAlias } from "./memory/shard.ts";
15
+ import { MemoryStore, openShardStore, type Graph, type View } from "./memory/store.ts";
16
+ import { vaultCandidates } from "./memory/vault.ts";
17
+ import { WorkListStore, type WorkListFields } from "./memory/work-lists.ts";
18
+
19
+ // Graph retains its complete-corpus ceiling; search/timeline/show page matching bodies instead.
20
+ export const MCP_SCAN_LIMIT = 5000;
21
+ // ponytail: rank the newest 5000 permitted matching candidates per tier; use since to narrow or upgrade to a policy-bound persistent index for global ranking.
22
+ const SEARCH_CANDIDATES = 5000;
23
+ const READ_PAGE = 256;
24
+ export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions }
25
+ export interface MemoryMcp { tools: McpHandlers; close(): void }
26
+ const unavailable = (): never => { throw new Error("memory operation unavailable"); };
27
+ const bound = (args: Record<string, unknown>, keys: string[]) => { if (Object.keys(args).some(key => !keys.includes(key))) unavailable(); };
28
+ const limitOf = (args: Record<string, unknown>): number => args.limit === undefined ? 20 : Number.isSafeInteger(args.limit) && Number(args.limit) >= 1 && Number(args.limit) <= 100 ? Number(args.limit) : unavailable();
29
+ const textOf = (args: Record<string, unknown>, key: string, optional = false): string | undefined => {
30
+ const value = args[key]; if (value === undefined && optional) return undefined;
31
+ return typeof value === "string" && value.trim() && !value.includes("\0") ? value : unavailable();
32
+ };
33
+ const sinceOf = (args: Record<string, unknown>): string | undefined => { const since = textOf(args, "since", true); if (since !== undefined && !Number.isFinite(Date.parse(since))) unavailable(); return since === undefined ? undefined : new Date(since).toISOString(); };
34
+
35
+ /** All identities are resolved once. Per-call arguments cannot change projects, homes, profiles or policy. */
36
+ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
37
+ const cwd = realPath(options.cwd), base = canonicalPath(options.base ?? memoryHome());
38
+ const requested = options.project ?? memoryProjectKey(cwd), project = resolveAlias(base, requested);
39
+ const policy = createStandalonePolicy(options.policy), release = registerReaderLease(base);
40
+ const closers: (() => void)[] = [release]; let closed = false;
41
+ const close = () => { if (closed) return; closed = true; let failure: unknown; for (const closer of closers.reverse()) { try { closer(); } catch (error) { failure ??= error; } } if (failure) throw failure; };
42
+ try {
43
+ const selection = readSelector(base).revision;
44
+ const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
45
+ const shardSelection = sharded ? JSON.stringify(readShardedSelection(base)) : undefined;
46
+ // #27: the project filter covers the canonical key and every key aliased to it; the keys are fixed with the project, so a later alias change fails the guard below.
47
+ const aliases = aliasedKeys(base, project), reconcile = aliases.length ? { canonical: project, aliases } : undefined;
48
+ const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true, ...(reconcile ? { reconcile } : {}) }) : new MemoryStore({ memoryDir: base, deferPromotions: true, ...(reconcile ? { reconcile } : {}) });
49
+ closers.push(() => store.close());
50
+ const capture = new CaptureStore({ memoryDir: base, project }); closers.push(() => capture.close());
51
+ const candidates = new CandidateStore({ memoryDir: base, ...(reconcile ? { reconcile } : {}) }); closers.push(() => candidates.close());
52
+ const work = new WorkListStore({ base, project, aliases, policy: async row => { const permitted = await policy.screen(row); guard(); return permitted; } }); closers.push(() => work.close());
53
+ const guard = () => {
54
+ if (closed) unavailable();
55
+ release.verify();
56
+ if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection) unavailable();
57
+ const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
58
+ if (split !== sharded || split && JSON.stringify(readShardedSelection(base)) !== shardSelection) unavailable();
59
+ };
60
+ const screen = async <T>(rows: T[]): Promise<T[]> => {
61
+ if (rows.length > MCP_SCAN_LIMIT) unavailable();
62
+ const publicRows: T[] = []; for (const row of rows) if (await policy.screen(row)) publicRows.push(row); return publicRows;
63
+ };
64
+ const curated = async (superseded = false) => {
65
+ guard();
66
+ // list() includes shared rows: remove other projects before screening or selection.
67
+ const rows = store.list({ project, includeSuperseded: superseded, limit: MCP_SCAN_LIMIT + 1 });
68
+ if (rows.length > MCP_SCAN_LIMIT) unavailable();
69
+ return screen(rows.filter(row => row.project === project));
70
+ };
71
+ const readPermitted = async <T>(read: (window: ReadWindow) => { rows: T[]; next?: ReadRef; engine?: "fts5" | "substring" },
72
+ options: Omit<ReadWindow, "project" | "limit" | "after">, cap: number, matches: (row: T, engine?: "fts5" | "substring") => boolean = () => true): Promise<T[]> => {
73
+ const result: T[] = []; let after: ReadWindow["after"];
74
+ while (result.length < cap) {
75
+ guard();
76
+ const page = read({ ...options, project, limit: READ_PAGE, after });
77
+ for (const row of page.rows) if (await policy.screen(row) && matches(row, page.engine)) {
78
+ result.push(row); if (result.length === cap) break;
79
+ }
80
+ guard();
81
+ if (!page.next) break;
82
+ after = { position: page.next.position, id: page.next.id };
83
+ }
84
+ return result;
85
+ };
86
+ const anchorAt = async (id: string): Promise<string | undefined> => {
87
+ if (/^capture:[1-9][0-9]*$/.test(id)) {
88
+ return (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0]?.at;
89
+ }
90
+ const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { prefix: id.replace(/^mem:/, "") }, 2);
91
+ return rows.length === 1 ? rows[0]!.at : undefined;
92
+ };
93
+ const tools: McpHandlers = {
94
+ async amem_search(args) {
95
+ bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
96
+ if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
97
+ if (!await policy.screen({ query })) unavailable();
98
+ const index = new SearchIndex(":memory:");
99
+ try {
100
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
101
+ const captureState: { engine?: "fts5" | "substring" } = {};
102
+ const recalls = args.includeCapture === false ? [] : await readPermitted(window => {
103
+ const page = capture.readPage({ ...window, query, ...(captureState.engine === "substring" ? { match: "substring" as const } : {}) });
104
+ captureState.engine ??= page.engine;
105
+ return page;
106
+ }, { since }, SEARCH_CANDIDATES,
107
+ (row, engine) => index.available && engine === "fts5" || words.every(word => `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}`.toLowerCase().includes(word)));
108
+ const useFts = index.available && captureState.engine !== "substring";
109
+ const notes = await readPermitted(window => store.readPage({ ...window, ...(useFts ? { query } : {}) }), { since }, SEARCH_CANDIDATES,
110
+ view => useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word)));
111
+ const hits = [...notes.map(view => ({ source: "curated" as const, evidence: true, view, ref: view.id, at: view.at, tier: view.tier, body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })),
112
+ ...recalls.map(row => ({ source: "capture" as const, evidence: false, capture: row, ref: `capture:${row.id}`, at: row.at, tier: "capture", body: `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}` }))].filter(hit => !since || Date.parse(hit.at) >= Date.parse(since));
113
+ if (useFts) {
114
+ index.resync(() => hits.map(hit => ({ id: hit.ref, at: hit.at, project, tier: hit.tier, scope: "project", body: hit.body })));
115
+ if (index.needsResync(hits.length)) unavailable();
116
+ }
117
+ const ranks = new Map((useFts ? index.query(query, MCP_SCAN_LIMIT * 2, project) : []).map(row => [row.ref, row.rank]));
118
+ const selected = hits.flatMap(hit => {
119
+ const rank = ranks.get(hit.ref);
120
+ if (useFts ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
121
+ const weight = hit.tier === "long" ? 1 : hit.tier === "capture" ? 0.5 : 0.9;
122
+ const score = -(rank ?? -1) * weight / (1 + Math.max(0, Date.now() - Date.parse(hit.at)) / (30 * 86400_000));
123
+ return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "curated" ? { view: hit.view } : { capture: hit.capture }), score }];
124
+ }).sort((a, b) => b.score - a.score).slice(0, limit);
125
+ guard(); return { engine: useFts ? "fts5" : "substring", hits: selected };
126
+ } finally { index.close(); }
127
+ },
128
+ async amem_show(args) {
129
+ bound(args, ["id"]); guard(); const id = textOf(args, "id")!;
130
+ if (/^capture:[1-9][0-9]*$/.test(id)) {
131
+ const row = (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0];
132
+ guard(); return row ? { source: "capture", evidence: false, capture: row } : null;
133
+ }
134
+ const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { prefix: id.replace(/^mem:/, "") }, 2);
135
+ guard(); return rows.length === 1 ? rows[0]! : null;
136
+ },
137
+ async amem_timeline(args) {
138
+ bound(args, ["id", "since", "limit"]);
139
+ const id = textOf(args, "id", true), since = sinceOf(args), limit = limitOf(args);
140
+ guard();
141
+ const anchor = id ? await anchorAt(id) : undefined;
142
+ if (id && !anchor) { guard(); return []; }
143
+ const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { since, anchor }, limit);
144
+ const recalls = await readPermitted(window => capture.readPage(window), { since, anchor }, limit);
145
+ const entries = [...rows.map(row => ({ at: row.at, ref: row.id, value: row as unknown })),
146
+ ...recalls.map(row => ({ at: row.at, ref: `capture:${row.id}`, value: { source: "capture", evidence: false, capture: row } as unknown }))];
147
+ entries.sort((a, b) => (anchor ? Math.abs(Date.parse(a.at) - Date.parse(anchor)) - Math.abs(Date.parse(b.at) - Date.parse(anchor)) : Date.parse(b.at) - Date.parse(a.at)) || (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0));
148
+ guard(); return entries.slice(0, limit).map(row => row.value);
149
+ },
150
+ async amem_graph(args) {
151
+ bound(args, ["start", "hops", "limit"]); const start = textOf(args, "start")!, cap = limitOf(args), hops = args.hops ?? 1;
152
+ if (hops !== 1 && hops !== 2) unavailable();
153
+ const rows = await curated(true); const result = screenedGraph(rows, start, Number(hops), cap); guard(); return result;
154
+ },
155
+ async amem_work_state_read(args) {
156
+ bound(args, ["list", "includeClosed"]); guard();
157
+ if (args.includeClosed !== undefined && typeof args.includeClosed !== "boolean") unavailable();
158
+ // Reduce complete views before policy: individually public appends can compose into private state.
159
+ const result = await work.read(textOf(args, "list", true), true);
160
+ const permitted: typeof result = [];
161
+ for (const view of result) if (await policy.screen(view)) permitted.push(view);
162
+ guard();
163
+ return args.includeClosed === true ? permitted : permitted.filter(view => !view.closed).map(view => ({ ...view, items: view.items.filter(item => item.fields.status !== "done" && item.fields.status !== "dropped") }));
164
+ },
165
+ async amem_work_state_write(args) { bound(args, ["list", "fields"]); guard(); const result = await work.write(textOf(args, "list")!, args.fields as WorkListFields); guard(); return result; },
166
+ async amem_remember(args) {
167
+ bound(args, ["text", "title", "kind"]); guard();
168
+ const kind = args.kind ?? "finding"; if (!["decision", "finding", "contract", "fail"].includes(String(kind))) unavailable();
169
+ const input = { project, kind: "memory" as const, text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" };
170
+ if (!await policy.screen(input)) unavailable(); guard(); return { id: candidates.put(input, "public") };
171
+ },
172
+ async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy, limit: limitOf(args) }); guard(); return result; },
173
+ async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; },
174
+ };
175
+ return { tools, close };
176
+ } catch (error) { close(); throw error; }
177
+ }
178
+
179
+ /** Build only from screened project rows; even an absent starting node does not leak its label. */
180
+ function screenedGraph(rows: View[], start: string, hops: number, cap: number): Graph {
181
+ const nodes = new Map<string, Graph["nodes"][number]>(), edges: Graph["edges"] = [];
182
+ const views = new Map(rows.map(row => [`mem:${row.id}`, row]));
183
+ for (const [id, row] of views) {
184
+ nodes.set(id, { id, type: "memory", label: (row.title ?? row.text).slice(0, 60), kind: row.kind, tier: row.tier });
185
+ for (const [other, rel] of [...row.about.map(node => [node, row.kind === "fail" ? "rules_out" : "about"] as const), [`peer:${row.peer}`, "by"], [`machine:${row.machine}`, "by"], [`project:${row.project}`, "in"]]) {
186
+ nodes.set(other!, { id: other!, type: other!.split(":")[0]!, label: other!.slice(other!.indexOf(":") + 1) }); edges.push({ from: id, to: other!, rel: rel! });
187
+ }
188
+ for (const old of row.supersedes) if (views.has(`mem:${old}`)) edges.push({ from: id, to: `mem:${old}`, rel: "supersedes" });
189
+ }
190
+ if (!nodes.has(start)) return { nodes: [], edges: [] };
191
+ const selected = new Set([start]); let frontier = [start];
192
+ for (let hop = 0; hop < hops; hop++) { const next: string[] = []; for (const id of frontier) for (const edge of edges) { const other = edge.from === id ? edge.to : edge.to === id ? edge.from : undefined; if (other && !selected.has(other) && selected.size < cap) { selected.add(other); next.push(other); } } frontier = next; }
193
+ return { nodes: [...selected].map(id => nodes.get(id)!), edges: edges.filter(edge => selected.has(edge.from) && selected.has(edge.to)) };
194
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { once } from "node:events";
2
+ import type { Readable, Writable } from "node:stream";
3
+
4
+ const string = (maxLength: number, minLength = 1) => ({ type: "string", minLength, maxLength });
5
+ const limit = { type: "integer", minimum: 1, maximum: 100 };
6
+ const object = (properties: Record<string, unknown>, required: string[] = []) => ({ type: "object", properties, required, additionalProperties: false });
7
+ const tool = <Name extends string>(name: Name, description: string, inputSchema: ReturnType<typeof object>, readOnlyHint = true) => ({ name, description, inputSchema,
8
+ annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } });
9
+
10
+ /** Frozen #13 P2 surface. Storage scope comes from startup, never tool arguments. */
11
+ export const MCP_TOOLS = [
12
+ tool("amem_search", "Search screened memory in this project. Capture results are recall, not curated evidence.", object({ query: string(1000), limit, since: string(40), includeCapture: { type: "boolean" } }, ["query"])),
13
+ tool("amem_show", "Show a screened memory entry in this project by its exact id or unique prefix.", object({ id: string(80) }, ["id"])),
14
+ tool("amem_timeline", "Read screened memory near an entry or time in this project.", object({ id: string(80), since: string(40), limit })),
15
+ tool("amem_graph", "Read the screened curated-memory neighbourhood. Capture is never graph evidence.", object({ start: string(500), hops: { type: "integer", minimum: 1, maximum: 2 }, limit }, ["start"])),
16
+ tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })),
17
+ tool("amem_work_state_write", "Append scalar project work-list fields; null clears, task selects an item, list status done closes.", object({ list: string(200), fields: { type: "object", minProperties: 1, maxProperties: 64,
18
+ additionalProperties: { type: ["string", "number", "boolean", "null"], maxLength: 4000 } } }, ["list", "fields"]), false),
19
+ tool("amem_remember", "Propose a general memory candidate for person acceptance. Never writes directly to curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] } }, ["text"]), false),
20
+ tool("amem_vault_candidates", "List screened durable curated notes eligible for a person to move to the vault. Read only.", object({ limit })),
21
+ tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it is person-only.", object({})),
22
+ ] as const;
23
+ export type McpToolName = typeof MCP_TOOLS[number]["name"];
24
+ export type McpHandlers = Record<McpToolName, (args: Record<string, unknown>) => unknown | Promise<unknown>>;
25
+ export interface McpOptions {
26
+ input?: Readable;
27
+ output?: Writable;
28
+ tools: McpHandlers;
29
+ close?: () => void | Promise<void>;
30
+ version?: string;
31
+ }
32
+ const FRAME_BYTES = 65_536, RESULT_BYTES = 262_144;
33
+ const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
34
+ const plain = (v: unknown): v is Record<string, unknown> => !!v && typeof v === "object" && !Array.isArray(v) && (Object.getPrototypeOf(v) === Object.prototype || Object.getPrototypeOf(v) === null);
35
+
36
+ /** The deliberately small schema vocabulary of the nine tools, shared with runtime validation. */
37
+ function valid(value: unknown, raw: unknown): boolean {
38
+ const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
39
+ const types = Array.isArray(s.type) ? s.type : [s.type];
40
+ if (!types.includes(type) && !(type === "number" && Number.isSafeInteger(value) && types.includes("integer"))) return false;
41
+ if (s.enum && !s.enum.includes(value)) return false;
42
+ if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false;
43
+ if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
44
+ if (plain(value)) {
45
+ const keys = Object.keys(value);
46
+ if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
47
+ if ((s.required ?? []).some((key: string) => !Object.hasOwn(value, key))) return false;
48
+ for (const key of keys) {
49
+ if (["__proto__", "prototype", "constructor"].includes(key) || key.length > 200) return false;
50
+ if (Object.hasOwn(s.properties ?? {}, key)) { if (!valid(value[key], s.properties[key])) return false; }
51
+ else if (s.additionalProperties === false || !plain(s.additionalProperties) || !valid(value[key], s.additionalProperties)) return false;
52
+ }
53
+ } else if (type === "object") return false;
54
+ return true;
55
+ }
56
+
57
+ /** MCP 2025-06-18 basic/transports: newline stdio only. Sequential, bounded, no listener or timer. */
58
+ export async function serveMcp(options: McpOptions): Promise<void> {
59
+ const input = options.input ?? process.stdin, output = options.output ?? process.stdout;
60
+ let phase: "new" | "initializing" | "ready" = "new";
61
+ const send = async (response: unknown) => { if (!output.write(JSON.stringify(response) + "\n")) await once(output, "drain"); };
62
+ const error = (id: unknown, code: number, message: string) => send({ jsonrpc: "2.0", id, error: { code, message } });
63
+ const handle = async (line: string) => {
64
+ let msg: unknown;
65
+ try { msg = JSON.parse(line); } catch { await error(null, -32700, "Parse error"); return; }
66
+ if (!plain(msg) || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") { await error(null, -32600, "Invalid request"); return; }
67
+ if (!Object.hasOwn(msg, "id")) {
68
+ if (msg.method === "notifications/initialized" && phase === "initializing") phase = "ready";
69
+ return; // Notifications never invoke a mutating handler.
70
+ }
71
+ const id = msg.id;
72
+ if (!(typeof id === "string" && id.length <= 200 || typeof id === "number" && Number.isSafeInteger(id))) { await error(null, -32600, "Invalid request id"); return; }
73
+ if (msg.method === "ping") { await send({ jsonrpc: "2.0", id, result: {} }); return; }
74
+ if (msg.method === "initialize") {
75
+ if (phase !== "new" || !plain(msg.params) || typeof msg.params.protocolVersion !== "string" || !plain(msg.params.capabilities) || !plain(msg.params.clientInfo)) { await error(id, -32602, "Invalid initialization"); return; }
76
+ phase = "initializing";
77
+ await send({ jsonrpc: "2.0", id, result: { protocolVersion: protocols.includes(msg.params.protocolVersion) ? msg.params.protocolVersion : protocols[0],
78
+ capabilities: { tools: { listChanged: false } }, serverInfo: { name: "amem", version: options.version ?? "unreleased" } } });
79
+ return;
80
+ }
81
+ if (phase !== "ready") { await error(id, -32002, "Initialize the session first"); return; }
82
+ if (msg.method === "tools/list") {
83
+ const params = msg.params;
84
+ if (params !== undefined && params !== null && (!plain(params) || Object.keys(params).some(key => key !== "cursor" && key !== "_meta") || params.cursor !== undefined && params.cursor !== null || params._meta !== undefined && (!plain(params._meta) || Object.keys(params._meta).length > 0))) { await error(id, -32602, "Invalid tool listing arguments"); return; }
85
+ await send({ jsonrpc: "2.0", id, result: { tools: MCP_TOOLS } }); return;
86
+ }
87
+ if (msg.method !== "tools/call") { await error(id, -32601, "Method not found"); return; }
88
+ const params = msg.params;
89
+ if (!plain(params) || Object.keys(params).some(k => !["name", "arguments", "_meta"].includes(k)) || typeof params.name !== "string") { await error(id, -32602, "Invalid tool call"); return; }
90
+ const definition = MCP_TOOLS.find(t => t.name === params.name), args = params.arguments ?? {};
91
+ if (!definition || !valid(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
92
+ let result: unknown;
93
+ try {
94
+ const value = await options.tools[definition.name](args as Record<string, unknown>), text = JSON.stringify(value);
95
+ if (text === undefined || Buffer.byteLength(text) > RESULT_BYTES) throw new Error("result unavailable");
96
+ result = { content: [{ type: "text", text }] };
97
+ } catch { result = { isError: true, content: [{ type: "text", text: "Memory operation unavailable or refused; no partial result is shown." }] }; }
98
+ await send({ jsonrpc: "2.0", id, result });
99
+ };
100
+ let pending = "", overflow = false;
101
+ const decoder = new TextDecoder("utf-8", { fatal: true });
102
+ const consume = async (chunk: string) => {
103
+ let start = 0;
104
+ for (;;) {
105
+ const newline = chunk.indexOf("\n", start), part = chunk.slice(start, newline < 0 ? undefined : newline);
106
+ if (!overflow) {
107
+ if (Buffer.byteLength(pending) + Buffer.byteLength(part) > FRAME_BYTES) { overflow = true; pending = ""; }
108
+ else pending += part;
109
+ }
110
+ if (newline < 0) return;
111
+ if (overflow) await error(null, -32600, "Request exceeds the 64 KiB frame bound");
112
+ else if (pending.trim()) await handle(pending);
113
+ pending = ""; overflow = false; start = newline + 1;
114
+ }
115
+ };
116
+ try {
117
+ for await (const chunk of input) await consume(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
118
+ await consume(decoder.decode());
119
+ // A truncated frame on EOF is not an accepted request and must never cause a write.
120
+ if (pending.trim() || overflow) await error(null, -32700, "Incomplete request at EOF");
121
+ } finally { await options.close?.(); }
122
+ }
@@ -14,6 +14,7 @@ export interface CandidateTarget { id: string; revision: string }
14
14
  export type CandidateInput = { project: string } & (
15
15
  | { kind: "correction"; target: CandidateTarget; proposedText: string; reason: string; noteKind?: "decision" | "contract" | "fail" }
16
16
  | { kind: "fail"; target?: CandidateTarget; approach: string; symptom: string; cause: string; evidenceRefs: string[]; retryWhen: string }
17
+ | { kind: "memory"; text: string; title?: string; noteKind?: "decision" | "finding" | "contract" | "fail" }
17
18
  | { kind: "decay"; target: CandidateTarget; reason: string }
18
19
  );
19
20
  export type CandidateStatus = "pending" | "accepting" | "accepted" | "rejected";
@@ -50,6 +51,8 @@ function validate(input: CandidateInput): void {
50
51
  if (!validTarget(input.target) || !text(input.proposedText) || !text(input.reason) || input.noteKind !== undefined && !["decision", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid correction candidate");
51
52
  } else if (input.kind === "fail") {
52
53
  if (input.target !== undefined && !validTarget(input.target) || ![input.approach, input.symptom, input.cause, input.retryWhen].every(x => text(x)) || !Array.isArray(input.evidenceRefs) || input.evidenceRefs.length < 1 || input.evidenceRefs.length > 16 || !input.evidenceRefs.every(x => text(x, 400))) throw new Error("invalid fail candidate");
54
+ } else if (input.kind === "memory") {
55
+ if (!text(input.text, 3000) || input.title !== undefined && !text(input.title, 200) || input.noteKind !== undefined && !["decision", "finding", "contract", "fail"].includes(input.noteKind)) throw new Error("invalid memory candidate");
53
56
  } else if (input.kind === "decay") {
54
57
  if (!validTarget(input.target) || !text(input.reason)) throw new Error("invalid decay candidate");
55
58
  } else throw new Error("invalid candidate kind");
@@ -73,8 +76,13 @@ export class CandidateStore {
73
76
  readonly path: string;
74
77
  private readonly db: Database;
75
78
  private readonly now: () => number;
76
- constructor(options: { home?: string; now?: () => number } = {}) {
77
- this.dir = options.home === undefined ? memoryHome() : join(options.home, "memory");
79
+ /** #27: the keys aliased to the canonical project; its candidates read, accept and reject under the canonical key. Absent in the library default. */
80
+ private readonly reconcile?: { canonical: string; aliases: readonly string[] };
81
+ private scope(project: string): string { return JSON.stringify(this.reconcile && project === this.reconcile.canonical ? [project, ...this.reconcile.aliases] : [project]); }
82
+ constructor(options: { home?: string; memoryDir?: string; now?: () => number; reconcile?: { canonical: string; aliases: readonly string[] } } = {}) {
83
+ if (options.reconcile?.aliases.length) this.reconcile = { canonical: options.reconcile.canonical, aliases: [...options.reconcile.aliases] };
84
+ if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
85
+ this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
78
86
  this.now = options.now ?? Date.now;
79
87
  mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
80
88
  this.path = join(this.dir, "candidates.db");
@@ -108,15 +116,15 @@ export class CandidateStore {
108
116
  get(id: string, project: string): Candidate | undefined {
109
117
  if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
110
118
  this.prune();
111
- const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project = ?").get(id, project) as Parameters<CandidateStore["decode"]>[0] | null;
119
+ const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(project)) as Parameters<CandidateStore["decode"]>[0] | null;
112
120
  return row ? this.decode(row) : undefined;
113
121
  }
114
122
  list(filter: CandidateFilter): Candidate[] {
115
- if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay"].includes(filter.kind)) throw new Error("invalid candidate filter");
123
+ if (!text(filter.project, 400) || filter.status !== undefined && !["pending", "accepting", "accepted", "rejected", "all"].includes(filter.status) || filter.kind !== undefined && !["correction", "fail", "decay", "memory"].includes(filter.kind)) throw new Error("invalid candidate filter");
116
124
  const limit = filter.limit ?? CANDIDATE_RETENTION.count;
117
125
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
118
126
  this.prune();
119
- const where = ["project = ?"], bind: (string | number)[] = [filter.project];
127
+ const where = ["project IN (SELECT value FROM json_each(?))"], bind: (string | number)[] = [this.scope(filter.project)];
120
128
  if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
121
129
  if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
122
130
  return (this.db.query(`SELECT * FROM candidates WHERE ${where.join(" AND ")} ORDER BY at DESC, rowid DESC LIMIT ?`).all(...bind, limit) as Parameters<CandidateStore["decode"]>[0][]).map(row => this.decode(row));
@@ -133,7 +141,7 @@ export class CandidateStore {
133
141
  counters(project: string, entry?: string): Counter[] {
134
142
  if (!text(project, 400) || entry !== undefined && !entryId(entry)) throw new Error("invalid counter filter");
135
143
  this.prune();
136
- const rows = this.db.query("SELECT * FROM counters WHERE project = ? ORDER BY at DESC, rowid DESC").all(project) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
144
+ const rows = this.db.query("SELECT * FROM counters WHERE project IN (SELECT value FROM json_each(?)) ORDER BY at DESC, rowid DESC").all(this.scope(project)) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
137
145
  return rows.map(row => ({ id: row.id, key: JSON.parse(row.payload) as CounterKey, createdAt: new Date(row.at).toISOString(), rendered: row.rendered, delivered: row.delivered, cited: row.cited })).filter(row => !entry || row.key.entryId === entry);
138
146
  }
139
147
  reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
@@ -142,12 +150,12 @@ export class CandidateStore {
142
150
  const candidate = this.get(id, context.project);
143
151
  if (!candidate) throw new Error("candidate unavailable");
144
152
  if (candidate.status === "accepted" || candidate.status === "accepting") throw new Error("candidate acceptance already started; inspect its ordinary note before any further action");
145
- this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project = ? AND status = 'pending'").run(id, context.project);
153
+ this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'pending'").run(id, this.scope(context.project));
146
154
  }).immediate();
147
155
  }
148
156
  accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
149
157
  personOnly(context.actor); publicOnly(context.verdict);
150
- if (realPath(context.store.dir) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
158
+ if (realPath(context.store.profile.home) !== realPath(this.dir) || typeof context.policy !== "function") throw new Error("candidate acceptance requires its memory store and a current host policy");
151
159
  return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
152
160
  }
153
161
  private acceptDeferred(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
@@ -157,21 +165,21 @@ export class CandidateStore {
157
165
  if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
158
166
  if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
159
167
  const resolve = () => {
160
- const target = candidate.input.target;
168
+ const target = candidate.input.kind === "memory" ? undefined : candidate.input.target;
161
169
  if (!target) return undefined;
162
170
  const current = context.store.get(target.id, context.project);
163
171
  if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
164
172
  return current;
165
173
  };
166
174
  const target = resolve(), data = candidate.input;
167
- const body = data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
175
+ const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
168
176
  : `Approach: ${data.approach}\nSymptom: ${data.symptom}\nCause: ${data.cause}\nEvidence: ${data.evidenceRefs.join(", ")}\nRetry when: ${data.retryWhen}`;
169
- const note: NoteInput = { op: "note", project: data.kind === "correction" ? target!.project : context.project,
177
+ const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project,
170
178
  scope: data.kind === "correction" ? target!.scope : "project",
171
179
  // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
172
180
  tier: data.kind === "correction" && target!.local ? "short" : "long",
173
181
  kind: data.kind === "fail" ? "fail" : data.noteKind ?? (target?.kind === "contract" || target?.kind === "fail" ? target.kind : "decision"), peer: "person", source: "person", about: [...target?.about ?? []],
174
- text: `Accepted candidate: ${id}\nTarget revision: ${data.target?.revision ?? "none"}\nTarget entry: ${data.target?.id ?? "none"}\n\n${body}` };
182
+ text: `Accepted candidate: ${id}\nTarget revision: ${(data.kind === "memory" ? undefined : data.target)?.revision ?? "none"}\nTarget entry: ${(data.kind === "memory" ? undefined : data.target)?.id ?? "none"}\n\n${body}` };
175
183
  if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
176
184
  if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
177
185
  return context.store.withAcceptedNote(note, append => {
@@ -181,7 +189,7 @@ export class CandidateStore {
181
189
  if (!current) throw new Error("candidate unavailable");
182
190
  if (current.status === "accepted") return current.acceptedNoteId!;
183
191
  if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
184
- this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project = ?").run(id, context.project);
192
+ this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(context.project));
185
193
  return undefined;
186
194
  }).immediate();
187
195
  if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
@@ -189,7 +197,7 @@ export class CandidateStore {
189
197
  // automatic replay; add explicit person-audited recovery before offering background acceptance retries.
190
198
  const noteId = append();
191
199
  if (data.kind === "correction" && !context.store.supersede(target!.id, noteId, "person", context.project)) throw new Error("candidate replacement refused; inspect the accepted note before recovery");
192
- this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project = ?").run(noteId, id, context.project);
200
+ this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
193
201
  return { candidateId: id, noteId, alreadyAccepted: false };
194
202
  });
195
203
  }
@@ -0,0 +1,179 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
3
+ import { totalmem } from "node:os";
4
+ import { join } from "node:path";
5
+ import { memoryBase } from "./home.ts";
6
+ import { walkShards } from "./catalog.ts";
7
+ import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
8
+
9
+ /**
10
+ * #13 R7: the capacity advisor. `capacityReport()` reads this machine's load and proposes capture bounds; the
11
+ * `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
12
+ * agent may read the proposal through this library function; applying stays person-only at the CLI.
13
+ *
14
+ * Proposal rule (owner decision, 2026-10-17): P1 is shrink-only — the proposed cap and retention are the largest
15
+ * values not exceeding the current ones whose projection stays within the disk-share bound (default 2% of free
16
+ * space, configurable) and whose measured search and hook readings stay within R2/R3. Growth proposals are a
17
+ * follow-up after the agent-hub release; growing in P1 is the person's explicit `amem capacity apply --cap-mb N`.
18
+ * The reason names the binding constraint: `disk-share retention`, `disk-share cap`, `R3 search RSS`, `R2 hook cost`, or `none`.
19
+ *
20
+ * Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
21
+ * `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
22
+ */
23
+
24
+ export interface MetricSample { at: string; kind: "hook" | "search"; wallMs?: number; rssMB?: number; idleMB?: number }
25
+ const METRICS_MAX_BYTES = 64 * 1024, METRICS_KEEP = 200;
26
+
27
+ export function metricsPath(base: string): string {
28
+ return join(base, "metrics.jsonl");
29
+ }
30
+
31
+ /** Append samples, trimming the rolling file when it outgrows its bound (newest samples win). */
32
+ export function appendMetrics(base: string, samples: MetricSample[]): void {
33
+ mkdirSync(base, { recursive: true, mode: 0o700 });
34
+ const file = metricsPath(base);
35
+ appendFileSync(file, samples.map((s) => JSON.stringify(s)).join("\n") + "\n", { mode: 0o600 });
36
+ try {
37
+ if (statSync(file).size <= METRICS_MAX_BYTES) return;
38
+ const lines = readFileSync(file, "utf8").split("\n").filter(Boolean).slice(-METRICS_KEEP);
39
+ writeFileSync(file, lines.join("\n") + "\n", { mode: 0o600 });
40
+ } catch { /* a trim failure loses nothing: the file simply stays larger */ }
41
+ }
42
+
43
+ /** The newest samples first-preserved order (oldest to newest); malformed lines are skipped. */
44
+ export function readMetrics(base: string, limit = METRICS_KEEP): MetricSample[] {
45
+ let lines: string[] = [];
46
+ try { lines = readFileSync(metricsPath(base), "utf8").split("\n").filter(Boolean); } catch { return []; }
47
+ const out: MetricSample[] = [];
48
+ for (const line of lines.slice(-limit)) {
49
+ try {
50
+ const s = JSON.parse(line) as MetricSample;
51
+ if ((s?.kind === "hook" || s?.kind === "search") && typeof s.at === "string") out.push(s);
52
+ } catch { /* a malformed line is skipped */ }
53
+ }
54
+ return out;
55
+ }
56
+
57
+ const p95 = (samples: number[]): number | null => samples.length ? [...samples].sort((a, b) => a - b)[Math.max(0, Math.ceil(samples.length * 0.95) - 1)]! : null;
58
+
59
+ function diskReadings(path: string): { freeBytes: number; totalBytes: number } {
60
+ const df = spawnSync("df", ["-k", path], { encoding: "utf8" });
61
+ const line = df.stdout?.trim().split("\n").at(-1) ?? "";
62
+ const fields = line.split(/\s+/);
63
+ const total = Number(fields[1]), avail = Number(fields[3]);
64
+ if (df.status !== 0 || !Number.isFinite(total) || !Number.isFinite(avail)) throw new Error(`df could not read ${path}`);
65
+ return { freeBytes: avail * 1024, totalBytes: total * 1024 };
66
+ }
67
+
68
+ /** Best-effort memory pressure: macOS `memory_pressure`, Linux `/proc/pressure/memory`; null when neither answers. */
69
+ function memoryPressure(): string | null {
70
+ if (process.platform === "darwin") {
71
+ const r = spawnSync("/usr/bin/memory_pressure", [], { encoding: "utf8" });
72
+ const free = /System-wide memory free percentage:\s*(\d+)%/.exec(r.stdout ?? "")?.[1];
73
+ if (free !== undefined) return `free ${free}%`;
74
+ const level = /The system has (?:memory pressure level )?(\w+)/.exec(r.stdout ?? "")?.[1];
75
+ return level ?? null;
76
+ }
77
+ try {
78
+ const avg10 = /avg10=([\d.]+)/.exec(readFileSync("/proc/pressure/memory", "utf8"))?.[1];
79
+ return avg10 === undefined ? null : `avg10 ${avg10}`;
80
+ } catch { return null; }
81
+ }
82
+
83
+ export interface CapacityReport {
84
+ at: string;
85
+ capture: { shards: number; bytes: number; rows: number; rowsPerDay: number; bytesPerDay: number };
86
+ disk: { freeBytes: number; totalBytes: number };
87
+ memory: { totalBytes: number; pressure: string | null };
88
+ metrics: { samples: number; hookP95Ms: number | null; hookRssMB: number | null; searchP95Ms: number | null; searchRssMB: number | null };
89
+ projected: { days: number; bytes: number };
90
+ current: CaptureConfig;
91
+ diskShare: number;
92
+ proposal: { capMb: number; retentionDays: number; reason: string };
93
+ }
94
+
95
+ const MB = 1048576, GROWTH_DAYS = 7, PROJECT_DAYS = 30;
96
+
97
+ export function capacityReport(opts: {
98
+ home?: string; memoryDir?: string; diskShare?: number; now?: () => number;
99
+ /** Tests and fixtures inject machine readings; the CLI always uses the native ones. */
100
+ inject?: { disk?: { freeBytes: number; totalBytes: number }; memoryPressure?: string | null; metrics?: MetricSample[] };
101
+ } = {}): CapacityReport {
102
+ if (opts.memoryDir !== undefined && opts.home !== undefined) throw new Error("choose a home or an exact memory directory");
103
+ const base = opts.memoryDir ?? memoryBase(opts.home);
104
+ mkdirSync(base, { recursive: true, mode: 0o700 }); // a report on a fresh home still needs a path df can read
105
+ const now = opts.now ?? Date.now;
106
+ const since = new Date(now() - GROWTH_DAYS * 24 * 3600_000).toISOString();
107
+ const shards = walkShards(base);
108
+ let bytes = 0, rows = 0, growthRows = 0, growthBytes = 0;
109
+ for (const shard of shards) for (const dir of shard.machineDirs) {
110
+ const total = captureShardStats(dir);
111
+ rows += total.rows;
112
+ bytes += total.bytes;
113
+ const growth = captureShardGrowth(dir, since);
114
+ growthRows += growth.rows;
115
+ growthBytes += growth.bytes;
116
+ }
117
+ const rowsPerDay = growthRows / GROWTH_DAYS, bytesPerDay = growthBytes / GROWTH_DAYS;
118
+ const disk = opts.inject?.disk ?? diskReadings(base);
119
+ const pressure = opts.inject?.memoryPressure !== undefined ? opts.inject.memoryPressure : memoryPressure();
120
+ const samples = opts.inject?.metrics ?? readMetrics(base);
121
+ const hookWalls = samples.filter((s) => s.kind === "hook" && typeof s.wallMs === "number").map((s) => s.wallMs!);
122
+ const hookRss = samples.filter((s) => s.kind === "hook" && typeof s.rssMB === "number").map((s) => s.rssMB!);
123
+ const searchWalls = samples.filter((s) => s.kind === "search" && typeof s.wallMs === "number").map((s) => s.wallMs!);
124
+ const searchRss = samples.filter((s) => s.kind === "search" && typeof s.rssMB === "number").map((s) => s.rssMB!);
125
+ const metrics = { samples: samples.length, hookP95Ms: p95(hookWalls), hookRssMB: p95(hookRss), searchP95Ms: p95(searchWalls), searchRssMB: p95(searchRss) };
126
+
127
+ const homeConfig = readCaptureConfigFile(join(base, "capture-config.json"));
128
+ const current: CaptureConfig = {
129
+ capMb: homeConfig.capMb ?? CAPTURE_DEFAULT_CAP_MB,
130
+ retentionDays: homeConfig.retentionDays ?? CAPTURE_DEFAULT_RETENTION_DAYS,
131
+ };
132
+ const diskShare = opts.diskShare ?? 0.02;
133
+ // ponytail: the 2026-10-17 owner decision keeps R6 caps per shard in P1, while the disk-share budget is
134
+ // machine-wide; #15 T5 (D7) owns the machine-total split. Until then a home with N shards can hold N x cap.
135
+ const budgetBytes = diskShare * disk.freeBytes;
136
+ let { capMb, retentionDays } = current;
137
+ const binding: string[] = [];
138
+ if (bytesPerDay > 0 && bytesPerDay * retentionDays > budgetBytes) {
139
+ retentionDays = Math.max(1, Math.floor(budgetBytes / bytesPerDay));
140
+ binding.push("disk-share retention: steady-state capture at the current growth exceeds the disk-share bound");
141
+ }
142
+ if (capMb * MB > budgetBytes) {
143
+ capMb = Math.max(1, Math.floor(budgetBytes / MB));
144
+ binding.push("disk-share cap: the cap exceeds the disk-share bound");
145
+ }
146
+ if (metrics.searchRssMB !== null && metrics.searchRssMB > 200) {
147
+ retentionDays = Math.max(1, Math.floor(retentionDays / 2));
148
+ binding.push("R3 search RSS: measured peak search memory exceeds 200 MB");
149
+ }
150
+ if ((metrics.hookRssMB !== null && metrics.hookRssMB > 100) || (metrics.hookP95Ms !== null && metrics.hookP95Ms > 150)) {
151
+ capMb = Math.max(1, Math.floor(capMb / 2));
152
+ binding.push("R2 hook cost: measured hook latency or RSS exceeds the budget");
153
+ }
154
+ const reason = binding.length ? binding.join("; ") : "none: current bounds fit the disk share and the measured R2/R3 readings";
155
+ return {
156
+ at: new Date(now()).toISOString(),
157
+ capture: { shards: shards.length, bytes, rows, rowsPerDay, bytesPerDay },
158
+ disk,
159
+ memory: { totalBytes: totalmem(), pressure },
160
+ metrics,
161
+ projected: { days: PROJECT_DAYS, bytes: Math.round(bytes + bytesPerDay * PROJECT_DAYS) },
162
+ current,
163
+ diskShare,
164
+ proposal: { capMb, retentionDays, reason },
165
+ };
166
+ }
167
+
168
+ /** The non-JSON rendering of `amem capacity`. */
169
+ export function capacityText(report: CapacityReport): string {
170
+ const mb = (n: number): string => `${(n / MB).toFixed(1)} MB`;
171
+ return [
172
+ `capture: ${report.capture.rows} rows, ${mb(report.capture.bytes)} in ${report.capture.shards} shard(s), +${report.capture.rowsPerDay.toFixed(1)} rows/day, +${mb(report.capture.bytesPerDay)}/day`,
173
+ `disk: ${mb(report.disk.freeBytes)} free of ${mb(report.disk.totalBytes)}; memory: ${mb(report.memory.totalBytes)} total${report.memory.pressure ? `, pressure ${report.memory.pressure}` : ""}`,
174
+ `metrics: ${report.metrics.samples} samples (hook p95 ${report.metrics.hookP95Ms?.toFixed(1) ?? "?"} ms, search peak ${report.metrics.searchRssMB?.toFixed(1) ?? "?"} MB)`,
175
+ `projected capture in ${report.projected.days} days: ${mb(report.projected.bytes)}`,
176
+ `current: cap ${report.current.capMb} MB, retention ${report.current.retentionDays} days`,
177
+ `proposal: cap ${report.proposal.capMb} MB, retention ${report.proposal.retentionDays} days (${report.proposal.reason})`,
178
+ ].join("\n");
179
+ }