@staix/agent-mem 0.6.0 → 0.7.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.
Files changed (63) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +12 -2
  3. package/README.md +135 -18
  4. package/package.json +1 -1
  5. package/src/cli.ts +476 -39
  6. package/src/commands-table.ts +53 -15
  7. package/src/hook-spool.ts +24 -2
  8. package/src/hooks.ts +9 -5
  9. package/src/index.ts +14 -0
  10. package/src/ingest.ts +3 -2
  11. package/src/mcp-memory.ts +141 -33
  12. package/src/mcp.ts +42 -7
  13. package/src/memory/aliases.ts +4 -0
  14. package/src/memory/bridge.ts +8 -2
  15. package/src/memory/candidate-pending.ts +119 -0
  16. package/src/memory/candidates.ts +144 -47
  17. package/src/memory/capacity-split.ts +76 -0
  18. package/src/memory/capacity.ts +139 -11
  19. package/src/memory/capture-input.ts +8 -3
  20. package/src/memory/capture.ts +30 -5
  21. package/src/memory/catalog.ts +43 -10
  22. package/src/memory/commands.ts +98 -44
  23. package/src/memory/config.ts +167 -0
  24. package/src/memory/cross-project.ts +75 -0
  25. package/src/memory/curated-read.ts +29 -0
  26. package/src/memory/decay-review.ts +136 -0
  27. package/src/memory/doctor.ts +412 -0
  28. package/src/memory/editor.ts +58 -0
  29. package/src/memory/eval-fixture.ts +137 -0
  30. package/src/memory/eval-set.json +443 -0
  31. package/src/memory/eval.ts +150 -0
  32. package/src/memory/gc.ts +97 -0
  33. package/src/memory/generation-index.ts +22 -17
  34. package/src/memory/generation-sync.ts +3 -0
  35. package/src/memory/goal-promotion.ts +71 -0
  36. package/src/memory/home.ts +15 -4
  37. package/src/memory/intent-brief.ts +89 -0
  38. package/src/memory/lifecycle-log.ts +54 -0
  39. package/src/memory/lifecycle.ts +61 -0
  40. package/src/memory/metrics.ts +2 -2
  41. package/src/memory/note-add.ts +100 -0
  42. package/src/memory/path-glob.ts +94 -0
  43. package/src/memory/plan-guard.ts +47 -0
  44. package/src/memory/policy.ts +44 -13
  45. package/src/memory/private-spans.ts +32 -0
  46. package/src/memory/project-export.ts +122 -0
  47. package/src/memory/recall-staleness.ts +56 -0
  48. package/src/memory/recall.ts +74 -0
  49. package/src/memory/repo-scope.ts +404 -0
  50. package/src/memory/shard-sync-format.ts +116 -0
  51. package/src/memory/shard-sync.ts +421 -0
  52. package/src/memory/store.ts +282 -27
  53. package/src/memory/sync-cadence.ts +139 -0
  54. package/src/memory/sync-screen.ts +86 -0
  55. package/src/memory/sync.ts +79 -11
  56. package/src/memory/usage.ts +35 -0
  57. package/src/memory/use-telemetry.ts +51 -0
  58. package/src/memory/work-lists.ts +10 -4
  59. package/src/session-brief-worker.ts +3 -2
  60. package/src/session-brief.ts +74 -15
  61. package/src/setup-owned.ts +120 -0
  62. package/src/setup-toml.ts +291 -0
  63. package/src/setup.ts +327 -0
package/src/mcp-memory.ts CHANGED
@@ -1,17 +1,26 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { buildIntentBrief } from "./memory/intent-brief.ts";
4
+ import { createIntentBriefSource, recallViewIsLive } from "./memory/recall.ts";
5
+ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
6
+ import { recordMemoryUse } from "./memory/use-telemetry.ts";
1
7
  import { lstatSync } from "node:fs";
2
8
  import { join } from "node:path";
3
- import type { McpHandlers } from "./mcp.ts";
4
- import { CandidateStore } from "./memory/candidates.ts";
9
+ import type { McpHandlers, McpOptions } from "./mcp.ts";
10
+ import { CandidateStore, acceptedCorrectionText, candidateTargetRevision, type CandidateInput } from "./memory/candidates.ts";
5
11
  import { capacityReport } from "./memory/capacity.ts";
6
12
  import { CaptureStore } from "./memory/capture.ts";
7
- import { readSelector } from "./memory/generation.ts";
13
+ import { hashJSON, readSelector } from "./memory/generation.ts";
8
14
  import { memoryHome } from "./memory/home.ts";
9
15
  import { canonicalPath, realPath } from "./memory/platform.ts";
10
- import { createStandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
16
+ import { createStandalonePolicy, type StandalonePolicy, type StandalonePolicyOptions } from "./memory/policy.ts";
17
+ import { configuredPolicyOptions } from "./memory/config.ts";
11
18
  import { memoryProjectKey } from "./memory/project.ts";
12
19
  import { registerReaderLease } from "./memory/reader-lease.ts";
13
20
  import { type ReadRef, type ReadWindow, SearchIndex } from "./memory/search-fts.ts";
14
21
  import { aliasedKeys, readShardedSelection, resolveAlias } from "./memory/shard.ts";
22
+ import { projectState } from "./memory/lifecycle.ts";
23
+ import { boundRepoRoot, readRepoScope, repoReadPolicy, type RepoView } from "./memory/repo-scope.ts";
15
24
  import { MemoryStore, openShardStore, type Graph, type View } from "./memory/store.ts";
16
25
  import { vaultCandidates } from "./memory/vault.ts";
17
26
  import { WorkListStore, type WorkListFields } from "./memory/work-lists.ts";
@@ -21,8 +30,8 @@ export const MCP_SCAN_LIMIT = 5000;
21
30
  // 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
31
  const SEARCH_CANDIDATES = 5000;
23
32
  const READ_PAGE = 256;
24
- export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions }
25
- export interface MemoryMcp { tools: McpHandlers; close(): void }
33
+ export interface MemoryMcpOptions { cwd: string; base?: string; project?: string; policy?: StandalonePolicyOptions; allowed?: () => boolean; policyVersion?: () => string; assertCurrent?: () => void }
34
+ export interface MemoryMcp { tools: McpHandlers; close(): void; onEmitted: NonNullable<McpOptions["onEmitted"]> }
26
35
  const unavailable = (): never => { throw new Error("memory operation unavailable"); };
