@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/README.md +184 -0
- package/dist/corpus.json +1420 -127
- package/package.json +3 -2
- package/src/base-tools.mjs +72 -0
- package/src/program-tools.mjs +181 -0
- package/src/prompts.mjs +134 -0
- package/src/report.mjs +104 -0
- package/src/resources.mjs +215 -0
- package/src/results.mjs +40 -0
- package/src/search.mjs +176 -0
- package/src/server.mjs +143 -65
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
|
-
|
|
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
|
|
163
|
-
const hits =
|
|
164
|
-
|
|
165
|
-
.
|
|
166
|
-
|
|
167
|
-
.
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) &&
|
|
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
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
"
|
|
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) => {
|