@fangorn-network/westmarch 0.0.1-dev → 0.0.3-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.
Files changed (53) hide show
  1. package/README.md +2 -2
  2. package/consume/apps.js +4 -2
  3. package/consume/corpora.js +1 -1
  4. package/consume/directory.js +1 -1
  5. package/consume/embed.js +1 -1
  6. package/consume/lint.js +1 -1
  7. package/consume/mcp-bin.js +6 -0
  8. package/consume/mcp.js +193 -34
  9. package/consume/ort-preload.js +25 -0
  10. package/consume/rank.js +1 -1
  11. package/consume/roles.js +6 -4
  12. package/consume/shard.js +1 -1
  13. package/consume/steam.js +1 -1
  14. package/consume/taste-doc.js +1 -1
  15. package/consume/taste.js +1 -1
  16. package/consume/tools.js +28 -5
  17. package/consume/ui.js +1 -1
  18. package/consume/view-tools.js +202 -0
  19. package/consume/x402.js +94 -0
  20. package/dist/ts/eval.js +244 -0
  21. package/docs/app-to-agent.md +595 -185
  22. package/package.json +24 -10
  23. package/publish/agent-card.js +12 -2
  24. package/publish/cohort.js +1 -1
  25. package/publish/demand.js +1 -1
  26. package/publish/enrich.js +1 -1
  27. package/publish/envelope.js +1 -1
  28. package/publish/eval.ts +250 -0
  29. package/publish/graph.js +1 -1
  30. package/publish/manifest.js +1 -1
  31. package/publish/reactions.js +4 -2
  32. package/publish/settle.js +1 -1
  33. package/publish/ship.js +462 -0
  34. package/publish/terms.js +1 -1
  35. package/publish/vault.js +1 -1
  36. package/publish/view-cli.js +39 -0
  37. package/publish/view.js +434 -0
  38. package/publish/westmarch.d.ts +35 -0
  39. package/site/agent.js +44 -0
  40. package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
  41. package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
  42. package/site/dist/assets/index-CaHGhiKK.js +8 -0
  43. package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  44. package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
  45. package/site/dist/assets/roles-C9V_uDr_.js +1 -0
  46. package/site/dist/assets/roles-YDRjv_PY.js +1 -0
  47. package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  48. package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
  49. package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
  50. package/site/dist/index.html +20 -0
  51. package/site/feed.js +62 -0
  52. package/site/store.js +87 -0
  53. package/site/worker.js +103 -0
