@staix/agent-mem 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +5 -0
- package/README.md +57 -7
- package/bin/amem.js +26 -3
- package/hooks/hooks.json +81 -0
- package/package.json +3 -1
- package/src/cli.ts +33 -7
- package/src/commands-table.ts +4 -0
- package/src/hook-contract.ts +3 -0
- package/src/hook-git.ts +42 -0
- package/src/hook-native.ts +92 -0
- package/src/hook-project.ts +206 -0
- package/src/hook-resources.ts +15 -0
- package/src/hook-spool.ts +544 -0
- package/src/hooks.ts +249 -0
- package/src/index.ts +4 -0
- package/src/ingest.ts +350 -0
- package/src/mcp-memory.ts +71 -28
- package/src/memory/aliases.ts +83 -0
- package/src/memory/candidates.ts +11 -7
- package/src/memory/capacity.ts +23 -48
- package/src/memory/capture-input.ts +31 -0
- package/src/memory/capture.ts +245 -59
- package/src/memory/catalog.ts +3 -4
- package/src/memory/import.ts +2 -3
- package/src/memory/machine.ts +31 -0
- package/src/memory/metrics.ts +63 -0
- package/src/memory/mutation.ts +6 -4
- package/src/memory/platform.ts +9 -4
- package/src/memory/project.ts +129 -20
- package/src/memory/reader-lease.ts +22 -10
- package/src/memory/search-fts.ts +44 -3
- package/src/memory/shard.ts +3 -46
- package/src/memory/store.ts +108 -62
- package/src/memory/work-lists.ts +227 -68
- package/src/session-brief-worker.ts +81 -0
- package/src/session-brief.ts +202 -0
package/src/mcp-memory.ts
CHANGED
|
@@ -10,14 +10,17 @@ import { canonicalPath, realPath } from "./memory/platform.ts";
|
|
|
10
10
|
import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
|
|
11
11
|
import { memoryProjectKey } from "./memory/project.ts";
|
|
12
12
|
import { registerReaderLease } from "./memory/reader-lease.ts";
|
|
13
|
-
import { SearchIndex } from "./memory/search-fts.ts";
|
|
14
|
-
import { readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
13
|
+
import { type ReadRef, type ReadWindow, SearchIndex } from "./memory/search-fts.ts";
|
|
14
|
+
import { aliasedKeys, readShardedSelection, resolveAlias } from "./memory/shard.ts";
|
|
15
15
|
import { MemoryStore, openShardStore, type Graph, type View } from "./memory/store.ts";
|
|
16
16
|
import { vaultCandidates } from "./memory/vault.ts";
|
|
17
17
|
import { WorkListStore, type WorkListFields } from "./memory/work-lists.ts";
|
|
18
18
|
|
|
19
|
-
//
|
|
19
|
+
// Graph retains its complete-corpus ceiling; search/timeline/show page matching bodies instead.
|
|
20
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;
|
|
21
24
|
export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions }
|
|
22
25
|
export interface MemoryMcp { tools: McpHandlers; close(): void }
|
|
23
26
|
const unavailable = (): never => { throw new Error("memory operation unavailable"); };
|
|
@@ -27,7 +30,7 @@ const textOf = (args: Record<string, unknown>, key: string, optional = false): s
|
|
|
27
30
|
const value = args[key]; if (value === undefined && optional) return undefined;
|
|
28
31
|
return typeof value === "string" && value.trim() && !value.includes("\0") ? value : unavailable();
|
|
29
32
|
};
|
|
30
|
-
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; };
|
|
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(); };
|
|
31
34
|
|
|
32
35
|
/** All identities are resolved once. Per-call arguments cannot change projects, homes, profiles or policy. */
|
|
33
36
|
export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
@@ -40,15 +43,17 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
40
43
|
const selection = readSelector(base).revision;
|
|
41
44
|
const sharded = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
|
|
42
45
|
const shardSelection = sharded ? JSON.stringify(readShardedSelection(base)) : undefined;
|
|
43
|
-
|
|
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 } : {}) });
|
|
44
49
|
closers.push(() => store.close());
|
|
45
50
|
const capture = new CaptureStore({ memoryDir: base, project }); closers.push(() => capture.close());
|
|
46
|
-
const candidates = new CandidateStore({ memoryDir: base }); closers.push(() => candidates.close());
|
|
47
|
-
const work = new WorkListStore({ base, project, policy: async row => { const permitted = await policy.screen(row); guard(); return permitted; } }); closers.push(() => work.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());
|
|
48
53
|
const guard = () => {
|
|
49
54
|
if (closed) unavailable();
|
|
50
55
|
release.verify();
|
|
51
|
-
if (resolveAlias(base, requested) !== project || readSelector(base).revision !== selection) unavailable();
|
|
56
|
+
if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection) unavailable();
|
|
52
57
|
const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
|
|
53
58
|
if (split !== sharded || split && JSON.stringify(readShardedSelection(base)) !== shardSelection) unavailable();
|
|
54
59
|
};
|
|
@@ -63,46 +68,84 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
|
|
|
63
68
|
if (rows.length > MCP_SCAN_LIMIT) unavailable();
|
|
64
69
|
return screen(rows.filter(row => row.project === project));
|
|
65
70
|
};
|
|
66
|
-
const
|
|
67
|
-
|
|
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
|
+
};
|
|
68
93
|
const tools: McpHandlers = {
|
|
69
94
|
async amem_search(args) {
|
|
70
95
|
bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
|
|
71
96
|
if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
|
|
72
97
|
if (!await policy.screen({ query })) unavailable();
|
|
73
|
-
const notes = await curated(), recalls = args.includeCapture === false ? [] : await captures();
|
|
74
|
-
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(" ")}` })),
|
|
75
|
-
...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));
|
|
76
98
|
const index = new SearchIndex(":memory:");
|
|
77
99
|
try {
|
|
78
|
-
index.resync(() => hits.map(hit => ({ id: hit.ref, at: hit.at, project, tier: hit.tier, scope: "project", body: hit.body })));
|
|
79
|
-
if (index.available && index.needsResync(hits.length)) unavailable();
|
|
80
|
-
const ranks = new Map(index.query(query, MCP_SCAN_LIMIT * 2, project).map(row => [row.ref, row.rank]));
|
|
81
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]));
|
|
82
118
|
const selected = hits.flatMap(hit => {
|
|
83
119
|
const rank = ranks.get(hit.ref);
|
|
84
|
-
if (
|
|
120
|
+
if (useFts ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
|
|
85
121
|
const weight = hit.tier === "long" ? 1 : hit.tier === "capture" ? 0.5 : 0.9;
|
|
86
122
|
const score = -(rank ?? -1) * weight / (1 + Math.max(0, Date.now() - Date.parse(hit.at)) / (30 * 86400_000));
|
|
87
123
|
return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "curated" ? { view: hit.view } : { capture: hit.capture }), score }];
|
|
88
124
|
}).sort((a, b) => b.score - a.score).slice(0, limit);
|
|
89
|
-
guard(); return { engine:
|
|
125
|
+
guard(); return { engine: useFts ? "fts5" : "substring", hits: selected };
|
|
90
126
|
} finally { index.close(); }
|
|
91
127
|
},
|
|
92
128
|
async amem_show(args) {
|
|
93
129
|
bound(args, ["id"]); guard(); const id = textOf(args, "id")!;
|
|
94
|
-
if (/^capture:[1-9][0-9]*$/.test(id)) {
|
|
95
|
-
|
|
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;
|
|
96
136
|
},
|
|
97
137
|
async amem_timeline(args) {
|
|
98
|
-
bound(args, ["id", "since", "limit"]);
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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);
|
|
106
149
|
},
|
|
107
150
|
async amem_graph(args) {
|
|
108
151
|
bound(args, ["start", "hops", "limit"]); const start = textOf(args, "start")!, cap = limitOf(args), hops = args.hops ?? 1;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
|
|
5
|
+
|
|
6
|
+
export interface AliasOp { schema: typeof SHARD_SCHEMA; op: "alias"; at: string; machine: string; alias: string; key: string; actor: string }
|
|
7
|
+
/**
|
|
8
|
+
* #27 option 2: the person's removal of one alias. An append-only op in the same local `aliases.jsonl` (never a shared-v1 segment, so no shared-v1 reader sees a new op
|
|
9
|
+
* kind there: an older reader skips the line and keeps resolving the alias, which is the C1 "local only" placement). The latest op for an alias wins, so after an
|
|
10
|
+
* `unalias` the key resolves to itself again, and a later `alias` op maps it again.
|
|
11
|
+
*/
|
|
12
|
+
export interface UnaliasOp { schema: typeof SHARD_SCHEMA; op: "unalias"; at: string; machine: string; alias: string; actor: string }
|
|
13
|
+
export type AliasLogOp = AliasOp | UnaliasOp;
|
|
14
|
+
|
|
15
|
+
export function aliasLogPath(base: string): string {
|
|
16
|
+
return join(base, "aliases.jsonl");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* One person-reviewed alias op, appended to `aliases.jsonl`. An alias never chains (its target must not itself be
|
|
21
|
+
* an alias), and a machine that races the append keeps both ops: the latest one wins at read time.
|
|
22
|
+
*/
|
|
23
|
+
export function appendAlias(base: string, input: { alias: string; key: string; actor: string; machine: string; at?: string }): AliasOp {
|
|
24
|
+
const alias = input.alias.trim(), key = input.key.trim();
|
|
25
|
+
if (!alias || !key) throw new Error("an alias needs both keys");
|
|
26
|
+
if (alias === key) throw new Error("an alias cannot target itself");
|
|
27
|
+
if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
|
|
28
|
+
// The other direction too, using only the latest op per alias: a target abandoned by a later retarget or a removal is free again.
|
|
29
|
+
const currentTargets = new Set(effectiveAliases(base).values());
|
|
30
|
+
if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
|
|
31
|
+
const op: AliasOp = { schema: SHARD_SCHEMA, op: "alias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, key, actor: input.actor };
|
|
32
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
33
|
+
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
34
|
+
return op;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every alias and unalias op in order (the full history); a malformed or unknown line is skipped, never fatal. */
|
|
38
|
+
export function listAliases(base: string): AliasLogOp[] {
|
|
39
|
+
let text = "";
|
|
40
|
+
try { text = readFileSync(aliasLogPath(base), "utf8"); } catch { return []; }
|
|
41
|
+
const ops: AliasLogOp[] = [];
|
|
42
|
+
for (const line of text.split("\n").filter(Boolean)) {
|
|
43
|
+
try {
|
|
44
|
+
const op = JSON.parse(line) as AliasLogOp;
|
|
45
|
+
if (op?.schema !== SHARD_SCHEMA || typeof op.alias !== "string") continue;
|
|
46
|
+
if (op.op === "alias" && typeof op.key === "string") ops.push(op);
|
|
47
|
+
else if (op.op === "unalias") ops.push(op);
|
|
48
|
+
} catch { /* a malformed line is skipped */ }
|
|
49
|
+
}
|
|
50
|
+
return ops;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The aliases in force: for each alias key the latest op decides, and an `unalias` removes it. Map of alias key to canonical key. */
|
|
54
|
+
export function effectiveAliases(base: string): Map<string, string> {
|
|
55
|
+
const latest = new Map<string, string>();
|
|
56
|
+
for (const op of listAliases(base)) { if (op.op === "alias") latest.set(op.alias, op.key); else latest.delete(op.alias); }
|
|
57
|
+
return latest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The canonical key for a possibly aliased one: the latest op for it, one hop (aliases never chain). An unaliased key is its own canonical key. */
|
|
61
|
+
export function resolveAlias(base: string, key: string): string {
|
|
62
|
+
return effectiveAliases(base).get(key) ?? key;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every key currently aliased to `canonical`, sorted (the keys whose curated rows the CLI and MCP project filter now covers, #27 option 1). */
|
|
66
|
+
export function aliasedKeys(base: string, canonical: string): string[] {
|
|
67
|
+
return [...effectiveAliases(base)].filter(([, key]) => key === canonical).map(([alias]) => alias).sort();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* One person-reviewed removal, appended to `aliases.jsonl`. Nothing is rewritten: the alias line stays in the history and the later op wins. Refused when the key is
|
|
72
|
+
* not aliased now, so a typo never records a removal that means nothing.
|
|
73
|
+
*/
|
|
74
|
+
export function appendUnalias(base: string, input: { alias: string; actor: string; machine: string; at?: string }): UnaliasOp {
|
|
75
|
+
const alias = input.alias.trim();
|
|
76
|
+
if (!alias) throw new Error("an alias removal needs the alias key");
|
|
77
|
+
if (!effectiveAliases(base).has(alias)) throw new Error(`no alias to remove: ${alias} is not aliased now`);
|
|
78
|
+
const op: UnaliasOp = { schema: SHARD_SCHEMA, op: "unalias", at: input.at ?? new Date().toISOString(), machine: input.machine, alias, actor: input.actor };
|
|
79
|
+
mkdirSync(base, { recursive: true, mode: 0o700 });
|
|
80
|
+
appendFileSync(aliasLogPath(base), JSON.stringify(op) + "\n", { mode: 0o600 });
|
|
81
|
+
return op;
|
|
82
|
+
}
|
|
83
|
+
|
package/src/memory/candidates.ts
CHANGED
|
@@ -76,7 +76,11 @@ export class CandidateStore {
|
|
|
76
76
|
readonly path: string;
|
|
77
77
|
private readonly db: Database;
|
|
78
78
|
private readonly now: () => number;
|
|
79
|
-
|
|
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] };
|
|
80
84
|
if (options.memoryDir !== undefined && options.home !== undefined) throw new Error("choose a home or an exact memory directory");
|
|
81
85
|
this.dir = options.memoryDir ?? (options.home === undefined ? memoryHome() : join(options.home, "memory"));
|
|
82
86
|
this.now = options.now ?? Date.now;
|
|
@@ -112,7 +116,7 @@ export class CandidateStore {
|
|
|
112
116
|
get(id: string, project: string): Candidate | undefined {
|
|
113
117
|
if (!candidateId(id) || !text(project, 400)) throw new Error("invalid candidate identity");
|
|
114
118
|
this.prune();
|
|
115
|
-
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;
|
|
116
120
|
return row ? this.decode(row) : undefined;
|
|
117
121
|
}
|
|
118
122
|
list(filter: CandidateFilter): Candidate[] {
|
|
@@ -120,7 +124,7 @@ export class CandidateStore {
|
|
|
120
124
|
const limit = filter.limit ?? CANDIDATE_RETENTION.count;
|
|
121
125
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > CANDIDATE_RETENTION.count) throw new Error("candidate limit must be 1..500");
|
|
122
126
|
this.prune();
|
|
123
|
-
const where = ["project
|
|
127
|
+
const where = ["project IN (SELECT value FROM json_each(?))"], bind: (string | number)[] = [this.scope(filter.project)];
|
|
124
128
|
if (filter.status === undefined) where.push("status IN ('pending', 'accepting')"); else if (filter.status !== "all") { where.push("status = ?"); bind.push(filter.status); }
|
|
125
129
|
if (filter.kind) { where.push("kind = ?"); bind.push(filter.kind); }
|
|
126
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));
|
|
@@ -137,7 +141,7 @@ export class CandidateStore {
|
|
|
137
141
|
counters(project: string, entry?: string): Counter[] {
|
|
138
142
|
if (!text(project, 400) || entry !== undefined && !entryId(entry)) throw new Error("invalid counter filter");
|
|
139
143
|
this.prune();
|
|
140
|
-
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 }[];
|
|
141
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);
|
|
142
146
|
}
|
|
143
147
|
reject(id: string, context: { project: string; actor: "person" | "agent"; verdict: CandidateVerdict }): void {
|
|
@@ -146,7 +150,7 @@ export class CandidateStore {
|
|
|
146
150
|
const candidate = this.get(id, context.project);
|
|
147
151
|
if (!candidate) throw new Error("candidate unavailable");
|
|
148
152
|
if (candidate.status === "accepted" || candidate.status === "accepting") throw new Error("candidate acceptance already started; inspect its ordinary note before any further action");
|
|
149
|
-
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));
|
|
150
154
|
}).immediate();
|
|
151
155
|
}
|
|
152
156
|
accept(id: string, context: CandidateAcceptance): CandidateAcceptanceResult {
|
|
@@ -185,7 +189,7 @@ export class CandidateStore {
|
|
|
185
189
|
if (!current) throw new Error("candidate unavailable");
|
|
186
190
|
if (current.status === "accepted") return current.acceptedNoteId!;
|
|
187
191
|
if (current.status !== "pending") throw new Error("candidate is rejected or acceptance was interrupted; no automatic replay");
|
|
188
|
-
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));
|
|
189
193
|
return undefined;
|
|
190
194
|
}).immediate();
|
|
191
195
|
if (started) return { candidateId: id, noteId: started, alreadyAccepted: true };
|
|
@@ -193,7 +197,7 @@ export class CandidateStore {
|
|
|
193
197
|
// automatic replay; add explicit person-audited recovery before offering background acceptance retries.
|
|
194
198
|
const noteId = append();
|
|
195
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");
|
|
196
|
-
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));
|
|
197
201
|
return { candidateId: id, noteId, alreadyAccepted: false };
|
|
198
202
|
});
|
|
199
203
|
}
|
package/src/memory/capacity.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync, readFileSync } from "node:fs";
|
|
3
3
|
import { totalmem } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { memoryBase } from "./home.ts";
|
|
6
|
+
import { readMetrics, type MetricSample } from "./metrics.ts";
|
|
7
|
+
export { appendMetrics, readMetrics, metricsPath, type MetricSample } from "./metrics.ts";
|
|
6
8
|
import { walkShards } from "./catalog.ts";
|
|
7
9
|
import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_DEFAULT_CAP_MB, CAPTURE_DEFAULT_RETENTION_DAYS, type CaptureConfig } from "./capture.ts";
|
|
8
10
|
|
|
@@ -11,49 +13,16 @@ import { captureShardGrowth, captureShardStats, readCaptureConfigFile, CAPTURE_D
|
|
|
11
13
|
* `amem capacity` CLI renders it and `amem capacity apply` writes the home default config after a preview. An
|
|
12
14
|
* agent may read the proposal through this library function; applying stays person-only at the CLI.
|
|
13
15
|
*
|
|
14
|
-
* Proposal rule (owner decision, 2026-10-17):
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Proposal rule (owner decision, 2026-10-17, item 2): the proposed cap and retention are the largest values whose projection stays within the
|
|
17
|
+
* disk-share bound (default 2% of free space, configurable) and whose measured search and hook readings stay within R2/R3, and they may be above
|
|
18
|
+
* the current ones. The cap is the disk-share bound itself; retention is the share over the daily growth, up to a 365-day ceiling. R2 halves the cap
|
|
19
|
+
* and R3 halves the retention. The reason names every binding constraint: `disk-share retention` or `retention ceiling`, `disk-share cap`,
|
|
20
|
+
* `R3 search RSS`, `R2 hook cost`. The advisor never writes: applying stays person-only, preview first.
|
|
19
21
|
*
|
|
20
22
|
* Metrics: a small rolling `metrics.jsonl` in the memory home (not a memory log: it is trimmed, never synced).
|
|
21
23
|
* `scripts/measure-budget.ts` writes only to its synthetic home; the P3 hooks will append real samples per invocation.
|
|
22
24
|
*/
|
|
23
25
|
|
|
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
26
|
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
27
|
|
|
59
28
|
function diskReadings(path: string): { freeBytes: number; totalBytes: number } {
|
|
@@ -93,6 +62,8 @@ export interface CapacityReport {
|
|
|
93
62
|
}
|
|
94
63
|
|
|
95
64
|
const MB = 1048576, GROWTH_DAYS = 7, PROJECT_DAYS = 30;
|
|
65
|
+
// ponytail: the advisor never proposes more than a year of retention. The ceiling keeps a quiet home's proposal finite; the upgrade path is a person setting (a configured ceiling) once someone wants longer.
|
|
66
|
+
const RETENTION_CEILING_DAYS = 365;
|
|
96
67
|
|
|
97
68
|
export function capacityReport(opts: {
|
|
98
69
|
home?: string; memoryDir?: string; diskShare?: number; now?: () => number;
|
|
@@ -133,16 +104,20 @@ export function capacityReport(opts: {
|
|
|
133
104
|
// ponytail: the 2026-10-17 owner decision keeps R6 caps per shard in P1, while the disk-share budget is
|
|
134
105
|
// machine-wide; #15 T5 (D7) owns the machine-total split. Until then a home with N shards can hold N x cap.
|
|
135
106
|
const budgetBytes = diskShare * disk.freeBytes;
|
|
136
|
-
|
|
107
|
+
// Growth (owner decision 2026-10-17, item 2): propose the largest cap and retention that fit the disk share, above the current values when there is room.
|
|
108
|
+
// The cap's ceiling is the disk-share bound itself. A person's own setting above the retention ceiling is never proposed down for the ceiling alone.
|
|
109
|
+
const retentionCeiling = Math.max(RETENTION_CEILING_DAYS, current.retentionDays);
|
|
110
|
+
let capMb = Math.max(1, Math.floor(budgetBytes / MB));
|
|
111
|
+
let retentionDays = retentionCeiling;
|
|
137
112
|
const binding: string[] = [];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
binding.push("disk-share cap: the cap exceeds the disk-share bound");
|
|
113
|
+
const fitDays = bytesPerDay > 0 ? Math.floor(budgetBytes / bytesPerDay) : Infinity;
|
|
114
|
+
if (fitDays < retentionCeiling) {
|
|
115
|
+
retentionDays = Math.max(1, fitDays);
|
|
116
|
+
binding.push("disk-share retention: steady-state capture at the current growth fills the disk-share bound");
|
|
117
|
+
} else {
|
|
118
|
+
binding.push(`retention ceiling: ${retentionCeiling} days fit the disk share, so the ceiling stops growth`);
|
|
145
119
|
}
|
|
120
|
+
binding.push("disk-share cap: the cap is the largest size the disk-share bound allows");
|
|
146
121
|
if (metrics.searchRssMB !== null && metrics.searchRssMB > 200) {
|
|
147
122
|
retentionDays = Math.max(1, Math.floor(retentionDays / 2));
|
|
148
123
|
binding.push("R3 search RSS: measured peak search memory exceeds 200 MB");
|
|
@@ -151,7 +126,7 @@ export function capacityReport(opts: {
|
|
|
151
126
|
capMb = Math.max(1, Math.floor(capMb / 2));
|
|
152
127
|
binding.push("R2 hook cost: measured hook latency or RSS exceeds the budget");
|
|
153
128
|
}
|
|
154
|
-
const reason = binding.
|
|
129
|
+
const reason = binding.join("; ");
|
|
155
130
|
return {
|
|
156
131
|
at: new Date(now()).toISOString(),
|
|
157
132
|
capture: { shards: shards.length, bytes, rows, rowsPerDay, bytesPerDay },
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { looksSecret } from "./screen.ts";
|
|
2
|
+
import { isDenied } from "./platform.ts";
|
|
3
|
+
|
|
4
|
+
export const CAPTURE_MAX_EXCERPT = 2000;
|
|
5
|
+
export const CAPTURE_MAX_PATHS = 32;
|
|
6
|
+
const ROW_OVERHEAD_BYTES = 96;
|
|
7
|
+
|
|
8
|
+
export interface CaptureFields {
|
|
9
|
+
agent: string; event: string;
|
|
10
|
+
tool?: string; paths?: string[]; status?: string; excerpt?: string;
|
|
11
|
+
/** Tests and the spool replay pin the time; hooks leave it to the store clock. */
|
|
12
|
+
at?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface NormalizedCaptureFields {
|
|
15
|
+
at: string; agent: string; event: string;
|
|
16
|
+
tool: string | null; paths: string[]; status: string | null; excerpt: string; bytes: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pure capture shaping; no store, project lookup or filesystem operation is loaded or invoked here. */
|
|
20
|
+
export function normalizeCaptureFields(input: CaptureFields, now: () => number = Date.now, screen: (text: string) => boolean = looksSecret): NormalizedCaptureFields {
|
|
21
|
+
const str = (v: unknown, max: number) => typeof v === "string" && v.length > 0 && v.length <= max;
|
|
22
|
+
// Canonical UTC ISO text, so text order is time order for eviction (`at < cutoff`) and search (`at >= notBefore`).
|
|
23
|
+
const at = new Date(input.at === undefined ? now() : Date.parse(input.at)).toISOString();
|
|
24
|
+
const tool = str(input.tool, 64) ? input.tool! : null;
|
|
25
|
+
const status = str(input.status, 64) ? input.status! : null;
|
|
26
|
+
const paths = (input.paths ?? []).map(String).filter((p) => p && !isDenied(p)).slice(0, CAPTURE_MAX_PATHS).map((p) => p.slice(0, 400));
|
|
27
|
+
let excerpt = (input.excerpt ?? "").slice(0, CAPTURE_MAX_EXCERPT);
|
|
28
|
+
if (excerpt && screen(excerpt)) excerpt = "[secret-screened]";
|
|
29
|
+
return { at, agent: input.agent, event: input.event, tool, paths, status, excerpt,
|
|
30
|
+
bytes: Buffer.byteLength(excerpt) + Buffer.byteLength(JSON.stringify(paths)) + ROW_OVERHEAD_BYTES };
|
|
31
|
+
}
|