27
36
  const bound = (args: Record<string, unknown>, keys: string[]) => { if (Object.keys(args).some(key => !keys.includes(key))) unavailable(); };
28
37
  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();
@@ -34,9 +43,19 @@ const sinceOf = (args: Record<string, unknown>): string | undefined => { const s
34
43
 
35
44
  /** All identities are resolved once. Per-call arguments cannot change projects, homes, profiles or policy. */
36
45
  export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
46
+ const permitted = () => { try { return options.allowed?.() !== false; } catch { return false; } };
47
+ if (!permitted()) return unavailable();
37
48
  const cwd = realPath(options.cwd), base = canonicalPath(options.base ?? memoryHome());
38
49
  const requested = options.project ?? memoryProjectKey(cwd), project = resolveAlias(base, requested);
39
- const policy = createStandalonePolicy(options.policy), release = registerReaderLease(base);
50
+ // #15 T5: an archived or forgotten project has no MCP server: the person took it out of recall.
51
+ if (projectState(base, project) !== "active") unavailable();
52
+ // F4: the person's configured policy (machine file, plus this repository's narrowing file) screens every MCP answer; a host passes a stricter one through `options.policy`.
53
+ const calls = new AsyncLocalStorage<{ policy: StandalonePolicy; revision: string; configured: StandalonePolicyOptions }>();
54
+ const policyOptions = () => configuredPolicyOptions(base, cwd, options.policy);
55
+ const policyKey = (value: StandalonePolicyOptions) => hashJSON({ denyPaths: value.denyPaths ?? [], piiPatterns: value.piiPatterns ?? [], hostRevision: options.policyVersion?.() ?? null });
56
+ const currentPolicy = () => calls.getStore()?.policy ?? createStandalonePolicy(policyOptions());
57
+ const policyRevisionForCall = () => { const revision = policyKey(policyOptions()), active = calls.getStore(); if (active && active.revision !== revision) return unavailable(); return revision; };
58
+ const policy = currentPolicy(), release = registerReaderLease(base);
40
59
  const closers: (() => void)[] = [release]; let closed = false;
41
60
  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
61
  try {
@@ -47,26 +66,47 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
47
66
  const aliases = aliasedKeys(base, project), reconcile = aliases.length ? { canonical: project, aliases } : undefined;
48
67
  const store = sharded ? openShardStore({ memoryDir: base, project, deferPromotions: true, ...(reconcile ? { reconcile } : {}) }) : new MemoryStore({ memoryDir: base, deferPromotions: true, ...(reconcile ? { reconcile } : {}) });
49
68
  closers.push(() => store.close());
50
- const capture = new CaptureStore({ memoryDir: base, project }); closers.push(() => capture.close());
69
+ const capture = new CaptureStore({ memoryDir: base, project, cwd }); closers.push(() => capture.close());
70
+ // #15 T4: the bound repository's `.amem/log.jsonl`, only when that repository's own key IS this project (AC3). The root is fixed at start like the project; the file is read per call.
71
+ const repoRoot = boundRepoRoot(cwd, base, project);
72
+ const repoRows = async (): Promise<RepoView[]> => {
73
+ const scoped = repoRoot ? repoReadPolicy(repoRoot, calls.getStore()?.configured ?? policyOptions()) : undefined;
74
+ if (!scoped || "reason" in scoped) return [];
75
+ const read = await readRepoScope(repoRoot, scoped.policy); guard();
76
+ return read.state === "ok" ? read.views : [];
77
+ };
51
78
  const candidates = new CandidateStore({ memoryDir: base, ...(reconcile ? { reconcile } : {}) }); closers.push(() => candidates.close());
52
79
  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();
80
+ const fullGuard = () => {
81
+ if (!permitted()) unavailable();
82
+ if (closed) unavailable(); options.assertCurrent?.();
55
83
  release.verify();
56
- if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection) unavailable();
84
+ if (resolveAlias(base, requested) !== project || aliasedKeys(base, project).join("\n") !== aliases.join("\n") || readSelector(base).revision !== selection || projectState(base, project) !== "active") unavailable();
57
85
  const split = !!lstatSync(join(base, "shards.json"), { throwIfNoEntry: false });
58
86
  if (split !== sharded || split && JSON.stringify(readShardedSelection(base)) !== shardSelection) unavailable();
59
87
  };
88
+ const guard = () => { if (!permitted() || closed) return unavailable(); if (!calls.getStore()) fullGuard(); };
89
+ const emitted = new WeakMap<object, { ids: string[]; deliveryId: string }>(), session = randomUUID(); let sequence = 0;
90
+ const observed = <T>(value: T, ids: readonly string[], _rows: readonly unknown[] = []): T => { guard(); if (value && typeof value === "object") emitted.set(value, { ids: [...new Set(ids)].filter(id => /^[a-f0-9]{32}$/.test(id)).slice(0, 256), deliveryId: `mcp:${session}:${++sequence}` }); return value; };
91
+ const onEmitted: MemoryMcp["onEmitted"] = (_tool, _requestId, value) => {
92
+ if (!value || typeof value !== "object") return;
93
+ const receipt = emitted.get(value); emitted.delete(value); if (!receipt?.ids.length) return;
94
+ const { ids, deliveryId } = receipt;
95
+ for (const entryId of ids) recordMemoryUse(candidates, { project, entryId, deliveryId, session }, { rendered: 1 }, { allowed: permitted, assertCurrent: guard });
96
+ };
97
+ const staleRoot = repoRoot ?? cwd, rootConfirmed = repoRoot !== undefined || project.startsWith("local:") && resolveAlias(base, memoryProjectKey(cwd)) === project;
98
+ const stale = () => { policyRevisionForCall(); return createRecallStalenessProbe({ cwd: staleRoot, rootConfirmed, policy: currentPolicy }); };
99
+ const decorate = (view: View, probe: ReturnType<typeof stale>) => ({ ...view, ...(probe(view) === true ? { stale: true } : {}) });
60
100
  const screen = async <T>(rows: T[]): Promise<T[]> => {
61
101
  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;
102
+ const publicRows: T[] = []; for (const row of rows) { guard(); if (await currentPolicy().screen(row)) publicRows.push(row); guard(); } return publicRows;
63
103
  };
64
104
  const curated = async (superseded = false) => {
65
105
  guard();
66
106
  // list() includes shared rows: remove other projects before screening or selection.
67
107
  const rows = store.list({ project, includeSuperseded: superseded, limit: MCP_SCAN_LIMIT + 1 });
68
108
  if (rows.length > MCP_SCAN_LIMIT) unavailable();
69
- return screen(rows.filter(row => row.project === project));
109
+ return screen(rows.filter(row => row.project === project && recallViewIsLive(row, store.readNow())));
70
110
  };
71
111
  const readPermitted = async <T>(read: (window: ReadWindow) => { rows: T[]; next?: ReadRef; engine?: "fts5" | "substring" },
72
112
  options: Omit<ReadWindow, "project" | "limit" | "after">, cap: number, matches: (row: T, engine?: "fts5" | "substring") => boolean = () => true): Promise<T[]> => {
@@ -74,7 +114,7 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
74
114
  while (result.length < cap) {
75
115
  guard();
76
116
  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)) {
117
+ for (const row of page.rows) if (await currentPolicy().screen(row) && matches(row, page.engine)) {
78
118
  result.push(row); if (result.length === cap) break;
79
119
  }
80
120
  guard();
@@ -87,14 +127,35 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
87
127
  if (/^capture:[1-9][0-9]*$/.test(id)) {
88
128
  return (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0]?.at;
89
129
  }
90
- const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { prefix: id.replace(/^mem:/, "") }, 2);
130
+ const rows = await readPermitted(window => store.readPage(window), { prefix: id.replace(/^mem:/, "") }, 2, view => recallViewIsLive(view, store.readNow()));
91
131
  return rows.length === 1 ? rows[0]!.at : undefined;
92
132
  };
