@333eco/corpus 2.0.0 → 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.
package/README.md CHANGED
@@ -48,6 +48,22 @@ manufactured on a schedule.
48
48
  | Tool | Returns |
49
49
  | --- | --- |
50
50
  | `search_corpus` | matching documents, provenance envelope, and an excerpt around each match |
51
+
52
+ ⭐ **`search_corpus` matches the phrase first, then all terms.** An exact substring hit
53
+ always wins and ranks above everything else — which keeps `B-Heart` and `Re-Tip` precise,
54
+ since the tokeniser holds hyphenated marks together. If the phrase is absent, a document
55
+ matches when it contains **every** term somewhere (AND, never OR), ranked by how tightly
56
+ those terms cluster. Each hit reports `match: "phrase" | "terms"`, because a term match's
57
+ excerpt need not contain the words you typed.
58
+
59
+ ⚠️ **This replaced a bare `indexOf`, and a real caller paid for it.** The first external
60
+ client to reach the hosted endpoint searched `"gratitude alignment human wellbeing kindness"`
61
+ and got **zero results** — while `gratitude` alone returns 109, `alignment` 50, `kindness` 50.
62
+ Nothing was missing from the corpus; the matcher demanded that exact five-word string appear
63
+ verbatim. **A zero result was recording the matcher's limits while being read as a gap.**
64
+
65
+ ⭐ On a zero result the response now names **which terms appear in no document at all**
66
+ (`absent_terms`), so a dead end says something instead of nothing.
51
67
  | `get_document` | one document in full — canonical text, never a summary |
52
68
  | `list_documents` | slugs, titles, genres, licences, provenance summaries |
53
69
  | `list_predictions` | the research program's pre-registered predictions, with falsifiers and status |
@@ -260,6 +276,104 @@ npm run deploy # sync + wrangler deploy
260
276
  { "mcpServers": { "corpus": { "url": "https://corpus.333.eco/mcp" } } }
