@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.
@@ -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
- return link ? { ...out, url: link } : out;
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
  /**
@@ -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 (fangorn-mcp: on find-apps), and a search
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, parseAbiItem, parseSignature, toHex } from "viem";
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(pub, settlement.getAddress(), s, { fromBlock: config.fromBlock, window: config.logWindow ?? 1000n }));
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 getLogs from a node behind the one that mined `register`:
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's logs don't show it yet; retry the purchase");
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: fangorn.getStorage(), fetch: fetchImpl });
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
  }
@@ -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 port = await new Promise((ok) => { const s = createServer().listen(0, "127.0.0.1", () => { const p = s.address().port; s.close(() => ok(p)); }); });
34
- const w = spawn("npx", ["wrangler", "dev", ...(config ? ["--config", config] : []), "--port", String(port), "--ip", "127.0.0.1",
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 = "";
@@ -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 # claims ${app} if it isn't yet, deploys the Worker, registers its agent`);
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
 
@@ -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), offered by open-app
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 whatever its members commit (repos: [] = all of them).
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
- state.fromBlock = String(await chain.getBlockNumber());
270
- log(`creating the app's repo ${me}:${cfg.app} (fromBlock ${state.fromBlock})`);
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();
@@ -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 one in
150
- * ~/.fangorn/config.json (what the fangorn CLI reads through), else none (the SDK default). */
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
- let gateway = process.env.FANGORN_IPFS_GATEWAY ?? process.env.IPFS_GATEWAY;
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 } : {}),
@@ -9,15 +9,13 @@ indexes it every 5 minutes, and serves the site, the view, `/mcp` and the agent
9
9
 
10
10
  [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/fangorn-network/westmarch/tree/main/template)
11
11
 
12
- 1. Claim the app at [fangorn.network](https://fangorn.network).
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
- 3. Back at fangorn.network, give the app its Worker's URL. Your wallet registers the
17
- app's agent and binds its card there.
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: set `"app"` in
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 --app my-app repo init items
37
- fangorn --app my-app commit items.json -m "first items"
38
- fangorn --app my-app push
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
@@ -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
- }