@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/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
- // ponytail: 5000 rows per tier per operation; above this ceiling refuse rather than select on an incomplete privacy-filtered corpus. Upgrade to screened streaming pagination.
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
- const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true }) : new MemoryStore({ memoryDir: base, deferPromotions: true });
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 captures = async () => screen(capture.rows({ project, limit: MCP_SCAN_LIMIT + 1 }));
67
- const find = (rows: View[], id: string): View | null => { const found = rows.filter(row => row.id === id || row.id.startsWith(id.replace(/^mem:/, ""))); return found.length === 1 ? found[0]! : null; };
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 (index.available ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
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: index.available ? "fts5" : "substring", hits: selected };
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)) { const row = (await captures()).find(row => `capture:${row.id}` === id); guard(); return row ? { source: "capture", evidence: false, capture: row } : null; }
95
- const result = find(await curated(true), id); guard(); return result;
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"]); const rows = await curated(true), recalls = await captures(), id = textOf(args, "id", true), since = sinceOf(args), limit = limitOf(args);
99
- const entries = [...rows.map(row => ({ at: row.at, value: row as unknown })), ...recalls.map(row => ({ at: row.at, value: { source: "capture", evidence: false, capture: row } as unknown }))];
100
- let selected = entries.filter(row => !since || Date.parse(row.at) >= Date.parse(since));
101
- if (id) {
102
- const anchor = id.startsWith("capture:") ? recalls.find(row => `capture:${row.id}` === id) : find(rows, id);
103
- if (!anchor) return []; selected = selected.sort((a, b) => Math.abs(Date.parse(a.at) - Date.parse(anchor.at)) - Math.abs(Date.parse(b.at) - Date.parse(anchor.at)));
104
- } else selected.sort((a, b) => b.at.localeCompare(a.at));
105
- guard(); return selected.slice(0, limit).map(row => row.value);
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
+
@@ -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
- constructor(options: { home?: string; memoryDir?: string; now?: () => number } = {}) {
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 = ?").get(id, project) as Parameters<CandidateStore["decode"]>[0] | null;
119
+ const row = this.db.query("SELECT * FROM candidates WHERE id = ? AND project IN (SELECT value FROM json_each(?))").get(id, this.scope(project)) as Parameters<CandidateStore["decode"]>[0] | null;
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 = ?"], bind: (string | number)[] = [filter.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 = ? ORDER BY at DESC, rowid DESC").all(project) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
144
+ const rows = this.db.query("SELECT * FROM counters WHERE project IN (SELECT value FROM json_each(?)) ORDER BY at DESC, rowid DESC").all(this.scope(project)) as { id: string; payload: string; at: number; rendered: number | null; delivered: number | null; cited: number | null }[];
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 = ? AND status = 'pending'").run(id, context.project);
153
+ this.db.query("UPDATE candidates SET status = 'rejected' WHERE id = ? AND project IN (SELECT value FROM json_each(?)) AND status = 'pending'").run(id, this.scope(context.project));
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 = ?").run(id, context.project);
192
+ this.db.query("UPDATE candidates SET status = 'accepting' WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(id, this.scope(context.project));
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 = ?").run(noteId, id, context.project);
200
+ this.db.query("UPDATE candidates SET status = 'accepted', note_id = ? WHERE id = ? AND project IN (SELECT value FROM json_each(?))").run(noteId, id, this.scope(context.project));
197
201
  return { candidateId: id, noteId, alreadyAccepted: false };
198
202
  });
199
203
  }
@@ -1,8 +1,10 @@
1
1
  import { spawnSync } from "node:child_process";
2
- import { appendFileSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
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): 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`.
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
- let { capMb, retentionDays } = current;
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
- 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");
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.length ? binding.join("; ") : "none: current bounds fit the disk share and the measured R2/R3 readings";
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
+ }