@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/agent/tools.js
CHANGED
|
@@ -29,7 +29,8 @@ const PLUMBING = new Set(["text", "vector", "norm", "id", "owner", "embed"]);
|
|
|
29
29
|
export const brief = (r, roles, fields = []) => {
|
|
30
30
|
const out = briefOf(r, roles, fields);
|
|
31
31
|
const link = linkOf(r, roles);
|
|
32
|
-
|
|
32
|
+
// Whose it is, and its price when sold: what an agent needs to cite or buy a hit.
|
|
33
|
+
return { ...out, ...(link ? { url: link } : {}), ...(r.owner ? { publisher: r.owner } : {}), ...(r.sale ? { sale: r.sale } : {}) };
|
|
33
34
|
};
|
|
34
35
|
|
|
35
36
|
/**
|
package/src/agent/view-tools.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// - a search goes to the 3 views whose coverage sketch best matches the query,
|
|
12
12
|
// not to all of them (Kingsfoil: ~3 of 23 views, ~92% of the true top 10 with
|
|
13
13
|
// a 32-vector sketch). The result names what was searched and what was not.
|
|
14
|
-
// - `warm()` starts the embedder before the first query
|
|
14
|
+
// - `warm()` starts the embedder before the first query, and a search
|
|
15
15
|
// never waits on a first-time model download.
|
|
16
16
|
//
|
|
17
17
|
// `get`/`similar`/`count` answer over what is loaded, and say so.
|
|
@@ -86,6 +86,7 @@ export function viewTools(app, { fetchCatalog = fetch, queryVector: embedQ = que
|
|
|
86
86
|
view, name: label(view),
|
|
87
87
|
description: domains.map((d) => d.description).filter(Boolean).join(" "),
|
|
88
88
|
count: domains.reduce((s, d) => s + (d.count ?? 0), 0),
|
|
89
|
+
repos: domains.filter((d) => d.publisher).map((d) => ({ publisher: d.publisher, repo: d.repo, rows: d.count ?? 0, description: d.description ?? "" })),
|
|
89
90
|
coverage: { vectors: domains.flatMap((d) => d.coverage?.vectors ?? []) },
|
|
90
91
|
};
|
|
91
92
|
})).catch((e) => { surveyed = null; throw e; }));
|
|
@@ -112,7 +113,8 @@ export function viewTools(app, { fetchCatalog = fetch, queryVector: embedQ = que
|
|
|
112
113
|
const run = {
|
|
113
114
|
describe: async () => ({
|
|
114
115
|
app: app.name, description: app.desc,
|
|
115
|
-
views: (await survey()).map((v) => ({ view: v.name, rows: v.count, description: v.description, loaded: loaded.has(v.view)
|
|
116
|
+
views: (await survey()).map((v) => ({ view: v.name, rows: v.count, description: v.description, loaded: loaded.has(v.view),
|
|
117
|
+
...(v.repos.length ? { repos: v.repos } : {}) })),
|
|
116
118
|
}),
|
|
117
119
|
search: async ({ query, limit = 10, views, where: w }) => {
|
|
118
120
|
if (!query) throw new Error("query is required");
|
package/src/agent/x402.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
//
|
|
20
20
|
// `maxPrice` (USDC base units) is checked before anything is signed.
|
|
21
21
|
|
|
22
|
-
import { concat, createPublicClient, http, keccak256,
|
|
22
|
+
import { concat, createPublicClient, http, keccak256, parseSignature, toHex } from "viem";
|
|
23
23
|
import { privateKeyToAccount } from "viem/accounts";
|
|
24
24
|
import { openField, recordKey, registerNonce, repoId, subjectOf } from "@fangorn-network/sdk";
|
|
25
25
|
|
|
@@ -66,18 +66,6 @@ export async function signPayment(req, account, { subject, commitment, salt, now
|
|
|
66
66
|
v: Number(v ?? 27n + BigInt(yParity)), r, s };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
const REGISTERED = parseAbiItem("event Registered(bytes32 indexed subject, address indexed publisher, address indexed referrer, uint256 commitment, uint256 price, uint256 referrer_cut)");
|
|
70
|
-
|
|
71
|
-
/** Every commitment registered for `subject`, in the order the contract added them to its group. */
|
|
72
|
-
async function membersOf(pub, registry, subject, { fromBlock, window = 1000n }) {
|
|
73
|
-
const head = await pub.getBlockNumber(), out = [];
|
|
74
|
-
for (let from = BigInt(fromBlock); from <= head; from += window) {
|
|
75
|
-
const to = from + window - 1n < head ? from + window - 1n : head;
|
|
76
|
-
out.push(...await pub.getLogs({ address: registry, event: REGISTERED, args: { subject }, fromBlock: from, toBlock: to }));
|
|
77
|
-
}
|
|
78
|
-
return out.map((l) => l.args.commitment);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
69
|
/** A Semaphore proof that `identity` is in the group, naming `reader`, scoped to `subject`, as `settle` checks it. */
|
|
82
70
|
async function prove({ identity, members, reader, subject }) {
|
|
83
71
|
const { Group, generateProof } = await semaphore();
|
|
@@ -99,10 +87,16 @@ export async function buyRecord(quoteUrl, { privateKey, account = privateKeyToAc
|
|
|
99
87
|
if (terms.publisher.toLowerCase() !== record.publisher.toLowerCase() || terms.repo !== repoId(record.repo) || terms.key !== recordKey(record.id))
|
|
100
88
|
throw new Error("the quote's terms are for another record");
|
|
101
89
|
const subject = subjectOf(terms.publisher, terms.repo, terms.key);
|
|
90
|
+
// Every sealed field must be readable before anything is paid: a sale we can't open is money lost.
|
|
91
|
+
const storage = fangorn.getStorage(), held = new Map();
|
|
92
|
+
for (const h of Object.values(fields)) {
|
|
93
|
+
try { held.set(h.cid, await storage.getFile(h.cid)); }
|
|
94
|
+
catch (e) { throw new Error(`not buying: the record's sealed data (${h.cid}) can't be read: ${e.message}`); }
|
|
95
|
+
}
|
|
102
96
|
|
|
103
97
|
const config = fangorn.getConfig(), settlement = fangorn.getSettlementRegistry();
|
|
104
98
|
const pub = deps.pub ?? createPublicClient({ transport: http(config.rpcUrl) });
|
|
105
|
-
const members = deps.members ?? ((s) => membersOf(
|
|
99
|
+
const members = deps.members ?? ((s) => settlement.membersOf(s).then((m) => [...m]));
|
|
106
100
|
const { identity, reader } = await identityOf(account);
|
|
107
101
|
|
|
108
102
|
let tx = null;
|
|
@@ -116,7 +110,7 @@ export async function buyRecord(quoteUrl, { privateKey, account = privateKeyToAc
|
|
|
116
110
|
tx = JSON.parse(unb64(paid.headers.get("PAYMENT-RESPONSE") ?? b64("{}"))).transaction || null;
|
|
117
111
|
if (tx) await pub.waitForTransactionReceipt({ hash: tx });
|
|
118
112
|
}
|
|
119
|
-
// A load-balanced RPC can answer
|
|
113
|
+
// A load-balanced RPC can answer from a node behind the one that mined `register`:
|
|
120
114
|
// our commitment is registered, so read until it shows up.
|
|
121
115
|
const has = (g) => !!deps.members || g.some((c) => BigInt(c) === identity.commitment);
|
|
122
116
|
let group = await members(subject);
|
|
@@ -124,7 +118,7 @@ export async function buyRecord(quoteUrl, { privateKey, account = privateKeyToAc
|
|
|
124
118
|
await new Promise((r) => setTimeout(r, 3000));
|
|
125
119
|
group = await members(subject);
|
|
126
120
|
}
|
|
127
|
-
if (!has(group)) throw new Error("our commitment is registered but the RPC
|
|
121
|
+
if (!has(group)) throw new Error("our commitment is registered but the RPC doesn't show it yet; retry the purchase");
|
|
128
122
|
const proof = await (deps.prove ?? prove)({ identity, members: group, reader: reader.address, subject });
|
|
129
123
|
const res = await fetchImpl(`${relay}/relay/settle`, { method: "POST", headers: { "content-type": "application/json" },
|
|
130
124
|
body: JSON.stringify({ subject, reader: reader.address, ...proof }) });
|
|
@@ -135,7 +129,7 @@ export async function buyRecord(quoteUrl, { privateKey, account = privateKeyToAc
|
|
|
135
129
|
|
|
136
130
|
const opened = {};
|
|
137
131
|
for (const [field, handle] of Object.entries(fields)) {
|
|
138
|
-
const bytes = await openField({ handle, deposit, key: terms.key, field, signer: reader, storage:
|
|
132
|
+
const bytes = await openField({ handle, deposit, key: terms.key, field, signer: reader, storage: { getFile: async (cid) => held.get(cid) ?? storage.getFile(cid) }, fetch: fetchImpl });
|
|
139
133
|
opened[field] = JSON.parse(new TextDecoder().decode(bytes));
|
|
140
134
|
}
|
|
141
135
|
return { record, fields: opened, price: req.amount, tx };
|
|
@@ -188,6 +182,10 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
188
182
|
await buyRecord(quoteUrl, { account: buyer, fangorn, fetchImpl, deps, maxPrice: 9999n }).then(() => { throw new Error("over the cap must refuse"); },
|
|
189
183
|
(e) => { if (!/exceeds the cap/.test(e.message)) throw e; });
|
|
190
184
|
eq(signed, null, "nothing is signed over the cap");
|
|
185
|
+
const gone = { ...fangorn, getStorage: () => ({ getFile: async () => { throw new Error("HTTP 404"); } }) };
|
|
186
|
+
await buyRecord(quoteUrl, { account: buyer, fangorn: gone, fetchImpl, deps }).then(() => { throw new Error("unreadable data must refuse"); },
|
|
187
|
+
(e) => { if (!/can't be read/.test(e.message)) throw e; });
|
|
188
|
+
eq(signed, null, "nothing is paid for data that can't be read");
|
|
191
189
|
|
|
192
190
|
const got = await buyRecord(quoteUrl, { account: buyer, fangorn, fetchImpl, deps, maxPrice: 10000n });
|
|
193
191
|
eq([got.fields, got.price, got.tx], [{ body: { text: "the body" } }, "10000", "0xabc"], "paid, settled, and the field opens as JSON");
|
|
@@ -212,5 +210,5 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
212
210
|
{ status: 402, headers: { "PAYMENT-REQUIRED": b64(JSON.stringify({ x402Version: 2, accepts: [other] })) } }) : fetchImpl(url, init);
|
|
213
211
|
await buyRecord(quoteUrl, { account: privateKeyToAccount(generatePrivateKey()), fangorn, fetchImpl: lying, deps }).then(() => { throw new Error("must refuse"); },
|
|
214
212
|
(e) => { if (!/another record/.test(e.message)) throw e; });
|
|
215
|
-
console.log("x402.js self-check ok — the cap holds before signing, the payment is an ERC-3009 authorization bound to this purchase, the proof names a reader that isn't the wallet, a retry pays nothing, a quote for another record is refused, and the field opens");
|
|
213
|
+
console.log("x402.js self-check ok — the cap holds before signing, the payment is an ERC-3009 authorization bound to this purchase, the proof names a reader that isn't the wallet, a retry pays nothing, a quote for another record is refused, nothing is paid when the sealed data can't be read, and the field opens");
|
|
216
214
|
}
|
package/src/publish/devnet.js
CHANGED
|
@@ -30,8 +30,10 @@ const ADMIN_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f
|
|
|
30
30
|
* answers. Its own process group, so `kill` takes workerd down with wrangler. Local bindings only.
|
|
31
31
|
*/
|
|
32
32
|
export async function wranglerDev(dir, vars = {}, { config, probe = "/", name = dir, log = () => {} } = {}) {
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const free = () => new Promise((ok) => { const s = createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => ok(p)); }); });
|
|
34
|
+
// Its own inspector port too: two workers started at once both take wrangler's default (9229), and one dies.
|
|
35
|
+
const [port, inspector] = await Promise.all([free(), free()]);
|
|
36
|
+
const w = spawn("npx", ["wrangler", "dev", ...(config ? ["--config", config] : []), "--port", String(port), "--inspector-port", String(inspector), "--ip", "127.0.0.1",
|
|
35
37
|
"--persist-to", mkdtempSync(join(tmpdir(), "devnet-wrangler-")), ...Object.entries(vars).flatMap(([k, v]) => ["--var", `${k}:${v}`])],
|
|
36
38
|
{ cwd: dir, detached: true, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, NO_D1_WARNING: "true" } });
|
|
37
39
|
let out = "";
|
package/src/publish/init.js
CHANGED
|
@@ -41,7 +41,7 @@ export function init(app, dir = app, { install = true, log = console.log } = {})
|
|
|
41
41
|
}
|
|
42
42
|
log(`${app} is in ${dir}. Next:
|
|
43
43
|
1. app.json: the description, and the type's fields (role_map)
|
|
44
|
-
2. npx westmarch-ship app.json #
|
|
44
|
+
2. npx westmarch-ship app.json # creates the repo ${app}, deploys the Worker, registers its agent`);
|
|
45
45
|
return dir;
|
|
46
46
|
}
|
|
47
47
|
|
package/src/publish/ship.js
CHANGED
|
@@ -53,7 +53,7 @@ const log = (s) => console.error(`[ship] ${s}`);
|
|
|
53
53
|
// // (pages/intro.html, a fragment, replaces the description on the front page)
|
|
54
54
|
// "nav": [{ "href": "coverage.html", "label": "Coverage" }], // optional: header links to them
|
|
55
55
|
// "examples": ["…"], // optional: questions to start from, on the front page
|
|
56
|
-
// "plugin": {"marketplace": "owner/repo", "name": "…"}, // optional: its Claude Code plugin (hooks),
|
|
56
|
+
// "plugin": {"marketplace": "owner/repo", "name": "…"}, // optional: its Claude Code plugin (hooks), named in its card
|
|
57
57
|
// "skill": "skill/SKILL.md", // optional: the app's agent skill, served at skill/<app>/SKILL.md and named
|
|
58
58
|
// // in the card with its sha256; fangorn-index offers it to whoever opens the app
|
|
59
59
|
// "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
|
|
@@ -240,8 +240,8 @@ export async function buildForDeploy(configPath, wranglerPath = join(dirname(res
|
|
|
240
240
|
|
|
241
241
|
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false } = {}) {
|
|
242
242
|
const cfg = loadConfig(configPath);
|
|
243
|
-
// No sources is fine: the app then presents
|
|
244
|
-
if (!register && !Object.keys(cfg.types).length)
|
|
243
|
+
// No sources is fine: the app then presents every repo on chain (repos: [] = all of them), as the explorer does.
|
|
244
|
+
if (!register && cfg.sources.length && !Object.keys(cfg.types).length)
|
|
245
245
|
throw new Error(`${configPath}: no types yet, so nothing to build; --register sets up the app alone`);
|
|
246
246
|
if (!cfg.network) throw new Error(`${configPath}: "network" is required, e.g. "baseSepolia"`);
|
|
247
247
|
if (cfg.paid && !cfg.facilitator) throw new Error(`${configPath}: "paid" needs "facilitator", the URL of the x402 facilitator (Barliman) its quotes settle through`);
|
|
@@ -266,8 +266,9 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
266
266
|
const chain = createPublicClient({ transport: http(FangornConfig.rpcUrl) });
|
|
267
267
|
const repoDir = join(dir, "repo");
|
|
268
268
|
if (!(await fangorn.onChainTip(me, cfg.app))) {
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
// Its own records start now; an app presenting every repo (the explorer) reads from the deployment's start.
|
|
270
|
+
if (presentedRepos(cfg).length) state.fromBlock = String(await chain.getBlockNumber());
|
|
271
|
+
log(`creating the app's repo ${me}:${cfg.app}`);
|
|
271
272
|
mkdirSync(repoDir, { recursive: true });
|
|
272
273
|
fangornCli(["repo", "init", cfg.app], { cwd: repoDir, dry });
|
|
273
274
|
save();
|
package/src/publish/view.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
//
|
|
20
20
|
// The layout is fixed, so any reader (shard.js, a tab, an agent) reads any app's view:
|
|
21
21
|
// - URL layout, domain names (`domainFor`: the publisher's address prefix and the repo),
|
|
22
|
-
// row shape {track_id, fields, v}, manifest and catalog keys;
|
|
22
|
+
// row shape {track_id, owner, fields, v}, manifest and catalog keys;
|
|
23
23
|
// - a record is a row: track_id = its CID, fields = payload + entityType (its type);
|
|
24
24
|
// - the schema: a declared type is embedded and shipped with exactly its role map;
|
|
25
25
|
// - the embedded text (`composeText`).
|
|
@@ -146,11 +146,10 @@ export async function network(name = process.env.FANGORN_NETWORK || "baseSepolia
|
|
|
146
146
|
return SupportedNetworks[name];
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
/** The IPFS gateway to read contents through: FANGORN_IPFS_GATEWAY, else the
|
|
150
|
-
*
|
|
149
|
+
/** The IPFS gateway to read contents through: FANGORN_IPFS_GATEWAY, else none (the SDK
|
|
150
|
+
* default, Fangorn's gateway). Not the deployer's CLI config: that would ship into the Worker. */
|
|
151
151
|
export function ipfsGateway() {
|
|
152
|
-
|
|
153
|
-
try { gateway ??= JSON.parse(readFileSync(`${process.env.HOME}/.fangorn/config.json`, "utf8")).pinataGateway || undefined; } catch { /* no CLI config */ }
|
|
152
|
+
const gateway = process.env.FANGORN_IPFS_GATEWAY || process.env.IPFS_GATEWAY;
|
|
154
153
|
return gateway && !/^https?:\/\//.test(gateway) ? `https://${gateway}` : gateway;
|
|
155
154
|
}
|
|
156
155
|
|
|
@@ -447,7 +446,7 @@ async function publishDomain({ fangorn, store, schema, schemaHash, publisher, re
|
|
|
447
446
|
const ids = fresh.slice(i, i + 32);
|
|
448
447
|
try {
|
|
449
448
|
const vs = await embedBatch(ids.map((id) => composeText(fieldsOf(id), rolesOf.get(fieldsOf(id).entityType))));
|
|
450
|
-
ids.forEach((id, j) => lines.push(JSON.stringify({ track_id: id, fields: fieldsOf(id), v: packVec(vs[j]) })));
|
|
449
|
+
ids.forEach((id, j) => lines.push(JSON.stringify({ track_id: id, owner: publisher, fields: fieldsOf(id), v: packVec(vs[j]) })));
|
|
451
450
|
} catch (e) {
|
|
452
451
|
// The encoder gave out (Workers AI's free daily allowance, say): keep what is embedded
|
|
453
452
|
// and come back for the rest. Nothing served yet, nothing to save: fail as before.
|
|
@@ -543,6 +542,8 @@ export async function writeCatalog(store) {
|
|
|
543
542
|
generated_at: Math.floor(Date.now() / 1000), embedding_model: EMBED_MODEL,
|
|
544
543
|
domains: domains.map((m) => ({
|
|
545
544
|
name: m.name, description: m.description, count: m.count, dim: m.dim,
|
|
545
|
+
// Whose it is: a reader can list publishers without opening a manifest.
|
|
546
|
+
...(m.filter?.owner?.length === 1 ? { publisher: m.filter.owner[0], repo: m.filter.repo?.[0] } : {}),
|
|
546
547
|
bytes: m.shards.reduce((s, x) => s + x.bytes, 0), shard_count: m.shards.length,
|
|
547
548
|
entity_types: m.entity_types.map((e) => e.type), manifest: `${m.name}/manifest.json`,
|
|
548
549
|
...(m.link_hosts?.length ? { link_hosts: m.link_hosts } : {}),
|
package/template/README.md
CHANGED
|
@@ -9,15 +9,13 @@ indexes it every 5 minutes, and serves the site, the view, `/mcp` and the agent
|
|
|
9
9
|
|
|
10
10
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/fangorn-network/westmarch/tree/main/template)
|
|
11
11
|
|
|
12
|
-
1.
|
|
13
|
-
2. Press the button. Name the Worker after the app (`my-app`) and its R2 bucket
|
|
12
|
+
1. Press the button. Name the Worker after the app (`my-app`) and its R2 bucket
|
|
14
13
|
`my-app-view`. Cloudflare copies this repo into your GitHub, makes the bucket and
|
|
15
14
|
deploys; every push to `main` deploys again.
|
|
16
|
-
|
|
17
|
-
app
|
|
15
|
+
2. At fangorn.network, give the app its Worker's URL. Your wallet creates the app's repo,
|
|
16
|
+
`<your wallet>:my-app`, and binds its card there.
|
|
18
17
|
|
|
19
|
-
An app name with a dot or an underscore can't be a Worker name
|
|
20
|
-
`app.json` to the name you claimed.
|
|
18
|
+
An app name with a dot or an underscore can't be a Worker name.
|
|
21
19
|
|
|
22
20
|
`npm run build` (`westmarch build`) is what Cloudflare runs: it builds the site and writes
|
|
23
21
|
the app's config into `wrangler.json`, without signing or deploying anything.
|
|
@@ -33,9 +31,9 @@ Any wallet commits records of its type, from anywhere:
|
|
|
33
31
|
|
|
34
32
|
```sh
|
|
35
33
|
echo '{"records":[{"id":"first","type":"my-app.item.v1","payload":{"id":"first","title":"Hello","text":"The first item.","tags":["demo"]}}]}' > items.json
|
|
36
|
-
fangorn
|
|
37
|
-
fangorn
|
|
38
|
-
fangorn
|
|
34
|
+
fangorn repo init items
|
|
35
|
+
fangorn commit items.json -m "first items"
|
|
36
|
+
fangorn push
|
|
39
37
|
```
|
|
40
38
|
|
|
41
39
|
The view picks it up within 5 minutes at `<url>/view`. A source that crawls something
|
package/plugin/scripts/fx.mjs
DELETED
|
@@ -1,266 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// The Fangorn app index, headless. One command per verb, JSON on stdout.
|
|
3
|
-
//
|
|
4
|
-
// WHY THERE IS NO MCP SERVER HERE
|
|
5
|
-
// -------------------------------
|
|
6
|
-
// An index that POINTS AT apps rather than hosting them needs no runtime of its
|
|
7
|
-
// own. Every verb below is a function `@fangorn/westmarch` already exports, and
|
|
8
|
-
// the agent's shell is the transport. Nobody has to stand up a server to be in
|
|
9
|
-
// the index — a publisher bakes a view, and that is the whole obligation.
|
|
10
|
-
//
|
|
11
|
-
// WHY THIS IS A PLUGIN AND NOT A PAGE
|
|
12
|
-
// -----------------------------------
|
|
13
|
-
// One thing here is not stateless: the taste kernel. It lives in
|
|
14
|
-
// ~/.fangorn/taste.json — on the filesystem, between the human and the agent —
|
|
15
|
-
// because it has to outlive any one app, any one tab and any one ORIGIN.
|
|
16
|
-
// localStorage cannot do that: a taste learned in fangorn.tv is invisible to the
|
|
17
|
-
// next app by construction, which is the whole reason apps keep rebuilding it.
|
|
18
|
-
// A file the agent can read is the shortest thing that carries it across.
|
|
19
|
-
//
|
|
20
|
-
// ponytail: a plaintext file, no wallet, no sync. `src/taste/vault.js` is the
|
|
21
|
-
// upgrade path — same object, sealed to a signature, kept in the graph — and it
|
|
22
|
-
// is worth doing the day the kernel needs to travel between machines, not now.
|
|
23
|
-
// ponytail: every command re-fetches its view. Fine at 917 rows, ~seconds at
|
|
24
|
-
// 42k; add a disk cache keyed by view URL when a big corpus makes it hurt.
|
|
25
|
-
|
|
26
|
-
import { homedir } from "node:os";
|
|
27
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
-
import { join } from "node:path";
|
|
29
|
-
import { configure, domainManifests, loadShard, trimView } from "../../src/core/shard.js";
|
|
30
|
-
import { findCorpora, sourcesFromChain } from "../../src/discover/directory.js";
|
|
31
|
-
import { linkOf, rolesFrom, textOf, titleOf } from "../../src/core/roles.js";
|
|
32
|
-
import { exportTaste, recommend, taste } from "../../src/taste/taste.js";
|
|
33
|
-
import { EMBED_MODEL, embedQuery, packVec, unpackVec } from "../../src/core/embed.js";
|
|
34
|
-
import { browse, describe, facet, getRow, neighbors, search } from "../../src/agent/tools.js";
|
|
35
|
-
|
|
36
|
-
const HOME = process.env.FANGORN_HOME ?? join(homedir(), ".fangorn");
|
|
37
|
-
const TASTE = join(HOME, "taste.json");
|
|
38
|
-
|
|
39
|
-
// ── the kernel on disk ───────────────────────────────────────────────────────
|
|
40
|
-
const store = () => { try { return JSON.parse(readFileSync(TASTE, "utf8")); } catch { return { model: EMBED_MODEL, likes: [], dislikes: [] }; } };
|
|
41
|
-
const save = (s) => { mkdirSync(HOME, { recursive: true }); writeFileSync(TASTE, JSON.stringify(s, null, 1)); };
|
|
42
|
-
/** Picks are stored PACKED (base64 int8, the shard encoding) and unpacked here.
|
|
43
|
-
* Storing the picks rather than only the derived vector is what keeps the
|
|
44
|
-
* kernel correctable: you can read what it was built from and drop one. */
|
|
45
|
-
const picks = (l = []) => l.map((p) => ({ ...p, vector: unpackVec(p.v) })).filter((p) => p.vector);
|
|
46
|
-
const kernel = (s = store()) => taste(picks(s.likes), picks(s.dislikes));
|
|
47
|
-
|
|
48
|
-
// ── args ─────────────────────────────────────────────────────────────────────
|
|
49
|
-
const argv = process.argv.slice(2);
|
|
50
|
-
const [cmd, ...rest] = argv;
|
|
51
|
-
const pos = rest.filter((a) => !a.startsWith("--"));
|
|
52
|
-
const flag = (n, d) => { const a = rest.find((x) => x.startsWith(`--${n}=`)); return a === undefined ? d : a.slice(n.length + 3); };
|
|
53
|
-
const num = (n, d) => { const v = flag(n); return v === undefined ? d : Number(v); };
|
|
54
|
-
const pairs = (s) => s ? Object.fromEntries(String(s).split(",").map((p) => p.split("=").map((x) => x.trim()))) : undefined;
|
|
55
|
-
const list = (s) => String(s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
56
|
-
const out = (o) => console.log(JSON.stringify(o, null, 1));
|
|
57
|
-
const die = (m) => { console.error(m); process.exit(1); };
|
|
58
|
-
|
|
59
|
-
// ── a view, loaded ───────────────────────────────────────────────────────────
|
|
60
|
-
let roles = rolesFrom([], []);
|
|
61
|
-
async function load(view) {
|
|
62
|
-
if (!view) die("need a view URL");
|
|
63
|
-
const base = trimView(view);
|
|
64
|
-
configure({
|
|
65
|
-
resolveView: () => base,
|
|
66
|
-
// Roles must be ready BEFORE the first row is parsed: rowText runs during
|
|
67
|
-
// parsing, so deriving them afterwards leaves every row with empty text
|
|
68
|
-
// and lexical search silently matching nothing.
|
|
69
|
-
onManifests: (ms) => { roles = rolesFrom(ms); },
|
|
70
|
-
rowText: (f) => textOf(f, roles),
|
|
71
|
-
});
|
|
72
|
-
const rows = await loadShard(base);
|
|
73
|
-
roles = rolesFrom(domainManifests(), rows);
|
|
74
|
-
let model = null;
|
|
75
|
-
try { model = (await (await fetch(`${base}/cdn/catalog`)).json()).embedding_model ?? null; } catch { /* unranked, not fatal */ }
|
|
76
|
-
return { rows, roles, base, model };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** A query vector, or null with the reason. Never throws: every verb here
|
|
80
|
-
* degrades to lexical and SAYS so rather than returning confident nonsense. */
|
|
81
|
-
const qvOf = async (q) => { try { return { qv: await embedQuery(q), why: null }; } catch (e) { return { qv: null, why: e?.message ?? String(e) }; } };
|
|
82
|
-
|
|
83
|
-
/** The corpus and the kernel must share an embedding space or cosine between
|
|
84
|
-
* them is noise that still looks like a score. */
|
|
85
|
-
const mismatch = (model, s) => model && s.model && model !== s.model
|
|
86
|
-
? `taste was built with ${s.model}, this corpus is ${model} — scores would be noise` : null;
|
|
87
|
-
|
|
88
|
-
const sources = async () => {
|
|
89
|
-
const f = flag("sources"); if (f) return list(f);
|
|
90
|
-
if (process.env.FANGORN_SOURCES) return list(process.env.FANGORN_SOURCES);
|
|
91
|
-
try { return JSON.parse(readFileSync(join(HOME, "sources.json"), "utf8")); } catch { /* none pinned */ }
|
|
92
|
-
return chainSources();
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
// Every app with a verified card, the scan fangorn-mcp's list-apps runs, on FANGORN_NETWORK
|
|
96
|
-
// (default baseSepolia). From the DataRegistry's deployment; --from-block overrides.
|
|
97
|
-
async function chainSources() {
|
|
98
|
-
const [{ Fangorn }, { generatePrivateKey }, { network }] = await Promise.all([
|
|
99
|
-
import("@fangorn-network/sdk"), import("viem/accounts"), import("../../src/publish/view.js")]);
|
|
100
|
-
const config = await network();
|
|
101
|
-
const fangorn = Fangorn.create({ privateKey: generatePrivateKey(), config }); // reads only
|
|
102
|
-
const { sources } = await sourcesFromChain(fangorn, { fromBlock: BigInt(flag("from-block", String(config.fromBlock))) });
|
|
103
|
-
return sources;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// ── verbs ────────────────────────────────────────────────────────────────────
|
|
107
|
-
switch (cmd) {
|
|
108
|
-
|
|
109
|
-
// WHICH app has this. Downloads no corpus — coverage centroids only, ~4KB each.
|
|
110
|
-
case "find": {
|
|
111
|
-
const src = await sources();
|
|
112
|
-
if (!src.length) die("no apps found on chain; pass --sources=a,b or write ~/.fangorn/sources.json");
|
|
113
|
-
out(await findCorpora(pos.join(" "), { sources: src, embed: embedQuery, model: EMBED_MODEL, limit: num("limit", 10) }));
|
|
114
|
-
break;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
case "describe": {
|
|
118
|
-
const { rows, roles: r, model } = await load(pos[0]);
|
|
119
|
-
out({
|
|
120
|
-
...describe(rows), model,
|
|
121
|
-
shape: r.declared ? "declared" : "sniffed",
|
|
122
|
-
title: r.title, subtitle: r.subtitle, tags: r.tags, text: r.text,
|
|
123
|
-
entityTypes: r.entityTypes, gates: r.gates,
|
|
124
|
-
// Where the reader goes next. The index's job ends here.
|
|
125
|
-
launch: r.launch, externalUrl: r.externalUrl, actions: r.actions,
|
|
126
|
-
});
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
case "browse": {
|
|
131
|
-
const { rows, roles: r } = await load(pos[0]);
|
|
132
|
-
out(browse(rows, r, { type: flag("type"), where: pairs(flag("where")), sort: flag("sort"),
|
|
133
|
-
desc: flag("asc") === undefined, limit: num("limit", 20), offset: num("offset", 0), fields: list(flag("fields")) }));
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
case "search": {
|
|
138
|
-
const { rows, roles: r } = await load(pos[0]);
|
|
139
|
-
const q = pos.slice(1).join(" ");
|
|
140
|
-
const { qv, why } = await qvOf(q);
|
|
141
|
-
out({ query: q, mode: qv ? "semantic where rows have vectors" : "lexical", ...(why ? { why } : {}),
|
|
142
|
-
hits: search(rows, q, r, { qv, limit: num("limit", 10), where: pairs(flag("where")), fields: list(flag("fields")) }) });
|
|
143
|
-
break;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
case "facet": {
|
|
147
|
-
const { rows } = await load(pos[0]);
|
|
148
|
-
out(facet(rows, pos[1], { limit: num("limit", 20), where: pairs(flag("where")) }));
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
case "row": {
|
|
153
|
-
const { rows, roles: r } = await load(pos[0]);
|
|
154
|
-
const g = getRow(rows, pos[1], r);
|
|
155
|
-
out(g ? { ...g, url: linkOf(rows.find((x) => x.id === g.id) ?? {}, r) } : null);
|
|
156
|
-
break;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
case "similar": {
|
|
160
|
-
const { rows, roles: r } = await load(pos[0]);
|
|
161
|
-
out(neighbors(rows, pos[1], r, { limit: num("limit", 10), fields: list(flag("fields")) }));
|
|
162
|
-
break;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── the kernel ───────────────────────────────────────────────────────────────
|
|
166
|
-
case "like": case "dislike": {
|
|
167
|
-
const { rows, roles: r, base, model } = await load(pos[0]);
|
|
168
|
-
const s = store();
|
|
169
|
-
const into = cmd === "like" ? s.likes : s.dislikes;
|
|
170
|
-
const added = [];
|
|
171
|
-
for (const id of pos.slice(1)) {
|
|
172
|
-
const row = rows.find((x) => x.id === id);
|
|
173
|
-
if (!row) { console.error(`no row ${id}`); continue; }
|
|
174
|
-
if (!row.vector) { console.error(`${id} has no vector — nothing to learn from`); continue; }
|
|
175
|
-
// Newest last: taste() weights by recency, so order in the file is signal.
|
|
176
|
-
const at = into.findIndex((p) => p.id === id);
|
|
177
|
-
if (at >= 0) into.splice(at, 1);
|
|
178
|
-
into.push({ id, title: titleOf(row, r), view: base, at: new Date().toISOString().slice(0, 10), v: packVec(Array.from(row.vector)) });
|
|
179
|
-
added.push(id);
|
|
180
|
-
}
|
|
181
|
-
s.model ??= model ?? EMBED_MODEL;
|
|
182
|
-
save(s);
|
|
183
|
-
const t = kernel(s);
|
|
184
|
-
out({ added, likes: s.likes.length, dislikes: s.dislikes.length, taste: t && exportTaste(t), file: TASTE });
|
|
185
|
-
break;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
case "forget": {
|
|
189
|
-
const s = store();
|
|
190
|
-
const drop = new Set(pos);
|
|
191
|
-
const before = s.likes.length + s.dislikes.length;
|
|
192
|
-
s.likes = s.likes.filter((p) => !drop.has(p.id));
|
|
193
|
-
s.dislikes = s.dislikes.filter((p) => !drop.has(p.id));
|
|
194
|
-
save(s);
|
|
195
|
-
out({ removed: before - s.likes.length - s.dislikes.length, likes: s.likes.length, dislikes: s.dislikes.length });
|
|
196
|
-
break;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
case "taste": {
|
|
200
|
-
const s = store();
|
|
201
|
-
const t = kernel(s);
|
|
202
|
-
out(t ? { model: s.model, n: t.n, from: t.from, rejected: t.rejected, drift: t.drift,
|
|
203
|
-
// Where each pick was learned. A kernel built in one publisher's
|
|
204
|
-
// corpus is meant to be pointed at another's — this is the record
|
|
205
|
-
// of whether it actually has been.
|
|
206
|
-
views: [...new Set([...s.likes, ...s.dislikes].map((p) => p.view))],
|
|
207
|
-
export: exportTaste(t), file: TASTE }
|
|
208
|
-
: { n: 0, file: TASTE, note: "nothing liked yet — `fx like <view> <id>`" });
|
|
209
|
-
break;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// The headline: rank a corpus by a taste learned somewhere else entirely.
|
|
213
|
-
case "recommend": {
|
|
214
|
-
const { rows, roles: r, model } = await load(pos[0]);
|
|
215
|
-
const s = store();
|
|
216
|
-
const t = kernel(s);
|
|
217
|
-
if (!t) die("no taste yet — `fx like <view> <id>` first");
|
|
218
|
-
const warn = mismatch(model, s);
|
|
219
|
-
if (warn) die(warn);
|
|
220
|
-
const seen = new Set([...s.likes, ...s.dislikes].map((p) => p.id));
|
|
221
|
-
out({ from: t.from, against: pos[0],
|
|
222
|
-
hits: recommend(rows, t, { limit: num("limit", 10), exclude: seen })
|
|
223
|
-
.map(({ row, score }) => ({ id: row.id, title: titleOf(row, r), score, url: linkOf(row, r) })) });
|
|
224
|
-
break;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// ── the handoff. The index points; the app does the work. ────────────────────
|
|
228
|
-
case "launch": {
|
|
229
|
-
const { rows, roles: r } = await load(pos[0]);
|
|
230
|
-
const id = flag("row");
|
|
231
|
-
if (id) {
|
|
232
|
-
const row = rows.find((x) => x.id === id);
|
|
233
|
-
if (!row) die(`no row ${id}`);
|
|
234
|
-
out({ url: linkOf(row, r), title: titleOf(row, r) });
|
|
235
|
-
break;
|
|
236
|
-
}
|
|
237
|
-
if (!r.launch) die("this corpus declares no presentation.launch — nowhere to hand off to");
|
|
238
|
-
const t = kernel();
|
|
239
|
-
const fill = { taste: t ? JSON.stringify(exportTaste(t)) : "", q: pos.slice(1).join(" ") };
|
|
240
|
-
const url = String(r.launch).replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(fill[k] ?? ""));
|
|
241
|
-
const u = new URL(url);
|
|
242
|
-
if (u.protocol !== "https:" && u.protocol !== "http:") die(`refusing a ${u.protocol} launch URL`);
|
|
243
|
-
// The kernel travels in the URL because that is the only channel a page on
|
|
244
|
-
// someone else's origin can receive it on. It is the app's copy from here.
|
|
245
|
-
out({ url: u.toString(), carries: t ? { n: t.n, from: t.from } : null });
|
|
246
|
-
break;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
default:
|
|
250
|
-
console.log(`fx <verb>
|
|
251
|
-
|
|
252
|
-
find <query> [--sources=a,b|--from-block=N] which app has this. No corpus downloaded.
|
|
253
|
-
describe <view> rows, field coverage, roles, where it hands off
|
|
254
|
-
browse <view> [--type= --where=k=v --sort= --asc --limit= --offset=]
|
|
255
|
-
search <view> <query> [--where=k=v --limit=] semantic where rows have vectors, else lexical
|
|
256
|
-
facet <view> <field> [--where=k=v --limit=] count a field's values
|
|
257
|
-
row <view> <id> one record, whole
|
|
258
|
-
similar <view> <id> [--limit=] nearest by vector
|
|
259
|
-
|
|
260
|
-
like|dislike <view> <id...> teach the kernel. Writes ${TASTE}
|
|
261
|
-
forget <id...> drop picks from it
|
|
262
|
-
taste what it is, and what it was built from
|
|
263
|
-
recommend <view> [--limit=] rank ANY corpus by it
|
|
264
|
-
|
|
265
|
-
launch <view> [query] | launch <view> --row=<id> the handoff URL, carrying the kernel`);
|
|
266
|
-
}
|