@fangorn-network/westmarch 0.0.1-dev → 0.0.3-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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/consume/apps.js +4 -2
  3. package/consume/corpora.js +1 -1
  4. package/consume/directory.js +1 -1
  5. package/consume/embed.js +1 -1
  6. package/consume/lint.js +1 -1
  7. package/consume/mcp-bin.js +6 -0
  8. package/consume/mcp.js +193 -34
  9. package/consume/ort-preload.js +25 -0
  10. package/consume/rank.js +1 -1
  11. package/consume/roles.js +6 -4
  12. package/consume/shard.js +1 -1
  13. package/consume/steam.js +1 -1
  14. package/consume/taste-doc.js +1 -1
  15. package/consume/taste.js +1 -1
  16. package/consume/tools.js +28 -5
  17. package/consume/ui.js +1 -1
  18. package/consume/view-tools.js +202 -0
  19. package/consume/x402.js +94 -0
  20. package/dist/ts/eval.js +244 -0
  21. package/docs/app-to-agent.md +595 -185
  22. package/package.json +24 -10
  23. package/publish/agent-card.js +12 -2
  24. package/publish/cohort.js +1 -1
  25. package/publish/demand.js +1 -1
  26. package/publish/enrich.js +1 -1
  27. package/publish/envelope.js +1 -1
  28. package/publish/eval.ts +250 -0
  29. package/publish/graph.js +1 -1
  30. package/publish/manifest.js +1 -1
  31. package/publish/reactions.js +4 -2
  32. package/publish/settle.js +1 -1
  33. package/publish/ship.js +462 -0
  34. package/publish/terms.js +1 -1
  35. package/publish/vault.js +1 -1
  36. package/publish/view-cli.js +39 -0
  37. package/publish/view.js +434 -0
  38. package/publish/westmarch.d.ts +35 -0
  39. package/site/agent.js +44 -0
  40. package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
  41. package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
  42. package/site/dist/assets/index-CaHGhiKK.js +8 -0
  43. package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  44. package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
  45. package/site/dist/assets/roles-C9V_uDr_.js +1 -0
  46. package/site/dist/assets/roles-YDRjv_PY.js +1 -0
  47. package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  48. package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
  49. package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
  50. package/site/dist/index.html +20 -0
  51. package/site/feed.js +62 -0
  52. package/site/store.js +87 -0
  53. package/site/worker.js +103 -0
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @fangorn/westmarch
1
+ # @fangorn-network/westmarch
2
2
 
3
3
  Everything you need to get started building apps with Fangorn.
4
4
 
@@ -235,7 +235,7 @@ like: a short file that holds the constants still and re-exports the rest.
235
235
  ## What an app has to wire
236
236
 
