@333eco/corpus 2.1.7 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@333eco/corpus",
3
- "version": "2.1.7",
3
+ "version": "2.2.0",
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",
@@ -63,10 +63,66 @@ export const BASE_TOOLS = [
63
63
  inputSchema: {
64
64
  type: "object",
65
65
  properties: {
66
- genre: { type: "string", description: "Optional: restrict to a genre — essays, defensive-publications, positions, white-papers, letters, program." },
67
- category: { type: "string", description: "Optional: restrict to a topic category — institutional (the four-body architecture and the institution itself), mechanism, alignment, essays, letters, program, capabilities. The response lists every category with its count." },
66
+ genre: { type: "string", description: "Optional: restrict to a genre." },
67
+ category: { type: "string", description: "Optional: restrict to a topic category. The response lists every category with its count." },
68
+ voice: { type: "string", description: "Optional: restrict by whose voice a document is in." },
68
69
  licence: { type: "string", description: "Optional: restrict to a licence id, e.g. CC0-1.0 or CC-BY-4.0." }
69
70
  }
70
71
  }
71
72
  }
72
73
  ];
74
+
75
+
76
+ // ── the facet enumerations, DERIVED ─────────────────────────────────────────
77
+ //
78
+ // ⚠️ THEY WERE TYPED INTO THE DESCRIPTIONS ABOVE. `list_documents` listed its own
79
+ // genres and categories in prose — "essays, defensive-publications, positions,
80
+ // white-papers, letters, program" — in a file nothing checks, read by agents that
81
+ // act on it. Adding the `about` genre and the `voice` axis would have made two of
82
+ // them wrong on the same day.
83
+ //
84
+ // ⭐ So the values come from the artifact being served. Both surfaces load the
85
+ // same pinned corpus, so they cannot disagree, and a facet that gains a value
86
+ // gains it in the description at the same moment it gains it in the data.
87
+ //
88
+ // ⚠️ The hand-written GLOSSES stay: "institutional" is worth explaining and no
89
+ // count can explain it. A value with no gloss degrades to its bare name rather
90
+ // than disappearing, which is the failure mode worth avoiding — an enumeration
91
+ // that silently omits a value is worse than one with a terse entry.
92
+ const GLOSS = {
93
+ institutional: "the four-body architecture and the institution itself",
94
+ founder: "Thon Ly's own voice",
95
+ collaborative: "the research corpus, disclosed as co-authored with Miss Aquarius\u2120",
96
+ "defensive-publications": "prior-art publications",
97
+ program: "the research programme and its register"
98
+ };
99
+
100
+ const enumerate = (values) =>
101
+ values
102
+ .map((v) => (GLOSS[v] ? `${v} (${GLOSS[v]})` : v))
103
+ .join(", ");
104
+
105
+ /**
106
+ * Fill `list_documents`' facet enumerations from the corpus this server serves.
107
+ * Everything else is passed through untouched.
108
+ */
109
+ export function withFacets(tools, corpus) {
110
+ if (!corpus || !Array.isArray(corpus.documents)) return tools;
111
+ const uniq = (key) =>
112
+ [...new Set(corpus.documents.map((d) => d[key]).filter(Boolean))].sort();
113
+ const genres = uniq("genre");
114
+ const categories = uniq("category");
115
+ const voices = Object.keys(corpus.voices || {}).sort();
116
+
117
+ return tools.map((t) => {
118
+ if (t.name !== "list_documents") return t;
119
+ const props = { ...t.inputSchema.properties };
120
+ if (genres.length)
121
+ props.genre = { ...props.genre, description: `Optional: restrict to a genre — ${enumerate(genres)}.` };
122
+ if (categories.length)
123
+ props.category = { ...props.category, description: `Optional: restrict to a topic category — ${enumerate(categories)}. The response lists every category with its count.` };
124
+ if (voices.length)
125
+ props.voice = { ...props.voice, description: `Optional: restrict by whose voice a document is in — ${enumerate(voices)}. Orthogonal to genre and category.` };
126
+ return { ...t, inputSchema: { ...t.inputSchema, properties: props } };
127
+ });
128
+ }
package/src/search.mjs CHANGED
@@ -10,7 +10,11 @@
10
10
  // ⚠️ WHY THIS REPLACED A BARE `indexOf`, and the evidence was a real caller.
