@fangorn-network/westmarch 0.0.5-dev → 0.0.7-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/README.md CHANGED
@@ -5,6 +5,9 @@ Everything you need to get started building apps with Fangorn.
5
5
  **Start here:** [docs/app-to-agent.md](docs/app-to-agent.md) takes a static site from nothing
6
6
  to a Fangorn app and ERC-8004 agent that any agent can discover, verify and drive.
7
7
 
8
+ **Just want to query apps from Claude or another MCP client?** [docs/mcp.md](docs/mcp.md)
9
+ installs `fangorn-mcp` in one command.
10
+
8
11
 
9
12
  **For Publishers** — shape a tree into a commit graph, describe what's in it, seal and upload the bytes. Needs `fangorn`. Runs anywhere: a script, a server, a browser.
10
13
 
package/consume/embed.js CHANGED
@@ -24,7 +24,11 @@ export const EMBED_DIM = DIM;
24
24
  let _extractor = null;
25
25
  const extractor = () => (_extractor ??= (async () => {
26
26
  const { pipeline } = await import("@huggingface/transformers");
27
- return pipeline("feature-extraction", MODEL, { dtype: "q8" });
27
+ // WESTMARCH_EMBED_DEVICE=cuda puts the publisher's document side on a GPU (Node only; needs
28
+ // onnxruntime-node's CUDA binaries). Still q8 at batch 1, so still the same encoder: its
29
+ // vectors agree with CPU q8 to ~0.99, closer than the browser's WASM q8 queries (~0.98).
30
+ const device = globalThis.process?.env?.WESTMARCH_EMBED_DEVICE;
31
+ return pipeline("feature-extraction", MODEL, { dtype: "q8", ...(device && { device }) });
28
32
  })());
29
33
 
30
34
  /** Standardize over the full vector, slice to `dim`, L2-normalize the slice. */
