@fangorn-network/westmarch 0.0.1-dev → 0.0.2-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 +2 -2
- package/consume/apps.js +4 -2
- package/consume/corpora.js +1 -1
- package/consume/directory.js +1 -1
- package/consume/embed.js +1 -1
- package/consume/lint.js +1 -1
- package/consume/mcp-bin.js +6 -0
- package/consume/mcp.js +193 -34
- package/consume/ort-preload.js +25 -0
- package/consume/rank.js +1 -1
- package/consume/roles.js +6 -4
- package/consume/shard.js +1 -1
- package/consume/steam.js +1 -1
- package/consume/taste-doc.js +1 -1
- package/consume/taste.js +1 -1
- package/consume/tools.js +28 -5
- package/consume/ui.js +1 -1
- package/consume/view-tools.js +202 -0
- package/consume/x402.js +94 -0
- package/docs/app-to-agent.md +553 -185
- package/package.json +11 -4
- package/publish/agent-card.js +12 -2
- package/publish/cohort.js +1 -1
- package/publish/demand.js +1 -1
- package/publish/enrich.js +1 -1
- package/publish/envelope.js +1 -1
- package/publish/graph.js +1 -1
- package/publish/manifest.js +1 -1
- package/publish/reactions.js +4 -2
- package/publish/settle.js +1 -1
- package/publish/ship.js +378 -0
- package/publish/terms.js +1 -1
- package/publish/vault.js +1 -1
- package/publish/view-cli.js +39 -0
- package/publish/view.js +399 -0
- package/site/agent.js +36 -0
- package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
- package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
- package/site/dist/assets/index-DlzVyQ_9.js +6 -0
- package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
- package/site/dist/assets/roles-C9V_uDr_.js +1 -0
- package/site/dist/assets/roles-YDRjv_PY.js +1 -0
- package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
- package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
- package/site/dist/index.html +20 -0
- package/site/feed.js +62 -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);
|
package/consume/corpora.js
CHANGED
|
@@ -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);
|
package/consume/directory.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
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,
|
|
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
|
|
238
|
-
if (
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = `
|
package/consume/taste-doc.js
CHANGED
|
@@ -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
|
-
|
|
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",
|