@333eco/corpus 2.3.5 → 2.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@333eco/corpus",
3
- "version": "2.3.5",
3
+ "version": "2.4.1",
4
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",
@@ -5,8 +5,8 @@
5
5
  // closes over it) and those functions are genuinely different code. What must
6
6
  // never differ is what the two servers ADVERTISE — a client picks a tool by name
7
7
  // and schema, so a drifted definition is a client calling something that isn't
8
- // there. The envelope stays duplicated on purpose; that one is twenty lines and
9
- // diffable by eye.
8
+ // there. ⚠️ The envelope was once duplicated on purpose, "diffable by eye"; it drifted
9
+ // unseen, and now lives in envelope.mjs, imported by both.
10
10
 
11
11
  // ⭐⭐ EVERY TOOL HERE IS READ-ONLY, AND SAYING SO IS NOT DECORATION. A client that
12
12
  // knows a call cannot mutate anything can stop putting a confirmation dialog in
@@ -27,9 +27,9 @@ export const BASE_TOOLS = [
27
27
  title: "Search the corpus",
28
28
  annotations: { ...READ_ONLY, title: "Search the corpus" },
29
29
  description:
30
- "Full-text search across the open-licensed corpus. Returns matching documents with a provenance envelope " +
31
- "and a short excerpt around each match — not the full text; call get_document for that. Every result can " +
32
- "be independently verified via its sha256, DOI and OpenTimestamps proof.",
30
+ "Full-text search across the open-licensed corpus; diacritics are optional. Returns matching documents with " +
31
+ "a provenance envelope and a short excerpt around each match — not the full text; read_documents or " +
32
+ "get_document return that. Every result can be verified via its sha256, DOI and OpenTimestamps proof.",
33
33
  inputSchema: {
34
34
  type: "object",
35
35
  properties: {
@@ -51,7 +51,11 @@ export const BASE_TOOLS = [
51
51
  type: "object",
52
52
  properties: { slug: { type: "string", description: "Document slug, as returned by search_corpus or list_documents." } },
53
53
  required: ["slug"]
54
- }
54
+ },
55
+ // ⚠️ The largest document is ~140 KB — over the 25,000-token default at which
56
+ // Claude Code moves a tool result into a file. A document is the unit that
57
+ // verifies, so it is kept whole in the conversation.
58
+ _meta: { "anthropic/maxResultSizeChars": 250_000 }
55
59
  },
56
60
  {
57
61
  name: "list_documents",
@@ -0,0 +1,64 @@
1
+ // The provenance envelope — attached to every document a tool returns, on both surfaces.
2
+ //
3
+ // `verify` is the instruction rather than a promise: it tells the agent exactly what
4
+ // to run, so the claim is checkable without trusting this sentence either.
5
+ //
6
+ // ⚠️⚠️ IT WAS WRITTEN TWICE, ON PURPOSE, AND THE PURPOSE FAILED. Each server kept its own
7
+ // copy "so the two can be diffed by eye; if they ever disagree, that is a bug in one of
8
+ // them and the diff is where it shows." They did disagree — the OpenTimestamps
9
+ // instruction and the revised-since-deposit note had different wording on the two
10
+ // surfaces — and no eye caught it. The first check that COMPARED THE ANSWERS did, on its
11
+ // first run (2026-09-13). A rule that depends on someone looking is replaced by one
12
+ // function both surfaces import: the difference can no longer be written.
13
+
14
+ export const envelope = (d) => ({
15
+ slug: d.slug,
16
+ title: d.title,
17
+ licence: {
18
+ id: d.licence.id,
19
+ url: d.licence.url,
20
+ attribution_required: d.licence.attribution_required,
21
+ // Only present where it is actually owed, so an agent can act on the
22
+ // field's presence rather than parsing the licence id.
23
+ ...(d.licence.attribution_required && d.authors ? { attribute_to: d.authors } : {})
24
+ },
25
+ provenance: {
26
+ ...d.provenance,
27
+ // ⚠️ A LIVING DOCUMENT IS VERIFIED AND CITED WITH DIFFERENT DOIs, and
28
+ // saying only "cite accordingly" leaves the reader to guess which.
29
+ // The VERSION doi is the only one that can be true of the bytes here
30
+ // — it pins them — so it is what a hash check resolves against. The
31
+ // CONCEPT doi follows the document, so it is what a citation should
32
+ // name: a living register is revised on purpose, and a citation
33
+ // pinned to one revision goes stale by design rather than by accident.
34
+ ...(d.status === "living" && d.provenance.concept_doi
35
+ ? {
36
+ citation: {
37
+ cite: `https://doi.org/${d.provenance.concept_doi}`,
38
+ verify_against: d.provenance.doi ? `https://doi.org/${d.provenance.doi}` : null,
39
+ why: "This document is living — it is revised on purpose. Cite the concept DOI, which always resolves to the newest version; verify the text you were served against the version DOI and sha256 above, which pin these exact bytes."
40
+ }
41
+ }
42
+ : {}),
43
+ verify: {
44
+ // Concrete, because an instruction that says "the source file"
45
+ // without saying which one is not an instruction. All three source
46
+ // repositories are public, so this is genuinely runnable.
47
+ sha256: d.provenance.source_url
48
+ ? `curl -sL ${d.provenance.source_url} | shasum -a 256 # compare to provenance.sha256`
49
+ : "shasum -a 256 <the source file> # compare to provenance.sha256",
50
+ ...(d.provenance.doi ? { doi: `https://doi.org/${d.provenance.doi}` } : {}),
51
+ ...(d.provenance.opentimestamps
52
+ ? { opentimestamps: `ots verify ${d.path}.ots # in the source repository; the proof is anchored in the Bitcoin blockchain` }
53
+ : {}),
54
+ ...(d.provenance.deposited_matches_current === false
55
+ ? {
56
+ note:
57
+ "This document has been REVISED since its Zenodo deposit, so provenance.sha256 " +
58
+ "and provenance.deposited_sha256 differ legitimately. The DOI resolves to the " +
59
+ "deposited version; the text served here is newer. Cite accordingly."
60
+ }
61
+ : {})
62
+ }
63
+ }
64
+ });
@@ -0,0 +1,228 @@
1
+ // Reading more than one document at a time, and knowing that nothing is missing —
2
+ // shared by both surfaces.
3
+ //
4
+ // ⭐ WHY THIS EXISTS (A140). A model connected to this server gave answers that were
5
+ // "quite accurate but incomplete": search returns excerpts, ten by default, and a
6
+ // model answers from the few documents it happens to open. The window was not the
7
+ // limit — a shelf of the corpus fits — the reading pattern was. So a caller can now
8
+ // ask for a whole slice and see its size first.
9
+ //
10
+ // ⭐ Imported by both surfaces, like the program tools and the envelope: the filter
11
+ // here must be the SAME filter list_documents applies, or
12
+ // "read what I just listed" returns a different set. One function makes that true
13
+ // by construction instead of by vigilance.
14
+
15
+ import { READ_ONLY } from "./base-tools.mjs";
16
+ import { documentText } from "./resources.mjs";
17
+
18
+ // ~22k tokens of English prose: under the 25,000-token default at which Claude Code
19
+ // moves a tool result out of the conversation and into a file. Other clients cap
20
+ // differently; the caller can ask for more.
21
+ export const DEFAULT_PAGE_BYTES = 90_000;
22
+ export const MAX_PAGE_BYTES = 400_000;
23
+
24
+ const byteLength = (s) => new TextEncoder().encode(s).length;
25
+
26
+ /** The list_documents filter. Every facet is optional; absent means "any". */
27
+ export const filterDocuments = (documents, args) =>
28
+ documents.filter(
29
+ (d) =>
30
+ (!args?.genre || d.genre === args.genre) &&
31
+ (!args?.licence || d.licence.id === args.licence) &&
32
+ (!args?.voice || d.voice === args.voice) &&
33
+ (!args?.category || d.category === args.category)
34
+ );
35
+
36
+ export const READ_TOOLS = [
37
+ {
38
+ name: "read_documents",
39
+ title: "Read a set of documents in full",
40
+ annotations: { ...READ_ONLY, title: "Read a set of documents in full" },
41
+ // ⚠️ KEPT SHORT ON PURPOSE: every client that loads tool definitions up front pays
42
+ // for this text in every session, whether or not the corpus is used. The filter
43
+ // facets are described once, on list_documents.
44
+ description:
45
+ "Whole documents, never summarised: those matching the list_documents filters, or the given slugs. " +
46
+ "One content block each. Paged: follow next_cursor.",
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ genre: { type: "string" },
51
+ category: { type: "string" },
52
+ voice: { type: "string" },
53
+ licence: { type: "string" },
54
+ slugs: { type: "array", items: { type: "string" } },
55
+ max_bytes: { type: "number", description: `Page size. Default ${DEFAULT_PAGE_BYTES}, max ${MAX_PAGE_BYTES}.` },
56
+ cursor: { type: "string" }
57
+ }
58
+ },
59
+ // ⚠️ Claude Code moves a tool result over its limit (25,000 tokens by default)
60
+ // into a file. A page is sized by the CALLER, so the tool declares the
61
+ // largest page it can return and the client keeps it in the conversation.
62
+ _meta: { "anthropic/maxResultSizeChars": MAX_PAGE_BYTES + 100_000 }
63
+ }
64
+ ];
65
+
66
+ export const READ_TOOL_NAMES = READ_TOOLS.map((t) => t.name);
67
+
68
+ // ⚠️ CONDITIONAL ON PURPOSE (2.4.1). "Prefer read_documents" unqualified would send a model to
69
+ // read a 260,000-token shelf to answer a one-fact question — slower, costlier and often worse.
70
+ // ⛔ It names shelves by their facets (list_documents' own vocabulary), never by what the
71
+ // documents say: describing the API, not the subject matter.
72
+ export const READ_INSTRUCTIONS =
73
+ " For a broad question, read the relevant shelf in full: choose it with list_documents, read it with " +
74
+ "read_documents. Search returns excerpts, suited to finding one passage. Every document ends with an " +
75
+ "[END OF DOCUMENT] line; if it is missing, the text was cut short.";
76
+
77
+ /**
78
+ * list_documents' pointer to reading what it just listed: the same filters, as arguments
79
+ * read_documents accepts. Only the facets actually given, so the pointer reads back exactly.
80
+ */
81
+ export const readWith = (args, count) =>
82
+ count
83
+ ? {
84
+ tool: "read_documents",
85
+ arguments: Object.fromEntries(["genre", "category", "voice", "licence"].filter((k) => args?.[k]).map((k) => [k, args[k]])),
86
+ note: "Reads every document listed here in full, paged by size."
87
+ }
88
+ : null;
89
+
90
+ // ⚠️ The cursor carries the INDEX VERSION as well as the offset. Pages are offsets
91
+ // into a list, and a list read across a redeploy is two lists: without the version
92
+ // a caller could be handed page 2 of a different corpus and never know.
93
+ const encodeCursor = (version, offset) => btoa(`${version}:${offset}`);
94
+ const decodeCursor = (cursor, version) => {
95
+ let raw;
96
+ try {
97
+ raw = atob(String(cursor));
98
+ } catch {
99
+ throw new Error(`invalid cursor: ${cursor}`);
100
+ }
101
+ const at = raw.lastIndexOf(":");
102
+ const v = raw.slice(0, at);
103
+ const offset = Number(raw.slice(at + 1));
104
+ if (at < 0 || !Number.isInteger(offset) || offset < 0) throw new Error(`invalid cursor: ${cursor}`);
105
+ if (v !== version) {
106
+ throw new Error(`this cursor belongs to corpus ${v}, and the server now serves ${version}. Start again without a cursor.`);
107
+ }
108
+ return offset;
109
+ };
110
+
111
+ /**
112
+ * read_documents. `ctx` is what differs between the surfaces:
113
+ * { documents, bySlug, envelope, version }.
114
+ */
115
+ export const readDocuments = ({ documents, bySlug, envelope, version }, args) => {
116
+ const hasFilter = ["genre", "category", "voice", "licence"].some((k) => args?.[k]);
117
+ let matched;
118
+ if (args?.slugs !== undefined) {
119
+ if (hasFilter) throw new Error("give slugs or filters, not both.");
120
+ if (!Array.isArray(args.slugs) || !args.slugs.length) throw new Error("slugs must be a non-empty array of document slugs.");
121
+ const slugs = [...new Set(args.slugs.map(String))];
122
+ const unknown = slugs.filter((s) => !bySlug.has(s));
123
+ if (unknown.length) throw new Error(`no document with slug ${unknown.map((s) => `"${s}"`).join(", ")}. Call list_documents to see what is available.`);
124
+ matched = slugs.map((s) => bySlug.get(s));
125
+ } else {
126
+ matched = filterDocuments(documents, args);
127
+ }
128
+
129
+ const budget = args?.max_bytes === undefined ? DEFAULT_PAGE_BYTES : Number(args.max_bytes);
130
+ if (!Number.isFinite(budget) || budget <= 0) throw new Error("max_bytes must be a positive number.");
131
+ if (budget > MAX_PAGE_BYTES) throw new Error(`max_bytes may be at most ${MAX_PAGE_BYTES}; follow next_cursor for the rest.`);
132
+
133
+ const start = args?.cursor ? decodeCursor(args.cursor, version) : 0;
134
+ if (start > matched.length) throw new Error(`invalid cursor: ${args.cursor}`);
135
+
136
+ // Whole documents only. ⛔ A document is never split across pages: its hash, its
137
+ // header and its end marker describe the whole, and half of one verifies nothing.
138
+ // So a document larger than the budget arrives alone, over budget, and says so.
139
+ const page = [];
140
+ let used = 0;
141
+ for (let i = start; i < matched.length; i++) {
142
+ const text = documentText(matched[i]);
143
+ const size = byteLength(text);
144
+ if (page.length && used + size > budget) break;
145
+ page.push({ d: matched[i], text, size });
146
+ used += size;
147
+ }
148
+ const nextOffset = start + page.length;
149
+ const next = nextOffset < matched.length ? encodeCursor(version, nextOffset) : undefined;
150
+
151
+ if (!matched.length) {
152
+ return {
153
+ content: [{ type: "text", text: "no documents match these filters. Call list_documents to see the facets and their counts." }],
154
+ structuredContent: { matched: 0, returned: 0, offset: 0, page_bytes: 0, max_bytes: budget, documents: [] }
155
+ };
156
+ }
157
+
158
+ const content = page.map((p) => ({ type: "text", text: p.text }));
159
+ // The one block that is not a document: what a reader of `content` alone needs in
160
+ // order to keep going, or to know it has everything.
161
+ content.push({
162
+ type: "text",
163
+ text: next
164
+ ? `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}. ${matched.length - nextOffset} more: call read_documents again with the same arguments and cursor "${next}".]`
165
+ : `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}. That is every matching document.]`
166
+ });
167
+
168
+ return {
169
+ content,
170
+ structuredContent: {
171
+ matched: matched.length,
172
+ returned: page.length,
173
+ offset: start,
174
+ page_bytes: used,
175
+ max_bytes: budget,
176
+ ...(page.length === 1 && used > budget ? { over_budget: "one document larger than max_bytes, returned whole rather than split" } : {}),
177
+ ...(next ? { next_cursor: next } : {}),
178
+ documents: page.map((p) => ({
179
+ ...envelope(p.d),
180
+ genre: p.d.genre,
181
+ category: p.d.category,
182
+ voice: p.d.voice,
183
+ bytes: p.d.bytes,
184
+ words: p.d.words,
185
+ text_in: "content, one block per document in this order, each prefixed by its provenance header"
186
+ }))
187
+ }
188
+ };
189
+ };
190
+
191
+ /**
192
+ * list_documents' completeness block: the manifest's counts and the reasons, so the
193
+ * listing a caller already has is checkable against what the build considered.
194
+ */
195
+ export const completenessOf = (corpus) =>
196
+ corpus.manifest
197
+ ? {
198
+ candidates: corpus.manifest.candidates,
199
+ served: corpus.manifest.served,
200
+ excluded: corpus.manifest.excluded_documents,
201
+ held: corpus.manifest.held_documents,
202
+ note: "An unfiltered listing should name every served document; the full manifest is at https://corpus.333.eco/manifest.json."
203
+ }
204
+ : null;
205
+
206
+ /**
207
+ * ⛔ What both surfaces check before serving anything: do the documents loaded match
208
+ * the manifest the build wrote? Returns the problems; empty means serve.
209
+ * ⚠️ An index built before the manifest existed has none, and that is not refused —
210
+ * it is the previous release, not a damaged one.
211
+ */
212
+ export const manifestProblems = (corpus) => {
213
+ const m = corpus.manifest;
214
+ if (!m) return [];
215
+ const problems = [];
216
+ if (m.candidates !== m.served + m.excluded + m.held) {
217
+ problems.push(`manifest does not reconcile: ${m.candidates} candidates vs ${m.served} + ${m.excluded} + ${m.held}`);
218
+ }
219
+ if (corpus.documents.length !== m.served) {
220
+ problems.push(`${corpus.documents.length} documents loaded, manifest names ${m.served}`);
221
+ }
222
+ const loaded = new Map(corpus.documents.map((d) => [d.slug, d.provenance?.sha256]));
223
+ for (const s of m.served_documents ?? []) {
224
+ if (!loaded.has(s.slug)) problems.push(`manifest names "${s.slug}", which is not loaded`);
225
+ else if (loaded.get(s.slug) !== s.sha256) problems.push(`"${s.slug}" does not carry the sha256 the manifest records`);
226
+ }
227
+ return problems;
228
+ };
package/src/resources.mjs CHANGED
@@ -128,10 +128,23 @@ export const provenanceHeader = (d) => {
128
128
  if (d.editorial?.annotation) {
129
129
  L.push(`editorial: ${d.editorial.annotation.replace(/\s+/g, " ")}`);
130
130
  }
131
- L.push("[END PROVENANCE — everything below this line is the document, verbatim.]");
131
+ L.push("[END PROVENANCE — the document follows verbatim and ends at its [END OF DOCUMENT] line.]");
132
132
  return L.join("\n");
133
133
  };
