@fangorn-network/westmarch 0.0.1-dev → 0.0.2-dev

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) 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/docs/app-to-agent.md +553 -185
  21. package/package.json +11 -4
  22. package/publish/agent-card.js +12 -2
  23. package/publish/cohort.js +1 -1
  24. package/publish/demand.js +1 -1
  25. package/publish/enrich.js +1 -1
  26. package/publish/envelope.js +1 -1
  27. package/publish/graph.js +1 -1
  28. package/publish/manifest.js +1 -1
  29. package/publish/reactions.js +4 -2
  30. package/publish/settle.js +1 -1
  31. package/publish/ship.js +378 -0
  32. package/publish/terms.js +1 -1
  33. package/publish/vault.js +1 -1
  34. package/publish/view-cli.js +39 -0
  35. package/publish/view.js +399 -0
  36. package/site/agent.js +36 -0
  37. package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
  38. package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
  39. package/site/dist/assets/index-DlzVyQ_9.js +6 -0
  40. package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
  41. package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
  42. package/site/dist/assets/roles-C9V_uDr_.js +1 -0
  43. package/site/dist/assets/roles-YDRjv_PY.js +1 -0
  44. package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
  45. package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
  46. package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
  47. package/site/dist/index.html +20 -0
  48. package/site/feed.js +62 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "0.0.1-dev",
3
+ "version": "0.0.2-dev",
4
4
  "description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,7 @@
13
13
  "./rank": "./consume/rank.js",
14
14
  "./apps": "./consume/apps.js",
15
15
  "./agent-card": "./publish/agent-card.js",
16
+ "./view": "./publish/view.js",
16
17
  "./roles": "./consume/roles.js",
17
18
  "./directory": "./consume/directory.js",
18
19
  "./taste": "./consume/taste.js",
@@ -29,10 +30,11 @@
29
30
  "./demand": "./publish/demand.js",
30
31
  "./steam": "./consume/steam.js",
31
32
  "./tools": "./consume/tools.js",
33
+ "./x402": "./consume/x402.js",
32
34
  "./package.json": "./package.json"
33
35
  },
34
36
  "scripts": {
35
- "test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js",
37
+ "test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js",
36
38
  "lint": "node consume/lint.js"
37
39
  },
38
40
  "peerDependencies": {
@@ -54,12 +56,17 @@
54
56
  },
55
57
  "bin": {
56
58
  "westmarch-publish": "./publish/cli.js",
57
- "fangorn-mcp": "./consume/mcp.js"
59
+ "fangorn-mcp": "./consume/mcp.js",
60
+ "westmarch-view": "./publish/view-cli.js",
61
+ "westmarch-ship": "./publish/ship.js"
58
62
  },
59
63
  "files": [
60
64
  "consume",
61
65
  "publish",
62
- "docs/app-to-agent.md"
66
+ "docs/app-to-agent.md",
67
+ "site/agent.js",
68
+ "site/dist",
69
+ "site/feed.js"
63
70
  ],