@@ -0,0 +1,243 @@
1
+ // The Fangorn explorer: every verified app, what it is about, and what the
2
+ // ERC-8004 registries say about it — read live, with no bake of its own.
3
+ //
4
+ // Everything here is already published by someone else:
5
+ // AppRegistry `AppAgentChanged` which apps exist and where their cards are
6
+ // each card name, tools, views; checked against the chain
7
+ // each view's catalog rows, domains, and coverage centroids (what it is about)
8
+ // ERC-8004 identity `Registered` the agent minted for the app
9
+ // ERC-8004 reputation feedback count and average per agent
10
+ //
11
+ // Both registries answer the whole range in one `eth_getLogs` on Arbitrum
12
+ // Sepolia (39 and ~1.5k logs today), so the page and the MCP tool read the same
13
+ // chain the same way, and a new app shows up on the next read.
14
+ //
15
+ // `client` is a viem PublicClient (getLogs, readContract): passed in, so this
16
+ // runs in a tab and in node, and the self-check needs no chain.
17
+ //
18
+ // ponytail: one getLogs per registry from block 0. An RPC that caps log ranges
19
+ // will refuse it; window the scan then (the SDK's getLogsInWindows).
20
+
21
+ import { parseAbi, parseAbiItem } from "viem";
22
+ import { APP_EXTENSION } from "./apps.js";
23
+ import { survey, comparable } from "./directory.js";
24
+ import { rankDomains, zFloor, lexScore } from "./rank.js";
25
+
26
+ export const CHAIN = {
27
+ chainId: 421614,
28
+ appRegistry: "0x11d228c4774af3d9cae3b4b6874a12576a1a83ec",
29
+ identity: "0x8004A818BFB912233c491871b3d84c89A494BD9e",
30
+ reputation: "0x8004B663056A597Dffe9eCcC1965A193B7388713",
31
+ scan: (agentId) => `https://8004scan.io/agents/arbitrum-sepolia/${agentId}`,
32
+ };
33
+
34
+ const APP_AGENT_CHANGED = parseAbiItem("event AppAgentChanged(bytes32 indexed app_id, string agent_uri)");
35
+ const REGISTERED = parseAbiItem("event Registered(uint256 indexed agentId, string agentURI, address indexed owner)");
36
+ const APPS = parseAbi(["function getAppOwner(bytes32) view returns (address)"]);
37
+ const REP = parseAbi([
38
+ "function getClients(uint256) view returns (address[])",
39
+ "function getSummary(uint256, address[], string, string) view returns (uint64, int128, uint8)",
40
+ ]);
41
+
42
+ /** A link someone else wrote, shown to a reader: https or nothing. */
43
+ const https = (u) => { try { const x = new URL(String(u)); return x.protocol === "https:" ? x.href : null; } catch { return null; } };
44
+ const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "app";
45
+
46
+ /** A card is listed only if it names this chain's AppRegistry and the app that
47
+ * bound it — the checks `discoverApp` makes. The binding itself is the log we
48
+ * read it from: we scanned to `latest`, so the last write per app is current. */
49
+ export function verifyCard(card, appId, chain = CHAIN) {
50
+ const p = card?.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION)?.params;
51
+ if (!p) return "no fangorn app extension";
52
+ if (String(p.appId).toLowerCase() !== appId.toLowerCase()) return "card names another app";
53
+ if (p.chainId !== chain.chainId || String(p.appRegistry).toLowerCase() !== chain.appRegistry.toLowerCase()) return "card is for another registry";
54
+ if (!/^\d+$/.test(String(p.fromBlock))) return "bad fromBlock";
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * The agent an app registered. `fangorn app agent` mints it and then binds the
60
+ * card, from the owner's wallet, so it is the owner's latest `Registered` at or
61
+ * before the binding.
62
+ *
63
+ * ponytail: paired by owner and block, not by reading the registration file
64
+ * (IPFS gateways are too slow and flaky for a page load). An owner that
65
+ * rebinds with --skip-register after minting another app's agent gets that
66
+ * agent; fetch tokenURI and match its A2A endpoint if that starts happening.
67
+ */
68
+ export function pairAgent(registered, owner, bindBlock) {
69
+ let best = null;
70
+ for (const r of registered) {
71
+ if (r.owner.toLowerCase() !== owner.toLowerCase() || r.block > bindBlock) continue;
72
+ if (!best || r.block > best.block || (r.block === best.block && r.agentId > best.agentId)) best = r;
73
+ }
74
+ return best?.agentId ?? null;
75
+ }
76
+
77
+ /** Retried: dropping a verified app over a blip hides it from everyone who
78
+ * loads the page in that second. Seen 2026-09-28: headless Chrome in WSL got
79
+ * intermittent net::ERR_SSL_PROTOCOL_ERROR from one pages.dev host (curl never
80
+ * did); three tries do not always get past that one. */
81
+ async function json(url, timeoutMs = 8000, tries = 3) {
82
+ try {
83
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
84
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
85
+ return await res.json();
86
+ } catch (e) {
87
+ if (tries <= 1) throw e;
88
+ await new Promise((ok) => setTimeout(ok, 600 * (4 - tries))); // 600ms, then 1.2s
89
+ return json(url, timeoutMs, tries - 1);
90
+ }
91
+ }
92
+
93
+ async function reputation(client, agentId, chain) {
94
+ try {
95
+ const clients = await client.readContract({ address: chain.reputation, abi: REP, functionName: "getClients", args: [agentId] });
96
+ if (!clients.length) return { feedback: 0, average: null };
97
+ const [count, value, decimals] = await client.readContract({ address: chain.reputation, abi: REP, functionName: "getSummary", args: [agentId, clients, "", ""] });
98
+ return { feedback: Number(count), average: Number(value) / 10 ** decimals, clients: clients.length };
99
+ } catch (e) {
100
+ return { error: e?.shortMessage ?? e?.message ?? String(e) };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Every verified app, with its 8004 agent, reputation, and catalog.
106
+ * Unverified apps are dropped, not listed; `dropped` says why, for debugging.
107
+ */
108
+ export async function readDirectory(client, { chain = CHAIN } = {}) {
109
+ const [bindLogs, regLogs] = await Promise.all([
110
+ client.getLogs({ address: chain.appRegistry, event: APP_AGENT_CHANGED, fromBlock: 0n, toBlock: "latest" }),
111
+ client.getLogs({ address: chain.identity, event: REGISTERED, fromBlock: 0n, toBlock: "latest" }),
112
+ ]);
113
+ const registered = regLogs.map((l) => ({ agentId: l.args.agentId, owner: l.args.owner, block: l.blockNumber }));
114
+ const latest = new Map(); // logs come oldest first: the last write wins
115
+ for (const l of bindLogs) latest.set(l.args.app_id, { uri: l.args.agent_uri, block: l.blockNumber });
116
+
117
+ // Two passes: verify every card (small), then read catalogs (Quorum's is
118
+ // 1.7 MB, Kingsfoil has 23). Only a card failure drops an app; an owner or
119
+ // catalog that fails leaves it listed with less to say about it.
120
+ const dropped = [];
121
+ const verified = (await Promise.all([...latest].map(async ([appId, { uri, block }]) => {
122
+ if (!uri) return null; // unbound on purpose
123
+ try {
124
+ if (!/^https:\/\//.test(uri)) throw new Error("card is not https");
125
+ const card = await json(uri);
126
+ const why = verifyCard(card, appId, chain);
127
+ if (why) throw new Error(why);
128
+ return { appId, uri, block, card };
129
+ } catch (e) { dropped.push({ card: uri, why: e?.message ?? String(e) }); return null; }
130
+ }))).filter(Boolean);
131
+
132
+ const apps = await Promise.all(verified.map(async ({ appId, uri, block, card }) => {
133
+ const p = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION).params;
134
+ const owner = await client.readContract({ address: chain.appRegistry, abi: APPS, functionName: "getAppOwner", args: [appId] }).catch(() => null);
135
+ const agentId = owner && pairAgent(registered, owner, block);
136
+ const { corpora, unreachable } = await survey((p.views ?? []).filter((v) => /^https:\/\//.test(v)));
137
+ const name = typeof card.name === "string" && card.name.trim() ? card.name.trim() : `App ${appId.slice(0, 10)}`;
138
+ return {
139
+ app: slug(name), name, description: typeof card.description === "string" ? card.description : "",
140
+ site: https(card.url), card: uri, appId, owner,
141
+ boundAt: Number(block), fromBlock: Number(p.fromBlock),
142
+ tools: (card.skills ?? []).map((s) => s.id),
143
+ agent: agentId == null ? null : { id: Number(agentId), scan: chain.scan(agentId) },
144
+ reputation: agentId == null ? null : await reputation(client, agentId, chain),
145
+ // null, not 0, when no catalog answered: "0 records" would claim it was counted.
146
+ rows: corpora.length ? corpora.reduce((n, c) => n + (c.rows || 0), 0) : null,
147
+ unreachable: unreachable.length,
148
+ domains: corpora.map((c) => ({ name: c.domain, description: c.description, rows: c.rows, coverage: c.coverage, model: c.model })),
149
+ };
150
+ }));
151
+ apps.sort((a, b) => a.name.localeCompare(b.name));
152
+ return { apps, dropped };
153
+ }
154
+
155
+ /** A calibration knob, not a law: nomic-embed-text-v1.5 at 256d, 5 apps
156
+ * (2026-09-28). Every on-topic top app scored >= 0.545 ("a cozy game" ->
157
+ * Sidequest), every off-topic top <= 0.52 ("bitcoin" -> Audius). Re-measure
158
+ * with the explorer's check when the model or the network changes much. */
159
+ export const MIN_AFFINITY = 0.53;
160
+
161
+ // ponytail: a short English list, enough for questions put to a directory.
162
+ const FILLER = new Set("the and for are was were has have had not but with from that this these those what which where when who whom whose why how can could would should will shall may might must about into onto over under than then there their them they you your our any all some more most much many app apps find show tell learn know want need good best reputable".split(" "));
163
+
164
+ /**
165
+ * Rank a directory against a question. An app scores as its best domain's
166
+ * coverage affinity (what its data is about, not what its blurb says). With no
167
+ * `qv` (no embedder yet), name and description are matched by words.
168
+ *
169
+ * `relevant`: mean + 1σ of this query's scores, and at least MIN_AFFINITY.
170
+ * Unlike findCorpora the top app is NOT always relevant: "which app has stock
171
+ * prices" must be able to answer "none", or the explorer vouches for a
172
+ * government-records app on a finance question.
173
+ */
174
+ export function rankApps(apps, query, { qv = null, model = null } = {}) {
175
+ const q = String(query ?? "").trim().toLowerCase();
176
+ if (!q) return apps.map((a) => ({ ...a, score: null, relevant: null, ranked_by: null }));
177
+ // Filler dropped, or "where can I learn about X" matches every blurb with "about" in it.
178
+ const content = q.split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2 && !FILLER.has(w)).join(" ");
179
+ const words = (a) => (content ? lexScore({ text: `${a.name} ${a.description} ${a.domains.map((d) => d.description ?? "").join(" ")}` }, content) : 0);
180
+ let scored;
181
+ if (qv) {
182
+ scored = apps.map((a) => {
183
+ const doms = rankDomains(a.domains.filter((d) => comparable(d, model)), qv);
184
+ const best = doms.find((d) => d.affinity != null);
185
+ // No coverage (its catalog did not answer, or predates coverage):
186
+ // its own blurb, by words, rather than silently ranking it last.
187
+ if (!best) return { ...a, score: null, matched: null, ranked_by: "words", wordHit: words(a) > 0 };
188
+ return { ...a, score: best.affinity, matched: best.name, ranked_by: "meaning" };
189
+ });
190
+ } else {
191
+ scored = apps.map((a) => ({ ...a, score: words(a), ranked_by: "words" }));
192
+ }
193
+ scored.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity));
194
+ const s = scored.filter((a) => a.ranked_by === "meaning").map((a) => a.score);
195
+ const floor = qv ? Math.max(zFloor(s), MIN_AFFINITY) : 1e-9;
196
+ return scored.map(({ wordHit, ...a }) => ({ ...a, relevant: wordHit ?? (a.score != null && a.score >= floor) }));
197
+ }
198
+
199
+ /** What a caller sees: the ranking minus the centroids. Quorum alone has 29
200
+ * domains, so an app reports how many, and which one matched. */
201
+ export const brief = ({ domains, ...a }) => ({ ...a, domains: domains.length });
202
+
203
+ // ── self-check: `node consume/explore.js` ──────────────────────────────────
204
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/explore.js")) {
205
+ const j = (x) => JSON.stringify(x, (_, v) => (typeof v === "bigint" ? `${v}n` : v));
206
+ const eq = (a, b, m) => { if (j(a) !== j(b)) throw new Error(`${m}: ${j(a)} != ${j(b)}`); };
207
+ const O = "0x00000000000000000000000000000000000000aa", P = "0x00000000000000000000000000000000000000bb";
208
+ const reg = [
209
+ { agentId: 1n, owner: O, block: 10n }, { agentId: 2n, owner: P, block: 11n },
210
+ { agentId: 3n, owner: O, block: 20n }, { agentId: 4n, owner: O, block: 99n },
211
+ ];
212
+ eq(pairAgent(reg, O, 21n), 3n, "owner's latest agent at or before the binding");
213
+ eq(pairAgent(reg, P.toUpperCase().replace("0X", "0x"), 50n), 2n, "owner compared case-insensitively");
214
+ eq(pairAgent(reg, O, 5n), null, "no agent before the binding");
215
+
216
+ const A = "0x" + "a".repeat(64);
217
+ const card = (params) => ({ capabilities: { extensions: [{ uri: APP_EXTENSION, params }] } });
218
+ const good = { appId: A, chainId: CHAIN.chainId, appRegistry: CHAIN.appRegistry.toUpperCase().replace("0X", "0x"), fromBlock: "7" };
219
+ eq(verifyCard(card(good), A), null, "a good card verifies");
220
+ eq(verifyCard(card({ ...good, appId: "0x" + "b".repeat(64) }), A), "card names another app", "wrong app");
221
+ eq(verifyCard(card({ ...good, chainId: 1 }), A), "card is for another registry", "wrong chain");
222
+ eq(verifyCard({}, A), "no fangorn app extension", "no extension");
223
+
224
+ const cov = (v) => ({ vectors: [v] });
225
+ const apps = [
226
+ { name: "Tunes", description: "music tracks", domains: [{ name: "tracks", coverage: cov([1, 0, 0]) }] },
227
+ { name: "Sky", description: "weather alerts", domains: [{ name: "alerts", coverage: cov([0, 1, 0]) }, { name: "old", coverage: null }] },
228
+ { name: "Law", description: "meetings", domains: [{ name: "minutes", coverage: cov([0, 0, 1]) }] },
229
+ { name: "Empty", description: "", domains: [] },
230
+ ];
231
+ const r = rankApps(apps, "rain", { qv: [0.1, 1, 0] });
232
+ eq(r.map((a) => a.name), ["Sky", "Tunes", "Law", "Empty"], "ranked by best domain affinity, unscored last");
233
+ eq([r[0].relevant, r[0].matched, r[3].relevant], [true, "alerts", false], "top is relevant, unscored is not");
234
+ const blind = rankApps([{ ...apps[1], domains: [] }, apps[0], apps[2]], "weather alerts", { qv: [0.1, 1, 0] });
235
+ eq(blind.map((a) => [a.name, a.relevant, a.ranked_by]).at(-1), ["Sky", true, "words"], "an app with no coverage still matches on its own words");
236
+ eq(rankApps([{ ...apps[1], description: "all about the sky", domains: [] }], "where can I learn about music", { qv: [1, 0, 0] })[0].relevant, false, "filler words never match");
237
+ eq(rankApps(apps, "stock prices", { qv: [-1, -1, -1] }).filter((a) => a.relevant).length, 0, "a question no app covers matches none");
238
+ eq(rankApps(apps, "music").map((a) => [a.name, a.relevant]).slice(0, 2), [["Tunes", true], ["Sky", false]], "word fallback");
239
+ eq(rankApps(apps, "").map((a) => a.score), [null, null, null, null], "no query: listed, not ranked");
240
+ eq([https("javascript:alert(1)"), https("http://x.test"), https("https://x.test/a")], [null, null, "https://x.test/a"], "only https links reach a reader");
241
+ eq(brief({ name: "x", domains: [{ name: "d", coverage: 1 }] }), { name: "x", domains: 1 }, "brief drops centroids");
242
+ console.log("explore.js self-check ok — cards verified, agents paired by owner and block, apps ranked by coverage or words");
243
+ }
package/consume/mcp.js CHANGED
@@ -284,6 +284,11 @@ const META = [
284
284
  { name: "list-apps",
285
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.",
286
286
  inputSchema: { type: "object", properties: { query: { type: "string", description: "Case-insensitive match on name or description" } } } },
287
+ { name: "find-apps",
288
+ description: "Find Fangorn apps for a question, ranked by what their data is about (e.g. \"where can I learn about weather?\", \"music\"). Each app carries its site, agent card, ERC-8004 agent with an 8004scan link, ERC-8004 reputation (feedback count and average), record count and bound block. Only apps whose cards verify on chain. `relevant: true` marks the ones worth opening; the rest are sorted, not relevant. With no query, lists every app. Then open-app.",
289
+ inputSchema: { type: "object", properties: {
290
+ query: { type: "string", description: "What the person is looking for, in their words" },
291
+ limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } } } },
287
292
  { name: "open-app",
288
293
  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
294
  inputSchema: { type: "object", properties: { app: { type: "string" }, page: { type: "boolean", description: "Also load the page's own WebMCP tools in a browser" } }, required: ["app"] } },