261
277
  ```
262
278
 
279
+ ## Telling us what is missing, or broken
280
+
281
+ ```sh
282
+ npx @333eco/corpus --report-gap "what you looked for and did not find"
283
+ npx @333eco/corpus --report-bug "what went wrong, and what you expected instead"
284
+ ```
285
+
286
+ ⭐⭐ **A command, not telemetry, and the difference is the whole point.** The most
287
+ useful thing a corpus server can learn is what someone went looking for and did
288
+ not find. The hosted endpoint learns that from its own callers as a property of
289
+ being the server they called. This package runs on *your* machine, so collecting
290
+ it here would be an outbound report about your private reading — and the guard
291
+ against that is not a consent prompt or an opt-out flag. **It is that the serving
292
+ path cannot reach the code that sends.** `server.mjs` loads `report.mjs` with
293
+ a dynamic import inside the argv branch, so a normal session never reads the file
294
+ off disk at all.
295
+
296
+ ⭐ **Both flags share one module and one dynamic import**, so the second kind
297
+ added no second way into the network — which is why generalising was right and
298
+ copying the file would have been wrong.
299
+
300
+ The command prints the entire payload before sending it, and the payload is the
301
+ text you typed plus the version you have. ⚠️ **A bug report carries exactly the
302
+ same payload as a gap report, deliberately** — attaching a node version and
303
+ platform would be useful to whoever fixes it, but it would give the command two
304
+ different promises about what it sends, and the promise is the valuable part.
305
+ Anything about your environment that matters, put in the text; then you have
306
+ said it on purpose. No machine id, no username, no
307
+ hostname, no path. ⭐ The receiving end deliberately does not record the country
308
+ it could resolve for free: a voluntary note about a missing document has no use
309
+ for where the sender was standing, and collecting a thing because it is available
310
+ is how a narrow purpose widens.
311
+
312
+ ⚠️ **The honest limit, because the claim changed shape when this was added.**
313
+ Before it, *"this package makes no network call"* was verifiable by
314
+ `grep -r fetch src/` returning nothing — the strongest kind of evidence, since it
315
+ needs no reasoning. The claim is now narrower: **there is exactly one `fetch` in
316
+ the package, it is in `src/report.mjs`, and that file is imported from exactly
317
+ one place — a branch requiring an explicit flag.** Still checkable in under a
318
+ minute, but it is a chain of two facts rather than one absence.
319
+
320
+ ## What the remote server records
321
+
322
+ ⛔ **The npm package records nothing and sends nothing.** `npx @333eco/corpus`
323
+ runs on your machine, reads a local file, and makes no outbound request of any
324
+ kind. Everything in this section is about `corpus.333.eco` and only about it.
325
+
326
+ The asymmetry is deliberate. The hosted endpoint already sees every request it
327
+ answers, so writing down what it was asked adds no reach it did not have. The
328
+ same lines inside the package would be an outbound report about a stranger's
329
+ private reading, which is a different artifact — and not one this is going to
330
+ become.
331
+
332
+ ⭐⭐ **No per-caller identity is computed anywhere.** The client label is the
333
+ *software's* name, taken from the `clientInfo` it volunteers at handshake —
334
+ `claude-code`, `cursor` — never an IP, never a hash of one, never a cookie. Every
335
+ user of a given client is one label. That is not a promise to behave well: there
336
+ is no code path in `worker/src/telemetry.mjs` that derives a per-caller id, so
337
+ there is nothing to leak, sell, subpoena or regret later. The question the server
338
+ wants answered is *which clients reach it*, and that question needs no persons in
339
+ it.
340
+
341
+ ⭐⭐ **The only search text ever stored is a search that found nothing.** The
342
+ reason to log queries at all is to learn what the corpus is missing; a query that
343
+ *succeeded* tells you only what a caller was reading, which is their business.
344
+ So the successful query has no storage path — an absent branch, not a redaction
345
+ step someone has to remember to keep. Remove the enforcer and nothing breaks,
346
+ because there is no enforcer.
347
+
348
+ | Channel | Carries | Why it exists |
349
+ | --- | --- | --- |
350
+ | Analytics Engine | one row per JSON-RPC call: method, tool, slug, client label, country, protocol, corpus version, result count, error flag, duration — plus the query text **when and only when it matched nothing** | counting; queried by SQL, stays at Cloudflare |
351
+ | `thonly.org/api/track` | `corpus_connect` (a handshake) and `corpus_error` (the corpus asset failed to load) | the two things worth interrupting someone about |
352
+
353
+ ⛔ **Not one beacon per tool call.** An agent working through the corpus fires
354
+ dozens of calls in seconds, and a notification channel that reports each of them
355
+ is a channel nobody reads. The beacon fires on the *handshake*, and the receiving
356
+ function pushes only the **first sighting of a client label**, counting every one
357
+ after it in silence — so a notification means *a client we have never seen
358
+ appeared*. `corpus_error` is exempt and always pushes: it is rare by construction,
359
+ and silence is the wrong default for an outage.
360
+
361
+ ⚠️ **A dead beacon must not look like a quiet one.** If the receiving allowlist
362
+ changes, the POST 403s and the pushes simply stop — indistinguishable from *no new
363
+ clients this week*, which is exactly the reading that would let it stay broken for
364
+ months. So the delivery status is written to Analytics Engine as its own row:
365
+ silence on the phone is then something you can go and check rather than infer.
366
+
367
+ ⚠️ **`ANALYTICS` is unbound under `wrangler dev` without `--remote`.** The module
368
+ degrades to a no-op rather than throwing, so a local session looks entirely normal
369
+ and records nothing — expected, and worth knowing before reading an empty dataset
370
+ as a finding.
371
+
372
+ The endpoint discloses all of this in its own `GET /` response, under `records`.
373
+ A privacy policy is a page someone has to go and find; this is the endpoint
374
+ describing itself, in the one response a caller gets for free before doing
375
+ anything, so the disclosure travels with the thing it is about.
376
+
263
377
  ## Client configuration
264
378
 
265
379
  ```json
