@fangorn-network/westmarch 0.0.1-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/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ # MIT License
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ 'Software'), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
18
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
19
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
20
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,280 @@
1
+ # @fangorn/westmarch
2
+
3
+ Everything you need to get started building apps with Fangorn.
4
+
5
+ **Start here:** [docs/app-to-agent.md](docs/app-to-agent.md) takes a static site from nothing
6
+ to a Fangorn app and ERC-8004 agent that any agent can discover, verify and drive.
7
+
8
+
9
+ **For Publishers** — shape a tree into a commit graph, describe what's in it, seal and upload the bytes. Needs `fangorn`. Runs anywhere: a script, a server, a browser.
10
+
11
+ **For Consumers** — read a quickbeam view's Semantic CDN shards in the reader's own process and rank them there. Needs a view URL and nothing else. No index server, no chain read, and the query never leaves the tab.
12
+
13
+ The two halves never talk to each other. A publisher writes a graph; some
14
+ watcher bakes it into a view; a consumer reads the view. Either half is useful
15
+ without the other.
16
+
17
+ ```
18
+ publish/ consume/
19
+ graph.js fs tree ⇄ vertices + edges directory.js WHICH corpus
20
+ enrich.js what a file says → search text shard.js view → rows, streamed
21
+ envelope.js chunk keys, AEAD, upload/delete roles.js which field is the title
22
+ lint.js …and whether any of that WORKED taste.js what YOU like, portably
23
+ rank.js rows + query → hits
24
+ embed.js text → 256-d vector
25
+ apps.js every app bound on chain
26
+ corpora.js SEVERAL corpora, open at once
27
+ ui.js previews, from the role_map
28
+ ```
29
+
30
+ `lint.js` sits in the consume/ directory and reads like a consumer, but it is
31
+ for the person who baked the view: every capability on the right is paid for at
32
+ bake time on the left, and the bill is invisible until a reader hits it.
33
+
34
+ ## lint.js — what will the index be able to do with this?
35
+
36
+ ```
37
+ $ node consume/lint.js https://cdn.example/q/qb_1
38
+ archive-transcripts — 21,131 rows — 1 thing stopping readers finding or reading this
39
+ ✗ paywall names 1 field the free shard ships anyway
40
+ text — readers are told these cost money and are handed them for free.
41
+ → Either withhold them from the shard, or take them out of paywall.locked.
42
+ ```
43
+
44
+ Three levels because there are three real outcomes: the reader cannot find you
45
+ (no `coverage`, a foreign embedding model, no vectors), they find you and cannot
46
+ read you (no `role_map`, no text role), or they read you and cannot go anywhere
47
+ (no `externalUrl`, no `actions`, no `launch`).
48
+
49
+ The check that needs a real shard — and the reason this is not a schema
50
+ validator — is a `paywall` naming fields the free shard ships anyway. It found
51
+ that in this repo's own fixtures on its first run, twice.
52
+
53
+ ## corpora.js — the questions one publisher cannot answer
54
+
55
+ `directory.js` picks the right corpus. This holds several of them open, which is
56
+ a different thing and the more useful one: rank *these games* by a taste built
57
+ from *those films*, put one query to four publishers and get one ranking back.
58
+
59
+ `shard.js` always cached rows per view — what was singular was the consumer's
60
+ idea of "the" corpus, and the two `configure()` hooks, which now say which view
61
+ they are running for. Without that, the second corpus loaded is silently parsed
62
+ with the first one's text role.
63
+
64
+ ```js
65
+ const S = session({ blankRoles: () => rolesFrom([], []) });
66
+ configure({
67
+ onManifests: (ms, view) => { S.slot(view).roles = rolesFrom(ms); },
68
+ rowText: (f, view) => textOf(f, S.slot(view).roles),
69
+ });
70
+ // …then merge() collapses per-corpus hit lists onto one scale. It is a sort,
71
+ // not a normalisation: every corpus scored the same query vector.
72
+ ```
73
+
74
+ `example/cross.mjs` runs it against the four fixture bundles.
75
+
76
+ ## ui.js — an interface for data nobody wrote an interface for
77
+
78
+ A tool can return an HTML view beside its JSON (MCP Apps, formerly mcp-ui): an
79
+ `EmbeddedResource` with a `ui://` uri, rendered by a host that understands it and
80
+ ignored by one that does not. `resource` is a standard MCP content block, so
81
+ emitting one is safe everywhere.
82
+
83
+ It is worth doing *here* because this page does not know what its rows are. The
84
+ publisher's `role_map` already states which field is the title, the subtitle, the
85
+ tags, the measures — which is exactly a table renderer's input. So the interface
86
+ is generated per corpus at the moment of the call, from a declaration that
87
+ shipped with the data.
88
+
89
+ ## taste.js — the thing one shared embedding space actually buys
90
+
91
+ The directory refuses to rank a corpus baked with a different model, because
92
+ cosine between two models' vectors is noise. Stated that way it sounds like a
93
+ limitation. It is the opposite:
94
+
95
+ > a taste learned in ONE publisher's corpus applies directly to ANOTHER'S.
96
+
97
+ Not by matching ids — there are none in common. Not by a shared schema — there
98
+ isn't one. Measured on this repo's own corpora: four bleak sci-fi films picked
99
+ out of 20,986 archive films produce a 888-byte vector which, pointed at 1,571
100
+ Wikipedia video games from an unrelated publisher, returns Beam Breakers
101
+ (cyberpunk), B-Movie and Crimson Tears. Four comedies produce a vector that
102
+ returns Bugs Bunny, Elmo in Grouchland and Goofy Skateboarding. Same corpus,
103
+ same code, disjoint answers.
104
+
105
+ Every recommender that exists keeps its embedding private, which is exactly what
106
+ makes taste non-portable — your taste is an asset of whoever holds the model.
107
+ Here the model is public and the vectors are in files you already downloaded, so
108
+ the taste is a small object you own, carry, read and correct.
109
+
110
+ State is sond3r's session kernel cut to what travels: `mu` (where you are), `v`
111
+ (where you're heading), `q` (the lookahead you rank with), `no` (what you
112
+ rejected, with the same GAMMA the kernel uses, so a rejection means the same
113
+ thing on both surfaces). `exportTaste` packs it as base64 int8 and carries the
114
+ TITLES it was built from — a taste you cannot read is a taste you cannot
115
+ correct, and this is meant to be a thing a person owns rather than a profile
116
+ held about them.
117
+
118
+ ```sh
119
+ node example/taste-demo.mjs # films -> games, end to end
120
+ ```
121
+
122
+ ## steam.js — the cold start, solved by a file you already have
123
+
124
+ A taste needs picks and a new reader has none, so every recommender opens by
125
+ asking you to rate ten things. The obvious fix for games is Steam's Web API:
126
+ issue a key, call GetOwnedGames, receive the library. It works, and it means the
127
+ first thing a recommender does — before recommending anything — is tell Valve
128
+ that you asked.
129
+
130
+ Steam already wrote your playtime to your own disk. `localconfig.vdf` holds one
131
+ entry per app you have launched, with minutes played and a last-played
132
+ timestamp. No key, no login, no network, and reading it is not a request anyone
133
+ can log. `steamLibrary(text)` parses it, `seedTaste(library, rows)` matches it
134
+ against a corpus on `appid`, and what comes back drives `taste.js` unchanged.
135
+
136
+ The mapping is where the judgement is, and it is deliberately narrow:
137
+
138
+ | in the library | becomes | why |
139
+ |---|---|---|
140
+ | played a lot | a like | you kept coming back |
141
+ | launched, bounced off | a dislike | you tried it and stopped — the only negative signal a library actually contains |
142
+ | never launched | **neither** | a backlog is not a rejection |
143
+
144
+ That third row is the one that matters. `taste.js` applies GAMMA 0.6 to
145
+ rejections, so a wrong dislike actively steers recommendations away from things
146
+ you would have liked, and an unplayed game in a library is a sale or a bundle
147
+ rather than a verdict. Playtime decides membership; `LastPlayed` decides order,
148
+ because the half-life is what makes a taste steerable and sorting by hours would
149
+ tell it that a game you adored in 2021 is where you are now.
150
+
151
+ In a browser it is a drop target — parsed in the tab, going nowhere. In Node it
152
+ is a path. Same parser either way.
153
+
154
+ ## Sharing back — what a publisher can learn, and what it cannot
155
+
156
+ Three modules make the reverse direction honest, and until now none of them was
157
+ reachable from an agent:
158
+
159
+ - **`reactions.js`** turns a reader's log into a corpus someone can buy. The free
160
+ index carries coverage centroids, which corpus, and the month — the *shape* of
161
+ someone's attention. Which item, what it was called, and which way they reacted
162
+ are the paid columns, and no reaction ships a vector, because a reaction's
163
+ vector is a copy of a row anyone can download free.
164
+ - **`cohort.js`** answers a publisher's question by pairwise-masked secure
165
+ aggregation. Every reader evaluates the question against their own log and
166
+ returns a share that is uniform over the ring; sum the cohort and the masks
167
+ cancel. Below five readers it refuses, because an aggregate over two people is
168
+ two people's data with a total sign on it.
169
+ - **`demand.js`** names the resulting direction in the publisher's own declared
170
+ tag vocabulary, measures what their shelf holds along it in the shelf's own
171
+ sigma, and returns a verdict: commission, serve, fix, retire, cold.
172
+
173
+ `example/main.js` exposes the reader's half as `share-reactions` and
174
+ `answer-question`; `example/publisher-console.mjs` is the buyer's half.
175
+
176
+ ## directory.js — searching FOR data, before searching IN it
177
+
178
+ Every other module answers a question about rows you already have. An agent
179
+ facing a network of independent publishers has an earlier question: *which
180
+ corpus*.
181
+
182
+ The bake already publishes the answer. Each domain's `catalog.json` carries
183
+ `coverage` — spherical k-means centroids over a sample of its own vectors, 8 at
184
+ 128 dims, about 4 KB. Rank a query against every publisher's centroids and you
185
+ have ranked the network without downloading a shard.
186
+
187
+ That is what keeps it from being a search engine. A directory that indexed rows
188
+ would have to hold everyone's rows. This holds 4 KB per corpus and never sees
189
+ one — and the query is embedded in the client, so it never sees the question
190
+ either. Same privacy property the shards give search, one level up.
191
+
192
+ `findCorpora(query, {sources, embed, model})` returns matches with affinity,
193
+ size and price, plus what could not be reached and what could not be compared —
194
+ a corpus baked with a different embedding model is excluded and named, because
195
+ cosine between two models' vectors is noise that still looks like a score.
196
+
197
+ ## roles.js — why a reader works on a corpus nobody wrote code for
198
+
199
+ A generic reader has one hard problem: it has rows and no idea which field is
200
+ the title. Hardcode `name` and it works on one bundle and returns `undefined` on
201
+ the next.
202
+
203
+ The answer is already baked. A quickbeam domain manifest carries `role_map`
204
+ (title, subtitle, tags, the prose to search, measures, location, link) and
205
+ `presentation` (icon, accent and singular/plural per entity type, plus per-type
206
+ external URL templates). `rolesFrom(manifests, sample)` merges that across the
207
+ domains a view fuses and falls back to sniffing where a domain declared nothing.
208
+ `collections(roles, rows)` turns `entity_types` into browsable groups with the
209
+ publisher's own icons and plurals, and `values(v)` normalizes a multi-valued
210
+ field — one real shard holds the same field as an array, as `"['a', 'b']"` and
211
+ as `"a,b"`, and handing an agent two shapes for one field is how it filters on
212
+ the wrong one.
213
+
214
+ One ordering matters and it is silent when you get it wrong: `rowText` runs per
215
+ row *during parsing*, so anything it depends on must exist before the first
216
+ shard is read. Derive roles after `loadShard` resolves and every row is already
217
+ parsed with the default — search then matches nothing and nothing errors. That
218
+ is what `configure({ onManifests })` is for; the format puts every manifest on
219
+ the wire before any shard.
220
+
221
+ ## What an app has to pin
222
+
223
+ Almost nothing — but what there is, is permanent, because it goes on-chain.
224
+
225
+ | pin | where | why it can never change |
226
+ |---|---|---|
227
+ | **namespace** | `resourceIdFor(ns, …)` | folded into every resourceId ever minted. A different `ns` derives different ids, and bytes already stored become unreachable from the resource someone paid for. |
228
+ | **`file`** | `buildTreeGraph(nodes, { file })` | the fs node type, vertex tag and payload `kind`. Every vertex already committed says it. Default `"file"` — a new app takes it. |
229
+ | **`passages`** | same | the timestamped-text sidecar (cues, chapters, a transcript): its tag, edge rel, back-pointer field and id suffix. `null` if an app's files have no timeline. |
230
+ | **`pointerFields`** | same | which keys of a node's `published` blob reach the payload. `null` passes the blob through whole, which is right for a new app; pin a list once something is committed, so a field added upstream can't quietly start appearing. |
231
+
232
+ `sond3r/server/graph.js` and `sond3r/src/pay/envelope.js` are what a pin looks
233
+ like: a short file that holds the constants still and re-exports the rest.
234
+
235
+ ## What an app has to wire
236
+
237
+ ```js
238
+ import { configure } from "@fangorn/westmarch/shard";
239
+
240
+ configure({
241
+ // How to learn WHICH view to read. A callback, not a constant: a view id
242
+ // embeds its requester, so repointing an app must not need a rebuild.
243
+ resolveView: () => "https://registry.example/q/qb_1",
244
+ // The free text a lexical query matches, per row. Default: name/path/desc.
245
+ rowText: (f) => [f.name, f.path, f.desc].filter(Boolean).join(" "),
246
+ });
247
+ ```
248
+
249
+ Then `loadShard()` gives you every row, streamed, tombstones resolved, cached per
250
+ view; `watchShard(fn)` drops that cache when the view gains shards;
251
+ `suggestDomains(q)` ranks domains you have *not* downloaded, off the coverage
252
+ centroids in `catalog.json` — which is how you choose what to pull before the
253
+ bytes move.
254
+
255
+ ## What it deliberately does not do
256
+
257
+ No row schema, no plugin system, no adapter interface. A row is
258
+ `{ id, owner, ...fields, text, vector, norm }` and what those fields *mean* is
259
+ the app's: what counts as a file, how hits group into results, what a price is.
260
+ `sond3r/src/catalog/search.js` is that layer for one app — series collapsing,
261
+ subtitle seeks, an x402f pointer projection — and none of it belongs here.
262
+
263
+ No UI. The React publisher and storefront still live in sond3r. A component API
264
+ designed against one consumer is a guess; the second app is what earns it.
265
+
266
+ ## Test
267
+
268
+ ```sh
269
+ pnpm test # every module's own self-check, no network, no browser
270
+ ```
271
+
272
+ `publish/envelope.js` pins fixed vectors for sond3r's live ids. If that check
273
+ fails, the extraction broke money.
274
+
275
+ `example/` is a second consumer — five WebMCP verbs and a telemetry page — run
276
+ against two unrelated bundles to prove the seam holds. See its README.
277
+
278
+ `example-publisher/` is the other half: the smallest directory that publishes a
279
+ priced, encrypted file to a Fangorn app. Its only dependency is this package —
280
+ publishing to an app does not require a clone of that app. See its README.
@@ -0,0 +1,111 @@
1
+ // The index of apps — read off the chain.
2
+ //
3
+ // An app owner binds its agent card with `setAppAgentUri`, and the AppRegistry
4
+ // emits `AppAgentChanged(appId, uri)`. The log IS the directory: no registry
5
+ // namespace to bake, no relay to ask, nobody's server in the loop. Scan the
6
+ // logs, keep each app's latest uri, and hand every uri to `discoverApp`, which
7
+ // fetches the card and checks it names that app and is still the one bound on
8
+ // chain. The card then says where the data is (`views`) and where it starts
9
+ // (`fromBlock`).
10
+ //
11
+ // `fangorn` is an SDK client, passed in rather than imported, so this module
12
+ // stays usable where the SDK is not installed, and testable without a chain.
13
+ // Reads only: a reader can create one with a throwaway key.
14
+ //
15
+ // ponytail: one flat list, no ranking. Every card is one fetch, all in
16
+ // parallel. Past a few hundred apps, rank by the cards' own coverage instead of
17
+ // fetching them all.
18
+
19
+ /**
20
+ * A pointer someone else wrote, and following it is the one place a reader
21
+ * navigates to a URL it did not configure. So: http(s) only. `javascript:` and
22
+ * `data:` never reach a fetch or an EventSource from here.
23
+ */
24
+ function safeView(url) {
25
+ try {
26
+ const u = new URL(String(url));
27
+ if (u.protocol !== "https:" && u.protocol !== "http:") return null;
28
+ return u.toString().replace(/\/+$/, "").replace(/\/(stream|cdn)$/, "");
29
+ } catch { return null; }
30
+ }
31
+
32
+ /** The SDK's FANGORN_APP_EXTENSION. Copied so this file needs no SDK;
33
+ * publish/agent-card.js's self-check fails if the two drift. */
34
+ export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
35
+
36
+ /** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
37
+ export function toApp({ card, appId, fromBlock, namespaces }, cardUrl) {
38
+ const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
39
+ const views = (Array.isArray(ext?.params?.views) ? ext.params.views : []).map(safeView).filter(Boolean);
40
+ return {
41
+ appId, card: cardUrl, fromBlock, namespaces, views,
42
+ name: card.name ?? appId,
43
+ desc: card.description ?? "",
44
+ url: safeView(card.url),
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Every app with a bound, verifiable card. The ones that fail are reported
50
+ * with the reason, not dropped: a card that is down and a card that lies look
51
+ * the same from outside, and an agent should see both.
52
+ *
53
+ * An app that cleared its uri (bound to "") is skipped. It is not an error.
54
+ */
55
+ export async function listApps(fangorn, { fromBlock, toBlock } = {}) {
56
+ if (fromBlock == null) throw new Error("listApps: pass fromBlock, a block at or before the AppRegistry's deployment — from 0 is one RPC call per 1000 blocks of chain history");
57
+ const logs = await fangorn.getAppRegistry().getAppAgentLogs({ fromBlock: BigInt(fromBlock), toBlock });
58
+ // Oldest first, so the last write wins. discoverApp re-reads the binding,
59
+ // so a stale uri here is caught there, not trusted.
60
+ const latest = new Map(logs.map((l) => [l.appId, l.agentUri]));
61
+ const apps = [], rejected = [];
62
+ await Promise.all([...latest].map(async ([appId, uri]) => {
63
+ if (!uri) return;
64
+ try { apps.push(toApp(await fangorn.discoverApp(uri), uri)); }
65
+ catch (e) { rejected.push({ appId, card: uri, why: e?.message ?? String(e) }); }
66
+ }));
67
+ apps.sort((a, b) => a.name.localeCompare(b.name));
68
+ return { apps, rejected };
69
+ }
70
+
71
+ // ── self-check: `node consume/apps.js` ──────────────────────────────────────
72
+ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
73
+ const j = (x) => JSON.stringify(x, (_, v) => (typeof v === "bigint" ? `${v}n` : v));
74
+ const eq = (a, b, m) => { if (j(a) !== j(b)) throw new Error(`${m}: ${j(a)} != ${j(b)}`); };
75
+ const A = "0x" + "a".repeat(64), B = "0x" + "b".repeat(64), C = "0x" + "c".repeat(64);
76
+ const card = (appId, name, views) => ({ name, description: `${name}!`, url: "https://x.test/", capabilities: { extensions: [
77
+ { uri: "https://other/ext", params: { appId, views: ["https://evil.test/"] } },
78
+ { uri: "https://fangorn.network/a2a/app/v1", params: { appId, views } },
79
+ ] } });
80
+ const cards = {
81
+ "https://a.test/card": card(A, "Beta", ["https://a.test/q/v1/stream", "javascript:alert(1)"]),
82
+ "https://a.test/old": card(A, "Old", []),
83
+ "https://b.test/card": card(B, "Alpha", []),
84
+ };
85
+ let askedFrom;
86
+ const fangorn = {
87
+ getAppRegistry: () => ({ getAppAgentLogs: ({ fromBlock }) => (askedFrom = fromBlock, Promise.resolve([
88
+ { appId: A, agentUri: "https://a.test/old" },
89
+ { appId: B, agentUri: "https://b.test/card" },
90
+ { appId: A, agentUri: "https://a.test/card" }, // A rebound: last wins
91
+ { appId: C, agentUri: "https://c.test/card" },
92
+ { appId: C, agentUri: "" }, // C unbound
93
+ { appId: "0x" + "d".repeat(64), agentUri: "https://liar.test/card" },
94
+ ])) }),
95
+ discoverApp: async (uri) => {
96
+ if (uri === "https://liar.test/card") throw new Error("not bound to app");
97
+ const c = cards[uri];
98
+ return { card: c, appId: c.capabilities.extensions[1].params.appId, fromBlock: 7n, namespaces: ["n"] };
99
+ },
100
+ };
101
+ const { apps, rejected } = await listApps(fangorn, { fromBlock: 42 });
102
+ eq(askedFrom, 42n, "fromBlock forwarded as bigint");
103
+ eq(apps.map((a) => a.name), ["Alpha", "Beta"], "one entry per app, latest card, sorted, unbound skipped");
104
+ const beta = apps[1];
105
+ eq(beta.views, ["https://a.test/q/v1"], "views from the fangorn extension only, trimmed, non-http dropped");
106
+ eq([beta.appId, beta.card, beta.url, beta.fromBlock, beta.desc], [A, "https://a.test/card", "https://x.test", 7n, "Beta!"], "entry fields");
107
+ eq(rejected.map((r) => r.why), ["not bound to app"], "a card that fails verification is reported");
108
+ await listApps(fangorn).then(() => { throw new Error("missing fromBlock must throw"); }, () => {});
109
+
110
+ console.log("apps.js self-check ok — latest card per app, unbound skipped, failures reported, views gated to http(s)");
111
+ }