@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.
- package/README.md +2 -2
- package/consume/apps.js +4 -2
- package/consume/corpora.js +1 -1
- package/consume/directory.js +1 -1
- package/consume/embed.js +1 -1
- package/consume/lint.js +1 -1
- package/consume/mcp-bin.js +6 -0
- package/consume/mcp.js +193 -34
- package/consume/ort-preload.js +25 -0
- package/consume/rank.js +1 -1
- package/consume/roles.js +6 -4
- package/consume/shard.js +1 -1
- package/consume/steam.js +1 -1
- package/consume/taste-doc.js +1 -1
- package/consume/taste.js +1 -1
- package/consume/tools.js +28 -5
- package/consume/ui.js +1 -1
- package/consume/view-tools.js +202 -0
- package/consume/x402.js +94 -0
- package/dist/ts/eval.js +244 -0
- package/docs/app-to-agent.md +595 -185
- package/package.json +24 -10
- package/publish/agent-card.js +12 -2
- package/publish/cohort.js +1 -1
- package/publish/demand.js +1 -1
- package/publish/enrich.js +1 -1
- package/publish/envelope.js +1 -1
- package/publish/eval.ts +250 -0
- package/publish/graph.js +1 -1
- package/publish/manifest.js +1 -1
- package/publish/reactions.js +4 -2
- package/publish/settle.js +1 -1
- package/publish/ship.js +462 -0
- package/publish/terms.js +1 -1
- package/publish/vault.js +1 -1
- package/publish/view-cli.js +39 -0
- package/publish/view.js +434 -0
- package/publish/westmarch.d.ts +35 -0
- package/site/agent.js +44 -0
- package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
- package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
- package/site/dist/assets/index-CaHGhiKK.js +8 -0
- package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
- package/site/dist/assets/roles-C9V_uDr_.js +1 -0
- package/site/dist/assets/roles-YDRjv_PY.js +1 -0
- package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
- package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
- package/site/dist/index.html +20 -0
- package/site/feed.js +62 -0
- package/site/store.js +87 -0
- package/site/worker.js +103 -0
package/publish/ship.js
ADDED
|
@@ -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));
|