package/dist/corpus.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "package_version": "2.0.0",
2
+ "package_version": "2.1.0",
3
3
  "generated_from": [
4
4
  "H3/publications",
5
5
  "TH/publications",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@333eco/corpus",
3
- "version": "2.0.0",
4
- "description": "An MCP server for an open-licensed corpus, served with verifiable provenance \u2014 every document carries its sha256, DOI and OpenTimestamps proof so a consuming agent can check its own citation.",
3
+ "version": "2.1.0",
4
+ "description": "An MCP server for an open-licensed corpus, served with verifiable provenance every document carries its sha256, DOI and OpenTimestamps proof so a consuming agent can check its own citation.",
5
5
  "license": "CC0-1.0",
6
6
  "author": "Thon Ly",
7
7
  "homepage": "https://thonly.org/research",
package/src/report.mjs ADDED
@@ -0,0 +1,104 @@
1
+ // `--report-gap` and `--report-bug` — the only place this package makes an
2
+ // outbound request, and it is reached only by typing one of the flags.
3
+ //
4
+ // ⭐⭐ WHY THIS IS A COMMAND AND NOT TELEMETRY, because the distinction is the
5
+ // whole design. The useful signal from a corpus server is *what someone looked
6
+ // for and did not find*. The remote endpoint at corpus.333.eco collects that
7
+ // from its own callers as a property of being the server they called. This
8
+ // package runs on YOUR machine, so the same collection there would be an
9
+ // outbound report about your private reading — and the guard against that is
10
+ // not a consent flag or an opt-out. It is that THE SERVER PATH CANNOT REACH
11
+ // THIS FILE: `server.mjs` loads it with a dynamic import inside the argv branch,
12
+ // so during normal operation the module is never even read off disk.
13
+ //
14
+ // ⚠️ THE CHECKABLE CLAIM CHANGED SHAPE WHEN THIS FILE WAS ADDED, AND THAT IS
15
+ // WORTH STATING PLAINLY. Before it, "this package makes no network call" was
16
+ // verifiable by `grep -r fetch src/` returning nothing at all — the strongest
17
+ // kind of evidence, because it needs no reasoning. Now the honest claim is
18
+ // narrower: there is exactly ONE fetch in the package, it lives in this file,
19
+ // and this file is imported from exactly one place — a branch that requires an
20
+ // explicit flag. Still inspectable in under a minute, but it is a chain of two
21
+ // facts rather than one absence. ⛔ If a second import of this module ever
22
+ // appears, that chain is broken and the claim must be rewritten rather than
23
+ // repeated.
24
+ //
25
+ // ⛔ IT REPORTS TO THE WORKER, NOT TO THE NOTIFICATION BEACON. Sending to
26
+ // thonly.org/api/track would mean shipping every user of this package a working
27
+ // recipe for writing into the founder's admin notification channel, behind an
28
+ // Origin header a CLI can trivially assert. The worker's /gap endpoint writes to
29
+ // Analytics Engine and pushes nothing at anyone.
30
+
31
+ const ENDPOINT = "https://corpus.333.eco/report";
32
+
33
+ // ⚠️ A BUG REPORT AND A GAP REPORT CARRY THE SAME PAYLOAD, DELIBERATELY. It is
34
+ // tempting to attach a node version and a platform to a bug — genuinely useful
35
+ // to whoever fixes it — but that would give this command two different promises
36
+ // about what it sends, and the promise is the valuable part. Anything about your
37
+ // environment that matters, put in the text; then you have said it on purpose.
38
+ const KINDS = {
39
+ gap: {
40
+ prompt: "what you looked for and did not find",
41
+ thanks: "Thank you — recorded. It joins the gaps the hosted endpoint already sees."
42
+ },
43
+ bug: {
44
+ prompt: "what went wrong, and what you expected instead",
45
+ thanks: "Thank you — recorded. Issues are also welcome at github.com/333eco/corpus.333.eco/issues."
46
+ }
47
+ };
48
+ const MAX = 200;
49
+
50
+ // ⭐ WHAT IS SENT IS THE WHOLE OF WHAT IS SENT. The text you typed, and the
51
+ // version of the corpus you have. No machine id, no username, no hostname, no
52
+ // path, no timestamp of your own — and the receiving end deliberately does not
53
+ // record the country it could resolve, because a voluntary note about a missing
54
+ // document has no use for where the sender was standing.
55
+ export const report = async (kind, text, version) => {
56
+ const spec = KINDS[kind];
57
+ if (!spec) throw new Error(`unknown report kind: ${kind}`);
58
+ const body = String(text ?? "").trim().slice(0, MAX);
59
+ if (!body) {
60
+ console.error(`usage: corpus-mcp --report-${kind} "${spec.prompt}"`);
61
+ return 1;
62
+ }
63
+
64
+ const payload = { kind, text: body, version: version ?? null };
65
+
66
+ // Printed BEFORE the request, not after, so the disclosure is not
67
+ // contingent on the request succeeding.
68
+ console.log("Sending this, and nothing else:\n");
69
+ console.log(" " + JSON.stringify(payload));
70
+ console.log("\n to " + ENDPOINT + "\n");
71
+
72
+ try {
73
+ const res = await fetch(ENDPOINT, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json" },
76
+ body: JSON.stringify(payload)
77
+ });
78
+ // ⚠️⚠️ THE STATUS CODE IS NOT THE CONTRACT, AND TRUSTING IT REPORTED A
79
+ // FALSE SUCCESS ON THE FIRST RUN. A server predating /gap treats any
80
+ // POST without a JSON-RPC `id` as a NOTIFICATION and answers 202 with an
81
+ // empty body — so `res.ok` was true, and this printed "recorded" while
82
+ // nothing had been. The success signal must therefore be something only
83
+ // the real handler can produce: an explicit `ok` in the body. That also
84
+ // makes version skew safe in both directions, since an old server can
85
+ // never accidentally satisfy it.
86
+ const ack = await res.json().catch(() => null);
87
+ if (res.ok && ack?.ok === true) {
88
+ console.log(spec.thanks);
89
+ return 0;
90
+ }
91
+ console.error(
92
+ res.ok
93
+ ? "The endpoint accepted the request but did not confirm it recorded anything —\n" +
94
+ "it is probably running a version without /report. Nothing was recorded."
95
+ : `The endpoint answered ${res.status}. Nothing was recorded.`
96
+ );
97
+ return 1;
98
+ } catch (e) {
99
+ // A failure here is worth nobody's day. Say so and exit cleanly.
100
+ console.error(`Could not reach ${ENDPOINT}: ${e.message}`);
101
+ console.error("Nothing was sent. This is entirely optional — carry on.");
102
+ return 1;
103
+ }
104
+ };
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
+ };
package/src/server.mjs CHANGED
@@ -40,12 +40,38 @@ import { provenanceHeader } from "./resources.mjs";
40
40
  import { PROMPTS, getPrompt } from "./prompts.mjs";
