@333eco/corpus 2.4.0 → 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/README.md CHANGED
@@ -83,6 +83,18 @@ verbatim. **A zero result was recording the matcher's limits while being read as
83
83
  ⭐ On a zero result the response now names **which terms appear in no document at all**
84
84
  (`absent_terms`), so a dead end says something instead of nothing.
85
85
 
86
+ ⭐ **Diacritics are optional.** `metta` finds what `mettā` finds, and `Tonle Sap` reaches the
87
+ text that writes `Tonlé Sap` — before 2.4.1 a plain-ASCII query found 6 documents where the
88
+ marked one found 20. ⛔ **Fold to match, serve verbatim:** the fold touches only what is
89
+ compared, and every excerpt carries the corpus's own spelling, because a Pāli or Sanskrit
90
+ spelling is a tradition marker rather than a typo. Only Latin combining diacritics are folded;
91
+ Khmer and Burmese vowel signs are marks too, and are left exactly as written.
92
+
93
+ ⭐ **Every result says it is an excerpt.** The text ends with a line naming how many of the
94
+ matches it shows and the `read_documents` call that reads those documents in full. It sits in
95
+ the result rather than only in the server's instructions, because a result reaches the model in
96
+ every client, at the moment it decides whether to keep reading.
97
+
86
98
  ### Reading a whole shelf
87
99
 
88
100
  ⭐ **Search answers from excerpts, and a model answers from what it opened.** A frontier
@@ -103,6 +115,13 @@ corpus already fits one — but a way to read the shelf.
103
115
  split** — half a document verifies nothing — so one larger than the page arrives alone and
104
116
  says `over_budget`. Follow `next_cursor`; a cursor from a different corpus version is refused
105
117
  rather than serving page 2 of another corpus.
118
+ - **`list_documents` says how to read what it listed:** `read_with` carries the same filters as
119
+ `read_documents` arguments.
120
+
121
+ ⚠️ **The server can point; it cannot make a model read.** Its instructions say *for a broad
122
+ question, read the relevant shelf in full* — deliberately conditional, because a whole shelf read
123
+ to answer a one-fact question is slower, costlier and often worse. Clients differ in whether they
124
+ show server instructions at all, which is why the pointers also live in the results.
106
125
 
107
126
  ⭐ **Every document ends with an `[END OF DOCUMENT — <slug> · <n> words]` line**, by every
108
127
  route. The server never cuts a document, but a client may, and a cut document reads as a
package/dist/corpus.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "package_version": "2.4.0",
2
+ "package_version": "2.4.1",
3
3
  "generated_from": [
4
4
  "H3/publications",
5
5
  "TH/publications",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@333eco/corpus",
3
- "version": "2.4.0",
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",
@@ -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: {
@@ -65,9 +65,27 @@ export const READ_TOOLS = [
65
65
 
66
66
  export const READ_TOOL_NAMES = READ_TOOLS.map((t) => t.name);
67
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.
68
72
  export const READ_INSTRUCTIONS =
69
- " Search returns excerpts; to answer broadly, read whole documents with read_documents. Every document ends " +
70
- "with an [END OF DOCUMENT] line — if it is missing, the text was cut short.";
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;
71
89
 
72
90
  // ⚠️ The cursor carries the INDEX VERSION as well as the offset. Pages are offsets
73
91
  // into a list, and a list read across a redeploy is two lists: without the version
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
+ };
package/src/server.mjs CHANGED
@@ -40,9 +40,9 @@ import { documentText } from "./resources.mjs";
40
40
  import { envelope } from "./envelope.mjs";
41
41
  import { PROMPTS, getPrompt } from "./prompts.mjs";
42
42
  import { BASE_TOOLS, withFacets } from "./base-tools.mjs";
43
- import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, completenessOf, manifestProblems } from "./read-tools.mjs";
43
+ import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, readWith, completenessOf, manifestProblems } from "./read-tools.mjs";
44
44
  import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
45
- import { match, rank, absentTerms } from "./search.mjs";
45
+ import { searchCorpus } from "./search.mjs";
46
46
 
47
47
  const HERE = dirname(fileURLToPath(import.meta.url));
48
48
  const INDEX = resolve(HERE, "..", "dist", "corpus.json");
@@ -121,35 +121,9 @@ const text = (value) => ({ content: [{ type: "text", text: typeof value === "str
121
121
 
122
122
  const callTool = (name, args) => {
123
123
  if (name === "search_corpus") {
124
- const q = String(args?.query ?? "");
125
- if (!q) throw new Error("query is required");
126
- const pool = corpus.documents.filter((d) => !args?.genre || d.genre === args.genre);
127
- const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
128
- const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
129
- ...envelope(h.d),
130
- genre: h.d.genre,
131
- excerpt: h.m.excerpt,
132
- // ⭐ HOW it matched, not just that it did: a "terms" excerpt need not
133
- // contain the literal query, and a caller reading it should know
134
- // which question the excerpt is answering.
135
- match: h.m.mode
136
- }));
137
- // ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
138
- // capped at `limit`, which made "10 matches" and "at least 10 matches"
139
- // indistinguishable. `returned` carries the capped count.
140
- const absent = hits.length ? [] : absentTerms(pool, q);
141
- const readable = results.length
142
- ? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n")
143
- : absent.length
144
- ? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
145
- : `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
146
- return structuredWithText(readable, {
147
- query: q,
148
- matches: hits.length,
149
- returned: results.length,
150
- ...(absent.length ? { absent_terms: absent } : {}),
151
- results: results
152
- });
124
+ // Shared with the other surface (search.mjs): the answer, the excerpts and the
125
+ // reading line cannot differ between them.
126
+ return searchCorpus({ documents: corpus.documents, envelope }, args);
153
127
  }
154
128
 
155
129
  if (name === "get_document") {
@@ -233,6 +207,8 @@ const callTool = (name, args) => {
233
207
  ...(corpus.voices ? { voices: corpus.voices } : {}),
234
208
  // A87: the manifest's counts and reasons beside the listing they account for.
235
209
  ...(corpus.manifest ? { completeness: completenessOf(corpus) } : {}),
210
+ // 2.4.1: what to call to read this listing in full — the same filters, as arguments.
211
+ ...(readWith(args, list.length) ? { read_with: readWith(args, list.length) } : {}),
236
212
  documents: list
237
213
  });
238
214
  }