@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,434 @@
1
+ // An app's committed data → a view any Fangorn reader loads: the free, local tier.
2
+ //
3
+ // westmarch-view --app my-app --namespace my-app --out site/view
4
+ //
5
+ // Reads the chain and nothing else: each publisher's commits to `namespaces`, and
6
+ // the app owner's schema (`fangorn.schema`, see below). Embeds only records the view
7
+ // does not hold yet, marks removed ones as tombstones, and writes static files:
8
+ //
9
+ // <out>/cdn/catalog
10
+ // <out>/cdn/domains/<domain>/manifest
11
+ // <out>/cdn/domains/<domain>/shards/shard-NNNN-<sha12>.ndjson.gz
12
+ //
13
+ // Stateless: the view's own files are its state, so it runs from a laptop, CI or an
14
+ // agent, and a second run with nothing new on chain changes nothing.
15
+ //
16
+ // THE SAME CONTRACT AS QUICKBEAM (the hosted tier), so an app moves between the two by
17
+ // changing one URL in its card:
18
+ // - URL layout, domain names (`domainFor`, byte-identical to quickbeam's
19
+ // `_domain_for` and the registry's `domainFor`), row shape {track_id, fields, v},
20
+ // manifest and catalog keys;
21
+ // - a record is a vertex: track_id = its CID, fields = payload + entityType (its tag);
22
+ // - the schema: a declared type is embedded and shipped with exactly its role map;
23
+ // - the embedded text (`composeText` mirrors quickbeam's `compose_document_text`).
24
+ // What differs is the encoder: transformers.js q8 here, fastembed fp32 there. Their
25
+ // vectors agree to ~0.95 cosine, close but not interchangeable, so the manifest names
26
+ // its `embedder` and this refuses to append to a view another encoder built.
27
+ //
28
+ // ponytail: flat records. quickbeam folds a record's graph neighbours into its text
29
+ // and can fuse publishers by shared identity; that is the hosted tier's job. Edges
30
+ // are not shipped here either (quickbeam's edges.json) until a reader needs them.
31
+
32
+ import { gunzipSync, gzipSync } from "node:zlib";
33
+ import { createHash } from "node:crypto";
34
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
35
+ import { EMBED_DIM, EMBED_MODEL, embedDocumentDirect, packVec, unpackVec } from "../consume/embed.js";
36
+ import { coverage as fitCoverage } from "./reactions.js";
37
+
38
+ export const SCHEMA_NAMESPACE = "fangorn.schema";
39
+ export const TYPE_TAG = "fangorn.type.v1";
40
+ export const EMBEDDER = { runtime: "transformers.js", model: EMBED_MODEL, dtype: "q8" };
41
+ const SINGULAR = ["identity", "title", "subtitle", "temporal", "spatial", "media"];
42
+ const MULTI = ["tags", "measures", "relations", "text"];
43
+ const ZERO = "0x0000000000000000000000000000000000000000";
44
+ // Refit coverage once a domain has grown this many times over what it was fit on.
45
+ const COVERAGE_REFIT = 2;
46
+
47
+ // ── the contract shared with quickbeam ────────────────────────────────────────
48
+
49
+ const slug = (s) => (String(s ?? "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "x");
50
+ /** quickbeam `_domain_for` / registry `domainFor`, for an app id. */
51
+ export const domainFor = (appId, owner, namespace) =>
52
+ `${slug(appId.slice(2, 10))}-${slug(owner.slice(2, 10))}-${slug(namespace)}`;
53
+
54
+ /** quickbeam `compose_document_text`, minus the `search_document: ` prefix that
55
+ * `embedDocumentDirect` adds itself. */
56
+ export function composeText(fields, rm) {
57
+ const tags = (rm.tags ?? []).map((t) => (typeof fields[t] === "string" ? fields[t] : "")).join(" ");
58
+ const rels = Object.entries(fields)
59
+ .filter(([k, v]) => Array.isArray(v) && v.length && k !== "entityType")
60
+ .map(([k, v]) => `${k}: ${v.slice(0, 20).filter((x) => x).map(String).join(", ")}`).join("; ");
61
+ const subtitle = fields[rm.subtitle ?? ""] ?? "";
62
+ const text = (rm.text ?? []).filter((t) => fields[t]).map((t) => String(fields[t])).join("; ");
63
+ let s = `Title: ${fields[rm.title ?? ""] ?? ""}. Tags: ${tags}`;
64
+ if (subtitle) s += `. Subtitle: ${subtitle}`;
65
+ if (text) s += `. ${text}`;
66
+ if (rels) s += `. ${rels}`;
67
+ return s.slice(0, 1000 - "search_document: ".length);
68
+ }
69
+
70
+ /** `fangorn.schema` contents → { types: {tag: {description, role_map, presentation}}, relations }. */
71
+ export function parseSchema(contents) {
72
+ const types = {}, tagOf = {};
73
+ for (const v of contents?.vertices ?? []) {
74
+ const p = v.payload ?? {};
75
+ if (v.schemaId !== TYPE_TAG || typeof p.tag !== "string") continue;
76
+ types[p.tag] = { description: String(p.description ?? ""), role_map: p.role_map ?? {}, presentation: p.presentation ?? {} };
77
+ tagOf[v.cid] = p.tag;
78
+ }
79
+ const relations = (contents?.edges ?? []).filter((e) => tagOf[e.sourceCid] && tagOf[e.targetCid])
80
+ .map((e) => ({ rel: e.relation, from: tagOf[e.sourceCid], to: tagOf[e.targetCid] }));
81
+ return { types, relations };
82
+ }
83
+
84
+ /** Declared means declared: every role present, left-out ones empty. */
85
+ export function completeRoles(rm = {}) {
86
+ const out = {};
87
+ for (const r of SINGULAR) out[r] = rm[r] ?? null;
88
+ for (const r of MULTI) out[r] = rm[r] == null ? [] : [rm[r]].flat();
89
+ return out;
90
+ }
91
+
92
+ // ponytail: for a type the schema does not declare, a guess from field names and
93
+ // value lengths — enough to show and search something, and loud about it. quickbeam's
94
+ // `infer_roles` is richer; the fix either way is declaring the type.
95
+ export function guessRoles(rows) {
96
+ const keys = [...new Set(rows.flatMap((r) => Object.keys(r)))].filter((k) => k !== "entityType");
97
+ const find = (names) => keys.find((k) => names.includes(k.toLowerCase())) ?? null;
98
+ const avgLen = (k) => rows.reduce((s, r) => s + (typeof r[k] === "string" ? r[k].length : 0), 0) / (rows.length || 1);
99
+ return completeRoles({
100
+ identity: find(["id", "uid", "key", "slug"]),
101
+ title: find(["title", "name", "label", "headline"]),
102
+ text: keys.filter((k) => avgLen(k) >= 80),
103
+ tags: keys.filter((k) => ["tags", "category", "categories", "genre", "genres", "topics"].includes(k.toLowerCase())),
104
+ });
105
+ }
106
+
107
+ /** Merge the declared types a domain holds (most common first), like quickbeam's `domain_spec`. */
108
+ export function domainSpec(schema, tags) {
109
+ const decls = tags.map((t) => schema?.types?.[t]).filter(Boolean);
110
+ if (!decls.length) return null;
111
+ const role_map = {};
112
+ for (const d of decls) {
113
+ const c = completeRoles(d.role_map);
114
+ for (const r of SINGULAR) role_map[r] ??= c[r];
115
+ for (const r of MULTI) role_map[r] = [...new Set([...(role_map[r] ?? []), ...c[r]])];
116
+ }
117
+ const presentation = {};
118
+ for (const d of decls) for (const [k, v] of Object.entries(d.presentation ?? {}))
119
+ presentation[k] = v && typeof v === "object" && !Array.isArray(v) ? { ...presentation[k], ...v } : v;
120
+ return { description: decls.map((d) => d.description).filter(Boolean).join(" "), role_map, presentation };
121
+ }
122
+
123
+ // ── a client that only reads ──────────────────────────────────────────────────
124
+
125
+ /** An SDK client for reading commits: a throwaway key (nothing signs) and no upload
126
+ * credentials. Contents come through an IPFS gateway: FANGORN_IPFS_GATEWAY, else the one
127
+ * in ~/.fangorn/config.json (what the fangorn CLI reads through), else the SDK default.
128
+ * ponytail: the SDK's default is ipfs.io, which no longer serves raw blocks, so on a
129
+ * machine with neither setting reads fail; set FANGORN_IPFS_GATEWAY. */
130
+ export async function readOnlyFangorn() {
131
+ const [{ Fangorn, FangornConfig }, { generatePrivateKey }, { homedir }] = await Promise.all([
132
+ import("@fangorn-network/sdk"), import("viem/accounts"), import("node:os")]);
133
+ let gateway = process.env.FANGORN_IPFS_GATEWAY ?? process.env.IPFS_GATEWAY;
134
+ try { gateway ??= JSON.parse(readFileSync(`${homedir()}/.fangorn/config.json`, "utf8")).pinataGateway || undefined; } catch { /* no CLI config */ }
135
+ if (gateway && !/^https?:\/\//.test(gateway)) gateway = `https://${gateway}`;
136
+ return Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig,
137
+ storage: { signedUrl: gateway ? { gateway } : {} } });
138
+ }
139
+
140
+ /** What publishView reads, served from memory instead of the chain: `chain` maps
141
+ * `<owner>/<namespace>` to namespace contents ({ vertices: [{cid, schemaId, payload}],
142
+ * edges }). A build that must not touch the chain — a candidate recipe graded before it
143
+ * ships — runs the exact code a real one does. */
144
+ export function localFangorn(chain, { appId, owner }) {
145
+ return {
146
+ setAppId() {}, getAppId: () => appId,
147
+ getAppRegistry: () => ({ getAppOwner: async () => owner }),
148
+ appNamespaces: async ({ namespace }) => Object.keys(chain).filter((k) => k.endsWith(`/${namespace}`)).map((k) => ({ owner: k.split("/")[0] })),
149
+ readNamespace: async (o, ns) => { const c = chain[`${o}/${ns}`]; if (!c) throw new Error("no such namespace"); return { contents: c }; },
150
+ };
151
+ }
152
+
153
+ /** `embed`, remembered on disk by the exact text embedded (and the encoder). Two builds
154
+ * of the same data under different recipes then only pay for the text that differs.
155
+ * One ndjson file of packed vectors; packVec's fixed 1/127 scale makes the round trip exact.
156
+ * ponytail: the whole file is held in memory, ~400 bytes a record; shard it past a few million. */
157
+ export function cachedEmbed(path, embed = embedDocumentDirect) {
158
+ const seen = new Map();
159
+ // A build killed mid-write leaves half a line; that one vector is embedded again, nothing else is lost.
160
+ if (existsSync(path)) for (const l of readFileSync(path, "utf8").split("\n")) { try { const [k, v] = JSON.parse(l); seen.set(k, v); } catch { /* blank or cut short */ } }
161
+ const salt = JSON.stringify(EMBEDDER);
162
+ const fn = async (text) => {
163
+ const k = createHash("sha256").update(`${salt}\n${text}`).digest("hex");
164
+ let v = seen.get(k);
165
+ if (!v) { v = packVec(await embed(text)); seen.set(k, v); appendFileSync(path, `${JSON.stringify([k, v])}\n`); fn.misses++; }
166
+ return unpackVec(v);
167
+ };
168
+ fn.misses = 0;
169
+ return fn;
170
+ }
171
+
172
+ // ── the view on disk ──────────────────────────────────────────────────────────
173
+
174
+ const readJson = (p, dflt) => (existsSync(p) ? JSON.parse(readFileSync(p, "utf8")) : dflt);
175
+ const writeJson = (p, o) => { writeFileSync(`${p}.tmp`, JSON.stringify(o, null, 1)); renameSync(`${p}.tmp`, p); };
176
+
177
+ /** What a domain already serves: its manifest and every row (for tombstones and coverage). */
178
+ function loadDomain(dir) {
179
+ const manifest = readJson(`${dir}/manifest`, null);
180
+ const rows = new Map();
181
+ for (const s of manifest?.shards ?? []) {
182
+ const text = gunzipSync(readFileSync(`${dir}/shards/${s.file}`)).toString("utf8");
183
+ for (const line of text.split("\n")) if (line.trim()) { const r = JSON.parse(line); rows.set(r.track_id, r); }
184
+ }
185
+ return { manifest, rows };
186
+ }
187
+
188
+ /** Every live row's fields, across the view at `out`. */
189
+ export function liveFields(out) {
190
+ const root = `${out}/cdn/domains`;
191
+ if (!existsSync(root)) return [];
192
+ return readdirSync(root).flatMap((d) => {
193
+ const { manifest, rows } = loadDomain(`${root}/${d}`);
194
+ const dead = new Set(manifest?.tombstones ?? []);
195
+ return [...rows.values()].filter((r) => !dead.has(r.track_id)).map((r) => r.fields ?? {});
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Bring `out` up to date with the chain. `fangorn` is an SDK client (reads only).
201
+ * Returns per-domain counts of what changed.
202
+ */
203
+ export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0n, rebake = false,
204
+ embed = embedDocumentDirect, log = console.log }) {
205
+ if (!namespaces?.length) throw new Error("publishView: name the data namespace(s) to publish");
206
+ fangorn.setAppId(app);
207
+ const appId = fangorn.getAppId();
208
+ const owner = (await fangorn.getAppRegistry().getAppOwner()).toLowerCase();
209
+ let schema = { types: {}, relations: [] };
210
+ if (owner === ZERO) log(`app ${app} is unclaimed, so it has no schema; every type will be guessed`);
211
+ else {
212
+ try { schema = parseSchema((await fangorn.readNamespace(owner, SCHEMA_NAMESPACE)).contents); }
213
+ catch (e) { log(`schema: none read (${e.message}); every type will be guessed`); }
214
+ }
215
+ const declared = Object.keys(schema.types);
216
+ log(`schema: ${declared.length ? declared.join(", ") : "no declared types"}`);
217
+
218
+ const report = {};
219
+ for (const ns of namespaces.filter((n) => n !== SCHEMA_NAMESPACE)) {
220
+ const timelines = await fangorn.appNamespaces({ namespace: ns, fromBlock: BigInt(fromBlock) });
221
+ log(`${ns}: ${timelines.length} publisher(s) since block ${fromBlock}`);
222
+ for (const { owner: publisher } of timelines) {
223
+ const domain = domainFor(appId, publisher, ns);
224
+ report[domain] = await publishDomain({
225
+ fangorn, schema, publisher, ns, domain, dir: `${out}/cdn/domains/${domain}`, rebake, embed, log,
226
+ });
227
+ }
228
+ }
229
+ writeCatalog(out);
230
+ return report;
231
+ }
232
+
233
+ async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, rebake, embed, log }) {
234
+ const { contents } = await fangorn.readNamespace(publisher, ns);
235
+ const records = new Map((contents.vertices ?? []).filter((v) => v.payload && typeof v.payload === "object")
236
+ .map((v) => [v.cid, { ...v.payload, entityType: v.schemaId }]));
237
+
238
+ if (rebake) rmSync(dir, { recursive: true, force: true }); // old shards would stay reachable
239
+ const had = loadDomain(dir);
240
+ const made = had.manifest?.embedder ?? (had.manifest ? { runtime: "fastembed", dtype: "fp32" } : null);
241
+ if (made && JSON.stringify(made) !== JSON.stringify(EMBEDDER))
242
+ throw new Error(`${domain} was embedded by ${made.runtime} ${made.dtype}, not ${EMBEDDER.runtime} ${EMBEDDER.dtype}; ` +
243
+ "vectors from two encoders are not comparable. Rebake it with --rebake, or keep publishing it with the tool that made it.");
244
+
245
+ const dead = new Set(had.manifest?.tombstones ?? []);
246
+ const live = (id) => had.rows.has(id) && !dead.has(id);
247
+ const fresh = [...records.keys()].filter((id) => !live(id));
248
+ const gone = [...had.rows.keys()].filter((id) => live(id) && !records.has(id));
249
+
250
+ // Roles per type: declared exactly, else guessed (and said so).
251
+ const byType = new Map();
252
+ for (const id of fresh) { const t = records.get(id).entityType; byType.set(t, [...(byType.get(t) ?? []), id]); }
253
+ const rolesOf = new Map();
254
+ for (const [t, ids] of byType) {
255
+ if (schema.types[t]) rolesOf.set(t, completeRoles(schema.types[t].role_map));
256
+ else {
257
+ rolesOf.set(t, guessRoles(ids.slice(0, 200).map((id) => records.get(id))));
258
+ log(`${domain}: type ${t} is not in the schema; guessed title=${rolesOf.get(t).title} text=${rolesOf.get(t).text}`);
259
+ }
260
+ }
261
+
262
+ let n = 0;
263
+ const lines = [];
264
+ for (const id of fresh) {
265
+ const fields = records.get(id);
266
+ const v = await embed(composeText(fields, rolesOf.get(fields.entityType)));
267
+ lines.push(JSON.stringify({ track_id: id, fields, v: packVec(v) }));
268
+ if (++n % 100 === 0) log(`${domain}: embedded ${n}/${fresh.length}`);
269
+ }
270
+ if (!fresh.length && !gone.length && had.manifest && !rebake) {
271
+ applySpec(dir, had.manifest, schema, had.rows, dead); // a schema change alone still lands
272
+ return { added: 0, removed: 0 };
273
+ }
274
+
275
+ mkdirSync(`${dir}/shards`, { recursive: true });
276
+ const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
277
+ distance: "Cosine", embedder: EMBEDDER, filter: { owner: [publisher], namespace: [ns] }, shards: [], tombstones: [] };
278
+ const writeShard = (ls) => {
279
+ const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
280
+ const sha256 = createHash("sha256").update(gz).digest("hex");
281
+ const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${sha256.slice(0, 12)}.ndjson.gz`;
282
+ writeFileSync(`${dir}/shards/${file}`, gz);
283
+ manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256 });
284
+ };
285
+ if (lines.length) {
286
+ writeShard(lines);
287
+ for (const l of lines) { const r = JSON.parse(l); had.rows.set(r.track_id, r); dead.delete(r.track_id); }
288
+ }
289
+ for (const id of gone) dead.add(id);
290
+ // A tombstone hides a row from readers, but its shard still serves the bytes. A record
291
+ // retracted for what it said (a name, an address) has to be gone, so any removal
292
+ // rewrites the live rows into one shard and the old files are deleted. No re-embedding:
293
+ // the vectors are already in the rows.
294
+ // ponytail: rewrites the whole domain on every removal; compact past a threshold if domains get big.
295
+ if (dead.size) {
296
+ for (const s of manifest.shards) rmSync(`${dir}/shards/${s.file}`, { force: true });
297
+ manifest.shards = [];
298
+ for (const id of dead) had.rows.delete(id);
299
+ dead.clear();
300
+ writeShard([...had.rows.values()].map((r) => JSON.stringify(r)));
301
+ }
302
+ manifest.tombstones = [...dead].sort();
303
+ manifest.created_at = Math.floor(Date.now() / 1000);
304
+ applySpec(dir, manifest, schema, had.rows, dead, rolesOf);
305
+ log(`${domain}: +${fresh.length} embedded, -${gone.length} removed, ${manifest.count} live`);
306
+ return { added: fresh.length, removed: gone.length };
307
+ }
308
+
309
+ /** Counts, types, roles, description, presentation and coverage from what the domain now holds. */
310
+ function applySpec(dir, manifest, schema, rows, dead, guessed = new Map()) {
311
+ const liveRows = [...rows.values()].filter((r) => !dead.has(r.track_id));
312
+ const types = {};
313
+ for (const r of liveRows) types[r.fields?.entityType] = (types[r.fields?.entityType] ?? 0) + 1;
314
+ const tags = Object.entries(types).sort((a, b) => b[1] - a[1]).map(([t]) => t);
315
+ const before = JSON.stringify(manifest);
316
+
317
+ manifest.count = liveRows.length;
318
+ manifest.entity_types = tags.map((t) => ({ type: t, count: types[t] }));
319
+ const spec = domainSpec(schema, tags);
320
+ const fields = [...new Set(liveRows.slice(0, 500).flatMap((r) => Object.keys(r.fields ?? {})))].sort();
321
+ manifest.role_map = { ...(spec?.role_map ?? manifest.role_map ?? guessed.values().next().value ?? completeRoles()), fields };
322
+ if (spec?.description) manifest.description = spec.description;
323
+ if (spec && Object.keys(spec.presentation).length) manifest.presentation = spec.presentation;
324
+
325
+ const cov = manifest.coverage;
326
+ if (liveRows.length && (!cov || liveRows.length >= COVERAGE_REFIT * (cov.fit_at ?? 0))) {
327
+ manifest.coverage = { ...fitCoverage(liveRows.map((r) => unpackVec(r.v))), fit_at: liveRows.length };
328
+ }
329
+ if (JSON.stringify(manifest) !== before || !existsSync(`${dir}/manifest`)) {
330
+ mkdirSync(dir, { recursive: true });
331
+ writeJson(`${dir}/manifest`, manifest);
332
+ }
333
+ }
334
+
335
+ function writeCatalog(out) {
336
+ const root = `${out}/cdn/domains`;
337
+ mkdirSync(`${out}/cdn`, { recursive: true });
338
+ const domains = existsSync(root) ? readdirSync(root).sort().map((d) => readJson(`${root}/${d}/manifest`, null)).filter(Boolean) : [];
339
+ writeJson(`${out}/cdn/catalog`, {
340
+ generated_at: Math.floor(Date.now() / 1000), embedding_model: EMBED_MODEL,
341
+ domains: domains.map((m) => ({
342
+ name: m.name, description: m.description, count: m.count, dim: m.dim,
343
+ bytes: m.shards.reduce((s, x) => s + x.bytes, 0), shard_count: m.shards.length,
344
+ entity_types: m.entity_types.map((e) => e.type), manifest: `${m.name}/manifest.json`,
345
+ ...(m.coverage ? { coverage: m.coverage } : {}),
346
+ })),
347
+ });
348
+ }
349
+
350
+ // ── self-check: `node publish/view.js` — a fake chain, a fake embedder, the real reader ──
351
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/view.js")) {
352
+ const { createServer } = await import("node:http");
353
+ const { mkdtempSync } = await import("node:fs");
354
+ const { tmpdir } = await import("node:os");
355
+ const { loadShard, configure } = await import("../consume/shard.js");
356
+ const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
357
+ const throws = async (f, re, m) => { try { await f(); } catch (e) { if (re.test(e.message)) return; throw e; } throw new Error(`must throw: ${m}`); };
358
+
359
+ // quickbeam's own outputs, pinned: the contract, not an approximation of it.
360
+ eq(domainFor(`0x${"7e1497af".padEnd(64, "0")}`, "0x55cf6bd2aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "media"), "7e1497af-55cf6bd2-media", "domain name = quickbeam's");
361
+ eq(composeText({ name: "Oak", family: "Fagaceae", description: "big", entityType: "t" },
362
+ completeRoles({ title: "name", tags: ["family"], text: ["description"] })),
363
+ "Title: Oak. Tags: Fagaceae. big", "embedded text = quickbeam's compose_document_text");
364
+
365
+ const APP = `0x${"ab".repeat(32)}`, OWNER = `0x${"11".repeat(20)}`, PUB = `0x${"22".repeat(20)}`;
366
+ const TAG = "shop.item.v1";
367
+ const chain = {
368
+ [`${OWNER}/${SCHEMA_NAMESPACE}`]: { vertices: [{ cid: "t1", schemaId: TYPE_TAG, payload: {
369
+ tag: TAG, description: "Things for a room.", role_map: { identity: "sku", title: "name", text: "about" },
370
+ presentation: { externalUrl: { [TAG]: "https://shop.example/{sku}" } } } }], edges: [] },
371
+ [`${PUB}/shop`]: { vertices: [
372
+ { cid: "c1", schemaId: TAG, payload: { sku: "a1", name: "Oak table", about: "a solid oak dining table", price: 3 } },
373
+ { cid: "c2", schemaId: TAG, payload: { sku: "b2", name: "Lamp", about: "brass reading lamp", price: 1 } },
374
+ { cid: "c3", schemaId: "shop.note.v1", payload: { title: "Note", body: "an undeclared type, with a body long enough to count as its text field" } },
375
+ ], edges: [] },
376
+ };
377
+ const fangorn = localFangorn(chain, { appId: APP, owner: OWNER });
378
+ const embedded = [];
379
+ const embed = async (t) => { embedded.push(t); const v = new Array(EMBED_DIM).fill(0); for (const w of t.split(/\W+/)) if (w) v[w.length % EMBED_DIM] += 1; const n = Math.hypot(...v) || 1; return v.map((x) => x / n); };
380
+ const out = mkdtempSync(`${tmpdir()}/view-`);
381
+
382
+ // The vector cache: a second ask for the same text never reaches the encoder, and reads back exactly.
383
+ const cache = cachedEmbed(`${out}/vectors.ndjson`, embed);
384
+ const v1 = await cache("Title: Oak"), again = cachedEmbed(`${out}/vectors.ndjson`, embed);
385
+ eq([packVec(await again("Title: Oak")), again.misses, embedded.length], [packVec(v1), 0, 1], "a cached vector comes back from disk, unchanged");
386
+ appendFileSync(`${out}/vectors.ndjson`, '["cut sho');
387
+ eq(packVec(await cachedEmbed(`${out}/vectors.ndjson`, embed)("Title: Oak")), packVec(v1), "a line cut short by a kill is skipped, not fatal");
388
+ embedded.length = 0; rmSync(`${out}/vectors.ndjson`);
389
+ const run = () => publishView({ fangorn, app: APP, namespaces: ["shop"], out, embed, log: () => {} });
390
+
391
+ const d = domainFor(APP, PUB, "shop");
392
+ eq((await run())[d], { added: 3, removed: 0 }, "first run embeds everything");
393
+ if (!embedded.includes("Title: Oak table. Tags: . a solid oak dining table")) throw new Error(`declared roles compose the text: ${embedded}`);
394
+ let m = readJson(`${out}/cdn/domains/${d}/manifest`);
395
+ eq([m.role_map.identity, m.role_map.title, m.role_map.text, m.description], ["sku", "name", ["about"], "Things for a room."], "the schema is applied");
396
+ eq(m.embedder, EMBEDDER, "the manifest names its encoder");
397
+
398
+ eq((await run())[d], { added: 0, removed: 0 }, "nothing new on chain → nothing embedded");
399
+ eq(embedded.length, 3, "and the embedder was not called");
400
+
401
+ chain[`${PUB}/shop`].vertices = [chain[`${PUB}/shop`].vertices[0],
402
+ { cid: "c2b", schemaId: TAG, payload: { sku: "b2", name: "Lamp", about: "brass reading lamp", price: 2 } }];
403
+ eq((await run())[d], { added: 1, removed: 2 }, "a changed record is a new CID; removed ones are dropped");
404
+ m = readJson(`${out}/cdn/domains/${d}/manifest`);
405
+ eq([m.count, m.shards.length, m.tombstones, readdirSync(`${out}/cdn/domains/${d}/shards`).length], [2, 1, [], 1],
406
+ "a removal compacts: the retracted rows' bytes are no longer served anywhere");
407
+ if (embedded.length !== 4) throw new Error("compaction must not re-embed");
408
+ chain[`${PUB}/shop`].vertices.push({ cid: "c4", schemaId: TAG, payload: { sku: "d4", name: "Rug", about: "wool rug" } });
409
+ await run();
410
+ chain[`${PUB}/shop`].vertices.pop();
411
+ m = readJson(`${out}/cdn/domains/${d}/manifest`);
412
+ eq([m.count, m.shards.length], [3, 2], "an addition alone is a delta shard");
413
+ await run();
414
+ m = readJson(`${out}/cdn/domains/${d}/manifest`);
415
+
416
+ // The real reader over HTTP: digests checked, tombstones honoured, the publisher's id resolves.
417
+ const srv = createServer((q, s) => { try { s.end(readFileSync(`${out}${decodeURIComponent(q.url)}`)); } catch { s.statusCode = 404; s.end(); } });
418
+ await new Promise((ok) => srv.listen(0, ok));
419
+ configure({ rowText: (f) => f.about ?? "" });
420
+ const got = await loadShard(`http://127.0.0.1:${srv.address().port}`);
421
+ srv.close();
422
+ eq(got.map((r) => [r.id, r.price]).sort(), [["c1", 3], ["c2b", 2]], "readers see exactly the live records");
423
+ const { getRow } = await import("../consume/tools.js");
424
+ const { rolesFrom } = await import("../consume/roles.js");
425
+ eq(getRow(got, "b2", rolesFrom([m]))?.id, "c2b", "get by the publisher's own id, not the CID");
426
+
427
+ m.embedder = { runtime: "fastembed", dtype: "fp32" }; writeJson(`${out}/cdn/domains/${d}/manifest`, m);
428
+ await throws(run, /not comparable/, "appending to another encoder's view");
429
+ eq((await publishView({ fangorn, app: APP, namespaces: ["shop"], out, embed, rebake: true, log: () => {} }))[d],
430
+ { added: 2, removed: 0 }, "--rebake starts the domain over");
431
+ eq(readdirSync(`${out}/cdn/domains/${d}/shards`).length, 1, "and leaves no orphaned shards");
432
+ rmSync(out, { recursive: true, force: true });
433
+ console.log("view.js self-check ok — domain names and embedded text match quickbeam's, the schema is applied, a second run embeds nothing, additions ship as delta shards, removals compact the domain, the real reader sees exactly the live records by the publisher's id, and another encoder's view is refused");
434
+ }
@@ -0,0 +1,35 @@
1
+ // Types for the westmarch JS modules the TypeScript files import, by their package
2
+ // names. Only what those files use; widen as more of westmarch moves to TypeScript.
3
+
4
+ type Row = { id: string; text?: string; vector?: unknown; [field: string]: unknown };
5
+ type Roles = { title: string[]; subtitle: string[]; tags: string[]; text: string[]; declared?: boolean; [k: string]: unknown };
6
+ type Manifest = { role_map?: Record<string, unknown>; presentation?: Record<string, unknown>; [k: string]: unknown };
7
+
8
+ declare module "@fangorn-network/westmarch/shard" {
9
+ export function configure(hooks: {
10
+ rowText?: (fields: Record<string, unknown>, view: string) => string;
11
+ onManifests?: (manifests: Manifest[], view: string) => void;
12
+ }): void;
13
+ export function loadShard(base: string): Promise<Row[]>;
14
+ export function trimView(url: string): string;
15
+ }
16
+
17
+ declare module "@fangorn-network/westmarch/roles" {
18
+ export function rolesFrom(manifests?: Manifest[], sample?: Row[]): Roles;
19
+ export function textOf(row: Record<string, unknown>, roles: Roles): string;
20
+ export function titleOf(row: Row, roles: Roles): string;
21
+ export function subtitleOf(row: Row, roles: Roles): string | null;
22
+ export function values(v: unknown): string[];
23
+ }
24
+
25
+ declare module "@fangorn-network/westmarch/tools" {
26
+ export type Brief = { id: string; title: string; score: number; mode: string; [k: string]: unknown };
27
+ export function search(rows: Row[], query: string, roles: Roles,
28
+ opts?: { qv?: Float32Array | number[] | null; limit?: number; where?: Record<string, string>; lexBoost?: number }): Brief[];
29
+ export function matches(row: Row, where?: Record<string, string>): boolean;
30
+ }
31
+
32
+ declare module "@fangorn-network/westmarch/embed" {
33
+ export const EMBED_MODEL: string;
34
+ export function embedQueryDirect(text: string): Promise<Float32Array>;
35
+ }
package/site/agent.js ADDED
@@ -0,0 +1,44 @@
1
+ // The stock page's tools for agents in a browser: the same verbs as fangorn-mcp's data
2
+ // tools, over the rows this page loaded. `ctx` holds them; ship's card captures these.
3
+ import { describe, facet, getRow, search } from "../consume/tools.js";
4
+
5
+ const ok = (o) => ({ content: [{ type: "text", text: JSON.stringify(o) }] });
6
+ const obj = (properties, required = []) => ({ type: "object", properties, required });
7
+
8
+ export function registerAgent(ctx) {
9
+ const mc = document.modelContext;
10
+ if (!mc?.registerTool) return; // most browsers have no WebMCP yet
11
+ mc.registerTool({
12
+ name: "describe",
13
+ description: "What this app holds: its fields and how often each is present. Call it first.",
14
+ inputSchema: obj({}),
15
+ execute: async () => ok(describe(ctx.rows)),
16
+ });
17
+ mc.registerTool({
18
+ name: "search",
19
+ description: "Search by meaning: a question in plain words. Optional equality filters in `where`.",
20
+ inputSchema: obj({ query: { type: "string" }, limit: { type: "number" }, where: { type: "object" } }, ["query"]),
21
+ execute: async ({ query, limit = 10, where }) =>
22
+ ok(search(ctx.rows, query, ctx.roles, { qv: await ctx.queryVector(query), limit, where })),
23
+ });
24
+ mc.registerTool({
25
+ name: "get",
26
+ description: "One record in full, by the id search returns.",
27
+ inputSchema: obj({ id: { type: "string" } }, ["id"]),
28
+ execute: async ({ id }) => ok(getRow(ctx.rows, id, ctx.roles) ?? { error: `no record ${id}` }),
29
+ });
30
+ mc.registerTool({
31
+ name: "count",
32
+ description: "Count the distinct values of a field: the shape of the data before reading it.",
33
+ inputSchema: obj({ field: { type: "string" }, where: { type: "object" }, limit: { type: "number" } }, ["field"]),
34
+ execute: async ({ field, where, limit = 20 }) => ok(facet(ctx.rows, field, { where, limit })),
35
+ });
36
+ // What the person using this page has done: saved, passed on, searched, and the taste
37
+ // that makes. Theirs, in their browser; an agent in the same browser can act on it.
38
+ mc.registerTool({
39
+ name: "session",
40
+ description: "What the person on this page saved and searched this session, and the taste (a vector over this app's records) their choices make.",
41
+ inputSchema: obj({}),
42
+ execute: async () => ok(ctx.session?.() ?? { saved: [], searches: [], taste: null }),
43
+ });
44
+ }
@@ -0,0 +1 @@
1
+ import{t as e}from"./rolldown-runtime-Dd_uD5pT.js";var t=e(((e,t)=>{t.exports={}}));export default t();
@@ -0,0 +1 @@
1
+ let e=null;const t=()=>e??=(async()=>{let{pipeline:e}=await import(`./transformers.web-BgUm7F_-.js`);return e(`feature-extraction`,`nomic-ai/nomic-embed-text-v1.5`,{dtype:`q8`})})();function n(e,t=256){let n=e.length,r=0;for(let t=0;t<n;t++)r+=e[t];r/=n;let i=0;for(let t=0;t<n;t++)i+=(e[t]-r)**2;let a=Math.sqrt(i/n+1e-5),o=Array(Math.min(t,n));for(let t=0;t<o.length;t++)o[t]=(e[t]-r)/a;let s=0;for(let e of o)s+=e*e;if(s=Math.sqrt(s),s)for(let e=0;e<o.length;e++)o[e]/=s;return o}async function r(e){return n((await(await t())(`search_query: ${e}`,{pooling:`mean`,normalize:!1})).data)}async function i(e){return n((await(await t())(`search_document: ${e}`,{pooling:`mean`,normalize:!1})).data)}const a=()=>t().then(()=>{});function o(e){let t=new Uint8Array(e.length);for(let n=0;n<e.length;n++){let r=Math.round(Math.max(-1,Math.min(1,e[n]))*127);t[n]=r<0?r+256:r}let n=``;for(let e of t)n+=String.fromCharCode(e);return btoa(n)}function s(e){try{let t=atob(e),n=new Float32Array(t.length);for(let e=0;e<t.length;e++){let r=t.charCodeAt(e);n[e]=(r>127?r-256:r)/127}return n.length?n:null}catch{return null}}function c(e){let t=new Uint8Array(Math.ceil(e.length/8));for(let n=0;n<e.length;n++)e[n]>=0&&(t[n>>3]|=128>>(n&7));let n=``;for(let e of t)n+=String.fromCharCode(e);return btoa(n)}function l(e){try{let t=atob(e.padEnd(Math.ceil(e.length/4)*4,`=`)),n=new Float32Array(t.length*8);for(let e=0;e<n.length;e++)n[e]=t.charCodeAt(e>>3)>>7-(e&7)&1?1:-1;return n.length?n:null}catch{return null}}if(typeof process<`u`&&import.meta.url===`file://${process.argv[1]}`&&process.argv[1].endsWith(`/embed.js`)){let e=n(Array.from({length:768},(e,t)=>Math.sin(t))),t=0;for(let n of e)t+=n*n;if(Math.abs(Math.sqrt(t)-1)>1e-6)throw Error(`matryoshka must L2-normalize`);if(e.length!==256)throw Error(`matryoshka must truncate to 256`);let r=s(o(e));if(r.length!==e.length)throw Error(`pack/unpack changed the dimension`);let i=0,a=0;for(let t=0;t<e.length;t++)i+=e[t]*r[t],a+=r[t]*r[t];let u=i/Math.sqrt(a);if(u<.999)throw Error(`int8 round-trip cosine ${u} — too lossy to rank with`);let d=s(o([-1,-.5,0,.5,1]));if(d[0]>-.99||d[1]>-.49||d[1]<-.51)throw Error(`negative components corrupted: ${[...d]}`);if(s(`!!!not base64!!!`)!==null)throw Error(`malformed vector must return null, not throw`);let f=c(e);if(f.length>48)throw Error(`sign bits must stay link-sized, got ${f.length} chars`);let p=l(f);if(p.length!==e.length)throw Error(`sign round-trip changed the dimension`);for(let t=0;t<e.length;t++)if(Math.sign(p[t])!==(e[t]<0?-1:1))throw Error(`sign flipped at ${t}`);if(c(p)!==f)throw Error(`sign packing must be idempotent`);if(l(`!!!not base64!!!`)!==null)throw Error(`malformed bits must return null, not throw`);console.log(`embed.js self-check ok — matryoshka norm, int8 round-trip, negatives, malformed input, sign bits`)}const u={query:r,document:i};self.onmessage=async({data:{id:e,kind:t,text:n}})=>{try{if(t===`warm`)return await a(),self.postMessage({id:e,vec:null});self.postMessage({id:e,vec:await u[t](n)})}catch(t){self.postMessage({id:e,error:t?.message??String(t)})}};