@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/src/server.mjs CHANGED
@@ -36,11 +36,13 @@ import { fileURLToPath } from "node:url";
36
36
  import { createInterface } from "node:readline";
37
37
  import { RESOURCE_TEMPLATES, listResources, readResource, completeArgument } from "./resources.mjs";
38
38
  import { structured, structuredWithText } from "./results.mjs";
39
- import { provenanceHeader } from "./resources.mjs";
39
+ import { documentText } from "./resources.mjs";
40
+ import { envelope } from "./envelope.mjs";
40
41
  import { PROMPTS, getPrompt } from "./prompts.mjs";
41
42
  import { BASE_TOOLS, withFacets } from "./base-tools.mjs";
43
+ import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, readWith, completenessOf, manifestProblems } from "./read-tools.mjs";
42
44
  import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
43
- import { match, rank, absentTerms } from "./search.mjs";
45
+ import { searchCorpus } from "./search.mjs";
44
46
 
45
47
  const HERE = dirname(fileURLToPath(import.meta.url));
46
48
  const INDEX = resolve(HERE, "..", "dist", "corpus.json");
@@ -84,6 +86,15 @@ const bySlug = new Map(corpus.documents.map((d) => [d.slug, d]));
84
86
  // not advertised at all. ⭐ An unadvertised tool is better than a tool that
85
87
  // exists and always errors — a client can reason about the first.
86
88
  const program = corpus.program ?? null;
89
+ // ⛔ A CORPUS THAT DOES NOT MATCH ITS OWN MANIFEST IS NOT SERVED. Every envelope would
90
+ // still verify, which is exactly why a missing document could otherwise go unseen.
91
+ {
92
+ const problems = manifestProblems(corpus);
93
+ if (problems.length) {
94
+ log("dist/corpus.json does not match its manifest — refusing to serve:\n " + problems.join("\n "));
95
+ process.exit(1);
96
+ }
97
+ }
87
98
  log(`${corpus.document_count} documents loaded —`, JSON.stringify(corpus.licences));
88
99
  if (program) log(`research program: ${program.prediction_count} predictions, ${program.reconciliation.reconciles ? "register arithmetic reconciles" : "⚠️ REGISTER ARITHMETIC DOES NOT RECONCILE"}`);
89
100
 
@@ -98,103 +109,21 @@ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
98
109
  const result = (id, value) => send({ jsonrpc: "2.0", id, result: value });
99
110
  const failure = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
100
111
 
101
- /* ------------------------------------------------------------- the envelope ---
102
- Attached to every document a tool returns. `verify` is the instruction rather
103
- than a promise: it tells the agent exactly what to run, so the claim is
104
- checkable without trusting this sentence either. */
105
-
106
- const envelope = (d) => ({
107
- slug: d.slug,
108
- title: d.title,
109
- licence: {
110
- id: d.licence.id,
111
- url: d.licence.url,
112
- attribution_required: d.licence.attribution_required,
113
- // Only present where it is actually owed, so an agent can act on the
114
- // field's presence rather than parsing the licence id.
115
- ...(d.licence.attribution_required && d.authors ? { attribute_to: d.authors } : {})
116
- },
117
- provenance: {
118
- ...d.provenance,
119
- // ⚠️ A LIVING DOCUMENT IS VERIFIED AND CITED WITH DIFFERENT DOIs, and
120
- // saying only "cite accordingly" leaves the reader to guess which.
121
- // The VERSION doi is the only one that can be true of the bytes here
122
- // — it pins them — so it is what a hash check resolves against. The
123
- // CONCEPT doi follows the document, so it is what a citation should
124
- // name: a living register is revised on purpose, and a citation
125
- // pinned to one revision goes stale by design rather than by accident.
126
- ...(d.status === "living" && d.provenance.concept_doi
127
- ? {
128
- citation: {
129
- cite: `https://doi.org/${d.provenance.concept_doi}`,
130
- verify_against: d.provenance.doi ? `https://doi.org/${d.provenance.doi}` : null,
131
- 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."
132
- }
133
- }
134
- : {}),
135
- verify: {
136
- // Concrete, because an instruction that says "the source file"
137
- // without saying which one is not an instruction. All three source
138
- // repositories are public, so this is genuinely runnable.
139
- sha256: d.provenance.source_url
140
- ? `curl -sL ${d.provenance.source_url} | shasum -a 256 # compare to provenance.sha256`
141
- : "shasum -a 256 <the source file> # compare to provenance.sha256",
142
- ...(d.provenance.doi ? { doi: `https://doi.org/${d.provenance.doi}` } : {}),
143
- ...(d.provenance.opentimestamps
144
- ? { opentimestamps: `ots verify ${d.path}.ots # in the source repository; the proof is anchored in the Bitcoin blockchain` }
145
- : {}),
146
- ...(d.provenance.deposited_matches_current === false
147
- ? {
148
- note:
149
- "This document has been REVISED since its Zenodo deposit, so provenance.sha256 " +
150
- "and provenance.deposited_sha256 differ legitimately. The DOI resolves to the " +
151
- "deposited version; the text served here is newer. Cite accordingly."
152
- }
153
- : {})
154
- }
155
- }
156
- });
157
112
 