237
237
  ```js
238
- import { configure } from "@fangorn/westmarch/shard";
238
+ import { configure } from "@fangorn-network/westmarch/shard";
239
239
 
240
240
  configure({
241
241
  // How to learn WHICH view to read. A callback, not a constant: a view id
package/consume/apps.js CHANGED
@@ -37,8 +37,10 @@ export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
37
37
  export function toApp({ card, appId, fromBlock, namespaces }, cardUrl) {
38
38
  const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
39
39
  const views = (Array.isArray(ext?.params?.views) ? ext.params.views : []).map(safeView).filter(Boolean);
40
+ const p = ext?.params?.paid;
41
+ const paid = p && /^https?:\/\//.test(p.url ?? "") && p.url.includes("{id}") && /^\d+$/.test(String(p.price)) ? p : null;
40
42
  return {
41
- appId, card: cardUrl, fromBlock, namespaces, views,
43
+ appId, card: cardUrl, fromBlock, namespaces, views, paid,
42
44
  name: card.name ?? appId,
43
45
  desc: card.description ?? "",
44
46
  url: safeView(card.url),
@@ -69,7 +71,7 @@ export async function listApps(fangorn, { fromBlock, toBlock } = {}) {
69
71
  }
70
72
 
71
73
  // ── self-check: `node consume/apps.js` ──────────────────────────────────────
72
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
74
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/apps.js")) {
73
75
  const j = (x) => JSON.stringify(x, (_, v) => (typeof v === "bigint" ? `${v}n` : v));
74
76
  const eq = (a, b, m) => { if (j(a) !== j(b)) throw new Error(`${m}: ${j(a)} != ${j(b)}`); };
75
77
  const A = "0x" + "a".repeat(64), B = "0x" + "b".repeat(64), C = "0x" + "c".repeat(64);
@@ -189,7 +189,7 @@ export function follow(picks = [], corpora = [], { at = null } = {}) {
189
189
  }
190
190
 
191
191
  // ── self-check: `node consume/corpora.js` ───────────────────────────────────
192
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
192
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/corpora.js")) {
193
193
  const s = session();
194
194
  const add = (view, name, n) => {
195
195
  const c = s.slot(view);
@@ -168,7 +168,7 @@ export async function sourcesFromChain(fangorn, opts) {
168
168
  }
169
169
 
170
170
  // ── self-check: `node consume/directory.js` ─────────────────────────────────
171
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
171
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/directory.js")) {
172
172
  const warn = console.warn; console.warn = () => {};
173
173
  const cov = (...vectors) => ({ dim: 2, sampled: 100, vectors, counts: vectors.map(() => 50) });
174
174
 
package/consume/embed.js CHANGED
@@ -214,7 +214,7 @@ export const warmEmbedder = () => {
214
214
  };
215
215
 
216
216
  // ── self-check: `node src/llm/embed.js` — quantization only, no model download ────
217
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
217
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/embed.js")) {
218
218
  const v = matryoshka(Array.from({ length: 768 }, (_, i) => Math.sin(i)));
219
219
  let norm = 0;
220
220
  for (const x of v) norm += x * x;
package/consume/lint.js CHANGED
@@ -306,7 +306,7 @@ export function format(r) {
306
306
  }
307
307
 
308
308
  // ── cli + self-check: `node consume/lint.js [viewUrl]` ─────────────────────
309
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
309
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/lint.js")) {
310
310
  const arg = process.argv.slice(2).find((a) => !a.startsWith("--"));
311
311
  if (arg) {
312
312
  console.log(format(await lint(arg, { rows: !process.argv.includes("--no-rows") })));
@@ -0,0 +1,6 @@
1
+ // Entry for the single-file `fangorn-mcp` (build-bin.js), not for node.
2
+ // Static imports run in order, so the library is loaded before the server
3
+ // starts. Not `await import("./mcp.js")`: bun 1.1 then wraps each module in a
4
+ // function, and their top-level awaits stop parsing.
5
+ import "./ort-preload.js";
6
+ import "./mcp.js";
package/consume/mcp.js CHANGED
@@ -24,15 +24,20 @@
24
24
  // dies is reopened on the next call.
25
25
 
26
26
  import { spawn } from "node:child_process";
27
- import { mkdirSync, readFileSync, existsSync, rmSync } from "node:fs";
27
+ import { mkdirSync, readFileSync, existsSync, rmSync, writeFileSync, renameSync } from "node:fs";
28
+ import { createHash } from "node:crypto";
29
+ import net from "node:net";
28
30
  import { homedir } from "node:os";
29
31
  import { createInterface } from "node:readline";
30
- import { APP_EXTENSION, listApps } from "./apps.js";
32
+ import { APP_EXTENSION, listApps, toApp } from "./apps.js";
31
33
 
32
34
  // The current AppRegistry's first event on Arbitrum Sepolia. A reader's
33
35
  // default, not deployment config: pass --from-block for another deployment.
34
36
  const DEFAULT_FROM_BLOCK = 311637349n;
35
37
  const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
38
+ // FANGORN_MCP_TRACE=1: step timings on stderr, for finding where an answer waits.
39
+ const T0 = performance.now();
40
+ const trace = process.env.FANGORN_MCP_TRACE ? (what) => log(`${Math.round(performance.now() - T0)}ms ${what}`) : () => {};
36
41
 
37
42
  let client;
38
43
  async function fangorn() {
@@ -40,6 +45,15 @@ async function fangorn() {
40
45
  const [{ Fangorn, FangornConfig }, { generatePrivateKey }] = await Promise.all([
41
46
  import("@fangorn-network/sdk"), import("viem/accounts")]);
42
47
  client = Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig }); // reads only
48
+ // list-apps verifies every card and open-app verifies one again: once a minute is enough.
49
+ const verify = client.discoverApp.bind(client), seen = new Map();
50
+ client.discoverApp = (url) => {
51
+ const hit = seen.get(url);
52
+ if (hit && Date.now() - hit.at < 60_000) return hit.p;
53
+ const p = verify(url).catch((e) => { seen.delete(url); throw e; });
54
+ seen.set(url, { at: Date.now(), p });
55
+ return p;
56
+ };
43
57
  }
44
58
  return client;
45
59
  }
@@ -49,10 +63,69 @@ async function fangorn() {
49
63
  async function resolveCard(cardUrl) {
50
64
  const card = await (await fetch(cardUrl)).json();
51
65
  const fangornApp = card.capabilities?.extensions?.some((e) => e.uri === APP_EXTENSION);
52
- if (fangornApp) await (await fangorn()).discoverApp(cardUrl);
66
+ // Views only from a verified card, and only http(s) ones (toApp filters).
67
+ const app = fangornApp ? toApp(await (await fangorn()).discoverApp(cardUrl), cardUrl) : null;
53
68
  const page = new URL(card.url);
54
69
  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 };
70
+ return { card, page: page.toString(), verified: fangornApp, views: app?.views ?? [], paid: app?.paid ?? null };
71
+ }
72
+
73
+ // ── the network ─────────────────────────────────────────────────────────────
74
+ // Every byte an answer waits on, cached where it is safe to:
75
+ // - a content-addressed file (`…-<12 hex>.<ext>`, every shard) never changes,
76
+ // so it is kept on disk forever. shard.js still checks it against the
77
+ // manifest's sha256, so a bad cache entry fails loudly, not silently.
78
+ // - catalogs, manifests and cards change on a rebake: held for a minute, so
79
+ // one search (or resolving then verifying a card) does not fetch each twice.
80
+ const CACHE = `${homedir()}/.cache/fangorn-mcp`;
81
+ /** Stale-while-revalidate from disk: the last copy at once, a fresh one fetched
82
+ * behind it for next time. Only for reads where stale is harmless. */
83
+ function staleFetch(fetcher) {
84
+ mkdirSync(`${CACHE}/stale`, { recursive: true });
85
+ return async (url) => {
86
+ const f = `${CACHE}/stale/${createHash("sha256").update(url).digest("hex")}`;
87
+ const refresh = fetcher(url).then(async (r) => {
88
+ if (!r.ok) return r;
89
+ const b = new Uint8Array(await r.arrayBuffer());
90
+ writeFileSync(`${f}.tmp`, b); renameSync(`${f}.tmp`, f);
91
+ return new Response(b);
92
+ });
93
+ if (existsSync(f)) { refresh.catch(() => {}); return new Response(readFileSync(f)); }
94
+ return refresh;
95
+ };
96
+ }
97
+
98
+ function cachingFetch(real = globalThis.fetch) {
99
+ mkdirSync(`${CACHE}/blobs`, { recursive: true });
100
+ const memo = new Map();
101
+ return async (input, init) => {
102
+ const url = String(input?.url ?? input);
103
+ if ((init?.method ?? input?.method ?? "GET") !== "GET" || !/^https?:/.test(url)) return real(input, init);
104
+ const path = new URL(url).pathname;
105
+ if (/-[0-9a-f]{12}\.[a-z.]+$/.test(path)) {
106
+ const f = `${CACHE}/blobs/${createHash("sha256").update(url).digest("hex")}`;
107
+ if (existsSync(f)) return new Response(readFileSync(f));
108
+ const r = await real(input, init);
109
+ if (!r.ok) return r;
110
+ const b = new Uint8Array(await r.arrayBuffer());
111
+ writeFileSync(`${f}.tmp`, b); renameSync(`${f}.tmp`, f); // never a torn file
112
+ return new Response(b, { headers: r.headers });
113
+ }
114
+ // A card, catalog or manifest is small: 8s without one means the host is
115
+ // down, and a dead card in the directory must not stall a listing for a
116
+ // TCP timeout. Shards get no deadline; a big file on a slow link is legitimate.
117
+ init = { ...init, signal: init?.signal ?? AbortSignal.timeout(8000) };
118
+ if (/\/cdn\/catalog$|\/manifest$|\/agent-card\.json$/.test(path)) {
119
+ const hit = memo.get(url);
120
+ if (hit && Date.now() - hit.at < 60_000) return new Response(hit.body, { headers: hit.headers });
121
+ const r = await real(input, init);
122
+ if (!r.ok) return r;
123
+ const body = new Uint8Array(await r.arrayBuffer());
124
+ memo.set(url, { at: Date.now(), body, headers: r.headers });
125
+ return new Response(body, { headers: r.headers });
126
+ }
127
+ return real(input, init);
128
+ };
56
129
  }
57
130
 
58
131
  // ── the browser ─────────────────────────────────────────────────────────────
@@ -93,7 +166,8 @@ async function localChrome({ headed }) {
93
166
  ], { stdio: "ignore" });
94
167
  const failed = new Promise((_, j) => proc.on("error", (e) => j(new Error(
95
168
  `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++) {
169
+ // 30s: a cold start on a loaded machine or CI box has taken 16s.
170
+ for (let i = 0; i < 300; i++) {
97
171
  await Promise.race([new Promise((r) => setTimeout(r, 100)), failed]);
98
172
  if (existsSync(portFile)) return { ws: await browserWs(`http://127.0.0.1:${readFileSync(portFile, "utf-8").split("\n")[0]}`), proc };
99
173
  }
@@ -211,8 +285,8 @@ const META = [
211
285
  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
286
  inputSchema: { type: "object", properties: { query: { type: "string", description: "Case-insensitive match on name or description" } } } },
213
287
  { 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"] } },
288
+ description: "Verify an app's card against the chain and add its tools to this server as `<app>__<tool>`. An app that publishes views gets fast data tools (describe, search, get, similar, count, browse) with no browser. `page: true` also opens the app's page in a browser for its own WebMCP tools (slower). `app` is a name from list-apps, an appId, or a card URL.",
289
+ inputSchema: { type: "object", properties: { app: { type: "string" }, page: { type: "boolean", description: "Also load the page's own WebMCP tools in a browser" } }, required: ["app"] } },
216
290
  { name: "call-app-tool",
217
291
  description: "Call one tool of an opened app. Same as calling `<app>__<tool>` directly.",
218
292
  inputSchema: { type: "object", properties: { app: { type: "string" }, tool: { type: "string" }, arguments: { type: "object" } }, required: ["app", "tool"] } },
@@ -221,46 +295,73 @@ const META = [
221
295
  /**
222
296
  * The server. `deps` is the outside world, so the self-check can fake it:
223
297
  * findApps() → { apps: [{ name, desc, card, appId }], rejected }
224
- * resolveCard(url) → { card, page, verified }
298
+ * findByName(name) → { card, appId } | null (optional: a direct registry read)
299
+ * resolveCard(url) → { card, page, verified, views }
300
+ * viewTools(app) → { tools, call(name, args) } | null (no browser)
225
301
  * openTab(page) → ev
226
302
  * tabTools(ev), tabCall(ev, name, args)
227
303
  * notify(method) set after serve()
228
304
  */
229
305
  export function handlers(deps) {
230
- const open = new Map(); // slug → { name, card, verified, ev, tools }
306
+ const open = new Map(); // slug → { name, card, verified, page, views?, pages?, tools }
231
307
 
232
308
  const findOpen = (app) => open.get(slug(app)) ?? [...open.values()].find((o) => o.card === app || o.appId === app);
309
+ const retool = (o) => {
310
+ const taken = new Set(o.views?.tools.map((t) => t.name));
311
+ // A page tool that shares a data tool's name is kept, under `page-<name>`.
312
+ o.pageNames = new Map((o.pageTools ?? []).map((t) => [taken.has(t.name) ? `page-${t.name}` : t.name, t.name]));
313
+ o.tools = [...(o.views?.tools ?? []), ...(o.pageTools ?? []).map((t) => ({ ...t, name: taken.has(t.name) ? `page-${t.name}` : t.name }))];
314
+ };
315
+ async function openPage(o) {
316
+ o.ev = await deps.openTab(o.page);
317
+ o.pageTools = await deps.tabTools(o.ev);
318
+ }
233
319
 
234
- async function openApp(app) {
320
+ async function openApp(app, { page = false } = {}) {
235
321
  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);
322
+ if (hit && (hit.ev || !page)) return hit;
323
+ let o = hit;
324
+ if (!o) {
325
+ let cardUrl = app, appId;
326
+ if (!/^https?:\/\//.test(app)) {
327
+ // One registry read when the name is the app's own; the scan only when it is not.
328
+ trace(`open ${app}: lookup`);
329
+ const a = await deps.findByName?.(app)
330
+ ?? (await deps.findApps()).apps.find((x) => x.appId === app || slug(x.name) === slug(app));
331
+ if (!a) throw new Error(`no verified app named ${JSON.stringify(app)}; see list-apps`);
332
+ ({ card: cardUrl, appId } = a);
333
+ }
334
+ trace(`open ${app}: resolve ${cardUrl}`);
335
+ const { card, page: url, verified, views, paid } = await deps.resolveCard(cardUrl);
336
+ trace(`open ${app}: verified`);
337
+ let s = slug(card.name ?? app);
338
+ while (open.has(s)) s += "-";
339
+ o = { slug: s, name: card.name, card: cardUrl, appId, verified, page: url,
340
+ views: deps.viewTools?.({ name: card.name, desc: card.description, views, paid }) ?? null };
243
341
  }
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);
342
+ // The browser only when asked, or when there is no other way in.
343
+ if (page || !o.views) await openPage(o);
344
+ retool(o);
345
+ trace(`open ${app}: ready`);
346
+ open.set(o.slug, o);
250
347
  deps.notify?.("notifications/tools/list_changed");
251
- return entry;
348
+ return o;
252
349
  }
253
350
 
254
351
  async function call(app, tool, args) {
255
352
  const o = findOpen(app);
256
353
  if (!o) throw new Error(`${app} is not open; call open-app first`);
257
- try { return await deps.tabCall(o.ev, tool, args); }
354
+ if (!o.pageNames?.has(tool)) {
355
+ if (o.views?.tools.some((t) => t.name === tool)) return o.views.call(tool, args);
356
+ throw new Error(`${o.slug} has no tool ${tool}${o.ev ? "" : " (its page tools load with open-app page: true)"}`);
357
+ }
358
+ const name = o.pageNames.get(tool);
359
+ try { return await deps.tabCall(o.ev, name, args); }
258
360
  catch (e) {
259
361
  if (!/tab closed/.test(e.message)) throw e;
260
362
  // 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);
363
+ await openPage(o);
364
+ return deps.tabCall(o.ev, name, args);
264
365
  }
265
366
  }
266
367
 
@@ -276,9 +377,10 @@ export function handlers(deps) {
276
377
  unverified: rejected.length,
277
378
  });
278
379
  },
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,
380
+ "open-app": async ({ app, page }) => {
381
+ const o = await openApp(app, { page });
382
+ return text({ app: o.slug, name: o.name, verified: o.verified,
383
+ data_tools: !!o.views, page_tools: o.ev ? o.page : false,
282
384
  call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools });
283
385
  },
