@333eco/corpus 2.4.1 → 2.4.2

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 CHANGED
@@ -113,8 +113,8 @@ corpus already fits one — but a way to read the shelf.
113
113
  - **Paged by size.** `max_bytes` defaults to 90,000 (under the 25,000-token point at which
114
114
  Claude Code moves a tool result into a file) and may go to 400,000. **A document is never
115
115
  split** — half a document verifies nothing — so one larger than the page arrives alone and
116
- says `over_budget`. Follow `next_cursor`; a cursor from a different corpus version is refused
117
- rather than serving page 2 of another corpus.
116
+ says so in its `[PAGE]` line. That last block names the cursor for the next page; a cursor from a
117
+ different corpus version is refused rather than serving page 2 of another corpus.
118
118
  - **`list_documents` says how to read what it listed:** `read_with` carries the same filters as
119
119
  `read_documents` arguments.
120
120
 
@@ -195,15 +195,23 @@ hands every consumer an obligation nobody can discharge, so **the build fails**
195
195
  rather than serving it — the same reasoning as the licence gate: a property, not a
196
196
  rule someone has to remember.
197
197
 
198
- **Results are structured.** Every tool returns `structuredContent` — the typed
199
- object — and uses `content` for the human form: the document text with its
200
- provenance header for `get_document`, readable excerpts for `search_corpus`, a
201
- compact serialisation for the rest. ⛔ **Nothing is sent twice.** The spec's
202
- back-compat advice is to serialise the JSON into `content` as well; measured, that
203
- doubles every response (1.76×–2.00×), so this server splits by role instead —
204
- `content` is read, `structuredContent` is validated, and the two carry different
205
- things. `outputSchema` is deliberately not declared yet: a schema binds the server
206
- on every future change, and the envelope is still moving.
198
+ **Documents come as text; everything else is structured.** `get_document` and
199
+ `read_documents` return **`content` only** — each document behind its provenance header
200
+ and ending at its `[END OF DOCUMENT]` line. The other tools return `structuredContent`
201
+ (the typed object) plus a readable or compact `content`, and anything a reader needs —
202
+ search's `reading` line included — is in the structured part.
203
+
204
+ ⛔⛔ **Why the document tools carry no `structuredContent` (2.4.2).** From 2.0.0 to 2.4.1
205
+ they split by role: text in `content`, the envelope *without the body* in
206
+ `structuredContent`. A Claude client shown a result that carries `structuredContent` hands its
207
+ model **only that part** — so for eleven days **no document text reached any Claude-based
208
+ reader**, while every check here passed, because every check read the server's output
209
+ directly rather than through a client. The MCP spec assumes the two parts carry the same
210
+ information; a server that splits them is at the mercy of whichever part a client picks. The
211
+ document is the payload and its provenance already rides in the header, so the document tools
212
+ now send the text and nothing else — and `check-parity` fails if either ever carries
213
+ `structuredContent` again. `outputSchema` is deliberately not declared: a schema binds the server
214
+ on every future change.
207
215
 
208
216
  **Text is returned verbatim and is never summarised.** Not a stylistic
209
217
  preference — a summary cannot be hash-verified, so summarising at the server
