@fangorn-network/westmarch 2026.11.3 → 2026.11.4-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 +1 -1
- package/docs/app-to-agent.md +12 -80
- package/package.json +5 -8
- package/site/worker.js +4 -1
- package/src/agent/mcp.js +134 -386
- package/src/agent/tools.js +2 -1
- package/src/agent/view-tools.js +4 -2
- package/src/agent/x402.js +16 -18
- package/src/publish/devnet.js +4 -2
- package/src/publish/init.js +1 -1
- package/src/publish/ship.js +6 -5
- package/src/publish/view.js +7 -6
- package/template/README.md +7 -9
- package/plugin/scripts/fx.mjs +0 -266
- package/src/discover/explore.js +0 -227
package/src/discover/explore.js
DELETED
|
@@ -1,227 +0,0 @@
|
|
|
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
|
-
// the update log + each repo's @card which apps exist and where their cards are (apps.js)
|
|
6
|
-
// each card name, tools, views; checked by discoverCard
|
|
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
|
-
// `fangorn` is an SDK client and `client` a viem PublicClient: passed in, so the self-check
|
|
12
|
-
// needs no chain. Pass `store` and only what changed since the last read is read again.
|
|
13
|
-
|
|
14
|
-
import { parseAbi, parseAbiItem } from "viem";
|
|
15
|
-
// The config module alone (viem only), not the SDK's root: this runs in a tab too.
|
|
16
|
-
import { BaseSepoliaConfig } from "@fangorn-network/sdk/lib/config.js";
|
|
17
|
-
import { cachedLogs, listApps } from "./apps.js";
|
|
18
|
-
import { survey, comparable } from "./directory.js";
|
|
19
|
-
import { rankDomains, zFloor, lexScore } from "../core/rank.js";
|
|
20
|
-
|
|
21
|
-
// The ERC-8004 registries per chain id (agent0-sdk's defaults), where `fangorn card` registers.
|
|
22
|
-
const ERC8004 = {
|
|
23
|
-
84532: { identity: "0x8004A818BFB912233c491871b3d84c89A494BD9e", reputation: "0x8004B663056A597Dffe9eCcC1965A193B7388713",
|
|
24
|
-
scan: "base-sepolia" },
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
/** What explore reads on one of the SDK's networks (`SupportedNetworks.baseSepolia`, …). */
|
|
28
|
-
export function chainOf(net = BaseSepoliaConfig) {
|
|
29
|
-
const e = ERC8004[net.caip2];
|
|
30
|
-
if (!e) throw new Error(`explore: no ERC-8004 registries known for chain ${net.caip2}`);
|
|
31
|
-
return {
|
|
32
|
-
chainId: net.caip2, identity: e.identity, reputation: e.reputation,
|
|
33
|
-
// ponytail: identity read from the DataRegistry's deployment, since an app's agent is
|
|
34
|
-
// registered after its repo exists. An agent minted earlier is missed; scan earlier if that happens.
|
|
35
|
-
identityFrom: e.identityFrom ?? net.fromBlock,
|
|
36
|
-
window: e.window ?? net.logWindow ?? 1000n,
|
|
37
|
-
scan: (agentId) => `https://8004scan.io/agents/${e.scan}/${agentId}`,
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export const CHAIN = chainOf();
|
|
42
|
-
|
|
43
|
-
const REGISTERED = parseAbiItem("event Registered(uint256 indexed agentId, string agentURI, address indexed owner)");
|
|
44
|
-
const REP = parseAbi([
|
|
45
|
-
"function getClients(uint256) view returns (address[])",
|
|
46
|
-
"function getSummary(uint256, address[], string, string) view returns (uint64, int128, uint8)",
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
/** A link someone else wrote, shown to a reader: https or nothing. */
|
|
50
|
-
const https = (u) => { try { const x = new URL(String(u)); return x.protocol === "https:" ? x.href : null; } catch { return null; } };
|
|
51
|
-
const slug = (name) => String(name).toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-|-$/g, "").slice(0, 24) || "app";
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* The agent an app registered: its publisher's latest `Registered` at or before `block`.
|
|
55
|
-
*
|
|
56
|
-
* ponytail: paired by owner, not by reading the registration file (IPFS gateways are too
|
|
57
|
-
* slow and flaky for a page load). A publisher with two apps gets its latest agent for both;
|
|
58
|
-
* fetch tokenURI and match the card if that starts happening.
|
|
59
|
-
*/
|
|
60
|
-
export function pairAgent(registered, owner, block) {
|
|
61
|
-
let best = null;
|
|
62
|
-
for (const r of registered) {
|
|
63
|
-
if (r.owner.toLowerCase() !== owner.toLowerCase() || r.block > block) continue;
|
|
64
|
-
if (!best || r.block > best.block || (r.block === best.block && r.agentId > best.agentId)) best = r;
|
|
65
|
-
}
|
|
66
|
-
return best?.agentId ?? null;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
async function reputation(client, agentId, chain) {
|
|
70
|
-
try {
|
|
71
|
-
const clients = await client.readContract({ address: chain.reputation, abi: REP, functionName: "getClients", args: [agentId] });
|
|
72
|
-
if (!clients.length) return { feedback: 0, average: null };
|
|
73
|
-
const [count, value, decimals] = await client.readContract({ address: chain.reputation, abi: REP, functionName: "getSummary", args: [agentId, clients, "", ""] });
|
|
74
|
-
return { feedback: Number(count), average: Number(value) / 10 ** decimals, clients: clients.length };
|
|
75
|
-
} catch (e) {
|
|
76
|
-
return { error: e?.shortMessage ?? e?.message ?? String(e) };
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
// `window`: the most blocks one getLogs may span on the client's RPC (chainOf says, per chain).
|
|
81
|
-
async function getLogsSince(client, start, filter, window, store) {
|
|
82
|
-
const head = await client.getBlockNumber();
|
|
83
|
-
const read = async (start, head) => {
|
|
84
|
-
const windows = [];
|
|
85
|
-
for (let from = start; from <= head; from += window) {
|
|
86
|
-
const to = from + window - 1n < head ? from + window - 1n : head;
|
|
87
|
-
windows.push(client.getLogs({ ...filter, fromBlock: from, toBlock: to }));
|
|
88
|
-
}
|
|
89
|
-
return (await Promise.all(windows)).flat().map(({ args, blockNumber }) => ({ args, blockNumber }));
|
|
90
|
-
};
|
|
91
|
-
return store ? cachedLogs(store, `${filter.address}:${filter.event.name}:${start}`, start, head, read) : read(start, head);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Every verified app, with its 8004 agent, reputation, and catalog. Apps whose cards don't
|
|
96
|
-
* verify are dropped; `dropped` says why. `onUpdate({ apps, pending })` is called as each
|
|
97
|
-
* app lands, so a page can show them as they arrive.
|
|
98
|
-
*/
|
|
99
|
-
export async function readDirectory(fangorn, client, { chain = CHAIN, onUpdate = () => {}, store } = {}) {
|
|
100
|
-
const [{ apps: verified, rejected }, regLogs, head] = await Promise.all([
|
|
101
|
-
listApps(fangorn, { store }),
|
|
102
|
-
getLogsSince(client, chain.identityFrom ?? 0n, { address: chain.identity, event: REGISTERED }, chain.window ?? 1000n, store),
|
|
103
|
-
client.getBlockNumber(),
|
|
104
|
-
]);
|
|
105
|
-
const registered = regLogs.map((l) => ({ agentId: l.args.agentId, owner: l.args.owner, block: l.blockNumber }));
|
|
106
|
-
const dropped = rejected.map((r) => ({ card: r.card, why: r.why }));
|
|
107
|
-
|
|
108
|
-
// One pipeline per app, so the quick ones need not wait on a big catalog.
|
|
109
|
-
const apps = [];
|
|
110
|
-
let pending = verified.length;
|
|
111
|
-
const settle = () => { pending--; apps.sort((a, b) => a.name.localeCompare(b.name)); onUpdate({ apps: [...apps], pending }); };
|
|
112
|
-
onUpdate({ apps: [], pending });
|
|
113
|
-
await Promise.all(verified.map(async (a) => {
|
|
114
|
-
const agentId = pairAgent(registered, a.publisher, head);
|
|
115
|
-
const [rep, { corpora, unreachable }] = await Promise.all([
|
|
116
|
-
agentId == null ? null : reputation(client, agentId, chain),
|
|
117
|
-
survey(a.views.filter((v) => /^https:\/\//.test(v))),
|
|
118
|
-
]);
|
|
119
|
-
apps.push({
|
|
120
|
-
app: slug(a.name), name: a.name, description: a.desc,
|
|
121
|
-
site: https(a.url), card: a.card, id: a.id, owner: a.publisher, repo: a.repo,
|
|
122
|
-
fromBlock: a.fromBlock == null ? null : Number(a.fromBlock),
|
|
123
|
-
tools: a.tools,
|
|
124
|
-
agent: agentId == null ? null : { id: Number(agentId), scan: chain.scan(agentId) },
|
|
125
|
-
reputation: rep,
|
|
126
|
-
// null, not 0, when no catalog answered: "0 records" would claim it was counted.
|
|
127
|
-
rows: corpora.length ? corpora.reduce((n, c) => n + (c.rows || 0), 0) : null,
|
|
128
|
-
unreachable: unreachable.length,
|
|
129
|
-
domains: corpora.map((c) => ({ name: c.domain, description: c.description, rows: c.rows, coverage: c.coverage, model: c.model })),
|
|
130
|
-
});
|
|
131
|
-
settle();
|
|
132
|
-
}));
|
|
133
|
-
return { apps, dropped };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/** A calibration knob, not a law: nomic-embed-text-v1.5 at 256d, 5 apps
|
|
137
|
-
* (2026-09-28). Every on-topic top app scored >= 0.545 ("a cozy game" ->
|
|
138
|
-
* Sidequest), every off-topic top <= 0.52 ("bitcoin" -> Audius). Re-measure
|
|
139
|
-
* with the explorer's check when the model or the network changes much. */
|
|
140
|
-
export const MIN_AFFINITY = 0.53;
|
|
141
|
-
|
|
142
|
-
// ponytail: a short English list, enough for questions put to a directory.
|
|
143
|
-
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(" "));
|
|
144
|
-
|
|
145
|
-
/**
|
|
146
|
-
* Rank a directory against a question. An app scores as its best domain's
|
|
147
|
-
* coverage affinity (what its data is about, not what its blurb says). With no
|
|
148
|
-
* `qv` (no embedder yet), name and description are matched by words.
|
|
149
|
-
*
|
|
150
|
-
* `relevant`: mean + 1σ of this query's scores, and at least MIN_AFFINITY.
|
|
151
|
-
* Unlike findCorpora the top app is NOT always relevant: "which app has stock
|
|
152
|
-
* prices" must be able to answer "none", or the explorer vouches for a
|
|
153
|
-
* government-records app on a finance question.
|
|
154
|
-
*/
|
|
155
|
-
export function rankApps(apps, query, { qv = null, model = null } = {}) {
|
|
156
|
-
const q = String(query ?? "").trim().toLowerCase();
|
|
157
|
-
if (!q) return apps.map((a) => ({ ...a, score: null, relevant: null, ranked_by: null }));
|
|
158
|
-
// Filler dropped, or "where can I learn about X" matches every blurb with "about" in it.
|
|
159
|
-
const content = q.split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 2 && !FILLER.has(w)).join(" ");
|
|
160
|
-
const words = (a) => (content ? lexScore({ text: `${a.name} ${a.description} ${a.domains.map((d) => d.description ?? "").join(" ")}` }, content) : 0);
|
|
161
|
-
let scored;
|
|
162
|
-
if (qv) {
|
|
163
|
-
scored = apps.map((a) => {
|
|
164
|
-
const doms = rankDomains(a.domains.filter((d) => comparable(d, model)), qv);
|
|
165
|
-
const best = doms.find((d) => d.affinity != null);
|
|
166
|
-
// No coverage (its catalog did not answer, or predates coverage):
|
|
167
|
-
// its own blurb, by words, rather than silently ranking it last.
|
|
168
|
-
if (!best) return { ...a, score: null, matched: null, ranked_by: "words", wordHit: words(a) > 0 };
|
|
169
|
-
return { ...a, score: best.affinity, matched: best.name, ranked_by: "meaning" };
|
|
170
|
-
});
|
|
171
|
-
} else {
|
|
172
|
-
scored = apps.map((a) => ({ ...a, score: words(a), ranked_by: "words" }));
|
|
173
|
-
}
|
|
174
|
-
scored.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity));
|
|
175
|
-
const s = scored.filter((a) => a.ranked_by === "meaning").map((a) => a.score);
|
|
176
|
-
const floor = qv ? Math.max(zFloor(s), MIN_AFFINITY) : 1e-9;
|
|
177
|
-
return scored.map(({ wordHit, ...a }) => ({ ...a, relevant: wordHit ?? (a.score != null && a.score >= floor) }));
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/** What a caller sees: the ranking minus the centroids. Quorum alone has 29
|
|
181
|
-
* domains, so an app reports how many, and which one matched. */
|
|
182
|
-
export const brief = ({ domains, ...a }) => ({ ...a, domains: domains.length });
|
|
183
|
-
|
|
184
|
-
// ── self-check: `node src/discover/explore.js` ──────────────────────────────────
|
|
185
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/explore.js")) {
|
|
186
|
-
const j = (x) => JSON.stringify(x, (_, v) => (typeof v === "bigint" ? `${v}n` : v));
|
|
187
|
-
const eq = (a, b, m) => { if (j(a) !== j(b)) throw new Error(`${m}: ${j(a)} != ${j(b)}`); };
|
|
188
|
-
const O = "0x00000000000000000000000000000000000000aa", P = "0x00000000000000000000000000000000000000bb";
|
|
189
|
-
const reg = [
|
|
190
|
-
{ agentId: 1n, owner: O, block: 10n }, { agentId: 2n, owner: P, block: 11n },
|
|
191
|
-
{ agentId: 3n, owner: O, block: 20n }, { agentId: 4n, owner: O, block: 99n },
|
|
192
|
-
];
|
|
193
|
-
eq(pairAgent(reg, O, 21n), 3n, "owner's latest agent at or before the binding");
|
|
194
|
-
eq(pairAgent(reg, P.toUpperCase().replace("0X", "0x"), 50n), 2n, "owner compared case-insensitively");
|
|
195
|
-
eq(pairAgent(reg, O, 5n), null, "no agent before the binding");
|
|
196
|
-
|
|
197
|
-
const fangorn = {
|
|
198
|
-
repos: async () => [{ owner: O, repo: "tunes", root: "0x1" }],
|
|
199
|
-
cardOf: async () => "https://t.test/card",
|
|
200
|
-
discoverCard: async (url) => ({ card: { name: "Tunes", description: "music", url: "https://t.test/", capabilities: { extensions: [
|
|
201
|
-
{ uri: "https://fangorn.network/a2a/repo/v1", params: { publisher: O, repo: "tunes", fromBlock: "7", views: [] } }] } }, publisher: O, repo: "tunes" }),
|
|
202
|
-
};
|
|
203
|
-
const client = { getBlockNumber: async () => 30n, getLogs: async () => [{ args: { agentId: 3n, owner: O }, blockNumber: 20n }],
|
|
204
|
-
readContract: async () => [] };
|
|
205
|
-
const dir = await readDirectory(fangorn, client, { chain: { ...CHAIN, identityFrom: 0n, window: 100n } });
|
|
206
|
-
eq(dir.apps.map((a) => [a.name, a.owner, a.repo, a.agent?.id, a.fromBlock]), [["Tunes", O, "tunes", 3, 7]], "an app is a carded repo, paired with its publisher's agent");
|
|
207
|
-
|
|
208
|
-
const cov = (v) => ({ vectors: [v] });
|
|
209
|
-
const apps = [
|
|
210
|
-
{ name: "Tunes", description: "music tracks", domains: [{ name: "tracks", coverage: cov([1, 0, 0]) }] },
|
|
211
|
-
{ name: "Sky", description: "weather alerts", domains: [{ name: "alerts", coverage: cov([0, 1, 0]) }, { name: "old", coverage: null }] },
|
|
212
|
-
{ name: "Law", description: "meetings", domains: [{ name: "minutes", coverage: cov([0, 0, 1]) }] },
|
|
213
|
-
{ name: "Empty", description: "", domains: [] },
|
|
214
|
-
];
|
|
215
|
-
const r = rankApps(apps, "rain", { qv: [0.1, 1, 0] });
|
|
216
|
-
eq(r.map((a) => a.name), ["Sky", "Tunes", "Law", "Empty"], "ranked by best domain affinity, unscored last");
|
|
217
|
-
eq([r[0].relevant, r[0].matched, r[3].relevant], [true, "alerts", false], "top is relevant, unscored is not");
|
|
218
|
-
const blind = rankApps([{ ...apps[1], domains: [] }, apps[0], apps[2]], "weather alerts", { qv: [0.1, 1, 0] });
|
|
219
|
-
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");
|
|
220
|
-
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");
|
|
221
|
-
eq(rankApps(apps, "stock prices", { qv: [-1, -1, -1] }).filter((a) => a.relevant).length, 0, "a question no app covers matches none");
|
|
222
|
-
eq(rankApps(apps, "music").map((a) => [a.name, a.relevant]).slice(0, 2), [["Tunes", true], ["Sky", false]], "word fallback");
|
|
223
|
-
eq(rankApps(apps, "").map((a) => a.score), [null, null, null, null], "no query: listed, not ranked");
|
|
224
|
-
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");
|
|
225
|
-
eq(brief({ name: "x", domains: [{ name: "d", coverage: 1 }] }), { name: "x", domains: 1 }, "brief drops centroids");
|
|
226
|
-
console.log("explore.js self-check ok — apps from carded repos, agents paired by owner, apps ranked by coverage or words");
|
|
227
|
-
}
|