@333eco/corpus 2.1.7 → 2.3.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.3.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
 
@@ -207,6 +207,14 @@ const callTool = (name, args) => {
207
207
  return structuredWithText(provenanceHeader(d) + "\n\n" + d.text, {
208
208
  ...envelope(d),
209
209
  genre: d.genre,
210
+ // ⚠️ category and voice were in list_documents and NOT here, so a
211
+ // caller who fetched ONE document could not see the facets it had
212
+ // just filtered on — the discovery call and the read call disagreed
213
+ // about what a document is. Both are cheap strings; the reason the
214
+ // body is excluded (it is in `content`, never sent twice) does not
215
+ // apply to them.
216
+ category: d.category,
217
+ voice: d.voice,
210
218
  repo: d.repo,
211
219
  path: d.path,
212
220
  metadata_convention: d.metadata_convention,
@@ -229,6 +237,7 @@ const callTool = (name, args) => {
229
237
  const list = corpus.documents
230
238
  .filter((d) => (!args?.genre || d.genre === args.genre) &&
231
239
  (!args?.licence || d.licence.id === args.licence) &&
240
+ (!args?.voice || d.voice === args.voice) &&
232
241
  (!args?.category || d.category === args.category))
233
242
  .map((d) => ({
234
243
  slug: d.slug,
@@ -239,6 +248,10 @@ const callTool = (name, args) => {
239
248
  // `mechanism` the how-it-works shelf — the corpus already had a
240
249
  // topic taxonomy and no way to ask it a question.
241
250
  category: d.category,
251
+ // ⭐ WHO IS SPEAKING, derived from genre in the builder rather
252
+ // than stored per file. It is what makes "tell me about the
253
+ // founder" one call instead of three.
254
+ voice: d.voice,
242
255
  date: d.date,
243
256
  licence: d.licence.id,
244
257
  doi: d.provenance.doi,
@@ -249,6 +262,10 @@ const callTool = (name, args) => {
249
262
  licences: corpus.licences,
250
263
  // The shelves, so a caller can narrow without guessing the vocabulary.
251
264
  categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
265
+ // Always the FULL count, never narrowed by the filter — a set of
266
+ // documents means nothing without the honest denominator beside it,
267
+ // the same reason list_predictions returns by_state unfiltered.
268
+ ...(corpus.voices ? { voices: corpus.voices } : {}),
252
269
  documents: list
253
270
  });
254
271
  }
@@ -277,7 +294,12 @@ const handlers = {
277
294
  "honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
278
295
  (program ? PROGRAM_INSTRUCTIONS : "")
279
296
  }),
280
- "tools/list": () => ({ tools: program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS }),
297
+ // withFacets fills list_documents' facet enumerations FROM THE CORPUS.
298
+ // They used to be typed into the description and would have been wrong twice
299
+ // over the day the `about` genre and the `voice` axis landed.
300
+ "tools/list": () => ({
301
+ tools: withFacets(program ? [...TOOLS, ...PROGRAM_TOOLS] : TOOLS, corpus)
302
+ }),
281
303
  "resources/list": (params) => listResources(corpus.documents, params?.cursor),
282
304
  "resources/templates/list": () => ({ resourceTemplates: RESOURCE_TEMPLATES }),
283
305
  "resources/read": (params) => readResource(params?.uri, bySlug),