@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.
- package/README.md +76 -6
- package/package.json +4 -3
- package/src/cli.ts +126 -15
- package/src/commands-table.ts +16 -2
- package/src/index.ts +13 -0
- package/src/mcp-memory.ts +194 -0
- package/src/mcp.ts +122 -0
- package/src/memory/candidates.ts +22 -14
- package/src/memory/capacity.ts +179 -0
- package/src/memory/capture.ts +557 -0
- package/src/memory/catalog.ts +135 -0
- package/src/memory/commands.ts +7 -4
- package/src/memory/generation-index.ts +54 -11
- package/src/memory/generation.ts +41 -2
- package/src/memory/home.ts +5 -0
- package/src/memory/import.ts +59 -0
- package/src/memory/migrate.ts +8 -0
- package/src/memory/mutation.ts +13 -1
- package/src/memory/ops-contract.ts +19 -2
- package/src/memory/policy.ts +120 -0
- package/src/memory/project.ts +22 -1
- package/src/memory/reader-lease.ts +136 -0
- package/src/memory/screen.ts +8 -0
- package/src/memory/search-fts.ts +244 -0
- package/src/memory/shard-bridge.ts +19 -0
- package/src/memory/shard-migration.ts +562 -0
- package/src/memory/shard.ts +213 -0
- package/src/memory/store.ts +376 -67
- package/src/memory/vault.ts +40 -0
- package/src/memory/work-lists.ts +331 -0
|
@@ -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
|
+
}
|
package/src/memory/candidates.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
+
}
|