package/dist/corpus.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "package_version": "2.4.1",
2
+ "package_version": "2.4.2",
3
3
  "generated_from": [
4
4
  "H3/publications",
5
5
  "TH/publications",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@333eco/corpus",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "An MCP server for an open-licensed corpus, served with verifiable provenance — every document carries its sha256, DOI and OpenTimestamps proof so a consuming agent can check its own citation.",
5
5
  "license": "CC0-1.0",
6
6
  "author": "Thon Ly",
@@ -43,7 +43,7 @@ export const READ_TOOLS = [
43
43
  // facets are described once, on list_documents.
44
44
  description:
45
45
  "Whole documents, never summarised: those matching the list_documents filters, or the given slugs. " +
46
- "One content block each. Paged: follow next_cursor.",
46
+ "One content block each; the last block names the cursor for the next page.",
47
47
  inputSchema: {
48
48
  type: "object",
49
49
  properties: {
@@ -110,9 +110,9 @@ const decodeCursor = (cursor, version) => {
110
110
 
111
111
  /**
112
112
  * read_documents. `ctx` is what differs between the surfaces:
113
- * { documents, bySlug, envelope, version }.
113
+ * { documents, bySlug, version }.
114
114
  */
115
- export const readDocuments = ({ documents, bySlug, envelope, version }, args) => {
115
+ export const readDocuments = ({ documents, bySlug, version }, args) => {
116
116
  const hasFilter = ["genre", "category", "voice", "licence"].some((k) => args?.[k]);
117
117
  let matched;
118
118
  if (args?.slugs !== undefined) {
@@ -149,43 +149,34 @@ export const readDocuments = ({ documents, bySlug, envelope, version }, args) =>
149
149
  const next = nextOffset < matched.length ? encodeCursor(version, nextOffset) : undefined;
150
150
 
151
151
  if (!matched.length) {
152
- return {
153
- content: [{ type: "text", text: "no documents match these filters. Call list_documents to see the facets and their counts." }],
154
- structuredContent: { matched: 0, returned: 0, offset: 0, page_bytes: 0, max_bytes: budget, documents: [] }
155
- };
152
+ return { content: [{ type: "text", text: "no documents match these filters. Call list_documents to see the facets and their counts." }] };
156
153
  }
157
154
 
155
+ // ⛔⛔ TEXT ONLY — NO structuredContent (2.4.2, founder-ruled 2026-09-13). A Claude client shown a
156
+ // result that carries structuredContent hands its model ONLY that part; the envelope-without-body
157
+ // shape this tool shipped with in 2.4.0 therefore delivered no document to any Claude-based reader
158
+ // (§the-text-never-arrived). The document IS the payload, and the provenance already rides in each
159
+ // document's header, so the structured copy bought validation for programs at the price of the
160
+ // text for models. Everything a reader needs to keep going is in the [PAGE] line.
158
161
  const content = page.map((p) => ({ type: "text", text: p.text }));
159
- // The one block that is not a document: what a reader of `content` alone needs in
160
- // order to keep going, or to know it has everything.
162
+ const over = page.length === 1 && used > budget ? " This document is larger than max_bytes and was returned whole rather than split." : "";
161
163
  content.push({
162
164
  type: "text",
163
165
  text: next
164
- ? `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}. ${matched.length - nextOffset} more: call read_documents again with the same arguments and cursor "${next}".]`
165
- : `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}. That is every matching document.]`
166
+ ? `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}.${over} ${matched.length - nextOffset} more: call read_documents again with the same arguments and cursor "${next}".]`
167
+ : `[PAGE — documents ${start + 1}–${nextOffset} of ${matched.length}.${over} That is every matching document.]`
166
168
  });
169
+ return { content };
170
+ };
167
171
 
168
- return {
169
- content,
170
- structuredContent: {
171
- matched: matched.length,
172
- returned: page.length,
173
- offset: start,
174
- page_bytes: used,
175
- max_bytes: budget,
176
- ...(page.length === 1 && used > budget ? { over_budget: "one document larger than max_bytes, returned whole rather than split" } : {}),
177
- ...(next ? { next_cursor: next } : {}),
178
- documents: page.map((p) => ({
179
- ...envelope(p.d),
180
- genre: p.d.genre,
181
- category: p.d.category,
182
- voice: p.d.voice,
183
- bytes: p.d.bytes,
184
- words: p.d.words,
185
- text_in: "content, one block per document in this order, each prefixed by its provenance header"
186
- }))
187
- }
188
- };
172
+ /**
173
+ * get_document — shared by both surfaces since 2.4.2, TEXT ONLY for the same reason as read_documents:
174
+ * the envelope-without-body in structuredContent was the only part a Claude client showed its model.
175
+ */
176
+ export const getDocument = ({ bySlug }, args) => {
177
+ const d = bySlug.get(String(args?.slug ?? ""));
178
+ if (!d) throw new Error(`no document with slug "${args?.slug}". Call list_documents to see what is available.`);
179
+ return { content: [{ type: "text", text: documentText(d) }] };
189
180
  };
190
181
 
191
182
  /**
package/src/search.mjs CHANGED
@@ -259,6 +259,9 @@ export const searchCorpus = ({ documents, envelope }, args) => {
259
259
  query: q,
260
260
  matches: hits.length,
261
261
  returned: results.length,
262
+ // ⛔ 2.4.2: the reading line was in `content` only, and a Claude client shows its model only
263
+ // structuredContent — so the 2.4.1 nudge reached no Claude reader. It rides here too.
264
+ ...(results.length ? { reading: readingLine(results, hits.length) } : {}),
262
265
  ...(absent.length ? { absent_terms: absent } : {}),
263
266
  results: results
264
267
  });
package/src/server.mjs CHANGED
@@ -35,12 +35,11 @@ import { dirname, join, resolve } from "node:path";
35
35
  import { fileURLToPath } from "node:url";
36
36
  import { createInterface } from "node:readline";
37
37
  import { RESOURCE_TEMPLATES, listResources, readResource, completeArgument } from "./resources.mjs";
38
- import { structured, structuredWithText } from "./results.mjs";
39
- import { documentText } from "./resources.mjs";
38
+ import { structured } from "./results.mjs";
40
39
  import { envelope } from "./envelope.mjs";
41
40
  import { PROMPTS, getPrompt } from "./prompts.mjs";
42
41
  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
+ import { READ_TOOLS, READ_TOOL_NAMES, READ_INSTRUCTIONS, filterDocuments, readDocuments, getDocument, readWith, completenessOf, manifestProblems } from "./read-tools.mjs";
44
43
  import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
45
44
  import { searchCorpus } from "./search.mjs";
46
45
 
@@ -127,45 +126,11 @@ const callTool = (name, args) => {
127
126
  }
128
127
 
129
128
  if (name === "get_document") {
130
- const d = bySlug.get(String(args?.slug ?? ""));
131
- if (!d) throw new Error(`no document with slug "${args?.slug}". Call list_documents to see what is available.`);
132
- // ⭐ The document goes to `content` WITH ITS PROVENANCE HEADER — the same
133
- // bytes resources/read returns, so the two doors into a document agree —
134
- // and the envelope goes to `structuredContent` WITHOUT the body. Split by
135
- // role; nothing is sent twice.
136
- return structuredWithText(documentText(d), {
137
- ...envelope(d),
138
- genre: d.genre,
139
- bytes: d.bytes,
140
- words: d.words,
141
- // ⚠️ category and voice were in list_documents and NOT here, so a
142
- // caller who fetched ONE document could not see the facets it had
143
- // just filtered on — the discovery call and the read call disagreed
144
- // about what a document is. Both are cheap strings; the reason the
145
- // body is excluded (it is in `content`, never sent twice) does not
146
- // apply to them.
147
- category: d.category,
148
- voice: d.voice,
149
- repo: d.repo,
150
- path: d.path,
151
- metadata_convention: d.metadata_convention,
152
- // ⚠️ PRESENT ONLY WHERE THEY MEAN SOMETHING, and dropping them was a
153
- // real bug: `editorial` is what tells a consumer that the inline
154
- // [VERBATIM]/[SCAFFOLD] markers in `text` are a convention rather
155
- // than noise, and `segments` is the same split structurally. The
156
- // markers alone are the load-bearing half — they survive quotation —
157
- // but shipping them with nothing that explains them made the
158
- // convention look like an artefact of bad extraction.
159
- ...(d.editorial ? { editorial: d.editorial } : {}),
160
- ...(d.segments ? { segments: d.segments } : {}),
161
- // ⚠️ Deliberately absent: the body is in `content`. Carrying it here
162
- // too is the duplication this shape exists to avoid.
163
- text_in: "content[0].text, prefixed by the provenance header and ending at its [END OF DOCUMENT] line"
164
- });
129
+ // Shared (read-tools.mjs), and TEXT ONLY since 2.4.2 — see §the-text-never-arrived there.
130
+ return getDocument({ bySlug: bySlug }, args);
165
131
  }
166
-
167
132
  if (name === "read_documents") {
168
- return readDocuments({ documents: corpus.documents, bySlug, envelope, version: String(corpus.package_version ?? "") }, args);
133
+ return readDocuments({ documents: corpus.documents, bySlug, version: String(corpus.package_version ?? "") }, args);
169
134
  }
170
135
 
171
136
  if (name === "list_documents") {