64
71
  "engines": {
65
72
  "node": ">=20"
@@ -73,7 +73,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
73
73
 
74
74
  /** The params `discoverApp` reads, validated the same way it validates them.
75
75
  * A malformed card should fail the build, not the first reader. */
76
- export function fangornExtension({ app, fromBlock, namespaces = [], views = [], config = FangornConfig }) {
76
+ export function fangornExtension({ app, fromBlock, namespaces = [], views = [], paid, config = FangornConfig }) {
77
77
  if (!app) throw new Error("fangorn.app is required");
78
78
  const id = HEX32.test(app) ? app : hashAppId(app);
79
79
  const from = String(fromBlock ?? "");
@@ -83,6 +83,14 @@ export function fangornExtension({ app, fromBlock, namespaces = [], views = [],
83
83
  const u = new URL(v); // throws on garbage
84
84
  if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`fangorn.views: not http(s): ${v}`);
85
85
  }
86
+ // `paid`: records sold over x402. `url` has an `{id}` for the record's id; `price` is in
87
+ // the token's base units. Readers only offer to buy; the 402 itself is the quote.
88
+ if (paid) {
89
+ const u = new URL(paid.url.replace("{id}", "x"));
90
+ if (!/^https?:$/.test(u.protocol) || !paid.url.includes("{id}")) throw new Error(`fangorn.paid.url must be http(s) with {id}: ${paid.url}`);
91
+ if (!/^\d+$/.test(String(paid.price))) throw new Error(`fangorn.paid.price must be base units, got ${paid.price}`);
92
+ if (!/^0x[0-9a-fA-F]{40}$/.test(paid.asset ?? "")) throw new Error(`fangorn.paid.asset must be a token address`);
93
+ }
86
94
  return {
87
95
  uri: FANGORN_APP_EXTENSION,
88
96
  description: "The Fangorn app this agent fronts.",
@@ -92,12 +100,14 @@ export function fangornExtension({ app, fromBlock, namespaces = [], views = [],
92
100
  appRegistry: config.appRegistryContractAddress,
93
101
  dataRegistry: config.dataRegistryContractAddress,
94
102
  appId: id, fromBlock: from, namespaces, views,
103
+ ...(paid ? { paid: { url: paid.url, price: String(paid.price), asset: paid.asset, network: paid.network,
104
+ symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
95
105
  },
96
106
  };
97
107
  }
98
108
 
99
109
  // ── self-check: `node publish/agent-card.js` ────────────────────────────────
100
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
110
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/agent-card.js")) {
101
111
  const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
102
112
  const throws = (f, re, m) => { try { f(); } catch (e) { if (re.test(e.message)) return; throw e; } throw new Error(`must throw: ${m}`); };
103
113
 
package/publish/cohort.js CHANGED
@@ -247,7 +247,7 @@ export function readout(total, cohort, { minSupport = MIN_SUPPORT } = {}) {
247
247
  }
248
248
 
249
249
  // ── self-check: `node publish/cohort.js` ──────────────────────────────────
250
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
250
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/cohort.js")) {
251
251
  const vec = (a, b) => { const v = new Float32Array(STAT_DIM); v[0] = a; v[1] = b; return v; };
252
252
  const log = (n, seed) => Array.from({ length: n }, (_, i) => ({
253
253
  id: `row-${seed}-${i}`, corpus: "films", reaction: i % 4 === 3 ? "skip" : "like",
package/publish/demand.js CHANGED
@@ -383,7 +383,7 @@ export function brief(rep, { title = "demand" } = {}) {
383
383
  }
384
384
 
385
385
  // ── self-check: `node publish/demand.js` ────────────────────────────────────
386
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
386
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/demand.js")) {
387
387
  const { keypair, statistics, contribute, aggregate, readout } = await import("./cohort.js");
388
388
 
389
389
  // A shelf with two genuine regions and a hole between them. 128 dims so the
package/publish/enrich.js CHANGED
@@ -155,7 +155,7 @@ export function enrichText({ name, path, desc, mime, abs = null }) {
155
155
  // in the same browser that already runs the embedder at publish time.
156
156
 
157
157
  // ── self-check: `node server/enrich.js` — no files, no model ──────────────────
158
- if (import.meta.url === `file://${process.argv[1]}`) {
158
+ if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/enrich.js")) {
159
159
  const { deflateSync } = await import("node:zlib");
160
160
  const eq = (got, want, msg) => { if (got !== want) throw new Error(`${msg}: got ${JSON.stringify(got)}`); };
161
161
 
@@ -157,7 +157,7 @@ export async function deleteResource({ workerUrl, uploadToken, resourceId, chunk
157
157
  // every resourceId ever minted by fangorn.tv is derived by this function, and a
158
158
  // change here that shifts one byte makes ciphertext already in R2 unreachable from
159
159
  // the resource anyone paid for. If this fails, the extraction broke money.
160
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
160
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/envelope.js")) {
161
161
  const owner = `0x${"11".repeat(20)}`;
162
162
  const uid = "Show/S1/a.mp4";
163
163
 
package/publish/graph.js CHANGED
@@ -183,7 +183,7 @@ export function treeFromGraph(contents) {
183
183
  // `process` doesn't exist in the browser, and consumers import nest() from here
184
184
  // to build a viewer tree — so this guard has to survive being evaluated by a
185
185
  // browser.
186
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
186
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/graph.js")) {
187
187
  // sond3r's pinned names, which is also the case with every option exercised.
188
188
  const SOND3R = {
189
189
  file: "video",
@@ -90,7 +90,7 @@ export function manifestFromTree(files, { defaultPrice = "1000", newUid: uid = n
90
90
  }
91
91
 
92
92
  // ── self-check: `node publish/manifest.js` ───────────────────────────────────
93
- if (import.meta.url === `file://${process.argv[1]}`) {
93
+ if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/manifest.js")) {
94
94
  const assert = (c, m) => { if (!c) throw new Error(m); };
95
95
 
96
96
  // PINNED. sond3r's manifests are already in R2 under this exact key; if this
@@ -53,7 +53,9 @@
53
53
  import { packVec } from "../consume/embed.js";
54
54
  import { lineage } from "./terms.js";
55
55
 
56
- export const COVERAGE_K = 8;
56
+ // 32, not 8: on Kingsfoil the top 3 of 23 views held 92% of the true top 10 with 32
57
+ // centroids and 86% with 8, and 8 missed whole topics (ALS → neurology).
58
+ export const COVERAGE_K = 32;
57
59
  export const COVERAGE_DIM = 128;
58
60
  /** Fewest reactions that may stand behind one published centroid. */
59
61
  export const MIN_PER_CENTROID = 8;
@@ -235,7 +237,7 @@ export function reactionCorpus(events = [], {
235
237
  }
236
238
 
237
239
  // ── self-check: `node publish/reactions.js` ────────────────────────────────
238
- if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
240
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/reactions.js")) {
239
241
  const { splitPayment } = await import("./terms.js");
240
242
  const A = (n) => `0x${String(n).repeat(40).slice(0, 40)}`;
241
243
  const vec = (a, b) => { const v = new Float32Array(256); v[0] = a; v[1] = b; return v; };
package/publish/settle.js CHANGED
@@ -70,7 +70,7 @@ export async function encryptAndUpload({ file, resourceId, workerUrl, uploadToke
70
70
  // under test here is the ENCRYPTOR: chunk addressing, the AAD index binding, the
71
71
  // ragged tail, and that pack/unpack round trip. An app's full publisher→buyer
72
72
  // round trip belongs in that app, against its own buyer.
73
- if (import.meta.url === `file://${process.argv[1]}`) {
73
+ if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/settle.js")) {
74
74
  const { writeFileSync, rmSync } = await import("node:fs");
75
75
  const { unseal } = await import("@fangorn-network/sdk");
76
76
  const { x25519 } = await import("@noble/curves/ed25519");
@@ -0,0 +1,378 @@
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
+ //
6
+ // Every step checks the chain (or Cloudflare) first and only acts on a difference, so
7
+ // re-running is how you update: new data, a changed schema, a new source, a moved site.
8
+ //
9
+ // 1. claim the app (first run only; the claim block becomes the card's fromBlock)
10
+ // 2. join it as a publisher
11
+ // 3. commit the schema, if the one on chain differs from app.json's `types`
12
+ // 4. run each source, which crawls and publishes straight into the app
13
+ // 5. westmarch-view: the app's commits → site/view
14
+ // 6. the stock page, the card and _headers → site/
15
+ // 7. deploy to Cloudflare Pages
16
+ // 8. register the ERC-8004 agent and bind the card, if the chain points elsewhere
17
+ //
18
+ // Chain writes go through the `fangorn` CLI (its configured wallet signs, and
19
+ // `fangorn wallet` shows which). Reads go through the SDK. State lives in `.ship/` next
20
+ // to app.json: the site, the view (its own state, see view.js) and a small state.json.
21
+
22
+ import { spawnSync } from "node:child_process";
23
+ import { createHash } from "node:crypto";
24
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
25
+ import { createRequire } from "node:module";
26
+ import { dirname, join, resolve } from "node:path";
27
+ import { homedir } from "node:os";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ const HERE = dirname(fileURLToPath(import.meta.url));
31
+ const USDC = { "arbitrum-sepolia": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
32
+ "base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" };
33
+ const STOCK = join(HERE, "../site/dist");
34
+ const log = (s) => console.error(`[ship] ${s}`);
35
+
36
+ // ── config ────────────────────────────────────────────────────────────────────
37
+ //
38
+ // {
39
+ // "app": "quorum", "name": "Quorum", "description": "…", "tags": ["civic"],
40
+ // "site": { "project": "quorum", "account": "<cloudflare account id>" },
41
+ // "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
42
+ // "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
43
+ // "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
44
+ // "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js"
45
+ // "paid": { "price": "0.01", "network": "arbitrum-sepolia", "description": "…" }
46
+ // }
47
+ //
48
+ // `paid` sells per-record detail over x402 (site/paid-worker.js): each source that names a
49
+ // `paid_dir` gets `--paid-dir` and writes one JSON per record there; they are deployed
50
+ // behind the worker, and the price goes in the card. The payee is the app owner unless
51
+ // `paid.payTo` says otherwise; the asset is USDC on `paid.network` unless `paid.asset` does.
52
+ //
53
+ // A source is any command that publishes into `--namespace` when given `--publish`:
54
+ // quickbeam's scraper harness does, so every quickbeam Source is a source here. It runs
55
+ // with FANGORN_APP_ID set, so it publishes into this app whatever the CLI's saved app is.
56
+
57
+ /** `{field}`, `{field|slug}` and `{field|lower}` from a row; an argv item that is exactly
58
+ * `{args...}` splices the row's own `args` array (for flags only some rows need). */
59
+ export function fill(template, row) {
60
+ const one = (s) => s.replace(/\{(\w+)(?:\|(slug|lower))?\}/g, (_, k, f) => {
61
+ if (row[k] == null) throw new Error(`template needs "${k}", which ${JSON.stringify(row)} lacks`);
62
+ const v = String(row[k]);
63
+ return f === "slug" ? v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") : f === "lower" ? v.toLowerCase() : v;
64
+ });
65
+ return Array.isArray(template) ? template.flatMap((t) => (t === "{args...}" ? row.args ?? [] : [one(t)])) : one(template);
66
+ }
67
+
68
+ /** A source with `each: "towns.json"` is a template: one source per row of that file
69
+ * (rows with `"skip": true` are left out). Adding a town is adding a row. */
70
+ export function expandSources(sources, base) {
71
+ return sources.flatMap((s) => {
72
+ if (!s.each) return [s];
73
+ const rows = JSON.parse(readFileSync(resolve(base, s.each), "utf8")).filter((r) => !r.skip);
74
+ return rows.map((r) => ({ ...s, each: undefined, row: r, namespace: fill(s.namespace, r), command: fill(s.command, r),
75
+ ...(s.paid_dir ? { paid_dir: fill(s.paid_dir, r) } : {}) }));
76
+ });
77
+ }
78
+
79
+ export function loadConfig(path) {
80
+ const c = JSON.parse(readFileSync(path, "utf8"));
81
+ c.sources = expandSources(c.sources ?? [], dirname(resolve(path)));
82
+ for (const k of ["app", "name", "description", "types", "sources", "site"])
83
+ if (!c[k]) throw new Error(`${path}: missing "${k}"`);
84
+ if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Pages project`);
85
+ for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
86
+ throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
87
+ return { relations: [], tags: [], ...c };
88
+ }
89
+
90
+ /** The schema commit for `types` + `relations`: one fangorn.type.v1 vertex per type,
91
+ * one edge per allowed relation. */
92
+ export function schemaGraph(types, relations = []) {
93
+ const id = (tag) => `type:${tag}`;
94
+ return {
95
+ vertices: Object.entries(types).map(([tag, t]) => ({ id: id(tag), tag: "fangorn.type.v1", payload: {
96
+ tag, description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} } })),
97
+ edges: relations.map((r) => ({ rel: r.rel, from: id(r.from), to: id(r.to) })),
98
+ };
99
+ }
100
+
101
+ /** Keys sorted at every depth: the chain stores dag-cbor, whose maps come back in
102
+ * canonical key order, not the order app.json wrote them in. */
103
+ const stable = (v) => Array.isArray(v) ? v.map(stable)
104
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, stable(v[k])])) : v;
105
+
106
+ /** Same shape parseSchema returns, so a config and the chain compare directly. */
107
+ const canonical = (types, relations = []) => JSON.stringify(stable({
108
+ types: Object.fromEntries(Object.entries(types).sort(([a], [b]) => a.localeCompare(b))
109
+ .map(([tag, t]) => [tag, { description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} }])),
110
+ relations: [...relations].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
111
+ }));
112
+
113
+ // ── processes ─────────────────────────────────────────────────────────────────
114
+
115
+ /** `${VAR}` and `${VAR:-default}` from the environment, so one app.json runs on a laptop
116
+ * and in CI (e.g. `"${PYTHON:-python3}"`). */
117
+ export const expandEnv = (s) => s.replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (_, k, d) => process.env[k] ?? d ?? "");
118
+
119
+ function run(cmd, args, { cwd, env, capture = false, dry = false } = {}) {
120
+ cmd = expandEnv(cmd); args = args.map(expandEnv);
121
+ log(`$ ${[cmd, ...args].join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
122
+ if (dry) return "";
123
+ const r = spawnSync(cmd, args, { cwd, env: { ...process.env, ...env }, encoding: "utf8",
124
+ stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit" });
125
+ if (r.status !== 0) throw new Error(`${cmd} ${args[0]} failed (exit ${r.status})`);
126
+ return r.stdout ?? "";
127
+ }
128
+ const expand = (p) => p.replace(/^~(?=\/|$)/, homedir());
129
+ const cliArgv = (cfg) => expandEnv(process.env.FANGORN_CLI ?? cfg.fangorn ?? "fangorn").split(/\s+/).filter(Boolean).map(expand);
130
+ let CLI = ["fangorn"];
131
+ const fangornCli = (app, args, opts = {}) => run(CLI[0], [...CLI.slice(1), "--app", app, ...args], { capture: true, ...opts });
132
+
133
+ /** The CLI must be the SDK version westmarch reads with. A different one signs against
134
+ * another deployment's contracts: its claim and commits land where nothing here looks,
135
+ * and every read says the app is unclaimed. (It happened: a global 2026.08.18-dev CLI
136
+ * claimed and published onto the previous registries.) */
137
+ function assertSameDeployment(cli) {
138
+ const which = (c) => spawnSync("sh", ["-c", `command -v ${c}`], { encoding: "utf8" }).stdout.trim();
139
+ const script = cli.length > 1 && /(^|\/)node$/.test(cli[0]) ? cli[1] : which(cli[0]);
140
+ if (!script) throw new Error(`fangorn CLI not found: ${cli.join(" ")}`);
141
+ let d = dirname(realpathSync(script)), cliVersion = null;
142
+ for (; d !== dirname(d); d = dirname(d)) {
143
+ const pj = join(d, "package.json");
144
+ if (existsSync(pj)) { const p = JSON.parse(readFileSync(pj, "utf8")); if (p.name === "@fangorn-network/sdk") { cliVersion = p.version; break; } }
145
+ }
146
+ const ours = createRequire(import.meta.url)("@fangorn-network/sdk/package.json").version;
147
+ if (cliVersion !== ours) throw new Error(`the fangorn CLI (${cli.join(" ")}) is SDK ${cliVersion ?? "?"}, westmarch reads with ${ours}: ` +
148
+ "they talk to different contracts. Point app.json's \"fangorn\" at a matching CLI.");
149
+ }
150
+
151
+ // ── the steps ─────────────────────────────────────────────────────────────────
152
+
153
+ export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false } = {}) {
154
+ const cfg = loadConfig(configPath);
155
+ CLI = cliArgv(cfg);
156
+ assertSameDeployment(CLI);
157
+ const base = dirname(resolve(configPath));
158
+ const dir = join(base, ".ship");
159
+ const statePath = join(dir, "state.json");
160
+ mkdirSync(dir, { recursive: true });
161
+ const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {};
162
+ const save = () => !dry && writeFileSync(statePath, JSON.stringify(state, null, 1));
163
+
164
+ const [{ FangornConfig }, { createPublicClient, http }, { env }, view, card] = await Promise.all([
165
+ import("@fangorn-network/sdk"), import("viem"),
166
+ import("@huggingface/transformers"), import("./view.js"), import("./agent-card.js"),
167
+ ]);
168
+ env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
169
+ const fangorn = await view.readOnlyFangorn();
170
+ fangorn.setAppId(cfg.app);
171
+ const appId = fangorn.getAppId();
172
+ const registry = fangorn.getAppRegistry();
173
+
174
+ // 1. claim
175
+ const me = (fangornCli(cfg.app, ["wallet"]).match(/Address:\s+(0x[0-9a-fA-F]{40})/) ?? [])[1]?.toLowerCase();
176
+ if (!me) throw new Error("`fangorn wallet` shows no address; run `fangorn init`");
177
+ let owner = (await registry.getAppOwner()).toLowerCase();
178
+ if (/^0x0+$/.test(owner)) {
179
+ const block = await createPublicClient({ transport: http(FangornConfig.rpcUrl) }).getBlockNumber();
180
+ state.fromBlock = String(block);
181
+ log(`${cfg.app} is unclaimed; claiming it for ${me} (fromBlock ${block})`);
182
+ fangornCli(cfg.app, ["app", "claim"], { dry });
183
+ owner = me;
184
+ save();
185
+ } else if (owner !== me) {
186
+ throw new Error(`${cfg.app} belongs to ${owner}, not this wallet (${me}); pick another name`);
187
+ }
188
+ state.fromBlock ??= String(cfg.fromBlock ?? 0);
189
+ log(`app ${cfg.app} = ${appId}, owner ${owner}, fromBlock ${state.fromBlock}`);
190
+
191
+ // 2. join
192
+ // Both registries: claiming joins the owner to the app, but commits also need the
193
+ // wallet's global DataRegistry standing, or every push reverts NotRegistered().
194
+ const info = fangornCli(cfg.app, ["app", "info"]);
195
+ if (!/DataRegistry:\s+registered/.test(info) || !/This app:\s+joined/.test(info)) fangornCli(cfg.app, ["register"], { dry });
196
+
197
+ // 3. schema
198
+ let onChain = null;
199
+ try { onChain = view.parseSchema((await fangorn.readNamespace(owner, view.SCHEMA_NAMESPACE)).contents); } catch { /* none yet */ }
200
+ if (!onChain || canonical(onChain.types, onChain.relations) !== canonical(cfg.types, cfg.relations)) {
201
+ const sdir = join(dir, "schema");
202
+ mkdirSync(sdir, { recursive: true });
203
+ writeFileSync(join(sdir, "graph.json"), JSON.stringify(schemaGraph(cfg.types, cfg.relations), null, 1));
204
+ log(`schema ${onChain ? "changed" : "is new"}: committing ${Object.keys(cfg.types).join(", ")}`);
205
+ fangornCli(cfg.app, ["repo", "init", view.SCHEMA_NAMESPACE], { cwd: sdir, dry });
206
+ fangornCli(cfg.app, ["commit", "graph.json", "-m", "schema", "--replace"], { cwd: sdir, dry });
207
+ fangornCli(cfg.app, ["push"], { cwd: sdir, dry });
208
+ } else log("schema unchanged");
209
+
210
+ // 4. sources: crawl + publish straight into the app
211
+ if (crawl) for (const s of cfg.sources) {
212
+ const cwd = s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base;
213
+ const paidArgs = cfg.paid && s.paid_dir ? ["--paid-dir", resolve(cwd, s.paid_dir)] : [];
214
+ run(s.command[0], [...s.command.slice(1), ...paidArgs, ...(replace ? ["--replace"] : []),
215
+ "--publish", "--namespace", s.namespace, "--fangorn-bin", CLI.join(" ")],
216
+ { cwd, env: { FANGORN_APP_ID: cfg.app }, dry });
217
+ }
218
+
219
+ // 5. the view. Its files are its state: on a fresh machine (CI), start from the deployed
220
+ // copy rather than re-embed everything. shard.js checks each shard against its manifest.
221
+ const site = join(dir, "site");
222
+ if (!existsSync(join(site, "view/cdn/catalog")) && state.url && !dry) await mirrorView(`${state.url}/view`, join(site, "view"));
223
+ const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
224
+ const report = dry ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
225
+ fromBlock: BigInt(state.fromBlock), log });
226
+
227
+ // 6. the page, the card, the headers
228
+ if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
229
+ mkdirSync(site, { recursive: true });
230
+ for (const f of readdirSync(site, { withFileTypes: true })) if (f.name !== "view" && f.name !== "_paid") rmSync(join(site, f.name), { recursive: true, force: true });
231
+ cpSync(STOCK, site, { recursive: true });
232
+ const url = deploy && !dry ? pagesUrl(cfg) : state.url ?? `https://${cfg.site.project}.pages.dev`;
233
+ // Paid records: every source's paid_dir → site/_paid (served only through the worker).
234
+ let paid = null;
235
+ if (cfg.paid) {
236
+ const network = cfg.paid.network ?? "arbitrum-sepolia";
237
+ const asset = cfg.paid.asset ?? USDC[network];
238
+ if (!asset) throw new Error(`paid.network ${network}: no default USDC address; set paid.asset`);
239
+ const decimals = cfg.paid.decimals ?? 6;
240
+ const [whole, frac = ""] = String(cfg.paid.price).split(".");
241
+ const price = (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + "0".repeat(decimals)).slice(0, decimals) || "0")).toString();
242
+ paid = { url: `${url}/paid/{id}`, price, asset, network, decimals, symbol: cfg.paid.symbol ?? "USDC", description: cfg.paid.description ?? "" };
243
+ // Only records a live row vouches for (its committed paid_sha256): one left over from
244
+ // an item since removed or re-extracted is not for sale, whatever is still on disk.
245
+ rmSync(join(site, "_paid"), { recursive: true, force: true });
246
+ mkdirSync(join(site, "_paid"), { recursive: true });
247
+ const vouched = new Set(view.liveFields(join(site, "view")).map((f) => f.paid_sha256).filter(Boolean));
248
+ let n = 0;
249
+ for (const s of cfg.sources) {
250
+ const d = s.paid_dir && resolve(s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base, s.paid_dir);
251
+ if (d && existsSync(d)) for (const f of readdirSync(d)) {
252
+ const bytes = readFileSync(join(d, f));
253
+ if (!vouched.has(createHash("sha256").update(bytes).digest("hex"))) continue;
254
+ writeFileSync(join(site, "_paid", f), bytes); n++;
255
+ }
256
+ }
257
+ const worker = readFileSync(join(HERE, "../site/paid-worker.js"), "utf8").replace(/\/\*CONFIG\*\/[\s\S]*?\/\*END\*\//, JSON.stringify({
258
+ price, network, asset, payTo: cfg.paid.payTo ?? owner, facilitator: cfg.paid.facilitator ?? "https://facilitator.payai.network",
259
+ name: cfg.paid.tokenName ?? "USD Coin", version: cfg.paid.tokenVersion ?? "2", description: paid.description }));
260
+ writeFileSync(join(site, "_worker.js"), worker);
261
+ log(`paid: ${n} record(s) at ${cfg.paid.price} ${paid.symbol} on ${network}, to ${cfg.paid.payTo ?? owner}`);
262
+ }
263
+ const { registerAgent } = await import("../site/agent.js");
264
+ const agentCard = card.agentCard({
265
+ name: cfg.name, description: cfg.description, url: `${url}/`, version: new Date().toISOString().slice(0, 10),
266
+ tools: await card.captureTools(() => registerAgent({})), tags: cfg.tags,
267
+ fangorn: { app: cfg.app, fromBlock: state.fromBlock, namespaces, views: [`${url}/view`], ...(paid ? { paid } : {}) },
268
+ });
269
+ mkdirSync(join(site, ".well-known"), { recursive: true });
270
+ writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
271
+ const immutable = " Cache-Control: public, max-age=31536000, immutable\n";
272
+ const domains = existsSync(join(site, "view/cdn/domains")) ? readdirSync(join(site, "view/cdn/domains")) : [];
273
+ writeFileSync(join(site, "_headers"),
274
+ "/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n"
275
+ + "/view/*\n Access-Control-Allow-Origin: *\n"
276
+ + `/assets/*\n${immutable}`
277
+ // An hour, not immutable: a shard is deleted when a record is retracted (see view.js).
278
+ + domains.map((d) => `/view/cdn/domains/${d}/shards/*\n Cache-Control: public, max-age=3600\n`).join(""));
279
+
280
+ // 7. deploy
281
+ if (deploy) {
282
+ run("npx", ["wrangler", "pages", "deploy", site, "--project-name", cfg.site.project, "--branch", "main", "--commit-dirty=true"],
283
+ { env: cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {}, dry });
284
+ state.url = url;
285
+ save();
286
+ }
287
+
288
+ // 8. ERC-8004 + the binding
289
+ const cardUrl = `${url}/.well-known/agent-card.json`;
290
+ const bound = await registry.appAgentUri(appId).catch(() => "");
291
+ if (deploy && bound !== cardUrl) {
292
+ log(`binding ${cardUrl} (on chain: ${bound || "nothing"})`);
293
+ const out = dry ? "" : fangornCli(cfg.app, ["app", "agent", cardUrl]);
294
+ state.agentId = (out.match(/Agent ID:\s+(\S+)/) ?? [])[1] ?? state.agentId;
295
+ save();
296
+ } else if (deploy) log("card already bound");
297
+
298
+ const added = Object.values(report).reduce((n, r) => n + r.added, 0);
299
+ log(`done: ${url} · card ${cardUrl} · agent ${state.agentId ?? "(unchanged)"} · +${added} records embedded`);
300
+ return { url, cardUrl, agentId: state.agentId, report };
301
+ }
302
+
303
+ /** Copy a deployed view (catalog, manifests, shards) to `dir`, so the next publish appends. */
304
+ async function mirrorView(base, dir) {
305
+ const get = async (p) => { const r = await fetch(`${base}/${p}`); if (!r.ok) throw new Error(`${base}/${p}: HTTP ${r.status}`); return r; };
306
+ let catalog;
307
+ try { catalog = await (await get("cdn/catalog")).json(); } catch (e) { log(`no deployed view to start from (${e.message})`); return; }
308
+ const { createHash } = await import("node:crypto");
309
+ let files = 0;
310
+ for (const d of catalog.domains ?? []) {
311
+ const m = await (await get(`cdn/domains/${d.name}/manifest`)).json();
312
+ mkdirSync(join(dir, "cdn/domains", d.name, "shards"), { recursive: true });
313
+ writeFileSync(join(dir, "cdn/domains", d.name, "manifest"), JSON.stringify(m, null, 1));
314
+ for (const s of m.shards ?? []) {
315
+ const buf = Buffer.from(await (await get(`cdn/domains/${d.name}/shards/${s.file}`)).arrayBuffer());
316
+ if (s.sha256 && createHash("sha256").update(buf).digest("hex") !== s.sha256) throw new Error(`${s.file} does not match its manifest`);
317
+ writeFileSync(join(dir, "cdn/domains", d.name, "shards", s.file), buf);
318
+ files++;
319
+ }
320
+ }
321
+ writeFileSync(join(dir, "cdn/catalog"), JSON.stringify(catalog, null, 1));
322
+ log(`started from the deployed view: ${catalog.domains?.length ?? 0} domain(s), ${files} shard(s)`);
323
+ }
324
+
325
+ /** The project's pages.dev URL, creating the project on first use. Pages appends a
326
+ * suffix when the name is taken elsewhere, so the URL is read back, never assumed. */
327
+ function pagesUrl(cfg) {
328
+ const env = cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {};
329
+ const find = () => {
330
+ const out = run("npx", ["wrangler", "pages", "project", "list"], { env, capture: true });
331
+ const row = out.split("\n").find((l) => l.split("│")[1]?.trim() === cfg.site.project);
332
+ const host = row?.match(/([a-z0-9-]+\.pages\.dev)/)?.[1];
333
+ return host ? `https://${host}` : null;
334
+ };
335
+ let url = find();
336
+ if (!url) {
337
+ run("npx", ["wrangler", "pages", "project", "create", cfg.site.project, "--production-branch", "main"], { env });
338
+ url = find();
339
+ }
340
+ if (!url) throw new Error(`could not read the pages.dev URL of project ${cfg.site.project}`);
341
+ return url;
342
+ }
343
+
344
+ // ── cli ───────────────────────────────────────────────────────────────────────
345
+ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch-ship")) {
346
+ if (process.argv[2] === "--selfcheck") {
347
+ // The schema as committed, read back the way the chain returns it, must compare
348
+ // equal to the config, or every run re-commits an unchanged schema.
349
+ const { parseSchema } = await import("./view.js");
350
+ const types = { "t.v1": { description: "d", role_map: { title: "name", text: ["body"] }, presentation: { externalUrl: { "t.v1": "{url}" } } },
351
+ "u.v1": { description: "e", role_map: { title: "label" } } };
352
+ const relations = [{ rel: "cites", from: "t.v1", to: "u.v1" }];
353
+ const g = schemaGraph(types, relations);
354
+ // Keys reversed at every depth, as a canonical (sorted) encoding hands them back.
355
+ const reverse = (v) => Array.isArray(v) ? v.map(reverse)
356
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).reverse().map((k) => [k, reverse(v[k])])) : v;
357
+ const readBack = parseSchema({
358
+ vertices: g.vertices.map((v) => ({ cid: `cid-${v.id}`, schemaId: v.tag, payload: reverse(v.payload) })),
359
+ edges: g.edges.map((e) => ({ relation: e.rel, sourceCid: `cid-${e.from}`, targetCid: `cid-${e.to}` })),
360
+ });
361
+ if (canonical(readBack.types, readBack.relations) !== canonical(types, relations)) throw new Error("schema does not round-trip");
362
+ if (canonical(readBack.types, readBack.relations) === canonical({ ...types, "v.v1": {} }, relations)) throw new Error("a new type must count as a change");
363
+ const row = { city: "Wisconsin Rapids", state: "WI", site: "https://x", args: ["--archive-page", "/569/A"] };
364
+ const cmd = fill(["py", "--site", "{site}", "--city", "{city}", "{args...}", "--ns", "us-{state|lower}-{city|slug}"], row);
365
+ if (JSON.stringify(cmd) !== JSON.stringify(["py", "--site", "https://x", "--city", "Wisconsin Rapids", "--archive-page", "/569/A", "--ns", "us-wi-wisconsin-rapids"]))
366
+ throw new Error(`template fill: ${JSON.stringify(cmd)}`);
367
+ 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");
368
+ process.exit(0);
369
+ }
370
+ const [path, ...flags] = process.argv.slice(2);
371
+ if (!path || flags.includes("--help")) {
372
+ console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace]");
373
+ process.exit(path ? 0 : 2);
374
+ }
375
+ await ship(path, { crawl: !flags.includes("--no-crawl"), deploy: !flags.includes("--no-deploy"), dry: flags.includes("--dry-run"),
376
+ replace: flags.includes("--replace") })
377
+ .catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
378
+ }
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));