@333eco/corpus 1.2.4 → 2.0.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 +70 -0
- package/dist/corpus.json +1446 -153
- package/package.json +4 -3
- package/src/base-tools.mjs +72 -0
- package/src/program-tools.mjs +181 -0
- package/src/prompts.mjs +134 -0
- package/src/resources.mjs +215 -0
- package/src/results.mjs +40 -0
- package/src/server.mjs +94 -52
package/src/results.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// How a tool result is shaped.
|
|
2
|
+
//
|
|
3
|
+
// ⭐⭐ JSON IS THE PAYLOAD; THE TEXT BLOCK IS THE HUMAN FORM. Until 2026-09-02 every
|
|
4
|
+
// tool returned pretty-printed JSON inside a text block, which meant a consuming
|
|
5
|
+
// program had to parse a string to reach data the server already had as an object.
|
|
6
|
+
// `structuredContent` is where that object belongs.
|
|
7
|
+
//
|
|
8
|
+
// ⚠️ THE SPEC'S BACK-COMPAT ADVICE — also serialise the JSON into `content` — is a
|
|
9
|
+
// SHOULD, and following it literally DOUBLES every response (measured: 1.76×–2.00×
|
|
10
|
+
// across the six tools). So this file does not duplicate. It SPLITS BY ROLE:
|
|
11
|
+
//
|
|
12
|
+
// content — what a reader reads: document text, excerpts, a listing
|
|
13
|
+
// structuredContent — what a program validates: the typed envelope and metadata
|
|
14
|
+
//
|
|
15
|
+
// Nothing appears in both. For the document tools that makes `content` the text
|
|
16
|
+
// WITH ITS PROVENANCE HEADER — identical to what resources/read returns, so the two
|
|
17
|
+
// ways into the same document finally agree — and `structuredContent` the envelope
|
|
18
|
+
// without the body. Measured cost: 1.00× on the large tools, 0.76×–0.78× on the
|
|
19
|
+
// metadata tools, which are smaller than what they replaced.
|
|
20
|
+
//
|
|
21
|
+
// ⛔ NO `outputSchema` YET, and that is a decision rather than an omission. The spec
|
|
22
|
+
// makes a declared schema binding — "servers MUST provide structured results that
|
|
23
|
+
// conform" — and the envelope changed twice on the day this was written. A schema
|
|
24
|
+
// is a promise kept on every future change; publishing one over a shape still in
|
|
25
|
+
// motion buys validation now and breaks validating clients later.
|
|
26
|
+
|
|
27
|
+
// A payload that is data all the way down: the object, plus a COMPACT serialisation
|
|
28
|
+
// for clients that read only `content`. Compact, not pretty — indentation is the
|
|
29
|
+
// one part of the old shape that was pure cost.
|
|
30
|
+
export const structured = (value) => ({
|
|
31
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
32
|
+
structuredContent: value
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// A payload with a human form: `text` is read, `value` is validated, and the two
|
|
36
|
+
// carry different things rather than the same thing twice.
|
|
37
|
+
export const structuredWithText = (text, value) => ({
|
|
38
|
+
content: [{ type: "text", text }],
|
|
39
|
+
structuredContent: value
|
|
40
|
+
});
|
package/src/server.mjs
CHANGED
|
@@ -34,6 +34,12 @@ 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";
|
|
37
43
|
|
|
38
44
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
39
45
|
const INDEX = resolve(HERE, "..", "dist", "corpus.json");
|
|
@@ -47,7 +53,13 @@ if (!existsSync(INDEX)) {
|
|
|
47
53
|
|
|
48
54
|
const corpus = JSON.parse(readFileSync(INDEX, "utf8"));
|
|
49
55
|
const bySlug = new Map(corpus.documents.map((d) => [d.slug, d]));
|
|
56
|
+
// The research program is optional: an index built over a corpus that does not
|
|
57
|
+
// contain it simply has no `program` block, and the three program tools are then
|
|
58
|
+
// not advertised at all. ⭐ An unadvertised tool is better than a tool that
|
|
59
|
+
// exists and always errors — a client can reason about the first.
|
|
60
|
+
const program = corpus.program ?? null;
|
|
50
61
|
log(`${corpus.document_count} documents loaded —`, JSON.stringify(corpus.licences));
|
|
62
|
+
if (program) log(`research program: ${program.prediction_count} predictions, ${program.reconciliation.reconciles ? "register arithmetic reconciles" : "⚠️ REGISTER ARITHMETIC DOES NOT RECONCILE"}`);
|
|
51
63
|
|
|
52
64
|
/* ------------------------------------------------------- protocol plumbing ---
|
|
53
65
|
Versions this server knows how to speak, newest first. On initialize the spec
|
|
@@ -78,6 +90,22 @@ const envelope = (d) => ({
|
|
|
78
90
|
},
|
|
79
91
|
provenance: {
|
|
80
92
|
...d.provenance,
|
|
93
|
+
// ⚠️ A LIVING DOCUMENT IS VERIFIED AND CITED WITH DIFFERENT DOIs, and
|
|
94
|
+
// saying only "cite accordingly" leaves the reader to guess which.
|
|
95
|
+
// The VERSION doi is the only one that can be true of the bytes here
|
|
96
|
+
// — it pins them — so it is what a hash check resolves against. The
|
|
97
|
+
// CONCEPT doi follows the document, so it is what a citation should
|
|
98
|
+
// name: a living register is revised on purpose, and a citation
|
|
99
|
+
// pinned to one revision goes stale by design rather than by accident.
|
|
100
|
+
...(d.status === "living" && d.provenance.concept_doi
|
|
101
|
+
? {
|
|
102
|
+
citation: {
|
|
103
|
+
cite: `https://doi.org/${d.provenance.concept_doi}`,
|
|
104
|
+
verify_against: d.provenance.doi ? `https://doi.org/${d.provenance.doi}` : null,
|
|
105
|
+
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."
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
: {}),
|
|
81
109
|
verify: {
|
|
82
110
|
// Concrete, because an instruction that says "the source file"
|
|
83
111
|
// without saying which one is not an instruction. All three source
|
|
@@ -103,48 +131,9 @@ const envelope = (d) => ({
|
|
|
103
131
|
|
|
104
132
|
/* -------------------------------------------------------------------- tools --- */
|
|
105
133
|
|
|
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
|
-
];
|
|
134
|
+
const TOOLS = BASE_TOOLS;
|
|
135
|
+
|
|
136
|
+
const KNOWN_TOOLS = new Set([...BASE_TOOLS, ...PROGRAM_TOOLS].map((t) => t.name));
|
|
148
137
|
|
|
149
138
|
const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
150
139
|
|
|
@@ -166,13 +155,20 @@ const callTool = (name, args) => {
|
|
|
166
155
|
.filter((h) => h.ex !== null)
|
|
167
156
|
.slice(0, limit)
|
|
168
157
|
.map((h) => ({ ...envelope(h.d), genre: h.d.genre, excerpt: h.ex }));
|
|
169
|
-
|
|
158
|
+
const readable = hits.length
|
|
159
|
+
? hits.map((h) => `${h.slug} — ${h.title}\n ${h.excerpt}`).join("\n\n")
|
|
160
|
+
: `no document matches "${q}".`;
|
|
161
|
+
return structuredWithText(readable, { query: q, matches: hits.length, results: hits });
|
|
170
162
|
}
|
|
171
163
|
|
|
172
164
|
if (name === "get_document") {
|
|
173
165
|
const d = bySlug.get(String(args?.slug ?? ""));
|
|
174
166
|
if (!d) throw new Error(`no document with slug "${args?.slug}". Call list_documents to see what is available.`);
|
|
175
|
-
|
|
167
|
+
// ⭐ The document goes to `content` WITH ITS PROVENANCE HEADER — the same
|
|
168
|
+
// bytes resources/read returns, so the two doors into a document agree —
|
|
169
|
+
// and the envelope goes to `structuredContent` WITHOUT the body. Split by
|
|
170
|
+
// role; nothing is sent twice.
|
|
171
|
+
return structuredWithText(provenanceHeader(d) + "\n\n" + d.text, {
|
|
176
172
|
...envelope(d),
|
|
177
173
|
genre: d.genre,
|
|
178
174
|
repo: d.repo,
|
|
@@ -187,23 +183,42 @@ const callTool = (name, args) => {
|
|
|
187
183
|
// convention look like an artefact of bad extraction.
|
|
188
184
|
...(d.editorial ? { editorial: d.editorial } : {}),
|
|
189
185
|
...(d.segments ? { segments: d.segments } : {}),
|
|
190
|
-
|
|
186
|
+
// ⚠️ Deliberately absent: the body is in `content`. Carrying it here
|
|
187
|
+
// too is the duplication this shape exists to avoid.
|
|
188
|
+
text_in: "content[0].text, prefixed by the provenance header"
|
|
191
189
|
});
|
|
192
190
|
}
|
|
193
191
|
|
|
194
192
|
if (name === "list_documents") {
|
|
195
193
|
const list = corpus.documents
|
|
196
|
-
.filter((d) => (!args?.genre || d.genre === args.genre) &&
|
|
194
|
+
.filter((d) => (!args?.genre || d.genre === args.genre) &&
|
|
195
|
+
(!args?.licence || d.licence.id === args.licence) &&
|
|
196
|
+
(!args?.category || d.category === args.category))
|
|
197
197
|
.map((d) => ({
|
|
198
198
|
slug: d.slug,
|
|
199
199
|
title: d.title,
|
|
200
200
|
genre: d.genre,
|
|
201
|
+
// ⭐ Carried in the index since the first build and exposed by
|
|
202
|
+
// nothing until now. `institutional` is the four-body shelf,
|
|
203
|
+
// `mechanism` the how-it-works shelf — the corpus already had a
|
|
204
|
+
// topic taxonomy and no way to ask it a question.
|
|
205
|
+
category: d.category,
|
|
201
206
|
date: d.date,
|
|
202
207
|
licence: d.licence.id,
|
|
203
208
|
doi: d.provenance.doi,
|
|
204
209
|
opentimestamps: d.provenance.opentimestamps
|
|
205
210
|
}));
|
|
206
|
-
return
|
|
211
|
+
return structured({
|
|
212
|
+
count: list.length,
|
|
213
|
+
licences: corpus.licences,
|
|
214
|
+
// The shelves, so a caller can narrow without guessing the vocabulary.
|
|
215
|
+
categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
|
|
216
|
+
documents: list
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (PROGRAM_TOOL_NAMES.includes(name)) {
|
|
221
|
+
return callProgramTool({ program, bySlug, envelope }, name, args);
|
|
207
222
|
}
|
|
208
223
|
|
|
209
224
|
throw new Error(`unknown tool: ${name}`);
|
|
@@ -214,16 +229,43 @@ const callTool = (name, args) => {
|
|
|
214
229
|
const handlers = {
|
|
215
230
|
initialize: (params) => ({
|
|
216
231
|
protocolVersion: PROTOCOL_VERSIONS.includes(params?.protocolVersion) ? params.protocolVersion : PROTOCOL_VERSIONS[0],
|
|
217
|
-
|
|
218
|
-
|
|
232
|
+
// ⚠️ Declared because they are implemented. `subscribe`/`listChanged` are
|
|
233
|
+
// deliberately absent: the corpus is fixed for the life of a build, so a
|
|
234
|
+
// subscription would be a promise to send notifications that can never fire.
|
|
235
|
+
capabilities: { tools: {}, resources: {}, completions: {}, prompts: {} },
|
|
236
|
+
serverInfo: { name: "corpus.333.eco", version: corpus.package_version ?? "0.0.0-unbuilt" },
|
|
219
237
|
instructions:
|
|
220
238
|
"An open-licensed corpus served with verifiable provenance. Every document carries a sha256, and most " +
|
|
221
239
|
"carry a DOI and an OpenTimestamps proof anchored in Bitcoin, so you can check any passage you intend to " +
|
|
222
240
|
"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."
|
|
241
|
+
"honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
|
|
242
|
+
(program ? PROGRAM_INSTRUCTIONS : "")
|
|
224
243
|
}),
|
|
225
|
-
"tools/list": () => ({ tools: TOOLS }),
|
|
226
|
-
"
|
|
244
|
+
"tools/list": () => ({ tools: program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS }),
|
|
245
|
+
"resources/list": (params) => listResources(corpus.documents, params?.cursor),
|
|
246
|
+
"resources/templates/list": () => ({ resourceTemplates: RESOURCE_TEMPLATES }),
|
|
247
|
+
"resources/read": (params) => readResource(params?.uri, bySlug),
|
|
248
|
+
"prompts/list": () => ({ prompts: PROMPTS }),
|
|
249
|
+
"prompts/get": (params) => getPrompt(params?.name, params?.arguments),
|
|
250
|
+
"completion/complete": (params) => completeArgument(params?.ref, params?.argument, corpus.documents),
|
|
251
|
+
// ⛔ TWO KINDS OF FAILURE, AND THEY ARE NOT THE SAME KIND. An unknown tool is a
|
|
252
|
+
// PROTOCOL error — the client asked for something that does not exist. A miss
|
|
253
|
+
// INSIDE a known tool ("no document with that slug") is a tool-execution error,
|
|
254
|
+
// and the spec puts those in the result with isError, not in a JSON-RPC error.
|
|
255
|
+
// ⭐ The distinction is load-bearing here specifically because our failure
|
|
256
|
+
// messages are GUIDANCE — "call list_documents to see what is available" — and
|
|
257
|
+
// a protocol error frequently never reaches the model as recoverable context.
|
|
258
|
+
// Returned as a result, the guidance is read and can be acted on; raised as
|
|
259
|
+
// -32603 it was written for a reader who mostly would not see it.
|
|
260
|
+
"tools/call": (params) => {
|
|
261
|
+
const name = params?.name;
|
|
262
|
+
if (!KNOWN_TOOLS.has(name)) throw new Error(`unknown tool: ${name}`);
|
|
263
|
+
try {
|
|
264
|
+
return callTool(name, params?.arguments);
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return { content: [{ type: "text", text: e.message }], isError: true };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
227
269
|
};
|
|
228
270
|
|
|
229
271
|
createInterface({ input: process.stdin }).on("line", (line) => {
|