@333eco/corpus 1.2.5 → 2.1.0

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.
@@ -0,0 +1,215 @@
1
+ // MCP resources over the corpus — shared by both surfaces.
2
+ //
3
+ // ⭐⭐ THE ONE DECISION THAT SHAPES THIS FILE: A RESOURCE CARRIES ITS PROVENANCE
4
+ // IN THE TEXT, NOT BESIDE IT. A tool response wraps a document in an envelope and
5
+ // a caller reads the envelope. A resource is different in kind — clients hand its
6
+ // contents straight to a model as context, and a `mimeType` field does not travel
7
+ // with a quotation. Serving bare text here would hand out corpus material stripped
8
+ // of the one property this server exists to provide.
9
+ //
10
+ // ⚠️ This is not a new judgement; it is the letters' rule applied a second time.
11
+ // The letters mark voice INLINE — "[VERBATIM — Thon Ly]" / "[SCAFFOLD …]" — rather
12
+ // than in a metadata field, because *with a field an agent must LOOK to know; with
13
+ // a marker it must STRIP not to*. The same asymmetry decides this: a header the
14
+ // model must delete is safe, a field it must consult is not.
15
+ //
16
+ // ⚠️⚠️ AND THE HEADER MUST SAY WHAT THE HASH DOES NOT COVER. `provenance.sha256`
17
+ // is computed over the whole source file, front matter included; `text` is the
18
+ // body. Prepending a header makes the served bytes hash to nothing at all — so
19
+ // the header states plainly that it is not part of the hashed artifact and points
20
+ // at the file that is. A "verifiable" resource that quietly cannot be verified
21
+ // would be worse than one that never claimed it.
22
+
23
+ const CORPUS_SCHEME = "corpus://";
24
+ const PAGE = 50;
25
+
26
+ export const RESOURCE_TEMPLATES = [
27
+ {
28
+ uriTemplate: "corpus://{slug}",
29
+ name: "corpus-document",
30
+ title: "Corpus document by slug",
31
+ description:
32
+ "Any document in the corpus, addressed by its slug — e.g. corpus://co-presence-gated-redemption. " +
33
+ "Returns the canonical text prefixed with a provenance header carrying its licence, sha256, DOI and " +
34
+ "a runnable verification command. Slugs come from resources/list or the list_documents tool.",
35
+ mimeType: "text/markdown"
36
+ }
37
+ ];
38
+
39
+ const mime = (d) => (d.metadata_convention === "html" ? "text/plain" : "text/markdown");
40
+
41
+ // The catalogue entry. Deliberately terse: a client renders this in a picker, and
42
+ // the licence is the one thing a person choosing a document needs to see.
43
+ export const resourceEntry = (d) => ({
44
+ uri: CORPUS_SCHEME + d.slug,
45
+ name: d.slug,
46
+ title: d.title,
47
+ description:
48
+ `${d.genre} · ${d.licence.id}` +
49
+ (d.date ? ` · ${d.date}` : "") +
50
+ (d.provenance.doi ? ` · doi:${d.provenance.doi}` : "") +
51
+ (d.subtitle ? ` — ${d.subtitle}` : ""),
52
+ mimeType: mime(d),
53
+ // ⚠️ `lastModified` carries the document's own date and NOTHING MORE PRECISE.
54
+ // ISO 8601 permits a date alone, and inventing "T00:00:00Z" would assert a
55
+ // time this corpus does not record — a small lie in a provenance server.
56
+ annotations: {
57
+ audience: ["user", "assistant"],
58
+ // Anchored papers rank above unanchored scaffold: a consumer choosing
59
+ // among 140 documents should meet the ones with proofs first.
60
+ priority: d.provenance.doi ? 0.9 : d.provenance.opentimestamps ? 0.6 : 0.4,
61
+ ...(d.date ? { lastModified: d.date } : {})
62
+ }
63
+ });
64
+
65
+ export const listResources = (documents, cursor) => {
66
+ // Cursor-based paging, because a corpus grows and a client should not have to
67
+ // take 140 entries to find one. The cursor is an offset encoded as a string —
68
+ // opaque to the client, which is all the protocol asks of it.
69
+ // ⚠️ btoa/atob, NOT Buffer. The worker runs without nodejs_compat, so Buffer is
70
+ // undefined there and only there — a break that passes every local test and
71
+ // fails once, in production, on the surface nobody runs by hand.
72
+ let start = 0;
73
+ if (cursor) {
74
+ try {
75
+ start = Number(atob(String(cursor)));
76
+ } catch {
77
+ throw new Error(`invalid cursor: ${cursor}`);
78
+ }
79
+ }
80
+ if (!Number.isInteger(start) || start < 0 || start > documents.length) {
81
+ throw new Error(`invalid cursor: ${cursor}`);
82
+ }
83
+ const page = documents.slice(start, start + PAGE);
84
+ const next = start + PAGE < documents.length ? btoa(String(start + PAGE)) : undefined;
85
+ return { resources: page.map(resourceEntry), ...(next ? { nextCursor: next } : {}) };
86
+ };
87
+
88
+ // ⚠️ Attribution is stated as an instruction, not a licence id. CC-BY on seven of
89
+ // these documents means a real obligation, and "CC-BY-4.0" alone leaves an agent
90
+ // to know what that entails.
91
+ const licenceLine = (d) =>
92
+ d.licence.attribution_required
93
+ ? `${d.licence.id} — ATTRIBUTION REQUIRED. Attribute to: ${d.authors ?? "⚠️ UNKNOWN — the index carries no author for this document; do not quote it until that is fixed"}. ${d.licence.url}`
94
+ : `${d.licence.id} — no attribution required, though citation is welcome. ${d.licence.url}`;
95
+
96
+ export const provenanceHeader = (d) => {
97
+ const p = d.provenance;
98
+ const L = [];
99
+ L.push("[PROVENANCE — corpus.333.eco. This header is NOT part of the document; the document begins below.]");
100
+ L.push(`title: ${d.title}`);
101
+ L.push(`slug: ${d.slug} (${d.genre}${d.date ? `, ${d.date}` : ""})`);
102
+ L.push(`licence: ${licenceLine(d)}`);
103
+ if (p.source_url) L.push(`source: ${p.source_url}`);
104
+ if (p.canonical_url) L.push(`canonical: ${p.canonical_url}`);
105
+ L.push(`sha256: ${p.sha256}`);
106
+ // The single most misreadable field, so it gets a full sentence.
107
+ L.push(" ⚠️ covers the COMPLETE SOURCE FILE at `source`, metadata block included —");
108
+ L.push(" NOT the text below, and NOT this header. Hashing what you were served");
109
+ L.push(" will not reproduce it. Fetch `source` to check.");
110
+ if (p.doi) L.push(`doi: https://doi.org/${p.doi} (this exact version)`);
111
+ if (p.concept_doi) L.push(`concept: https://doi.org/${p.concept_doi} (follows the document across versions)`);
112
+ if (p.opentimestamps) L.push(`proof: ots verify ${d.path}.ots — in the source repository, anchored in Bitcoin`);
113
+ if (p.source_url) L.push(`verify: curl -sL ${p.source_url} | shasum -a 256 # compare with sha256 above`);
114
+
115
+ if (p.deposited_matches_current === false) {
116
+ L.push("status: ⚠️ REVISED SINCE ITS DEPOSIT. The DOI above resolves to the deposited");
117
+ L.push(" version; the text below is newer. They differ legitimately.");
118
+ }
119
+ if (d.status === "living" && p.concept_doi) {
120
+ L.push("living: This document is revised on purpose. CITE the concept DOI, which always");
121
+ L.push(" resolves to the newest version; VERIFY against the version DOI and");
122
+ L.push(" sha256 above, which pin these exact bytes.");
123
+ }
124
+ // ⚠️ The letters' inline voice markers are meaningless without the sentence
125
+ // that explains them, and that sentence lives in `editorial` — a field a
126
+ // resource read would otherwise drop, reintroducing the exact bug the tool
127
+ // responses were once shipped with.
128
+ if (d.editorial?.annotation) {
129
+ L.push(`editorial: ${d.editorial.annotation.replace(/\s+/g, " ")}`);
130
+ }
131
+ L.push("[END PROVENANCE — everything below this line is the document, verbatim.]");
132
+ return L.join("\n");
133
+ };
134
+
135
+ export const readResource = (uri, bySlug) => {
136
+ const raw = String(uri ?? "");
137
+ if (!raw.startsWith(CORPUS_SCHEME)) {
138
+ throw new Error(`unsupported resource uri "${raw}". Corpus documents are addressed as corpus://<slug>.`);
139
+ }
140
+ const slug = raw.slice(CORPUS_SCHEME.length);
141
+ const d = bySlug.get(slug);
142
+ if (!d) throw new Error(`no document with slug "${slug}". Call resources/list to see what is available.`);
143
+ return {
144
+ contents: [
145
+ {
146
+ uri: raw,
147
+ mimeType: mime(d),
148
+ // Header, blank line, then the document exactly as the tools return it.
149
+ text: provenanceHeader(d) + "\n\n" + d.text
150
+ }
151
+ ]
152
+ };
153
+ };
154
+
155
+ /* ------------------------------------------------------------- completions ---
156
+ ⭐ A URI TEMPLATE WITHOUT COMPLETION IS A TEMPLATE YOU MUST ALREADY KNOW THE
157
+ ANSWER TO USE. `corpus://{slug}` is only usable by someone who already has the
158
+ slug; this is what turns it into something a person can discover by typing.
159
+
160
+ ⚠️ Prefix matches rank above substring matches, because a slug is a name and a
161
+ person typing one is almost always typing its beginning. Within each group the
162
+ order is the corpus's own (alphabetical by slug) — stable, so the same keystroke
163
+ never reorders the list under the user's cursor. */
164
+
165
+ const COMPLETION_MAX = 100; // the spec's ceiling
166
+
167
+ const slugCompletion = (value, documents) => {
168
+ const q = String(value ?? "").toLowerCase();
169
+ const slugs = documents.map((d) => d.slug);
170
+ const starts = slugs.filter((s) => s.startsWith(q));
171
+ const contains = q ? slugs.filter((s) => !s.startsWith(q) && s.includes(q)) : [];
172
+ const all = [...starts, ...contains];
173
+ return {
174
+ completion: {
175
+ values: all.slice(0, COMPLETION_MAX),
176
+ total: all.length,
177
+ hasMore: all.length > COMPLETION_MAX
178
+ }
179
+ };
180
+ };
181
+
182
+ export const completeArgument = (ref, argument, documents) => {
183
+ // ⭐ Prompts and completions compose: `verify_a_quote` takes a slug, and the
184
+ // same slug list that completes corpus://{slug} completes it here. A prompt
185
+ // argument nobody can autocomplete is a prompt you must already know how to
186
+ // fill in — the same defect the resource template had before completions.
187
+ if (ref?.type === "ref/prompt") {
188
+ if (ref.name === "verify_a_quote" && argument?.name === "slug") {
189
+ return slugCompletion(argument?.value, documents);
190
+ }
191
+ const e = new Error(
192
+ `no completions for prompt "${ref?.name}" argument "${argument?.name}". ` +
193
+ "The completable prompt argument is verify_a_quote(slug)."
194
+ );
195
+ e.code = -32602;
196
+ throw e;
197
+ }
198
+ if (ref?.type !== "ref/resource") {
199
+ const e = new Error(`unsupported completion reference type "${ref?.type}"`);
200
+ e.code = -32602;
201
+ throw e;
202
+ }
203
+ if (ref.uri !== "corpus://{slug}") {
204
+ const e = new Error(`no completions for "${ref.uri}". The completable template is corpus://{slug}.`);
205
+ e.code = -32602;
206
+ throw e;
207
+ }
208
+ if (argument?.name !== "slug") {
209
+ const e = new Error(`corpus://{slug} has one argument, "slug"; got "${argument?.name}"`);
210
+ e.code = -32602;
211
+ throw e;
212
+ }
213
+
214
+ return slugCompletion(argument?.value, documents);
215
+ };
@@ -0,0 +1,40 @@
1
+ // How a tool result is shaped.
2
+ //
3
+ // ⭐⭐ JSON IS THE PAYLOAD; THE TEXT BLOCK IS THE HUMAN FORM. Until 2026-09-02 every
4
+ // tool returned pretty-printed JSON inside a text block, which meant a consuming
5
+ // program had to parse a string to reach data the server already had as an object.
6
+ // `structuredContent` is where that object belongs.
7
+ //
8
+ // ⚠️ THE SPEC'S BACK-COMPAT ADVICE — also serialise the JSON into `content` — is a
9
+ // SHOULD, and following it literally DOUBLES every response (measured: 1.76×–2.00×
10
+ // across the six tools). So this file does not duplicate. It SPLITS BY ROLE:
11
+ //
12
+ // content — what a reader reads: document text, excerpts, a listing
13
+ // structuredContent — what a program validates: the typed envelope and metadata
14
+ //
15
+ // Nothing appears in both. For the document tools that makes `content` the text
16
+ // WITH ITS PROVENANCE HEADER — identical to what resources/read returns, so the two
17
+ // ways into the same document finally agree — and `structuredContent` the envelope
18
+ // without the body. Measured cost: 1.00× on the large tools, 0.76×–0.78× on the
19
+ // metadata tools, which are smaller than what they replaced.
20
+ //
21
+ // ⛔ NO `outputSchema` YET, and that is a decision rather than an omission. The spec
22
+ // makes a declared schema binding — "servers MUST provide structured results that
23
+ // conform" — and the envelope changed twice on the day this was written. A schema
24
+ // is a promise kept on every future change; publishing one over a shape still in
25
+ // motion buys validation now and breaks validating clients later.
26
+
27
+ // A payload that is data all the way down: the object, plus a COMPACT serialisation
28
+ // for clients that read only `content`. Compact, not pretty — indentation is the
29
+ // one part of the old shape that was pure cost.
30
+ export const structured = (value) => ({
31
+ content: [{ type: "text", text: JSON.stringify(value) }],
32
+ structuredContent: value
33
+ });
34
+
35
+ // A payload with a human form: `text` is read, `value` is validated, and the two
36
+ // carry different things rather than the same thing twice.
37
+ export const structuredWithText = (text, value) => ({
38
+ content: [{ type: "text", text }],
39
+ structuredContent: value
40
+ });
package/src/search.mjs ADDED
@@ -0,0 +1,176 @@
1
+ // How `search_corpus` decides a document matches.
2
+ //
3
+ // ⭐⭐ SHARED, AND IT HAS TO BE. The two surfaces keep their own `callTool` on
4
+ // purpose — twenty lines each, diffable by eye. Matching is not that: it is
5
+ // ranking, tokenising and window-finding, and if the stdio server and the worker
6
+ // ever disagreed about which documents answer a query, the corpus would have two
7
+ // opinions about itself. That is the same failure `sync.mjs` exists to prevent,
8
+ // arriving through the search path instead of the data path.
9
+ //
10
+ // ⚠️ WHY THIS REPLACED A BARE `indexOf`, and the evidence was a real caller.
11
+ // The first external client to reach the hosted endpoint searched
12
+ // `"gratitude alignment human wellbeing kindness"` and got ZERO results — while
13
+ // `gratitude` alone returns 50, `alignment` 50, `kindness` 50, `wellbeing` 16.
14
+ // The corpus was not missing the material; the matcher required that exact
15
+ // five-word string to appear verbatim, which of course it never does. A literal
16
+ // substring search silently punishes anyone who types a sentence, and it makes
17
+ // the zero-result signal MEAN THE WRONG THING: those queries were recording the
18
+ // matcher's limits while being read as gaps in the corpus.
19
+ //
20
+ // ⭐ PHRASE FIRST, THEN TERMS — the order is what keeps precision. This corpus is
21
+ // full of hyphenated marks (`B-Heart`, `Re-Tip`, `B-Sey`), and an exact phrase is
22
+ // always the strongest evidence a document is about the thing asked for. So an
23
+ // exact hit wins and is ranked above every term match. Only when the phrase is
24
+ // absent do we ask the weaker question: does this document contain ALL of these
25
+ // words, anywhere?
26
+ //
27
+ // ⚠️ The tokeniser KEEPS intra-word hyphens and apostrophes, so `B-Heart` is one
28
+ // token and not two. Splitting them would have made a search for `B-Heart` match
29
+ // any document containing "b" and "heart" separately — precision traded away for
30
+ // nothing, in the corpus where those marks matter most.
31
+
32
+ const TOKEN = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
33
+
34
+ // Dropped only when something survives the dropping. These are words present in
35
+ // essentially every document, so they never narrow the result set — but they do
36
+ // wreck the tightest-window calculation, because an "and" is always close by.
37
+ const STOP = new Set(["a", "an", "and", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "the", "to", "with"]);
38
+
39
+ export const terms = (query) => {
40
+ const all = String(query ?? "").toLowerCase().match(TOKEN) ?? [];
41
+ const kept = all.filter((t) => !STOP.has(t));
42
+ return kept.length ? kept : all;
43
+ };
44
+
45
+ // ⚠️ THE EDGES SNAP TO WHITESPACE, because a fixed-width slice cuts words in
46
+ // half — `…ratitude is immen…` — and a half-word at an excerpt boundary is not a
47
+ // cosmetic problem here. These excerpts are read by agents deciding whether a
48
+ // document is worth fetching, and a truncated token is a token that can be
49
+ // matched, quoted or reasoned about as if it were a word.
50
+ //
51
+ // ⚠️ THE SNAP IS BUDGETED, AND THE BUDGET IS THE WHOLE SAFETY OF IT. A corpus
52
+ // document can contain a 200-character unbroken run — a base64 blob, a sha256, a
53
+ // long URL — and an unbounded search for whitespace would eat the entire excerpt
54
+ // looking for a space that is not there. Past LOOK characters we accept the hard
55
+ // cut: a slightly ugly excerpt beats an empty one.
56
+ const LOOK = 40;
57
+
58
+ const clip = (body, centre, span) => {
59
+ if (body.length <= span) return body.trim();
60
+
61
+ let start = Math.max(0, Math.min(Math.round(centre - span / 2), body.length - span));
62
+ let end = start + span;
63
+
64
+ // ⭐ EACH EDGE TRIES BOTH DIRECTIONS, and the second direction is what fixes
65
+ // the long-token case. Snapping INWARD is preferred (it never grows the
66
+ // excerpt), but this corpus is full of runs that exceed LOOK with no space
67
+ // in them at all — `project_future_kindness_operating_noun`, bare URLs,
68
+ // markdown link targets, sha256 hex. Inward alone gave up on exactly those
69
+ // and left the half-word it was meant to prevent. Snapping OUTWARD instead
70
+ // costs at most LOOK extra characters and always lands on a real boundary.
71
+ if (start > 0) {
72
+ const ahead = body.slice(start, start + LOOK).search(/\s/);
73
+ if (ahead !== -1) {
74
+ start += ahead + 1;
75
+ } else {
76
+ const back = /\s\S*$/.exec(body.slice(Math.max(0, start - LOOK), start));
77
+ if (back) start = Math.max(0, start - LOOK) + back.index + 1;
78
+ }
79
+ }
80
+ if (end < body.length) {
81
+ const from = Math.max(start + 1, end - LOOK);
82
+ const back = /\s\S*$/.exec(body.slice(from, end));
83
+ if (back) {
84
+ end = from + back.index;
85
+ } else {
86
+ const ahead = body.slice(end, end + LOOK).search(/\s/);
87
+ end = ahead !== -1 ? end + ahead : Math.min(body.length, end + LOOK);
88
+ }
89
+ }
90
+ if (end <= start) end = Math.min(body.length, start + span); // pathological input
91
+
92
+ return (start > 0 ? "…" : "") + body.slice(start, end).trim() + (end < body.length ? "…" : "");
93
+ };
94
+
95
+ // Positions of a term, capped: a common word can appear thousands of times and
96
+ // the window sweep only needs enough of them to find a tight one.
97
+ const positions = (hay, term, cap = 200) => {
98
+ const out = [];
99
+ let i = hay.indexOf(term);
100
+ while (i !== -1 && out.length < cap) {
101
+ out.push(i);
102
+ i = hay.indexOf(term, i + term.length);
103
+ }
104
+ return out;
105
+ };
106
+
107
+ // Smallest span containing at least one occurrence of every term. Classic sweep
108
+ // over the merged, sorted occurrence list — linear in the number of positions.
109
+ const tightestWindow = (lists) => {
110
+ const merged = [];
111
+ lists.forEach((ps, t) => ps.forEach((p) => merged.push([p, t])));
112
+ merged.sort((a, b) => a[0] - b[0]);
113
+ const need = lists.length;
114
+ const seen = new Map();
115
+ let best = null;
116
+ let left = 0;
117
+ for (let right = 0; right < merged.length; right++) {
118
+ seen.set(merged[right][1], (seen.get(merged[right][1]) ?? 0) + 1);
119
+ while (seen.size === need) {
120
+ const width = merged[right][0] - merged[left][0];
121
+ if (!best || width < best.width) best = { width, from: merged[left][0], to: merged[right][0] };
122
+ const t = merged[left][1];
123
+ const n = seen.get(t) - 1;
124
+ if (n === 0) seen.delete(t);
125
+ else seen.set(t, n);
126
+ left++;
127
+ }
128
+ }
129
+ return best;
130
+ };
131
+
132
+ // null when the document does not match; otherwise the excerpt, how it matched,
133
+ // and a score for ranking. ⭐ `mode` is returned rather than hidden because a
134
+ // term match's excerpt may NOT contain the literal query, and a caller reading
135
+ // an excerpt deserves to know which question it answered.
136
+ export const match = (body, query, span = 320) => {
137
+ const hay = body.toLowerCase();
138
+ const phrase = String(query ?? "").toLowerCase().trim();
139
+
140
+ if (phrase) {
141
+ const i = hay.indexOf(phrase);
142
+ // An exact hit always wins: score is above anything a window can score.
143
+ if (i !== -1) return { mode: "phrase", excerpt: clip(body, i + phrase.length / 2, span), score: 1e9 };
144
+ }
145
+
146
+ const ts = [...new Set(terms(query))];
147
+ // A single term has already been tried as a phrase; there is no weaker
148
+ // question left to ask, so a miss is a miss.
149
+ if (ts.length < 2) return null;
150
+
151
+ const lists = ts.map((t) => positions(hay, t));
152
+ if (lists.some((l) => l.length === 0)) return null; // AND, not OR
153
+
154
+ const win = tightestWindow(lists);
155
+ if (!win) return null;
156
+ // Tighter is better; every term match ranks below every phrase match.
157
+ return { mode: "terms", excerpt: clip(body, Math.floor((win.from + win.to) / 2), span), score: 1e6 / (1 + win.width) };
158
+ };
159
+
160
+ // Rank and cut. Phrase matches first, then tighter term windows, corpus order
161
+ // breaking ties (Array.prototype.sort is stable).
162
+ export const rank = (hits, limit) =>
163
+ hits.slice().sort((a, b) => b.m.score - a.m.score).slice(0, Math.max(0, limit));
164
+
165
+ // ⭐⭐ WHICH TERMS APPEAR IN NO DOCUMENT AT ALL. Under AND-matching a zero result
166
+ // means at least one term is missing, and saying WHICH turns a dead end into a
167
+ // finding: `unicorn` absent is a fact about the corpus, while `wellbeing` absent
168
+ // would be a fact about the query. Computed only when a search returns nothing,
169
+ // so the cost never lands on a successful call — and it is exactly the sentence
170
+ // a caller would otherwise have to write to us by hand.
171
+ export const absentTerms = (documents, query) => {
172
+ const ts = [...new Set(terms(query))];
173
+ if (ts.length === 0) return [];
174
+ const hays = documents.map((d) => d.text.toLowerCase());
175
+ return ts.filter((t) => !hays.some((h) => h.includes(t)));
176
+ };