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/LICENSE +32 -0
- package/README.md +142 -0
- package/dist/core/ai-sessions.js +325 -0
- package/dist/core/db.js +221 -0
- package/dist/core/init.js +218 -0
- package/dist/core/project-activity.js +225 -0
- package/dist/core/runner.js +109 -0
- package/dist/index.js +3412 -0
- package/package.json +61 -0
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
|
+
}
|