133
+ const correctionTarget = (id: string): View => {
134
+ guard();
135
+ const current = store.get(id, project);
136
+ // Proposal visibility matches MCP reads; shared rows from another project are not evidence here.
137
+ if (!current || current.id !== id || current.supersededBy || current.project !== project) return unavailable();
138
+ return current;
139
+ };
140
+ const propose = async (input: CandidateInput, target?: View): Promise<{ id: string }> => {
141
+ if (input.kind === "correction" && acceptedCorrectionText(input).length > 4000) return unavailable();
142
+ const revision = target && candidateTargetRevision(target);
143
+ const currentPolicy = () => createStandalonePolicy(configuredPolicyOptions(base, cwd, options.policy));
144
+ const check = () => { guard(); if (target && candidateTargetRevision(correctionTarget(target.id)) !== revision) unavailable(); };
145
+ const complete = target ? { ...input, targetView: target } : input;
146
+ check();
147
+ if (!await currentPolicy().screen(complete)) unavailable();
148
+ check();
149
+ // Config can tighten while the host screen awaits. Its synchronous check fences the final append.
150
+ if (!currentPolicy().screenStandalone(complete)) unavailable();
151
+ check();
152
+ return { id: candidates.put(input, "public") };
153
+ };
93
154
  const tools: McpHandlers = {
94
155
  async amem_search(args) {
95
156
  bound(args, ["query", "limit", "since", "includeCapture"]); const query = textOf(args, "query")!, limit = limitOf(args), since = sinceOf(args);
96
157
  if (args.includeCapture !== undefined && typeof args.includeCapture !== "boolean") unavailable();
97
- if (!await policy.screen({ query })) unavailable();
158
+ if (!await currentPolicy().screen({ query })) unavailable();
98
159
  const index = new SearchIndex(":memory:");
99
160
  try {
100
161
  const words = query.toLowerCase().split(/\s+/).filter(Boolean);
@@ -107,9 +168,11 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
107
168
  (row, engine) => index.available && engine === "fts5" || words.every(word => `${row.excerpt}\n${row.paths.join(" ")}\n${row.event}`.toLowerCase().includes(word)));
108
169
  const useFts = index.available && captureState.engine !== "substring";
109
170
  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)));
171
+ view => recallViewIsLive(view, store.readNow()) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
172
+ const repoNotes = (await repoRows()).filter(view => (!since || Date.parse(view.at) >= Date.parse(since)) && (useFts || words.every(word => `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}`.toLowerCase().includes(word))));
111
173
  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));
174
+ ...repoNotes.map(view => ({ source: "repo" as const, evidence: false, view, ref: `repo:${view.id}`, at: view.at, tier: "long", body: `${view.title ?? ""}\n${view.text}\n${view.about.join(" ")}` })),
175
+ ...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)) && (hit.source !== "curated" || recallViewIsLive(hit.view, store.readNow())));
113
176
  if (useFts) {
114
177
  index.resync(() => hits.map(hit => ({ id: hit.ref, at: hit.at, project, tier: hit.tier, scope: "project", body: hit.body })));
115
178
  if (index.needsResync(hits.length)) unavailable();
@@ -120,19 +183,22 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
120
183
  if (useFts ? rank === undefined : !words.every(word => hit.body.toLowerCase().includes(word))) return [];
121
184
  const weight = hit.tier === "long" ? 1 : hit.tier === "capture" ? 0.5 : 0.9;
122
185
  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 }];
