@staix/agent-mem 0.3.1 → 0.4.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/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,9 @@ 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
+ constructor(options: { home?: string; memoryDir?: string; now?: () => number } = {}) {
80
+ if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
81
+ this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
78
82
  this.now = options.now ?? Date.now;
79
83
  mkdirSync(this.dir, { recursive: true, mode: 0o700 }); chmodSync(this.dir, 0o700);
80
84
  this.path = join(this.dir, "candidates.db");
@@ -112,7 +116,7 @@ export class CandidateStore {
112
116
  return row ? this.decode(row) : undefined;
113
117
  }
114
118
  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");
119
+ 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
120
  const limit = filter.limit ?? CANDIDATE_RETENTION.count;
117
121
  if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
118
122
  this.prune();
@@ -147,7 +151,7 @@ export class CandidateStore {
147
151
  }
148
152
  accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
149
153
  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");
154
+ 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
155
  return context.store.withDeferredPromotions(() => this.acceptDeferred(id, context));
152
156
  }
153
157
  private acceptDeferred(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
@@ -157,21 +161,21 @@ export class CandidateStore {
157
161
  if (candidate.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
158
162
  if (candidate.input.kind === "decay") throw new Error("decay candidates are listing-only; no automatic mutation is available");
159
163
  const resolve = () => {
160
- const target = candidate.input.target;
164
+ const target = candidate.input.kind === "memory" ? undefined : candidate.input.target;
161
165
  if (!target) return undefined;
162
166
  const current = context.store.get(target.id, context.project);
163
167
  if (!current || current.id !== target.id || candidateTargetRevision(current) !== target.revision) throw new Error("candidate target revision is stale");
164
168
  return current;
165
169
  };
166
170
  const target = resolve(), data = candidate.input;
167
- const body = data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
171
+ const body = data.kind === "memory" ? data.text : data.kind === "correction" ? `${data.proposedText}\n\nReason: ${data.reason}`
168
172
  : `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,
173
+ const note: NoteInput = { op: "note", ...(data.kind === "memory" && data.title ? { title: data.title } : {}), project: data.kind === "correction" ? target!.project : context.project,
170
174
  scope: data.kind === "correction" ? target!.scope : "project",
171
175
  // A demoted synced target is logically short, but replacing it locally would narrow its reach (X04).
172
176
  tier: data.kind === "correction" && target!.local ? "short" : "long",
173
177
  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}` };
178
+ 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
179
  if (note.text.length > 4000) throw new Error("accepted candidate text exceeds the v1 note limit");
176
180
  if (context.policy(structuredClone(candidate), target && structuredClone(target), structuredClone(note)) !== true) throw new Error("candidate refused by current host policy");
177
181
  return context.store.withAcceptedNote(note, append => {
@@ -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
+ }