@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/README.md +14 -6
- package/dist/corpus.json +181 -3
- package/package.json +1 -1
- package/src/base-tools.mjs +58 -2
- package/src/search.mjs +5 -1
- package/src/server.mjs +24 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@333eco/corpus",
|
|
3
|
-
"version": "2.
|
|
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",
|
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
|
|
|
@@ -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
|
-
|
|
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),
|