@@ -0,0 +1,462 @@
1
+ #!/usr/bin/env node
2
+ // westmarch-ship: one config, one command, a live Fangorn app.
3
+ //
4
+ // westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run]
5
+ // westmarch-ship app.json --local <out> [--crawl] a view from staged records, no chain (see buildLocal)
6
+ //
7
+ // Every step checks the chain (or Cloudflare) first and only acts on a difference, so
8
+ // re-running is how you update: new data, a changed schema, a new source, a moved site.
9
+ //
10
+ // 1. claim the app (first run only; the claim block becomes the card's fromBlock)
11
+ // 2. join it as a publisher
12
+ // 3. commit the schema, if the one on chain differs from app.json's `types`
13
+ // 4. run each source, which crawls and publishes straight into the app
14
+ // 5. westmarch-view: the app's commits → site/view
15
+ // 6. the stock page, the card and _headers → site/
16
+ // 7. deploy to Cloudflare Pages
17
+ // 8. register the ERC-8004 agent and bind the card, if the chain points elsewhere
18
+ //
19
+ // Chain writes go through the `fangorn` CLI (its configured wallet signs, and
20
+ // `fangorn wallet` shows which). Reads go through the SDK. State lives in `.ship/` next
21
+ // to app.json: the site, the view (its own state, see view.js) and a small state.json.
22
+
23
+ import { spawnSync } from "node:child_process";
24
+ import { createHash } from "node:crypto";
25
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
26
+ import { createRequire } from "node:module";
27
+ import { dirname, join, resolve } from "node:path";
28
+ import { homedir } from "node:os";
29
+ import { fileURLToPath, pathToFileURL } from "node:url";
30
+
31
+ const HERE = dirname(fileURLToPath(import.meta.url));
32
+ const USDC = { "arbitrum-sepolia": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
33
+ "base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" };
34
+ const STOCK = join(HERE, "../site/dist");
35
+ const log = (s) => console.error(`[ship] ${s}`);
36
+
37
+ // ── config ────────────────────────────────────────────────────────────────────
38
+ //
39
+ // {
40
+ // "app": "quorum", "name": "Quorum", "description": "…", "tags": ["civic"],
41
+ // "site": { "project": "quorum", "account": "<cloudflare account id>",
42
+ // "pages": "pages", // optional: the app's own files, copied over the stock page
43
+ // "nav": [{ "href": "coverage.html", "label": "Coverage" }], // optional: header links to them
44
+ // "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
45
+ // // `pages` replaces the stock index.html with a page of its own
46
+ // "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
47
+ // "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
48
+ // "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
49
+ // "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js"
50
+ // "paid": { "price": "0.01", "network": "arbitrum-sepolia", "description": "…" }
51
+ // }
52
+ //
53
+ // `paid` sells per-record detail over x402 (site/worker.js): each source that names a
54
+ // `paid_dir` gets `--paid-dir` and writes one JSON per record there; they are deployed
55
+ // behind the worker, and the price goes in the card. The payee is the app owner unless
56
+ // `paid.payTo` says otherwise; the asset is USDC on `paid.network` unless `paid.asset` does.
57
+ //
58
+ // A source is any command that publishes into `--namespace` when given `--publish`:
59
+ // quickbeam's scraper harness does, so every quickbeam Source is a source here. It runs
60
+ // with FANGORN_APP_ID set, so it publishes into this app whatever the CLI's saved app is.
61
+
62
+ /** `{field}`, `{field|slug}` and `{field|lower}` from a row; an argv item that is exactly
63
+ * `{args...}` splices the row's own `args` array (for flags only some rows need). */
64
+ export function fill(template, row) {
65
+ const one = (s) => s.replace(/\{(\w+)(?:\|(slug|lower))?\}/g, (_, k, f) => {
66
+ if (row[k] == null) throw new Error(`template needs "${k}", which ${JSON.stringify(row)} lacks`);
67
+ const v = String(row[k]);
68
+ return f === "slug" ? v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") : f === "lower" ? v.toLowerCase() : v;
69
+ });
70
+ return Array.isArray(template) ? template.flatMap((t) => (t === "{args...}" ? row.args ?? [] : [one(t)])) : one(template);
71
+ }
72
+
73
+ /** A source with `each: "towns.json"` is a template: one source per row of that file
74
+ * (rows with `"skip": true` are left out). Adding a town is adding a row. */
75
+ export function expandSources(sources, base) {
76
+ return sources.flatMap((s) => {
77
+ if (!s.each) return [s];
78
+ const rows = JSON.parse(readFileSync(resolve(base, s.each), "utf8")).filter((r) => !r.skip);
79
+ return rows.map((r) => ({ ...s, each: undefined, row: r, namespace: fill(s.namespace, r), command: fill(s.command, r),
80
+ ...(s.paid_dir ? { paid_dir: fill(s.paid_dir, r) } : {}) }));
81
+ });
82
+ }
83
+
84
+ export function loadConfig(path) {
85
+ const c = JSON.parse(readFileSync(path, "utf8"));
86
+ c.sources = expandSources(c.sources ?? [], dirname(resolve(path)));
87
+ for (const k of ["app", "name", "description", "types", "sources", "site"])
88
+ if (!c[k]) throw new Error(`${path}: missing "${k}"`);
89
+ if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Pages project`);
90
+ for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
91
+ throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
92
+ return { relations: [], tags: [], ...c };
93
+ }
94
+
95
+ /** The schema commit for `types` + `relations`: one fangorn.type.v1 vertex per type,
96
+ * one edge per allowed relation. */
97
+ export function schemaGraph(types, relations = []) {
98
+ const id = (tag) => `type:${tag}`;
99
+ return {
100
+ vertices: Object.entries(types).map(([tag, t]) => ({ id: id(tag), tag: "fangorn.type.v1", payload: {
101
+ tag, description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} } })),
102
+ edges: relations.map((r) => ({ rel: r.rel, from: id(r.from), to: id(r.to) })),
103
+ };
104
+ }
105
+
106
+ /** Keys sorted at every depth: the chain stores dag-cbor, whose maps come back in
107
+ * canonical key order, not the order app.json wrote them in. */
108
+ const stable = (v) => Array.isArray(v) ? v.map(stable)
109
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, stable(v[k])])) : v;
110
+
111
+ /** Same shape parseSchema returns, so a config and the chain compare directly. */
112
+ const canonical = (types, relations = []) => JSON.stringify(stable({
113
+ types: Object.fromEntries(Object.entries(types).sort(([a], [b]) => a.localeCompare(b))
114
+ .map(([tag, t]) => [tag, { description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} }])),
115
+ relations: [...relations].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
116
+ }));
117
+
118
+ // ── processes ─────────────────────────────────────────────────────────────────
119
+
120
+ /** `${VAR}` and `${VAR:-default}` from the environment, so one app.json runs on a laptop
121
+ * and in CI (e.g. `"${PYTHON:-python3}"`). */
122
+ export const expandEnv = (s) => s.replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (_, k, d) => process.env[k] ?? d ?? "");
123
+
124
+ function run(cmd, args, { cwd, env, capture = false, dry = false } = {}) {
125
+ cmd = expandEnv(cmd); args = args.map(expandEnv);
126
+ log(`$ ${[cmd, ...args].join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
127
+ if (dry) return "";
128
+ const r = spawnSync(cmd, args, { cwd, env: { ...process.env, ...env }, encoding: "utf8",
129
+ stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit" });
130
+ if (r.status !== 0) throw new Error(`${cmd} ${args[0]} failed (exit ${r.status})`);
131
+ return r.stdout ?? "";
132
+ }
133
+ const expand = (p) => p.replace(/^~(?=\/|$)/, homedir());
134
+ const cliArgv = (cfg) => expandEnv(process.env.FANGORN_CLI ?? cfg.fangorn ?? "fangorn").split(/\s+/).filter(Boolean).map(expand);
135
+ let CLI = ["fangorn"];
136
+ const fangornCli = (app, args, opts = {}) => run(CLI[0], [...CLI.slice(1), "--app", app, ...args], { capture: true, ...opts });
137
+
138
+ /** The CLI must be the SDK version westmarch reads with. A different one signs against
139
+ * another deployment's contracts: its claim and commits land where nothing here looks,
140
+ * and every read says the app is unclaimed. (It happened: a global 2026.08.18-dev CLI
141
+ * claimed and published onto the previous registries.) */
142
+ function assertSameDeployment(cli) {
143
+ const which = (c) => spawnSync("sh", ["-c", `command -v ${c}`], { encoding: "utf8" }).stdout.trim();
144
+ const script = cli.length > 1 && /(^|\/)node$/.test(cli[0]) ? cli[1] : which(cli[0]);
145
+ if (!script) throw new Error(`fangorn CLI not found: ${cli.join(" ")}`);
146
+ let d = dirname(realpathSync(script)), cliVersion = null;
147
+ for (; d !== dirname(d); d = dirname(d)) {
148
+ const pj = join(d, "package.json");
149
+ if (existsSync(pj)) { const p = JSON.parse(readFileSync(pj, "utf8")); if (p.name === "@fangorn-network/sdk") { cliVersion = p.version; break; } }
150
+ }
151
+ const ours = createRequire(import.meta.url)("@fangorn-network/sdk/package.json").version;
152
+ if (cliVersion !== ours) throw new Error(`the fangorn CLI (${cli.join(" ")}) is SDK ${cliVersion ?? "?"}, westmarch reads with ${ours}: ` +
153
+ "they talk to different contracts. Point app.json's \"fangorn\" at a matching CLI.");
154
+ }
155
+
156
+ // ── the steps ─────────────────────────────────────────────────────────────────
157
+
158
+ export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null } = {}) {
159
+ const cfg = loadConfig(configPath);
160
+ CLI = cliArgv(cfg);
161
+ assertSameDeployment(CLI);
162
+ const base = dirname(resolve(configPath));
163
+ const dir = join(base, ".ship");
164
+ const statePath = join(dir, "state.json");
165
+ mkdirSync(dir, { recursive: true });
166
+ const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {};
167
+ const save = () => !dry && writeFileSync(statePath, JSON.stringify(state, null, 1));
168
+
169
+ const [{ FangornConfig }, { createPublicClient, http }, { env }, view, card] = await Promise.all([
170
+ import("@fangorn-network/sdk"), import("viem"),
171
+ import("@huggingface/transformers"), import("./view.js"), import("./agent-card.js"),
172
+ ]);
173
+ env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
174
+ const fangorn = await view.readOnlyFangorn();
175
+ fangorn.setAppId(cfg.app);
176
+ const appId = fangorn.getAppId();
177
+ const registry = fangorn.getAppRegistry();
178
+
179
+ // 1. claim
180
+ const me = (fangornCli(cfg.app, ["wallet"]).match(/Address:\s+(0x[0-9a-fA-F]{40})/) ?? [])[1]?.toLowerCase();
181
+ if (!me) throw new Error("`fangorn wallet` shows no address; run `fangorn init`");
182
+ let owner = (await registry.getAppOwner()).toLowerCase();
183
+ if (/^0x0+$/.test(owner)) {
184
+ const block = await createPublicClient({ transport: http(FangornConfig.rpcUrl) }).getBlockNumber();
185
+ state.fromBlock = String(block);
186
+ log(`${cfg.app} is unclaimed; claiming it for ${me} (fromBlock ${block})`);
187
+ fangornCli(cfg.app, ["app", "claim"], { dry });
188
+ owner = me;
189
+ save();
190
+ } else if (owner !== me) {
191
+ throw new Error(`${cfg.app} belongs to ${owner}, not this wallet (${me}); pick another name`);
192
+ }
193
+ state.fromBlock ??= String(cfg.fromBlock ?? 0);
194
+ log(`app ${cfg.app} = ${appId}, owner ${owner}, fromBlock ${state.fromBlock}`);
195
+
196
+ // 2. join
197
+ // Both registries: claiming joins the owner to the app, but commits also need the
198
+ // wallet's global DataRegistry standing, or every push reverts NotRegistered().
199
+ const info = fangornCli(cfg.app, ["app", "info"]);
200
+ if (!/DataRegistry:\s+registered/.test(info) || !/This app:\s+joined/.test(info)) fangornCli(cfg.app, ["register"], { dry });
201
+
202
+ // 3. schema
203
+ let onChain = null;
204
+ try { onChain = view.parseSchema((await fangorn.readNamespace(owner, view.SCHEMA_NAMESPACE)).contents); } catch { /* none yet */ }
205
+ if (!onChain || canonical(onChain.types, onChain.relations) !== canonical(cfg.types, cfg.relations)) {
206
+ const sdir = join(dir, "schema");
207
+ mkdirSync(sdir, { recursive: true });
208
+ writeFileSync(join(sdir, "graph.json"), JSON.stringify(schemaGraph(cfg.types, cfg.relations), null, 1));
209
+ log(`schema ${onChain ? "changed" : "is new"}: committing ${Object.keys(cfg.types).join(", ")}`);
210
+ fangornCli(cfg.app, ["repo", "init", view.SCHEMA_NAMESPACE], { cwd: sdir, dry });
211
+ fangornCli(cfg.app, ["commit", "graph.json", "-m", "schema", "--replace"], { cwd: sdir, dry });
212
+ fangornCli(cfg.app, ["push"], { cwd: sdir, dry });
213
+ } else log("schema unchanged");
214
+
215
+ // 4. sources: crawl + publish straight into the app. One source failing (a site that
216
+ // blocks us, a feed that is down) keeps its last published data and does not stop the
217
+ // others; the ship still exits non-zero at the end, naming it.
218
+ const failed = [];
219
+ if (crawl) for (const s of cfg.sources.filter((s) => !only || only.includes(s.namespace))) {
220
+ const cwd = s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base;
221
+ const paidArgs = cfg.paid && s.paid_dir ? ["--paid-dir", resolve(cwd, s.paid_dir)] : [];
222
+ try {
223
+ run(s.command[0], [...s.command.slice(1), ...paidArgs, ...(replace ? ["--replace"] : []),
224
+ "--publish", "--namespace", s.namespace, "--fangorn-bin", CLI.join(" ")],
225
+ { cwd, env: { FANGORN_APP_ID: cfg.app }, dry });
226
+ } catch (e) {
227
+ log(`✗ ${s.namespace}: ${e.message}; its last published records stay live`);
228
+ failed.push(s.namespace);
229
+ }
230
+ }
231
+
232
+ // 5. the view. Its files are its state: on a fresh machine (CI), start from the deployed
233
+ // copy rather than re-embed everything. shard.js checks each shard against its manifest.
234
+ const site = join(dir, "site");
235
+ if (!existsSync(join(site, "view/cdn/catalog")) && state.url && !dry) await mirrorView(`${state.url}/view`, join(site, "view"));
236
+ const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
237
+ const report = dry ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
238
+ fromBlock: BigInt(state.fromBlock), log });
239
+
240
+ // 6. the page, the card, the headers
241
+ if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
242
+ mkdirSync(site, { recursive: true });
243
+ for (const f of readdirSync(site, { withFileTypes: true })) if (f.name !== "view" && f.name !== "_paid") rmSync(join(site, f.name), { recursive: true, force: true });
244
+ cpSync(STOCK, site, { recursive: true });
245
+ if (cfg.site.pages) cpSync(resolve(base, cfg.site.pages), site, { recursive: true });
246
+ writeFileSync(join(site, "nav.json"), JSON.stringify(cfg.site.nav ?? []));
247
+ const url = deploy && !dry ? pagesUrl(cfg) : state.url ?? `https://${cfg.site.project}.pages.dev`;
248
+ // Paid records: every source's paid_dir → site/_paid (served only through the worker).
249
+ let paid = null, sale = null;
250
+ const live = view.liveFields(join(site, "view"));
251
+ if (cfg.paid) {
252
+ const network = cfg.paid.network ?? "arbitrum-sepolia";
253
+ const asset = cfg.paid.asset ?? USDC[network];
254
+ if (!asset) throw new Error(`paid.network ${network}: no default USDC address; set paid.asset`);
255
+ const decimals = cfg.paid.decimals ?? 6;
256
+ const [whole, frac = ""] = String(cfg.paid.price).split(".");
257
+ const price = (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + "0".repeat(decimals)).slice(0, decimals) || "0")).toString();
258
+ paid = { url: `${url}/paid/{id}`, price, asset, network, decimals, symbol: cfg.paid.symbol ?? "USDC", description: cfg.paid.description ?? "" };
259
+ // Only records a live row vouches for (its committed paid_sha256): one left over from
260
+ // an item since removed or re-extracted is not for sale, whatever is still on disk.
261
+ rmSync(join(site, "_paid"), { recursive: true, force: true });
262
+ mkdirSync(join(site, "_paid"), { recursive: true });
263
+ const vouched = new Set(live.map((f) => f.paid_sha256).filter(Boolean));
264
+ let n = 0;
265
+ for (const s of cfg.sources) {
266
+ const d = s.paid_dir && resolve(s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base, s.paid_dir);
267
+ if (d && existsSync(d)) for (const f of readdirSync(d)) {
268
+ const bytes = readFileSync(join(d, f));
269
+ if (!vouched.has(createHash("sha256").update(bytes).digest("hex"))) continue;
270
+ writeFileSync(join(site, "_paid", f), bytes); n++;
271
+ }
272
+ }
273
+ sale = { price, network, asset, payTo: cfg.paid.payTo ?? owner, facilitator: cfg.paid.facilitator ?? "https://facilitator.payai.network",
274
+ name: cfg.paid.tokenName ?? "USD Coin", version: cfg.paid.tokenVersion ?? "2", description: paid.description };
275
+ log(`paid: ${n} record(s) at ${cfg.paid.price} ${paid.symbol} on ${network}, to ${sale.payTo}`);
276
+ }
277
+ // The worker: paid records if any, and each record's source document shown in the page.
278
+ // Only the hosts the app's own records link to (their media role) are proxied.
279
+ const media = Object.values(cfg.types).flatMap((t) => [t.role_map?.media ?? []].flat());
280
+ const docHosts = [...new Set(live.flatMap((f) => media.map((m) => f[m])).filter((u) => typeof u === "string" && u.startsWith("https://"))
281
+ .map((u) => { try { return new URL(u).hostname; } catch { return null; } }).filter(Boolean))].sort();
282
+ writeFileSync(join(site, "_worker.js"), readFileSync(join(HERE, "../site/worker.js"), "utf8")
283
+ .replace(/\/\*CONFIG\*\/[\s\S]*?\/\*END\*\//, JSON.stringify({ docHosts, paid: sale })));
284
+ log(`documents: shown inline from ${docHosts.length} source host(s)`);
285
+ // The card lists the tools the page at `url` registers: the stock page's, or the app's own.
286
+ const { registerAgent } = await import(cfg.site.agent ? pathToFileURL(resolve(base, cfg.site.agent)).href : "../site/agent.js");
287
+ const agentCard = card.agentCard({
288
+ name: cfg.name, description: cfg.description, url: `${url}/`, version: new Date().toISOString().slice(0, 10),
289
+ tools: await card.captureTools(() => registerAgent({})), tags: cfg.tags,
290
+ fangorn: { app: cfg.app, fromBlock: state.fromBlock, namespaces, views: [`${url}/view`], ...(paid ? { paid } : {}) },
291
+ });
292
+ mkdirSync(join(site, ".well-known"), { recursive: true });
293
+ writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
294
+ const immutable = " Cache-Control: public, max-age=31536000, immutable\n";
295
+ const domains = existsSync(join(site, "view/cdn/domains")) ? readdirSync(join(site, "view/cdn/domains")) : [];
296
+ writeFileSync(join(site, "_headers"),
297
+ "/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n"
298
+ + "/view/*\n Access-Control-Allow-Origin: *\n"
299
+ + `/assets/*\n${immutable}`
300
+ // An hour, not immutable: a shard is deleted when a record is retracted (see view.js).
301
+ + domains.map((d) => `/view/cdn/domains/${d}/shards/*\n Cache-Control: public, max-age=3600\n`).join(""));
302
+
303
+ // 7. deploy
304
+ if (deploy) {
305
+ run("npx", ["wrangler", "pages", "deploy", site, "--project-name", cfg.site.project, "--branch", "main", "--commit-dirty=true"],
306
+ { env: cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {}, dry });
307
+ state.url = url;
308
+ save();
309
+ }
310
+
311
+ // 8. ERC-8004 + the binding
312
+ const cardUrl = `${url}/.well-known/agent-card.json`;
313
+ const bound = await registry.appAgentUri(appId).catch(() => "");
314
+ if (deploy && bound !== cardUrl) {
315
+ log(`binding ${cardUrl} (on chain: ${bound || "nothing"})`);
316
+ const out = dry ? "" : fangornCli(cfg.app, ["app", "agent", cardUrl]);
317
+ state.agentId = (out.match(/Agent ID:\s+(\S+)/) ?? [])[1] ?? state.agentId;
318
+ save();
319
+ } else if (deploy) log("card already bound");
320
+
321
+ const added = Object.values(report).reduce((n, r) => n + r.added, 0);
322
+ log(`done: ${url} · card ${cardUrl} · agent ${state.agentId ?? "(unchanged)"} · +${added} records embedded`);
323
+ if (failed.length) throw new Error(`deployed, but these sources failed and kept their old records: ${failed.join(", ")}`);
324
+ return { url, cardUrl, agentId: state.agentId, report };
325
+ }
326
+
327
+ /**
328
+ * A view from what the sources staged, with app.json's schema, touching no chain and no
329
+ * site: what a recipe change would ship, built so it can be graded first (westmarch-eval).
330
+ * The sources run without --publish, or not at all with crawl=false. Every build is whole
331
+ * (a changed `text` role must reach every row, and publishView re-embeds only new CIDs);
332
+ * `.ship/vectors.ndjson` keeps it cheap, so two recipes over the same data embed only the
333
+ * text that differs.
334
+ *
335
+ * ponytail: reads every `volume_<n>_*.json` but edges in a source's --output-dir; the
336
+ * harness publishes only the stems its source declares, so a stale file left by an older
337
+ * shape of a source is built here and not on chain. Clear the stage dir if that bites.
338
+ */
339
+ export async function buildLocal(configPath, out, { crawl = false, only = null, log: say = log } = {}) {
340
+ const cfg = loadConfig(configPath);
341
+ const base = dirname(resolve(configPath));
342
+ const [{ env }, view] = await Promise.all([import("@huggingface/transformers"), import("./view.js")]);
343
+ env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
344
+ // Not the app's real id or owner: nothing here is compared with the chain.
345
+ const appId = `0x${createHash("sha256").update(cfg.app).digest("hex")}`, owner = `0x${"10ca1".padStart(40, "0")}`;
346
+ const g = schemaGraph(cfg.types, cfg.relations);
347
+ const chain = { [`${owner}/${view.SCHEMA_NAMESPACE}`]: {
348
+ vertices: g.vertices.map((v) => ({ cid: v.id, schemaId: v.tag, payload: v.payload })),
349
+ edges: g.edges.map((e) => ({ relation: e.rel, sourceCid: e.from, targetCid: e.to })) } };
350
+ for (const s of cfg.sources.filter((s) => !only || only.includes(s.namespace))) {
351
+ const cwd = s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base;
352
+ if (crawl) run(s.command[0], s.command.slice(1), { cwd, env: { FANGORN_APP_ID: cfg.app } });
353
+ const at = s.command.indexOf("--output-dir");
354
+ if (at < 0) { say(`${s.namespace}: no --output-dir in its command, so nothing staged to read`); continue; }
355
+ const dir = resolve(cwd, expandEnv(s.command[at + 1]));
356
+ const volume = s.command.includes("--volume") ? s.command[s.command.indexOf("--volume") + 1] : "1";
357
+ const ns = (chain[`${owner}/${s.namespace}`] ??= { vertices: [], edges: [] });
358
+ for (const f of existsSync(dir) ? readdirSync(dir).sort() : []) {
359
+ const m = new RegExp(`^volume_${volume}_(.+)\\.json$`).exec(f);
360
+ if (!m || m[1] === "edges") continue;
361
+ // The harness publishes {id: name, tag: entity, payload: fields}; the chain
362
+ // addresses it by the payload's hash, and so does this.
363
+ for (const n of JSON.parse(readFileSync(join(dir, f), "utf8"))) ns.vertices.push({
364
+ cid: createHash("sha256").update(JSON.stringify(stable(n.fields))).digest("hex"),
365
+ schemaId: n.fields?.entityType ?? m[1][0].toUpperCase() + m[1].slice(1), payload: n.fields });
366
+ }
367
+ }
368
+ rmSync(out, { recursive: true, force: true });
369
+ mkdirSync(join(base, ".ship"), { recursive: true });
370
+ const embed = view.cachedEmbed(join(base, ".ship/vectors.ndjson"));
371
+ const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))].filter((n) => !only || only.includes(n));
372
+ const report = await view.publishView({ fangorn: view.localFangorn(chain, { appId, owner }), app: cfg.app,
373
+ namespaces, out, embed, log: say });
374
+ const rows = Object.values(report).reduce((n, r) => n + r.added, 0);
375
+ say(`local view: ${out} · ${rows} records, ${embed.misses} embedded, ${rows - embed.misses} from the vector cache`);
376
+ return report;
377
+ }
378
+
379
+ /** Copy a deployed view (catalog, manifests, shards) to `dir`, so the next publish appends. */
380
+ async function mirrorView(base, dir) {
381
+ const get = async (p) => { const r = await fetch(`${base}/${p}`); if (!r.ok) throw new Error(`${base}/${p}: HTTP ${r.status}`); return r; };
382
+ let catalog;
383
+ try { catalog = await (await get("cdn/catalog")).json(); } catch (e) { log(`no deployed view to start from (${e.message})`); return; }
384
+ const { createHash } = await import("node:crypto");
385
+ let files = 0;
386
+ for (const d of catalog.domains ?? []) {
387
+ const m = await (await get(`cdn/domains/${d.name}/manifest`)).json();
388
+ mkdirSync(join(dir, "cdn/domains", d.name, "shards"), { recursive: true });
389
+ writeFileSync(join(dir, "cdn/domains", d.name, "manifest"), JSON.stringify(m, null, 1));
390
+ for (const s of m.shards ?? []) {
391
+ const buf = Buffer.from(await (await get(`cdn/domains/${d.name}/shards/${s.file}`)).arrayBuffer());
392
+ if (s.sha256 && createHash("sha256").update(buf).digest("hex") !== s.sha256) throw new Error(`${s.file} does not match its manifest`);
393
+ writeFileSync(join(dir, "cdn/domains", d.name, "shards", s.file), buf);
394
+ files++;
395
+ }
396
+ }
397
+ writeFileSync(join(dir, "cdn/catalog"), JSON.stringify(catalog, null, 1));
398
+ log(`started from the deployed view: ${catalog.domains?.length ?? 0} domain(s), ${files} shard(s)`);
399
+ }
400
+
401
+ /** The project's pages.dev URL, creating the project on first use. Pages appends a
402
+ * suffix when the name is taken elsewhere, so the URL is read back, never assumed. */
403
+ function pagesUrl(cfg) {
404
+ const env = cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {};
405
+ const find = () => {
406
+ const out = run("npx", ["wrangler", "pages", "project", "list"], { env, capture: true });
407
+ const row = out.split("\n").find((l) => l.split("│")[1]?.trim() === cfg.site.project);
408
+ const host = row?.match(/([a-z0-9-]+\.pages\.dev)/)?.[1];
409
+ return host ? `https://${host}` : null;
410
+ };
411
+ let url = find();
412
+ if (!url) {
413
+ run("npx", ["wrangler", "pages", "project", "create", cfg.site.project, "--production-branch", "main"], { env });
414
+ url = find();
415
+ }
416
+ if (!url) throw new Error(`could not read the pages.dev URL of project ${cfg.site.project}`);
417
+ return url;
418
+ }
419
+
420
+ // ── cli ───────────────────────────────────────────────────────────────────────
421
+ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch-ship")) {
422
+ if (process.argv[2] === "--selfcheck") {
423
+ // The schema as committed, read back the way the chain returns it, must compare
424
+ // equal to the config, or every run re-commits an unchanged schema.
425
+ const { parseSchema } = await import("./view.js");
426
+ const types = { "t.v1": { description: "d", role_map: { title: "name", text: ["body"] }, presentation: { externalUrl: { "t.v1": "{url}" } } },
427
+ "u.v1": { description: "e", role_map: { title: "label" } } };
428
+ const relations = [{ rel: "cites", from: "t.v1", to: "u.v1" }];
429
+ const g = schemaGraph(types, relations);
430
+ // Keys reversed at every depth, as a canonical (sorted) encoding hands them back.
431
+ const reverse = (v) => Array.isArray(v) ? v.map(reverse)
432
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).reverse().map((k) => [k, reverse(v[k])])) : v;
433
+ const readBack = parseSchema({
434
+ vertices: g.vertices.map((v) => ({ cid: `cid-${v.id}`, schemaId: v.tag, payload: reverse(v.payload) })),
435
+ edges: g.edges.map((e) => ({ relation: e.rel, sourceCid: `cid-${e.from}`, targetCid: `cid-${e.to}` })),
436
+ });
437
+ if (canonical(readBack.types, readBack.relations) !== canonical(types, relations)) throw new Error("schema does not round-trip");
438
+ if (canonical(readBack.types, readBack.relations) === canonical({ ...types, "v.v1": {} }, relations)) throw new Error("a new type must count as a change");
439
+ const row = { city: "Wisconsin Rapids", state: "WI", site: "https://x", args: ["--archive-page", "/569/A"] };
440
+ const cmd = fill(["py", "--site", "{site}", "--city", "{city}", "{args...}", "--ns", "us-{state|lower}-{city|slug}"], row);
441
+ if (JSON.stringify(cmd) !== JSON.stringify(["py", "--site", "https://x", "--city", "Wisconsin Rapids", "--archive-page", "/569/A", "--ns", "us-wi-wisconsin-rapids"]))
442
+ throw new Error(`template fill: ${JSON.stringify(cmd)}`);
443
+ console.log("ship.js self-check ok — a committed schema reads back equal to its config, a new type counts as a change, and a source template fills per row");
444
+ process.exit(0);
445
+ }
446
+ const [path, ...flags] = process.argv.slice(2);
447
+ if (!path || flags.includes("--help")) {
448
+ console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace] [--only ns1,ns2]\n" +
449
+ " westmarch-ship app.json --local <out> [--crawl] [--only ns1,ns2] (no chain, no deploy)");
450
+ process.exit(path ? 0 : 2);
451
+ }
452
+ const only = flags.includes("--only") ? flags[flags.indexOf("--only") + 1].split(",") : null;
453
+ if (flags.includes("--local")) {
454
+ await buildLocal(path, resolve(flags[flags.indexOf("--local") + 1]), { crawl: flags.includes("--crawl"), only })
455
+ .catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
456
+ process.exit(0);
457
+ }
458
+ await ship(path, { crawl: !flags.includes("--no-crawl"), deploy: !flags.includes("--no-deploy"), dry: flags.includes("--dry-run"),
459
+ replace: flags.includes("--replace"),
460
+ only })
461
+ .catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
462
+ }
package/publish/terms.js CHANGED
@@ -137,7 +137,7 @@ export function splitPayment(amount, { terms, publisher, lineage: up = [] } = {}
137
137
  export const label = (base, currency = "USDC") => `${(Number(base) / 1e6).toFixed(2)} ${currency}`;
138
138
 
139
139
  // ── self-check: `node publish/terms.js` ────────────────────────────────────
140
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
140
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/terms.js")) {
141
141
  const A = "0x" + "a".repeat(40); // app owner
142
142
  const P = "0x" + "b".repeat(40); // publisher
143
143
  const S1 = "0x" + "c".repeat(40); // a source
package/publish/vault.js CHANGED
@@ -105,7 +105,7 @@ export function tasteFrom(vertices = []) {
105
105
  }
106
106
 
107
107
  // ── self-check: `node publish/vault.js` ───────────────────────────────────
108
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
108
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/vault.js")) {
109
109
  const { privateKeyToAccount } = await import("viem/accounts");
110
110
  const KEY_A = `0x${"11".repeat(32)}`, KEY_B = `0x${"22".repeat(32)}`;
111
111
  // A "machine": a fresh client over the same key, holding no state at all.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+ // westmarch-view: publish an app's committed data as a view (see view.js).
3
+ //
4
+ // westmarch-view --app my-app --namespace my-app --out site/view [--from-block N] [--rebake]
5
+ //
6
+ // Reads only; needs no key. Deploy <out> with the site, and put its URL in the card's
7
+ // `views`. Run it after every push, by hand, in CI, or from an agent.
8
+
9
+ import { homedir } from "node:os";
10
+
11
+ const flags = { namespace: [] };
12
+ for (let i = 2; i < process.argv.length; i++) {
13
+ const a = process.argv[i];
14
+ const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
15
+ if (!m) { console.error(`unexpected argument: ${a}`); process.exit(2); }
16
+ const v = m[2] ?? (["rebake", "help"].includes(m[1]) ? true : process.argv[++i]);
17
+ if (m[1] === "namespace") flags.namespace.push(v); else flags[m[1]] = v;
18
+ }
19
+ if (flags.help || !flags.app || !flags.namespace.length || !flags.out) {
20
+ console.error("usage: westmarch-view --app <name|0xid> --namespace <ns> [--namespace …] --out <dir> [--from-block N] [--rebake]");
21
+ process.exit(flags.help ? 0 : 2);
22
+ }
23
+
24
+ const [{ env }, { publishView, readOnlyFangorn }] = await Promise.all([
25
+ import("@huggingface/transformers"), import("./view.js"),
26
+ ]);
27
+ // Shared with fangorn-mcp, so a machine downloads the model once.
28
+ env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
29
+ const fangorn = await readOnlyFangorn();
30
+
31
+ const t0 = Date.now();
32
+ const report = await publishView({
33
+ fangorn, app: flags.app, namespaces: flags.namespace, out: flags.out,
34
+ fromBlock: BigInt(flags["from-block"] ?? 0), rebake: !!flags.rebake,
35
+ log: (s) => console.error(s),
36
+ });
37
+ const sum = Object.values(report).reduce((a, r) => ({ added: a.added + r.added, removed: a.removed + r.removed }), { added: 0, removed: 0 });
38
+ console.error(`done in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${Object.keys(report).length} domain(s), +${sum.added} -${sum.removed}`);
39
+ console.log(JSON.stringify(report));