284
386
  "call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
@@ -289,7 +391,7 @@ export function handlers(deps) {
289
391
  protocolVersion: p.protocolVersion ?? "2025-06-18",
290
392
  capabilities: { tools: { listChanged: true } },
291
393
  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>.",
394
+ instructions: "Fangorn apps are static sites anchored on chain. list-apps to find one, open-app to load it, then call its tools as <app>__<tool>. Start with <app>__describe when it exists.",
293
395
  }),
294
396
  ping: async () => ({}),
295
397
  "tools/list": async () => ({ tools: [
@@ -349,11 +451,40 @@ if (process.argv[2] === "--selfcheck") {
349
451
  assert(body(8).args.who === "a" && body(9).name === "greet", "both call paths reach the tab");
350
452
  assert(res(10).result.isError && res(11).result.isError, "failures are tool errors");
351
453
  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>");
454
+
455
+ // An app with views: data tools, no browser, until page: true.
456
+ {
457
+ const out2 = new PassThrough(), in2 = new PassThrough(), r2 = [];
458
+ createInterface({ input: out2 }).on("line", (l) => r2.push(JSON.parse(l)));
459
+ let tabs = 0;
460
+ const d2 = { ...deps,
461
+ resolveCard: async () => ({ card: { name: "Kings Foil" }, page: "https://k.test/p", verified: true, views: ["https://k.test/v"] }),
462
+ viewTools: (app) => ({ tools: [{ name: "search" }, { name: "greet" }], call: async (n, a) => text({ via: "views", n, a, views: app.views }) }),
463
+ openTab: async () => (tabs++, "ev"),
464
+ };
465
+ d2.notify = serve(handlers(d2), { input: in2, output: out2 });
466
+ const q = (id, name, args) => in2.write(JSON.stringify({ jsonrpc: "2.0", id, method: "tools/call", params: { name, arguments: args } }) + "\n");
467
+ const got = (id) => r2.find((r) => r.id === id)?.result;
468
+ q(1, "open-app", { app: "Kings Foil" }); await wait(); q(2, "kings-foil__search", { query: "x" }); await wait();
469
+ assert(tabs === 0 && JSON.parse(got(1).content[0].text).data_tools, "views mean no browser");
470
+ assert(JSON.parse(got(2).content[0].text).via === "views", "data tools answer from the views");
471
+ q(3, "open-app", { app: "kings-foil", page: true }); await wait();
472
+ const names = JSON.parse(got(3).content[0].text).tools.map((t) => t.name);
473
+ assert(tabs === 1 && names.includes("search") && names.includes("greet") && names.includes("page-greet"), `page: true adds page tools, a clash renamed: ${names}`);
474
+ q(4, "kings-foil__page-greet", { who: "p" }); q(5, "kings-foil__greet", {}); await wait();
475
+ assert(JSON.parse(got(4).content[0].text).ev === "ev" && JSON.parse(got(5).content[0].text).via === "views", "each name reaches its own side");
476
+ }
477
+ console.log("mcp.js self-check ok — one server, apps opened on demand, data tools without a browser, page tools on request");
353
478
  process.exit(0);
354
479
  }
355
480
 
356
481
  const argv = process.argv.slice(2);
482
+ globalThis.fetch = cachingFetch();
483
+ // Node gives each address 250ms before trying the next; on a slow link, or a
484
+ // host with no working IPv6, every attempt times out and fetch fails outright.
485
+ net.setDefaultAutoSelectFamilyAttemptTimeout?.(2000);
486
+ const vt = await import("./view-tools.js");
487
+ const stale = staleFetch(globalThis.fetch);
357
488
  const fb = argv.indexOf("--from-block");
358
489
  const fromBlock = fb >= 0 ? BigInt(argv[fb + 1]) : DEFAULT_FROM_BLOCK;
359
490
  const headed = argv.includes("--headed");
@@ -373,9 +504,37 @@ const deps = {
373
504
  }
374
505
  return found;
375
506
  },
507
+ findByName: async (name) => {
508
+ const [f, { appId }] = await Promise.all([fangorn(), import("@fangorn-network/sdk")]);
509
+ const reg = f.getAppRegistry();
510
+ for (const id of /^0x[0-9a-fA-F]{64}$/.test(name) ? [name] : [...new Set([name, name.toLowerCase()])].map(appId)) {
511
+ const card = await reg.appAgentUri(id).catch(() => "");
512
+ if (card) return { card, appId: id };
513
+ }
514
+ return null;
515
+ },
376
516
  resolveCard,
517
+ viewTools: (app) => {
518
+ const v = vt.viewTools(app, { fetchCatalog: stale });
519
+ if (v) vt.warm({ cacheDir: `${CACHE}/models` }); // already started at initialize; this is the fallback
520
+ return v;
521
+ },
377
522
  openTab: (page) => openTab(page, { headed, cdp }),
378
523
  tabTools, tabCall,
379
524
  };
380
- deps.notify = serve(handlers(deps));
525
+ const h = handlers(deps);
526
+ // The client starts this server with the session, long before an agent opens an
527
+ // app: load the SDK and the embedding model then, behind the agent's first
528
+ // thought. After the initialize reply, not before: both block the thread while
529
+ // they load, and the client is waiting on that reply.
530
+ const reply = h.initialize;
531
+ h.initialize = async (p) => {
532
+ setImmediate(() => {
533
+ fangorn().then(() => trace("sdk ready"), () => {});
534
+ vt.warm({ cacheDir: `${CACHE}/models` }).then((ok) => ok ? trace("embedder ready")
535
+ : log(`embedder unavailable, searching by words: ${vt.warmError}`));
536
+ });
537
+ return reply(p);
538
+ };
539
+ deps.notify = serve(h);
381
540
  log(`ready: apps from block ${fromBlock}; browser ${cdp ?? "local Chrome"}, connected on the first open-app`);
@@ -0,0 +1,25 @@
1
+ // Loads onnxruntime's library for the single-file `fangorn-mcp` (mcp-bin.js).
2
+ //
3
+ // bun embeds onnxruntime's binding and extracts it to a temp dir at run time,
4
+ // but not the library the binding links against, so the embedder fails to load.
5
+ // This embeds that library too, writes it to the cache once, and loads it before
6
+ // anything imports transformers: the binding then finds it already loaded, by name.
7
+ // A failure here only costs the embedder, and search falls back to words.
8
+ import { dlopen } from "bun:ffi";
9
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ // build-bin.js puts the target's library here before it compiles.
12
+ import lib from "../dist/ort/libonnxruntime.so.1" with { type: "file" };
13
+
14
+ try {
15
+ const dir = `${homedir()}/.cache/fangorn-mcp/lib/${lib.split("/").pop()}`; // bun names it by content hash
16
+ const path = `${dir}/libonnxruntime.so.1`;
17
+ if (!existsSync(path)) {
18
+ mkdirSync(dir, { recursive: true });
19
+ writeFileSync(`${path}.${process.pid}`, readFileSync(lib)); // copyFile cannot read bun's embedded fs
20
+ renameSync(`${path}.${process.pid}`, path);
21
+ }
22
+ dlopen(path, { OrtGetApiBase: { args: [], returns: "ptr" } }); // bun wants one symbol; this is the lib's entry point
23
+ } catch (e) {
24
+ console.error(`[fangorn-mcp] onnxruntime not loaded, search will rank by words: ${e.message}`);
25
+ }
package/consume/rank.js CHANGED
@@ -159,7 +159,7 @@ export function rankDomains(domains = [], q) {
159
159
  }
160
160
 
161
161
  // ── self-check: `node consume/rank.js` ──────────────────────────────────────
162
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
162
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/rank.js")) {
163
163
  // Word boundaries: the bug this replaced ranked "intoxicated" for "cat".
164
164
  if (lexScore({ text: "intoxicated education" }, "cat") !== 0) throw new Error("substring matched — word boundaries lost");
165
165
  if (lexScore({ text: "a cat sat" }, "cat") <= 0) throw new Error("whole word must match");
package/consume/roles.js CHANGED
@@ -66,7 +66,7 @@ export function rolesFrom(manifests = [], sample = []) {
66
66
  const seen = new Set();
67
67
  for (const r of sample) for (const k of Object.keys(r)) seen.add(k);
68
68
 
69
- const title = [], subtitle = [], tags = [], text = [], measures = [], spatial = [], media = [];
69
+ const title = [], subtitle = [], tags = [], text = [], measures = [], spatial = [], media = [], temporal = [], facets = [];
70
70
  const labels = {}, types = {}, fieldLabels = {}, externalUrl = {}, actions = {};
71
71
  let launch = null, refers = null;
72
72
  const entityTypes = [];
@@ -78,7 +78,7 @@ export function rolesFrom(manifests = [], sample = []) {
78
78
  for (const m of manifests) {
79
79
  const rm = m?.role_map ?? {};
80
80
  push(title, rm.title); push(subtitle, rm.subtitle); push(tags, rm.tags);
81
- push(text, rm.text); push(measures, rm.measures); push(spatial, rm.spatial); push(media, rm.media);
81
+ push(text, rm.text); push(measures, rm.measures); push(spatial, rm.spatial); push(media, rm.media); push(temporal, rm.temporal);
82
82
  identity ??= rm.identity ?? null;
83
83
  // Where a row in THIS corpus points, in someone else's.
84
84
  //
@@ -101,6 +101,8 @@ export function rolesFrom(manifests = [], sample = []) {
101
101
  Object.assign(fieldLabels, pr.fieldLabels ?? {});
102
102
  Object.assign(externalUrl, pr.externalUrl ?? {});
103
103
  Object.assign(actions, pr.actions ?? {});
104
+ // The fields a reader narrows by first (a town, a genre): the app's to name.
105
+ push(facets, pr.facets);
104
106
  // The app's own interface, if it ships one. A url, not markup: it is a
105
107
  // separate document with a separate origin, which is the only way a
106
108
  // stranger's UI can be run at all.
@@ -135,7 +137,7 @@ export function rolesFrom(manifests = [], sample = []) {
135
137
  guess(text, GUESS.text);
136
138
 
137
139
  return {
138
- title, subtitle, tags, text, measures, spatial, media, identity,
140
+ title, subtitle, tags, text, measures, spatial, media, temporal, facets, identity,
139
141
  labels, types, fieldLabels, externalUrl, actions, launch, refers, entityTypes,
140
142
  // What this view sells, if anything. A list because a fused view can pull
141
143
  // domains from several publishers and they price independently.
@@ -365,7 +367,7 @@ export function actionsOf(row, roles) {
365
367
  }
366
368
 
367
369
  // ── self-check: `node consume/roles.js` ─────────────────────────────────────
368
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
370
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/roles.js")) {
369
371
  // The real places manifest's shape.
370
372
  const places = {
371
373
  role_map: {
package/consume/shard.js CHANGED
@@ -438,7 +438,7 @@ export async function suggestDomains(q, { url } = {}) {
438
438
  }
439
439
 
440
440
  // ── self-check: `node consume/shard.js` — no network, no view ────────────────
441
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
441
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/shard.js")) {
442
442
  const { gzipSync } = await import("node:zlib");
443
443
  const warn = console.warn, info = console.info;
444
444
  console.warn = console.info = () => {}; // the fallback warnings are behaviour under test, not output
package/consume/steam.js CHANGED
@@ -237,7 +237,7 @@ export const summarize = (seed) =>
237
237
  `${seed.hours} hours · ${seed.likes.length} liked, ${seed.dislikes.length} bounced off`;
238
238
 
239
239
  // ── self-check: `node consume/steam.js` ─────────────────────────────────────
240
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
240
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/steam.js")) {
241
241
  // A localconfig.vdf, cut down but structurally exact — including the mixed
242
242
  // casing of the Valve/valve segment that defeats a hardcoded path.
243
243
  const VDF = `
@@ -236,7 +236,7 @@ export function tasteDoc({ t = null, catalogues = [], instructions = "", now = n
236
236
  }
237
237
 
238
238
  // ── self-check: `node consume/taste-doc.js` ─────────────────────────────────
239
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
239
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/taste-doc.js")) {
240
240
  const { taste } = await import("./taste.js");
241
241
 
242
242
  // A catalogue with a KNOWN shape: two axes, and a tag at each end of each.
package/consume/taste.js CHANGED
@@ -212,7 +212,7 @@ export function withProvenance(t, { carried = null, likes = [], dislikes = [] }
212
212
  }
213
213
 
214
214
  // ── self-check: `node consume/taste.js` ─────────────────────────────────────
215
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
215
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/taste.js")) {
216
216
 
217
217
  // recommend() returns {row, score} — NOT {row, s}. kingsfoil's agent read
218
218
  // h.s and reported every recommendation with score: undefined.
package/consume/tools.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // Pure functions, so `node tools.js` checks them with no browser and no network.
13
13
 
14
14
  import { bestPassage, cosine, lexScore, norm, zFloor } from "./rank.js";
15
- import { briefOf, collections, linkOf, titleOf, typeOf, values } from "./roles.js";
15
+ import { briefOf, collections, linkOf, subtitleOf, titleOf, typeOf, values } from "./roles.js";
16
16
 
17
17
  /** Fields that are plumbing rather than content — never worth faceting or
18
18
  * showing as "what this corpus holds". */
@@ -72,7 +72,27 @@ export function describe(rows) {
72
72
  * `qv` is passed in, not computed: embedding is async and model-shaped, and
73
73
  * keeping it out here is what lets this file be a pure-function self-check.
74
74
  */
75
- export function search(rows, query, roles, { qv = null, limit = 10, fields, where } = {}) {
75
+ /** How much an exact word match adds to a cosine. Meaning alone ranks "wisconsin rapids"
76
+ * by what the towns have in common (every row is in Wisconsin), so an Eau Claire item
77
+ * about a bike ride "across Wisconsin" beat every Wisconsin Rapids one. The words decide
78
+ * among rows that mean about the same thing; they never outvote meaning outright.
79
+ * Measured on Quorum's eval (quorum/eval/search.mjs, 11k rows), top-5 precision / known @1:
80
+ * 0 → towns 92%, topics 88%, @1 91%; 0.1 → 100/94/97; 0.15 → 100/98/98.5; 0.3 → 100/98/97. */
81
+ export const LEX_BOOST = 0.15;
82
+
83
+ // The words a row can be found by: its title, subtitle and tags, not only its prose.
84
+ // A town, a body or a kind lives in the tags, and "Plover" should find Plover's items.
85
+ const lexCache = new WeakMap();
86
+ const lexText = (r, roles) => {
87
+ let t = lexCache.get(r);
88
+ if (t === undefined) {
89
+ t = [titleOf(r, roles), subtitleOf(r, roles) ?? "", ...(roles.tags ?? []).flatMap((f) => values(r[f] ?? "")), r.text ?? ""].join(" ");
90
+ lexCache.set(r, t);
91
+ }
92
+ return t;
93
+ };
94
+
95
+ export function search(rows, query, roles, { qv = null, limit = 10, fields, where, lexBoost = LEX_BOOST } = {}) {
76
96
  const ql = query.trim().toLowerCase();
77
97
  if (!ql) return [];
78
98
  const qn = qv ? norm(qv) : 1;
@@ -84,8 +104,8 @@ export function search(rows, query, roles, { qv = null, limit = 10, fields, wher
84
104
  // ever answers the question the way it was asked.
85
105
  if (where) rows = rows.filter((r) => matches(r, where));
86
106
  const scored = rows.map((r) => (qv && r.vector
87
- ? { r, score: cosine(r, qv, qn), mode: "semantic" }
88
- : { r, score: lexScore(r, ql), mode: "lexical" }));
107
+ ? { r, score: cosine(r, qv, qn) + (lexBoost ? lexBoost * lexScore({ text: lexText(r, roles) }, ql) : 0), mode: "semantic" }
108
+ : { r, score: lexScore({ text: lexText(r, roles) }, ql), mode: "lexical" }));
89
109
  // The floor is per QUERY over the whole corpus, so it is measured before
90
110
  // anything is dropped. Raw cosine is offset per query, not per corpus: on this
91
111
  // catalog every row scores 0.38–0.65 against anything, and `score > 0` admits
@@ -200,7 +220,10 @@ export function getRow(rows, id, roles) {
200
220
  // id first, then whatever the publisher calls a title — an agent that read a
201
221
  // title off a search result and passed it back must land on the same row.
202
222
  if (!id) return null; // else `x.path === undefined` matches the first row lacking one
223
+ // A view built from commits keys rows by vertex CID, so the publisher's own id
224
+ // (the declared identity field) has to find them too.
203
225
  const r = rows.find((x) => x.id === id)
226
+ ?? (roles?.identity ? rows.find((x) => String(x[roles.identity]) === String(id)) : null)
204
227
  ?? rows.find((x) => x.path === id || x.name === id || titleOf(x, roles) === id);
205
228
  if (!r) return null;
206
229
  const { vector, norm: _n, ...rest } = r;
@@ -208,7 +231,7 @@ export function getRow(rows, id, roles) {
208
231
  }
209
232
 
210
233
  // ── self-check: `node tools.js` — no browser, no network ────────────────────
211
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
234
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/tools.js")) {
212
235
  const { rolesFrom } = await import("./roles.js");
213
236
  const row = (id, fields, vector) => ({
214
237
  id, owner: "0xa", ...fields,
package/consume/ui.js CHANGED
@@ -505,7 +505,7 @@ export function stage({ picks = [], heading = "", note = "" } = {}) {
505
505
  }
506
506
 
507
507
  // ── self-check: `node consume/ui.js` ────────────────────────────────────────
508
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
508
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/ui.js")) {
509
509
  const roles = { measures: ["rating", "year", "runtime", "votes"] };
510
510
  const html = rankedList({
511
511
  heading: 'search "frozen"', note: "2 corpora",