186
+ return [{ source: hit.source, evidence: hit.evidence, ...(hit.source === "capture" ? { capture: hit.capture } : { view: hit.view }), score }];
124
187
  }).sort((a, b) => b.score - a.score).slice(0, limit);
125
- guard(); return { engine: useFts ? "fts5" : "substring", hits: selected };
188
+ const probe = stale(), result = { engine: useFts ? "fts5" : "substring", hits: selected.map(hit => hit.source === "curated" && "view" in hit && hit.view ? { ...hit, view: decorate(hit.view as View, probe) } : hit) };
189
+ guard(); return observed(result, selected.flatMap(hit => hit.source === "curated" && "view" in hit && hit.view ? [hit.view.id] : []), selected.map(hit => "view" in hit ? hit.view : hit.capture));
126
190
  } finally { index.close(); }
127
191
  },
128
192
  async amem_show(args) {
129
193
  bound(args, ["id"]); guard(); const id = textOf(args, "id")!;
194
+ if (/^repo:[a-f0-9]{32}$/.test(id)) { const row = (await repoRows()).find(view => `repo:${view.id}` === id); guard(); return row ? { source: "repo", evidence: false, view: row } : null; }
130
195
  if (/^capture:[1-9][0-9]*$/.test(id)) {
131
196
  const row = (await readPermitted(window => capture.readPage(window), { id: id.slice(8) }, 1))[0];
132
197
  guard(); return row ? { source: "capture", evidence: false, capture: row } : null;
133
198
  }
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;
199
+ const rows = await readPermitted(window => store.readPage(window), { prefix: id.replace(/^mem:/, "") }, 2);
200
+ const view = rows.length === 1 && recallViewIsLive(rows[0]!, store.readNow()) ? rows[0]! : undefined;
201
+ guard(); return view ? observed(decorate(view, stale()), [view.id], [view]) : null;
136
202
  },
137
203
  async amem_timeline(args) {
138
204
  bound(args, ["id", "since", "limit"]);
@@ -140,17 +206,30 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
140
206
  guard();
141
207
  const anchor = id ? await anchorAt(id) : undefined;
142
208
  if (id && !anchor) { guard(); return []; }
143
- const rows = await readPermitted(window => store.readPage({ ...window, includeSuperseded: true }), { since, anchor }, limit);
209
+ const rows = await readPermitted(window => store.readPage(window), { since, anchor }, limit, view => recallViewIsLive(view, store.readNow()));
144
210
  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 })),
211
+ const entries: { at: string; ref: string; value: unknown; memoryId?: string }[] = [...rows.map(row => ({ at: row.at, ref: row.id, value: row as unknown, memoryId: row.id })),
146
212
  ...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);
213
+ const currentEntries = entries.filter(row => !row.memoryId || recallViewIsLive(row.value as View, store.readNow()));
214
+ currentEntries.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));
215
+ const selected = currentEntries.slice(0, limit), probe = stale();
216
+ const values = selected.map(row => row.memoryId ? decorate(row.value as View, probe) : row.value);
217
+ guard(); return observed(values, selected.flatMap(row => row.memoryId ? [row.memoryId] : []), selected.map(row => row.value));
218
+ },
219
+ async amem_brief(args) {
220
+ bound(args, ["intent", "paths", "symbols", "maxBytes"]); guard();
221
+ const values = (key: string): string[] => { const value = args[key]; if (value === undefined) return []; if (!Array.isArray(value) || value.length > 150 || value.some(item => typeof item !== "string" || item.length > 512 || item.includes("\0"))) return unavailable(); return value as string[]; };
222
+ const intent = textOf(args, "intent")!, paths = values("paths"), symbols = values("symbols"), maxBytes = args.maxBytes ?? 2048;
223
+ if (intent.length > 1000 || !Number.isSafeInteger(maxBytes) || Number(maxBytes) < 1 || Number(maxBytes) > 8192) return unavailable();
224
+ const result = await buildIntentBrief(createIntentBriefSource(store, project),
225
+ { project, cwd: staleRoot, repo: project, intent, paths, symbols, maxBytes: Number(maxBytes), policy: currentPolicy, policyVersion: policyRevisionForCall, allowed: permitted, assertCurrent: guard, now: () => store.readNow(), stale: stale() });
226
+ guard();
227
+ return observed({ brief: result.text, bytes: result.bytes, truncated: result.truncated, state: result.state }, result.items.map(view => view.id), result.items);
149
228
  },
150
229
  async amem_graph(args) {
151
230
  bound(args, ["start", "hops", "limit"]); const start = textOf(args, "start")!, cap = limitOf(args), hops = args.hops ?? 1;
152
231
  if (hops !== 1 && hops !== 2) unavailable();
153
- const rows = await curated(true); const result = screenedGraph(rows, start, Number(hops), cap); guard(); return result;
232
+ const rows = (await curated()).filter(row => recallViewIsLive(row, store.readNow())); const result = screenedGraph(rows, start, Number(hops), cap); guard(); return observed(result, result.nodes.filter(node => node.id.startsWith("mem:")).map(node => node.id.slice(4)), rows.filter(row => result.nodes.some(node => node.id === `mem:${row.id}`)));
154
233
  },
