becki-mcp 1.1.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/dist/index.js ADDED
@@ -0,0 +1,3412 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Becki MCP Server
4
+ *
5
+ * Exposes NeuraVault to Claude Code and Cursor via the Model Context Protocol.
6
+ * Transport: stdio (standard for Claude Code integration)
7
+ *
8
+ * Tool: becki_context
9
+ * Input: { query: string }
10
+ * Output: { context: string }
11
+ *
12
+ * Add to ~/.claude/claude_desktop_config.json:
13
+ * {
14
+ * "mcpServers": {
15
+ * "becki": {
16
+ * "command": "node",
17
+ * "args": ["/Users/bdsantos/becki/tools/becki-mcp/dist/index.js"]
18
+ * }
19
+ * }
20
+ * }
21
+ */
22
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
23
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
24
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
25
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
26
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, appendFileSync, statSync, watch as fsWatch, } from "fs";
27
+ import { join } from "path";
28
+ import { homedir, platform } from "os";
29
+ import { randomUUID, createHash } from "crypto";
30
+ import { AsyncLocalStorage } from "node:async_hooks";
31
+ import { createServer as createHttpServer } from "node:http";
32
+ // ---------------------------------------------------------------------------
33
+ // Config
34
+ // ---------------------------------------------------------------------------
35
+ //
36
+ // Only public values live here. No Voyage key, no Anthropic key, no Supabase
37
+ // service-role key. All server-side secrets stay on Supabase.
38
+ //
39
+ // - Embeddings (both query and write) are proxied through the
40
+ // voyage-embed edge function. The function owns the Voyage key and
41
+ // enforces per-install quotas.
42
+ // - Vault reads use the anon key with RLS handling scoping.
43
+ // - Vault writes flow through the running Becki.app's IPC listener
44
+ // (localhost:9876) so the authenticated Swift client does the actual
45
+ // DB insert. The MCP binary therefore never handles user credentials.
46
+ //
47
+ // The SUPABASE_ANON_KEY below is intentionally embedded in a shipped
48
+ // binary — it's a public-by-design credential (every Supabase project
49
+ // has a public anon key). Every row it can touch is RLS-gated, so it
50
+ // carries no data-access risk. Abuse protection is quota-based, handled
51
+ // server-side by the edge function.
52
+ const SUPABASE_URL = "https://mbutedmgtkmoigfbrthr.supabase.co";
53
+ const SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im1idXRlZG1ndGttb2lnZmJydGhyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY4Mjk1NTYsImV4cCI6MjA5MjQwNTU1Nn0.FHMSjKrwSgglGa6__ykxuPjxCrjmj8gKqwpEbJoVvgk";
54
+ const BECKI_IPC_URL = "http://localhost:9876";
55
+ // ---------------------------------------------------------------------------
56
+ // Install identity
57
+ // ---------------------------------------------------------------------------
58
+ //
59
+ // The edge function's anon-query branch buckets Voyage spend by install_id
60
+ // so one abusive process can't drain the global anon quota. The id is a
61
+ // random UUID generated on first run and persisted next to the MCP
62
+ // registry. Users never see it.
63
+ // ── Cross-platform Becki home directory ─────────────────────────────────────
64
+ //
65
+ // becki-mcp ships in two shapes:
66
+ // - Bundled inside Becki.app (Studio tier on macOS) — reads/writes the
67
+ // legacy `~/Library/Application Support/Becki/` path so the Swift app
68
+ // and the MCP daemon share state.
69
+ // - Standalone npm/Homebrew install (Core tier, cross-platform) — uses
70
+ // `~/.becki/` everywhere.
71
+ //
72
+ // Resolution order:
73
+ // 1. `BECKI_HOME` env var (explicit override, useful for tests & non-default installs)
74
+ // 2. On macOS with the legacy Application Support dir already present
75
+ // (i.e. Becki.app is installed), keep using it for backward compatibility
76
+ // 3. Otherwise `~/.becki/` (cross-platform default)
77
+ function resolveBeckiHome() {
78
+ const envOverride = process.env.BECKI_HOME;
79
+ if (envOverride && envOverride.length > 0)
80
+ return envOverride;
81
+ if (platform() === "darwin") {
82
+ const legacy = join(homedir(), "Library", "Application Support", "Becki");
83
+ if (existsSync(legacy))
84
+ return legacy;
85
+ }
86
+ return join(homedir(), ".becki");
87
+ }
88
+ const APP_SUPPORT_DIR = resolveBeckiHome();
89
+ const INSTALL_ID_PATH = join(APP_SUPPORT_DIR, "mcp-install-id");
90
+ function getInstallId() {
91
+ try {
92
+ if (existsSync(INSTALL_ID_PATH)) {
93
+ const existing = readFileSync(INSTALL_ID_PATH, "utf8").trim();
94
+ if (existing)
95
+ return existing;
96
+ }
97
+ }
98
+ catch {
99
+ // fall through and regenerate
100
+ }
101
+ const fresh = randomUUID();
102
+ try {
103
+ mkdirSync(APP_SUPPORT_DIR, { recursive: true });
104
+ writeFileSync(INSTALL_ID_PATH, fresh + "\n", "utf8");
105
+ }
106
+ catch {
107
+ // best-effort — if we can't persist, rotating every cold start is
108
+ // acceptable degradation (quota just resets more often).
109
+ }
110
+ return fresh;
111
+ }
112
+ const INSTALL_ID = getInstallId();
113
+ // ---------------------------------------------------------------------------
114
+ // MCP ingest token (v0.7.0)
115
+ // ---------------------------------------------------------------------------
116
+ //
117
+ // Becki.app issues a per-install scoped token that authorizes ONLY POST to
118
+ // /functions/v1/ingest-vault-row. Stored in a 0600 file at:
119
+ //
120
+ // ~/Library/Application Support/Becki/mcp-ingest-token
121
+ //
122
+ // We read this on startup + watch for changes. When present, becki_ingest
123
+ // POSTs directly to the edge function — meaning the embed lands in
124
+ // vault_embeddings even when Becki.app is closed/crashed/quit.
125
+ //
126
+ // When ABSENT (e.g. user hasn't upgraded Becki.app yet), we fall back to the
127
+ // existing IPC + file-watch path. Backward compatible.
128
+ //
129
+ // Security: file mode is checked on every read. If perms aren't 0600 we
130
+ // refuse to use the token (defends against accidental permission downgrades
131
+ // or hostile processes touching the file).
132
+ const MCP_INGEST_TOKEN_PATH = join(APP_SUPPORT_DIR, "mcp-ingest-token");
133
+ const MCP_USER_ID_PATH = join(APP_SUPPORT_DIR, "mcp-user-id");
134
+ let cachedIngestToken = null;
135
+ let cachedUserId = null;
136
+ let ingestTokenWatcher = null;
137
+ const requestContext = new AsyncLocalStorage();
138
+ function currentUserId() {
139
+ return requestContext.getStore()?.userId ?? cachedUserId;
140
+ }
141
+ /**
142
+ * Scopes for the active request. HTTP requests carry the OAuth token's
143
+ * granted scopes. stdio gets full access — the local user owns their own
144
+ * machine and vault; there is no scope to withhold.
145
+ */
146
+ function currentScopes() {
147
+ return requestContext.getStore()?.scopes ?? ["read", "write"];
148
+ }
149
+ // True when this process is the remote-MCP HTTP service (Railway), false for
150
+ // the Mac-app-bundled stdio server. Gates the ingest path: HTTP ingests via
151
+ // the ingest_vault_row RPC scoped to the OAuth user; stdio uses the local
152
+ // install-token + file + IPC fallback chain.
153
+ const IS_HTTP_TRANSPORT = process.env.BECKI_MCP_TRANSPORT === "http";
154
+ function readIngestToken() {
155
+ return readProtectedFile(MCP_INGEST_TOKEN_PATH, "mcp-ingest-token");
156
+ }
157
+ function readUserId() {
158
+ // user_id is required by match_vault_v4 (post-migration 027 — closes the
159
+ // cross-user vault read by requiring an explicit user scope). Becki.app
160
+ // writes this file alongside mcp-ingest-token after registration.
161
+ const v = readProtectedFile(MCP_USER_ID_PATH, "mcp-user-id");
162
+ if (!v)
163
+ return null;
164
+ // UUID format check — defensive: a malformed user_id would be rejected by
165
+ // the RPC anyway, but we'd rather not POST garbage.
166
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v) ? v : null;
167
+ }
168
+ function readProtectedFile(path, label) {
169
+ try {
170
+ if (!existsSync(path))
171
+ return null;
172
+ const stat = statSync(path);
173
+ // 0o777 mask isolates the permission bits. Anything other than 0o600
174
+ // means somebody touched the file — refuse to trust it.
175
+ if ((stat.mode & 0o777) !== 0o600) {
176
+ console.error(`${label}: refusing to read — file mode is ${(stat.mode & 0o777).toString(8)}, expected 600. ` +
177
+ `Run \`chmod 600 ${path}\` if this was unintentional.`);
178
+ return null;
179
+ }
180
+ const raw = readFileSync(path, "utf8").trim();
181
+ return raw || null;
182
+ }
183
+ catch (err) {
184
+ console.error(`${label}: read failed`, err);
185
+ return null;
186
+ }
187
+ }
188
+ function startIngestTokenWatcher() {
189
+ cachedIngestToken = readIngestToken();
190
+ cachedUserId = readUserId();
191
+ if (ingestTokenWatcher)
192
+ return;
193
+ // Watch the parent dir, not the file itself — fsWatch on a single file is
194
+ // unreliable on macOS when the file is replaced via atomic move (which is
195
+ // exactly what Becki.app does to write the token). Watching the dir picks
196
+ // up the rename event reliably.
197
+ try {
198
+ if (!existsSync(APP_SUPPORT_DIR)) {
199
+ mkdirSync(APP_SUPPORT_DIR, { recursive: true });
200
+ }
201
+ ingestTokenWatcher = fsWatch(APP_SUPPORT_DIR, (_event, filename) => {
202
+ if (filename === "mcp-ingest-token") {
203
+ const next = readIngestToken();
204
+ if (next !== cachedIngestToken) {
205
+ cachedIngestToken = next;
206
+ console.error(`mcp-ingest-token: ${next ? "loaded" : "cleared"}`);
207
+ }
208
+ }
209
+ else if (filename === "mcp-user-id") {
210
+ const next = readUserId();
211
+ if (next !== cachedUserId) {
212
+ cachedUserId = next;
213
+ console.error(`mcp-user-id: ${next ? "loaded" : "cleared"}`);
214
+ }
215
+ }
216
+ });
217
+ }
218
+ catch (err) {
219
+ console.error("mcp-ingest-token: watcher setup failed", err);
220
+ }
221
+ }
222
+ startIngestTokenWatcher();
223
+ /**
224
+ * Canonical project-slug form on write — mirrors the SQL becki.norm_project_slug
225
+ * (migration 046): case-folded, '_' and '-' treated as equivalent, trimmed.
226
+ * Applied to project_name before ingest so new rows stop fragmenting
227
+ * (BECKI / becki / Becki, forecast_performance vs forecast-performance).
228
+ */
229
+ function normProjectSlug(raw) {
230
+ if (!raw)
231
+ return undefined;
232
+ const norm = raw.trim().toLowerCase().replace(/_/g, "-");
233
+ return norm || undefined;
234
+ }
235
+ /**
236
+ * POST to ingest-vault-row using the install token. Returns ok:true on 2xx,
237
+ * ok:false otherwise. Caller falls back to the IPC + file-watch path on
238
+ * failure or when no token is loaded.
239
+ */
240
+ async function cloudIngest(type, content, project, title) {
241
+ const token = cachedIngestToken;
242
+ if (!token)
243
+ return { ok: false, error: "no_token" };
244
+ const sourceId = title ? `${todayStr()}-${toSlug(title)}` : undefined;
245
+ const metadata = {};
246
+ const projectSlug = normProjectSlug(project);
247
+ if (projectSlug)
248
+ metadata.project_name = projectSlug;
249
+ try {
250
+ const resp = await fetch(`${SUPABASE_URL}/functions/v1/ingest-vault-row`, {
251
+ method: "POST",
252
+ headers: {
253
+ "Content-Type": "application/json",
254
+ "Authorization": `Bearer ${token}`,
255
+ "User-Agent": `becki-mcp/${INSTALL_ID.slice(0, 8)}`,
256
+ },
257
+ body: JSON.stringify({
258
+ content,
259
+ source_type: type,
260
+ source_id: sourceId,
261
+ metadata,
262
+ }),
263
+ });
264
+ if (!resp.ok) {
265
+ const body = await resp.text();
266
+ return { ok: false, status: resp.status, error: body.slice(0, 300) };
267
+ }
268
+ const json = await resp.json();
269
+ return {
270
+ ok: true,
271
+ status: resp.status,
272
+ vault_id: json.vault_id,
273
+ source_id: json.source_id,
274
+ };
275
+ }
276
+ catch (err) {
277
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
278
+ }
279
+ }
280
+ // Decision Reasoning Graph forward-capture (HTTP-transport path). Fire the
281
+ // extractor for a freshly-written decision so its structured "why" lands in
282
+ // becki.decision_reasoning. Fire-and-forget: becki-mcp is a long-lived Node
283
+ // server (unlike an edge isolate), so the request completes after the ingest
284
+ // response returns. extract-decision-reasoning is idempotent + does its own
285
+ // service-role auth (verify_jwt=false). Never throws into the caller.
286
+ function fireDecisionReasoning(vaultId, userId, content, secret) {
287
+ fetch(`${SUPABASE_URL}/functions/v1/extract-decision-reasoning`, {
288
+ method: "POST",
289
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${secret}` },
290
+ body: JSON.stringify({ text: content, decision_id: vaultId, user_id: userId }),
291
+ }).catch((err) => console.error("[decision-reasoning] fire failed (non-fatal):", err instanceof Error ? err.message : err));
292
+ }
293
+ // ---------------------------------------------------------------------------
294
+ // HTTP-transport ingest (v0.10.0 — remote MCP web ingest)
295
+ // ---------------------------------------------------------------------------
296
+ // The stdio cloudIngest() above POSTs to the ingest-vault-row edge function
297
+ // authed by a local install-token file — which doesn't exist on Railway.
298
+ // The HTTP transport instead: embed the content via voyage-embed, then call
299
+ // the ingest_vault_row RPC (migration 044) with the service key, scoped to
300
+ // the OAuth-resolved user (currentUserId()). No local file, no install token,
301
+ // no Becki.app IPC.
302
+ async function httpIngest(type, content, project, title) {
303
+ const secret = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
304
+ if (!secret)
305
+ return { ok: false, error: "SUPABASE_SECRET_KEY not configured" };
306
+ const userId = currentUserId();
307
+ if (!userId)
308
+ return { ok: false, error: "no user_id in request context" };
309
+ let embedding;
310
+ try {
311
+ // Service-role auth: voyage-embed forbids document embeds on the anon
312
+ // path. The secret key routes through voyage-embed's "service" branch,
313
+ // and X-Becki-User-Id (set inside generateEmbedding) attributes spend.
314
+ embedding = await generateEmbedding(content, "document", { secretKey: secret, userId });
315
+ }
316
+ catch (err) {
317
+ return { ok: false, error: `embed failed: ${err instanceof Error ? err.message : String(err)}` };
318
+ }
319
+ // source_id matches cloudIngest's scheme so a row ingested via remote MCP
320
+ // dedups against the same decision ingested any other way.
321
+ const sourceId = title ? `${todayStr()}-${toSlug(title)}` : null;
322
+ const metadata = {};
323
+ const projectSlug = normProjectSlug(project);
324
+ if (projectSlug)
325
+ metadata.project_name = projectSlug;
326
+ // Origin tag — marks this row as web-ingested (remote MCP, not the Mac).
327
+ // The macOS app's cloud->local pull-sync (#150) queries on this to know
328
+ // which rows need a local ~/Documents/Becki/*.md file written.
329
+ metadata.origin = "remote-mcp";
330
+ try {
331
+ const resp = await fetch(`${SUPABASE_URL}/rest/v1/rpc/ingest_vault_row`, {
332
+ method: "POST",
333
+ headers: {
334
+ "Content-Type": "application/json",
335
+ apikey: secret,
336
+ Authorization: `Bearer ${secret}`,
337
+ "Content-Profile": "becki",
338
+ },
339
+ body: JSON.stringify({
340
+ pp_user_id: userId,
341
+ pp_source_type: type,
342
+ pp_source_id: sourceId,
343
+ pp_content: content,
344
+ pp_embedding: embedding,
345
+ pp_metadata: metadata,
346
+ }),
347
+ });
348
+ if (!resp.ok) {
349
+ const body = await resp.text();
350
+ return { ok: false, error: `${resp.status} ${body.slice(0, 200)}` };
351
+ }
352
+ // The RPC returns the row uuid as a bare JSON scalar.
353
+ const raw = await resp.json();
354
+ const vaultId = typeof raw === "string" ? raw : String(raw);
355
+ if (type === "decision" && vaultId) {
356
+ fireDecisionReasoning(vaultId, userId, content, secret);
357
+ }
358
+ return { ok: true, vault_id: vaultId };
359
+ }
360
+ catch (err) {
361
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
362
+ }
363
+ }
364
+ // ---------------------------------------------------------------------------
365
+ // Embedding proxy — all Voyage calls go through the edge function so the
366
+ // key never touches the client.
367
+ // ---------------------------------------------------------------------------
368
+ async function generateEmbedding(text, inputType = "query", serviceAuth) {
369
+ // voyage-embed's anon path is query-only — it returns 403
370
+ // anon_writes_forbidden for input_type "document" (no anonymous writes).
371
+ // The remote-MCP HTTP transport ingests documents, so it must authenticate
372
+ // as service-role: voyage-embed's "service" branch allows document embeds
373
+ // and attributes the Voyage spend to the OAuth-resolved user via the
374
+ // X-Becki-User-Id header. The stdio transport keeps the anon+install-id
375
+ // path (query embeds only ever go through anon, document embeds there go
376
+ // through Becki.app's authenticated IPC, not this function).
377
+ const headers = { "Content-Type": "application/json" };
378
+ if (serviceAuth) {
379
+ headers.apikey = serviceAuth.secretKey;
380
+ headers.Authorization = `Bearer ${serviceAuth.secretKey}`;
381
+ headers["X-Becki-User-Id"] = serviceAuth.userId;
382
+ }
383
+ else {
384
+ headers.apikey = SUPABASE_ANON_KEY;
385
+ headers.Authorization = `Bearer ${SUPABASE_ANON_KEY}`;
386
+ headers["X-Becki-Install-Id"] = INSTALL_ID;
387
+ }
388
+ const res = await fetch(`${SUPABASE_URL}/functions/v1/voyage-embed`, {
389
+ method: "POST",
390
+ headers,
391
+ body: JSON.stringify({ texts: [text], input_type: inputType }),
392
+ });
393
+ if (res.status === 429) {
394
+ throw new Error("NeuraVault is rate-limited right now — the daily query quota is exhausted. Try again in an hour.");
395
+ }
396
+ if (!res.ok) {
397
+ const body = await res.text();
398
+ throw new Error(`voyage-embed ${res.status}: ${body.slice(0, 300)}`);
399
+ }
400
+ const json = (await res.json());
401
+ if (!Array.isArray(json.vectors) || !Array.isArray(json.vectors[0])) {
402
+ throw new Error("voyage-embed returned no vector");
403
+ }
404
+ return json.vectors[0];
405
+ }
406
+ // Retrieval tuning — v0.4 retrieval hardening (task #32).
407
+ //
408
+ // MATCH_COUNT: was 5, bumped to 12. Pre-v0.4 the MCP returned the top
409
+ // 5 chunks and the downstream LLM had to work from that. Empirically
410
+ // that was too tight — the 2026-04-24 cross-tool test showed a fresh
411
+ // dead_end node getting ranked 6th and silently dropped, producing a
412
+ // confident hallucination in Codex. 12 gives the LLM enough surface
413
+ // to spot the right node when it exists without overwhelming context.
414
+ //
415
+ // MATCH_THRESHOLD: was 0.3, lowered to 0.2. Same trade — rather lean in
416
+ // more loose-related candidates and let the LLM filter, than cut a
417
+ // near-miss that would have been the correct answer.
418
+ //
419
+ // CONFIDENCE_THRESHOLD: new. If the top hit's similarity is below this
420
+ // value, queryVault / queryCodeContext prepend a "⚠ No high-confidence
421
+ // match" warning to the returned context so the downstream LLM knows
422
+ // not to confabulate. 0.55 chosen empirically — a genuinely relevant
423
+ // chunk for a focused question usually scores 0.6-0.8; below 0.55 we
424
+ // are almost always looking at noise that will produce hallucinations
425
+ // if fed to an LLM without a warning.
426
+ const MATCH_COUNT = 12;
427
+ const MATCH_THRESHOLD = 0.2;
428
+ const CONFIDENCE_THRESHOLD = 0.55;
429
+ function classifyIntent(query) {
430
+ const q = query.toLowerCase();
431
+ // Decision-recall triggers — the dominant failure mode pre-v0.4 (knowledge
432
+ // chunks buried under code). Hits all the question shapes a user typically
433
+ // uses to ask Becki "what's in our memory about X."
434
+ const decisionPatterns = [
435
+ /what (did|do|have) we decide/, /what(?:'s| is) (our|the) decision/,
436
+ /what(?:'s| is) the plan/, /what did we agree/, /how did we resolve/,
437
+ /any decisions? (about|on)/, /any dead.ends?/, /what (did|have) we tried/,
438
+ /open loops?/, /commitments?/, /what(?:'s| is) outstanding/,
439
+ /what changed/, /what(?:'s| is) (the|our) latest/, /recent (decisions?|changes?)/,
440
+ // v0.6.0: action verbs that imply decision-recall intent. Real users ask
441
+ // "what did we ship to fix X" expecting the decision/dead_end about X — not
442
+ // a code chunk. Eval surfaced this gap on 2026-05-01.
443
+ /what (did|have) we (ship|build|release|fix|implement|add|launch|deploy)/,
444
+ /how did we (ship|build|release|fix|implement|add|solve|handle)/,
445
+ /why (did|do) we (ship|build|release|fix|implement|choose|use)/,
446
+ ];
447
+ if (decisionPatterns.some((re) => re.test(q)))
448
+ return "decision_recall";
449
+ // Code-locator triggers — query is about implementation details, file
450
+ // structure, or specific functions. Knowledge boost would pollute these.
451
+ const codePatterns = [
452
+ /where (is|are) /, /show me (how|the )/, /find the /, /which file/,
453
+ /implementation of /, /how (is|does) .* implemented/, /the function /,
454
+ /the class /, /the handler /, /import .* from/,
455
+ ];
456
+ if (codePatterns.some((re) => re.test(q)))
457
+ return "code_locator";
458
+ return "general";
459
+ }
460
+ function paramsForIntent(intent, codeOnly) {
461
+ if (codeOnly)
462
+ return { knowledgeBoost: 1.0, filterSourceTypes: ["code"] };
463
+ switch (intent) {
464
+ case "decision_recall":
465
+ // Strong knowledge bias. Don't filter out code entirely — the LLM still
466
+ // benefits from seeing relevant file context, just not as the dominant
467
+ // signal. Threshold + boost combination pushes weak code chunks below
468
+ // the cutoff naturally.
469
+ return { knowledgeBoost: 1.5, filterSourceTypes: null };
470
+ case "code_locator":
471
+ // Pure raw similarity. Code wins when code is what's relevant.
472
+ return { knowledgeBoost: 1.0, filterSourceTypes: null };
473
+ case "general":
474
+ // Mild bias toward knowledge for ambiguous queries — keeps decisions
475
+ // visible without burying code that's relevant.
476
+ return { knowledgeBoost: 1.2, filterSourceTypes: null };
477
+ }
478
+ }
479
+ // RRF normalization ceiling — a chunk that hits rank 1 in BOTH vector AND BM25
480
+ // gets RRF = 1/(60+1) + 1/(60+1) ≈ 0.0328. Dividing raw RRF by this ceiling
481
+ // brings the score back into [0, 1] cosine-equivalent space so CONFIDENCE_THRESHOLD
482
+ // (0.55) stays meaningful and the downstream LLM warning fires on the same band
483
+ // of "weakly related" results it always did. Mirrors the Swift Terminal calibration.
484
+ const RRF_CEILING = 0.0328;
485
+ // v0.6.0 temporal layer half-life defaults per intent. Code-locator queries
486
+ // disable temporal weighting entirely — old code is still correct code, freshness
487
+ // is irrelevant. Decision-recall and general queries use 30d half-life so a
488
+ // chunk loses ~17% relevance after 8 days, ~50% after 30 days, ~88% after 90 days
489
+ // (subject to the 0.1 floor in match_vault_v4).
490
+ const HALF_LIFE_DAYS_BY_INTENT = {
491
+ decision_recall: 30,
492
+ general: 30,
493
+ code_locator: 0, // disable
494
+ };
495
+ // v0.6.0: relax websearch_to_tsquery's phrase-query behavior on slug-shaped
496
+ // inputs. websearch_to_tsquery turns `foo-bar-baz` into a phrase query
497
+ // (`'foo-bar-baz' <-> 'foo' <-> 'bar' <-> 'baz'`) which fails to match content
498
+ // that has the slug appearing with extra trailing tokens (e.g. `foo-bar-baz-shipped`).
499
+ // Replacing internal hyphens with spaces in pure slug tokens lets websearch build
500
+ // an AND query over individual lexemes — same recall, less brittle.
501
+ //
502
+ // Heuristic: only rewrite if the query is a single whitespace-free token with
503
+ // 2+ hyphens. Multi-word natural-language queries are untouched so quoted
504
+ // phrases ("becki_context" handler) keep their websearch semantics.
505
+ function relaxSlugQuery(query) {
506
+ const trimmed = query.trim();
507
+ if (/\s/.test(trimmed))
508
+ return query; // multi-word — leave alone
509
+ if ((trimmed.match(/-/g) ?? []).length < 2)
510
+ return query; // not slug-shaped
511
+ return trimmed.replace(/-/g, " ");
512
+ }
513
+ // v1.x retrieval-quality (#168): obligation IDIOMS carry content words that
514
+ // collide lexically with unrelated projects — "what's on my plate?" (an
515
+ // obligation question) BM25-matches weightlifting "plate" chunks from a gym
516
+ // project. The deterministic commitments ledger already answers the obligation;
517
+ // the pun only pollutes the supporting chunk list. When an unambiguous
518
+ // obligation idiom is present in the ORIGINAL query, drop its pun token from the
519
+ // LEXICAL query only — the embedding still uses the raw query (semantic recall
520
+ // untouched) and the chunk set is never neutered, so this is symmetric with the
521
+ // in-app surface and never makes either poorer. Mirrors
522
+ // BECKI-macOS NeuraVaultQuery.stripObligationPun.
523
+ const OBLIGATION_IDIOMS = [
524
+ { marker: "on my plate", puns: ["plate", "plates"], embed: "among my open commitments and obligations" },
525
+ { marker: "on your plate", puns: ["plate", "plates"], embed: "among your open commitments and obligations" },
526
+ { marker: "off my plate", puns: ["plate", "plates"], embed: "from my open commitments and obligations" },
527
+ { marker: "off your plate", puns: ["plate", "plates"], embed: "from your open commitments and obligations" },
528
+ ];
529
+ // BM25 side: drop the idiom's pun token from the LEXICAL query.
530
+ function stripObligationPun(lexical, original) {
531
+ const lo = original.toLowerCase();
532
+ let out = lexical;
533
+ for (const idiom of OBLIGATION_IDIOMS) {
534
+ if (!lo.includes(idiom.marker))
535
+ continue;
536
+ for (const pun of idiom.puns) {
537
+ out = out.replace(new RegExp(`\\b${pun}\\b`, "gi"), " ");
538
+ }
539
+ }
540
+ return out.replace(/\s+/g, " ").trim();
541
+ }
542
+ // Embedding side (#168 step 2): the BM25 strip kills the lexical pun, but the
543
+ // VECTOR search still pulls "plate" content because the raw idiom embeds near
544
+ // food/weightlifting. Replace the idiom phrase with obligation vocabulary for
545
+ // the EMBEDDING INPUT ONLY so the vector search anchors on commitments, not the
546
+ // literal noun. The raw query is unchanged for BM25/display, and nothing is
547
+ // suppressed — symmetric with the in-app surface. Mirrors
548
+ // BECKI-macOS NeuraVaultQuery.canonicalizeObligationIdiom.
549
+ function canonicalizeObligationIdiom(query) {
550
+ let out = query;
551
+ for (const idiom of OBLIGATION_IDIOMS) {
552
+ const rx = new RegExp(idiom.marker.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
553
+ out = out.replace(rx, idiom.embed);
554
+ }
555
+ return out;
556
+ }
557
+ /**
558
+ * Calls match_vault_v4 (migration 023) — v0.6.0 temporal-aware hybrid retrieval.
559
+ * Builds on v3 (vector + BM25 + RRF + resolution-aware) by adding a recency
560
+ * half-life decay. Code-locator queries disable temporal weighting; everything
561
+ * else gets a 30d half-life so fresh decisions outrank ancient ones at equal
562
+ * semantic relevance.
563
+ *
564
+ * Knowledge_boost + project_anchor + resolution multipliers all apply pre-recency
565
+ * so relevance still dominates — recency is a tiebreaker (with a 0.1 floor so
566
+ * highly-relevant ancient chunks still surface).
567
+ *
568
+ * Returned similarity is the boosted RRF score post-recency; we normalize against
569
+ * RRF_CEILING to bring it back into [0,1] for confidence-threshold comparison.
570
+ */
571
+ /**
572
+ * v0.7.7 temporal pre-pass. Detect "today / yesterday / this week / last week /
573
+ * this month / last month / last N days / since YYYY-MM-DD" and resolve to
574
+ * absolute (since, until) ISO 8601 bounds for pp_since / pp_until on the v4
575
+ * RPC call. Half-open interval [since, until). Returns nulls for both when
576
+ * no temporal phrase matched.
577
+ *
578
+ * Mirrors the Swift NeuraVaultQuery.resolveTemporalFilters logic so the in-
579
+ * app Becki Terminal and the MCP-served vault search agree on temporal
580
+ * scoping. Tweak both together if either evolves.
581
+ */
582
+ function resolveTemporalFilters(query, now = new Date()) {
583
+ const q = query.toLowerCase();
584
+ // Helper: produce an ISO date at start-of-day UTC for a given Date.
585
+ const startOfDayUTC = (d) => {
586
+ const out = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
587
+ return out;
588
+ };
589
+ const startOfToday = startOfDayUTC(now);
590
+ const startOfTomorrow = new Date(startOfToday);
591
+ startOfTomorrow.setUTCDate(startOfTomorrow.getUTCDate() + 1);
592
+ if (q.includes("today")) {
593
+ return {
594
+ since: startOfToday.toISOString(),
595
+ until: startOfTomorrow.toISOString(),
596
+ matchedPhrase: "today",
597
+ };
598
+ }
599
+ if (q.includes("yesterday")) {
600
+ const startOfYesterday = new Date(startOfToday);
601
+ startOfYesterday.setUTCDate(startOfYesterday.getUTCDate() - 1);
602
+ return {
603
+ since: startOfYesterday.toISOString(),
604
+ until: startOfToday.toISOString(),
605
+ matchedPhrase: "yesterday",
606
+ };
607
+ }
608
+ // "this week" — Monday 00:00 UTC of current week (ISO week).
609
+ if (q.includes("this week")) {
610
+ const day = now.getUTCDay(); // 0 = Sun, 1 = Mon, ...
611
+ const offsetToMonday = ((day + 6) % 7); // Sun → 6, Mon → 0, Tue → 1, ...
612
+ const monday = new Date(startOfToday);
613
+ monday.setUTCDate(monday.getUTCDate() - offsetToMonday);
614
+ const nextMonday = new Date(monday);
615
+ nextMonday.setUTCDate(nextMonday.getUTCDate() + 7);
616
+ return {
617
+ since: monday.toISOString(),
618
+ until: nextMonday.toISOString(),
619
+ matchedPhrase: "this week",
620
+ };
621
+ }
622
+ if (q.includes("last week")) {
623
+ const day = now.getUTCDay();
624
+ const offsetToMonday = ((day + 6) % 7);
625
+ const thisMonday = new Date(startOfToday);
626
+ thisMonday.setUTCDate(thisMonday.getUTCDate() - offsetToMonday);
627
+ const lastMonday = new Date(thisMonday);
628
+ lastMonday.setUTCDate(lastMonday.getUTCDate() - 7);
629
+ return {
630
+ since: lastMonday.toISOString(),
631
+ until: thisMonday.toISOString(),
632
+ matchedPhrase: "last week",
633
+ };
634
+ }
635
+ if (q.includes("this month")) {
636
+ const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
637
+ const firstOfNext = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1));
638
+ return {
639
+ since: firstOfMonth.toISOString(),
640
+ until: firstOfNext.toISOString(),
641
+ matchedPhrase: "this month",
642
+ };
643
+ }
644
+ if (q.includes("last month")) {
645
+ const firstOfMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
646
+ const firstOfLast = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1));
647
+ return {
648
+ since: firstOfLast.toISOString(),
649
+ until: firstOfMonth.toISOString(),
650
+ matchedPhrase: "last month",
651
+ };
652
+ }
653
+ // "in the last N days" / "last N days" / "past N days" / "in the past N days"
654
+ const lastNDaysMatch = q.match(/(?:in the last|last|past|in the past)\s+(\d{1,3})\s+days?/);
655
+ if (lastNDaysMatch) {
656
+ const n = parseInt(lastNDaysMatch[1], 10);
657
+ if (n > 0 && n < 366) {
658
+ const since = new Date(startOfTomorrow);
659
+ since.setUTCDate(since.getUTCDate() - n);
660
+ return {
661
+ since: since.toISOString(),
662
+ until: null,
663
+ matchedPhrase: `last ${n} days`,
664
+ };
665
+ }
666
+ }
667
+ // "since YYYY-MM-DD" or "since YYYY-MM"
668
+ const sinceDateMatch = q.match(/since\s+(\d{4}-\d{2}-\d{2}|\d{4}-\d{2})/);
669
+ if (sinceDateMatch) {
670
+ const raw = sinceDateMatch[1];
671
+ const iso = raw.length === 7 ? `${raw}-01T00:00:00Z` : `${raw}T00:00:00Z`;
672
+ const parsed = new Date(iso);
673
+ if (!isNaN(parsed.getTime())) {
674
+ return {
675
+ since: parsed.toISOString(),
676
+ until: null,
677
+ matchedPhrase: `since ${raw}`,
678
+ };
679
+ }
680
+ }
681
+ return { since: null, until: null, matchedPhrase: null };
682
+ }
683
+ const REWRITE_ENDPOINT = `${SUPABASE_URL}/functions/v1/claude-proxy/rewrite`;
684
+ // Hard cap so a slow Anthropic call can't stall the MCP. Below this we just
685
+ // fall through to the regex layer — degraded retrieval beats a 12-second
686
+ // stall in Claude Desktop / Cursor.
687
+ const REWRITE_TIMEOUT_MS = 4500;
688
+ /**
689
+ * Calls claude-proxy /rewrite. Returns the rewrite on success, null on any
690
+ * failure (timeout, network, 4xx/5xx, invalid JSON, missing install token).
691
+ * Never throws — all callers expect a maybe-rewrite they can fall through.
692
+ */
693
+ async function rewriteQueryViaProxy(query) {
694
+ const token = cachedIngestToken;
695
+ if (!token)
696
+ return null; // no install token = MCP not registered yet
697
+ // tz from the OS — Intl.DateTimeFormat().resolvedOptions().timeZone returns
698
+ // an IANA name like "America/Chicago" on every modern Node. Fall back to
699
+ // "UTC" if for some reason it doesn't resolve (containers without zoneinfo).
700
+ let tz;
701
+ try {
702
+ tz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
703
+ }
704
+ catch {
705
+ tz = "UTC";
706
+ }
707
+ const ctrl = new AbortController();
708
+ const timer = setTimeout(() => ctrl.abort(), REWRITE_TIMEOUT_MS);
709
+ try {
710
+ const res = await fetch(REWRITE_ENDPOINT, {
711
+ method: "POST",
712
+ headers: {
713
+ Authorization: `Bearer ${token}`,
714
+ "Content-Type": "application/json",
715
+ },
716
+ body: JSON.stringify({ query, tz }),
717
+ signal: ctrl.signal,
718
+ });
719
+ if (!res.ok) {
720
+ // 422 is expected when Haiku misbehaves — drop to debug-level logging
721
+ // so the MCP doesn't spam stderr on transient model issues.
722
+ if (res.status === 422) {
723
+ console.error(`rewrite: 422 from claude-proxy, falling back to regex`);
724
+ }
725
+ else {
726
+ console.error(`rewrite: ${res.status} from claude-proxy`);
727
+ }
728
+ return null;
729
+ }
730
+ const body = (await res.json());
731
+ if (!body?.rewrite)
732
+ return null;
733
+ if (body.cache_hit) {
734
+ console.error(`rewrite: cache hit`);
735
+ }
736
+ return body.rewrite;
737
+ }
738
+ catch (err) {
739
+ if (err?.name === "AbortError") {
740
+ console.error(`rewrite: timeout after ${REWRITE_TIMEOUT_MS}ms — using regex fallback`);
741
+ }
742
+ else {
743
+ console.error(`rewrite: failed (non-fatal) — ${String(err).slice(0, 200)}`);
744
+ }
745
+ return null;
746
+ }
747
+ finally {
748
+ clearTimeout(timer);
749
+ }
750
+ }
751
+ /**
752
+ * Convert Haiku's local-date interval (YYYY-MM-DD strings in the user's
753
+ * timezone) into UTC ISO timestamps suitable for match_vault_v4's pp_since
754
+ * / pp_until parameters.
755
+ *
756
+ * Half-open semantics: until is the day AFTER the user's intended last day.
757
+ * Haiku is told this in the system prompt, so we only convert here. If
758
+ * Haiku slips and emits an inclusive range, the worst case is a one-day-
759
+ * wide miss at the edge — acceptable.
760
+ *
761
+ * Implementation note: `new Date("YYYY-MM-DDTHH:MM:SS")` (no Z, no offset)
762
+ * is interpreted in the runtime's LOCAL timezone, which is exactly what we
763
+ * want here — Haiku's "2026-05-05" is meant to be midnight in the user's
764
+ * local zone, and Node is running in that same zone (we read it via Intl
765
+ * one paragraph up). `.toISOString()` then returns the UTC equivalent.
766
+ */
767
+ function rewriteTemporalToUTC(rewrite) {
768
+ const sinceUtc = localDateToUtcStartOfDay(rewrite.since);
769
+ const untilUtc = localDateToUtcStartOfDay(rewrite.until);
770
+ if (!sinceUtc && !untilUtc) {
771
+ return { since: null, until: null, matchedPhrase: null };
772
+ }
773
+ return {
774
+ since: sinceUtc,
775
+ until: untilUtc,
776
+ matchedPhrase: `haiku:${rewrite.since ?? "*"}..${rewrite.until ?? "*"}`,
777
+ };
778
+ }
779
+ function localDateToUtcStartOfDay(yyyymmdd) {
780
+ if (!yyyymmdd)
781
+ return null;
782
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(yyyymmdd))
783
+ return null;
784
+ const d = new Date(`${yyyymmdd}T00:00:00`);
785
+ if (isNaN(d.getTime()))
786
+ return null;
787
+ return d.toISOString();
788
+ }
789
+ async function matchChunks(embedding, limit = MATCH_COUNT, codeOnly = false, query) {
790
+ // v0.7.8: Haiku semantic-rewrite pre-pass. Try to get structured retrieval
791
+ // params from claude-proxy /rewrite first; fall through to the regex layers
792
+ // below on any failure (network, 422, invalid JSON, etc.). Rewrite tells us:
793
+ // - intent (decision_recall | code_locator | general)
794
+ // - since/until in user's LOCAL date space — converted to UTC ISO here
795
+ // - expanded_query — fed to BM25 instead of the raw query for better recall
796
+ //
797
+ // codeOnly is an explicit caller override that beats the rewrite's intent
798
+ // (intel_analyst handles its own routing). The rewrite still informs
799
+ // since/until and expanded_query in that case.
800
+ const rewrite = query && !codeOnly ? await rewriteQueryViaProxy(query) : null;
801
+ const intent = codeOnly
802
+ ? "code_locator"
803
+ : (rewrite?.intent ?? (query ? classifyIntent(query) : "general"));
804
+ const { knowledgeBoost, filterSourceTypes } = paramsForIntent(intent, codeOnly);
805
+ // codeOnly forces zero half-life regardless of intent — explicit caller
806
+ // override beats heuristic intent. Otherwise pick from the per-intent table.
807
+ const halfLifeDays = codeOnly ? 0 : HALF_LIFE_DAYS_BY_INTENT[intent];
808
+ // Temporal: prefer Haiku's local-date interval (converted to UTC ISO) when
809
+ // available; otherwise fall back to the v0.7.7 regex pre-pass. The rewrite
810
+ // path handles a wider range of phrasings ("two Fridays ago", "since the
811
+ // restructure last quarter" — Haiku's call) AND uses the user's actual
812
+ // local timezone for "today / yesterday / this week" math, which the regex
813
+ // path computes in UTC and gets wrong near midnight.
814
+ const temporal = rewrite
815
+ ? rewriteTemporalToUTC(rewrite)
816
+ : query
817
+ ? resolveTemporalFilters(query)
818
+ : { since: null, until: null, matchedPhrase: null };
819
+ if (temporal.matchedPhrase) {
820
+ console.error(`becki-mcp: temporal filter matched '${temporal.matchedPhrase}' → ` +
821
+ `since=${temporal.since} until=${temporal.until}`);
822
+ }
823
+ // pp_user_id is REQUIRED post-migration 027 — match_vault_v4's COALESCE
824
+ // (pp_user_id, auth.uid()) returns NULL for anon callers without it,
825
+ // which yields zero rows. We read user_id from the file Becki.app writes
826
+ // alongside the install token. If we don't have it, the call would be a
827
+ // wasted round-trip — short-circuit with a clear error so the host (Claude
828
+ // Desktop, Cursor) knows to prompt the user to launch + sign in to Becki.
829
+ const userId = currentUserId();
830
+ if (!userId) {
831
+ throw new Error("becki: no user_id available — open Becki.app and sign in once to register this device. " +
832
+ "(Vault search requires per-user scoping after the v0.7 security fix.)");
833
+ }
834
+ // v1.x: use match_vault_v5 (resolution-aware) when available. v5 is a
835
+ // strict superset of v4 — same params + new optional pp_resolution_filter,
836
+ // new returned consolidation_level column. The "resolution_intent" Haiku
837
+ // returned drives this:
838
+ // - "atomic" → user wants specific items (recall query)
839
+ // - "project" → user wants synthesis (high-level / "what's X about" query)
840
+ // - "auto" or undefined → no filter; v5 mixes synthesis + atomic with
841
+ // the small synthesis boost letting them edge out at similar scores.
842
+ const resolutionFilter = rewrite?.resolution_intent === "atomic"
843
+ ? "atomic"
844
+ : rewrite?.resolution_intent === "project"
845
+ ? "project"
846
+ : null;
847
+ const url = `${SUPABASE_URL}/rest/v1/rpc/match_vault_v5`;
848
+ // BM25 input: prefer Haiku's expanded_query (fills in pronouns + ambiguous
849
+ // references), fall through to the raw query otherwise. relaxSlugQuery
850
+ // still applies — it handles the slug-shaped-input phrase-query bug, which
851
+ // is orthogonal to semantic expansion.
852
+ const queryTextForBm25 = stripObligationPun(relaxSlugQuery(rewrite?.expanded_query ?? query ?? ""), query ?? "");
853
+ const res = await fetch(url, {
854
+ method: "POST",
855
+ headers: {
856
+ "Content-Type": "application/json",
857
+ Accept: "application/json",
858
+ apikey: SUPABASE_ANON_KEY,
859
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
860
+ "Content-Profile": "becki",
861
+ },
862
+ body: JSON.stringify({
863
+ pp_query_embedding: embedding,
864
+ pp_query_text: queryTextForBm25,
865
+ pp_match_threshold: MATCH_THRESHOLD,
866
+ pp_match_count: limit,
867
+ pp_filter_source_types: filterSourceTypes,
868
+ // v0.7.8: Haiku may identify a project slug; pass it through. Swift
869
+ // app does its own AppState-aware project inference and would set
870
+ // this from there. Either path is exclusive at the call site.
871
+ pp_filter_project_id: rewrite?.project ?? null,
872
+ pp_knowledge_boost: knowledgeBoost,
873
+ pp_candidate_pool: 60,
874
+ pp_include_resolved: false,
875
+ pp_recency_half_life_days: halfLifeDays,
876
+ pp_since: temporal.since,
877
+ pp_until: temporal.until,
878
+ pp_recency_floor: 0.1,
879
+ pp_user_id: userId,
880
+ pp_resolution_filter: resolutionFilter,
881
+ }),
882
+ });
883
+ if (!res.ok) {
884
+ const body = await res.text();
885
+ throw new Error(`Supabase RPC error ${res.status}: ${body}`);
886
+ }
887
+ const raw = (await res.json());
888
+ const chunks = raw.map((c) => ({ ...c, similarity: Math.min(1.0, c.similarity / RRF_CEILING) }));
889
+ // v0.7.5: synapse-firing viz broadcast. Fire-and-forget — failures
890
+ // here NEVER fail the query. The viz subscribes to the same channel
891
+ // and animates the matching nodes. Tagged source: 'mcp' so the viz
892
+ // tints with the lime accent (vs indigo for app-side queries).
893
+ void broadcastQueryEvent({
894
+ queryText: query ?? '',
895
+ chunks,
896
+ source: 'mcp',
897
+ tool: 'becki_context',
898
+ });
899
+ return chunks;
900
+ }
901
+ /**
902
+ * v0.7.5 — synapse-firing viz broadcast.
903
+ *
904
+ * Sends an ephemeral Realtime broadcast to channel "vault-queries:{user_id}"
905
+ * announcing which chunks the latest query touched. The NeuraVault viz
906
+ * subscribes + animates the matching nodes (lime tint for MCP-triggered,
907
+ * indigo for app-triggered).
908
+ *
909
+ * Uses Supabase Realtime's HTTP broadcast endpoint — no websocket required
910
+ * on the becki-mcp side, so we don't pull supabase-js as a dep just for
911
+ * this. The endpoint accepts apikey + JSON body; auth is the anon key
912
+ * (per-user scoping happens via the channel name including user_id).
913
+ *
914
+ * Best-effort: never throws to caller. The query itself already returned
915
+ * by the time this runs.
916
+ */
917
+ async function broadcastQueryEvent(args) {
918
+ const userId = currentUserId();
919
+ if (!userId)
920
+ return;
921
+ try {
922
+ const payload = {
923
+ query_text: args.queryText.slice(0, 200),
924
+ source: args.source,
925
+ tool: args.tool,
926
+ chunks: args.chunks.map((c) => ({
927
+ source_type: c.source_type,
928
+ source_id: c.source_id,
929
+ similarity: c.similarity,
930
+ })),
931
+ ts: new Date().toISOString(),
932
+ };
933
+ const url = `${SUPABASE_URL}/realtime/v1/api/broadcast`;
934
+ await fetch(url, {
935
+ method: 'POST',
936
+ headers: {
937
+ 'Content-Type': 'application/json',
938
+ 'apikey': SUPABASE_ANON_KEY,
939
+ 'Authorization': `Bearer ${SUPABASE_ANON_KEY}`,
940
+ },
941
+ body: JSON.stringify({
942
+ messages: [{
943
+ topic: `vault-queries:${userId}`,
944
+ event: 'query',
945
+ payload,
946
+ private: false,
947
+ }],
948
+ }),
949
+ });
950
+ // Don't read the response. The endpoint returns 202 for queued sends;
951
+ // we don't care about deliverability — viz is best-effort UX, not a
952
+ // transactional signal.
953
+ }
954
+ catch (err) {
955
+ // Swallow — never propagate.
956
+ console.error('broadcastQueryEvent failed (non-fatal):', err);
957
+ }
958
+ }
959
+ /**
960
+ * If the strongest chunk in a retrieval batch is still weak, prepend a
961
+ * warning the downstream LLM can respect. This is the single most
962
+ * important retrieval-hardening knob — without it, an LLM given a
963
+ * low-similarity chunk will pad the gap with training-data hallucination
964
+ * and produce a confident wrong answer. With it, a well-behaved agent
965
+ * degrades to "I don't know — this may not be in the vault."
966
+ *
967
+ * Returns empty string when confidence is fine; otherwise a multi-line
968
+ * warning block terminated by a separator line.
969
+ */
970
+ /**
971
+ * Pull a leading YYYY-MM-DD out of a source_id when present.
972
+ * Becki's ingest writes source_ids like
973
+ * "2026-04-25-positioning-shift-vs-littlebird" for decisions/dead_ends/etc.
974
+ * Returns "" if no leading date is found.
975
+ */
976
+ function dateFromSourceId(source_id) {
977
+ if (!source_id)
978
+ return "";
979
+ const m = source_id.match(/^(\d{4}-\d{2}-\d{2})/);
980
+ return m ? m[1] : "";
981
+ }
982
+ /**
983
+ * One-line confidence bar. Replaces the multi-paragraph warning when
984
+ * top similarity ≥ threshold; below threshold we still want the loud
985
+ * warning so the LLM doesn't confabulate.
986
+ */
987
+ function renderConfidenceLine(top) {
988
+ const filled = Math.max(0, Math.min(12, Math.round(top * 12)));
989
+ const bar = "█".repeat(filled) + "░".repeat(12 - filled);
990
+ const label = top >= 0.7 ? "high" : top >= CONFIDENCE_THRESHOLD ? "medium" : "low";
991
+ return `Confidence: ${bar} ${top.toFixed(2)} (${label})`;
992
+ }
993
+ /**
994
+ * Quote a chunk body with a left bar (│ ) on every line. Reads as
995
+ * a clean blockquote in monospace renderers (Claude Code terminal,
996
+ * Cursor MCP panel, etc.).
997
+ */
998
+ function quoteBody(content) {
999
+ return content
1000
+ .split("\n")
1001
+ .map((l) => (l.length === 0 ? "│" : `│ ${l}`))
1002
+ .join("\n");
1003
+ }
1004
+ function retrievalConfidenceWarning(chunks) {
1005
+ if (chunks.length === 0)
1006
+ return "";
1007
+ const top = chunks[0]?.similarity ?? 0;
1008
+ if (top >= CONFIDENCE_THRESHOLD)
1009
+ return "";
1010
+ const pct = Math.round(top * 100);
1011
+ return [
1012
+ "⚠ No high-confidence match in Becki's vault.",
1013
+ `Best similarity: ${pct}% (threshold for confidence: ${Math.round(CONFIDENCE_THRESHOLD * 100)}%).`,
1014
+ "Treat the chunks below as weak leads, not ground truth.",
1015
+ "If answering the user's question requires definitive facts you do not",
1016
+ "see stated explicitly in the chunks below, prefer \"I don't know — this",
1017
+ "may not be in Becki's vault\" over confabulating from your training data.",
1018
+ "───────────────────────────────────────────────────────────────",
1019
+ "",
1020
+ ].join("\n");
1021
+ }
1022
+ // ---------------------------------------------------------------------------
1023
+ // Core query — text → embedding → pgvector → formatted context
1024
+ // ---------------------------------------------------------------------------
1025
+ /**
1026
+ * Render a single chunk's header line. Code chunks get file:lines:symbol
1027
+ * if metadata is present; other chunks just show source_type.
1028
+ */
1029
+ function renderChunkHeader(chunk, idx) {
1030
+ const pct = Math.round(chunk.similarity * 100);
1031
+ const md = chunk.metadata;
1032
+ // Code chunks keep the file:line:symbol format — that's the
1033
+ // identifier developers actually want for "where is X".
1034
+ if (chunk.source_type === "code" && md && md.file) {
1035
+ const range = md.line_start != null && md.line_end != null
1036
+ ? `:${md.line_start}-${md.line_end}`
1037
+ : "";
1038
+ const sym = md.symbol ? ` · ${md.symbol}` : "";
1039
+ const proj = md.project_name ? ` [${md.project_name}]` : "";
1040
+ // v0.7.5: rank prefix on every header (not just non-code) so the
1041
+ // synthesis-ordering instruction has a stable reference. "[1]" =
1042
+ // most-recent-best-match, [N] = lowest. Code chunks rank too because
1043
+ // newer commits should usually beat older ones for the same file.
1044
+ return `[${idx}] code${proj} ${md.file}${range}${sym} (${pct}% match)`;
1045
+ }
1046
+ // v1.x: synthesis rows get a distinctive header. Detection is via
1047
+ // metadata.synthesis_kind — the consolidate-vault edge function writes
1048
+ // this when generating project/cluster/career-level summaries.
1049
+ // Synthesis rows are returned alongside atomic rows by match_vault_v4;
1050
+ // they bubble up naturally because they're embedded the same way.
1051
+ // The distinctive header tells the consuming LLM: "this is the high-
1052
+ // level synthesis answer, not a raw item — lead with this when the
1053
+ // query is high-level/synthesis-shaped."
1054
+ if (md?.synthesis_kind) {
1055
+ const kindLabel = md.synthesis_kind === "project_level"
1056
+ ? "PROJECT SYNTHESIS"
1057
+ : md.synthesis_kind === "cluster_level"
1058
+ ? "CLUSTER SYNTHESIS"
1059
+ : md.synthesis_kind === "career_level"
1060
+ ? "CAREER SYNTHESIS"
1061
+ : "SYNTHESIS";
1062
+ const sourceCount = md.synthesis_atomic_count
1063
+ ? ` · derived from ${md.synthesis_atomic_count} atomic items`
1064
+ : "";
1065
+ const projTag = md.project_name ? ` [${md.project_name}]` : "";
1066
+ const idLine = chunk.id ? `\n row_id: ${chunk.id}` : "";
1067
+ // Star prefix instead of diamond — synthesis rows are visually distinct
1068
+ // so a downstream LLM scanning the result block immediately spots them.
1069
+ return `★ ${kindLabel}${projTag}${sourceCount} (${pct}% match)${idLine}`;
1070
+ }
1071
+ // Knowledge chunks (decision / dead_end / commitment / open_loop /
1072
+ // note) get a labeled diamond header. Date is parsed from source_id
1073
+ // when present — Becki's ingest writes "YYYY-MM-DD-slug" filenames.
1074
+ const date = dateFromSourceId(chunk.source_id);
1075
+ const label = SOURCE_LABELS[chunk.source_type] ?? chunk.source_type.toUpperCase();
1076
+ const dateSuffix = date ? ` · ${date}` : "";
1077
+ // v0.7.5: surface row UUID so agents can call becki_resolve. Two-line
1078
+ // format: human-readable header + machine-extractable id line that's
1079
+ // easy to grep ("row_id: <uuid>" prefix is stable + unique).
1080
+ const idLine = chunk.id ? `\n row_id: ${chunk.id}` : "";
1081
+ return `◆ ${label}${dateSuffix} (${pct}% match)${idLine}`;
1082
+ }
1083
+ const SOURCE_LABELS = {
1084
+ decision: "DECISION",
1085
+ dead_end: "DEAD END",
1086
+ commitment: "COMMITMENT",
1087
+ open_loop: "OPEN LOOP",
1088
+ note: "NOTE",
1089
+ meeting: "MEETING",
1090
+ commit: "COMMIT",
1091
+ };
1092
+ async function queryVault(query, codeOnly = false) {
1093
+ // Cap query length to avoid wasting embedding tokens
1094
+ const queryText = query.slice(0, 4000);
1095
+ // #168 step 2: anchor the embedding on obligation vocabulary when an idiom
1096
+ // fires, so the vector search stops pulling cross-project "plate" content.
1097
+ // queryText itself stays raw (BM25 + display).
1098
+ const embedding = await generateEmbedding(canonicalizeObligationIdiom(queryText), "query");
1099
+ // Pass `query` so matchChunks can route via the heuristic intent classifier
1100
+ // (decision_recall vs code_locator vs general) and apply the right
1101
+ // knowledge_boost on the v0.4 match_vault_v2 RPC.
1102
+ const chunks = await matchChunks(embedding, MATCH_COUNT, codeOnly, queryText);
1103
+ // v1.x: dead-end pattern recognition (Path B — at-query-time). Runs in
1104
+ // PARALLEL with the main retrieval, never blocks it. Code-only queries
1105
+ // skip this lane (code_locator intent has no use for dead-end warnings).
1106
+ // See: decisions/2026-05-10-dead-end-pattern-recognition-path-b-at-query-time-not-contin.md
1107
+ const deadEndWarningsPromise = codeOnly
1108
+ ? Promise.resolve([])
1109
+ : surfaceDeadEnds(queryText, embedding).catch((err) => {
1110
+ // Best-effort — never propagate. Dead-end surface is OPTIONAL context.
1111
+ console.error("[dead-end] surface failed (non-fatal):", err instanceof Error ? err.message : err);
1112
+ return [];
1113
+ });
1114
+ // v1.x Lane 3 (Path C): project-synthesis surfacing. Direct SQL lookup
1115
+ // (NOT embedding similarity) for the project's consolidated narrative
1116
+ // when Haiku /rewrite identifies a project_name slug. Synthesis is a
1117
+ // project ATTRIBUTE, not a haystack item — it surfaces deterministically
1118
+ // when project intent is detected, regardless of query phrasing.
1119
+ // The /rewrite call here hits the same 24h per-user cache that matchChunks
1120
+ // hit moments ago — second call is free. Skip for code-only queries
1121
+ // (intent is finding code, not project gestalt).
1122
+ const projectSynthesisPromise = codeOnly
1123
+ ? Promise.resolve(null)
1124
+ : surfaceProjectSynthesis(queryText).catch((err) => {
1125
+ console.error("[lane3] synthesis surface failed (non-fatal):", err instanceof Error ? err.message : err);
1126
+ return null;
1127
+ });
1128
+ // v1.x Lane 4: career-level synthesis surfacing. Same Path C mechanism
1129
+ // at user scope (no project filter). Fires when Haiku classifies
1130
+ // resolution_intent='career' — meta-questions about working patterns
1131
+ // ("how do I tend to handle X" / "what's my pattern with Y").
1132
+ // Skip for code-only queries.
1133
+ const careerSynthesisPromise = codeOnly
1134
+ ? Promise.resolve(null)
1135
+ : surfaceCareerSynthesis(queryText).catch((err) => {
1136
+ console.error("[lane4] synthesis surface failed (non-fatal):", err instanceof Error ? err.message : err);
1137
+ return null;
1138
+ });
1139
+ // Rung 2.5 Lane 5: deterministic commitments ledger. Fires only on a local
1140
+ // obligation/status heuristic (no LLM). Parallel, never blocks retrieval.
1141
+ const commitmentLedgerPromise = codeOnly
1142
+ ? Promise.resolve(null)
1143
+ : surfaceCommitmentLedger(queryText).catch((err) => {
1144
+ console.error("[lane5] ledger surface failed (non-fatal):", err instanceof Error ? err.message : err);
1145
+ return null;
1146
+ });
1147
+ // Lane 6: decision reasoning graph. Fires only on a local "why did we decide"
1148
+ // intent heuristic (no LLM). Parallel, never blocks retrieval.
1149
+ const decisionReasoningPromise = codeOnly
1150
+ ? Promise.resolve(null)
1151
+ : surfaceDecisionReasoning(queryText).catch((err) => {
1152
+ console.error("[lane6] decision-reasoning surface failed (non-fatal):", err instanceof Error ? err.message : err);
1153
+ return null;
1154
+ });
1155
+ if (chunks.length === 0) {
1156
+ // Even with zero main-retrieval chunks, surface dead-ends OR project
1157
+ // synthesis OR career synthesis if any were found — all useful context
1158
+ // on their own. Wait for parallel calls.
1159
+ const [earlyWarnings, earlyProjectSynth, earlyCareerSynth, earlyLedger, earlyDecisionReasoning] = await Promise.all([
1160
+ deadEndWarningsPromise,
1161
+ projectSynthesisPromise,
1162
+ careerSynthesisPromise,
1163
+ commitmentLedgerPromise,
1164
+ decisionReasoningPromise,
1165
+ ]);
1166
+ const scope = codeOnly ? "code" : "vault";
1167
+ if (earlyWarnings.length === 0 && !earlyProjectSynth && !earlyCareerSynth && !earlyLedger && !earlyDecisionReasoning) {
1168
+ return `NeuraVault: no relevant ${scope} context found for this query. The vault may be empty or no chunks matched the similarity threshold. Do not confabulate from training data — tell the user you don't have this in Becki's memory.`;
1169
+ }
1170
+ const lines = [];
1171
+ lines.push(`NeuraVault: no atomic ${scope} matches — surfacing synthesis + warnings:`);
1172
+ lines.push("");
1173
+ if (earlyLedger)
1174
+ lines.push(...renderCommitmentLedgerSection(earlyLedger));
1175
+ if (earlyDecisionReasoning)
1176
+ lines.push(...renderDecisionReasoningSection(earlyDecisionReasoning));
1177
+ if (earlyCareerSynth)
1178
+ lines.push(...renderCareerSynthesisSection(earlyCareerSynth));
1179
+ if (earlyProjectSynth)
1180
+ lines.push(...renderProjectSynthesisSection(earlyProjectSynth));
1181
+ if (earlyWarnings.length > 0)
1182
+ lines.push(...renderDeadEndSection(earlyWarnings));
1183
+ return lines.join("\n").trim();
1184
+ }
1185
+ const label = codeOnly ? "code context" : "context";
1186
+ const top = chunks[0]?.similarity ?? 0;
1187
+ const confidenceWarning = retrievalConfidenceWarning(chunks);
1188
+ const lines = [];
1189
+ lines.push("Querying NeuraVault…");
1190
+ lines.push("");
1191
+ if (confidenceWarning) {
1192
+ lines.push(confidenceWarning);
1193
+ lines.push("");
1194
+ }
1195
+ lines.push(`Relevant ${label} from NeuraVault (${chunks.length} chunk${chunks.length === 1 ? "" : "s"}):`, "");
1196
+ // v0.7.5: synthesis-ordering instruction. The chunks are returned in
1197
+ // recency-weighted retrieval rank order (match_vault_v4's combined
1198
+ // similarity × recency-half-life-decay × type-anchor score). Chunk [1]
1199
+ // is the BEST-MATCHING-AND-MOST-RECENT relevant chunk for this query.
1200
+ // Without this hint, downstream LLMs sometimes synthesize the narrative
1201
+ // from whichever chunk is most verbose — even if it's older + describes
1202
+ // a superseded approach. The "we recently switched to per-process Process
1203
+ // Tap" failure (Bryan, 2026-05-04: AI summarized v0.5.7 architecture
1204
+ // when the user asked about a v0.7.4 change) was exactly this shape.
1205
+ lines.push("When synthesizing an answer, LEAD with chunk [1] — it is the most", "recent + best-matching chunk. Only reference older chunks (lower in", "this list) to provide background or to show evolution; do NOT let an", "older chunk's verbosity dominate the narrative if a more recent chunk", "describes the current state. If the user asked about \"recent\",", "\"latest\", or \"current\", base the answer on chunk [1] specifically.", "");
1206
+ for (let i = 0; i < chunks.length; i++) {
1207
+ const chunk = chunks[i];
1208
+ lines.push(renderChunkHeader(chunk, i + 1));
1209
+ lines.push(quoteBody(chunk.content));
1210
+ lines.push("");
1211
+ }
1212
+ // One-line confidence summary at the bottom — easy to spot, easy to
1213
+ // visually parse. Distinct from the loud paragraph warning, which
1214
+ // only fires below CONFIDENCE_THRESHOLD.
1215
+ lines.push(renderConfidenceLine(top));
1216
+ // v1.x: dead-end warnings, if any. Rendered AFTER the main retrieval
1217
+ // so the model has full context first; placement at the END means
1218
+ // warnings are read last and stay top-of-mind during synthesis.
1219
+ const [deadEndWarnings, projectSynthesis, careerSynthesis, commitmentLedger, decisionReasoning] = await Promise.all([
1220
+ deadEndWarningsPromise,
1221
+ projectSynthesisPromise,
1222
+ careerSynthesisPromise,
1223
+ commitmentLedgerPromise,
1224
+ decisionReasoningPromise,
1225
+ ]);
1226
+ // v1.x Lanes 3 + 4 + Rung 2.5 Lane 5: lead blocks inserted at the VERY TOP
1227
+ // of the response (before atomic chunks) so the consuming LLM reads them
1228
+ // first as framing. Atomic chunks become supporting detail underneath.
1229
+ // unshift order is reverse of display order — whatever is unshifted LAST
1230
+ // ends up on top. Display order top→bottom: ledger (the exhaustive fact for
1231
+ // obligation queries) → career → project → atomic chunks.
1232
+ if (careerSynthesis) {
1233
+ lines.unshift(...renderCareerSynthesisSection(careerSynthesis), "");
1234
+ }
1235
+ if (projectSynthesis) {
1236
+ lines.unshift(...renderProjectSynthesisSection(projectSynthesis), "");
1237
+ }
1238
+ if (commitmentLedger) {
1239
+ lines.unshift(...renderCommitmentLedgerSection(commitmentLedger), "");
1240
+ }
1241
+ // Decision reasoning unshifted last → sits on top for "why did we decide" queries.
1242
+ if (decisionReasoning) {
1243
+ lines.unshift(...renderDecisionReasoningSection(decisionReasoning), "");
1244
+ }
1245
+ if (deadEndWarnings.length > 0) {
1246
+ lines.push("");
1247
+ lines.push(...renderDeadEndSection(deadEndWarnings));
1248
+ }
1249
+ return lines.join("\n").trim();
1250
+ }
1251
+ async function surfaceProjectSynthesis(query) {
1252
+ const userId = currentUserId();
1253
+ if (!userId)
1254
+ return null;
1255
+ // Reuse the Haiku rewrite — second call hits the 24h per-user cache, so
1256
+ // the latency cost is sub-50ms vs the original ~1500ms first call.
1257
+ const rewrite = await rewriteQueryViaProxy(query);
1258
+ if (!rewrite || !rewrite.project)
1259
+ return null;
1260
+ // Direct RPC lookup — no embedding similarity. The migration 039 RPC
1261
+ // get_project_synthesis takes p_user_id (install-token scoped) and
1262
+ // p_project_name (the slug Haiku returned).
1263
+ const url = `${SUPABASE_URL}/rest/v1/rpc/get_project_synthesis`;
1264
+ let res;
1265
+ try {
1266
+ res = await fetch(url, {
1267
+ method: "POST",
1268
+ headers: {
1269
+ "Content-Type": "application/json",
1270
+ Accept: "application/json",
1271
+ apikey: SUPABASE_ANON_KEY,
1272
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1273
+ "Content-Profile": "becki",
1274
+ },
1275
+ body: JSON.stringify({
1276
+ p_user_id: userId,
1277
+ p_project_name: rewrite.project,
1278
+ }),
1279
+ });
1280
+ }
1281
+ catch (err) {
1282
+ console.error("[lane3] get_project_synthesis fetch failed:", err instanceof Error ? err.message : err);
1283
+ return null;
1284
+ }
1285
+ if (!res.ok) {
1286
+ const body = await res.text().catch(() => "");
1287
+ console.error(`[lane3] get_project_synthesis ${res.status}: ${body.slice(0, 200)}`);
1288
+ return null;
1289
+ }
1290
+ const rows = (await res.json());
1291
+ return rows.length > 0 ? rows[0] : null;
1292
+ }
1293
+ function renderProjectSynthesisSection(row) {
1294
+ const md = row.metadata ?? {};
1295
+ const projectName = md.project_name ?? "(unnamed project)";
1296
+ const sourceCount = row.source_count ?? 0;
1297
+ const generatedAt = row.consolidated_at?.slice(0, 10) ?? "";
1298
+ const ageNote = generatedAt ? ` · synthesized ${generatedAt}` : "";
1299
+ const lines = [];
1300
+ lines.push(`★ PROJECT SYNTHESIS [${projectName}] · derived from ${sourceCount} atomic items${ageNote}`);
1301
+ lines.push(` row_id: ${row.id}`);
1302
+ lines.push("");
1303
+ lines.push("This is the curated project-level summary. Lead with this for any high-");
1304
+ lines.push("level question about the project. Atomic items below are supporting detail.");
1305
+ lines.push("");
1306
+ lines.push(row.content);
1307
+ return lines;
1308
+ }
1309
+ async function surfaceCareerSynthesis(query) {
1310
+ const userId = currentUserId();
1311
+ if (!userId)
1312
+ return null;
1313
+ // Reuse the cached Haiku rewrite — second call hits 24h per-user cache.
1314
+ const rewrite = await rewriteQueryViaProxy(query);
1315
+ if (!rewrite || rewrite.resolution_intent !== "career")
1316
+ return null;
1317
+ // Direct RPC lookup — no embedding similarity. The migration 040 RPC
1318
+ // get_career_synthesis takes p_user_id (install-token scoped) only.
1319
+ const url = `${SUPABASE_URL}/rest/v1/rpc/get_career_synthesis`;
1320
+ let res;
1321
+ try {
1322
+ res = await fetch(url, {
1323
+ method: "POST",
1324
+ headers: {
1325
+ "Content-Type": "application/json",
1326
+ Accept: "application/json",
1327
+ apikey: SUPABASE_ANON_KEY,
1328
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1329
+ "Content-Profile": "becki",
1330
+ },
1331
+ body: JSON.stringify({ p_user_id: userId }),
1332
+ });
1333
+ }
1334
+ catch (err) {
1335
+ console.error("[lane4] get_career_synthesis fetch failed:", err instanceof Error ? err.message : err);
1336
+ return null;
1337
+ }
1338
+ if (!res.ok) {
1339
+ const body = await res.text().catch(() => "");
1340
+ console.error(`[lane4] get_career_synthesis ${res.status}: ${body.slice(0, 200)}`);
1341
+ return null;
1342
+ }
1343
+ const rows = (await res.json());
1344
+ return rows.length > 0 ? rows[0] : null;
1345
+ }
1346
+ function renderCareerSynthesisSection(row) {
1347
+ const md = row.metadata ?? {};
1348
+ const projectCount = md.synthesis_project_count ?? 0;
1349
+ const atomicCount = md.synthesis_recent_atomic_count ?? 0;
1350
+ const generatedAt = row.consolidated_at?.slice(0, 10) ?? "";
1351
+ const ageNote = generatedAt ? ` · synthesized ${generatedAt}` : "";
1352
+ const lines = [];
1353
+ lines.push(`🌐 CAREER PATTERNS · derived from ${projectCount} project syntheses + ${atomicCount} recent atomic items${ageNote}`);
1354
+ lines.push(` row_id: ${row.id}`);
1355
+ lines.push("");
1356
+ lines.push("This is the cross-project view of how the user works — preferences,");
1357
+ lines.push("recurring dead-end patterns, vocabulary, current themes. Lead with this");
1358
+ lines.push("for meta-questions about working style. Project + atomic context below.");
1359
+ lines.push("");
1360
+ lines.push(row.content);
1361
+ return lines;
1362
+ }
1363
+ // Deterministic intent gate. Matches obligation/status phrasing only, so we
1364
+ // don't inject the ledger on unrelated queries. A false negative just means
1365
+ // normal retrieval still runs; a false positive only adds a little context.
1366
+ const LEDGER_INTENT_RE = /\b(owe|owed|owes|waiting on|waiting for|on my plate|outstanding|action items?|follow[\s-]?ups?|commitments?|open (?:asks?|items?|loops?|commitments?)|what (?:do|did) i (?:owe|need to do|promise|commit)|what(?:'?s| am i| are my)?\s+(?:waiting on|on my plate|due|open|outstanding|owed)|who owes me|my (?:tasks?|to-?dos?)|to-?do list|did i (?:promise|commit|agree))\b/i;
1367
+ function ledgerIntent(query) {
1368
+ return LEDGER_INTENT_RE.test(query);
1369
+ }
1370
+ const LEDGER_RENDER_PER_BUCKET = 12;
1371
+ async function surfaceCommitmentLedger(query) {
1372
+ const userId = currentUserId();
1373
+ if (!userId)
1374
+ return null;
1375
+ if (!ledgerIntent(query))
1376
+ return null;
1377
+ const url = `${SUPABASE_URL}/rest/v1/rpc/commitment_ledger_v1`;
1378
+ let res;
1379
+ try {
1380
+ res = await fetch(url, {
1381
+ method: "POST",
1382
+ headers: {
1383
+ "Content-Type": "application/json",
1384
+ Accept: "application/json",
1385
+ apikey: SUPABASE_ANON_KEY,
1386
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1387
+ "Content-Profile": "becki",
1388
+ },
1389
+ body: JSON.stringify({ pp_user_id: userId }),
1390
+ });
1391
+ }
1392
+ catch (err) {
1393
+ console.error("[lane5] commitment_ledger_v1 fetch failed:", err instanceof Error ? err.message : err);
1394
+ return null;
1395
+ }
1396
+ if (!res.ok) {
1397
+ const body = await res.text().catch(() => "");
1398
+ console.error(`[lane5] commitment_ledger_v1 ${res.status}: ${body.slice(0, 200)}`);
1399
+ return null;
1400
+ }
1401
+ const ledger = (await res.json());
1402
+ const total = (ledger?.my_commitments?.length ?? 0) +
1403
+ (ledger?.asks_of_me?.length ?? 0) +
1404
+ (ledger?.waiting_on?.length ?? 0) +
1405
+ (ledger?.dependencies?.length ?? 0);
1406
+ return total > 0 ? ledger : null;
1407
+ }
1408
+ function renderLedgerItem(it) {
1409
+ const who = it.counterparty ? ` → ${it.counterparty}` : "";
1410
+ const deadline = it.deadline
1411
+ ? ` · due ${it.deadline}${it.deadline_confidence && it.deadline_confidence !== "explicit" ? ` (${it.deadline_confidence})` : ""}`
1412
+ : "";
1413
+ const proj = it.project ? ` · ${it.project}` : "";
1414
+ const age = Number.isFinite(it.age_days) ? ` · ${it.age_days}d old` : "";
1415
+ return ` • ${it.text}${who}${deadline}${proj}${age}`;
1416
+ }
1417
+ function renderLedgerBucket(title, items, total) {
1418
+ if (total === 0)
1419
+ return [];
1420
+ const shown = items.slice(0, LEDGER_RENDER_PER_BUCKET);
1421
+ const lines = [`${title} (${total}):`];
1422
+ for (const it of shown)
1423
+ lines.push(renderLedgerItem(it));
1424
+ const remaining = total - shown.length;
1425
+ if (remaining > 0)
1426
+ lines.push(` …and ${remaining} more`);
1427
+ lines.push("");
1428
+ return lines;
1429
+ }
1430
+ function renderCommitmentLedgerSection(ledger) {
1431
+ const c = ledger.counts ?? {};
1432
+ const grand = (c.my_commitments ?? 0) + (c.asks_of_me ?? 0) + (c.waiting_on ?? 0) + (c.dependencies ?? 0);
1433
+ const lines = [];
1434
+ lines.push(`📋 ON YOUR PLATE · ${grand} open item${grand === 1 ? "" : "s"} (deterministic ledger — the COMPLETE set, not retrieval)`);
1435
+ lines.push("");
1436
+ lines.push("This is a relational walk over the user's tracked commitments, not a");
1437
+ lines.push("similarity search. It is exhaustive for the buckets shown — when answering");
1438
+ lines.push("\"what do I owe / what's owed to me / what am I waiting on\", trust these");
1439
+ lines.push("counts as complete and do NOT supplement from the atomic chunks below.");
1440
+ lines.push("");
1441
+ lines.push(...renderLedgerBucket("YOU OWE / PROMISED", ledger.my_commitments, c.my_commitments ?? 0));
1442
+ lines.push(...renderLedgerBucket("ASKED OF YOU (you owe a reply)", ledger.asks_of_me, c.asks_of_me ?? 0));
1443
+ lines.push(...renderLedgerBucket("WAITING ON OTHERS", ledger.waiting_on, c.waiting_on ?? 0));
1444
+ lines.push(...renderLedgerBucket("DEPENDENCIES", ledger.dependencies, c.dependencies ?? 0));
1445
+ return lines;
1446
+ }
1447
+ const DECISION_INTENT_RE = /\b(why (?:did|do|'d|would|are|is|was|were)?\s*(?:we|i|you|they)\s+(?:decide|decided|chose|choose|pick|picked|go with|went with|reject|rule out|ruled out|avoid)|why not\b|why (?:we|i) (?:chose|decided|picked|went with|rejected|avoided)|rationale (?:for|behind)|reasoning behind|what (?:did|do) (?:we|i|you) (?:rule out|reject|decide against|consider)|what (?:were|are) (?:the|our|my) (?:options|alternatives|trade-?offs?)|alternatives? (?:we |i )?considered|decided against|ruled out|trade-?offs?\b)/i;
1448
+ function decisionIntent(query) {
1449
+ return DECISION_INTENT_RE.test(query);
1450
+ }
1451
+ const DECISION_REASONING_LIMIT = 4;
1452
+ async function surfaceDecisionReasoning(query) {
1453
+ const userId = currentUserId();
1454
+ if (!userId)
1455
+ return null;
1456
+ if (!decisionIntent(query))
1457
+ return null;
1458
+ const url = `${SUPABASE_URL}/rest/v1/rpc/decision_reasoning_v1`;
1459
+ let res;
1460
+ try {
1461
+ res = await fetch(url, {
1462
+ method: "POST",
1463
+ headers: {
1464
+ "Content-Type": "application/json",
1465
+ Accept: "application/json",
1466
+ apikey: SUPABASE_ANON_KEY,
1467
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1468
+ "Content-Profile": "becki",
1469
+ },
1470
+ body: JSON.stringify({ pp_user_id: userId, pp_query: query, pp_match_count: DECISION_REASONING_LIMIT }),
1471
+ });
1472
+ }
1473
+ catch (err) {
1474
+ console.error("[lane6] decision_reasoning_v1 fetch failed:", err instanceof Error ? err.message : err);
1475
+ return null;
1476
+ }
1477
+ if (!res.ok) {
1478
+ const body = await res.text().catch(() => "");
1479
+ console.error(`[lane6] decision_reasoning_v1 ${res.status}: ${body.slice(0, 200)}`);
1480
+ return null;
1481
+ }
1482
+ const rows = (await res.json());
1483
+ return Array.isArray(rows) && rows.length > 0 ? rows : null;
1484
+ }
1485
+ function renderDecisionReasoningSection(rows) {
1486
+ const lines = [];
1487
+ lines.push(`🧭 DECISION REASONING · ${rows.length} matching decision${rows.length === 1 ? "" : "s"} (structured "why", not similarity retrieval)`);
1488
+ lines.push("");
1489
+ lines.push("The chosen path, the alternatives considered, and WHY each was rejected.");
1490
+ lines.push("When the user asks \"why did we decide X / what did we rule out\", lead with");
1491
+ lines.push("this — it is the structured record, not a fuzzy chunk match.");
1492
+ lines.push("");
1493
+ for (const r of rows) {
1494
+ lines.push(`▸ ${r.title || r.chosen || "(decision)"}`);
1495
+ if (r.chosen)
1496
+ lines.push(` chose: ${r.chosen}`);
1497
+ if (r.rationale)
1498
+ lines.push(` why: ${r.rationale}`);
1499
+ const opts = Array.isArray(r.options) ? r.options : [];
1500
+ for (const o of opts.filter((o) => o.verdict === "rejected")) {
1501
+ lines.push(` ✗ rejected — ${o.label ?? "option"}: ${o.why ?? ""}`.trimEnd());
1502
+ }
1503
+ for (const o of opts.filter((o) => o.verdict === "considered")) {
1504
+ lines.push(` ◦ considered — ${o.label ?? "option"}: ${o.why ?? ""}`.trimEnd());
1505
+ }
1506
+ if (r.tradeoffs)
1507
+ lines.push(` tradeoff: ${r.tradeoffs}`);
1508
+ lines.push("");
1509
+ }
1510
+ return lines;
1511
+ }
1512
+ const DEAD_END_CANDIDATE_LIMIT = 3;
1513
+ /** Top-level: returns warnings ready to render. Empty array on any failure. */
1514
+ async function surfaceDeadEnds(queryText, embedding) {
1515
+ const userId = currentUserId();
1516
+ if (!userId)
1517
+ return [];
1518
+ const candidates = await matchDeadEndCandidates(embedding, queryText, userId);
1519
+ if (candidates.length === 0)
1520
+ return [];
1521
+ const evaluations = await evaluateDeadEndRelevance(queryText, candidates);
1522
+ if (evaluations.length === 0)
1523
+ return [];
1524
+ const warnings = [];
1525
+ for (const evaluation of evaluations) {
1526
+ if (evaluation.decision === "suppress")
1527
+ continue;
1528
+ const candidate = candidates.find((c) => c.id === evaluation.id);
1529
+ if (!candidate)
1530
+ continue;
1531
+ warnings.push({
1532
+ id: candidate.id,
1533
+ decision: evaluation.decision,
1534
+ headline: evaluation.headline,
1535
+ reason: evaluation.reason,
1536
+ content: candidate.content,
1537
+ agePhrase: formatAgePhrase(candidate.age_days),
1538
+ resolved: candidate.resolved_at !== null || candidate.superseded_by !== null,
1539
+ });
1540
+ }
1541
+ return warnings;
1542
+ }
1543
+ async function matchDeadEndCandidates(embedding, queryText, userId) {
1544
+ const url = `${SUPABASE_URL}/rest/v1/rpc/match_dead_ends_v1`;
1545
+ let res;
1546
+ try {
1547
+ res = await fetch(url, {
1548
+ method: "POST",
1549
+ headers: {
1550
+ "Content-Type": "application/json",
1551
+ Accept: "application/json",
1552
+ apikey: SUPABASE_ANON_KEY,
1553
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1554
+ "Content-Profile": "becki",
1555
+ },
1556
+ body: JSON.stringify({
1557
+ pp_query_embedding: embedding,
1558
+ pp_query_text: stripObligationPun(relaxSlugQuery(queryText), queryText),
1559
+ pp_match_count: DEAD_END_CANDIDATE_LIMIT,
1560
+ pp_user_id: userId,
1561
+ }),
1562
+ });
1563
+ }
1564
+ catch (err) {
1565
+ console.error("[dead-end] match_dead_ends_v1 fetch failed:", err instanceof Error ? err.message : err);
1566
+ return [];
1567
+ }
1568
+ if (!res.ok) {
1569
+ const body = await res.text().catch(() => "");
1570
+ console.error(`[dead-end] match_dead_ends_v1 RPC ${res.status}: ${body.slice(0, 200)}`);
1571
+ return [];
1572
+ }
1573
+ try {
1574
+ return (await res.json());
1575
+ }
1576
+ catch (err) {
1577
+ console.error("[dead-end] match_dead_ends_v1 parse failed:", err instanceof Error ? err.message : err);
1578
+ return [];
1579
+ }
1580
+ }
1581
+ async function evaluateDeadEndRelevance(query, candidates) {
1582
+ const token = cachedIngestToken;
1583
+ if (!token)
1584
+ return []; // no install token = MCP not registered yet
1585
+ const endpoint = `${SUPABASE_URL}/functions/v1/claude-proxy/dead-end-evaluate`;
1586
+ const body = {
1587
+ query,
1588
+ candidates: candidates.map((c) => ({
1589
+ id: c.id,
1590
+ content: c.content,
1591
+ age_days: c.age_days,
1592
+ resolved: c.resolved_at !== null || c.superseded_by !== null,
1593
+ })),
1594
+ };
1595
+ // Hard-cap via AbortController — Haiku evaluation typically <2s; cap at
1596
+ // 6s so a slow call can't stall the query response perceptibly. Same
1597
+ // pattern as rewriteQueryViaProxy above so the AbortError telemetry is
1598
+ // consistent.
1599
+ const ctrl = new AbortController();
1600
+ const timer = setTimeout(() => ctrl.abort(), 6000);
1601
+ let res;
1602
+ try {
1603
+ res = await fetch(endpoint, {
1604
+ method: "POST",
1605
+ headers: {
1606
+ "Content-Type": "application/json",
1607
+ Authorization: `Bearer ${token}`,
1608
+ },
1609
+ body: JSON.stringify(body),
1610
+ signal: ctrl.signal,
1611
+ });
1612
+ }
1613
+ catch (err) {
1614
+ if (err?.name === "AbortError") {
1615
+ console.error("[dead-end] evaluator: timeout after 6000ms");
1616
+ }
1617
+ else {
1618
+ console.error("[dead-end] evaluator fetch failed:", err instanceof Error ? err.message : err);
1619
+ }
1620
+ return [];
1621
+ }
1622
+ finally {
1623
+ clearTimeout(timer);
1624
+ }
1625
+ if (!res.ok) {
1626
+ if (res.status !== 422) {
1627
+ const body = await res.text().catch(() => "");
1628
+ console.error(`[dead-end] evaluator ${res.status}: ${body.slice(0, 200)}`);
1629
+ }
1630
+ return [];
1631
+ }
1632
+ try {
1633
+ const envelope = (await res.json());
1634
+ return Array.isArray(envelope.evaluations) ? envelope.evaluations : [];
1635
+ }
1636
+ catch (err) {
1637
+ console.error("[dead-end] evaluator parse failed:", err instanceof Error ? err.message : err);
1638
+ return [];
1639
+ }
1640
+ }
1641
+ function formatAgePhrase(days) {
1642
+ if (days <= 0)
1643
+ return "today";
1644
+ if (days === 1)
1645
+ return "yesterday";
1646
+ if (days < 14)
1647
+ return `${days} days ago`;
1648
+ if (days < 60)
1649
+ return `about ${Math.round(days / 7)} weeks ago`;
1650
+ if (days < 365)
1651
+ return `about ${Math.round(days / 30)} months ago`;
1652
+ const years = days / 365;
1653
+ if (years < 1.5)
1654
+ return "about a year ago";
1655
+ return `over ${Math.floor(years)} years ago`;
1656
+ }
1657
+ /**
1658
+ * Rendered as a distinct callout block in the becki_context output. The
1659
+ * caller (Claude / Cursor / Zed via MCP) reads this as a heads-up section
1660
+ * separate from the main retrieval results, so it stays top-of-mind during
1661
+ * synthesis instead of being lost in the chunk list.
1662
+ */
1663
+ function renderDeadEndSection(warnings) {
1664
+ const lines = [];
1665
+ const warns = warnings.filter((w) => w.decision === "warn");
1666
+ const notes = warnings.filter((w) => w.decision === "footnote");
1667
+ lines.push("⚠ PAST FAILURES RELEVANT TO THIS QUERY");
1668
+ lines.push("(Becki's pattern recognition surfaces these to prevent repeating mistakes.)");
1669
+ lines.push("");
1670
+ for (const w of warns) {
1671
+ const tag = w.resolved ? "(resolved)" : "(active warning)";
1672
+ lines.push(` ⚠ ${w.headline} — ${w.agePhrase} ${tag}`);
1673
+ lines.push(` why surfaced: ${w.reason}`);
1674
+ lines.push(` full: ${truncateForCallout(w.content, 280)}`);
1675
+ lines.push("");
1676
+ }
1677
+ if (notes.length > 0) {
1678
+ lines.push(" Soft mentions (you might also want to check):");
1679
+ for (const w of notes) {
1680
+ lines.push(` · ${w.headline} — ${w.agePhrase}: ${w.reason}`);
1681
+ }
1682
+ }
1683
+ return lines;
1684
+ }
1685
+ function truncateForCallout(text, max) {
1686
+ const trimmed = text.replace(/\s+/g, " ").trim();
1687
+ if (trimmed.length <= max)
1688
+ return trimmed;
1689
+ return trimmed.slice(0, max - 1).trimEnd() + "…";
1690
+ }
1691
+ async function listVaultRows(projectId, sinceISO, untilISO, types, limit) {
1692
+ // p_user_id is REQUIRED post-migration 028 — same fix pattern as
1693
+ // match_vault_v4 (migration 027). Anon callers without it get zero rows.
1694
+ // p_until added in migration 029 for v0.7.5 time-window queries — pass
1695
+ // null when the caller didn't supply an upper bound.
1696
+ const userId = currentUserId();
1697
+ if (!userId) {
1698
+ throw new Error("becki: no user_id available — open Becki.app and sign in once to register this device. " +
1699
+ "(Vault listing requires per-user scoping after the v0.7 security fix.)");
1700
+ }
1701
+ const url = `${SUPABASE_URL}/rest/v1/rpc/list_vault_rows`;
1702
+ const res = await fetch(url, {
1703
+ method: "POST",
1704
+ headers: {
1705
+ "Content-Type": "application/json",
1706
+ Accept: "application/json",
1707
+ apikey: SUPABASE_ANON_KEY,
1708
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1709
+ "Content-Profile": "becki",
1710
+ },
1711
+ body: JSON.stringify({
1712
+ p_project_id: projectId ?? null,
1713
+ p_since: sinceISO ?? null,
1714
+ p_until: untilISO ?? null,
1715
+ p_types: types && types.length > 0 ? types : null,
1716
+ p_limit: limit,
1717
+ p_user_id: userId,
1718
+ }),
1719
+ });
1720
+ if (!res.ok) {
1721
+ const body = await res.text();
1722
+ throw new Error(`list_vault_rows RPC error ${res.status}: ${body.slice(0, 300)}`);
1723
+ }
1724
+ return (await res.json());
1725
+ }
1726
+ async function resolveVaultRow(rowId, supersededBy, note) {
1727
+ // v0.7.5: pp_user_id is REQUIRED post-migration 031 — same fix-shape as
1728
+ // match_vault_v4 (027) and list_vault_rows (028). The install-token call
1729
+ // path authenticates with the anon key (auth.uid() returns NULL); without
1730
+ // an explicit user_id the SECURITY DEFINER function falls through to the
1731
+ // NULL-return branch and the resolve silently no-ops, indistinguishable
1732
+ // from "row not found." This was the bug Bryan hit on 2026-05-05.
1733
+ const userId = currentUserId();
1734
+ if (!userId) {
1735
+ throw new Error("becki: no user_id available — open Becki.app and sign in once to register this device. " +
1736
+ "(Vault resolution requires per-user scoping after the v0.7.5 security fix.)");
1737
+ }
1738
+ // Two RPCs to choose between: supersede_vault_row when a newer row
1739
+ // takes the older one's place; resolve_vault_row for plain "done."
1740
+ const isSupersession = !!supersededBy;
1741
+ const rpcName = isSupersession ? "supersede_vault_row" : "resolve_vault_row";
1742
+ const body = isSupersession
1743
+ ? {
1744
+ pp_old_row_id: rowId,
1745
+ pp_new_row_id: supersededBy,
1746
+ pp_resolved_by: "agent",
1747
+ pp_note: note ?? null,
1748
+ pp_user_id: userId,
1749
+ }
1750
+ : {
1751
+ pp_row_id: rowId,
1752
+ pp_resolved_by: "agent",
1753
+ pp_note: note ?? null,
1754
+ pp_user_id: userId,
1755
+ };
1756
+ const url = `${SUPABASE_URL}/rest/v1/rpc/${rpcName}`;
1757
+ const res = await fetch(url, {
1758
+ method: "POST",
1759
+ headers: {
1760
+ "Content-Type": "application/json",
1761
+ Accept: "application/json",
1762
+ apikey: SUPABASE_ANON_KEY,
1763
+ Authorization: `Bearer ${SUPABASE_ANON_KEY}`,
1764
+ "Content-Profile": "becki",
1765
+ },
1766
+ body: JSON.stringify(body),
1767
+ });
1768
+ if (!res.ok) {
1769
+ const errBody = await res.text();
1770
+ throw new Error(`${rpcName} error ${res.status}: ${errBody.slice(0, 300)}`);
1771
+ }
1772
+ const rows = (await res.json());
1773
+ if (!rows || rows.length === 0) {
1774
+ throw new Error(`Row ${rowId} not found, not owned by the calling user, or RLS denied the update.`);
1775
+ }
1776
+ return {
1777
+ status: isSupersession ? "superseded" : "resolved",
1778
+ row_id: rows[0].id,
1779
+ superseded_by: supersededBy,
1780
+ note: note ?? undefined,
1781
+ };
1782
+ }
1783
+ function formatListResults(rows, projectId, sinceISO, untilISO, types) {
1784
+ if (rows.length === 0) {
1785
+ const scope = [];
1786
+ if (projectId)
1787
+ scope.push(`project=${projectId}`);
1788
+ if (sinceISO)
1789
+ scope.push(`since=${sinceISO}`);
1790
+ if (untilISO)
1791
+ scope.push(`until=${untilISO}`);
1792
+ if (types && types.length > 0)
1793
+ scope.push(`types=${types.join(",")}`);
1794
+ const scopeStr = scope.length > 0 ? ` (${scope.join(", ")})` : "";
1795
+ return `No vault rows found${scopeStr}. This is a definitive answer — the vault has nothing matching. Do not invent history.`;
1796
+ }
1797
+ const header = [`NeuraVault listing — ${rows.length} row${rows.length === 1 ? "" : "s"}`];
1798
+ if (projectId)
1799
+ header.push(` project: ${projectId}`);
1800
+ if (sinceISO)
1801
+ header.push(` since: ${sinceISO}`);
1802
+ if (untilISO)
1803
+ header.push(` until: ${untilISO}`);
1804
+ if (types && types.length > 0)
1805
+ header.push(` types: ${types.join(", ")}`);
1806
+ header.push("");
1807
+ const lines = [...header];
1808
+ for (let i = 0; i < rows.length; i++) {
1809
+ const r = rows[i];
1810
+ const date = r.created_at.slice(0, 10);
1811
+ const md = r.metadata;
1812
+ const projLabel = md?.project_name ? ` [${md.project_name}]` : "";
1813
+ lines.push(`[${i + 1}] ${date} · ${r.source_type}${projLabel}`);
1814
+ lines.push(r.content);
1815
+ lines.push("");
1816
+ }
1817
+ return lines.join("\n").trim();
1818
+ }
1819
+ async function lookupSymbols(query, projectId, limit = 20, timeoutMs = 1500) {
1820
+ const params = new URLSearchParams({ q: query, limit: String(limit) });
1821
+ if (projectId)
1822
+ params.set("project", projectId);
1823
+ const url = `${BECKI_IPC_URL}/symbols?${params.toString()}`;
1824
+ const controller = new AbortController();
1825
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1826
+ try {
1827
+ const res = await fetch(url, { signal: controller.signal });
1828
+ if (!res.ok)
1829
+ return [];
1830
+ const body = (await res.json());
1831
+ return Array.isArray(body) ? body : [];
1832
+ }
1833
+ catch {
1834
+ // Becki app not running / IPC port not listening — degrade silently.
1835
+ return [];
1836
+ }
1837
+ finally {
1838
+ clearTimeout(timer);
1839
+ }
1840
+ }
1841
+ /**
1842
+ * Answer a code query: try exact-symbol match via IPC first, then fall back
1843
+ * to code-scoped semantic search. Callers see one combined, formatted answer.
1844
+ */
1845
+ async function queryCodeContext(query, projectId) {
1846
+ // 1. Symbol-table exact / fuzzy match — fast, authoritative for
1847
+ // "where is validateToken" type queries. Symbol hits are
1848
+ // high-confidence by construction (exact/fuzzy name match from a
1849
+ // GRDB-indexed table), so when we get any, the confidence-warning
1850
+ // logic doesn't apply to them.
1851
+ const symbols = await lookupSymbols(query, projectId, 10);
1852
+ const lines = [];
1853
+ if (symbols.length > 0) {
1854
+ lines.push(`Symbol index hits (${symbols.length}):`, ...symbols.map((s) => ` ${s.filePath}:${s.lineStart}-${s.lineEnd} — ${s.symbolName} (${s.symbolKind})`), "");
1855
+ }
1856
+ // 2. Semantic search, code-only. queryVault already handles the
1857
+ // confidence warning + no-results wording, so just inline its
1858
+ // return value.
1859
+ try {
1860
+ const semantic = await queryVault(query, true);
1861
+ lines.push(semantic);
1862
+ }
1863
+ catch (err) {
1864
+ const message = err instanceof Error ? err.message : String(err);
1865
+ if (symbols.length === 0) {
1866
+ return `Code search failed: ${message}`;
1867
+ }
1868
+ lines.push(`(Semantic search unavailable: ${message})`);
1869
+ }
1870
+ return lines.join("\n").trim();
1871
+ }
1872
+ // ---------------------------------------------------------------------------
1873
+ // launch_neuravault — tell the Becki.app to open the WKWebView window.
1874
+ // The visualization itself is served from https://www.becki.io/viz, so
1875
+ // there's nothing to boot locally. If Becki.app isn't running we can't
1876
+ // open the window on the user's behalf — return a clear message.
1877
+ // ---------------------------------------------------------------------------
1878
+ async function isUp(url, timeoutMs = 1000) {
1879
+ const controller = new AbortController();
1880
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
1881
+ try {
1882
+ await fetch(url, { signal: controller.signal });
1883
+ return true;
1884
+ }
1885
+ catch {
1886
+ return false;
1887
+ }
1888
+ finally {
1889
+ clearTimeout(timer);
1890
+ }
1891
+ }
1892
+ async function notifyBeckiApp() {
1893
+ if (!(await isUp(`${BECKI_IPC_URL}/status`, 500))) {
1894
+ return { ok: false, detail: "Becki app IPC listener not running on :9876" };
1895
+ }
1896
+ try {
1897
+ const res = await fetch(`${BECKI_IPC_URL}/open-neuravault`, {
1898
+ method: "POST",
1899
+ });
1900
+ if (!res.ok)
1901
+ return { ok: false, detail: `IPC POST ${res.status}` };
1902
+ return { ok: true, detail: "window opened in Becki" };
1903
+ }
1904
+ catch (e) {
1905
+ return {
1906
+ ok: false,
1907
+ detail: e instanceof Error ? e.message : String(e),
1908
+ };
1909
+ }
1910
+ }
1911
+ const VAULT_ROOT = join(homedir(), "Documents", "Becki");
1912
+ function todayStr() {
1913
+ return new Date().toISOString().slice(0, 10);
1914
+ }
1915
+ function toSlug(s) {
1916
+ return s
1917
+ .toLowerCase()
1918
+ .replace(/[^a-z0-9]+/g, "-")
1919
+ .replace(/^-|-$/g, "")
1920
+ .slice(0, 60);
1921
+ }
1922
+ function ensureDir(p) {
1923
+ mkdirSync(p, { recursive: true });
1924
+ }
1925
+ /** A clean one-line headline for an open-loop / commitment checklist entry
1926
+ * in `_meta/open-loops.md`. Prefers the caller-supplied title (de-slugified
1927
+ * when it looks like a kebab-case slug); falls back to the first sentence of
1928
+ * the content. Dumping the whole content as the checklist line made the file
1929
+ * unreadable — Becki Shell surfaced paragraph fragments as thread names. */
1930
+ function openLoopHeadline(title, content) {
1931
+ const trunc = (s, n) => s.length > n ? s.slice(0, n - 1).trimEnd() + "…" : s;
1932
+ const t = (title || "").trim();
1933
+ if (t) {
1934
+ // "becki-mcp-use-claude" → "becki mcp use claude" — slugs read badly as
1935
+ // a checklist headline; spaces are kinder.
1936
+ const text = /^[a-z0-9]+(?:-[a-z0-9]+)+$/.test(t) ? t.replace(/-/g, " ") : t;
1937
+ return trunc(text.charAt(0).toUpperCase() + text.slice(1), 96);
1938
+ }
1939
+ const firstLine = content.trim().split("\n").find((l) => l.trim()) ?? "";
1940
+ const sentence = firstLine.split(/(?<=[.!?])\s/)[0] || firstLine;
1941
+ return trunc(sentence.trim(), 96);
1942
+ }
1943
+ function writeVaultFile(type, content, project, title) {
1944
+ const date = todayStr();
1945
+ const slug = title ? toSlug(title) : toSlug(content.slice(0, 40));
1946
+ const now = new Date().toLocaleTimeString("en-US", { hour12: false, hour: "2-digit", minute: "2-digit" });
1947
+ // Path-traversal guard: the `project` argument is user-controlled and
1948
+ // gets join()'d into the vault path. Slug it first so "../../etc"
1949
+ // becomes "etc" and can never escape the vault root.
1950
+ const projectSlug = project ? toSlug(project) : undefined;
1951
+ if (type === "decision") {
1952
+ const dir = join(VAULT_ROOT, "decisions");
1953
+ ensureDir(dir);
1954
+ const fileName = `${date}-${slug}.md`;
1955
+ const path = join(dir, fileName);
1956
+ const heading = title || slug;
1957
+ const body = [
1958
+ `# Decision: ${heading}`,
1959
+ `**Date:** ${date}`,
1960
+ `**Status:** Locked`,
1961
+ `**Source:** Claude.ai / Claude Code (MCP)`,
1962
+ "",
1963
+ "## What was decided",
1964
+ content,
1965
+ ].join("\n");
1966
+ writeFileSync(path, body, "utf8");
1967
+ if (projectSlug) {
1968
+ const projFile = join(VAULT_ROOT, "projects", projectSlug, "becki.md");
1969
+ if (existsSync(projFile)) {
1970
+ appendFileSync(projFile, `\n\n## Recent Decisions\n- ${date}: ${heading}\n`, "utf8");
1971
+ }
1972
+ }
1973
+ return path;
1974
+ }
1975
+ if (type === "commitment" || type === "open_loop") {
1976
+ const dir = join(VAULT_ROOT, "_meta");
1977
+ ensureDir(dir);
1978
+ const path = join(dir, "open-loops.md");
1979
+ // The checklist line is a clean title — matching the meeting-extracted
1980
+ // loops already in this file. The full content is kept as an indented
1981
+ // detail block so the local file stays a complete record; retrieval
1982
+ // uses the embedded cloud row regardless.
1983
+ const headline = openLoopHeadline(title, content);
1984
+ const tag = type === "commitment" ? "commitment" : "open loop";
1985
+ const projTag = projectSlug ? ` _(${projectSlug})_` : "";
1986
+ let line = `- [ ] ${headline}${projTag} — ${date} ${tag} via MCP\n`;
1987
+ const detail = content.trim();
1988
+ if (detail && detail !== headline) {
1989
+ line += detail.split("\n").map((l) => ` ${l}`).join("\n") + "\n";
1990
+ }
1991
+ appendFileSync(path, line, "utf8");
1992
+ return path;
1993
+ }
1994
+ if (type === "dead_end") {
1995
+ // Dead ends are as valuable as decisions — they prevent future agents
1996
+ // from repeating the same mistakes. Separate filename prefix so they
1997
+ // remain distinguishable from locked decisions at a glance.
1998
+ const dir = join(VAULT_ROOT, "decisions");
1999
+ ensureDir(dir);
2000
+ const fileName = `${date}-dead-end-${slug}.md`;
2001
+ const path = join(dir, fileName);
2002
+ const heading = title || slug;
2003
+ const body = [
2004
+ `# Dead End: ${heading}`,
2005
+ `**Date:** ${date}`,
2006
+ `**Type:** dead_end`,
2007
+ projectSlug ? `**Project:** ${projectSlug}` : undefined,
2008
+ `**Source:** Claude.ai / Claude Code (MCP)`,
2009
+ "",
2010
+ "## What was tried",
2011
+ content,
2012
+ "",
2013
+ "## Why it didn't work",
2014
+ "_(see above)_",
2015
+ "",
2016
+ "## What to do instead",
2017
+ "_(see above)_",
2018
+ ].filter(Boolean).join("\n");
2019
+ writeFileSync(path, body, "utf8");
2020
+ if (projectSlug) {
2021
+ const projFile = join(VAULT_ROOT, "projects", projectSlug, "becki.md");
2022
+ if (existsSync(projFile)) {
2023
+ appendFileSync(projFile, `\n\n## Dead Ends\n- ${date}: ${heading}\n`, "utf8");
2024
+ }
2025
+ }
2026
+ return path;
2027
+ }
2028
+ // note
2029
+ const dir = join(VAULT_ROOT, "activity");
2030
+ ensureDir(dir);
2031
+ const path = join(dir, `${date}-notes.md`);
2032
+ const entry = `\n## ${date} ${now} Note\n${content}\n`;
2033
+ appendFileSync(path, entry, "utf8");
2034
+ if (projectSlug) {
2035
+ const projFile = join(VAULT_ROOT, "projects", projectSlug, "becki.md");
2036
+ if (existsSync(projFile)) {
2037
+ appendFileSync(projFile, `\n\n## Notes\n- ${date} ${now}: ${content.slice(0, 120)}\n`, "utf8");
2038
+ }
2039
+ }
2040
+ return path;
2041
+ }
2042
+ function buildEmbedInput(type, content, project, title) {
2043
+ const parts = [];
2044
+ const label = type.replace("_", " ");
2045
+ const header = [
2046
+ `${label.charAt(0).toUpperCase() + label.slice(1)}`,
2047
+ title ? `— ${title}` : "",
2048
+ project ? `(${project})` : "",
2049
+ `${todayStr()}`,
2050
+ ].filter(Boolean).join(" ");
2051
+ parts.push(header + ":");
2052
+ parts.push(content);
2053
+ return parts.join("\n").slice(0, 4000);
2054
+ }
2055
+ /**
2056
+ * Store an embedding by asking the running Becki.app to do it.
2057
+ *
2058
+ * The Mac app owns the authenticated Supabase session — the MCP binary
2059
+ * never touches the service-role key or the user's JWT. This keeps all
2060
+ * writes RLS-compliant and audit-trail-correct. If the app isn't running,
2061
+ * embedding is skipped (and the caller surfaces that to the user).
2062
+ */
2063
+ async function storeEmbedding(type, content, project, title) {
2064
+ const embedInput = buildEmbedInput(type, content, project, title);
2065
+ try {
2066
+ const res = await fetch(`${BECKI_IPC_URL}/ingest`, {
2067
+ method: "POST",
2068
+ headers: { "Content-Type": "application/json" },
2069
+ body: JSON.stringify({
2070
+ sourceType: type,
2071
+ content: embedInput,
2072
+ sourceId: randomUUID(),
2073
+ project,
2074
+ title,
2075
+ }),
2076
+ });
2077
+ if (!res.ok) {
2078
+ const body = await res.text();
2079
+ return { ok: false, error: `Becki app ingest ${res.status}: ${body.slice(0, 200)}` };
2080
+ }
2081
+ return { ok: true };
2082
+ }
2083
+ catch (e) {
2084
+ return {
2085
+ ok: false,
2086
+ error: e instanceof Error ? e.message : String(e),
2087
+ };
2088
+ }
2089
+ }
2090
+ async function ingestToVault(type, content, project, title) {
2091
+ // v0.10.0: remote MCP HTTP transport. No local filesystem, no install
2092
+ // token, no Becki.app IPC — ingest goes straight to the ingest_vault_row
2093
+ // RPC scoped to the OAuth-resolved user. The 3-step local fallback chain
2094
+ // below is the stdio transport's path only.
2095
+ if (IS_HTTP_TRANSPORT) {
2096
+ const r = await httpIngest(type, content, project, title);
2097
+ return {
2098
+ status: r.ok ? "written" : "failed",
2099
+ file: "(remote MCP — no local file)",
2100
+ embedded: r.ok,
2101
+ message: r.ok
2102
+ ? `${type} written to NeuraVault (remote ingest, vault_id=${r.vault_id ?? "?"})`
2103
+ : `${type} ingest failed: ${r.error}`,
2104
+ };
2105
+ }
2106
+ // Step 1: write the local audit file. Becki.app's NeuraVault watcher will
2107
+ // ALSO pick this up if the cloud POST fails — defense in depth.
2108
+ // Note: under a sandboxed host (e.g. Codex sandboxes MCP children) the
2109
+ // file write can fail. That's non-fatal because the cloud path doesn't
2110
+ // depend on the local file existing.
2111
+ let filePath = "(not written — MCP sandbox or permission error)";
2112
+ let fileWriteError;
2113
+ try {
2114
+ filePath = writeVaultFile(type, content, project, title);
2115
+ }
2116
+ catch (err) {
2117
+ fileWriteError = err instanceof Error ? err.message : String(err);
2118
+ }
2119
+ // Step 2: try the v0.7.0 server-side ingest path FIRST. If we have a
2120
+ // valid install token, this runs independently of Becki.app — works even
2121
+ // when she's closed/crashed/quit. Replaces the old "Becki must be running
2122
+ // for embed" failure mode.
2123
+ const cloud = await cloudIngest(type, content, project, title);
2124
+ if (cloud.ok) {
2125
+ return {
2126
+ status: fileWriteError ? "embedded-no-local-file" : "written",
2127
+ file: filePath,
2128
+ embedded: true,
2129
+ message: fileWriteError
2130
+ ? `${type} embedded into NeuraVault via server-side ingest (local .md file was not written: ${fileWriteError}). Vault row is authoritative.`
2131
+ : `${type} written to NeuraVault and indexed (server-side ingest, vault_id=${cloud.vault_id ?? "?"})`,
2132
+ };
2133
+ }
2134
+ // Step 3: cloud path failed (no token, network down, server error, or
2135
+ // user hasn't upgraded Becki.app to v0.7.0+ yet). Fall back to the
2136
+ // legacy IPC + file-watch path. Backward compatible.
2137
+ const cloudReason = cloud.error === "no_token"
2138
+ ? "no install token registered (Becki.app may need a sign-in to register one)"
2139
+ : `${cloud.status ?? "?"} ${cloud.error ?? ""}`.trim();
2140
+ const appRunning = await isUp(`${BECKI_IPC_URL}/status`, 500);
2141
+ if (!appRunning) {
2142
+ const detail = fileWriteError
2143
+ ? `Neither the cloud ingest, the file write, nor the IPC embed could complete. Cloud: ${cloudReason}. File: ${fileWriteError}. Becki.app: not running. Launch Becki and re-try.`
2144
+ : `${type} written to disk (not indexed). Cloud ingest unavailable (${cloudReason}) and Becki.app isn't running. Launch Becki and the next backfill will pick this up.`;
2145
+ return {
2146
+ status: fileWriteError ? "failed" : "written",
2147
+ file: filePath,
2148
+ embedded: false,
2149
+ message: detail,
2150
+ };
2151
+ }
2152
+ try {
2153
+ const result = await storeEmbedding(type, content, project, title);
2154
+ const status = fileWriteError ? "embedded-no-local-file" : "written";
2155
+ let baseMessage;
2156
+ if (result.ok) {
2157
+ baseMessage = fileWriteError
2158
+ ? `${type} embedded into NeuraVault via IPC fallback (local .md file was not written because the MCP process couldn't access ~/Documents/Becki — likely a sandbox restriction. The Supabase vault entry is authoritative.)`
2159
+ : `${type} written to NeuraVault and indexed (IPC fallback — cloud ingest unavailable: ${cloudReason})`;
2160
+ }
2161
+ else {
2162
+ baseMessage = fileWriteError
2163
+ ? `${type} write failed both locally (${fileWriteError}) AND to NeuraVault (${result.error})`
2164
+ : `${type} written to NeuraVault (cloud ingest unavailable: ${cloudReason}; IPC embedding also failed: ${result.error})`;
2165
+ }
2166
+ return {
2167
+ status,
2168
+ file: filePath,
2169
+ embedded: result.ok,
2170
+ message: baseMessage,
2171
+ };
2172
+ }
2173
+ catch (err) {
2174
+ const errMsg = err instanceof Error ? err.message : String(err);
2175
+ return {
2176
+ status: fileWriteError ? "failed" : "written",
2177
+ file: filePath,
2178
+ embedded: false,
2179
+ message: fileWriteError
2180
+ ? `${type} write failed both locally (${fileWriteError}) AND via IPC (${errMsg})`
2181
+ : `${type} written to NeuraVault (cloud ingest unavailable: ${cloudReason}; IPC embedding error: ${errMsg})`,
2182
+ };
2183
+ }
2184
+ }
2185
+ // ---------------------------------------------------------------------------
2186
+ // becki_register_context_source — persistent registry of AI-declared MCPs
2187
+ // ---------------------------------------------------------------------------
2188
+ //
2189
+ // Flow:
2190
+ // 1. User types `/becki get-mcps` in their AI client.
2191
+ // 2. Client calls `becki_get_registration_prompt` → receives the standard
2192
+ // instruction string returned by `buildRegistrationPrompt()`.
2193
+ // 3. Client follows the steps, calling `becki_register_context_source`
2194
+ // once per (server × category).
2195
+ // 4. Becki macOS app reads `mcp-registry.json` at backfill time to honor
2196
+ // AI-declared list/fetch tool names, bypassing hardcoded profile
2197
+ // detection for that server.
2198
+ //
2199
+ // Registry lives under the resolved Becki home directory so it is shared
2200
+ // across every Becki process on this machine. On macOS with Becki.app
2201
+ // installed, this is `~/Library/Application Support/Becki/` (the Swift
2202
+ // reader points at the same path). On Core / non-Mac, it's `~/.becki/`.
2203
+ // An append-only JSONL log alongside it keeps an auditable history of
2204
+ // every registration call.
2205
+ const REGISTRY_DIR = APP_SUPPORT_DIR;
2206
+ const REGISTRY_PATH = join(REGISTRY_DIR, "mcp-registry.json");
2207
+ const REGISTRY_LOG_PATH = join(REGISTRY_DIR, "mcp-registry-log.jsonl");
2208
+ const REGISTRY_SCHEMA_VERSION = 1;
2209
+ function loadRegistry() {
2210
+ if (!existsSync(REGISTRY_PATH)) {
2211
+ return {
2212
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
2213
+ updatedAt: new Date().toISOString(),
2214
+ sources: [],
2215
+ };
2216
+ }
2217
+ try {
2218
+ const raw = readFileSync(REGISTRY_PATH, "utf8");
2219
+ const parsed = JSON.parse(raw);
2220
+ return {
2221
+ schemaVersion: parsed.schemaVersion ?? REGISTRY_SCHEMA_VERSION,
2222
+ updatedAt: parsed.updatedAt ?? new Date().toISOString(),
2223
+ sources: Array.isArray(parsed.sources) ? parsed.sources : [],
2224
+ };
2225
+ }
2226
+ catch (err) {
2227
+ // Corrupt file — do not overwrite silently. Rename so the user can
2228
+ // inspect, then start fresh. This path only fires after a manual edit
2229
+ // gone wrong or a disk error.
2230
+ const backup = `${REGISTRY_PATH}.corrupt.${Date.now()}`;
2231
+ try {
2232
+ writeFileSync(backup, readFileSync(REGISTRY_PATH, "utf8"), "utf8");
2233
+ }
2234
+ catch {
2235
+ /* best effort */
2236
+ }
2237
+ return {
2238
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
2239
+ updatedAt: new Date().toISOString(),
2240
+ sources: [],
2241
+ };
2242
+ }
2243
+ }
2244
+ function saveRegistry(reg) {
2245
+ ensureDir(REGISTRY_DIR);
2246
+ const body = JSON.stringify(reg, null, 2) + "\n";
2247
+ writeFileSync(REGISTRY_PATH, body, "utf8");
2248
+ }
2249
+ function appendRegistryLog(action, serverName, detail) {
2250
+ ensureDir(REGISTRY_DIR);
2251
+ const entry = JSON.stringify({
2252
+ ts: new Date().toISOString(),
2253
+ action,
2254
+ serverName,
2255
+ detail: detail ?? null,
2256
+ });
2257
+ try {
2258
+ appendFileSync(REGISTRY_LOG_PATH, entry + "\n", "utf8");
2259
+ }
2260
+ catch {
2261
+ // Logging must never fail the registration itself.
2262
+ }
2263
+ }
2264
+ function normalizeRegistrationInput(raw) {
2265
+ if (!raw || typeof raw !== "object") {
2266
+ throw new Error("Arguments must be an object");
2267
+ }
2268
+ const obj = raw;
2269
+ const serverName = typeof obj.serverName === "string" ? obj.serverName.trim() : "";
2270
+ if (!serverName)
2271
+ throw new Error('Missing required field: "serverName"');
2272
+ if (serverName.toLowerCase() === "becki") {
2273
+ throw new Error("Refusing to register 'becki' as a context source — Becki doesn't ingest from itself.");
2274
+ }
2275
+ const transportRaw = typeof obj.transport === "string" ? obj.transport : "";
2276
+ if (transportRaw !== "stdio" && transportRaw !== "http-sse") {
2277
+ throw new Error('Field "transport" must be "stdio" or "http-sse"');
2278
+ }
2279
+ const categoriesRaw = obj.categories;
2280
+ if (!Array.isArray(categoriesRaw) || categoriesRaw.length === 0) {
2281
+ throw new Error('Field "categories" must be a non-empty array');
2282
+ }
2283
+ const validCats = [
2284
+ "documents",
2285
+ "tickets",
2286
+ "messages",
2287
+ "emails",
2288
+ "code",
2289
+ "other",
2290
+ ];
2291
+ const categories = categoriesRaw.map((entry, idx) => {
2292
+ if (!entry || typeof entry !== "object") {
2293
+ throw new Error(`categories[${idx}] must be an object`);
2294
+ }
2295
+ const c = entry;
2296
+ const category = typeof c.category === "string" ? c.category : null;
2297
+ if (!category || !validCats.includes(category)) {
2298
+ throw new Error(`categories[${idx}].category must be one of: ${validCats.join(", ")}`);
2299
+ }
2300
+ const listTool = typeof c.listTool === "string" ? c.listTool.trim() : "";
2301
+ if (!listTool) {
2302
+ throw new Error(`categories[${idx}].listTool is required`);
2303
+ }
2304
+ const fetchTool = typeof c.fetchTool === "string" && c.fetchTool.trim() ? c.fetchTool.trim() : undefined;
2305
+ const sampleArgs = c.sampleArgs && typeof c.sampleArgs === "object" && !Array.isArray(c.sampleArgs)
2306
+ ? c.sampleArgs
2307
+ : undefined;
2308
+ const scopeNotes = typeof c.scopeNotes === "string" && c.scopeNotes.trim()
2309
+ ? c.scopeNotes.trim()
2310
+ : undefined;
2311
+ return { category, listTool, fetchTool, sampleArgs, scopeNotes };
2312
+ });
2313
+ return {
2314
+ serverName,
2315
+ transport: transportRaw,
2316
+ categories,
2317
+ authNotes: typeof obj.authNotes === "string" ? obj.authNotes.trim() || undefined : undefined,
2318
+ enterpriseManaged: typeof obj.enterpriseManaged === "boolean" ? obj.enterpriseManaged : undefined,
2319
+ notes: typeof obj.notes === "string" ? obj.notes.trim() || undefined : undefined,
2320
+ };
2321
+ }
2322
+ function detectRegisteredBy() {
2323
+ // MCP SDK populates clientInfo during the initialize handshake. We read it
2324
+ // off the server instance when handling a tool call — see
2325
+ // `handleRegisterContextSource`.
2326
+ // This function is only called if the server lookup returned nothing.
2327
+ return "unknown";
2328
+ }
2329
+ function upsertSource(reg, input, registeredBy) {
2330
+ const now = new Date().toISOString();
2331
+ const idx = reg.sources.findIndex((s) => s.serverName.toLowerCase() === input.serverName.toLowerCase());
2332
+ const next = {
2333
+ serverName: input.serverName,
2334
+ transport: input.transport,
2335
+ authNotes: input.authNotes,
2336
+ enterpriseManaged: input.enterpriseManaged,
2337
+ notes: input.notes,
2338
+ registeredAt: now,
2339
+ registeredBy,
2340
+ categories: input.categories,
2341
+ };
2342
+ let action;
2343
+ let sources;
2344
+ if (idx >= 0) {
2345
+ // Preserve original registeredAt if present — treat this as an update.
2346
+ const prev = reg.sources[idx];
2347
+ next.registeredAt = prev.registeredAt || now;
2348
+ sources = [...reg.sources];
2349
+ sources[idx] = next;
2350
+ action = "updated";
2351
+ }
2352
+ else {
2353
+ sources = [...reg.sources, next];
2354
+ action = "added";
2355
+ }
2356
+ return {
2357
+ reg: {
2358
+ schemaVersion: REGISTRY_SCHEMA_VERSION,
2359
+ updatedAt: now,
2360
+ sources,
2361
+ },
2362
+ action,
2363
+ };
2364
+ }
2365
+ const REGISTRATION_PROMPT = [
2366
+ "Your task: enumerate the MCP tools currently available to you in this session and register each non-Becki MCP server as a context source for Becki.",
2367
+ "",
2368
+ "Steps:",
2369
+ "1. List every MCP tool available in your current toolchain. Group them by server (the prefix before the first underscore or double-colon, depending on your client — e.g. `google-workspace__search_drive_files` → server = \"google-workspace\"; `slack_list_channels` → server = \"slack\").",
2370
+ "2. For each server that is NOT \"becki\" itself:",
2371
+ " a. Identify content categories it exposes. Look at tool names + descriptions. Map to: documents / tickets / messages / emails / code / other.",
2372
+ " b. For each category, pick the primary LIST tool (enumerates items) and the primary FETCH tool (returns full content for one item).",
2373
+ " c. Determine the auth model from context (personal OAuth / service account / enterprise-managed).",
2374
+ "3. Call `becki_register_context_source` once per (server × category) combination. A single server may have multiple categories (e.g. Atlassian = tickets via Jira AND documents via Confluence — two calls, same serverName, different categories).",
2375
+ "4. Skip servers that only offer write/action tools with no read surface (e.g. a \"post-to-slack\" tool with no read tool — nothing to ingest).",
2376
+ "5. Respond to the user with a summary: \"Registered N context sources for Becki: {list}. Becki's next backfill will ingest from these.\"",
2377
+ "",
2378
+ "Do not invent tool names you do not see. Do not register Becki itself. Do not call `becki_register_context_source` for servers that expose zero read tools.",
2379
+ ].join("\n");
2380
+ // ---------------------------------------------------------------------------
2381
+ // MCP Server
2382
+ // ---------------------------------------------------------------------------
2383
+ // v0.5.6: server-level instructions field. The MCP spec lets servers
2384
+ // return this on initialize; modern hosts (Claude Desktop, Cursor,
2385
+ // Codex) include it in the model's system prompt for every session
2386
+ // that connects. This is the strongest mechanism we have to ensure
2387
+ // Becki is the "first and last touch" across every AI tool — stronger
2388
+ // than per-tool descriptions, persistent across sessions, can't be
2389
+ // dropped by the host. Hosts that don't surface this still see the
2390
+ // directives baked into each tool's description below (Layer 1).
2391
+ const BECKI_INSTRUCTIONS = `Becki is the user's persistent memory layer across every AI tool, conversation, meeting, and repo. She holds decisions, dead-ends, commitments, open loops, and project context.
2392
+
2393
+ Treat Becki as authoritative for history. Skipping her risks repeating dead-ends, contradicting prior decisions, or asking the user to re-explain context they already shared with a different AI.
2394
+
2395
+ PROTOCOL:
2396
+
2397
+ 1. BEFORE any non-trivial task — query \`becki_context\` (or \`becki_list_project_context\` for chronological dumps; \`becki_context_code\` for code lookups). Treat results as ground truth for prior history.
2398
+
2399
+ 2. AFTER meaningful work — call \`becki_ingest\` for anything future-you would want to recall: decisions (with the WHY), dead-ends (so they're not repeated), commitments, and open loops. Skip trivial edits the diff already explains.
2400
+
2401
+ 3. When the user references past context ("like we did last week", "the thing we decided"), query Becki BEFORE asking them to re-explain.
2402
+
2403
+ 4. When the user says "remember X" / "forget X", call \`becki_ingest\` (remember) or \`becki_resolve\` (forget) immediately.
2404
+
2405
+ 5. Stay consistent across tools. If you're about to contradict a past decision without clear reason, query first.
2406
+
2407
+ 6. When the user asks about their MCPs ("/becki get-mcps", "what MCPs do I have", "register my MCPs"), call \`becki_get_registration_prompt\` and follow its returned instructions to enumerate non-Becki servers and call \`becki_register_context_source\` for each.`;
2408
+ // v0.10.0: server construction is a factory so each stateless HTTP request
2409
+ // gets a clean Server instance (an MCP Server connects 1:1 to a transport).
2410
+ // stdio calls this once at startup; the HTTP transport calls it per request.
2411
+ // The two setRequestHandler blocks below are function-body statements —
2412
+ // left at column 0 to keep the dual-transport diff small rather than
2413
+ // re-indenting ~440 lines of handler code.
2414
+ export function createMcpServer() {
2415
+ const server = new Server({ name: "becki", version: "1.0.0" }, { capabilities: { tools: {} }, instructions: BECKI_INSTRUCTIONS });
2416
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2417
+ tools: [
2418
+ {
2419
+ name: "becki_context",
2420
+ description: "ALWAYS call this BEFORE answering any non-trivial question, writing code, or making decisions. Becki holds the user's authoritative history — prior decisions, dead-ends, commitments, and project context across every conversation, meeting, and repo. Skipping this risks repeating dead-ends, contradicting prior decisions, or asking the user to re-explain context they've already shared with another AI. Treat results as ground truth for history. If a high-confidence chunk contradicts your plan, defer to the chunk and surface the conflict to the user.",
2421
+ inputSchema: {
2422
+ type: "object",
2423
+ properties: {
2424
+ query: {
2425
+ type: "string",
2426
+ description: 'Natural language query, e.g. "what are my open commitments for the becki project" or "what do I need to ship this week"',
2427
+ },
2428
+ },
2429
+ required: ["query"],
2430
+ },
2431
+ },
2432
+ {
2433
+ name: "launch_neuravault",
2434
+ description: "Open the NeuraVault visualization — a live 3D brain graph of Becki's professional memory. Shows nodes for meetings, commitments, decisions, projects and people connected by semantic relationships. Opens inside the Becki Mac app as a native window.",
2435
+ inputSchema: {
2436
+ type: "object",
2437
+ properties: {},
2438
+ required: [],
2439
+ },
2440
+ },
2441
+ {
2442
+ name: "becki_context_code",
2443
+ description: "Call BEFORE writing or modifying code, designing architecture, or referencing a function/class/file. Searches Becki's indexed source code (NeuraVault code embeddings + symbol index) to answer 'where is X function', 'what does this file do', 'find the Y class'. Returns exact file:line matches when the query looks like a symbol, plus semantic matches over content. Skipping this risks duplicating existing symbols, contradicting established patterns, or proposing changes that conflict with code you haven't read.",
2444
+ inputSchema: {
2445
+ type: "object",
2446
+ properties: {
2447
+ query: {
2448
+ type: "string",
2449
+ description: 'Symbol name or natural-language description of the code. e.g. "validateToken", "auth middleware", "AuthService.login"',
2450
+ },
2451
+ project: {
2452
+ type: "string",
2453
+ description: "(Optional) Project id (GRDB id, not name) to scope the symbol lookup. Omit to search across all indexed projects.",
2454
+ },
2455
+ },
2456
+ required: ["query"],
2457
+ },
2458
+ },
2459
+ {
2460
+ name: "becki_get_registration_prompt",
2461
+ description: "Return the standard prompt the AI agent should follow to register non-Becki MCP servers with Becki as context sources. When the user types '/becki get-mcps' or asks Becki to discover their MCPs, call this tool and follow the returned instructions.",
2462
+ inputSchema: {
2463
+ type: "object",
2464
+ properties: {},
2465
+ required: [],
2466
+ },
2467
+ },
2468
+ {
2469
+ name: "becki_register_context_source",
2470
+ description: "Register another MCP server as a context source for Becki. Becki's backfill will use the server's tools to ingest documents/tickets/messages into NeuraVault. Only call for MCP servers OTHER than becki itself. Gather: server name (as it appears in the user's MCP config), transport (stdio/http-sse), content categories (documents/tickets/messages/emails/code), a sample tool for each category, any auth or scoping notes, and whether the server is considered enterprise-managed (e.g. ToolHive-provisioned).",
2471
+ inputSchema: {
2472
+ type: "object",
2473
+ properties: {
2474
+ serverName: {
2475
+ type: "string",
2476
+ description: "Name as it appears in the client's MCP config (e.g. 'google-workspace', 'indeed-jira')",
2477
+ },
2478
+ transport: {
2479
+ type: "string",
2480
+ enum: ["stdio", "http-sse"],
2481
+ description: "How the server is reached",
2482
+ },
2483
+ categories: {
2484
+ type: "array",
2485
+ items: {
2486
+ type: "object",
2487
+ properties: {
2488
+ category: {
2489
+ type: "string",
2490
+ enum: ["documents", "tickets", "messages", "emails", "code", "other"],
2491
+ },
2492
+ listTool: {
2493
+ type: "string",
2494
+ description: "Tool name that enumerates items in this category",
2495
+ },
2496
+ fetchTool: {
2497
+ type: "string",
2498
+ description: "Tool name that fetches full content for an item",
2499
+ },
2500
+ sampleArgs: {
2501
+ type: "object",
2502
+ description: "Sample arguments for the list tool — e.g. { limit: 50 } for search-style tools",
2503
+ },
2504
+ scopeNotes: {
2505
+ type: "string",
2506
+ description: "How to scope results to the user's own items if applicable — e.g. 'filter by author = current user'",
2507
+ },
2508
+ },
2509
+ required: ["category", "listTool"],
2510
+ },
2511
+ },
2512
+ authNotes: {
2513
+ type: "string",
2514
+ description: "How the server authenticates (e.g. 'user's personal Google OAuth via Internet Accounts', 'company service account via ToolHive')",
2515
+ },
2516
+ enterpriseManaged: {
2517
+ type: "boolean",
2518
+ description: "True if this server is provisioned by the user's IT department (ToolHive, corporate MCP gateway)",
2519
+ },
2520
+ notes: {
2521
+ type: "string",
2522
+ description: "Anything else Becki should know — rate limits, gotchas, naming quirks",
2523
+ },
2524
+ },
2525
+ required: ["serverName", "transport", "categories"],
2526
+ },
2527
+ },
2528
+ {
2529
+ name: "becki_list_project_context",
2530
+ description: "Call this instead of becki_context when the user wants a chronological list rather than a similarity match — questions like 'what's happening in project X', 'what decisions have we made this week', 'recent commitments', or 'recent dead ends'. Returns rows ordered by created_at DESC. Unlike becki_context, this is DEFINITIVE: if it returns nothing, nothing is there for the given filters. Always prefer this over assumptions when the user asks for 'recent' or 'all' anything.",
2531
+ inputSchema: {
2532
+ type: "object",
2533
+ properties: {
2534
+ project: {
2535
+ type: "string",
2536
+ description: "(Optional) Project id (GRDB id or project name slug) to scope the listing. Omit to list across all projects.",
2537
+ },
2538
+ since: {
2539
+ type: "string",
2540
+ description: "(Optional) ISO 8601 timestamp or date (e.g. '2026-04-17' or '2026-04-17T00:00:00Z'). Returns rows created at or after this time. Omit for no lower bound.",
2541
+ },
2542
+ until: {
2543
+ type: "string",
2544
+ description: "(Optional, v0.7.5+) ISO 8601 timestamp or date upper bound. Returns rows created STRICTLY BEFORE this time (half-open interval [since, until)). Combine with `since` for explicit time-window queries like 'what happened between Apr 15 and Apr 22 on project X'. Omit for no upper bound.",
2545
+ },
2546
+ types: {
2547
+ type: "array",
2548
+ items: {
2549
+ type: "string",
2550
+ enum: ["decision", "commitment", "note", "open_loop", "dead_end", "activity", "project", "meeting", "code"],
2551
+ },
2552
+ description: "(Optional) Filter to specific source_type values. Omit for all types.",
2553
+ },
2554
+ limit: {
2555
+ type: "number",
2556
+ description: "(Optional) Maximum rows to return. Defaults to 50, capped at 200.",
2557
+ },
2558
+ },
2559
+ required: [],
2560
+ },
2561
+ },
2562
+ {
2563
+ name: "becki_ingest",
2564
+ description: "Call this AFTER any meaningful work to persist context for future-you and every other AI tool the user works with. Captures decisions (with the WHY), dead-ends tried and rejected (so they're not repeated), commitments, open loops, and notes. Entries are immediately available to every AI agent via becki_context. Skip trivial edits the diff already explains; do ingest anything the user said 'remember this' for, or anything you'd otherwise lose between sessions.",
2565
+ inputSchema: {
2566
+ type: "object",
2567
+ properties: {
2568
+ type: {
2569
+ type: "string",
2570
+ enum: ["decision", "commitment", "note", "open_loop", "dead_end"],
2571
+ description: "The type of entry to store. Use 'dead_end' for approaches tried and rejected — include what was tried, why it failed, and what replaced it in the content.",
2572
+ },
2573
+ content: {
2574
+ type: "string",
2575
+ description: "The full text to store in NeuraVault",
2576
+ },
2577
+ project: {
2578
+ type: "string",
2579
+ description: "(Optional) Project name — appends to that project's becki.md if present",
2580
+ },
2581
+ title: {
2582
+ type: "string",
2583
+ description: "(Optional) Short title/slug used for the decision filename",
2584
+ },
2585
+ },
2586
+ required: ["type", "content"],
2587
+ },
2588
+ },
2589
+ {
2590
+ name: "becki_resolve",
2591
+ description: "Mark a previously-recorded vault entry as resolved (commitment fulfilled, open loop closed, dead-end no longer relevant) OR mark it as superseded by a newer entry. After this call, the row is down-ranked in future becki_context retrieval so live items dominate the result set, but it remains queryable for history. Use when you complete a task captured by becki_ingest, when a newer decision replaces an older one, or when you discover a row is stale.",
2592
+ inputSchema: {
2593
+ type: "object",
2594
+ properties: {
2595
+ row_id: {
2596
+ type: "string",
2597
+ description: "UUID of the vault row to resolve. Get it from a previous becki_context call — each chunk header includes a 'row_id: <uuid>' line directly under the diamond/header. Pass that UUID here verbatim.",
2598
+ },
2599
+ superseded_by: {
2600
+ type: "string",
2601
+ description: "(Optional) UUID of a newer vault row that replaces this one — turns the resolution into a supersession link. Use when an older commitment/decision is replaced by a newer one rather than simply 'done.'",
2602
+ },
2603
+ note: {
2604
+ type: "string",
2605
+ description: "(Optional) One-line reason for resolution (e.g., 'shipped in v0.4.5', 'replaced by Cloudflare DNS approach', 'no longer relevant').",
2606
+ },
2607
+ },
2608
+ required: ["row_id"],
2609
+ },
2610
+ },
2611
+ {
2612
+ name: "becki_check_dead_ends",
2613
+ description: "Pre-prompt gate. Call this BEFORE a substantive user request to check whether the user is about to retry an approach they (or their team) previously tried and rejected. Returns the top dead-end matches with raw similarity scores — no Haiku synthesis, no narrative — so it's fast (~200-400ms typical) and safe to fire on every prompt. The caller decides the warn threshold: similarity ≥ 0.85 → strong match (worth interrupting the user with a 'You tried this 3 months ago and reverted it' banner); 0.7–0.85 → weak signal (include as a footnote in the assistant's context, don't interrupt); < 0.7 → ignore. Skip on prompts under ~10 chars or pure shell commands ('ls', 'git status'); only fires on substantive intent.",
2614
+ inputSchema: {
2615
+ type: "object",
2616
+ properties: {
2617
+ prompt: {
2618
+ type: "string",
2619
+ description: "The user's prompt text or approach description. Best results when the caller passes the FULL prompt verbatim (not a summary), since semantic matching against dead-end content needs intent + specifics.",
2620
+ },
2621
+ min_similarity: {
2622
+ type: "number",
2623
+ description: "(Optional) Lower bound on similarity to include in the returned matches. Defaults to 0.7. Use 0.85 if you only want strong matches.",
2624
+ minimum: 0,
2625
+ maximum: 1,
2626
+ },
2627
+ limit: {
2628
+ type: "integer",
2629
+ description: "(Optional) Maximum number of matches to return, sorted by similarity descending. Defaults to 3, capped at 5.",
2630
+ minimum: 1,
2631
+ maximum: 5,
2632
+ },
2633
+ },
2634
+ required: ["prompt"],
2635
+ },
2636
+ },
2637
+ {
2638
+ name: "becki_meeting_transcript",
2639
+ description: "Fetch the VERBATIM transcript for a specific meeting from the user's Becki vault. Use this when the user wants exact quotes, the precise phrasing someone used, or you need to verify what was actually said — becki_context returns the AI-summarized version, this returns the actual words. Filters let you narrow to a keyword, speaker, or time window inside the meeting. Requires Becki.app to be running on the user's Mac — transcripts are device-local by design and never sync to the cloud, so the hosted/remote MCP mode cannot serve them and will return a clear error.",
2640
+ inputSchema: {
2641
+ type: "object",
2642
+ properties: {
2643
+ meeting_id: {
2644
+ type: "string",
2645
+ description: "UUID of the meeting row. Get this from a becki_context or becki_list_project_context call that surfaced the meeting — each chunk header includes the source_id, which for meetings is the meeting UUID.",
2646
+ },
2647
+ query: {
2648
+ type: "string",
2649
+ description: "Optional case-insensitive substring filter on segment text. Use when the user is asking about a specific topic inside the meeting (e.g. 'sparkle token', 'pricing'). Omit for full transcript.",
2650
+ },
2651
+ speaker: {
2652
+ type: "string",
2653
+ description: "Optional case-insensitive substring filter on the resolved speaker name. Matches partials — 'bryan' matches 'Bryan Santos'.",
2654
+ },
2655
+ since: {
2656
+ type: "number",
2657
+ description: "Start of time window in seconds from meeting start. Use with `until` to scope to a section.",
2658
+ },
2659
+ until: {
2660
+ type: "number",
2661
+ description: "End of time window in seconds from meeting start.",
2662
+ },
2663
+ limit: {
2664
+ type: "integer",
2665
+ minimum: 1,
2666
+ maximum: 5000,
2667
+ default: 500,
2668
+ description: "Cap on returned segments. Default 500; long meetings might exceed this — narrow with `query`/`speaker` if you hit the cap.",
2669
+ },
2670
+ },
2671
+ required: ["meeting_id"],
2672
+ },
2673
+ },
2674
+ ],
2675
+ }));
2676
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2677
+ const tool = request.params.name;
2678
+ if (tool === "becki_context") {
2679
+ const { query } = request.params.arguments;
2680
+ if (!query || typeof query !== "string") {
2681
+ throw new Error('Missing required argument: "query"');
2682
+ }
2683
+ try {
2684
+ const context = await queryVault(query);
2685
+ return { content: [{ type: "text", text: context }] };
2686
+ }
2687
+ catch (err) {
2688
+ const message = err instanceof Error ? err.message : String(err);
2689
+ return {
2690
+ content: [
2691
+ { type: "text", text: `NeuraVault query failed: ${message}` },
2692
+ ],
2693
+ isError: true,
2694
+ };
2695
+ }
2696
+ }
2697
+ if (tool === "launch_neuravault") {
2698
+ const ipc = await notifyBeckiApp();
2699
+ const status = ipc.ok ? "opened" : "app-not-running";
2700
+ const message = ipc.ok
2701
+ ? "NeuraVault opened in Becki."
2702
+ : `Becki could not open the NeuraVault window: ${ipc.detail}. Make sure the Becki.app is running, then try again — or open it from the Becki menu bar → NeuraVault.`;
2703
+ return {
2704
+ content: [
2705
+ {
2706
+ type: "text",
2707
+ text: JSON.stringify({ status, message }, null, 2),
2708
+ },
2709
+ ],
2710
+ };
2711
+ }
2712
+ if (tool === "becki_context_code") {
2713
+ const { query, project } = request.params.arguments;
2714
+ if (!query || typeof query !== "string") {
2715
+ throw new Error('Missing required argument: "query"');
2716
+ }
2717
+ try {
2718
+ const context = await queryCodeContext(query, project);
2719
+ return { content: [{ type: "text", text: context }] };
2720
+ }
2721
+ catch (err) {
2722
+ const message = err instanceof Error ? err.message : String(err);
2723
+ return {
2724
+ content: [
2725
+ { type: "text", text: `Code context query failed: ${message}` },
2726
+ ],
2727
+ isError: true,
2728
+ };
2729
+ }
2730
+ }
2731
+ if (tool === "becki_get_registration_prompt") {
2732
+ return {
2733
+ content: [
2734
+ { type: "text", text: REGISTRATION_PROMPT },
2735
+ ],
2736
+ };
2737
+ }
2738
+ if (tool === "becki_register_context_source") {
2739
+ try {
2740
+ const input = normalizeRegistrationInput(request.params.arguments);
2741
+ // Identify the calling AI client via the MCP initialize handshake.
2742
+ const clientImpl = server.getClientVersion();
2743
+ const registeredBy = clientImpl?.name?.trim() || detectRegisteredBy();
2744
+ const current = loadRegistry();
2745
+ const { reg, action } = upsertSource(current, input, registeredBy);
2746
+ saveRegistry(reg);
2747
+ appendRegistryLog(action, input.serverName, `by=${registeredBy} cats=${input.categories.map(c => c.category).join(",")}`);
2748
+ const result = {
2749
+ status: "ok",
2750
+ action, // "added" | "updated"
2751
+ serverName: input.serverName,
2752
+ transport: input.transport,
2753
+ categoriesRegistered: input.categories.map((c) => c.category),
2754
+ registryPath: REGISTRY_PATH,
2755
+ registeredBy,
2756
+ };
2757
+ return {
2758
+ content: [
2759
+ { type: "text", text: JSON.stringify(result, null, 2) },
2760
+ ],
2761
+ };
2762
+ }
2763
+ catch (err) {
2764
+ const message = err instanceof Error ? err.message : String(err);
2765
+ return {
2766
+ content: [
2767
+ { type: "text", text: `becki_register_context_source failed: ${message}` },
2768
+ ],
2769
+ isError: true,
2770
+ };
2771
+ }
2772
+ }
2773
+ if (tool === "becki_list_project_context") {
2774
+ const args = (request.params.arguments ?? {});
2775
+ const limitRaw = typeof args.limit === "number" ? Math.floor(args.limit) : 50;
2776
+ const limit = Math.max(1, Math.min(200, limitRaw));
2777
+ // Normalize `since` and `until` to ISO 8601 strings. Accept dates or
2778
+ // full timestamps. Half-open interval [since, until) — until is
2779
+ // exclusive so callers can pass yyyy-mm-dd boundaries without the
2780
+ // edge case of "is the last microsecond of the day included or not".
2781
+ let sinceISO;
2782
+ if (args.since && typeof args.since === "string") {
2783
+ const parsed = new Date(args.since);
2784
+ if (!Number.isNaN(parsed.getTime())) {
2785
+ sinceISO = parsed.toISOString();
2786
+ }
2787
+ }
2788
+ let untilISO;
2789
+ if (args.until && typeof args.until === "string") {
2790
+ const parsed = new Date(args.until);
2791
+ if (!Number.isNaN(parsed.getTime())) {
2792
+ untilISO = parsed.toISOString();
2793
+ }
2794
+ }
2795
+ // Slug the project string for the same path-traversal / whitespace
2796
+ // reasons becki_ingest sluggifies it. Callers that pass the raw
2797
+ // project name still resolve to the stored slug.
2798
+ const projectArg = args.project && typeof args.project === "string"
2799
+ ? args.project.trim() || undefined
2800
+ : undefined;
2801
+ const typesArg = Array.isArray(args.types) && args.types.every((t) => typeof t === "string")
2802
+ ? args.types.map((t) => t.trim()).filter(Boolean)
2803
+ : undefined;
2804
+ try {
2805
+ const rows = await listVaultRows(projectArg, sinceISO, untilISO, typesArg, limit);
2806
+ const text = formatListResults(rows, projectArg, sinceISO, untilISO, typesArg);
2807
+ return { content: [{ type: "text", text }] };
2808
+ }
2809
+ catch (err) {
2810
+ const message = err instanceof Error ? err.message : String(err);
2811
+ return {
2812
+ content: [
2813
+ { type: "text", text: `becki_list_project_context failed: ${message}` },
2814
+ ],
2815
+ isError: true,
2816
+ };
2817
+ }
2818
+ }
2819
+ if (tool === "becki_ingest") {
2820
+ const args = request.params.arguments;
2821
+ if (!args.type || !args.content) {
2822
+ throw new Error('Missing required arguments: "type" and "content"');
2823
+ }
2824
+ const validTypes = ["decision", "commitment", "note", "open_loop", "dead_end"];
2825
+ if (!validTypes.includes(args.type)) {
2826
+ throw new Error(`Invalid type "${args.type}". Must be one of: ${validTypes.join(", ")}`);
2827
+ }
2828
+ try {
2829
+ const result = await ingestToVault(args.type, args.content, args.project, args.title);
2830
+ return {
2831
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2832
+ };
2833
+ }
2834
+ catch (err) {
2835
+ const message = err instanceof Error ? err.message : String(err);
2836
+ return {
2837
+ content: [{ type: "text", text: `becki_ingest failed: ${message}` }],
2838
+ isError: true,
2839
+ };
2840
+ }
2841
+ }
2842
+ if (tool === "becki_resolve") {
2843
+ const args = request.params.arguments;
2844
+ if (!args.row_id || typeof args.row_id !== "string") {
2845
+ throw new Error('Missing required argument: "row_id"');
2846
+ }
2847
+ const uuidRe = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
2848
+ if (!uuidRe.test(args.row_id)) {
2849
+ throw new Error(`row_id is not a valid UUID: ${args.row_id}`);
2850
+ }
2851
+ if (args.superseded_by && !uuidRe.test(args.superseded_by)) {
2852
+ throw new Error(`superseded_by is not a valid UUID: ${args.superseded_by}`);
2853
+ }
2854
+ try {
2855
+ const result = await resolveVaultRow(args.row_id, args.superseded_by, args.note);
2856
+ return {
2857
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
2858
+ };
2859
+ }
2860
+ catch (err) {
2861
+ const message = err instanceof Error ? err.message : String(err);
2862
+ return {
2863
+ content: [{ type: "text", text: `becki_resolve failed: ${message}` }],
2864
+ isError: true,
2865
+ };
2866
+ }
2867
+ }
2868
+ if (tool === "becki_check_dead_ends") {
2869
+ const args = (request.params.arguments ?? {});
2870
+ const prompt = String(args.prompt ?? "").trim();
2871
+ if (!prompt) {
2872
+ return {
2873
+ content: [
2874
+ {
2875
+ type: "text",
2876
+ text: "becki_check_dead_ends needs a `prompt` argument with the user's intent verbatim.",
2877
+ },
2878
+ ],
2879
+ isError: true,
2880
+ };
2881
+ }
2882
+ // Cheap heuristic gate — sub-10-char prompts ('ls', 'help') are
2883
+ // never going to match a substantive dead-end, and burning Voyage
2884
+ // credits on them inflates the per-prompt overhead this tool is
2885
+ // meant to avoid.
2886
+ if (prompt.length < 10) {
2887
+ return {
2888
+ content: [
2889
+ { type: "text", text: JSON.stringify({ matches: [], skipped: "prompt_too_short" }) },
2890
+ ],
2891
+ };
2892
+ }
2893
+ const userId = currentUserId();
2894
+ if (!userId) {
2895
+ return {
2896
+ content: [
2897
+ {
2898
+ type: "text",
2899
+ text: JSON.stringify({ matches: [], skipped: "no_user" }),
2900
+ },
2901
+ ],
2902
+ };
2903
+ }
2904
+ const minSim = typeof args.min_similarity === "number" ? args.min_similarity : 0.7;
2905
+ const limit = Math.min(5, Math.max(1, typeof args.limit === "number" ? args.limit : 3));
2906
+ try {
2907
+ // FAST PATH: embed → match RPC, no Haiku evaluate. Trades some
2908
+ // precision for sub-400ms latency — caller thresholds on raw
2909
+ // similarity to decide whether to interrupt the user. See
2910
+ // surfaceDeadEnds() for the precise-but-slow path used by
2911
+ // becki_context's narrative response.
2912
+ const embedding = await generateEmbedding(prompt, "query");
2913
+ const candidates = await matchDeadEndCandidates(embedding, prompt, userId);
2914
+ const matches = candidates
2915
+ .filter((c) => c.similarity >= minSim)
2916
+ .slice(0, limit)
2917
+ .map((c) => ({
2918
+ id: c.id,
2919
+ source_id: c.source_id,
2920
+ content: c.content,
2921
+ similarity: Math.round(c.similarity * 1000) / 1000,
2922
+ age_days: c.age_days,
2923
+ resolved: c.resolved_at !== null || c.superseded_by !== null,
2924
+ file: c.metadata?.file ?? null,
2925
+ project_name: c.metadata?.project_name ?? null,
2926
+ project_id: c.metadata?.project_id ?? null,
2927
+ created_at: c.created_at,
2928
+ }));
2929
+ return {
2930
+ content: [
2931
+ {
2932
+ type: "text",
2933
+ text: JSON.stringify({
2934
+ matches,
2935
+ checked_candidates: candidates.length,
2936
+ threshold: minSim,
2937
+ }, null, 2),
2938
+ },
2939
+ ],
2940
+ };
2941
+ }
2942
+ catch (err) {
2943
+ const message = err instanceof Error ? err.message : String(err);
2944
+ return {
2945
+ content: [
2946
+ {
2947
+ type: "text",
2948
+ text: `becki_check_dead_ends failed: ${message}`,
2949
+ },
2950
+ ],
2951
+ isError: true,
2952
+ };
2953
+ }
2954
+ }
2955
+ if (tool === "becki_meeting_transcript") {
2956
+ const args = (request.params.arguments ?? {});
2957
+ const meetingId = String(args.meeting_id ?? "").trim();
2958
+ if (!meetingId) {
2959
+ return {
2960
+ content: [
2961
+ {
2962
+ type: "text",
2963
+ text: "becki_meeting_transcript needs a meeting_id. Find it from a previous becki_context or becki_list_project_context call — the source_id of a meeting chunk is the meeting UUID.",
2964
+ },
2965
+ ],
2966
+ isError: true,
2967
+ };
2968
+ }
2969
+ // Hosted mode (becki-mcp running remotely on Fly) cannot reach the
2970
+ // user's local Becki.app IPC server. Return a clear, actionable
2971
+ // message rather than letting the fetch fail opaquely.
2972
+ if (IS_HTTP_TRANSPORT) {
2973
+ return {
2974
+ content: [
2975
+ {
2976
+ type: "text",
2977
+ text: "Transcripts are device-local by design — they don't sync to the cloud. " +
2978
+ "This hosted Becki MCP can't serve verbatim transcripts. To access them, " +
2979
+ "the user needs to be on their Mac with Becki.app running, using the local " +
2980
+ "becki-mcp install (the one bundled in the Becki app). For the AI-summarized " +
2981
+ "version of the meeting, use becki_context instead.",
2982
+ },
2983
+ ],
2984
+ isError: true,
2985
+ };
2986
+ }
2987
+ // Local mode (stdio MCP spawned by an AI tool on the user's Mac).
2988
+ // Talk to Becki.app's loopback IPC at 127.0.0.1:9876/transcript.
2989
+ if (!(await isUp(`${BECKI_IPC_URL}/status`, 500))) {
2990
+ return {
2991
+ content: [
2992
+ {
2993
+ type: "text",
2994
+ text: "Becki.app isn't running on this machine, so the local transcript path " +
2995
+ "isn't reachable. Start Becki.app and retry. (For AI-summarized context " +
2996
+ "that works without the app running, use becki_context.)",
2997
+ },
2998
+ ],
2999
+ isError: true,
3000
+ };
3001
+ }
3002
+ const url = new URL(`${BECKI_IPC_URL}/transcript`);
3003
+ url.searchParams.set("meeting_id", meetingId);
3004
+ if (args.query)
3005
+ url.searchParams.set("q", String(args.query));
3006
+ if (args.speaker)
3007
+ url.searchParams.set("speaker", String(args.speaker));
3008
+ if (typeof args.since === "number")
3009
+ url.searchParams.set("since", String(args.since));
3010
+ if (typeof args.until === "number")
3011
+ url.searchParams.set("until", String(args.until));
3012
+ if (typeof args.limit === "number")
3013
+ url.searchParams.set("limit", String(args.limit));
3014
+ try {
3015
+ const controller = new AbortController();
3016
+ const timer = setTimeout(() => controller.abort(), 5000);
3017
+ const res = await fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timer));
3018
+ if (res.status === 503) {
3019
+ return {
3020
+ content: [
3021
+ {
3022
+ type: "text",
3023
+ text: "Becki.app is starting up — its transcript handler isn't wired yet. Try again in a few seconds.",
3024
+ },
3025
+ ],
3026
+ isError: true,
3027
+ };
3028
+ }
3029
+ if (!res.ok) {
3030
+ return {
3031
+ content: [
3032
+ { type: "text", text: `Transcript fetch failed: HTTP ${res.status}` },
3033
+ ],
3034
+ isError: true,
3035
+ };
3036
+ }
3037
+ const segments = (await res.json());
3038
+ if (segments.length === 0) {
3039
+ return {
3040
+ content: [
3041
+ {
3042
+ type: "text",
3043
+ text: `No matching segments for meeting ${meetingId}.` +
3044
+ (args.query ? ` (query: "${args.query}")` : "") +
3045
+ (args.speaker ? ` (speaker: "${args.speaker}")` : ""),
3046
+ },
3047
+ ],
3048
+ };
3049
+ }
3050
+ const formatted = segments
3051
+ .map((s) => `${s.timestamp} · ${s.speaker} — ${s.text}`)
3052
+ .join("\n");
3053
+ return {
3054
+ content: [{ type: "text", text: formatted }],
3055
+ };
3056
+ }
3057
+ catch (err) {
3058
+ const message = err instanceof Error ? err.message : String(err);
3059
+ return {
3060
+ content: [
3061
+ { type: "text", text: `becki_meeting_transcript failed: ${message}` },
3062
+ ],
3063
+ isError: true,
3064
+ };
3065
+ }
3066
+ }
3067
+ throw new Error(`Unknown tool: ${tool}`);
3068
+ });
3069
+ return server;
3070
+ }
3071
+ // ---------------------------------------------------------------------------
3072
+ // HTTP transport (v0.10.0 — remote MCP)
3073
+ // ---------------------------------------------------------------------------
3074
+ // Runs becki-mcp as a Railway service reachable by web/mobile AI clients
3075
+ // (claude.ai, Gemini, ChatGPT). Stateless Streamable HTTP with JSON responses
3076
+ // — no long-lived SSE, so Railway's proxy idle timeout is a non-issue.
3077
+ //
3078
+ // Auth: OAuth 2.0 bearer. The token was issued by becki-site's /oauth flow
3079
+ // (migration 043 tables). We hash the bearer, look the row up with the
3080
+ // service key, resolve user_id + scopes, and run the MCP request inside
3081
+ // requestContext so every tool handler sees the right user via
3082
+ // currentUserId(). The service key lives ONLY in this Railway service's
3083
+ // env — never in the shipped Mac binary (which uses the stdio path and
3084
+ // never calls this function).
3085
+ const WRITE_TOOLS = new Set(["becki_ingest", "becki_resolve", "becki_register_context_source"]);
3086
+ function sha256hex(input) {
3087
+ return createHash("sha256").update(input).digest("hex");
3088
+ }
3089
+ /**
3090
+ * Validate an OAuth bearer token against becki.mcp_oauth_tokens. Returns the
3091
+ * token row id + resolved user_id + scopes, or null if the token is unknown,
3092
+ * revoked, or expired. Updates last_used_at best-effort (fire-and-forget).
3093
+ * The tokenId is used by the per-token rate limiter (task #148).
3094
+ */
3095
+ async function resolveOAuthToken(bearer) {
3096
+ const secret = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
3097
+ if (!secret) {
3098
+ console.error("becki-mcp http: SUPABASE_SECRET_KEY not set — cannot validate tokens");
3099
+ return null;
3100
+ }
3101
+ const hash = sha256hex(bearer);
3102
+ const headers = { apikey: secret, Authorization: `Bearer ${secret}`, "Accept-Profile": "becki" };
3103
+ let row;
3104
+ try {
3105
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/mcp_oauth_tokens?access_token_hash=eq.${hash}&revoked_at=is.null&select=id,user_id,scopes,access_expires_at`, { headers });
3106
+ if (!r.ok)
3107
+ return null;
3108
+ row = (await r.json())[0];
3109
+ }
3110
+ catch {
3111
+ return null;
3112
+ }
3113
+ if (!row)
3114
+ return null;
3115
+ if (new Date(row.access_expires_at).getTime() < Date.now())
3116
+ return null;
3117
+ // Best-effort last_used_at — never block the request on it.
3118
+ fetch(`${SUPABASE_URL}/rest/v1/mcp_oauth_tokens?access_token_hash=eq.${hash}`, {
3119
+ method: "PATCH",
3120
+ headers: { ...headers, "Content-Type": "application/json", "Content-Profile": "becki", Prefer: "return=minimal" },
3121
+ body: JSON.stringify({ last_used_at: new Date().toISOString() }),
3122
+ }).catch(() => { });
3123
+ return {
3124
+ tokenId: row.id,
3125
+ userId: row.user_id,
3126
+ scopes: Array.isArray(row.scopes) && row.scopes.length ? row.scopes : ["read"],
3127
+ };
3128
+ }
3129
+ /**
3130
+ * Per-token rate limit (task #148). Calls check_and_bump_mcp_quota — bumps
3131
+ * the minute + day counters and returns whether this tool call is allowed.
3132
+ * Only invoked for tools/call (the MCP handshake is free). Fails OPEN: if
3133
+ * the quota RPC itself errors, we allow the request rather than hard-fail a
3134
+ * legitimate user on an infra hiccup — the limit is an abuse ceiling, not a
3135
+ * correctness gate.
3136
+ */
3137
+ async function checkRateLimit(tokenId) {
3138
+ const secret = process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY;
3139
+ if (!secret)
3140
+ return { allowed: true, retryAfter: 0 };
3141
+ try {
3142
+ const r = await fetch(`${SUPABASE_URL}/rest/v1/rpc/check_and_bump_mcp_quota`, {
3143
+ method: "POST",
3144
+ headers: {
3145
+ "Content-Type": "application/json",
3146
+ apikey: secret,
3147
+ Authorization: `Bearer ${secret}`,
3148
+ "Content-Profile": "becki",
3149
+ },
3150
+ body: JSON.stringify({ p_token_id: tokenId }),
3151
+ });
3152
+ if (!r.ok)
3153
+ return { allowed: true, retryAfter: 0 };
3154
+ const rows = await r.json();
3155
+ const row = Array.isArray(rows) ? rows[0] : rows;
3156
+ if (!row || row.allowed)
3157
+ return { allowed: true, retryAfter: 0 };
3158
+ return { allowed: false, retryAfter: Number(row.retry_after) || 60 };
3159
+ }
3160
+ catch {
3161
+ return { allowed: true, retryAfter: 0 };
3162
+ }
3163
+ }
3164
+ /** True when the JSON-RPC body is a tools/call (any tool). */
3165
+ function isToolCall(body) {
3166
+ if (!body || typeof body !== "object")
3167
+ return false;
3168
+ return body.method === "tools/call";
3169
+ }
3170
+ /** True when the JSON-RPC body is a tools/call for a write-scoped tool. */
3171
+ function isWriteToolCall(body) {
3172
+ if (!body || typeof body !== "object")
3173
+ return false;
3174
+ const b = body;
3175
+ return b.method === "tools/call" && !!b.params?.name && WRITE_TOOLS.has(b.params.name);
3176
+ }
3177
+ function jsonResponse(res, status, payload, extraHeaders = {}) {
3178
+ res.writeHead(status, { "Content-Type": "application/json", ...CORS_HEADERS, ...extraHeaders });
3179
+ res.end(JSON.stringify(payload));
3180
+ }
3181
+ // Bearer-protected API — origin isn't the security boundary, the token is.
3182
+ const CORS_HEADERS = {
3183
+ "Access-Control-Allow-Origin": "*",
3184
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
3185
+ "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version",
3186
+ };
3187
+ async function startHttpTransport() {
3188
+ const port = Number(process.env.PORT) || 8080;
3189
+ const httpServer = createHttpServer(async (req, res) => {
3190
+ const url = (req.url || "").split("?")[0];
3191
+ // CORS preflight.
3192
+ if (req.method === "OPTIONS") {
3193
+ res.writeHead(204, CORS_HEADERS).end();
3194
+ return;
3195
+ }
3196
+ // Health check for Railway.
3197
+ if (req.method === "GET" && url === "/health") {
3198
+ res.writeHead(200, { "Content-Type": "text/plain" }).end("ok");
3199
+ return;
3200
+ }
3201
+ // MCP auth-spec: the resource server points clients at its auth server.
3202
+ if (req.method === "GET" && url === "/.well-known/oauth-protected-resource") {
3203
+ jsonResponse(res, 200, {
3204
+ resource: `https://${req.headers.host || "mcp.becki.io"}/mcp`,
3205
+ authorization_servers: ["https://www.becki.io"],
3206
+ });
3207
+ return;
3208
+ }
3209
+ if (url !== "/mcp") {
3210
+ res.writeHead(404, CORS_HEADERS).end();
3211
+ return;
3212
+ }
3213
+ // ── OAuth bearer ─────────────────────────────────────────────────────
3214
+ const authHeader = req.headers["authorization"];
3215
+ const headerVal = Array.isArray(authHeader) ? authHeader[0] : authHeader;
3216
+ const bearer = /^Bearer (.+)$/.exec(headerVal || "")?.[1];
3217
+ if (!bearer) {
3218
+ jsonResponse(res, 401, { error: "unauthorized" }, {
3219
+ "WWW-Authenticate": `Bearer realm="becki-mcp", resource_metadata="https://${req.headers.host || "mcp.becki.io"}/.well-known/oauth-protected-resource"`,
3220
+ });
3221
+ return;
3222
+ }
3223
+ const resolved = await resolveOAuthToken(bearer);
3224
+ if (!resolved) {
3225
+ jsonResponse(res, 401, { error: "invalid_token" }, {
3226
+ "WWW-Authenticate": `Bearer realm="becki-mcp", error="invalid_token"`,
3227
+ });
3228
+ return;
3229
+ }
3230
+ // ── Parse body ───────────────────────────────────────────────────────
3231
+ let body;
3232
+ try {
3233
+ const chunks = [];
3234
+ for await (const c of req)
3235
+ chunks.push(c);
3236
+ const raw = Buffer.concat(chunks).toString("utf8");
3237
+ body = raw ? JSON.parse(raw) : undefined;
3238
+ }
3239
+ catch {
3240
+ jsonResponse(res, 400, { error: "invalid_json" });
3241
+ return;
3242
+ }
3243
+ // ── Write-scope gate ─────────────────────────────────────────────────
3244
+ if (isWriteToolCall(body) && !resolved.scopes.includes("write")) {
3245
+ jsonResponse(res, 403, {
3246
+ jsonrpc: "2.0",
3247
+ error: { code: -32000, message: "this token is read-only — write tools require the 'write' scope" },
3248
+ id: body?.id ?? null,
3249
+ });
3250
+ return;
3251
+ }
3252
+ // ── Rate limit (task #148) ───────────────────────────────────────────
3253
+ // Only tools/call counts — the MCP handshake (initialize, tools/list)
3254
+ // is free protocol chatter. 30/min burst + 1000/day per token. 429 +
3255
+ // Retry-After when exceeded. checkRateLimit fails open on infra error.
3256
+ if (isToolCall(body)) {
3257
+ const rl = await checkRateLimit(resolved.tokenId);
3258
+ if (!rl.allowed) {
3259
+ jsonResponse(res, 429, {
3260
+ jsonrpc: "2.0",
3261
+ error: { code: -32000, message: "rate limit exceeded — slow down and retry" },
3262
+ id: body?.id ?? null,
3263
+ }, { "Retry-After": String(rl.retryAfter) });
3264
+ return;
3265
+ }
3266
+ }
3267
+ // ── Dispatch — fresh server+transport per request (stateless) ────────
3268
+ const mcpServer = createMcpServer();
3269
+ const transport = new StreamableHTTPServerTransport({
3270
+ sessionIdGenerator: undefined, // stateless: no session storage
3271
+ enableJsonResponse: true, // direct JSON, no long-lived SSE
3272
+ });
3273
+ res.on("close", () => {
3274
+ transport.close().catch(() => { });
3275
+ mcpServer.close().catch(() => { });
3276
+ });
3277
+ try {
3278
+ await mcpServer.connect(transport);
3279
+ await requestContext.run({ userId: resolved.userId, scopes: resolved.scopes }, () => transport.handleRequest(req, res, body));
3280
+ }
3281
+ catch (err) {
3282
+ console.error("becki-mcp http: request failed:", err instanceof Error ? err.message : err);
3283
+ if (!res.headersSent)
3284
+ jsonResponse(res, 500, { error: "internal_error" });
3285
+ }
3286
+ });
3287
+ httpServer.listen(port, () => {
3288
+ console.error(`becki-mcp HTTP transport listening on :${port}`);
3289
+ });
3290
+ }
3291
+ // ---------------------------------------------------------------------------
3292
+ // CLI subcommands (Core #191)
3293
+ // ---------------------------------------------------------------------------
3294
+ // `becki-mcp init` → first-time cross-platform setup (registers projects)
3295
+ // `becki-mcp bootstrap` → ingest last 90 days of AI sessions
3296
+ // `becki-mcp digest` → run the daily session digest right now
3297
+ // `becki-mcp --help` → print usage
3298
+ // (no args) → fall through to MCP stdio / HTTP transport
3299
+ //
3300
+ // Subcommand handling is intentionally lightweight — no commander/yargs
3301
+ // dependency. CLI subcommands exit before reaching the transport block.
3302
+ const argv = process.argv.slice(2);
3303
+ const subcommand = argv[0];
3304
+ const subArgs = argv.slice(1);
3305
+ if (subcommand === "--help" || subcommand === "-h") {
3306
+ console.log(`becki-mcp — Becki MCP server + Core daemon
3307
+
3308
+ Usage:
3309
+ becki-mcp Run MCP stdio server (default — for AI client config)
3310
+ becki-mcp init [opts] First-time setup: register projects, print client config
3311
+ becki-mcp bootstrap [days] Historical ingest of AI session logs (default 90 days)
3312
+ becki-mcp digest Run daily session digest immediately
3313
+ becki-mcp --help Show this help
3314
+
3315
+ Env:
3316
+ BECKI_HOME Override config directory (default ~/.becki/ or legacy ~/Library/Application Support/Becki/)
3317
+ BECKI_MCP_TRANSPORT=http Run HTTP transport instead of stdio (multi-user remote MCP)
3318
+ `);
3319
+ process.exit(0);
3320
+ }
3321
+ if (subcommand === "init") {
3322
+ const { runInit } = await import("./core/init.js");
3323
+ const code = await runInit(subArgs, APP_SUPPORT_DIR);
3324
+ process.exit(code);
3325
+ }
3326
+ if (subcommand === "bootstrap" || subcommand === "digest") {
3327
+ const { CoreRunner } = await import("./core/runner.js");
3328
+ // Initialize ingest-token cache so cloudIngest() can use it.
3329
+ cachedIngestToken = readIngestToken();
3330
+ if (!cachedIngestToken) {
3331
+ console.error("becki-mcp: no install token at " + MCP_INGEST_TOKEN_PATH + "\n" +
3332
+ "Run `becki-mcp init` and connect via becki.io/get-core to provision one.");
3333
+ process.exit(1);
3334
+ }
3335
+ // Real extractor — POSTs to extract-mcp-content edge function with the
3336
+ // install token. Mirrors the Swift GitContentExtractor wire format.
3337
+ const realExtractor = async (transcript) => {
3338
+ const resp = await fetch(`${SUPABASE_URL}/functions/v1/extract-mcp-content`, {
3339
+ method: "POST",
3340
+ headers: {
3341
+ "Content-Type": "application/json",
3342
+ "Authorization": `Bearer ${cachedIngestToken}`,
3343
+ "User-Agent": `becki-mcp/${INSTALL_ID.slice(0, 8)}`,
3344
+ },
3345
+ body: JSON.stringify({
3346
+ project_name: "AI Session",
3347
+ content_kind: "mcp_doc",
3348
+ title: "Session digest",
3349
+ body: transcript,
3350
+ metadata: {},
3351
+ }),
3352
+ });
3353
+ if (!resp.ok) {
3354
+ const errBody = await resp.text();
3355
+ throw new Error(`extract-mcp-content ${resp.status}: ${errBody.slice(0, 200)}`);
3356
+ }
3357
+ const j = await resp.json();
3358
+ // Flatten to title + body text — the Core ingester takes plain content,
3359
+ // not the nested entity shape. The structure is preserved in source_id/
3360
+ // metadata when each item is ingested.
3361
+ const toText = (e) => `${e.title}\n\n${e.body}`.trim();
3362
+ return {
3363
+ decisions: (j.decisions ?? []).map(toText),
3364
+ deadEnds: (j.dead_ends ?? []).map(toText),
3365
+ commitments: (j.commitments ?? []).map(toText),
3366
+ openLoops: (j.open_loops ?? []).map(toText),
3367
+ };
3368
+ };
3369
+ // Real ingester — reuses cloudIngest() path that already POSTs to
3370
+ // ingest-vault-row with install-token auth, embeds via voyage-embed, and
3371
+ // upserts vault_embeddings. Identical to the existing MCP `becki_ingest`
3372
+ // tool path, just driven by the Core background digest instead of an
3373
+ // interactive MCP call.
3374
+ const realIngester = async (rec) => {
3375
+ const result = await cloudIngest(rec.type, rec.content, typeof rec.metadata.project_name === "string" ? rec.metadata.project_name : undefined,
3376
+ // The session sourceId is already deterministic + slugged; pass it
3377
+ // through as the "title" so the existing cloudIngest hash matches the
3378
+ // Swift extractor's convention.
3379
+ rec.sourceId);
3380
+ if (!result.ok) {
3381
+ throw new Error(`ingest-vault-row failed: ${result.status ?? "?"} ${result.error ?? ""}`);
3382
+ }
3383
+ };
3384
+ const runner = new CoreRunner({
3385
+ beckiHome: APP_SUPPORT_DIR,
3386
+ extract: realExtractor,
3387
+ ingest: realIngester,
3388
+ logger: (m) => console.error(`[core] ${m}`),
3389
+ });
3390
+ if (subcommand === "bootstrap") {
3391
+ const days = subArgs[0] ? Number(subArgs[0]) : 90;
3392
+ await runner.runBootstrap(days);
3393
+ }
3394
+ else {
3395
+ await runner.runOneDigest("manual");
3396
+ }
3397
+ await runner.stop();
3398
+ process.exit(0);
3399
+ }
3400
+ // ---------------------------------------------------------------------------
3401
+ // Start — transport selected by BECKI_MCP_TRANSPORT
3402
+ // ---------------------------------------------------------------------------
3403
+ // stdio (default): the Mac-app-bundled server. One user — the machine owner.
3404
+ // http: remote MCP (v0.10.0). Many users, OAuth-bearer-scoped per request.
3405
+ if (IS_HTTP_TRANSPORT) {
3406
+ await startHttpTransport();
3407
+ }
3408
+ else {
3409
+ const server = createMcpServer();
3410
+ const transport = new StdioServerTransport();
3411
+ await server.connect(transport);
3412
+ }