@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/README.md +74 -17
- package/dist/corpus.json +1326 -147
- package/package.json +1 -1
- package/src/base-tools.mjs +10 -6
- package/src/envelope.mjs +64 -0
- package/src/read-tools.mjs +228 -0
- package/src/resources.mjs +16 -3
- package/src/search.mjs +92 -7
- package/src/server.mjs +41 -97
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@333eco/corpus",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/base-tools.mjs
CHANGED
|
@@ -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
|
|
9
|
-
//
|
|
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
|
|
31
|
-
"and a short excerpt around each match — not the full text;
|
|
32
|
-
"be
|
|
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",
|
package/src/envelope.mjs
ADDED
|
@@ -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 —
|
|
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,
|
|
149
|
-
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 =
|
|
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
|
|
142
|
-
const phrase =
|
|
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
|
|
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
|
+
};
|