@333eco/corpus 1.2.5 → 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.
@@ -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
- 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
- ];
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
- return text({ query: q, matches: hits.length, results: hits });
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
- return text({
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
- text: d.text
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) && (!args?.licence || d.licence.id === args.licence))
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 text({ count: list.length, licences: corpus.licences, documents: list });
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
- capabilities: { tools: {} },
218
- serverInfo: { name: "corpus.333.eco", version: "1.0.0" },
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
- "tools/call": (params) => callTool(params?.name, params?.arguments)
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) => {