134
134
 
135
+ // ⭐⭐ THE ELISION MARKER (A87, the peyyāla). The server never cuts a document — but a
136
+ // client may, and a cut document reads as a complete one: its last sentence is a real
137
+ // sentence. So the end is stated IN THE TEXT, where a cut removes it, rather than in
138
+ // a field that survives the cut and asserts a completeness the text no longer has.
139
+ // ⚠️ Like the header, it is NOT part of the hashed artifact; the hash covers the source.
140
+ export const endMarker = (d) =>
141
+ `[END OF DOCUMENT — ${d.slug}${typeof d.words === "number" ? ` · ${d.words} words` : ""}. ` +
142
+ "If this line is missing, the text above was cut short after it left the server.]";
143
+
144
+ // ONE function for every door into a document — get_document, resources/read and
145
+ // read_documents — so three ways in cannot become three different texts.
146
+ export const documentText = (d) => provenanceHeader(d) + "\n\n" + d.text + "\n\n" + endMarker(d);
147
+
135
148
  export const readResource = (uri, bySlug) => {
136
149
  const raw = String(uri ?? "");
137
150
  if (!raw.startsWith(CORPUS_SCHEME)) {
@@ -145,8 +158,8 @@ export const readResource = (uri, bySlug) => {
145
158
  {
146
159
  uri: raw,
147
160
  mimeType: mime(d),
148
- // Header, blank line, then the document exactly as the tools return it.
149
- text: provenanceHeader(d) + "\n\n" + d.text
161
+ // Header, the document, the end marker — exactly as the tools return it.
162
+ text: documentText(d)
150
163
  }
151
164
  ]
152
165
  };
package/src/search.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  // How `search_corpus` decides a document matches.
2
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
3
+ // ⭐⭐ SHARED, AND IT HAS TO BE — matching and, since 2.4.1, the whole search_corpus
4
+ // tool (see searchCorpus below). Matching is ranking, tokenising and window-finding, and
5
+ // if the stdio server and the worker
6
6
  // ever disagreed about which documents answer a query, the corpus would have two
7
7
  // opinions about itself. That is the same failure `sync.mjs` exists to prevent,
8
8
  // arriving through the search path instead of the data path.
@@ -33,15 +33,49 @@
33
33
  // any document containing "b" and "heart" separately — precision traded away for
34
34
  // nothing, in the corpus where those marks matter most.
35
35
 
36
+ import { structuredWithText } from "./results.mjs";
37
+
36
38
  const TOKEN = /[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu;
37
39
 
40
+ // ⭐⭐ DIACRITICS ARE OPTIONAL IN A QUERY (2.4.1). A model types `metta`; the corpus writes
41
+ // `mettā`. Without a fold that search found 6 documents against 20, and `Tonle Sap` found none
42
+ // — a reader typing plain ASCII, which is most of them, silently missed most of the Pāli
43
+ // material. ⛔ FOLD TO MATCH, SERVE VERBATIM: the spelling is a tradition marker (Pāli for a
44
+ // Theravāda citation, Sanskrit for a Hindu one), so the fold touches only what is COMPARED —
45
+ // every excerpt is cut from the original text.
46
+ // ⚠️ LENGTH-PRESERVING, character by character, so a position in the folded text is the same
47
+ // position in the original and the excerpt window lands where the match is. Only Latin
48
+ // combining diacritics (U+0300–U+036F) are removed, and only where the base letter survives
49
+ // as one character: Khmer and Burmese vowel signs are marks too, and stripping them would
50
+ // make Khmer text match what it does not say.
51
+ const LATIN_MARKS = /[\u0300-\u036f]/g;
52
+ const foldChar = (c) => {
53
+ const f = c.normalize("NFD").replace(LATIN_MARKS, "");
54
+ return f.length === c.length ? f : c;
55
+ };
56
+ export const fold = (s) => String(s ?? "").replace(/[^\x00-\x7f]/gu, foldChar);
57
+
58
+ // The folded, lowercased body, computed once per document text rather than on every search.
59
+ // Keyed by the string itself: the corpus holds one string per document for the life of the
60
+ // process, so the cache is bounded by the corpus.
61
+ const HAY = new Map();
62
+ const hayOf = (body) => {
63
+ let h = HAY.get(body);
64
+ if (h === undefined) {
65
+ h = fold(body.toLowerCase());
66
+ HAY.set(body, h);
67
+ }
68
+ return h;
69
+ };
70
+ const foldQuery = (query) => fold(String(query ?? "").normalize("NFC").toLowerCase());
71
+
38
72
  // Dropped only when something survives the dropping. These are words present in
39
73
  // essentially every document, so they never narrow the result set — but they do
40
74
  // wreck the tightest-window calculation, because an "and" is always close by.
41
75
  const STOP = new Set(["a", "an", "and", "as", "at", "be", "by", "for", "from", "in", "is", "it", "of", "on", "or", "the", "to", "with"]);
42
76
 
43
77
  export const terms = (query) => {
44
- const all = String(query ?? "").toLowerCase().match(TOKEN) ?? [];
78
+ const all = foldQuery(query).match(TOKEN) ?? [];
45
79
  const kept = all.filter((t) => !STOP.has(t));
46
80
  return kept.length ? kept : all;
47
81
  };
@@ -138,8 +172,8 @@ const tightestWindow = (lists) => {
138
172
  // term match's excerpt may NOT contain the literal query, and a caller reading
139
173
  // an excerpt deserves to know which question it answered.
140
174
  export const match = (body, query, span = 320) => {
141
- const hay = body.toLowerCase();
142
- const phrase = String(query ?? "").toLowerCase().trim();
175
+ const hay = hayOf(body);
176
+ const phrase = foldQuery(query).trim();
143
177
 
144
178
  if (phrase) {
145
179
  const i = hay.indexOf(phrase);
@@ -175,6 +209,57 @@ export const rank = (hits, limit) =>
175
209
  export const absentTerms = (documents, query) => {
176
210
  const ts = [...new Set(terms(query))];
177
211
  if (ts.length === 0) return [];
178
- const hays = documents.map((d) => d.text.toLowerCase());
212
+ const hays = documents.map((d) => hayOf(d.text));
179
213
  return ts.filter((t) => !hays.some((h) => h.includes(t)));
180
214
  };
215
+
216
+ /* ---------------------------------------------------------- search_corpus ---
217
+ ⭐ THE WHOLE TOOL, SHARED (2.4.1). It lived twice, once in each surface's callTool, and
218
+ its guidance line was about to be written twice too — the shape in which the envelope
219
+ drifted. One function, imported by both. */
220
+
221
+ // A search returns EXCERPTS, and a model that reads only excerpts answers from them. So the
222
+ // text a model reads says so at the moment it decides what to do next — in the result, where
223
+ // every client delivers it, rather than only in server instructions a client may drop.
224
+ const readingLine = (results, matches) => {
225
+ const slugs = results.map((r) => r.slug);
226
+ return (
227
+ `[Showing ${results.length} of ${matches} matching document${matches === 1 ? "" : "s"}, as excerpts. ` +
228
+ `To answer from the full texts, call read_documents with slugs ${JSON.stringify(slugs)}` +
229
+ (matches > results.length ? `; raise limit to see more of the ${matches} matches` : "") +
230
+ ".]"
231
+ );
232
+ };
233
+
234
+ export const searchCorpus = ({ documents, envelope }, args) => {
235
+ const q = String(args?.query ?? "");
236
+ if (!q) throw new Error("query is required");
237
+ const pool = documents.filter((d) => !args?.genre || d.genre === args.genre);
238
+ const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
239
+ const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
240
+ ...envelope(h.d),
241
+ genre: h.d.genre,
242
+ excerpt: h.m.excerpt,
243
+ // ⭐ HOW it matched, not just that it did: a "terms" excerpt need not
244
+ // contain the literal query, and a caller reading it should know
245
+ // which question the excerpt is answering.
246
+ match: h.m.mode
247
+ }));
248
+ // ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
249
+ // capped at `limit`, which made "10 matches" and "at least 10 matches"
250
+ // indistinguishable. `returned` carries the capped count.
251
+ const absent = hits.length ? [] : absentTerms(pool, q);
252
+ const readable = results.length
253
+ ? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n") +
254
+ "\n\n" + readingLine(results, hits.length)
255
+ : absent.length
256
+ ? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
257
+ : `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
258
+ return structuredWithText(readable, {
259
+ query: q,
260
+ matches: hits.length,
261
+ returned: results.length,
262
+ ...(absent.length ? { absent_terms: absent } : {}),
263
+ results: results
264
+ });
265
+ };