158
113
  /* -------------------------------------------------------------------- tools --- */
159
114
 
160
- const TOOLS = BASE_TOOLS;
115
+ const TOOLS = [...BASE_TOOLS, ...READ_TOOLS];
161
116
 
162
- const KNOWN_TOOLS = new Set([...BASE_TOOLS, ...PROGRAM_TOOLS].map((t) => t.name));
117
+ const KNOWN_TOOLS = new Set([...TOOLS, ...PROGRAM_TOOLS].map((t) => t.name));
163
118
 
164
119
  const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
165
120
 
166
121
 
167
122
  const callTool = (name, args) => {
168
123
  if (name === "search_corpus") {
169
- const q = String(args?.query ?? "");
170
- if (!q) throw new Error("query is required");
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
- });
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);
198
127
  }
199
128
 
200
129
  if (name === "get_document") {
@@ -204,9 +133,11 @@ const callTool = (name, args) => {
204
133
  // bytes resources/read returns, so the two doors into a document agree —
205
134
  // and the envelope goes to `structuredContent` WITHOUT the body. Split by
206
135
  // role; nothing is sent twice.
207
- return structuredWithText(provenanceHeader(d) + "\n\n" + d.text, {
136
+ return structuredWithText(documentText(d), {
208
137
  ...envelope(d),
209
138
  genre: d.genre,
139
+ bytes: d.bytes,
140
+ words: d.words,
210
141
  // ⚠️ category and voice were in list_documents and NOT here, so a
211
142
  // caller who fetched ONE document could not see the facets it had
212
143
  // just filtered on — the discovery call and the read call disagreed
@@ -229,16 +160,18 @@ const callTool = (name, args) => {
229
160
  ...(d.segments ? { segments: d.segments } : {}),
230
161
  // ⚠️ Deliberately absent: the body is in `content`. Carrying it here
231
162
  // too is the duplication this shape exists to avoid.
232
- text_in: "content[0].text, prefixed by the provenance header"
163
+ text_in: "content[0].text, prefixed by the provenance header and ending at its [END OF DOCUMENT] line"
233
164
  });
234
165
  }
235
166
 
167
+ if (name === "read_documents") {
168
+ return readDocuments({ documents: corpus.documents, bySlug, envelope, version: String(corpus.package_version ?? "") }, args);
169
+ }
170
+
236
171
  if (name === "list_documents") {
237
- const list = corpus.documents
238
- .filter((d) => (!args?.genre || d.genre === args.genre) &&
239
- (!args?.licence || d.licence.id === args.licence) &&
240
- (!args?.voice || d.voice === args.voice) &&
241
- (!args?.category || d.category === args.category))
172
+ // ⭐ The SAME filter read_documents applies (read-tools.mjs), so "read what I
173
+ // just listed" cannot return a different set.
174
+ const list = filterDocuments(corpus.documents, args)
242
175
  .map((d) => ({
243
176
  slug: d.slug,
244
177
  title: d.title,
@@ -255,10 +188,16 @@ const callTool = (name, args) => {
255
188
  date: d.date,
256
189
  licence: d.licence.id,
257
190
  doi: d.provenance.doi,
258
- opentimestamps: d.provenance.opentimestamps
191
+ opentimestamps: d.provenance.opentimestamps,
192
+ // A140: size before reading. Bytes and words, never tokens.
193
+ bytes: d.bytes,
194
+ words: d.words
259
195
  }));
260
196
  return structured({
261
197
  count: list.length,
198
+ // For THIS filtered set — what reading it with read_documents would cost.
199
+ total_bytes: list.reduce((n, d) => n + (d.bytes ?? 0), 0),
200
+ total_words: list.reduce((n, d) => n + (d.words ?? 0), 0),
262
201
  licences: corpus.licences,
263
202
  // The shelves, so a caller can narrow without guessing the vocabulary.
264
203
  categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
@@ -266,6 +205,10 @@ const callTool = (name, args) => {
266
205
  // documents means nothing without the honest denominator beside it,
267
206
  // the same reason list_predictions returns by_state unfiltered.
268
207
  ...(corpus.voices ? { voices: corpus.voices } : {}),
208
+ // A87: the manifest's counts and reasons beside the listing they account for.
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) } : {}),
269
212
  documents: list
270
213
  });
271
214
  }
@@ -292,6 +235,7 @@ const handlers = {
292
235
  "carry a DOI and an OpenTimestamps proof anchored in Bitcoin, so you can check any passage you intend to " +
293
236
  "cite rather than trusting this server. Documents under CC-BY carry attribute_to in their licence block; " +
294
237
  "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
238
+ READ_INSTRUCTIONS +
295
239
  (program ? PROGRAM_INSTRUCTIONS : "")
296
240
  }),
297
241
  // ⭐ withFacets fills list_documents' facet enumerations FROM THE CORPUS.