155
234
  async amem_work_state_read(args) {
156
235
  bound(args, ["list", "includeClosed"]); guard();
@@ -164,15 +243,44 @@ export function createMemoryMcp(options: MemoryMcpOptions): MemoryMcp {
164
243
  },
165
244
  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
245
  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") };
246
+ const structured = ["approach", "symptom", "cause", "evidenceRefs", "retryWhen"];
247
+ bound(args, ["text", "title", "kind", ...structured]); guard();
248
+ const kind = args.kind ?? "finding";
249
+ if (typeof kind !== "string" || !["decision", "finding", "contract", "fail"].includes(kind)) unavailable();
250
+ if (structured.some(key => args[key] !== undefined)) {
251
+ if (kind !== "fail" || args.text !== undefined || args.title !== undefined || !Array.isArray(args.evidenceRefs) || args.evidenceRefs.length < 1 || args.evidenceRefs.length > 16) return unavailable();
252
+ const evidenceRefs = args.evidenceRefs.map(value => typeof value === "string" && value.trim() && value.length <= 400 && !value.includes("\0") ? value : unavailable());
253
+ return propose({ project, kind: "fail", approach: textOf(args, "approach")!, symptom: textOf(args, "symptom")!, cause: textOf(args, "cause")!, evidenceRefs, retryWhen: textOf(args, "retryWhen")! });
254
+ }
255
+ return propose({ project, kind: "memory", text: textOf(args, "text")!, ...(args.title === undefined ? {} : { title: textOf(args, "title")! }), noteKind: kind as "decision" | "finding" | "contract" | "fail" });
171
256
  },
172
- async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy, limit: limitOf(args) }); guard(); return result; },
257
+ async amem_correct(args) {
258
+ bound(args, ["id", "proposedText", "reason", "kind"]); guard();
259
+ const id = textOf(args, "id")!.replace(/^mem:/, "");
260
+ if (!/^[a-f0-9]{32}$/.test(id) || args.kind !== undefined && (typeof args.kind !== "string" || !["decision", "contract", "fail"].includes(args.kind))) unavailable();
261
+ const target = correctionTarget(id);
262
+ const input: CandidateInput = { project, kind: "correction", target: { id, revision: candidateTargetRevision(target) }, proposedText: textOf(args, "proposedText")!, reason: textOf(args, "reason")!,
263
+ ...(args.kind === undefined ? {} : { noteKind: args.kind as "decision" | "contract" | "fail" }) };
264
+ return propose(input, target);
265
+ },
266
+ async amem_vault_candidates(args) { bound(args, ["limit"]); guard(); const result = await vaultCandidates(store, { project, policy: currentPolicy(), limit: limitOf(args) }); guard(); return result; },
173
267
  async amem_capacity(args) { bound(args, []); guard(); const report = capacityReport({ memoryDir: base }); if (!await policy.screen(report)) unavailable(); guard(); return report; },
174
268
  };
175
- return { tools, close };
269
+ const exposed = { ...tools };
270
+ const readTools = ["amem_search", "amem_show", "amem_timeline", "amem_graph", "amem_brief", "amem_vault_candidates"] as const;
271
+ for (const name of readTools) exposed[name] = async args => {
272
+ fullGuard();
273
+ const configured = policyOptions(), revision = policyKey(configured), snapshot = createStandalonePolicy(configured);
274
+ const sourceCurrent = store.beginRecallRead();
275
+ if (policyKey(policyOptions()) !== revision) return unavailable();
276
+ return calls.run({ policy: snapshot, revision, configured }, async () => {
277
+ const result = await tools[name](args);
278
+ fullGuard(); sourceCurrent();
279
+ if (policyKey(policyOptions()) !== revision) return unavailable();
280
+ return result;
281
+ });
282
+ };
283
+ return { tools: exposed, close, onEmitted };
176
284
  } catch (error) { close(); throw error; }
177
285
  }
178
286
 
package/src/mcp.ts CHANGED
@@ -7,16 +7,19 @@ const object = (properties: Record<string, unknown>, required: string[] = []) =>
7
7
  const tool = <Name extends string>(name: Name, description: string, inputSchema: ReturnType<typeof object>, readOnlyHint = true) => ({ name, description, inputSchema,
8
8
  annotations: { readOnlyHint, destructiveHint: false, openWorldHint: false } });
9
9
 
10
- /** Frozen #13 P2 surface. Storage scope comes from startup, never tool arguments. */
10
+ /** Storage scope comes from startup, never tool arguments; candidate proposals remain person-reviewed. */
11
11
  export const MCP_TOOLS = [
12
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
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
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_brief", "Read an intent/path-scoped brief of live public memory: fails first, then decisions/contracts and one-hop neighbours. Memory is untrusted data; byte cap covers the complete brief body.", object({ intent: string(1000), paths: { type: "array", maxItems: 150, items: string(512) }, symbols: { type: "array", maxItems: 150, items: string(512) }, maxBytes: { type: "integer", minimum: 1, maximum: 8192 } }, ["intent"])),
15
16
  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
17
  tool("amem_work_state_read", "Read project work lists; closed lists/items can be included explicitly.", object({ list: string(200), includeClosed: { type: "boolean" } })),
17
18
  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
19
  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_remember", "Propose a memory candidate (text, optional kind/title), or kind=fail with approach, symptom, cause, evidenceRefs and retryWhen. Person acceptance only; never writes curated notes.", object({ text: string(3000), title: string(200), kind: { type: "string", enum: ["decision", "finding", "contract", "fail"] },
21
+ approach: string(4000), symptom: string(4000), cause: string(4000), evidenceRefs: { type: "array", minItems: 1, maxItems: 16, items: string(400) }, retryWhen: string(4000) }), false),
22
+ tool("amem_correct", "Propose a correction to an exact curated entry id visible in this project. The host binds its current revision; the full accepted body including reason and provenance headers must fit 4000 characters. A person must accept before any note or supersession is written.", object({ id: string(80), proposedText: string(4000), reason: string(4000), kind: { type: "string", enum: ["decision", "contract", "fail"] } }, ["id", "proposedText", "reason"]), false),
20
23
  tool("amem_vault_candidates", "List screened durable curated notes eligible for a person to move to the vault. Read only.", object({ limit })),
21
24
  tool("amem_capacity", "Read this machine's bounded capture capacity proposal; applying it is person-only.", object({})),
22
25
  ] as const;
