@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 +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/consume/roles.js
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
// What a row means, when nobody wrote code for this dataset.
|
|
2
|
+
//
|
|
3
|
+
// A generic reader has one hard problem: it has rows, and no idea which field is
|
|
4
|
+
// the title. Hardcode `name` and it works on one corpus and returns `undefined`
|
|
5
|
+
// on the next — which is exactly what happened the first time the probe was
|
|
6
|
+
// pointed at a second bundle.
|
|
7
|
+
//
|
|
8
|
+
// The answer is already baked. A quickbeam domain manifest carries `role_map`
|
|
9
|
+
// (which field is the title, the subtitle, the tags, the prose to search, the
|
|
10
|
+
// measures, the location, the link) and `presentation` (an icon, an accent and a
|
|
11
|
+
// singular/plural name per entity type, plus per-type external URL templates).
|
|
12
|
+
// The publisher declared all of it when they shaped the graph. This file reads
|
|
13
|
+
// that declaration, merges it across the domains a view fuses, and falls back to
|
|
14
|
+
// sniffing when a domain was baked before role_map existed.
|
|
15
|
+
//
|
|
16
|
+
// So: a page written against these roles renders any bundle. That is the whole
|
|
17
|
+
// point of the file — it is the difference between a demo and a storefront.
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A field's values, as a list.
|
|
21
|
+
*
|
|
22
|
+
* Bundles are ragged about this in a way that matters: the SAME field arrives as
|
|
23
|
+
* a real array on one row, as `"['Sitcom', 'Comedy']"` on the next, and as
|
|
24
|
+
* `"Documentary,History"` on a third — all three are in one shard here. A reader
|
|
25
|
+
* that passes them through unchanged hands an agent two different shapes for one
|
|
26
|
+
* field and it filters on the wrong one.
|
|
27
|
+
*
|
|
28
|
+
* A comma with NO space after it is the signature of a machine-joined list.
|
|
29
|
+
* Prose keeps its space ("Wilson, Michael G.") and must stay one value.
|
|
30
|
+
*
|
|
31
|
+
* ponytail: a sniff, not a parser. A value it misreads becomes a odd-looking
|
|
32
|
+
* bucket, never a wrong answer to a filter — matching is on whole values.
|
|
33
|
+
*/
|
|
34
|
+
const BRACKETED = /^\s*[[(].*[\])]\s*$/;
|
|
35
|
+
const JOINED = /,(?! )/;
|
|
36
|
+
export function values(v) {
|
|
37
|
+
if (Array.isArray(v)) return v.map((x) => String(x));
|
|
38
|
+
const s = String(v ?? "");
|
|
39
|
+
const inner = BRACKETED.test(s) ? s.trim().slice(1, -1) : JOINED.test(s) ? s : null;
|
|
40
|
+
if (inner === null) return s === "" ? [] : [s];
|
|
41
|
+
return inner.split(",").map((x) => x.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Fields worth sniffing for, in order, when a domain declares no role_map. */
|
|
45
|
+
const GUESS = {
|
|
46
|
+
title: ["title", "name", "label", "headline", "path"],
|
|
47
|
+
subtitle: ["subtitle", "primaryType", "entityType", "kind", "series", "creator"],
|
|
48
|
+
text: ["text", "desc", "description", "summary", "body", "abstract"],
|
|
49
|
+
};
|
|
50
|
+
const first = (fields, candidates) => candidates.find((c) => fields.has(c)) ?? null;
|
|
51
|
+
const arr = (v) => (Array.isArray(v) ? v.filter(Boolean) : v ? [v] : []);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Merge a view's domain manifests into one presentation contract.
|
|
55
|
+
*
|
|
56
|
+
* A view fuses several publishers' domains and they need not agree — one may
|
|
57
|
+
* call its title `title` and another `name`. Merging rather than picking the
|
|
58
|
+
* first is what lets a fused view render both: `title` becomes an ordered list
|
|
59
|
+
* of candidates and `titleOf` takes whichever a given row actually has.
|
|
60
|
+
*
|
|
61
|
+
* `sample` is any rows already downloaded; it is only consulted for a domain
|
|
62
|
+
* that declared nothing, and an empty sample is fine — the guesses below are
|
|
63
|
+
* ordered by how commonly they mean what they say.
|
|
64
|
+
*/
|
|
65
|
+
export function rolesFrom(manifests = [], sample = []) {
|
|
66
|
+
const seen = new Set();
|
|
67
|
+
for (const r of sample) for (const k of Object.keys(r)) seen.add(k);
|
|
68
|
+
|
|
69
|
+
const title = [], subtitle = [], tags = [], text = [], measures = [], spatial = [], media = [];
|
|
70
|
+
const labels = {}, types = {}, fieldLabels = {}, externalUrl = {}, actions = {};
|
|
71
|
+
let launch = null, refers = null;
|
|
72
|
+
const entityTypes = [];
|
|
73
|
+
const gates = [];
|
|
74
|
+
let identity = null;
|
|
75
|
+
|
|
76
|
+
const push = (into, v) => { for (const x of arr(v)) if (x && !into.includes(x)) into.push(x); };
|
|
77
|
+
|
|
78
|
+
for (const m of manifests) {
|
|
79
|
+
const rm = m?.role_map ?? {};
|
|
80
|
+
push(title, rm.title); push(subtitle, rm.subtitle); push(tags, rm.tags);
|
|
81
|
+
push(text, rm.text); push(measures, rm.measures); push(spatial, rm.spatial); push(media, rm.media);
|
|
82
|
+
identity ??= rm.identity ?? null;
|
|
83
|
+
// Where a row in THIS corpus points, in someone else's.
|
|
84
|
+
//
|
|
85
|
+
// The 21,131 subtitle rows in the archive bundle each carry the `path`
|
|
86
|
+
// of the film they were transcribed from, and every one of them resolves
|
|
87
|
+
// — a total foreign key across two separately baked, separately priced
|
|
88
|
+
// domains. Without this the consumer shows them as two unrelated
|
|
89
|
+
// shelves, which loses the entire reason the graph holds them together:
|
|
90
|
+
// you search what was SAID and arrive at what was FILMED.
|
|
91
|
+
//
|
|
92
|
+
// Declared, never sniffed. Testing whether one corpus's values happen to
|
|
93
|
+
// be a subset of another's would join two publishers who never agreed to
|
|
94
|
+
// be joined, on a collision.
|
|
95
|
+
if (rm.refers?.field && rm.refers?.corpus && rm.refers?.to) refers ??= { ...rm.refers };
|
|
96
|
+
Object.assign(labels, rm.labels ?? {});
|
|
97
|
+
for (const f of arr(rm.fields)) seen.add(f);
|
|
98
|
+
|
|
99
|
+
const pr = m?.presentation ?? {};
|
|
100
|
+
Object.assign(types, pr.types ?? {});
|
|
101
|
+
Object.assign(fieldLabels, pr.fieldLabels ?? {});
|
|
102
|
+
Object.assign(externalUrl, pr.externalUrl ?? {});
|
|
103
|
+
Object.assign(actions, pr.actions ?? {});
|
|
104
|
+
// The app's own interface, if it ships one. A url, not markup: it is a
|
|
105
|
+
// separate document with a separate origin, which is the only way a
|
|
106
|
+
// stranger's UI can be run at all.
|
|
107
|
+
// Where the app lives as a page of its own. An index that CONTAINS every
|
|
108
|
+
// app is a portal; one that points at them is a directory — and the
|
|
109
|
+
// choice is forced, not aesthetic: a framed document's modelContext is
|
|
110
|
+
// not the agent's, so hosting a real app deletes every tool it
|
|
111
|
+
// registers. The useful thing an index can do is hand over what it
|
|
112
|
+
// learned and get out of the way. `{taste}` and `{q}` fill at launch.
|
|
113
|
+
launch ??= pr.launch ?? null;
|
|
114
|
+
for (const t of arr(m?.entity_types)) entityTypes.push(t);
|
|
115
|
+
if (m?.paywall) gates.push({ domain: m.name ?? null, ...m.paywall });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Only where the manifests said nothing at all. A domain that declared a
|
|
119
|
+
// title must never be second-guessed by a sniff.
|
|
120
|
+
//
|
|
121
|
+
// Fields the sample actually has come first; the rest of the guess list
|
|
122
|
+
// follows. That tail matters more than it looks: sniffing needs a sample,
|
|
123
|
+
// and the one caller who needs roles EARLIEST — `rowText`, which runs per
|
|
124
|
+
// row during parsing — has no sample yet by definition. With the tail, an
|
|
125
|
+
// empty sample still yields usable candidates and `pick` takes whichever the
|
|
126
|
+
// row turns out to have; without it, every row parsed with empty text and
|
|
127
|
+
// lexical search silently matched nothing.
|
|
128
|
+
const guess = (into, candidates) => {
|
|
129
|
+
if (into.length) return;
|
|
130
|
+
push(into, candidates.filter((c) => seen.has(c)));
|
|
131
|
+
push(into, candidates);
|
|
132
|
+
};
|
|
133
|
+
guess(title, GUESS.title);
|
|
134
|
+
guess(subtitle, GUESS.subtitle);
|
|
135
|
+
guess(text, GUESS.text);
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
title, subtitle, tags, text, measures, spatial, media, identity,
|
|
139
|
+
labels, types, fieldLabels, externalUrl, actions, launch, refers, entityTypes,
|
|
140
|
+
// What this view sells, if anything. A list because a fused view can pull
|
|
141
|
+
// domains from several publishers and they price independently.
|
|
142
|
+
gates,
|
|
143
|
+
// Every field any domain mentions, plus everything the sample carried.
|
|
144
|
+
// This is what a facet picker offers.
|
|
145
|
+
fields: [...seen].sort(),
|
|
146
|
+
// True when at least one domain actually declared its shape, so a caller
|
|
147
|
+
// can say "sniffed" rather than implying the publisher chose this.
|
|
148
|
+
declared: manifests.some((m) => m?.role_map),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Which collection a row belongs to.
|
|
154
|
+
*
|
|
155
|
+
* `entityType` is quickbeam's own convention — both bundles here carry it, and
|
|
156
|
+
* the manifest's `entity_types` counts are keyed by it — so this is the one
|
|
157
|
+
* field name the format actually fixes. `kind` is the older spelling and is
|
|
158
|
+
* still on-chain in sond3r's vertices, hence the fallback.
|
|
159
|
+
*/
|
|
160
|
+
export const typeOf = (row) => {
|
|
161
|
+
const t = row?.entityType ?? row?.kind;
|
|
162
|
+
return t == null || t === "" ? null : String(t);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* English plural, for a type whose publisher declared none.
|
|
167
|
+
*
|
|
168
|
+
* ponytail: four rules. It exists because appending a bare "s" produced
|
|
169
|
+
* "subtitless" on a real bundle, which reads as a bug in the page rather than a
|
|
170
|
+
* missing declaration. A publisher who cares about the word declares `plural`
|
|
171
|
+
* in `presentation.types` and none of this runs.
|
|
172
|
+
*/
|
|
173
|
+
export function pluralize(word) {
|
|
174
|
+
const w = String(word);
|
|
175
|
+
// Already ends in s: assume it is already plural and leave it. Bundles name
|
|
176
|
+
// types like `subtitles` and `series`, and both "subtitless" and
|
|
177
|
+
// "subtitleses" read as a bug in the page. A genuine singular ending in s
|
|
178
|
+
// ("bus") comes out wrong, which is what declaring `plural` is for.
|
|
179
|
+
if (/s$/i.test(w)) return w;
|
|
180
|
+
if (/(x|z|ch|sh)$/i.test(w)) return `${w}es`;
|
|
181
|
+
if (/[^aeiou]y$/i.test(w)) return `${w.slice(0, -1)}ies`;
|
|
182
|
+
return `${w}s`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A collection, as the publisher wants it shown: its icon, accent, and the word
|
|
187
|
+
* for one of them and many. `count` comes from the manifest where it was baked
|
|
188
|
+
* and from the rows otherwise — a fused view has manifest counts for some
|
|
189
|
+
* domains and not others, and a browse column that said "—" for half of them
|
|
190
|
+
* would be worse than counting.
|
|
191
|
+
*
|
|
192
|
+
* Everything is optional. A bundle that declared no presentation still browses;
|
|
193
|
+
* it just does so with the raw type name and no icon, which is honest.
|
|
194
|
+
*/
|
|
195
|
+
export function collections(roles, rows = []) {
|
|
196
|
+
const counted = new Map();
|
|
197
|
+
for (const t of roles.entityTypes) counted.set(t.type, (counted.get(t.type) ?? 0) + (t.count ?? 0));
|
|
198
|
+
if (!counted.size) for (const r of rows) { const t = typeOf(r); if (t) counted.set(t, (counted.get(t) ?? 0) + 1); }
|
|
199
|
+
return [...counted]
|
|
200
|
+
.map(([type, count]) => {
|
|
201
|
+
const p = roles.types[type] ?? {};
|
|
202
|
+
return {
|
|
203
|
+
type, count,
|
|
204
|
+
icon: p.icon ?? null, accent: p.accent ?? null,
|
|
205
|
+
singular: p.singular ?? type,
|
|
206
|
+
plural: p.plural ?? pluralize(p.singular ?? type),
|
|
207
|
+
};
|
|
208
|
+
})
|
|
209
|
+
.sort((a, b) => b.count - a.count);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* What a paid domain is withholding, and what it costs.
|
|
214
|
+
*
|
|
215
|
+
* The shard a reader downloaded is a free INDEX: every row is present, every
|
|
216
|
+
* vector is present, so search and ranking are complete and private. What is
|
|
217
|
+
* absent is a named set of fields. That distinction is the whole offer, and a
|
|
218
|
+
* client that blurs it — "some results may be incomplete" — makes the purchase
|
|
219
|
+
* unjudgeable. Name the fields.
|
|
220
|
+
*
|
|
221
|
+
* `unpublished` marks a gate whose payload has not been sealed and uploaded yet:
|
|
222
|
+
* declared, priced, and nothing to buy. That is a different state from "not yet
|
|
223
|
+
* paid for" and a reader must not offer to sell it.
|
|
224
|
+
*/
|
|
225
|
+
export function gatesOf(roles) {
|
|
226
|
+
return (roles.gates ?? []).map((g) => ({
|
|
227
|
+
domain: g.domain ?? null,
|
|
228
|
+
locked: [...(g.locked ?? [])],
|
|
229
|
+
free: [...(g.free ?? [])],
|
|
230
|
+
price: String(g.price ?? "0"),
|
|
231
|
+
asset: g.asset ?? "USDC",
|
|
232
|
+
resourceId: g.resourceId ?? null,
|
|
233
|
+
workerUrl: g.workerUrl ?? null,
|
|
234
|
+
files: (g.files ?? []).map((f) => ({ file: f.file, count: f.count, bytes: f.bytes })),
|
|
235
|
+
unpublished: !g.resourceId,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Is this field withheld from the free tier anywhere in this view? */
|
|
240
|
+
export const isLocked = (field, roles) =>
|
|
241
|
+
(roles.gates ?? []).some((g) => (g.locked ?? []).includes(field));
|
|
242
|
+
|
|
243
|
+
/** Is this row part of the corpus's free sample — the deterministic slice a
|
|
244
|
+
* paywalled bake hands over whole so a buyer can read before paying?
|
|
245
|
+
*
|
|
246
|
+
* The bake picks it by hashing the row's own id (quickbeam `paywall.in_sample`),
|
|
247
|
+
* which is what stops a seller putting its best rows in the shop window. That
|
|
248
|
+
* only holds if someone CHECKS, and the check is this function: anyone can
|
|
249
|
+
* recompute which rows should be free and see whether the shard agrees. */
|
|
250
|
+
export async function inSample(trackId, rate) {
|
|
251
|
+
if (!(rate > 0)) return false;
|
|
252
|
+
if (rate >= 1) return true;
|
|
253
|
+
const d = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(String(trackId)));
|
|
254
|
+
return new DataView(d).getUint32(0) < rate * 2 ** 32;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Price in whole USDC, for display. Base units are 6-decimal on every network
|
|
258
|
+
* this settles on; a bare "50000" on a buy button is not a price anyone reads. */
|
|
259
|
+
export const priceLabel = (g) =>
|
|
260
|
+
`${(Number(g.price ?? 0) / 1e6).toFixed(g.price % 1000 ? 6 : 2)} ${g.asset ?? "USDC"}`;
|
|
261
|
+
|
|
262
|
+
/** The first of `candidates` this row actually has. A fused view is why this
|
|
263
|
+
* takes a list and not a name. */
|
|
264
|
+
export const pick = (row, candidates = []) => {
|
|
265
|
+
for (const c of candidates) {
|
|
266
|
+
const v = row?.[c];
|
|
267
|
+
if (v != null && v !== "") return v;
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
export const titleOf = (row, roles) => String(pick(row, roles.title) ?? row?.id ?? "untitled");
|
|
273
|
+
export const subtitleOf = (row, roles) => { const v = pick(row, roles.subtitle); return v == null ? null : String(v); };
|
|
274
|
+
|
|
275
|
+
/** The prose a lexical query should match. Every declared text field joined,
|
|
276
|
+
* not just the first — `text` and `editorialSummary` are both prose and a
|
|
277
|
+
* query can legitimately hit either. Falls back to the title so a row with no
|
|
278
|
+
* declared prose is still findable by name. */
|
|
279
|
+
export function textOf(row, roles) {
|
|
280
|
+
const parts = roles.text.map((f) => row?.[f]).filter((v) => v != null && v !== "");
|
|
281
|
+
if (parts.length) return parts.join(" ");
|
|
282
|
+
// A LOCKED text role has no text, and the title is not a stand-in for it.
|
|
283
|
+
// This fallback is what let two buying agents rank 21,131 subtitle rows by
|
|
284
|
+
// filename and never learn the dialogue was behind the gate — one bought
|
|
285
|
+
// blind, the other read it as a worthless corpus. Empty is the honest answer:
|
|
286
|
+
// it says "nothing to match here", which is true, instead of quietly
|
|
287
|
+
// substituting a filename that matches nothing anyone would ask.
|
|
288
|
+
if (roles.text.some((f) => isLocked(f, roles))) return "";
|
|
289
|
+
const t = pick(row, roles.title);
|
|
290
|
+
return t ? String(t) : "";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A compact preview: title, subtitle, and the declared tags/measures. What a
|
|
294
|
+
* tool result should carry so fifty hits don't spend a whole context window. */
|
|
295
|
+
export const BRIEF_CHARS = 160;
|
|
296
|
+
/** Tag lists in the wild are not short: this bundle's `categories` runs to fifty
|
|
297
|
+
* entries per row, so four hits came back as four kilobytes of "Establishment",
|
|
298
|
+
* "Point Of Interest", "Store". A preview that costs that much is not a preview.
|
|
299
|
+
* The first few carry the meaning; `get-row` has the rest. */
|
|
300
|
+
export const BRIEF_ITEMS = 6;
|
|
301
|
+
|
|
302
|
+
export function briefOf(row, roles, extra = []) {
|
|
303
|
+
const out = { id: row.id, title: titleOf(row, roles) };
|
|
304
|
+
const sub = subtitleOf(row, roles);
|
|
305
|
+
if (sub) out.subtitle = sub;
|
|
306
|
+
for (const f of [...roles.tags, ...roles.measures, ...extra]) {
|
|
307
|
+
const v = row[f];
|
|
308
|
+
if (v == null || v === "") continue;
|
|
309
|
+
// Tags are normalized to a list whatever shape the shard held them in;
|
|
310
|
+
// measures and everything else pass through as the scalar they are.
|
|
311
|
+
const list = roles.tags.includes(f) ? values(v) : null;
|
|
312
|
+
if (list) {
|
|
313
|
+
out[f] = list.length > BRIEF_ITEMS ? [...list.slice(0, BRIEF_ITEMS), `…+${list.length - BRIEF_ITEMS} more`] : list;
|
|
314
|
+
} else {
|
|
315
|
+
out[f] = typeof v === "string" && v.length > BRIEF_CHARS ? `${v.slice(0, BRIEF_CHARS)}…` : v;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** The publisher's own link for this row, from `presentation.externalUrl` —
|
|
322
|
+
* `{"Business": "{googleMapsUri}"}` keyed by entity type. Null when the type
|
|
323
|
+
* has no template or the row is missing the field it names. */
|
|
324
|
+
export function linkOf(row, roles) {
|
|
325
|
+
const tpl = roles.externalUrl?.[typeOf(row)];
|
|
326
|
+
if (!tpl) return null;
|
|
327
|
+
let missing = false;
|
|
328
|
+
const url = String(tpl).replace(/\{(\w+)\}/g, (_, f) => {
|
|
329
|
+
const v = row?.[f];
|
|
330
|
+
if (v == null || v === "") { missing = true; return ""; }
|
|
331
|
+
return String(v);
|
|
332
|
+
});
|
|
333
|
+
return missing ? null : url;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* What you can DO with this row, as its publisher declared it.
|
|
338
|
+
*
|
|
339
|
+
* `presentation.actions` is `{ <entityType>: [{ kind, label, href }] }`, where
|
|
340
|
+
* `href` is a `{field}` template like `externalUrl`. `kind` says what the thing
|
|
341
|
+
* IS — "video", "audio", "image" get a player, anything else gets a link — so a
|
|
342
|
+
* renderer never has to sniff a file extension or know what archive.org is.
|
|
343
|
+
*
|
|
344
|
+
* The interesting case is a template that names a field THIS TIER DOES NOT HAVE.
|
|
345
|
+
* A film's playable url is exactly what the paywall withholds, so the action
|
|
346
|
+
* comes back with `locked` naming the fields that are missing and why. That is
|
|
347
|
+
* a player with a price on it rather than a broken <video> or, worse, no sign
|
|
348
|
+
* that a player was ever on offer.
|
|
349
|
+
*/
|
|
350
|
+
export function actionsOf(row, roles) {
|
|
351
|
+
const defs = roles?.actions?.[typeOf(row)] ?? [];
|
|
352
|
+
return defs.map((a) => {
|
|
353
|
+
const locked = [], missing = [];
|
|
354
|
+
const href = String(a.href ?? "").replace(/\{(\w+)\}/g, (_, f) => {
|
|
355
|
+
const v = row?.[f];
|
|
356
|
+
if (v != null && v !== "") return String(v);
|
|
357
|
+
(isLocked(f, roles) ? locked : missing).push(f);
|
|
358
|
+
return "";
|
|
359
|
+
});
|
|
360
|
+
return { kind: a.kind ?? "link", label: a.label ?? a.kind ?? "open", href, locked, missing };
|
|
361
|
+
// An action whose template names a field this bundle simply never had is not
|
|
362
|
+
// a paywall, it is a bad declaration — drop it rather than show a dead
|
|
363
|
+
// button. Locked ones stay: those are on offer.
|
|
364
|
+
}).filter((a) => a.locked.length || !a.missing.length);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── self-check: `node consume/roles.js` ─────────────────────────────────────
|
|
368
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
369
|
+
// The real places manifest's shape.
|
|
370
|
+
const places = {
|
|
371
|
+
role_map: {
|
|
372
|
+
identity: "placeId", title: "title", subtitle: "primaryType", spatial: "locality",
|
|
373
|
+
media: "googleMapsUri", tags: ["categories"], measures: ["rating", "userRatingCount"],
|
|
374
|
+
temporal: null, relations: [], text: ["text", "editorialSummary"],
|
|
375
|
+
labels: { title: "Title" }, fields: ["address", "phone", "website"],
|
|
376
|
+
},
|
|
377
|
+
presentation: {
|
|
378
|
+
types: { Business: { icon: "🏪", singular: "Place" } },
|
|
379
|
+
externalUrl: { Business: "{googleMapsUri}", Event: "{ticketUrl}" },
|
|
380
|
+
},
|
|
381
|
+
entity_types: [{ type: "Event", count: 654 }, { type: "Business", count: 263 }],
|
|
382
|
+
};
|
|
383
|
+
const row = {
|
|
384
|
+
id: "r1", entityType: "Business", title: "Cira's Red, White & Brew Bar",
|
|
385
|
+
primaryType: "Bar & Grill", text: "Bar & Grill in Saint Germain, WI.",
|
|
386
|
+
editorialSummary: "", categories: "['Bar', 'Restaurant']", rating: "4.7",
|
|
387
|
+
userRatingCount: "293", googleMapsUri: "https://maps.google.com/?cid=140971",
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
let r = rolesFrom([places], [row]);
|
|
391
|
+
if (!r.declared) throw new Error("a manifest with a role_map is declared, not sniffed");
|
|
392
|
+
if (titleOf(row, r) !== "Cira's Red, White & Brew Bar") throw new Error("declared title lost");
|
|
393
|
+
if (subtitleOf(row, r) !== "Bar & Grill") throw new Error("declared subtitle lost");
|
|
394
|
+
if (textOf(row, r) !== "Bar & Grill in Saint Germain, WI.") throw new Error("empty declared text fields must be skipped, not joined as blanks");
|
|
395
|
+
// A declared reference into another publisher's corpus. All three parts or
|
|
396
|
+
// none: a half-written join would silently point at nothing, and the caller
|
|
397
|
+
// would show a matched subtitle with no film behind it.
|
|
398
|
+
{
|
|
399
|
+
const full = { field: "videoPath", corpus: "archive-films", to: "path" };
|
|
400
|
+
const ref = rolesFrom([{ role_map: { identity: "videoPath", refers: full } }]).refers;
|
|
401
|
+
if (ref?.corpus !== "archive-films" || ref.field !== "videoPath" || ref.to !== "path") throw new Error("a complete reference must survive the manifest");
|
|
402
|
+
for (const drop of ["field", "corpus", "to"]) {
|
|
403
|
+
const partial = { ...full }; delete partial[drop];
|
|
404
|
+
if (rolesFrom([{ role_map: { refers: partial } }]).refers) throw new Error(`a reference missing '${drop}' must not be honoured`);
|
|
405
|
+
}
|
|
406
|
+
if (rolesFrom([{ role_map: { title: "name" } }]).refers !== null) throw new Error("a corpus that declared no reference has none — this is never sniffed");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (linkOf(row, r) !== "https://maps.google.com/?cid=140971") throw new Error("presentation.externalUrl not applied");
|
|
410
|
+
// A template naming a field the row lacks yields null, not a broken URL.
|
|
411
|
+
if (linkOf({ ...row, entityType: "Event" }, r) !== null) throw new Error("a template with a missing field must be null, not a half-built link");
|
|
412
|
+
if (!r.fields.includes("phone") || !r.fields.includes("website")) throw new Error("role_map.fields must reach the facet picker");
|
|
413
|
+
if (r.types.Business.icon !== "🏪") throw new Error("presentation.types lost");
|
|
414
|
+
// One field, three shapes, one output. All three of these are in one real
|
|
415
|
+
// shard, and handing an agent two shapes for one field is how it filters on
|
|
416
|
+
// the wrong one.
|
|
417
|
+
for (const raw of [["Bar", "Restaurant"], "['Bar', 'Restaurant']", "Bar,Restaurant"]) {
|
|
418
|
+
const got = briefOf({ ...row, categories: raw }, r).categories;
|
|
419
|
+
if (!Array.isArray(got) || got.join() !== "Bar,Restaurant") throw new Error(`tags must normalize: ${JSON.stringify(raw)} → ${JSON.stringify(got)}`);
|
|
420
|
+
}
|
|
421
|
+
if (values("Wilson, Michael G.").length !== 1) throw new Error("a comma followed by a space is prose, not a list");
|
|
422
|
+
if (values("").length !== 0) throw new Error("an empty value is no values, not one empty one");
|
|
423
|
+
// A measure is a scalar and must not become a one-item list.
|
|
424
|
+
if (Array.isArray(briefOf(row, r).rating)) throw new Error("a measure must stay scalar");
|
|
425
|
+
|
|
426
|
+
// A fifty-entry tag list is truncated, and says it was — an agent must not
|
|
427
|
+
// read six categories as the whole list.
|
|
428
|
+
const many = briefOf({ ...row, categories: Array.from({ length: 50 }, (_, i) => `c${i}`) }, r);
|
|
429
|
+
if (many.categories.length !== BRIEF_ITEMS + 1) throw new Error(`long tag lists must truncate: ${many.categories.length}`);
|
|
430
|
+
if (!String(many.categories.at(-1)).includes("+44 more")) throw new Error("truncation must say how much it dropped");
|
|
431
|
+
if (briefOf({ ...row, categories: ["a", "b"] }, r).categories.length !== 2) throw new Error("a short list must pass through whole");
|
|
432
|
+
|
|
433
|
+
const b = briefOf(row, r);
|
|
434
|
+
if (b.title !== row.title || b.rating !== "4.7" || !b.categories) throw new Error(`brief must carry declared tags + measures: ${JSON.stringify(b)}`);
|
|
435
|
+
if ("googleMapsUri" in b) throw new Error("brief must not dump undeclared fields");
|
|
436
|
+
|
|
437
|
+
// ── the paywall, as the manifest declares it ──
|
|
438
|
+
const paid = rolesFrom([{ ...places, name: "places", paywall: {
|
|
439
|
+
free: ["title", "primaryType"], locked: ["text", "phone"], price: "50000",
|
|
440
|
+
asset: "USDC", resourceId: "0xdead", workerUrl: "https://w",
|
|
441
|
+
files: [{ file: "locked-0000-abc.ndjson.gz", count: 917, bytes: 1234 }],
|
|
442
|
+
} }]);
|
|
443
|
+
let [g] = gatesOf(paid);
|
|
444
|
+
if (g.locked.join() !== "text,phone") throw new Error("the locked field list must survive");
|
|
445
|
+
if (g.unpublished) throw new Error("a gate with a resourceId is buyable");
|
|
446
|
+
if (priceLabel(g) !== "0.05 USDC") throw new Error(`price must render as USDC, got ${priceLabel(g)}`);
|
|
447
|
+
if (!isLocked("phone", paid) || isLocked("title", paid)) throw new Error("isLocked must read the manifest");
|
|
448
|
+
// Declared but never sealed: priced, and nothing to sell. A reader must be
|
|
449
|
+
// able to tell that apart from "you have not paid yet".
|
|
450
|
+
[g] = gatesOf(rolesFrom([{ ...places, paywall: { locked: ["text"], price: "1" } }]));
|
|
451
|
+
if (!g.unpublished) throw new Error("no resourceId means there is nothing to buy yet");
|
|
452
|
+
// A view with no gate sells nothing, and says so by saying nothing.
|
|
453
|
+
if (gatesOf(rolesFrom([places])).length) throw new Error("an ungated view must report no gates");
|
|
454
|
+
if (isLocked("text", rolesFrom([places]))) throw new Error("nothing is locked in an ungated view");
|
|
455
|
+
|
|
456
|
+
// The free sample, recomputed. Must agree with quickbeam's paywall.in_sample
|
|
457
|
+
// byte for byte or the check that keeps a seller honest checks nothing —
|
|
458
|
+
// these ids and their verdicts are pinned against that implementation.
|
|
459
|
+
for (const [id, yes] of [["row-47", true], ["row-169", true], ["row-0", false], ["row-3", false], ["row-19", false]]) {
|
|
460
|
+
if (await inSample(id, 0.01) !== yes) throw new Error(`inSample must match the bake: ${id}`);
|
|
461
|
+
}
|
|
462
|
+
if (await inSample("row-0", 0) !== false || await inSample("row-0", 1) !== true) throw new Error("rate 0 gives nothing away, rate 1 gives everything");
|
|
463
|
+
let n = 0;
|
|
464
|
+
for (let i = 0; i < 2000; i++) if (await inSample(`row-${i}`, 0.01)) n++;
|
|
465
|
+
if (n !== 19) throw new Error(`the bake picks exactly 19 of these 2000 — got ${n}`);
|
|
466
|
+
|
|
467
|
+
// A locked text role reads as EMPTY, never as the title. The title-as-text
|
|
468
|
+
// fallback still stands for an ungated corpus that simply has no prose.
|
|
469
|
+
const gatedText = rolesFrom([{ ...places, name: "places", role_map: { ...places.role_map, text: ["text"] },
|
|
470
|
+
paywall: { free: ["title"], locked: ["text"], price: "1" } }]);
|
|
471
|
+
if (textOf({ title: "Cira's Bar" }, gatedText) !== "") throw new Error("a locked text role must read empty, not as the title");
|
|
472
|
+
if (textOf({ title: "Cira's Bar", text: "A bar." }, gatedText) !== "A bar.") throw new Error("a sampled row's real text must still win");
|
|
473
|
+
if (textOf({ title: "Cira's Bar" }, rolesFrom([places])) !== "Cira's Bar") throw new Error("the title fallback still stands when nothing is gated");
|
|
474
|
+
|
|
475
|
+
// ── collections: the publisher's own words for their own groups ──
|
|
476
|
+
let cols = collections(rolesFrom([places]));
|
|
477
|
+
if (cols.map((c) => c.type).join() !== "Event,Business") throw new Error(`collections must sort by count: ${cols.map((c) => c.type)}`);
|
|
478
|
+
if (cols[1].icon !== "🏪" || cols[1].singular !== "Place") throw new Error("presentation.types must reach the collection");
|
|
479
|
+
// A declared singular with no declared plural still reads correctly.
|
|
480
|
+
if (cols[1].plural !== "Places") throw new Error(`plural must derive from the declared singular: ${cols[1].plural}`);
|
|
481
|
+
// A type the publisher never described still browses — raw name, no icon.
|
|
482
|
+
if (cols[0].singular !== "Event" || cols[0].icon !== "🎫" && cols[0].icon !== null) throw new Error("an undescribed type must still be a collection");
|
|
483
|
+
// No manifest counts at all: fall back to counting rows, because "—" for half
|
|
484
|
+
// a fused view's collections is worse than counting them.
|
|
485
|
+
cols = collections(rolesFrom([{ role_map: { title: "title" } }]), [row, row, { id: "e", entityType: "Event" }]);
|
|
486
|
+
if (cols[0].type !== "Business" || cols[0].count !== 2) throw new Error(`row counting fallback wrong: ${JSON.stringify(cols)}`);
|
|
487
|
+
// The fallback plural. "subtitless" on a real bundle is what earned these
|
|
488
|
+
// rules; anything harder is the publisher's job to declare.
|
|
489
|
+
for (const [one, many] of [["subtitles", "subtitles"], ["series", "series"], ["video", "videos"],
|
|
490
|
+
["folder", "folders"], ["box", "boxes"], ["category", "categories"], ["day", "days"]]) {
|
|
491
|
+
if (pluralize(one) !== many) throw new Error(`pluralize(${one}) = ${pluralize(one)}, want ${many}`);
|
|
492
|
+
}
|
|
493
|
+
if (typeOf({ kind: "video" }) !== "video") throw new Error("the older `kind` spelling is still on-chain");
|
|
494
|
+
if (typeOf({}) !== null) throw new Error("a typeless row must be null, not 'undefined'");
|
|
495
|
+
|
|
496
|
+
// ── the archive bundle: no role_map at all, so sniff ──
|
|
497
|
+
// serve-embeddings.js writes {name, model, dim, shards} and nothing else.
|
|
498
|
+
const bare = { name: "videos", shards: [] };
|
|
499
|
+
const vid = { id: "v1", name: "S01E02 - Overture.mp4", path: "classic_tv/Duet/S01E02.mp4", kind: "video", desc: "A forgotten Fox sitcom." };
|
|
500
|
+
r = rolesFrom([bare], [vid]);
|
|
501
|
+
if (r.declared) throw new Error("a manifest with no role_map must report itself as sniffed");
|
|
502
|
+
if (titleOf(vid, r) !== "S01E02 - Overture.mp4") throw new Error(`sniff must prefer name over path: ${titleOf(vid, r)}`);
|
|
503
|
+
if (r.title[0] !== "name") throw new Error("a field the sample HAS must outrank one it does not");
|
|
504
|
+
if (subtitleOf(vid, r) !== "video") throw new Error("sniff must find a subtitle");
|
|
505
|
+
if (!textOf(vid, r).includes("forgotten Fox")) throw new Error("sniff must find the prose");
|
|
506
|
+
|
|
507
|
+
// A row with nothing to go on still gets a title rather than "undefined" —
|
|
508
|
+
// which is exactly the bug this file exists to prevent.
|
|
509
|
+
if (titleOf({ id: "x" }, rolesFrom([], [])) !== "x") throw new Error("a title must always fall back to the id");
|
|
510
|
+
if (textOf({ id: "x" }, rolesFrom([], [])) !== "") throw new Error("no prose and no title must be empty, not 'undefined'");
|
|
511
|
+
|
|
512
|
+
// ── no manifest AND no sample: the state rowText is called in ──
|
|
513
|
+
// This is the case that broke silently. Roles derived with nothing at all
|
|
514
|
+
// must still name candidates, because `pick` resolves them per row later.
|
|
515
|
+
const blind = rolesFrom([], []);
|
|
516
|
+
if (!blind.text.includes("desc") || !blind.title.includes("name")) throw new Error("a blind sniff must still offer candidates");
|
|
517
|
+
if (textOf(vid, blind) !== "A forgotten Fox sitcom.") throw new Error("a row parsed before any sample must still get its prose");
|
|
518
|
+
if (titleOf({ id: "z", title: "declared later" }, blind) !== "declared later") throw new Error("a blind sniff must resolve per row");
|
|
519
|
+
|
|
520
|
+
// ── a fused view: two domains that disagree about their own field names ──
|
|
521
|
+
// Merging rather than picking the first is what lets one page render both.
|
|
522
|
+
r = rolesFrom([places, { role_map: { title: "name", text: ["body"] } }]);
|
|
523
|
+
if (titleOf({ name: "a note" }, r) !== "a note") throw new Error("the second domain's title field must resolve too");
|
|
524
|
+
if (titleOf(row, r) !== row.title) throw new Error("the first domain's title must still resolve");
|
|
525
|
+
if (!r.text.includes("text") || !r.text.includes("body")) throw new Error("text fields must union across domains");
|
|
526
|
+
// A declared shape is never second-guessed by a sniff, even a plausible one.
|
|
527
|
+
if (r.title.includes("label")) throw new Error("a declared title must not be widened by guesses");
|
|
528
|
+
|
|
529
|
+
// ── actions: what you can DO with a row, declared per entity type ───────
|
|
530
|
+
{
|
|
531
|
+
const films = rolesFrom([{
|
|
532
|
+
name: "films",
|
|
533
|
+
role_map: { identity: "identifier", title: "name", text: ["desc"] },
|
|
534
|
+
paywall: { free: ["identifier", "name"], locked: ["path"], price: "50000", asset: "USDC" },
|
|
535
|
+
presentation: { actions: { video: [
|
|
536
|
+
{ kind: "video", label: "Play", href: "https://cdn.test/{path}" },
|
|
537
|
+
{ kind: "link", label: "On archive.org", href: "https://archive.org/details/{identifier}" },
|
|
538
|
+
{ kind: "link", label: "Dead", href: "https://x.test/{nosuchfield}" },
|
|
539
|
+
] } },
|
|
540
|
+
}]);
|
|
541
|
+
const acts = actionsOf({ entityType: "video", identifier: "the-temp", name: "temp.mp4" }, films);
|
|
542
|
+
if (acts.length !== 2) throw new Error(`a template naming a field the bundle never had must drop: ${JSON.stringify(acts)}`);
|
|
543
|
+
const play = acts.find((a) => a.kind === "video");
|
|
544
|
+
if (!play.locked.includes("path")) throw new Error("a player whose url is behind the paywall must say which field is locked");
|
|
545
|
+
const link = acts.find((a) => a.kind === "link");
|
|
546
|
+
if (link.href !== "https://archive.org/details/the-temp") throw new Error(`free fields must interpolate: ${link.href}`);
|
|
547
|
+
if (link.locked.length) throw new Error("a fully resolved action is not locked");
|
|
548
|
+
// …and once the payload is bought, the same declaration is a real player.
|
|
549
|
+
const bought = actionsOf({ entityType: "video", identifier: "the-temp", name: "temp.mp4", path: "a/b.mp4" }, films);
|
|
550
|
+
if (bought.find((a) => a.kind === "video").href !== "https://cdn.test/a/b.mp4") throw new Error("an unlocked player must resolve");
|
|
551
|
+
// A bundle that declared no actions offers none, rather than throwing.
|
|
552
|
+
if (actionsOf({ entityType: "video" }, rolesFrom([])).length) throw new Error("no declaration, no actions");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
console.log("roles.js self-check ok — declared role_map wins, sniffed fallback, fused domains merge, external links, declared actions resolve and name their locked fields, a cross-corpus reference is honoured only when whole and never sniffed, title never undefined");
|
|
556
|
+
}
|