11
11
  // The first external client to reach the hosted endpoint searched
12
12
  // `"gratitude alignment human wellbeing kindness"` and got ZERO results — while
13
- // `gratitude` alone returns 50, `alignment` 50, `kindness` 50, `wellbeing` 16.
13
+ // while each of those terms alone matched many documents.
14
+ // ⚠️ The per-term counts that used to sit on this line disagreed with the
15
+ // README's copy of the same anecdote (50 here, 109 there, 112 in the live
16
+ // corpus). A number in a comment about a corpus that grows is a number that
17
+ // will be wrong; the point of the story does not need it.
14
18
  // The corpus was not missing the material; the matcher required that exact
15
19
  // five-word string to appear verbatim, which of course it never does. A literal
16
20
  // substring search silently punishes anyone who types a sentence, and it makes
package/src/server.mjs CHANGED
@@ -38,7 +38,7 @@ import { RESOURCE_TEMPLATES, listResources, readResource, completeArgument } fro
38
38
  import { structured, structuredWithText } from "./results.mjs";
39
39
  import { provenanceHeader } from "./resources.mjs";
40
40
  import { PROMPTS, getPrompt } from "./prompts.mjs";
41
- import { BASE_TOOLS } from "./base-tools.mjs";
41
+ import { BASE_TOOLS, withFacets } from "./base-tools.mjs";
42
42
  import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
43
43
  import { match, rank, absentTerms } from "./search.mjs";
44
44
 
@@ -229,6 +229,7 @@ const callTool = (name, args) => {
229
229
  const list = corpus.documents
230
230
  .filter((d) => (!args?.genre || d.genre === args.genre) &&
231
231
  (!args?.licence || d.licence.id === args.licence) &&
232
+ (!args?.voice || d.voice === args.voice) &&
232
233
  (!args?.category || d.category === args.category))
233
234
  .map((d) => ({
234
235
  slug: d.slug,
@@ -239,6 +240,10 @@ const callTool = (name, args) => {
239
240
  // `mechanism` the how-it-works shelf — the corpus already had a
240
241
  // topic taxonomy and no way to ask it a question.
241
242
  category: d.category,
243
+ // ⭐ WHO IS SPEAKING, derived from genre in the builder rather
244
+ // than stored per file. It is what makes "tell me about the
245
+ // founder" one call instead of three.
246
+ voice: d.voice,
242
247
  date: d.date,
243
248
  licence: d.licence.id,
244
249
  doi: d.provenance.doi,
@@ -249,6 +254,10 @@ const callTool = (name, args) => {
249
254
  licences: corpus.licences,
250
255
  // The shelves, so a caller can narrow without guessing the vocabulary.
251
256
  categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
257
+ // Always the FULL count, never narrowed by the filter — a set of
258
+ // documents means nothing without the honest denominator beside it,
259
+ // the same reason list_predictions returns by_state unfiltered.
260
+ ...(corpus.voices ? { voices: corpus.voices } : {}),
252
261
  documents: list
253
262
  });
254
263
  }
@@ -277,7 +286,12 @@ const handlers = {
277
286
  "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
278
287
  (program ? PROGRAM_INSTRUCTIONS : "")
279
288
  }),
280
- "tools/list": () => ({ tools: program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS }),
289
+ // withFacets fills list_documents' facet enumerations FROM THE CORPUS.
290
+ // They used to be typed into the description and would have been wrong twice
291
+ // over the day the `about` genre and the `voice` axis landed.
292
+ "tools/list": () => ({
293
+ tools: withFacets(program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS, corpus)
294
+ }),
281
295
  "resources/list": (params) => listResources(corpus.documents, params?.cursor),
282
296
  "resources/templates/list": () => ({ resourceTemplates: RESOURCE_TEMPLATES }),
283
297
  "resources/read": (params) => readResource(params?.uri, bySlug),