@@ -295,6 +300,7 @@ const META = [
295
300
  /**
296
301
  * The server. `deps` is the outside world, so the self-check can fake it:
297
302
  * findApps() → { apps: [{ name, desc, card, appId }], rejected }
303
+ * exploreApps(query) → { apps: ranked explore.js entries, ranked_by }
298
304
  * findByName(name) → { card, appId } | null (optional: a direct registry read)
299
305
  * resolveCard(url) → { card, page, verified, views }
300
306
  * viewTools(app) → { tools, call(name, args) } | null (no browser)
@@ -377,6 +383,11 @@ export function handlers(deps) {
377
383
  unverified: rejected.length,
378
384
  });
379
385
  },
386
+ "find-apps": async ({ query, limit = 10 }) => {
387
+ const { apps, ranked_by } = await deps.exploreApps(query);
388
+ return text({ query: query ?? null, ranked_by, apps: apps.slice(0, Math.min(50, Math.max(1, limit)))
389
+ .map((a) => ({ ...a, open: open.has(a.app) })) });
390
+ },
380
391
  "open-app": async ({ app, page }) => {
381
392
  const o = await openApp(app, { page });
382
393
  return text({ app: o.slug, name: o.name, verified: o.verified,
@@ -419,6 +430,7 @@ if (process.argv[2] === "--selfcheck") {
419
430
  let opened = 0;
420
431
  const deps = {
421
432
  findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", appId: "0xk", tools: ["greet"] }], rejected: [{}] }),
433
+ exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
422
434
  resolveCard: async () => ({ card: { name: "Kings Foil" }, page: "https://k.test/p", verified: true }),
423
435
  openTab: async () => (opened++ ? "ev" : "dead"), // the first tab dies
424
436
  tabTools: async () => [{ name: "greet", description: "Hi.", inputSchema: { type: "object" } }],
@@ -435,10 +447,13 @@ if (process.argv[2] === "--selfcheck") {
435
447
 
436
448
  rpc(1, "initialize", {}); rpc(2, "tools/list"); call(3, "list-apps", { query: "TRIAL" }); call(4, "kings-foil__greet"); await wait();
437
449
  assert(res(1).result.capabilities.tools.listChanged, "advertises list_changed");
438
- assert(res(2).result.tools.length === 3, "only the meta tools before anything is open");
450
+ assert(res(2).result.tools.length === 4, "only the meta tools before anything is open");
439
451
  assert(body(3).apps[0].app === "kings-foil" && body(3).unverified === 1, "list-apps filters and slugs");
440
452
  assert(res(4).result.isError && /not open/.test(res(4).result.content[0].text), "an unopened app's tool is an error");
441
453
 
454
+ call(30, "find-apps", { query: "trials", limit: 1 }); await wait();
455
+ assert(body(30).ranked_by === "meaning" && body(30).apps.length === 1 && body(30).apps[0].open === false, "find-apps ranks, caps, and says what is open");
456
+
442
457
  call(5, "open-app", { app: "Kings Foil" }); await wait();
443
458
  call(6, "open-app", { app: "https://k.test/card" }); await wait();
444
459
  assert(body(5).tools[0].name === "greet" && opened === 1, "open once, by name or by card URL");
@@ -490,7 +505,7 @@ const fromBlock = fb >= 0 ? BigInt(argv[fb + 1]) : DEFAULT_FROM_BLOCK;
490
505
  const headed = argv.includes("--headed");
491
506
  const ci = argv.indexOf("--cdp");
492
507
  const cdp = ci >= 0 ? argv[ci + 1] : process.env.FANGORN_MCP_CDP || undefined;
493
- let found, foundAt = 0;
508
+ let found, foundAt = 0, explored, exploredAt = 0;
494
509
  const deps = {
495
510
  // ponytail: cached for a minute. The scan is a few RPC calls plus a fetch per card.
496
511
  findApps: async () => {
@@ -504,6 +519,19 @@ const deps = {
504
519
  }
505
520
  return found;
506
521
  },
522
+ // ponytail: cached for a minute, like findApps. Two getLogs, a card and catalog per app.
523
+ exploreApps: async (query) => {
524
+ if (!explored || Date.now() - exploredAt > 60_000) {
525
+ const [ex, { createPublicClient, http }, { arbitrumSepolia }] = await Promise.all([import("./explore.js"), import("viem"), import("viem/chains")]);
526
+ explored = { ex, dir: await ex.readDirectory(createPublicClient({ chain: arbitrumSepolia, transport: http() })) };
527
+ exploredAt = Date.now();
528
+ }
529
+ const { ex, dir } = explored;
530
+ const { EMBED_MODEL } = await import("./embed.js");
531
+ const qv = query?.trim() ? await vt.queryVector(query) : null;
532
+ const apps = ex.rankApps(dir.apps, query, { qv, model: EMBED_MODEL }).map(ex.brief);
533
+ return { apps, ranked_by: apps[0]?.ranked_by ?? null };
534
+ },
507
535
  findByName: async (name) => {
508
536
  const [f, { appId }] = await Promise.all([fangorn(), import("@fangorn-network/sdk")]);
509
537
  const reg = f.getAppRegistry();
package/consume/taste.js CHANGED
@@ -143,6 +143,42 @@ export function recommend(rows, t, { limit = 10, exclude = new Set(), lambda = 0
143
143
  .map(({ row, s }) => ({ row, score: Number(s.toFixed(4)) }));
144
144
  }
145
145
 
146
+ /** The four knobs a person turns on "For you", each 0–1: [default, what it means].
147
+ * Tools publish these descriptions as-is, so a person and an agent turn the same dials. */
148
+ export const KNOBS = {
149
+ lookahead: [0.35, "how far past what you like, toward where your recent likes are heading (the kernel's drift)"],
150
+ variety: [0.3, "how different the picks are from each other"],
151
+ surprise: [0, "how much chance reorders the list; 0 is strict best-first"],
152
+ reach: [0, "how far out from the closest matches to start: 0 is nearest, 1 skips the nearest ~400"],
153
+ };
154
+ const dial = (x, d) => Math.min(1, Math.max(0, Number.isFinite(Number(x)) ? Number(x) : d));
155
+
156
+ /**
157
+ * "For you" with the knobs: the kernel over likes and dislikes (`{ id, title, vector }`,
158
+ * newest last), then `reach` skips the nearest, `surprise` reorders with seeded Gumbel noise
159
+ * (same seed, same list), and `variety` sets how hard MMR spreads the picks. What Sidequest
160
+ * shipped, lifted so every page and its `discover` tool rank the same way.
161
+ */
162
+ export function discover(rows, likes = [], dislikes = [], { limit = 20, seed = 1, exclude = new Set(), ...k } = {}) {
163
+ const knobs = Object.fromEntries(Object.entries(KNOBS).map(([n, [d]]) => [n, dial(k[n], d)]));
164
+ const t = taste(likes, dislikes, { drift: knobs.lookahead });
165
+ if (!t) return { taste: null, knobs: { ...knobs, seed }, picks: [] };
166
+ const skip = new Set([...exclude, ...likes.map((x) => x.id), ...dislikes.map((x) => x.id)]);
167
+ let ranked = rows.filter((r) => r.vector && !skip.has(r.id)).map((row) => ({ row, s: score(row, t) })).sort((a, b) => b.s - a.s);
168
+ const start = Math.round(knobs.reach * Math.min(400, ranked.length / 4));
169
+ ranked = ranked.slice(start, start + 300);
170
+ if (knobs.surprise) {
171
+ let a = (Number(seed) >>> 0) || 1;
172
+ const rand = () => { a = (a + 0x6d2b79f5) >>> 0; let x = Math.imul(a ^ (a >>> 15), 1 | a); x ^= x + Math.imul(x ^ (x >>> 7), 61 | x); return ((x ^ (x >>> 14)) >>> 0) / 4294967296; };
173
+ ranked = ranked.map((p) => ({ ...p, s: p.s + 0.04 * knobs.surprise * -Math.log(-Math.log(rand() * 0.999998 + 1e-6)) })).sort((x, y) => y.s - x.s);
174
+ }
175
+ return {
176
+ taste: { from: t.from, rejected: t.rejected, heading: !!t.v, n: t.n },
177
+ knobs: { ...knobs, seed },
178
+ picks: diversify(ranked, { limit, lambda: 1 - 0.6 * knobs.variety }).map(({ row, s }) => ({ row, score: Number(s.toFixed(4)) })),
179
+ };
180
+ }
181
+
146
182
  /**
147
183
  * A taste, small enough to paste.
148
184
  *
@@ -346,5 +382,19 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
346
382
  if (twice.n !== 3 || twice.from.join() !== kept.from.join()) throw new Error("a second reload must change nothing");
347
383
  }
348
384
 
349
- console.log(`taste.js self-check ok — mu/v/q, drift leans forward, rejection steers, portable in ${size}B, ranks a pool it never saw, MMR keeps a run of near-duplicates from filling the list, provenance and heading survive the restore collapse and do not drift on reload`);
385
+ // discover: the knobs move the list, a seed pins it, and nothing voted on comes back.
386
+ {
387
+ const pool = Array.from({ length: 40 }, (_, i) => { const a = (i * Math.PI) / 80; return { id: `g${i}`, title: `g${i}`, vector: [Math.cos(a), Math.sin(a)], norm: 1 }; });
388
+ const likes = [pool[0]], dislikes = [pool[39]];
389
+ const ids = (o) => o.picks.map((p) => p.row.id).join();
390
+ const base = discover(pool, likes, dislikes, { limit: 5 });
391
+ if (base.picks[0].row.id !== "g1") throw new Error(`nearest first by default: ${ids(base)}`);
392
+ if (base.picks.some((p) => p.row.id === "g0" || p.row.id === "g39")) throw new Error("discover must not return what was voted on");
393
+ if (discover(pool, likes, [], { limit: 5, reach: 1 }).picks[0].row.id === "g1") throw new Error("reach must skip the nearest");
394
+ const s1 = discover(pool, likes, [], { limit: 5, surprise: 1, seed: 7 });
395
+ if (ids(s1) !== ids(discover(pool, likes, [], { limit: 5, surprise: 1, seed: 7 }))) throw new Error("the same seed must give the same list");
396
+ if (discover(pool, [], [], {}).picks.length) throw new Error("no likes, no picks");
397
+ }
398
+
399
+ console.log(`taste.js self-check ok — discover's knobs, mu/v/q, drift leans forward, rejection steers, portable in ${size}B, ranks a pool it never saw, MMR keeps a run of near-duplicates from filling the list, provenance and heading survive the restore collapse and do not drift on reload`);
350
400
  }
@@ -51,7 +51,7 @@ export const warm = ({ cacheDir } = {}) => (embedder ??= (async () => {
51
51
  export let warmError = null;
52
52
  // A model on disk loads in ~0.5s and is worth the wait. A download is 131MB, so
53
53
  // until it lands, search ranks by words and says so, rather than stall ~10s.
54
- const queryVector = async (q) => {
54
+ export const queryVector = async (q) => {
55
55
  if (!ready && downloading) return null;
56
56
  return (await warm()) ? embedQueryDirect(q).catch(() => null) : null;
57
57
  };
package/dist/ts/eval.js CHANGED
File without changes
@@ -495,13 +495,16 @@ writeFileSync("site/.well-known/agent-card.json", JSON.stringify(card, null, 1))
495
495
 
496
496
  // The card is read from other origins, so it needs CORS, and so does the view, for
497
497
  // agents and pages elsewhere. Bundles and shards are named by their digest, so they
498
- // never change: tell caches so. (Pages allows one * per rule, hence a rule per domain.)
498
+ // never change: tell caches so. Shards only in the reader's own browser (`private`): a
499
+ // retracted record's bytes must stop being served by shared caches once the deploy drops
500
+ // its shard. (Pages allows one * per rule, hence a rule per domain.)
499
501
  const immutable = " Cache-Control: public, max-age=31536000, immutable\n";
502
+ const mine = " Cache-Control: private, max-age=31536000, immutable\n";
500
503
  writeFileSync("site/_headers",
501
504
  "/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n"
502
505
  + "/view/*\n Access-Control-Allow-Origin: *\n"
503
506
  + `/assets/*\n${immutable}`
504
- + readdirSync("site/view/cdn/domains").map((d) => `/view/cdn/domains/${d}/shards/*\n${immutable}`).join(""));
507
+ + readdirSync("site/view/cdn/domains").map((d) => `/view/cdn/domains/${d}/shards/*\n${mine}`).join(""));
505
508
  ```
506
509
 
507
510
  ```sh
@@ -587,29 +590,20 @@ can keep an app current: a cron job, a CI workflow, or an agent that just publis
587
590
  ## Using Fangorn apps from an agent
588
591
 
589
592
  `fangorn-mcp` is one MCP server for every Fangorn app: a new app needs no new
590
- registration. You can install it as a single file (Linux x64/arm64): no node, no install,
591
- and search by meaning included.
593
+ registration. Install it through npm (node ≥ 20):
592
594
 
593
595
  ```sh
594
- curl -fLo fangorn-mcp https://github.com/fangorn-network/westmarch/releases/latest/download/fangorn-mcp-linux-x64
595
- chmod +x fangorn-mcp
596
- claude mcp add fangorn -- "$PWD/fangorn-mcp"
597
- ```
598
-
599
- Or through npm, wherever node is:
600
-
601
- ```sh
602
- claude mcp add fangorn -- npx -y -p @fangorn-network/westmarch -p @huggingface/transformers fangorn-mcp
596
+ claude mcp add fangorn -e FANGORN_LOG_WINDOW=100000 -- \
597
+ npx -y -p @fangorn-network/westmarch -p @huggingface/transformers fangorn-mcp
603
598
  ```
604
599
 
605
600
  Without `-p @huggingface/transformers`, the install is ~500 MB lighter and `search` ranks by
606
- words, not meaning. For any MCP client:
607
-
608
- ```json
609
- { "mcpServers": { "fangorn": { "command": "/path/to/fangorn-mcp" } } }
610
- ```
601
+ words, not meaning. Without `FANGORN_LOG_WINDOW`, `list-apps` scans the chain 1,000 blocks
602
+ per call and takes minutes.
611
603
 
612
- `bun build-bin.js` in westmarch builds the file (bun ≥ 1.2).
604
+ [docs/mcp.md](mcp.md) has the full setup: other MCP clients, every setting, running from
605
+ source, and troubleshooting. A single-file binary (`bun build-bin.js`) exists but is not
606
+ published yet.
613
607
 
614
608
  It starts with three tools:
615
609
  - **`list-apps`** reads the apps off the chain and keeps the ones whose cards verify.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "0.0.5-dev",
3
+ "version": "0.0.7-dev",
4
4
  "description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "./embed": "./consume/embed.js",
13
13
  "./rank": "./consume/rank.js",
14
14
  "./apps": "./consume/apps.js",
15
+ "./explore": "./consume/explore.js",
15
16
  "./agent-card": "./publish/agent-card.js",
16
17
  "./view": "./publish/view.js",
17
18
  "./roles": "./consume/roles.js",
@@ -36,7 +37,7 @@
36
37
  "scripts": {
37
38
  "build": "tsc -p .",
38
39
  "prepare": "tsc -p .",
39
- "test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/eval.ts --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js && node site/store.js",
40
+ "test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/eval.ts --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/explore.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js && node site/store.js",
40
41
  "lint": "node consume/lint.js"
41
42
  },
42
43
  "peerDependencies": {
@@ -62,7 +63,8 @@
62
63
  "fangorn-mcp": "./consume/mcp.js",
63
64
  "westmarch-view": "./publish/view-cli.js",
64
65
  "westmarch-ship": "./publish/ship.js",
65
- "westmarch-eval": "./dist/ts/eval.js"
66
+ "westmarch-eval": "./dist/ts/eval.js",
67
+ "fangorn-fx": "./plugin/scripts/fx.mjs"
66
68
  },
67
69
  "files": [
68
70
  "consume",
@@ -73,7 +75,8 @@
73
75
  "site/feed.js",
74
76
  "site/worker.js",
75
77
  "site/store.js",
76
- "dist/ts"
78
+ "dist/ts",
79
+ "plugin/scripts/fx.mjs"
77
80
  ],
78
81
  "engines": {
79
82
  "node": ">=20"