@@ -28,12 +31,14 @@ export interface McpOptions {
28
31
  tools: McpHandlers;
29
32
  close?: () => void | Promise<void>;
30
33
  version?: string;
34
+ /** Host-only observation after a successful tool response reaches the write callback. Never implies delivery or citation. */
35
+ onEmitted?: (tool: McpToolName, requestId: string | number, value: unknown) => void | Promise<void>;
31
36
  }
32
37
  const FRAME_BYTES = 65_536, RESULT_BYTES = 262_144;
33
38
  const protocols = ["2025-06-18", "2025-03-26", "2024-11-05"];
34
39
  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
40
 
36
- /** The deliberately small schema vocabulary of the nine tools, shared with runtime validation. */
41
+ /** The deliberately small schema vocabulary of the tools, shared with runtime validation. */
37
42
  function valid(value: unknown, raw: unknown): boolean {
38
43
  const s = raw as Record<string, any>, type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
39
44
  const types = Array.isArray(s.type) ? s.type : [s.type];
@@ -41,6 +46,10 @@ function valid(value: unknown, raw: unknown): boolean {
41
46
  if (s.enum && !s.enum.includes(value)) return false;
42
47
  if (typeof value === "string" && (value.length < (s.minLength ?? 0) || value.length > (s.maxLength ?? FRAME_BYTES) || value.includes("\0"))) return false;
43
48
  if (typeof value === "number" && (!Number.isFinite(value) || value < (s.minimum ?? -Infinity) || value > (s.maximum ?? Infinity))) return false;
49
+ if (Array.isArray(value)) {
50
+ if (value.length < (s.minItems ?? 0) || value.length > (s.maxItems ?? 64) || !plain(s.items)) return false;
51
+ return value.every(item => valid(item, s.items));
52
+ }
44
53
  if (plain(value)) {
45
54
  const keys = Object.keys(value);
46
55
  if (keys.length < (s.minProperties ?? 0) || keys.length > (s.maxProperties ?? 64)) return false;
@@ -54,6 +63,24 @@ function valid(value: unknown, raw: unknown): boolean {
54
63
  return true;
55
64
  }
56
65
 
66
+ /** Shared callback-confirmed emission; close/error are failures and never delivery evidence. */
67
+ export const writeConfirmed = (output: Writable, text: string): Promise<void> => new Promise((resolve, reject) => {
68
+ let settled = false, writing = true, callbackDone = false, callbackFailure: Error | null | undefined;
69
+ const finish = (failure?: Error | null) => {
70
+ if (settled) return; settled = true; output.removeListener("close", closed);
71
+ // A Writable callback failure precedes its error event; keep that one-shot listener until the event arrives.
72
+ if (!failure) output.removeListener("error", failed);
73
+ if (failure) reject(failure); else resolve();
74
+ };
75
+ const failed = (failure: Error) => { finish(failure); output.removeListener("error", failed); };
76
+ const closed = () => { finish(Error("output closed before emission")); output.removeListener("error", failed); };
77
+ output.once("error", failed); output.once("close", closed);
78
+ try {
79
+ output.write(text, failure => { callbackDone = true; callbackFailure = failure; if (!writing) finish(failure); });
80
+ writing = false; if (callbackDone) finish(callbackFailure);
81
+ } catch (failure) { writing = false; finish(failure as Error); output.removeListener("error", failed); }
82
+ });
83
+
57
84
  /** MCP 2025-06-18 basic/transports: newline stdio only. Sequential, bounded, no listener or timer. */
58
85
  export async function serveMcp(options: McpOptions): Promise<void> {
59
86
  const input = options.input ?? process.stdin, output = options.output ?? process.stdout;
@@ -89,13 +116,21 @@ export async function serveMcp(options: McpOptions): Promise<void> {
89
116
  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
117
  const definition = MCP_TOOLS.find(t => t.name === params.name), args = params.arguments ?? {};
91
118
  if (!definition || !valid(args, definition.inputSchema)) { await error(id, -32602, "Invalid tool name or arguments"); return; }
92
- let result: unknown;
119
+ let result: unknown, observedValue: unknown, succeeded = false;
93
120
  try {
94
121
  const value = await options.tools[definition.name](args as Record<string, unknown>), text = JSON.stringify(value);
95
122
  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 });
123
+ result = { content: [{ type: "text", text }] }; observedValue = value; succeeded = true;
124
+ } catch (failure) {
125
+ // Every failure is opaque to the model except one named, fixed, text-free condition: the person's review queue is full. It carries no stored content, so it is safe to say, and it
126
+ // tells the agent what to do (stop proposing, ask the person) instead of leaving it to retry blindly.
127
+ const full = typeof failure === "object" && failure !== null && (failure as { code?: unknown }).code === "candidate_queue_full";
128
+ result = { isError: true, content: [{ type: "text", text: full ? "The person's review queue is full: no more candidates are accepted until the person runs `amem review`. Do not retry; ask the person to review the pending candidates." : "Memory operation unavailable or refused; no partial result is shown." }] };
129
+ }
130
+ if (succeeded && options.onEmitted) {
131
+ await writeConfirmed(output, JSON.stringify({ jsonrpc: "2.0", id, result }) + "\n");
132
+ try { await options.onEmitted(definition.name, id, observedValue); } catch { /* optional observation never changes the response or emits another */ }
133
+ } else await send({ jsonrpc: "2.0", id, result });
99
134
  };
100
135
  let pending = "", overflow = false;
101
136
  const decoder = new TextDecoder("utf-8", { fatal: true });
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { lifecycleStates } from "./lifecycle-log.ts";
3
4
 
4
5
  export const SHARD_SCHEMA = "agent-hub.memory.shard/v1";
5
6
 
@@ -25,6 +26,9 @@ export function appendAlias(base: string, input: { alias: string; key: string; a
25
26
  if (!alias || !key) throw new Error("an alias needs both keys");
26
27
  if (alias === key) throw new Error("an alias cannot target itself");
27
28
  if (resolveAlias(base, key) !== key) throw new Error("an alias cannot target another alias");
29
+ // A forget is terminal: an alias op could otherwise bring a forgotten project's notes back under an active key, or hide an active one behind it.
30
+ const lifecycle = lifecycleStates(base);
31
+ for (const forgotten of [alias, key]) if (lifecycle.get(forgotten) === "forgotten") throw new Error(`project ${forgotten} is forgotten: an alias to or from it would bring it back`);
28
32
  // 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
33
  const currentTargets = new Set(effectiveAliases(base).values());
30
34
  if (currentTargets.has(alias)) throw new Error("an alias target cannot become an alias");
@@ -38,6 +38,10 @@ function directory(path: string, optional = false): boolean {
38
38
  catch (error) { if (optional && (error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; }
39
39
  }
40
40
 
41
+ export class SegmentReadBudgetError extends Error {
42
+ readonly code = "retained_read_budget";
43
+ constructor() { super("retained history unavailable within the read byte budget"); this.name = "SegmentReadBudgetError"; }
44
+ }
41
45
  export interface SegmentReadMetrics { bytesRead: number; bytesHashed: number; coldChanged: number }
42
46
  interface PrefixState { offset: number; hash: string; hasher: Hash; bytes: Buffer }
43
47
  type SegmentBytes = Pick<LegacySegment, "bytes" | "offset" | "hash" | "tailBytes" | "stamp" | "start">;
@@ -47,6 +51,7 @@ const statStamp = (stat: { dev: bigint | number; ino: bigint | number; size: big
47
51
  export class SegmentReader {
48
52
  private readonly prefixes = new Map<string, PrefixState>();
49
53
  private measured: SegmentReadMetrics = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 };
54
+ constructor(private remainingBytes = Infinity) { if (remainingBytes !== Infinity && (!Number.isSafeInteger(remainingBytes) || remainingBytes < 0)) failure(); }
50
55
  metrics(): SegmentReadMetrics { return { ...this.measured }; }
51
56
  resetMetrics(): void { this.measured = { bytesRead: 0, bytesHashed: 0, coldChanged: 0 }; }
52
57
  read(path: string, prior?: LegacyCheckpoint, full = false, immutable = false): SegmentBytes {
@@ -60,8 +65,9 @@ export class SegmentReader {
60
65
  const opened = fstatSync(fd, { bigint: true });
61
66
  if (statStamp(opened) !== stamp) failure();
62
67
  const read = (length: number): Buffer => {
68
+ if (length > this.remainingBytes) throw new SegmentReadBudgetError();
63
69
  const bytes = Buffer.alloc(length); let at = 0;
64
- while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; }
70
+ while (at < length) { const n = readSync(fd, bytes, at, length - at, at); if (!n) failure(); at += n; this.measured.bytesRead += n; this.remainingBytes -= n; }
65
71
  return bytes;
66
72
  };
67
73
  const all = read(size), end = all.lastIndexOf(10) + 1, bytes = all.subarray(0, end);
@@ -273,7 +279,7 @@ function head(homeM: string, generation: string): string | undefined {
273
279
 
274
280
 
275
281
  export function readLegacySnapshot(homeM: string, options: LegacySnapshotOptions = {}): LegacySegment[] {
276
- try { return snapshot(homeM, options); } catch { return failure(); }
282
+ try { return snapshot(homeM, options); } catch (error) { if (error instanceof SegmentReadBudgetError) throw error; return failure(); }
277
283
  }
278
284
  export function legacyHead(homeM: string, generation: string): string | undefined {
279
285
  try { return head(homeM, generation); } catch { return failure(); }
@@ -0,0 +1,119 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * F7 (agent-mem#40): pending-review visibility. This leaf imports only `bun:sqlite` and node built-ins, so the SessionStart hook can read it without loading the
7
+ * candidate store (no create, no prune, no schema change on that path).
8
+ *
9
+ * AC4, "no candidate expires without having been shown": a candidate's age expiry (30 days from creation) only deletes it once it was SHOWN, and not before a grace period after
10
+ * it was first shown, or once the person decided it (accepted or rejected). Only `amem review walk` stamps `shown_at`, after it has written the candidate to the terminal. The
11
+ * brief line and `amem review list` print no candidate content, and the SessionStart hook cannot know the context was read, so none of them stamps: the brief line informs, it does
12
+ * not count as showing. An unshown pending candidate stays however old it is. The 500-per-project count cap keeps unshown pending rows first, then shown pending rows (owed their
13
+ * grace), then decided ones, so it only reaches an unshown candidate when 500 of them wait.
14
+ */
15
+ export const CANDIDATE_AGE_MS = 30 * 86400_000;
16
+ /** After a candidate is first shown it stays at least this long, so the person has a real chance to act on what the brief said. */
17
+ export const CANDIDATE_SHOWN_GRACE_MS = 7 * 86400_000;
18
+ /** A candidate this close to expiry is flagged in `amem status`. */
19
+ export const CANDIDATE_DUE_MS = 7 * 86400_000;
20
+ const DAY_MS = 86400_000;
21
+
22
+ export interface PendingSummary {
23
+ /** Candidates waiting for the person (status pending or an interrupted accepting). */
24
+ count: number;
25
+ /** Whole days until the soonest of them can expire, counting an unshown one as shown now (so at least the grace period). 0 when it may go today. Null with nothing pending. */
26
+ oldestExpiresInDays: number | null;
27
+ /** How many are within CANDIDATE_DUE_MS of their 30-day age expiry (an unshown one is protected beyond it, shown or not it is flagged so the person acts). */
28
+ dueWithin7Days: number;
29
+ /** How many were never shown. */
30
+ unshown: number;
31
+ }
32
+ export const EMPTY_PENDING: PendingSummary = { count: 0, oldestExpiresInDays: null, dueWithin7Days: 0, unshown: 0 };
33
+
34
+ /**
35
+ * Open the sidecar read-only. A WAL database whose `-wal` and `-shm` files are gone (a truncating checkpoint, a copy) cannot be opened read-only, so the fallback is an immutable
36
+ * open: it writes nothing and creates nothing, at the price of not seeing a write another process has not checkpointed yet, which a count line can live with. Undefined when
37
+ * neither opens (a missing or unreadable file).
38
+ */
39
+ function openReadOnly(file: string): Database | undefined {
40
+ // A construction that succeeds can still fail at its first read (no -wal or -shm to attach to), so each form is proven with a real query before it is returned.
41
+ const forms: (() => Database)[] = [
42
+ () => new Database(file, { readonly: true }),
43
+ () => new Database(`file:${encodeURI(file).replace(/\?/g, "%3F").replace(/#/g, "%23")}?immutable=1`, { readonly: true } as ConstructorParameters<typeof Database>[1]),
44
+ ];
45
+ for (const open of forms) {
46
+ let db: Database | undefined;
47
+ try { db = open(); db.query("SELECT 1 FROM sqlite_master LIMIT 1").get(); return db; }
48
+ catch { try { db?.close(); } catch { /* nothing to close */ } }
49
+ }
50
+ return undefined;
51
+ }
52
+
53
+ /** The first-shown column, added in place: an old database gets it when a `CandidateStore` opens, a reader treats its absence as "never shown". */
54
+ export function hasShownColumn(db: Database): boolean {
55
+ return (db.query("PRAGMA table_info(candidates)").all() as { name: string }[]).some(column => column.name === "shown_at");
56
+ }
57
+
58
+ /** The instant a pending candidate becomes deletable: the later of its age expiry and the end of its grace period (counted from `now` while it is unshown). */
59
+ export function expiresAt(createdAt: number, shownAt: number | null, now: number): number {
60
+ return Math.max(createdAt + CANDIDATE_AGE_MS, (shownAt ?? now) + CANDIDATE_SHOWN_GRACE_MS);
61
+ }
62
+
63
+ /**
64
+ * Read-only summary of the pending candidates of one project (with its aliases), or of every project when `projects` is undefined. Never throws. No file means nothing is
65
+ * pending (`EMPTY_PENDING`); a file that exists but cannot be read gives `null`, so `amem status` can say "unavailable" instead of a false zero.
66
+ */
67
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number): PendingSummary;
68
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict: true): PendingSummary | null;
69
+ export function pendingSummary(base: string, projects: readonly string[] | undefined, now: number, strict?: true): PendingSummary | null {
70
+ const file = join(base, "candidates.db");
71
+ if (!existsSync(file)) return EMPTY_PENDING;
72
+ const unreadable = strict ? null : EMPTY_PENDING;
73
+ let db: Database | undefined;
74
+ try {
75
+ db = openReadOnly(file);
76
+ if (!db) return unreadable;
77
+ db.run("PRAGMA busy_timeout = 0");
78
+ const shown = hasShownColumn(db);
79
+ const where = projects === undefined ? "" : " AND project IN (SELECT value FROM json_each(?))";
80
+ const rows = db.query(`SELECT at, ${shown ? "shown_at" : "NULL AS shown_at"} FROM candidates WHERE status IN ('pending', 'accepting')${where}`)
81
+ .all(...(projects === undefined ? [] : [JSON.stringify(projects)])) as { at: number; shown_at: number | null }[];
82
+ if (!rows.length) return EMPTY_PENDING;
83
+ let soonest = Infinity, due = 0, unshown = 0;
84
+ for (const row of rows) {
85
+ const at = expiresAt(row.at, row.shown_at, now);
86
+ soonest = Math.min(soonest, at);
87
+ if (row.at + CANDIDATE_AGE_MS - now <= CANDIDATE_DUE_MS) due++; // by age: an unshown row is protected past it, but the person should hear it is old
88
+ if (row.shown_at === null) unshown++;
89
+ }
90
+ return { count: rows.length, oldestExpiresInDays: Math.max(0, Math.ceil((soonest - now) / DAY_MS)), dueWithin7Days: due, unshown };
91
+ } catch { return unreadable; } // fail open: a locked or unreadable sidecar costs the brief this line, never the brief
92
+ finally { try { db?.close(); } catch { /* nothing to close */ } }
93
+ }
94
+
95
+ /** The one brief line. Numbers only, so it can carry no private text. */
96
+ export function pendingLine(summary: PendingSummary): string | undefined {
97
+ if (!summary.count || summary.oldestExpiresInDays === null) return undefined;
98
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
99
+ return `${plural(summary.count, "candidate")} pending, oldest expires in ${plural(summary.oldestExpiresInDays, "day")}`;
100
+ }
101
+
102
+ /**
103
+ * Stamp the first time candidates were shown (`shown_at` stays at its first value). Best effort and zero-wait: on a locked, absent or pre-column database it stamps
104
+ * nothing, which only keeps candidates longer. Returns how many rows it stamped.
105
+ */
106
+ export function markShown(base: string, projects: readonly string[], now: number, ids?: readonly string[]): number {
107
+ const file = join(base, "candidates.db");
108
+ if (!existsSync(file) || !projects.length) return 0;
109
+ let db: Database | undefined;
110
+ try {
111
+ db = new Database(file);
112
+ db.run("PRAGMA busy_timeout = 0");
113
+ if (!hasShownColumn(db)) return 0;
114
+ const filter = ids === undefined ? "" : " AND id IN (SELECT value FROM json_each(?))";
115
+ return db.query(`UPDATE candidates SET shown_at = ? WHERE shown_at IS NULL AND status IN ('pending', 'accepting') AND project IN (SELECT value FROM json_each(?))${filter}`)
116
+ .run(now, JSON.stringify(projects), ...(ids === undefined ? [] : [JSON.stringify(ids)])).changes;
117
+ } catch { return 0; }
118
+ finally { try { db?.close(); } catch { /* nothing to close */ } }
119
+ }