41
41
  import { BASE_TOOLS } from "./base-tools.mjs";
42
42
  import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
43
+ import { match, rank, absentTerms } from "./search.mjs";
43
44
 
44
45
  const HERE = dirname(fileURLToPath(import.meta.url));
45
46
  const INDEX = resolve(HERE, "..", "dist", "corpus.json");
46
47
 
47
48
  const log = (...a) => console.error("[corpus-mcp]", ...a);
48
49
 
50
+ // ⭐⭐ THE ONE NON-SERVING PATH, AND IT IS GUARDED BY BEING A SEPARATE MODULE.
51
+ // `--report-gap "<text>"` sends a voluntary note about something this corpus does
52
+ // not contain. It is a COMMAND, never telemetry: nothing here runs unless the
53
+ // flag is typed, and the reporter is loaded with a DYNAMIC IMPORT so the serving
54
+ // path does not so much as read the file off disk. ⛔ Never import report-gap.mjs
55
+ // at the top of this file — a static import would put a fetch in the module graph
56
+ // of every session, which is exactly the property this arrangement preserves.
57
+ // ⚠️ Handled before the index check on purpose: reporting a gap must not require
58
+ // a built corpus, since "there is no corpus here" is itself a reportable gap.
59
+ // ⭐ Both flags share ONE module and ONE dynamic import, so adding the second
60
+ // kind did not add a second way into the network. `--report-gap` says what the
61
+ // corpus is missing; `--report-bug` says what this server got wrong.
62
+ const REPORTS = { "--report-gap": "gap", "--report-bug": "bug" };
63
+ const flag = process.argv.find((a) => a in REPORTS);
64
+ if (flag) {
65
+ const { report } = await import("./report.mjs");
66
+ let version = null;
67
+ try {
68
+ version = JSON.parse(readFileSync(resolve(HERE, "..", "package.json"), "utf8")).version;
69
+ } catch {
70
+ // Version is a convenience for whoever reads the report, never required.
71
+ }
72
+ process.exit(await report(REPORTS[flag], process.argv[process.argv.indexOf(flag) + 1], version));
73
+ }
74
+
49
75
  if (!existsSync(INDEX)) {
50
76
  log("dist/corpus.json is missing. Build it: node scripts/build-index.mjs --from <corpus repos>");
51
77
  process.exit(1);
@@ -137,28 +163,38 @@ const KNOWN_TOOLS = new Set([...BASE_TOOLS, ...PROGRAM_TOOLS].map((t) => t.name)
137
163
 
138
164
  const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
139
165
 
140
- const excerpt = (body, query, span = 320) => {
141
- const i = body.toLowerCase().indexOf(query.toLowerCase());
142
- if (i === -1) return null;
143
- const from = Math.max(0, i - span / 2);
144
- return (from > 0 ? "…" : "") + body.slice(from, from + span).trim() + (from + span < body.length ? "…" : "");
145
- };
146
166
 
147
167
  const callTool = (name, args) => {
148
168
  if (name === "search_corpus") {
149
169
  const q = String(args?.query ?? "");
150
170
  if (!q) throw new Error("query is required");
151
- const limit = Number(args?.limit ?? 10);
152
- const hits = corpus.documents
153
- .filter((d) => !args?.genre || d.genre === args.genre)
154
- .map((d) => ({ d, ex: excerpt(d.text, q) }))
155
- .filter((h) => h.ex !== null)
156
- .slice(0, limit)
157
- .map((h) => ({ ...envelope(h.d), genre: h.d.genre, excerpt: h.ex }));
158
- const readable = hits.length
159
- ? hits.map((h) => `${h.slug} ${h.title}\n ${h.excerpt}`).join("\n\n")
160
- : `no document matches "${q}".`;
161
- return structuredWithText(readable, { query: q, matches: hits.length, results: hits });
171
+ const pool = corpus.documents.filter((d) => !args?.genre || d.genre === args.genre);
172
+ const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
173
+ const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
174
+ ...envelope(h.d),
175
+ genre: h.d.genre,
176
+ excerpt: h.m.excerpt,
177
+ // HOW it matched, not just that it did: a "terms" excerpt need not
178
+ // contain the literal query, and a caller reading it should know
179
+ // which question the excerpt is answering.
180
+ match: h.m.mode
181
+ }));
182
+ // ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
183
+ // capped at `limit`, which made "10 matches" and "at least 10 matches"
184
+ // indistinguishable. `returned` carries the capped count.
185
+ const absent = hits.length ? [] : absentTerms(pool, q);
186
+ const readable = results.length
187
+ ? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n")
188
+ : absent.length
189
+ ? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
190
+ : `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
191
+ return structuredWithText(readable, {
192
+ query: q,
193
+ matches: hits.length,
194
+ returned: results.length,
195
+ ...(absent.length ? { absent_terms: absent } : {}),
196
+ results: results
197
+ });
162
198
  }
163
199
 
164
200
  if (name === "get_document") {