@333eco/corpus 1.2.5 → 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/src/server.mjs CHANGED
@@ -34,12 +34,44 @@ import { readFileSync, existsSync } from "node:fs";
34
34
  import { dirname, join, resolve } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { createInterface } from "node:readline";
37
+ import { RESOURCE_TEMPLATES, listResources, readResource, completeArgument } from "./resources.mjs";
38
+ import { structured, structuredWithText } from "./results.mjs";
39
+ import { provenanceHeader } from "./resources.mjs";
40
+ import { PROMPTS, getPrompt } from "./prompts.mjs";
41
+ import { BASE_TOOLS } from "./base-tools.mjs";
42
+ import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
43
+ import { match, rank, absentTerms } from "./search.mjs";
37
44
 
38
45
  const HERE = dirname(fileURLToPath(import.meta.url));
39
46
  const INDEX = resolve(HERE, "..", "dist", "corpus.json");
40
47
 
41
48
  const log = (...a) => console.error("[corpus-mcp]", ...a);
42
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
+
43
75
  if (!existsSync(INDEX)) {
44
76
  log("dist/corpus.json is missing. Build it: node scripts/build-index.mjs --from <corpus repos>");
45
77
  process.exit(1);
@@ -47,7 +79,13 @@ if (!existsSync(INDEX)) {
47
79
 
48
80
  const corpus = JSON.parse(readFileSync(INDEX, "utf8"));
49
81
  const bySlug = new Map(corpus.documents.map((d) => [d.slug, d]));
82
+ // The research program is optional: an index built over a corpus that does not
83
+ // contain it simply has no `program` block, and the three program tools are then
84
+ // not advertised at all. ⭐ An unadvertised tool is better than a tool that
85
+ // exists and always errors — a client can reason about the first.
86
+ const program = corpus.program ?? null;
50
87
  log(`${corpus.document_count} documents loaded —`, JSON.stringify(corpus.licences));
88
+ if (program) log(`research program: ${program.prediction_count} predictions, ${program.reconciliation.reconciles ? "register arithmetic reconciles" : "⚠️ REGISTER ARITHMETIC DOES NOT RECONCILE"}`);
51
89
 
52
90
  /* ------------------------------------------------------- protocol plumbing ---
53
91
  Versions this server knows how to speak, newest first. On initialize the spec
@@ -78,6 +116,22 @@ const envelope = (d) => ({
78
116
  },
79
117
  provenance: {
80
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
+ : {}),
81
135
  verify: {
82
136
  // Concrete, because an instruction that says "the source file"
83
137
  // without saying which one is not an instruction. All three source
@@ -103,76 +157,54 @@ const envelope = (d) => ({
103
157
 
104
158
  /* -------------------------------------------------------------------- tools --- */
105
159
 
106
- const TOOLS = [
107
- {
108
- name: "search_corpus",
109
- description:
110
- "Full-text search across the open-licensed corpus. Returns matching documents with a provenance envelope " +
111
- "and a short excerpt around each match — not the full text; call get_document for that. Every result can " +
112
- "be independently verified via its sha256, DOI and OpenTimestamps proof.",
113
- inputSchema: {
114
- type: "object",
115
- properties: {
116
- query: { type: "string", description: "Text to search for. Case-insensitive." },
117
- genre: { type: "string", description: "Optional: restrict to a genre, e.g. essays, defensive-publications, positions, white-papers." },
118
- limit: { type: "number", description: "Maximum documents to return. Default 10." }
119
- },
120
- required: ["query"]
121
- }
122
- },
123
- {
124
- name: "get_document",
125
- description:
126
- "Return one document in full, with its provenance envelope. The text is the canonical source — never a " +
127
- "summary — so its sha256 can be checked against the envelope and against the anchored proof.",
128
- inputSchema: {
129
- type: "object",
130
- properties: { slug: { type: "string", description: "Document slug, as returned by search_corpus or list_documents." } },
131
- required: ["slug"]
132
- }
133
- },
134
- {
135
- name: "list_documents",
136
- description:
137
- "List the corpus: slugs, titles, genres, licences and provenance summaries, without full text. Use to " +
138
- "orient before searching, or to enumerate what is available under a given licence.",
139
- inputSchema: {
140
- type: "object",
141
- properties: {
142
- genre: { type: "string", description: "Optional: restrict to a genre." },
143
- licence: { type: "string", description: "Optional: restrict to a licence id, e.g. CC0-1.0 or CC-BY-4.0." }
144
- }
145
- }
146
- }
147
- ];
160
+ const TOOLS = BASE_TOOLS;
161
+
162
+ const KNOWN_TOOLS = new Set([...BASE_TOOLS, ...PROGRAM_TOOLS].map((t) => t.name));
148
163
 
149
164
  const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
150
165
 
151
- const excerpt = (body, query, span = 320) => {
152
- const i = body.toLowerCase().indexOf(query.toLowerCase());
153
- if (i === -1) return null;
154
- const from = Math.max(0, i - span / 2);
155
- return (from > 0 ? "…" : "") + body.slice(from, from + span).trim() + (from + span < body.length ? "…" : "");
156
- };
157
166
 
158
167
  const callTool = (name, args) => {
159
168
  if (name === "search_corpus") {
160
169
  const q = String(args?.query ?? "");
161
170
  if (!q) throw new Error("query is required");
162
- const limit = Number(args?.limit ?? 10);
163
- const hits = corpus.documents
164
- .filter((d) => !args?.genre || d.genre === args.genre)
165
- .map((d) => ({ d, ex: excerpt(d.text, q) }))
166
- .filter((h) => h.ex !== null)
167
- .slice(0, limit)
168
- .map((h) => ({ ...envelope(h.d), genre: h.d.genre, excerpt: h.ex }));
169
- return text({ 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
+ });
170
198
  }
171
199
 
172
200
  if (name === "get_document") {
173
201
  const d = bySlug.get(String(args?.slug ?? ""));
174
202
  if (!d) throw new Error(`no document with slug "${args?.slug}". Call list_documents to see what is available.`);
175
- return text({
203
+ // ⭐ The document goes to `content` WITH ITS PROVENANCE HEADER — the same
204
+ // bytes resources/read returns, so the two doors into a document agree —
205
+ // and the envelope goes to `structuredContent` WITHOUT the body. Split by
206
+ // role; nothing is sent twice.
207
+ return structuredWithText(provenanceHeader(d) + "\n\n" + d.text, {
176
208
  ...envelope(d),
177
209
  genre: d.genre,
178
210
  repo: d.repo,
@@ -187,23 +219,42 @@ const callTool = (name, args) => {
187
219
  // convention look like an artefact of bad extraction.
188
220
  ...(d.editorial ? { editorial: d.editorial } : {}),
189
221
  ...(d.segments ? { segments: d.segments } : {}),
190
- text: d.text
222
+ // ⚠️ Deliberately absent: the body is in `content`. Carrying it here
223
+ // too is the duplication this shape exists to avoid.
224
+ text_in: "content[0].text, prefixed by the provenance header"
191
225
  });
192
226
  }
193
227
 
194
228
  if (name === "list_documents") {
195
229
  const list = corpus.documents
196
- .filter((d) => (!args?.genre || d.genre === args.genre) && (!args?.licence || d.licence.id === args.licence))
230
+ .filter((d) => (!args?.genre || d.genre === args.genre) &&
231
+ (!args?.licence || d.licence.id === args.licence) &&
232
+ (!args?.category || d.category === args.category))
197
233
  .map((d) => ({
198
234
  slug: d.slug,
199
235
  title: d.title,
200
236
  genre: d.genre,
237
+ // ⭐ Carried in the index since the first build and exposed by
238
+ // nothing until now. `institutional` is the four-body shelf,
239
+ // `mechanism` the how-it-works shelf — the corpus already had a
240
+ // topic taxonomy and no way to ask it a question.
241
+ category: d.category,
201
242
  date: d.date,
202
243
  licence: d.licence.id,
203
244
  doi: d.provenance.doi,
204
245
  opentimestamps: d.provenance.opentimestamps
205
246
  }));
206
- return text({ count: list.length, licences: corpus.licences, documents: list });
247
+ return structured({
248
+ count: list.length,
249
+ licences: corpus.licences,
250
+ // The shelves, so a caller can narrow without guessing the vocabulary.
251
+ categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
252
+ documents: list
253
+ });
254
+ }
255
+
256
+ if (PROGRAM_TOOL_NAMES.includes(name)) {
257
+ return callProgramTool({ program, bySlug, envelope }, name, args);
207
258
  }
208
259
 
209
260
  throw new Error(`unknown tool: ${name}`);
@@ -214,16 +265,43 @@ const callTool = (name, args) => {
214
265
  const handlers = {
215
266
  initialize: (params) => ({
216
267
  protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
217
- capabilities: { tools: {} },
218
- serverInfo: { name: "corpus.333.eco", version: "1.0.0" },
268
+ // ⚠️ Declared because they are implemented. `subscribe`/`listChanged` are
269
+ // deliberately absent: the corpus is fixed for the life of a build, so a
270
+ // subscription would be a promise to send notifications that can never fire.
271
+ capabilities: { tools: {}, resources: {}, completions: {}, prompts: {} },
272
+ serverInfo: { name: "corpus.333.eco", version: corpus.package_version ?? "0.0.0-unbuilt" },
219
273
  instructions:
220
274
  "An open-licensed corpus served with verifiable provenance. Every document carries a sha256, and most " +
221
275
  "carry a DOI and an OpenTimestamps proof anchored in Bitcoin, so you can check any passage you intend to " +
222
276
  "cite rather than trusting this server. Documents under CC-BY carry attribute_to in their licence block; " +
223
- "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified."
277
+ "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
278
+ (program ? PROGRAM_INSTRUCTIONS : "")
224
279
  }),
225
- "tools/list": () => ({ tools: TOOLS }),
226
- "tools/call": (params) => callTool(params?.name, params?.arguments)
280
+ "tools/list": () => ({ tools: program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS }),
281
+ "resources/list": (params) => listResources(corpus.documents, params?.cursor),
282
+ "resources/templates/list": () => ({ resourceTemplates: RESOURCE_TEMPLATES }),
283
+ "resources/read": (params) => readResource(params?.uri, bySlug),
284
+ "prompts/list": () => ({ prompts: PROMPTS }),
285
+ "prompts/get": (params) => getPrompt(params?.name, params?.arguments),
286
+ "completion/complete": (params) => completeArgument(params?.ref, params?.argument, corpus.documents),
287
+ // ⛔ TWO KINDS OF FAILURE, AND THEY ARE NOT THE SAME KIND. An unknown tool is a
288
+ // PROTOCOL error — the client asked for something that does not exist. A miss
289
+ // INSIDE a known tool ("no document with that slug") is a tool-execution error,
290
+ // and the spec puts those in the result with isError, not in a JSON-RPC error.
291
+ // ⭐ The distinction is load-bearing here specifically because our failure
292
+ // messages are GUIDANCE — "call list_documents to see what is available" — and
293
+ // a protocol error frequently never reaches the model as recoverable context.
294
+ // Returned as a result, the guidance is read and can be acted on; raised as
295
+ // -32603 it was written for a reader who mostly would not see it.
296
+ "tools/call": (params) => {
297
+ const name = params?.name;
298
+ if (!KNOWN_TOOLS.has(name)) throw new Error(`unknown tool: ${name}`);
299
+ try {
300
+ return callTool(name, params?.arguments);
301
+ } catch (e) {
302
+ return { content: [{ type: "text", text: e.message }], isError: true };
303
+ }
304
+ }
227
305
  };
228
306
 
229
307
  createInterface({ input: process.stdin }).on("line", (line) => {