@333eco/corpus 2.1.6 → 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/README.md +14 -6
- package/dist/corpus.json +293 -115
- package/package.json +1 -1
- package/src/base-tools.mjs +58 -2
- package/src/search.mjs +5 -1
- package/src/server.mjs +16 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@333eco/corpus",
|
|
3
|
-
"version": "2.
|
|
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",
|
package/src/base-tools.mjs
CHANGED
|
@@ -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
|
|
67
|
-
category: { type: "string", description: "Optional: restrict to a topic category
|
|
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
|
-
//
|
|
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
|
-
|
|
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),
|