@fangorn-network/westmarch 0.0.1-dev

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/consume/mcp.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ // Every Fangorn app's WebMCP tools, behind one stdio MCP server.
3
+ //
4
+ // fangorn-mcp [--cdp <url>] [--headed] [--from-block <n>]
5
+ // claude mcp add fangorn -- fangorn-mcp
6
+ // claude mcp add fangorn -e FANGORN_MCP_CDP=ws://browser:9222 -- fangorn-mcp
7
+ //
8
+ // WebMCP tools live on `document.modelContext` inside a loaded tab. There is no
9
+ // endpoint, so an agent outside the browser cannot reach them, and one MCP
10
+ // server per app would mean one registration per app. Instead this server is
11
+ // the directory and the browser both:
12
+ //
13
+ // list-apps apps read off the chain (AppAgentChanged → verified cards)
14
+ // open-app verify one app's card, open its page in a tab, and add its
15
+ // tools to this server as `<app>__<tool>` (tools/list_changed)
16
+ // call-app-tool the same call by name, for clients that ignore list_changed
17
+ //
18
+ // One browser, one tab per opened app: a WebMCP browser at --cdp, or else a
19
+ // local Chrome with WebMCP on, headless unless --headed. Calls
20
+ // are relayed to the tab's own `modelContext.executeTool`, so the tools, their
21
+ // schemas and their answers are the page's, and queries still run in the tab.
22
+ // A local Chrome's profile persists (~/.cache/westmarch-mcp), so a page's
23
+ // downloads, such as an embedding model, are fetched once. A tab or browser that
24
+ // dies is reopened on the next call.
25
+
26
+ import { spawn } from "node:child_process";
27
+ import { mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { createInterface } from "node:readline";
30
+ import { APP_EXTENSION, listApps } from "./apps.js";
31
+
32
+ // The current AppRegistry's first event on Arbitrum Sepolia. A reader's
33
+ // default, not deployment config: pass --from-block for another deployment.
34
+ const DEFAULT_FROM_BLOCK = 311637349n;
35
+ const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
36
+
37
+ let client;
38
+ async function fangorn() {
39
+ if (!client) {
40
+ const [{ Fangorn, FangornConfig }, { generatePrivateKey }] = await Promise.all([
41
+ import("@fangorn-network/sdk"), import("viem/accounts")]);
42
+ client = Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig }); // reads only
43
+ }
44
+ return client;
45
+ }
46
+
47
+ /** A card URL → the verified card and the page to open. A plain A2A card (no
48
+ * Fangorn extension) opens unverified: the caller asked for it by URL. */
49
+ async function resolveCard(cardUrl) {
50
+ const card = await (await fetch(cardUrl)).json();
51
+ const fangornApp = card.capabilities?.extensions?.some((e) => e.uri === APP_EXTENSION);
52
+ if (fangornApp) await (await fangorn()).discoverApp(cardUrl);
53
+ const page = new URL(card.url);
54
+ if (page.protocol !== "https:" && page.protocol !== "http:") throw new Error(`card.url is not http(s): ${card.url}`);
55
+ return { card, page: page.toString(), verified: fangornApp };
56
+ }
57
+
58
+ // ── the browser ─────────────────────────────────────────────────────────────
59
+ // Anything that speaks the Chrome DevTools Protocol and has WebMCP: a headless
60
+ // Chromium service, a hosted browser, or the local Chrome. `--cdp` (or
61
+ // FANGORN_MCP_CDP) names it as http(s)://host:port or a ws(s):// browser
62
+ // endpoint. Without it, a local Chrome is reused if one is already serving
63
+ // this profile (another session's), else launched.
64
+ //
65
+ // Everything goes over the one browser-level WebSocket (Target.* plus flat
66
+ // sessions). That is the endpoint every CDP host exposes, where the per-tab
67
+ // /json HTTP routes are not.
68
+
69
+ const PROFILE = `${homedir()}/.cache/westmarch-mcp`;
70
+
71
+ /** An http(s) DevTools address → its browser WebSocket, reached via the host we were given. */
72
+ async function browserWs(http) {
73
+ const v = await (await fetch(new URL("/json/version", http), { signal: AbortSignal.timeout(3000) })).json();
74
+ const ws = new URL(v.webSocketDebuggerUrl), base = new URL(http);
75
+ ws.host = base.host; // the service reports its own view of itself
76
+ if (base.protocol === "https:") ws.protocol = "wss:";
77
+ return ws.toString();
78
+ }
79
+
80
+ async function localChrome({ headed }) {
81
+ mkdirSync(PROFILE, { recursive: true });
82
+ const portFile = `${PROFILE}/DevToolsActivePort`;
83
+ if (existsSync(portFile)) {
84
+ const ws = await browserWs(`http://127.0.0.1:${readFileSync(portFile, "utf-8").split("\n")[0]}`).catch(() => null);
85
+ if (ws) { log("reusing the Chrome already serving this profile"); return { ws }; }
86
+ rmSync(portFile, { force: true }); // stale: that Chrome is gone
87
+ }
88
+ const bin = process.env.CHROME ?? "google-chrome";
89
+ const proc = spawn(bin, [
90
+ ...(headed ? [] : ["--headless=new"]),
91
+ "--enable-features=WebMCP", "--remote-debugging-port=0", "--no-first-run",
92
+ "--no-default-browser-check", `--user-data-dir=${PROFILE}`, "about:blank",
93
+ ], { stdio: "ignore" });
94
+ const failed = new Promise((_, j) => proc.on("error", (e) => j(new Error(
95
+ `no browser: cannot launch ${bin} (${e.code}). Point --cdp / FANGORN_MCP_CDP at a WebMCP browser, or set CHROME.`))));
96
+ for (let i = 0; i < 100; i++) {
97
+ await Promise.race([new Promise((r) => setTimeout(r, 100)), failed]);
98
+ if (existsSync(portFile)) return { ws: await browserWs(`http://127.0.0.1:${readFileSync(portFile, "utf-8").split("\n")[0]}`), proc };
99
+ }
100
+ proc.kill();
101
+ throw new Error(`${bin} did not start`);
102
+ }
103
+
104
+ let conn;
105
+ /** The browser connection, opened once and reopened after it drops. */
106
+ function browser(opts) {
107
+ return conn ??= (async () => {
108
+ const { cdp } = opts;
109
+ const { ws: url, proc } = !cdp ? await localChrome(opts)
110
+ : /^wss?:/.test(cdp) ? { ws: cdp } : { ws: await browserWs(cdp) };
111
+ const ws = new WebSocket(url);
112
+ await new Promise((r, j) => { ws.onopen = r; ws.onerror = () => j(new Error(`cannot connect to the browser at ${url}`)); });
113
+ let id = 0; const pending = new Map(), dead = new Set();
114
+ ws.onmessage = (m) => {
115
+ const d = JSON.parse(m.data);
116
+ if (d.method === "Target.detachedFromTarget") dead.add(d.params.sessionId);
117
+ if (d.id !== undefined) { pending.get(d.id)?.(d); pending.delete(d.id); }
118
+ };
119
+ ws.onclose = () => { conn = null; for (const r of pending.values()) r({ error: { message: "browser connection closed" } }); };
120
+ const send = (method, params = {}, sessionId) => new Promise((res, rej) => {
121
+ if (ws.readyState !== WebSocket.OPEN) return rej(new Error("browser connection closed"));
122
+ pending.set(++id, (d) => (d.error ? rej(new Error(d.error.message)) : res(d.result)));
123
+ ws.send(JSON.stringify({ id, method, params, ...(sessionId && { sessionId }) }));
124
+ });
125
+ const tabs = new Set();
126
+ // Close what we opened. A shared or remote browser outlives this process.
127
+ const bye = async () => {
128
+ await Promise.race([Promise.all([...tabs].map((t) => send("Target.closeTarget", { targetId: t }).catch(() => {}))),
129
+ new Promise((r) => setTimeout(r, 500))]);
130
+ // A local Chrome may be serving another session: leave it running
131
+ // while anyone else has a tab open; the last one out closes it.
132
+ const others = await Promise.race([send("Target.getTargets").then((r) => r.targetInfos.filter((t) => t.type === "page" && t.url !== "about:blank").length).catch(() => 0),
133
+ new Promise((r) => setTimeout(() => r(0), 500))]);
134
+ // Never a --cdp browser: that one is somebody's service.
135
+ if (!cdp && !others) await Promise.race([send("Browser.close").catch(() => {}), new Promise((r) => setTimeout(r, 500))]);
136
+ proc?.kill();
137
+ process.exit(0);
138
+ };
139
+ process.once("SIGINT", bye); process.once("SIGTERM", bye); process.stdin.once("end", bye);
140
+ log(`browser: ${cdp ?? (proc ? "launched local Chrome" : "local Chrome")}`);
141
+ return { send, dead, tabs };
142
+ })().catch((e) => { conn = null; throw e; });
143
+ }
144
+
145
+ /** A new tab on `page`; returns `ev(expr)`, which evaluates in it. */
146
+ async function openTab(page, opts) {
147
+ const { send, dead, tabs } = await browser(opts);
148
+ const { targetId } = await send("Target.createTarget", { url: page });
149
+ tabs.add(targetId);
150
+ const { sessionId } = await send("Target.attachToTarget", { targetId, flatten: true });
151
+ return async (expression) => {
152
+ if (dead.has(sessionId)) throw new Error(`tab closed: ${page}`);
153
+ const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true }, sessionId)
154
+ .catch((e) => { throw new Error(`tab closed: ${page} (${e.message})`); });
155
+ if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text);
156
+ return r.result.value;
157
+ };
158
+ }
159
+
160
+ /** The tab's tools in MCP's shape. Waits for the page to register them. */
161
+ async function tabTools(ev) {
162
+ let hasWebMCP = false;
163
+ for (let i = 0; i < 150; i++) {
164
+ const got = await ev(`(async () => {
165
+ const mc = document.modelContext;
166
+ if (!mc?.getTools) return { webmcp: false, loaded: location.protocol.startsWith("http") && document.readyState === "complete" };
167
+ return { webmcp: true, tools: (await mc.getTools()).map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) };
168
+ })()`).catch(() => null);
169
+ hasWebMCP ||= !!got?.webmcp;
170
+ // modelContext is there from the first script or never; no need to wait out the page.
171
+ if (got && !got.webmcp && got.loaded) break;
172
+ if (got?.tools?.length) return got.tools.map((t) => ({ ...t, inputSchema: t.inputSchema ? JSON.parse(t.inputSchema) : { type: "object" } }));
173
+ await new Promise((r) => setTimeout(r, 200));
174
+ }
175
+ throw new Error(hasWebMCP
176
+ ? "the page registered no WebMCP tools in 30s (is card.url the page that registers them?)"
177
+ : "this browser has no WebMCP (document.modelContext.getTools): it needs Chrome 150+ with --enable-features=WebMCP, or a Chromium built with it");
178
+ }
179
+
180
+ /** One call, relayed. WebMCP takes the arguments as a JSON string and returns one. */
181
+ async function tabCall(ev, name, args) {
182
+ const out = await ev(`document.modelContext.getTools().then((ts) => {
183
+ const t = ts.find((t) => t.name === ${JSON.stringify(name)});
184
+ if (!t) throw new Error("no tool " + ${JSON.stringify(name)});
185
+ return document.modelContext.executeTool(t, ${JSON.stringify(JSON.stringify(args ?? {}))});
186
+ })`);
187
+ try { const r = JSON.parse(out); if (Array.isArray(r?.content)) return r; } catch {}
188
+ return { content: [{ type: "text", text: String(out) }] };
189
+ }
190
+
191
+ /** JSON-RPC over newline-delimited stdio. Returns `notify` for server → client messages. */
192
+ export function serve(handlers, { input = process.stdin, output = process.stdout } = {}) {
193
+ const send = (m) => output.write(JSON.stringify({ jsonrpc: "2.0", ...m }) + "\n");
194
+ createInterface({ input }).on("line", async (line) => {
195
+ let msg; try { msg = JSON.parse(line); } catch { return; }
196
+ if (msg.id === undefined) return; // notifications
197
+ const h = handlers[msg.method];
198
+ if (!h) return send({ id: msg.id, error: { code: -32601, message: `unknown method ${msg.method}` } });
199
+ try { send({ id: msg.id, result: await h(msg.params ?? {}) }); }
200
+ catch (e) { send({ id: msg.id, error: { code: -32000, message: e?.message ?? String(e) } }); }
201
+ });
202
+ return (method, params = {}) => send({ method, params });
203
+ }
204
+
205
+ const text = (o) => ({ content: [{ type: "text", text: typeof o === "string" ? o : JSON.stringify(o, null, 1) }] });
206
+ // MCP tool names: [a-zA-Z0-9_-]{1,64}. `__` separates app from tool.
207
+ const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "app";
208
+
209
+ const META = [
210
+ { name: "list-apps",
211
+ description: "List the Fangorn apps registered on chain whose agent cards verify: name, description, card URL, and the WebMCP tools each page offers. Filter with `query`. Call open-app on one to use its tools.",
212
+ inputSchema: { type: "object", properties: { query: { type: "string", description: "Case-insensitive match on name or description" } } } },
213
+ { name: "open-app",
214
+ description: "Verify an app's card against the chain, open its page in a browser tab, and add its tools to this server as `<app>__<tool>`. Returns the tools and their input schemas. `app` is a name from list-apps, an appId, or a card URL.",
215
+ inputSchema: { type: "object", properties: { app: { type: "string" } }, required: ["app"] } },
216
+ { name: "call-app-tool",
217
+ description: "Call one tool of an opened app. Same as calling `<app>__<tool>` directly.",
218
+ inputSchema: { type: "object", properties: { app: { type: "string" }, tool: { type: "string" }, arguments: { type: "object" } }, required: ["app", "tool"] } },
219
+ ];
220
+
221
+ /**
222
+ * The server. `deps` is the outside world, so the self-check can fake it:
223
+ * findApps() → { apps: [{ name, desc, card, appId }], rejected }
224
+ * resolveCard(url) → { card, page, verified }
225
+ * openTab(page) → ev
226
+ * tabTools(ev), tabCall(ev, name, args)
227
+ * notify(method) set after serve()
228
+ */
229
+ export function handlers(deps) {
230
+ const open = new Map(); // slug → { name, card, verified, ev, tools }
231
+
232
+ const findOpen = (app) => open.get(slug(app)) ?? [...open.values()].find((o) => o.card === app || o.appId === app);
233
+
234
+ async function openApp(app) {
235
+ const hit = findOpen(app);
236
+ if (hit) return hit;
237
+ let cardUrl = app, appId;
238
+ if (!/^https?:\/\//.test(app)) {
239
+ const { apps } = await deps.findApps();
240
+ const a = apps.find((x) => x.appId === app || slug(x.name) === slug(app));
241
+ if (!a) throw new Error(`no verified app named ${JSON.stringify(app)}; see list-apps`);
242
+ ({ card: cardUrl, appId } = a);
243
+ }
244
+ const { card, page, verified } = await deps.resolveCard(cardUrl);
245
+ let s = slug(card.name ?? app);
246
+ while (open.has(s)) s += "-";
247
+ const ev = await deps.openTab(page);
248
+ const entry = { slug: s, name: card.name, card: cardUrl, appId, verified, page, ev, tools: await deps.tabTools(ev) };
249
+ open.set(s, entry);
250
+ deps.notify?.("notifications/tools/list_changed");
251
+ return entry;
252
+ }
253
+
254
+ async function call(app, tool, args) {
255
+ const o = findOpen(app);
256
+ if (!o) throw new Error(`${app} is not open; call open-app first`);
257
+ try { return await deps.tabCall(o.ev, tool, args); }
258
+ catch (e) {
259
+ if (!/tab closed/.test(e.message)) throw e;
260
+ // The tab or the whole browser went away. Reopen once; the tools keep their names.
261
+ o.ev = await deps.openTab(o.page);
262
+ await deps.tabTools(o.ev);
263
+ return deps.tabCall(o.ev, tool, args);
264
+ }
265
+ }
266
+
267
+ const run = {
268
+ "list-apps": async ({ query }) => {
269
+ const { apps, rejected } = await deps.findApps();
270
+ const q = String(query ?? "").toLowerCase();
271
+ return text({
272
+ apps: apps.filter((a) => !q || `${a.name} ${a.desc}`.toLowerCase().includes(q)).map((a) => ({
273
+ app: slug(a.name), name: a.name, description: a.desc, card: a.card, appId: a.appId,
274
+ tools: a.tools, open: open.has(slug(a.name)),
275
+ })),
276
+ unverified: rejected.length,
277
+ });
278
+ },
279
+ "open-app": async ({ app }) => {
280
+ const o = await openApp(app);
281
+ return text({ app: o.slug, name: o.name, page: o.page, verified: o.verified,
282
+ call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools });
283
+ },
284
+ "call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
285
+ };
286
+
287
+ return {
288
+ initialize: async (p) => ({
289
+ protocolVersion: p.protocolVersion ?? "2025-06-18",
290
+ capabilities: { tools: { listChanged: true } },
291
+ serverInfo: { name: "fangorn-mcp", version: "0.1.0" },
292
+ instructions: "Fangorn apps are static sites whose tools run in the browser. list-apps to find one, open-app to load it, then call its tools as <app>__<tool>.",
293
+ }),
294
+ ping: async () => ({}),
295
+ "tools/list": async () => ({ tools: [
296
+ ...META,
297
+ ...[...open.values()].flatMap((o) => o.tools.map((t) => ({ ...t, name: `${o.slug}__${t.name}`, description: `[${o.name}] ${t.description ?? ""}` }))),
298
+ ] }),
299
+ "tools/call": async ({ name, arguments: args = {} }) => {
300
+ try {
301
+ if (run[name]) return await run[name](args);
302
+ const i = name.indexOf("__");
303
+ if (i > 0) return await call(name.slice(0, i), name.slice(i + 2), args);
304
+ throw new Error(`no tool ${name}`);
305
+ } catch (e) { return { isError: true, content: [{ type: "text", text: e?.message ?? String(e) }] }; }
306
+ },
307
+ };
308
+ }
309
+
310
+ // ── main / self-check ───────────────────────────────────────────────────────
311
+ // A command, not a module: installed as a bin, argv[1] is the symlink, so there is no is-main check.
312
+ if (process.argv[2] === "--selfcheck") {
313
+ const { PassThrough } = await import("node:stream");
314
+ const input = new PassThrough(), output = new PassThrough();
315
+ const replies = [];
316
+ createInterface({ input: output }).on("line", (l) => replies.push(JSON.parse(l)));
317
+ let opened = 0;
318
+ const deps = {
319
+ findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", appId: "0xk", tools: ["greet"] }], rejected: [{}] }),
320
+ resolveCard: async () => ({ card: { name: "Kings Foil" }, page: "https://k.test/p", verified: true }),
321
+ openTab: async () => (opened++ ? "ev" : "dead"), // the first tab dies
322
+ tabTools: async () => [{ name: "greet", description: "Hi.", inputSchema: { type: "object" } }],
323
+ tabCall: async (ev, name, args) => (name === "boom" ? Promise.reject(new Error("no tool boom"))
324
+ : ev === "dead" ? Promise.reject(new Error("tab closed: x")) : text({ ev, name, args })),
325
+ };
326
+ deps.notify = serve(handlers(deps), { input, output });
327
+ const rpc = (id, method, params) => input.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
328
+ const wait = () => new Promise((r) => setTimeout(r, 20));
329
+ const call = (id, name, args) => rpc(id, "tools/call", { name, arguments: args });
330
+ const res = (id) => replies.find((r) => r.id === id);
331
+ const body = (id) => JSON.parse(res(id).result.content[0].text);
332
+ const assert = (c, m) => { if (!c) throw new Error(m); };
333
+
334
+ rpc(1, "initialize", {}); rpc(2, "tools/list"); call(3, "list-apps", { query: "TRIAL" }); call(4, "kings-foil__greet"); await wait();
335
+ assert(res(1).result.capabilities.tools.listChanged, "advertises list_changed");
336
+ assert(res(2).result.tools.length === 3, "only the meta tools before anything is open");
337
+ assert(body(3).apps[0].app === "kings-foil" && body(3).unverified === 1, "list-apps filters and slugs");
338
+ assert(res(4).result.isError && /not open/.test(res(4).result.content[0].text), "an unopened app's tool is an error");
339
+
340
+ call(5, "open-app", { app: "Kings Foil" }); await wait();
341
+ call(6, "open-app", { app: "https://k.test/card" }); await wait();
342
+ assert(body(5).tools[0].name === "greet" && opened === 1, "open once, by name or by card URL");
343
+ call(50, "kings-foil__greet", { who: "b" }); await wait();
344
+ assert(body(50).ev === "ev" && opened === 2, "a dead tab is reopened and the call retried");
345
+ assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
346
+ rpc(7, "tools/list"); call(8, "kings-foil__greet", { who: "a" }); call(9, "call-app-tool", { app: "kings-foil", tool: "greet" });
347
+ call(10, "call-app-tool", { app: "kings-foil", tool: "boom" }); call(11, "open-app", { app: "nope" }); rpc(12, "nope"); await wait();
348
+ assert(res(7).result.tools.some((t) => t.name === "kings-foil__greet"), "opened tools are listed under the app");
349
+ assert(body(8).args.who === "a" && body(9).name === "greet", "both call paths reach the tab");
350
+ assert(res(10).result.isError && res(11).result.isError, "failures are tool errors");
351
+ assert(res(12).error.code === -32601, "unknown method");
352
+ console.log("mcp.js self-check ok — one server, apps opened on demand, their tools listed as <app>__<tool>");
353
+ process.exit(0);
354
+ }
355
+
356
+ const argv = process.argv.slice(2);
357
+ const fb = argv.indexOf("--from-block");
358
+ const fromBlock = fb >= 0 ? BigInt(argv[fb + 1]) : DEFAULT_FROM_BLOCK;
359
+ const headed = argv.includes("--headed");
360
+ const ci = argv.indexOf("--cdp");
361
+ const cdp = ci >= 0 ? argv[ci + 1] : process.env.FANGORN_MCP_CDP || undefined;
362
+ let found, foundAt = 0;
363
+ const deps = {
364
+ // ponytail: cached for a minute. The scan is a few RPC calls plus a fetch per card.
365
+ findApps: async () => {
366
+ if (!found || Date.now() - foundAt > 60_000) {
367
+ const { apps, rejected } = await listApps(await fangorn(), { fromBlock });
368
+ found = { rejected, apps: await Promise.all(apps.map(async (a) => {
369
+ const card = await (await fetch(a.card)).json().catch(() => ({}));
370
+ return { ...a, tools: card.skills?.filter((s) => s.tags?.includes("webmcp")).map((s) => s.id) ?? [] };
371
+ })) };
372
+ foundAt = Date.now();
373
+ }
374
+ return found;
375
+ },
376
+ resolveCard,
377
+ openTab: (page) => openTab(page, { headed, cdp }),
378
+ tabTools, tabCall,
379
+ };
380
+ deps.notify = serve(handlers(deps));
381
+ log(`ready: apps from block ${fromBlock}; browser ${cdp ?? "local Chrome"}, connected on the first open-app`);
@@ -0,0 +1,218 @@
1
+ // Ranking a row against a query. Two modes, chosen per row, so search degrades
2
+ // rather than breaks: cosine for rows that carry a vector, word-boundary lexical
3
+ // for rows that don't (or when the embedder won't load).
4
+ //
5
+ // Nothing here knows what a row IS. A row is `{ text, vector, norm }` — whatever
6
+ // the app's shard put in it. That is why this file is in the library and the
7
+ // grouping rules that turn hits into results (series, episodes, cues) are not:
8
+ // those are the app's semantics, this is arithmetic.
9
+
10
+ /** Cosine of a row's vector against a query vector. `row.norm` is precomputed at
11
+ * parse time (see shard.js `toRow`), `qn` by the caller once per query — the
12
+ * scan runs per row, so neither belongs inside it.
13
+ *
14
+ * The shorter of the two lengths wins: vectors are matryoshka prefixes, so a
15
+ * 256-d query and a 768-d row still line up on their leading components. */
16
+ export const cosine = (row, qv, qn) => {
17
+ let d = 0;
18
+ for (let i = 0; i < qv.length && i < row.vector.length; i++) d += row.vector[i] * qv[i];
19
+ return d / (row.norm * qn);
20
+ };
21
+
22
+ /** L2 norm, for the query side. */
23
+ export const norm = (v) => { let n = 0; for (const x of v) n += x * x; return Math.sqrt(n) || 1; };
24
+
25
+ /**
26
+ * How far above the corpus mean a cosine has to sit to count as a hit.
27
+ *
28
+ * Raw nomic-256 cosine is offset per query, not per corpus: measured on the
29
+ * fangorn.tv catalog, EVERY row scores 0.38–0.65 against "panama", so `score > 0`
30
+ * admitted all twelve and the one right answer was buried in eleven near-misses
31
+ * that looked, by their printed scores, just as confident. Same measurement in z:
32
+ * the right row lands 2.4σ above that query's own mean, the plausible ones ~1.3σ,
33
+ * the rest below.
34
+ *
35
+ * Lexical scores keep a meaningful zero (a term either matched or it didn't), so
36
+ * this applies to the semantic half only.
37
+ */
38
+ export const Z_FLOOR = 1;
39
+
40
+ /** score ≥ mean + Z_FLOOR·σ, or -Infinity when there's no distribution to speak
41
+ * of (too few rows, or every row scored the same). */
42
+ export function zFloor(scores, z = Z_FLOOR) {
43
+ if (scores.length < 3) return -Infinity;
44
+ const mean = scores.reduce((a, b) => a + b, 0) / scores.length;
45
+ const sd = Math.sqrt(scores.reduce((s, x) => s + (x - mean) ** 2, 0) / scores.length);
46
+ return sd > 1e-6 ? mean + z * sd : -Infinity;
47
+ }
48
+
49
+ /**
50
+ * Maximal Marginal Relevance: pick the next-best row that is not already said.
51
+ *
52
+ * Ranking by score alone returns the same thing several times, because in an
53
+ * embedding space near-duplicates sit next to each other by construction. Ask
54
+ * for eight neighbours of Frostpunk and three of them are consecutive episodes
55
+ * of the same cartoon; build a taste from one film and five of the six
56
+ * recommendations are the same series. The list looks confident and says one
57
+ * thing.
58
+ *
59
+ * So: greedily take the row maximising `λ·score − (1−λ)·(closest already taken)`.
60
+ * λ=1 is the old behaviour. Lower trades a little relevance for a list that
61
+ * covers more ground, which for a recommendation IS the relevance.
62
+ *
63
+ * `ranked` is `[{ row, s }]` sorted desc, rows carrying `.vector` and `.norm`.
64
+ * Only the top `pool` are considered — beyond that the scores are noise and the
65
+ * arithmetic is O(limit · pool · dim), about 128k multiplies at the defaults.
66
+ */
67
+ export function diversify(ranked, { limit = 10, lambda = 0.7, key = "s" } = {}) {
68
+ if (lambda >= 1 || ranked.length <= 1) return ranked.slice(0, limit);
69
+ const pool = ranked.slice(0, Math.max(limit * 6, 30));
70
+ const out = [];
71
+ while (out.length < limit && pool.length) {
72
+ let at = 0, best = -Infinity;
73
+ for (let i = 0; i < pool.length; i++) {
74
+ let sim = 0;
75
+ // `cosine(a, b.vector, b.norm)` is dot/(a.norm·b.norm) — the same
76
+ // arithmetic as a query, with the other row standing in for one.
77
+ for (const p of out) {
78
+ if (!pool[i].row?.vector || !p.row?.vector) continue;
79
+ const c = cosine(pool[i].row, p.row.vector, p.row.norm);
80
+ if (c > sim) sim = c;
81
+ }
82
+ const val = lambda * pool[i][key] - (1 - lambda) * sim;
83
+ if (val > best) { best = val; at = i; }
84
+ }
85
+ out.push(pool.splice(at, 1)[0]);
86
+ }
87
+ return out;
88
+ }
89
+
90
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
91
+ const wordRe = (s) => new RegExp(`\\b${esc(s)}\\b`);
92
+
93
+ /**
94
+ * Word-boundary term scoring, normalized to 0..1 so it merges with cosine scores.
95
+ *
96
+ * This used to be a bare `text.includes(q)`, which meant a search for "cat"
97
+ * ranked "intoxi(cat)ed" and "edu(cat)ion" — every short query drowned in
98
+ * substring noise. Terms must now match whole words; the full phrase and an
99
+ * early position are bonuses on top.
100
+ *
101
+ * `q` is expected lowercased — the caller lowercases once per query, not per row.
102
+ */
103
+ export function lexScore(row, q) {
104
+ const text = String(row.text ?? "").toLowerCase();
105
+ if (!text) return 0;
106
+ const terms = q.split(/\s+/).filter(Boolean);
107
+ if (!terms.length) return 0;
108
+
109
+ let hits = 0;
110
+ for (const t of terms) if (wordRe(t).test(text)) hits++;
111
+ if (!hits) return 0;
112
+
113
+ const at = text.indexOf(q);
114
+ return Math.min(1,
115
+ 0.5 * (hits / terms.length) // how much of the query is present
116
+ + (wordRe(q).test(text) ? 0.35 : 0) // the whole phrase, intact
117
+ + (at >= 0 ? 0.15 * (1 - at / text.length) : 0)); // earlier beats later
118
+ }
119
+
120
+ /** Of a list of timestamped passages, the one a hit should quote and seek to:
121
+ * the line that best matches the query, or the first when nothing matches
122
+ * lexically (a semantic hit on a whole transcript has no single line to point
123
+ * at). Generic over `{ text }` — cues, chapters, log lines, commit messages. */
124
+ export function bestPassage(passages, ql) {
125
+ let best = passages[0], score = 0;
126
+ for (const c of passages) {
127
+ const s = lexScore({ text: c.text }, ql);
128
+ if (s > score) { score = s; best = c; }
129
+ }
130
+ return best;
131
+ }
132
+
133
+ /**
134
+ * Rank catalog entries against a lookahead vector, WITHOUT downloading them.
135
+ *
136
+ * The point is choosing what to pull before the bytes move: a view that watches
137
+ * something big can't be downloaded whole, and the only thing available to decide
138
+ * with is the coverage centroids the bake writes into catalog.json.
139
+ *
140
+ * A domain baked before coverage existed gets `affinity: null` and sorts last —
141
+ * NOT 0, which would claim it was measured and found unrelated.
142
+ */
143
+ export function rankDomains(domains = [], q) {
144
+ const qn = norm(q);
145
+ return domains
146
+ .map((d) => {
147
+ const c = d.coverage;
148
+ if (!c?.vectors?.length) return { ...d, affinity: null };
149
+ let best = -1;
150
+ for (const v of c.vectors) {
151
+ let dot = 0, vn = 0;
152
+ for (let i = 0; i < v.length && i < q.length; i++) { dot += v[i] * q[i]; vn += v[i] * v[i]; }
153
+ const score = dot / ((Math.sqrt(vn) || 1) * qn);
154
+ if (score > best) best = score;
155
+ }
156
+ return { ...d, affinity: best };
157
+ })
158
+ .sort((a, b) => (b.affinity ?? -Infinity) - (a.affinity ?? -Infinity));
159
+ }
160
+
161
+ // ── self-check: `node consume/rank.js` ──────────────────────────────────────
162
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
163
+ // Word boundaries: the bug this replaced ranked "intoxicated" for "cat".
164
+ if (lexScore({ text: "intoxicated education" }, "cat") !== 0) throw new Error("substring matched — word boundaries lost");
165
+ if (lexScore({ text: "a cat sat" }, "cat") <= 0) throw new Error("whole word must match");
166
+ // The intact phrase beats the same words scattered.
167
+ const intact = lexScore({ text: "cold war submarines" }, "cold war");
168
+ const apart = lexScore({ text: "cold nights, the war ended" }, "cold war");
169
+ if (!(intact > apart)) throw new Error(`phrase bonus lost: ${intact} vs ${apart}`);
170
+ // Regex metacharacters in a query are data, not syntax.
171
+ if (lexScore({ text: "what is c++" }, "c++") === undefined) throw new Error("must not throw on regex metachars");
172
+
173
+ // Cosine truncates to the shorter vector — matryoshka prefixes line up.
174
+ const row = { vector: [1, 0, 0, 0], norm: 1 };
175
+ if (Math.abs(cosine(row, [1, 0], 1) - 1) > 1e-9) throw new Error("cosine must truncate to the query");
176
+
177
+ // The floor is relative to the query's own distribution, which is the whole
178
+ // point — a corpus where everything scores 0.5 admits nothing.
179
+ if (zFloor([0.5, 0.5, 0.5, 0.5]) !== -Infinity) throw new Error("no spread must admit everything, not nothing");
180
+ if (zFloor([0.4, 0.4]) !== -Infinity) throw new Error("too few rows to have a distribution");
181
+ const f = zFloor([0.4, 0.41, 0.42, 0.9]);
182
+ if (!(0.9 >= f && 0.42 < f)) throw new Error(`floor did not separate the outlier: ${f}`);
183
+
184
+ // bestPassage quotes the matching line, and falls back to the first.
185
+ const cues = [{ text: "opening titles" }, { text: "the submarine dives" }];
186
+ if (bestPassage(cues, "submarine").text !== "the submarine dives") throw new Error("wrong passage quoted");
187
+ if (bestPassage(cues, "nothing here").text !== "opening titles") throw new Error("must fall back to the first passage");
188
+
189
+ // MMR: the same thing five times is one answer, not five.
190
+ {
191
+ const v = (x, y) => ({ vector: [x, y], norm: Math.sqrt(x * x + y * y) || 1 });
192
+ const ranked = [
193
+ { row: { id: "ep1", ...v(1, 0) }, s: 0.90 },
194
+ { row: { id: "ep2", ...v(1, 0.01) }, s: 0.89 },
195
+ { row: { id: "ep3", ...v(1, 0.02) }, s: 0.88 },
196
+ { row: { id: "other", ...v(0, 1) }, s: 0.70 },
197
+ ];
198
+ const plain = diversify(ranked, { limit: 2, lambda: 1 }).map((x) => x.row.id);
199
+ if (plain.join() !== "ep1,ep2") throw new Error(`lambda 1 must be the old behaviour: ${plain}`);
200
+ const mixed = diversify(ranked, { limit: 2, lambda: 0.7 }).map((x) => x.row.id);
201
+ if (mixed.join() !== "ep1,other") throw new Error(`MMR must not return the same episode twice: ${mixed}`);
202
+ // The best row is still first — diversity reorders the tail, never the head.
203
+ if (diversify(ranked, { limit: 4, lambda: 0.5 })[0].row.id !== "ep1") throw new Error("the top hit must survive");
204
+ // A row with no vector cannot be compared and must still be returnable.
205
+ if (diversify([{ row: { id: "a" }, s: 1 }], { limit: 3 }).length !== 1) throw new Error("vectorless rows must pass through");
206
+ }
207
+
208
+ // Uncovered domains sort last and say so — null, never 0.
209
+ const ranked = rankDomains([
210
+ { name: "old", coverage: null },
211
+ { name: "far", coverage: { vectors: [[0, 1]] } },
212
+ { name: "near", coverage: { vectors: [[1, 0]] } },
213
+ ], [1, 0]);
214
+ if (ranked.map((d) => d.name).join(",") !== "near,far,old") throw new Error(`domains misranked: ${ranked.map((d) => d.name)}`);
215
+ if (ranked[2].affinity !== null) throw new Error("an unmeasured domain must be null, not 0");
216
+
217
+ console.log("rank.js self-check ok — word boundaries, matryoshka cosine, z-floor, MMR drops near-duplicates without moving the top hit, passage pick, domain affinity");
218
+ }