@fangorn-network/westmarch 0.0.5-dev → 0.0.7-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 +3 -0
- package/consume/embed.js +5 -1
- package/consume/explore.js +243 -0
- package/consume/mcp.js +30 -2
- package/consume/taste.js +51 -1
- package/consume/view-tools.js +1 -1
- package/dist/ts/eval.js +0 -0
- package/docs/app-to-agent.md +13 -19
- package/package.json +7 -4
- package/plugin/scripts/fx.mjs +268 -0
- package/publish/ship.js +54 -21
- package/publish/view.js +25 -11
- package/site/agent.js +23 -0
- package/site/dist/assets/embed.worker-Dn3fKctb.js +1 -0
- package/site/dist/assets/index-w2wfUwhm.js +8 -0
- package/site/dist/index.html +5 -4
- package/site/dist/theme.css +7 -0
- package/site/store.js +10 -3
- package/site/dist/assets/embed.worker-CQqmvS52.js +0 -1
- package/site/dist/assets/index-CaHGhiKK.js +0 -8
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The Fangorn app index, headless. One command per verb, JSON on stdout.
|
|
3
|
+
//
|
|
4
|
+
// WHY THERE IS NO MCP SERVER HERE
|
|
5
|
+
// -------------------------------
|
|
6
|
+
// An index that POINTS AT apps rather than hosting them needs no runtime of its
|
|
7
|
+
// own. Every verb below is a function `@fangorn/westmarch` already exports, and
|
|
8
|
+
// the agent's shell is the transport. Nobody has to stand up a server to be in
|
|
9
|
+
// the index — a publisher bakes a view, and that is the whole obligation.
|
|
10
|
+
//
|
|
11
|
+
// WHY THIS IS A PLUGIN AND NOT A PAGE
|
|
12
|
+
// -----------------------------------
|
|
13
|
+
// One thing here is not stateless: the taste kernel. It lives in
|
|
14
|
+
// ~/.fangorn/taste.json — on the filesystem, between the human and the agent —
|
|
15
|
+
// because it has to outlive any one app, any one tab and any one ORIGIN.
|
|
16
|
+
// localStorage cannot do that: a taste learned in fangorn.tv is invisible to the
|
|
17
|
+
// next app by construction, which is the whole reason apps keep rebuilding it.
|
|
18
|
+
// A file the agent can read is the shortest thing that carries it across.
|
|
19
|
+
//
|
|
20
|
+
// ponytail: a plaintext file, no wallet, no sync. `publish/vault.js` is the
|
|
21
|
+
// upgrade path — same object, sealed to a signature, kept in the graph — and it
|
|
22
|
+
// is worth doing the day the kernel needs to travel between machines, not now.
|
|
23
|
+
// ponytail: every command re-fetches its view. Fine at 917 rows, ~seconds at
|
|
24
|
+
// 42k; add a disk cache keyed by view URL when a big corpus makes it hurt.
|
|
25
|
+
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { configure, domainManifests, loadShard, trimView } from "../../consume/shard.js";
|
|
30
|
+
import { findCorpora, sourcesFromChain } from "../../consume/directory.js";
|
|
31
|
+
import { linkOf, rolesFrom, textOf, titleOf } from "../../consume/roles.js";
|
|
32
|
+
import { exportTaste, recommend, taste } from "../../consume/taste.js";
|
|
33
|
+
import { EMBED_MODEL, embedQuery, packVec, unpackVec } from "../../consume/embed.js";
|
|
34
|
+
import { browse, describe, facet, getRow, neighbors, search } from "../../consume/tools.js";
|
|
35
|
+
|
|
36
|
+
const HOME = process.env.FANGORN_HOME ?? join(homedir(), ".fangorn");
|
|
37
|
+
const TASTE = join(HOME, "taste.json");
|
|
38
|
+
|
|
39
|
+
// ── the kernel on disk ───────────────────────────────────────────────────────
|
|
40
|
+
const store = () => { try { return JSON.parse(readFileSync(TASTE, "utf8")); } catch { return { model: EMBED_MODEL, likes: [], dislikes: [] }; } };
|
|
41
|
+
const save = (s) => { mkdirSync(HOME, { recursive: true }); writeFileSync(TASTE, JSON.stringify(s, null, 1)); };
|
|
42
|
+
/** Picks are stored PACKED (base64 int8, the shard encoding) and unpacked here.
|
|
43
|
+
* Storing the picks rather than only the derived vector is what keeps the
|
|
44
|
+
* kernel correctable: you can read what it was built from and drop one. */
|
|
45
|
+
const picks = (l = []) => l.map((p) => ({ ...p, vector: unpackVec(p.v) })).filter((p) => p.vector);
|
|
46
|
+
const kernel = (s = store()) => taste(picks(s.likes), picks(s.dislikes));
|
|
47
|
+
|
|
48
|
+
// ── args ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
const argv = process.argv.slice(2);
|
|
50
|
+
const [cmd, ...rest] = argv;
|
|
51
|
+
const pos = rest.filter((a) => !a.startsWith("--"));
|
|
52
|
+
const flag = (n, d) => { const a = rest.find((x) => x.startsWith(`--${n}=`)); return a === undefined ? d : a.slice(n.length + 3); };
|
|
53
|
+
const num = (n, d) => { const v = flag(n); return v === undefined ? d : Number(v); };
|
|
54
|
+
const pairs = (s) => s ? Object.fromEntries(String(s).split(",").map((p) => p.split("=").map((x) => x.trim()))) : undefined;
|
|
55
|
+
const list = (s) => String(s ?? "").split(",").map((x) => x.trim()).filter(Boolean);
|
|
56
|
+
const out = (o) => console.log(JSON.stringify(o, null, 1));
|
|
57
|
+
const die = (m) => { console.error(m); process.exit(1); };
|
|
58
|
+
|
|
59
|
+
// ── a view, loaded ───────────────────────────────────────────────────────────
|
|
60
|
+
let roles = rolesFrom([], []);
|
|
61
|
+
async function load(view) {
|
|
62
|
+
if (!view) die("need a view URL");
|
|
63
|
+
const base = trimView(view);
|
|
64
|
+
configure({
|
|
65
|
+
resolveView: () => base,
|
|
66
|
+
// Roles must be ready BEFORE the first row is parsed: rowText runs during
|
|
67
|
+
// parsing, so deriving them afterwards leaves every row with empty text
|
|
68
|
+
// and lexical search silently matching nothing.
|
|
69
|
+
onManifests: (ms) => { roles = rolesFrom(ms); },
|
|
70
|
+
rowText: (f) => textOf(f, roles),
|
|
71
|
+
});
|
|
72
|
+
const rows = await loadShard(base);
|
|
73
|
+
roles = rolesFrom(domainManifests(), rows);
|
|
74
|
+
let model = null;
|
|
75
|
+
try { model = (await (await fetch(`${base}/cdn/catalog`)).json()).embedding_model ?? null; } catch { /* unranked, not fatal */ }
|
|
76
|
+
return { rows, roles, base, model };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A query vector, or null with the reason. Never throws: every verb here
|
|
80
|
+
* degrades to lexical and SAYS so rather than returning confident nonsense. */
|
|
81
|
+
const qvOf = async (q) => { try { return { qv: await embedQuery(q), why: null }; } catch (e) { return { qv: null, why: e?.message ?? String(e) }; } };
|
|
82
|
+
|
|
83
|
+
/** The corpus and the kernel must share an embedding space or cosine between
|
|
84
|
+
* them is noise that still looks like a score. */
|
|
85
|
+
const mismatch = (model, s) => model && s.model && model !== s.model
|
|
86
|
+
? `taste was built with ${s.model}, this corpus is ${model} — scores would be noise` : null;
|
|
87
|
+
|
|
88
|
+
const sources = async () => {
|
|
89
|
+
const f = flag("sources"); if (f) return list(f);
|
|
90
|
+
if (process.env.FANGORN_SOURCES) return list(process.env.FANGORN_SOURCES);
|
|
91
|
+
try { return JSON.parse(readFileSync(join(HOME, "sources.json"), "utf8")); } catch { /* none pinned */ }
|
|
92
|
+
return chainSources();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Every app bound on chain, the scan fangorn-mcp's list-apps runs. From the
|
|
96
|
+
// current AppRegistry's first event on Arbitrum Sepolia; --from-block overrides.
|
|
97
|
+
async function chainSources() {
|
|
98
|
+
// The SDK scans 1,000 blocks per log request by default, which on the public
|
|
99
|
+
// RPC takes minutes; fangorn-mcp's install line sets the same window.
|
|
100
|
+
process.env.FANGORN_LOG_WINDOW ??= "100000";
|
|
101
|
+
const [{ Fangorn, FangornConfig }, { generatePrivateKey }] = await Promise.all([
|
|
102
|
+
import("@fangorn-network/sdk"), import("viem/accounts")]);
|
|
103
|
+
const fangorn = Fangorn.create({ privateKey: generatePrivateKey(), config: FangornConfig }); // reads only
|
|
104
|
+
const { sources } = await sourcesFromChain(fangorn, { fromBlock: BigInt(flag("from-block", "311637349")) });
|
|
105
|
+
return sources;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── verbs ────────────────────────────────────────────────────────────────────
|
|
109
|
+
switch (cmd) {
|
|
110
|
+
|
|
111
|
+
// WHICH app has this. Downloads no corpus — coverage centroids only, ~4KB each.
|
|
112
|
+
case "find": {
|
|
113
|
+
const src = await sources();
|
|
114
|
+
if (!src.length) die("no apps found on chain; pass --sources=a,b or write ~/.fangorn/sources.json");
|
|
115
|
+
out(await findCorpora(pos.join(" "), { sources: src, embed: embedQuery, model: EMBED_MODEL, limit: num("limit", 10) }));
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "describe": {
|
|
120
|
+
const { rows, roles: r, model } = await load(pos[0]);
|
|
121
|
+
out({
|
|
122
|
+
...describe(rows), model,
|
|
123
|
+
shape: r.declared ? "declared" : "sniffed",
|
|
124
|
+
title: r.title, subtitle: r.subtitle, tags: r.tags, text: r.text,
|
|
125
|
+
entityTypes: r.entityTypes, gates: r.gates,
|
|
126
|
+
// Where the reader goes next. The index's job ends here.
|
|
127
|
+
launch: r.launch, externalUrl: r.externalUrl, actions: r.actions,
|
|
128
|
+
});
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
case "browse": {
|
|
133
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
134
|
+
out(browse(rows, r, { type: flag("type"), where: pairs(flag("where")), sort: flag("sort"),
|
|
135
|
+
desc: flag("asc") === undefined, limit: num("limit", 20), offset: num("offset", 0), fields: list(flag("fields")) }));
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "search": {
|
|
140
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
141
|
+
const q = pos.slice(1).join(" ");
|
|
142
|
+
const { qv, why } = await qvOf(q);
|
|
143
|
+
out({ query: q, mode: qv ? "semantic where rows have vectors" : "lexical", ...(why ? { why } : {}),
|
|
144
|
+
hits: search(rows, q, r, { qv, limit: num("limit", 10), where: pairs(flag("where")), fields: list(flag("fields")) }) });
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case "facet": {
|
|
149
|
+
const { rows } = await load(pos[0]);
|
|
150
|
+
out(facet(rows, pos[1], { limit: num("limit", 20), where: pairs(flag("where")) }));
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
case "row": {
|
|
155
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
156
|
+
const g = getRow(rows, pos[1], r);
|
|
157
|
+
out(g ? { ...g, url: linkOf(rows.find((x) => x.id === g.id) ?? {}, r) } : null);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
case "similar": {
|
|
162
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
163
|
+
out(neighbors(rows, pos[1], r, { limit: num("limit", 10), fields: list(flag("fields")) }));
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── the kernel ───────────────────────────────────────────────────────────────
|
|
168
|
+
case "like": case "dislike": {
|
|
169
|
+
const { rows, roles: r, base, model } = await load(pos[0]);
|
|
170
|
+
const s = store();
|
|
171
|
+
const into = cmd === "like" ? s.likes : s.dislikes;
|
|
172
|
+
const added = [];
|
|
173
|
+
for (const id of pos.slice(1)) {
|
|
174
|
+
const row = rows.find((x) => x.id === id);
|
|
175
|
+
if (!row) { console.error(`no row ${id}`); continue; }
|
|
176
|
+
if (!row.vector) { console.error(`${id} has no vector — nothing to learn from`); continue; }
|
|
177
|
+
// Newest last: taste() weights by recency, so order in the file is signal.
|
|
178
|
+
const at = into.findIndex((p) => p.id === id);
|
|
179
|
+
if (at >= 0) into.splice(at, 1);
|
|
180
|
+
into.push({ id, title: titleOf(row, r), view: base, at: new Date().toISOString().slice(0, 10), v: packVec(Array.from(row.vector)) });
|
|
181
|
+
added.push(id);
|
|
182
|
+
}
|
|
183
|
+
s.model ??= model ?? EMBED_MODEL;
|
|
184
|
+
save(s);
|
|
185
|
+
const t = kernel(s);
|
|
186
|
+
out({ added, likes: s.likes.length, dislikes: s.dislikes.length, taste: t && exportTaste(t), file: TASTE });
|
|
187
|
+
break;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
case "forget": {
|
|
191
|
+
const s = store();
|
|
192
|
+
const drop = new Set(pos);
|
|
193
|
+
const before = s.likes.length + s.dislikes.length;
|
|
194
|
+
s.likes = s.likes.filter((p) => !drop.has(p.id));
|
|
195
|
+
s.dislikes = s.dislikes.filter((p) => !drop.has(p.id));
|
|
196
|
+
save(s);
|
|
197
|
+
out({ removed: before - s.likes.length - s.dislikes.length, likes: s.likes.length, dislikes: s.dislikes.length });
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "taste": {
|
|
202
|
+
const s = store();
|
|
203
|
+
const t = kernel(s);
|
|
204
|
+
out(t ? { model: s.model, n: t.n, from: t.from, rejected: t.rejected, drift: t.drift,
|
|
205
|
+
// Where each pick was learned. A kernel built in one publisher's
|
|
206
|
+
// corpus is meant to be pointed at another's — this is the record
|
|
207
|
+
// of whether it actually has been.
|
|
208
|
+
views: [...new Set([...s.likes, ...s.dislikes].map((p) => p.view))],
|
|
209
|
+
export: exportTaste(t), file: TASTE }
|
|
210
|
+
: { n: 0, file: TASTE, note: "nothing liked yet — `fx like <view> <id>`" });
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// The headline: rank a corpus by a taste learned somewhere else entirely.
|
|
215
|
+
case "recommend": {
|
|
216
|
+
const { rows, roles: r, model } = await load(pos[0]);
|
|
217
|
+
const s = store();
|
|
218
|
+
const t = kernel(s);
|
|
219
|
+
if (!t) die("no taste yet — `fx like <view> <id>` first");
|
|
220
|
+
const warn = mismatch(model, s);
|
|
221
|
+
if (warn) die(warn);
|
|
222
|
+
const seen = new Set([...s.likes, ...s.dislikes].map((p) => p.id));
|
|
223
|
+
out({ from: t.from, against: pos[0],
|
|
224
|
+
hits: recommend(rows, t, { limit: num("limit", 10), exclude: seen })
|
|
225
|
+
.map(({ row, score }) => ({ id: row.id, title: titleOf(row, r), score, url: linkOf(row, r) })) });
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── the handoff. The index points; the app does the work. ────────────────────
|
|
230
|
+
case "launch": {
|
|
231
|
+
const { rows, roles: r } = await load(pos[0]);
|
|
232
|
+
const id = flag("row");
|
|
233
|
+
if (id) {
|
|
234
|
+
const row = rows.find((x) => x.id === id);
|
|
235
|
+
if (!row) die(`no row ${id}`);
|
|
236
|
+
out({ url: linkOf(row, r), title: titleOf(row, r) });
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
if (!r.launch) die("this corpus declares no presentation.launch — nowhere to hand off to");
|
|
240
|
+
const t = kernel();
|
|
241
|
+
const fill = { taste: t ? JSON.stringify(exportTaste(t)) : "", q: pos.slice(1).join(" ") };
|
|
242
|
+
const url = String(r.launch).replace(/\{(\w+)\}/g, (_, k) => encodeURIComponent(fill[k] ?? ""));
|
|
243
|
+
const u = new URL(url);
|
|
244
|
+
if (u.protocol !== "https:" && u.protocol !== "http:") die(`refusing a ${u.protocol} launch URL`);
|
|
245
|
+
// The kernel travels in the URL because that is the only channel a page on
|
|
246
|
+
// someone else's origin can receive it on. It is the app's copy from here.
|
|
247
|
+
out({ url: u.toString(), carries: t ? { n: t.n, from: t.from } : null });
|
|
248
|
+
break;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
default:
|
|
252
|
+
console.log(`fx <verb>
|
|
253
|
+
|
|
254
|
+
find <query> [--sources=a,b|--from-block=N] which app has this. No corpus downloaded.
|
|
255
|
+
describe <view> rows, field coverage, roles, where it hands off
|
|
256
|
+
browse <view> [--type= --where=k=v --sort= --asc --limit= --offset=]
|
|
257
|
+
search <view> <query> [--where=k=v --limit=] semantic where rows have vectors, else lexical
|
|
258
|
+
facet <view> <field> [--where=k=v --limit=] count a field's values
|
|
259
|
+
row <view> <id> one record, whole
|
|
260
|
+
similar <view> <id> [--limit=] nearest by vector
|
|
261
|
+
|
|
262
|
+
like|dislike <view> <id...> teach the kernel. Writes ${TASTE}
|
|
263
|
+
forget <id...> drop picks from it
|
|
264
|
+
taste what it is, and what it was built from
|
|
265
|
+
recommend <view> [--limit=] rank ANY corpus by it
|
|
266
|
+
|
|
267
|
+
launch <view> [query] | launch <view> --row=<id> the handoff URL, carrying the kernel`);
|
|
268
|
+
}
|
package/publish/ship.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run]
|
|
5
5
|
// westmarch-ship app.json --local <out> [--crawl] a view from staged records, no chain (see buildLocal)
|
|
6
|
+
// westmarch-ship app.json --register the app alone: claim, join, empty site, card bound
|
|
6
7
|
//
|
|
7
8
|
// Every step checks the chain (or Cloudflare) first and only acts on a difference, so
|
|
8
9
|
// re-running is how you update: new data, a changed schema, a new source, a moved site.
|
|
@@ -16,6 +17,11 @@
|
|
|
16
17
|
// 7. deploy to Cloudflare Pages
|
|
17
18
|
// 8. register the ERC-8004 agent and bind the card, if the chain points elsewhere
|
|
18
19
|
//
|
|
20
|
+
// --register is the fast path, run before there is any data: 1, 2, 6 with an empty view, 7,
|
|
21
|
+
// and 8 binding the card without minting. The mint copies the card's skills into its IPFS
|
|
22
|
+
// registration file and is never redone, so it waits for the first full ship, when the card
|
|
23
|
+
// lists what the app really does.
|
|
24
|
+
//
|
|
19
25
|
// Chain writes go through the `fangorn` CLI (its configured wallet signs, and
|
|
20
26
|
// `fangorn wallet` shows which). Reads go through the SDK. State lives in `.ship/` next
|
|
21
27
|
// to app.json: the site, the view (its own state, see view.js) and a small state.json.
|
|
@@ -84,12 +90,12 @@ export function expandSources(sources, base) {
|
|
|
84
90
|
export function loadConfig(path) {
|
|
85
91
|
const c = JSON.parse(readFileSync(path, "utf8"));
|
|
86
92
|
c.sources = expandSources(c.sources ?? [], dirname(resolve(path)));
|
|
87
|
-
for (const k of ["app", "name", "description", "
|
|
93
|
+
for (const k of ["app", "name", "description", "site"]) // types and sources can come after --register
|
|
88
94
|
if (!c[k]) throw new Error(`${path}: missing "${k}"`);
|
|
89
95
|
if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Pages project`);
|
|
90
96
|
for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
|
|
91
97
|
throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
|
|
92
|
-
return { relations: [], tags: [], ...c };
|
|
98
|
+
return { relations: [], tags: [], types: {}, ...c };
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
/** The schema commit for `types` + `relations`: one fangorn.type.v1 vertex per type,
|
|
@@ -155,8 +161,10 @@ function assertSameDeployment(cli) {
|
|
|
155
161
|
|
|
156
162
|
// ── the steps ─────────────────────────────────────────────────────────────────
|
|
157
163
|
|
|
158
|
-
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null } = {}) {
|
|
164
|
+
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false } = {}) {
|
|
159
165
|
const cfg = loadConfig(configPath);
|
|
166
|
+
if (!register && (!Object.keys(cfg.types).length || !cfg.sources.length))
|
|
167
|
+
throw new Error(`${configPath}: no types or sources yet, so nothing to build; --register sets up the app alone`);
|
|
160
168
|
CLI = cliArgv(cfg);
|
|
161
169
|
assertSameDeployment(CLI);
|
|
162
170
|
const base = dirname(resolve(configPath));
|
|
@@ -201,22 +209,24 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
201
209
|
|
|
202
210
|
// 3. schema
|
|
203
211
|
let onChain = null;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
212
|
+
if (!register) {
|
|
213
|
+
try { onChain = view.parseSchema((await fangorn.readNamespace(owner, view.SCHEMA_NAMESPACE)).contents); } catch { /* none yet */ }
|
|
214
|
+
if (!onChain || canonical(onChain.types, onChain.relations) !== canonical(cfg.types, cfg.relations)) {
|
|
215
|
+
const sdir = join(dir, "schema");
|
|
216
|
+
mkdirSync(sdir, { recursive: true });
|
|
217
|
+
writeFileSync(join(sdir, "graph.json"), JSON.stringify(schemaGraph(cfg.types, cfg.relations), null, 1));
|
|
218
|
+
log(`schema ${onChain ? "changed" : "is new"}: committing ${Object.keys(cfg.types).join(", ")}`);
|
|
219
|
+
fangornCli(cfg.app, ["repo", "init", view.SCHEMA_NAMESPACE], { cwd: sdir, dry });
|
|
220
|
+
fangornCli(cfg.app, ["commit", "graph.json", "-m", "schema", "--replace"], { cwd: sdir, dry });
|
|
221
|
+
fangornCli(cfg.app, ["push"], { cwd: sdir, dry });
|
|
222
|
+
} else log("schema unchanged");
|
|
223
|
+
}
|
|
214
224
|
|
|
215
225
|
// 4. sources: crawl + publish straight into the app. One source failing (a site that
|
|
216
226
|
// blocks us, a feed that is down) keeps its last published data and does not stop the
|
|
217
227
|
// others; the ship still exits non-zero at the end, naming it.
|
|
218
228
|
const failed = [];
|
|
219
|
-
if (crawl) for (const s of cfg.sources.filter((s) => !only || only.includes(s.namespace))) {
|
|
229
|
+
if (crawl && !register) for (const s of cfg.sources.filter((s) => !only || only.includes(s.namespace))) {
|
|
220
230
|
const cwd = s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base;
|
|
221
231
|
const paidArgs = cfg.paid && s.paid_dir ? ["--paid-dir", resolve(cwd, s.paid_dir)] : [];
|
|
222
232
|
try {
|
|
@@ -234,8 +244,9 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
234
244
|
const site = join(dir, "site");
|
|
235
245
|
if (!existsSync(join(site, "view/cdn/catalog")) && state.url && !dry) await mirrorView(`${state.url}/view`, join(site, "view"));
|
|
236
246
|
const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
|
|
237
|
-
|
|
238
|
-
|
|
247
|
+
if (register && !dry) view.writeCatalog(join(site, "view")); // empty, or what an earlier build left
|
|
248
|
+
const report = dry || register ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
|
|
249
|
+
fromBlock: BigInt(state.fromBlock), embed: view.cachedEmbed(join(dir, "vectors.ndjson")), log });
|
|
239
250
|
|
|
240
251
|
// 6. the page, the card, the headers
|
|
241
252
|
if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
|
|
@@ -297,8 +308,12 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
297
308
|
"/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n"
|
|
298
309
|
+ "/view/*\n Access-Control-Allow-Origin: *\n"
|
|
299
310
|
+ `/assets/*\n${immutable}`
|
|
300
|
-
//
|
|
301
|
-
|
|
311
|
+
// A shard's name is its content hash, so a browser may keep it for good: a change or a
|
|
312
|
+
// retraction writes new names and the manifest stops pointing at the old ones. `private`
|
|
313
|
+
// keeps it out of shared caches, so a retracted record's bytes stop being served the
|
|
314
|
+
// moment the deploy drops the file (see view.js). An hour made readers on a phone fetch
|
|
315
|
+
// the whole catalog again after every hour away.
|
|
316
|
+
+ domains.map((d) => `/view/cdn/domains/${d}/shards/*\n Cache-Control: private, max-age=31536000, immutable\n`).join(""));
|
|
302
317
|
|
|
303
318
|
// 7. deploy
|
|
304
319
|
if (deploy) {
|
|
@@ -311,13 +326,29 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
311
326
|
// 8. ERC-8004 + the binding
|
|
312
327
|
const cardUrl = `${url}/.well-known/agent-card.json`;
|
|
313
328
|
const bound = await registry.appAgentUri(appId).catch(() => "");
|
|
314
|
-
|
|
315
|
-
log(`binding ${cardUrl} (on chain: ${bound || "nothing"})`);
|
|
329
|
+
const mint = () => {
|
|
316
330
|
const out = dry ? "" : fangornCli(cfg.app, ["app", "agent", cardUrl]);
|
|
317
331
|
state.agentId = (out.match(/Agent ID:\s+(\S+)/) ?? [])[1] ?? state.agentId;
|
|
332
|
+
delete state.agentPending;
|
|
318
333
|
save();
|
|
334
|
+
};
|
|
335
|
+
if (deploy && bound !== cardUrl && register) {
|
|
336
|
+
log(`binding ${cardUrl} (on chain: ${bound || "nothing"}); the agent is minted on the first full ship`);
|
|
337
|
+
fangornCli(cfg.app, ["app", "agent", cardUrl, "--skip-register"], { dry });
|
|
338
|
+
state.agentPending = true;
|
|
339
|
+
save();
|
|
340
|
+
} else if (deploy && bound !== cardUrl) {
|
|
341
|
+
log(`binding ${cardUrl} (on chain: ${bound || "nothing"})`);
|
|
342
|
+
mint();
|
|
343
|
+
} else if (deploy && state.agentPending && !register) {
|
|
344
|
+
log(`minting the ERC-8004 agent from the card as built`);
|
|
345
|
+
mint();
|
|
319
346
|
} else if (deploy) log("card already bound");
|
|
320
347
|
|
|
348
|
+
if (register) {
|
|
349
|
+
log(`registered: ${url} · card ${cardUrl} · ${cfg.app} is yours; build it with westmarch-ship ${configPath} --local <out>`);
|
|
350
|
+
return { url, cardUrl, report };
|
|
351
|
+
}
|
|
321
352
|
const added = Object.values(report).reduce((n, r) => n + r.added, 0);
|
|
322
353
|
log(`done: ${url} · card ${cardUrl} · agent ${state.agentId ?? "(unchanged)"} · +${added} records embedded`);
|
|
323
354
|
if (failed.length) throw new Error(`deployed, but these sources failed and kept their old records: ${failed.join(", ")}`);
|
|
@@ -450,6 +481,7 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
450
481
|
const [path, ...flags] = process.argv.slice(2);
|
|
451
482
|
if (!path || flags.includes("--help")) {
|
|
452
483
|
console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace] [--only ns1,ns2]\n" +
|
|
484
|
+
" westmarch-ship app.json --register [--dry-run] (claim, join, empty site, card bound: no data)\n" +
|
|
453
485
|
" westmarch-ship app.json --local <out> [--crawl] [--only ns1,ns2] (no chain, no deploy)");
|
|
454
486
|
process.exit(path ? 0 : 2);
|
|
455
487
|
}
|
|
@@ -460,7 +492,8 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
460
492
|
process.exit(0);
|
|
461
493
|
}
|
|
462
494
|
await ship(path, { crawl: !flags.includes("--no-crawl"), deploy: !flags.includes("--no-deploy"), dry: flags.includes("--dry-run"),
|
|
463
|
-
replace: flags.includes("--replace"),
|
|
495
|
+
replace: flags.includes("--replace"), register: flags.includes("--register"),
|
|
464
496
|
only })
|
|
465
497
|
.catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
|
|
498
|
+
process.exit(0); // a CUDA embedder keeps the process alive (and can abort in its teardown)
|
|
466
499
|
}
|
package/publish/view.js
CHANGED
|
@@ -201,7 +201,7 @@ export function liveFields(out) {
|
|
|
201
201
|
* Returns per-domain counts of what changed.
|
|
202
202
|
*/
|
|
203
203
|
export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0n, rebake = false,
|
|
204
|
-
embed = embedDocumentDirect, log = console.log }) {
|
|
204
|
+
embed = embedDocumentDirect, log = console.log, shardRows = 20000 }) {
|
|
205
205
|
if (!namespaces?.length) throw new Error("publishView: name the data namespace(s) to publish");
|
|
206
206
|
fangorn.setAppId(app);
|
|
207
207
|
const appId = fangorn.getAppId();
|
|
@@ -222,7 +222,7 @@ export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0
|
|
|
222
222
|
for (const { owner: publisher } of timelines) {
|
|
223
223
|
const domain = domainFor(appId, publisher, ns);
|
|
224
224
|
report[domain] = await publishDomain({
|
|
225
|
-
fangorn, schema, publisher, ns, domain, dir: `${out}/cdn/domains/${domain}`, rebake, embed, log,
|
|
225
|
+
fangorn, schema, publisher, ns, domain, dir: `${out}/cdn/domains/${domain}`, rebake, embed, log, shardRows,
|
|
226
226
|
});
|
|
227
227
|
}
|
|
228
228
|
}
|
|
@@ -230,7 +230,7 @@ export async function publishView({ fangorn, app, namespaces, out, fromBlock = 0
|
|
|
230
230
|
return report;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, rebake, embed, log }) {
|
|
233
|
+
async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, rebake, embed, log, shardRows }) {
|
|
234
234
|
const { contents } = await fangorn.readNamespace(publisher, ns);
|
|
235
235
|
const records = new Map((contents.vertices ?? []).filter((v) => v.payload && typeof v.payload === "object")
|
|
236
236
|
.map((v) => [v.cid, { ...v.payload, entityType: v.schemaId }]));
|
|
@@ -238,7 +238,9 @@ async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, reba
|
|
|
238
238
|
if (rebake) rmSync(dir, { recursive: true, force: true }); // old shards would stay reachable
|
|
239
239
|
const had = loadDomain(dir);
|
|
240
240
|
const made = had.manifest?.embedder ?? (had.manifest ? { runtime: "fastembed", dtype: "fp32" } : null);
|
|
241
|
-
|
|
241
|
+
// Same model and dtype is the same encoder, whatever ran it: q8 on CPU, CUDA and WASM agree to
|
|
242
|
+
// ~0.98-0.99 cosine, fp32 against q8 only to ~0.95.
|
|
243
|
+
if (made && (made.model ?? EMBED_MODEL) + made.dtype !== EMBEDDER.model + EMBEDDER.dtype)
|
|
242
244
|
throw new Error(`${domain} was embedded by ${made.runtime} ${made.dtype}, not ${EMBEDDER.runtime} ${EMBEDDER.dtype}; ` +
|
|
243
245
|
"vectors from two encoders are not comparable. Rebake it with --rebake, or keep publishing it with the tool that made it.");
|
|
244
246
|
|
|
@@ -275,12 +277,17 @@ async function publishDomain({ fangorn, schema, publisher, ns, domain, dir, reba
|
|
|
275
277
|
mkdirSync(`${dir}/shards`, { recursive: true });
|
|
276
278
|
const manifest = had.manifest ?? { name: domain, description: "", count: 0, dim: EMBED_DIM, model: EMBED_MODEL,
|
|
277
279
|
distance: "Cosine", embedder: EMBEDDER, filter: { owner: [publisher], namespace: [ns] }, shards: [], tombstones: [] };
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
280
|
+
// Pages serves files up to 25 MiB; 52k Steam games gzipped to 28 MiB in one shard.
|
|
281
|
+
// ponytail: split by row count (~550 B a row gzipped); split by bytes if rows get much fatter.
|
|
282
|
+
const writeShard = (all) => {
|
|
283
|
+
for (let i = 0; i < all.length; i += shardRows) {
|
|
284
|
+
const ls = all.slice(i, i + shardRows);
|
|
285
|
+
const gz = gzipSync(`${ls.join("\n")}\n`, { level: 9 });
|
|
286
|
+
const sha256 = createHash("sha256").update(gz).digest("hex");
|
|
287
|
+
const file = `shard-${String(manifest.shards.length).padStart(4, "0")}-${sha256.slice(0, 12)}.ndjson.gz`;
|
|
288
|
+
writeFileSync(`${dir}/shards/${file}`, gz);
|
|
289
|
+
manifest.shards.push({ file, count: ls.length, bytes: gz.length, sha256 });
|
|
290
|
+
}
|
|
284
291
|
};
|
|
285
292
|
if (lines.length) {
|
|
286
293
|
writeShard(lines);
|
|
@@ -332,7 +339,7 @@ function applySpec(dir, manifest, schema, rows, dead, guessed = new Map()) {
|
|
|
332
339
|
}
|
|
333
340
|
}
|
|
334
341
|
|
|
335
|
-
function writeCatalog(out) {
|
|
342
|
+
export function writeCatalog(out) {
|
|
336
343
|
const root = `${out}/cdn/domains`;
|
|
337
344
|
mkdirSync(`${out}/cdn`, { recursive: true });
|
|
338
345
|
const domains = existsSync(root) ? readdirSync(root).sort().map((d) => readJson(`${root}/${d}/manifest`, null)).filter(Boolean) : [];
|
|
@@ -389,6 +396,11 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
389
396
|
const run = () => publishView({ fangorn, app: APP, namespaces: ["shop"], out, embed, log: () => {} });
|
|
390
397
|
|
|
391
398
|
const d = domainFor(APP, PUB, "shop");
|
|
399
|
+
const split = mkdtempSync(`${tmpdir()}/view-`);
|
|
400
|
+
await publishView({ fangorn, app: APP, namespaces: ["shop"], out: split, embed, log: () => {}, shardRows: 2 });
|
|
401
|
+
m0: { const m = readJson(`${split}/cdn/domains/${d}/manifest`);
|
|
402
|
+
eq([m.count, m.shards.map((s) => s.count)], [3, [2, 1]], "rows past shardRows go to the next shard (Pages caps a file at 25 MiB)"); }
|
|
403
|
+
embedded.length = 0;
|
|
392
404
|
eq((await run())[d], { added: 3, removed: 0 }, "first run embeds everything");
|
|
393
405
|
if (!embedded.includes("Title: Oak table. Tags: . a solid oak dining table")) throw new Error(`declared roles compose the text: ${embedded}`);
|
|
394
406
|
let m = readJson(`${out}/cdn/domains/${d}/manifest`);
|
|
@@ -424,6 +436,8 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
424
436
|
const { rolesFrom } = await import("../consume/roles.js");
|
|
425
437
|
eq(getRow(got, "b2", rolesFrom([m]))?.id, "c2b", "get by the publisher's own id, not the CID");
|
|
426
438
|
|
|
439
|
+
m.embedder = { ...EMBEDDER, runtime: "transformers.js cuda" }; writeJson(`${out}/cdn/domains/${d}/manifest`, m);
|
|
440
|
+
await run(); // throws if another runtime of the same q8 model is refused
|
|
427
441
|
m.embedder = { runtime: "fastembed", dtype: "fp32" }; writeJson(`${out}/cdn/domains/${d}/manifest`, m);
|
|
428
442
|
await throws(run, /not comparable/, "appending to another encoder's view");
|
|
429
443
|
eq((await publishView({ fangorn, app: APP, namespaces: ["shop"], out, embed, rebake: true, log: () => {} }))[d],
|
package/site/agent.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The stock page's tools for agents in a browser: the same verbs as fangorn-mcp's data
|
|
2
2
|
// tools, over the rows this page loaded. `ctx` holds them; ship's card captures these.
|
|
3
3
|
import { describe, facet, getRow, search } from "../consume/tools.js";
|
|
4
|
+
import { KNOBS, discover } from "../consume/taste.js";
|
|
4
5
|
|
|
5
6
|
const ok = (o) => ({ content: [{ type: "text", text: JSON.stringify(o) }] });
|
|
6
7
|
const obj = (properties, required = []) => ({ type: "object", properties, required });
|
|
@@ -41,4 +42,26 @@ export function registerAgent(ctx) {
|
|
|
41
42
|
inputSchema: obj({}),
|
|
42
43
|
execute: async () => ok(ctx.session?.() ?? { saved: [], searches: [], taste: null }),
|
|
43
44
|
});
|
|
45
|
+
// Like / dislike and "For you": the same calls the page's 👍 👎 and knobs make (ctx.rate,
|
|
46
|
+
// ctx.votes, set by main.js), so what an agent records shows on the page.
|
|
47
|
+
mc.registerTool({
|
|
48
|
+
name: "rate",
|
|
49
|
+
description: "Record that the person liked or disliked a record (or clear it), by the id search returns. Builds the taste discover ranks with.",
|
|
50
|
+
inputSchema: obj({ id: { type: "string" }, verdict: { type: "string", enum: ["like", "dislike", "clear"] } }, ["id", "verdict"]),
|
|
51
|
+
execute: async ({ id, verdict }) => ok(ctx.rate?.(id, verdict) ?? { error: "this page keeps no taste" }),
|
|
52
|
+
});
|
|
53
|
+
mc.registerTool({
|
|
54
|
+
name: "discover",
|
|
55
|
+
description: "Records for a taste: a kernel over what was liked (newest last) and disliked, not a text search. Pass likes and dislikes (ids from search), or omit them to use the taste on this page. Knobs are 0–1.",
|
|
56
|
+
inputSchema: obj({ likes: { type: "array", items: { type: "string" } }, dislikes: { type: "array", items: { type: "string" } },
|
|
57
|
+
...Object.fromEntries(Object.entries(KNOBS).map(([k, [d, what]]) => [k, { type: "number", minimum: 0, maximum: 1, default: d, description: what }])),
|
|
58
|
+
seed: { type: "integer", description: "Same seed, same shuffle" }, limit: { type: "integer", minimum: 1, maximum: 50, default: 10 } }),
|
|
59
|
+
execute: async ({ likes, dislikes, limit = 10, ...knobs }) => {
|
|
60
|
+
const byId = (ids) => ids.map((id) => ctx.rows.find((r) => r.id === getRow(ctx.rows, id, ctx.roles)?.id)).filter((r) => r?.vector);
|
|
61
|
+
const v = likes ? { likes: byId(likes), dislikes: byId(dislikes ?? []) } : ctx.votes?.() ?? { likes: [], dislikes: [] };
|
|
62
|
+
if (!v.likes.length) return ok({ error: "like at least one record first (ids from search, or rate)" });
|
|
63
|
+
const out = discover(ctx.rows, v.likes, v.dislikes, { ...knobs, limit: Math.min(50, limit) });
|
|
64
|
+
return ok({ ...out, picks: out.picks.map(({ row, score }) => ({ ...getRow(ctx.rows, row.id, ctx.roles), score })) });
|
|
65
|
+
},
|
|
66
|
+
});
|
|
44
67
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
let e=null;const t=()=>e??=(async()=>{let{pipeline:e}=await import(`./transformers.web-BgUm7F_-.js`),t={}.WESTMARCH_EMBED_DEVICE;return e(`feature-extraction`,`nomic-ai/nomic-embed-text-v1.5`,{dtype:`q8`,...t&&{device:t}})})();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)})}};
|