@333eco/corpus 2.3.5 → 2.4.1
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 +74 -17
- package/dist/corpus.json +1326 -147
- package/package.json +1 -1
- package/src/base-tools.mjs +10 -6
- package/src/envelope.mjs +64 -0
- package/src/read-tools.mjs +228 -0
- package/src/resources.mjs +16 -3
- package/src/search.mjs +92 -7
- package/src/server.mjs +41 -97
package/src/server.mjs
CHANGED
|
@@ -36,11 +36,13 @@ import { fileURLToPath } from "node:url";
|
|
|
36
36
|
import { createInterface } from "node:readline";
|
|
37
37
|
import { RESOURCE_TEMPLATES, listResources, readResource, completeArgument } from "./resources.mjs";
|
|
38
38
|
import { structured, structuredWithText } from "./results.mjs";
|
|
39
|
-
import {
|
|
39
|
+
import { documentText } from "./resources.mjs";
|
|
40
|
+
import { envelope } from "./envelope.mjs";
|
|
40
41
|
import { PROMPTS, getPrompt } from "./prompts.mjs";
|
|
41
42
|
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
44
|
import { PROGRAM_TOOLS, PROGRAM_TOOL_NAMES, PROGRAM_INSTRUCTIONS, callProgramTool } from "./program-tools.mjs";
|
|
43
|
-
import {
|
|
45
|
+
import { searchCorpus } from "./search.mjs";
|
|
44
46
|
|
|
45
47
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
46
48
|
const INDEX = resolve(HERE, "..", "dist", "corpus.json");
|
|
@@ -84,6 +86,15 @@ const bySlug = new Map(corpus.documents.map((d) => [d.slug, d]));
|
|
|
84
86
|
// not advertised at all. ⭐ An unadvertised tool is better than a tool that
|
|
85
87
|
// exists and always errors — a client can reason about the first.
|
|
86
88
|
const program = corpus.program ?? null;
|
|
89
|
+
// ⛔ A CORPUS THAT DOES NOT MATCH ITS OWN MANIFEST IS NOT SERVED. Every envelope would
|
|
90
|
+
// still verify, which is exactly why a missing document could otherwise go unseen.
|
|
91
|
+
{
|
|
92
|
+
const problems = manifestProblems(corpus);
|
|
93
|
+
if (problems.length) {
|
|
94
|
+
log("dist/corpus.json does not match its manifest — refusing to serve:\n " + problems.join("\n "));
|
|
95
|
+
process.exit(1);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
87
98
|
log(`${corpus.document_count} documents loaded —`, JSON.stringify(corpus.licences));
|
|
88
99
|
if (program) log(`research program: ${program.prediction_count} predictions, ${program.reconciliation.reconciles ? "register arithmetic reconciles" : "⚠️ REGISTER ARITHMETIC DOES NOT RECONCILE"}`);
|
|
89
100
|
|
|
@@ -98,103 +109,21 @@ const send = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
|
98
109
|
const result = (id, value) => send({ jsonrpc: "2.0", id, result: value });
|
|
99
110
|
const failure = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } });
|
|
100
111
|
|
|
101
|
-
/* ------------------------------------------------------------- the envelope ---
|
|
102
|
-
Attached to every document a tool returns. `verify` is the instruction rather
|
|
103
|
-
than a promise: it tells the agent exactly what to run, so the claim is
|
|
104
|
-
checkable without trusting this sentence either. */
|
|
105
|
-
|
|
106
|
-
const envelope = (d) => ({
|
|
107
|
-
slug: d.slug,
|
|
108
|
-
title: d.title,
|
|
109
|
-
licence: {
|
|
110
|
-
id: d.licence.id,
|
|
111
|
-
url: d.licence.url,
|
|
112
|
-
attribution_required: d.licence.attribution_required,
|
|
113
|
-
// Only present where it is actually owed, so an agent can act on the
|
|
114
|
-
// field's presence rather than parsing the licence id.
|
|
115
|
-
...(d.licence.attribution_required && d.authors ? { attribute_to: d.authors } : {})
|
|
116
|
-
},
|
|
117
|
-
provenance: {
|
|
118
|
-
...d.provenance,
|
|
119
|
-
// ⚠️ A LIVING DOCUMENT IS VERIFIED AND CITED WITH DIFFERENT DOIs, and
|
|
120
|
-
// saying only "cite accordingly" leaves the reader to guess which.
|
|
121
|
-
// The VERSION doi is the only one that can be true of the bytes here
|
|
122
|
-
// — it pins them — so it is what a hash check resolves against. The
|
|
123
|
-
// CONCEPT doi follows the document, so it is what a citation should
|
|
124
|
-
// name: a living register is revised on purpose, and a citation
|
|
125
|
-
// pinned to one revision goes stale by design rather than by accident.
|
|
126
|
-
...(d.status === "living" && d.provenance.concept_doi
|
|
127
|
-
? {
|
|
128
|
-
citation: {
|
|
129
|
-
cite: `https://doi.org/${d.provenance.concept_doi}`,
|
|
130
|
-
verify_against: d.provenance.doi ? `https://doi.org/${d.provenance.doi}` : null,
|
|
131
|
-
why: "This document is living — it is revised on purpose. Cite the concept DOI, which always resolves to the newest version; verify the text you were served against the version DOI and sha256 above, which pin these exact bytes."
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
: {}),
|
|
135
|
-
verify: {
|
|
136
|
-
// Concrete, because an instruction that says "the source file"
|
|
137
|
-
// without saying which one is not an instruction. All three source
|
|
138
|
-
// repositories are public, so this is genuinely runnable.
|
|
139
|
-
sha256: d.provenance.source_url
|
|
140
|
-
? `curl -sL ${d.provenance.source_url} | shasum -a 256 # compare to provenance.sha256`
|
|
141
|
-
: "shasum -a 256 <the source file> # compare to provenance.sha256",
|
|
142
|
-
...(d.provenance.doi ? { doi: `https://doi.org/${d.provenance.doi}` } : {}),
|
|
143
|
-
...(d.provenance.opentimestamps
|
|
144
|
-
? { opentimestamps: `ots verify ${d.path}.ots # in the source repository; the proof is anchored in the Bitcoin blockchain` }
|
|
145
|
-
: {}),
|
|
146
|
-
...(d.provenance.deposited_matches_current === false
|
|
147
|
-
? {
|
|
148
|
-
note:
|
|
149
|
-
"This document has been REVISED since its Zenodo deposit, so provenance.sha256 " +
|
|
150
|
-
"and provenance.deposited_sha256 differ legitimately. The DOI resolves to the " +
|
|
151
|
-
"deposited version; the text served here is newer. Cite accordingly."
|
|
152
|
-
}
|
|
153
|
-
: {})
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
});
|
|
157
112
|
|
|
158
113
|
/* -------------------------------------------------------------------- tools --- */
|
|
159
114
|
|
|
160
|
-
const TOOLS = BASE_TOOLS;
|
|
115
|
+
const TOOLS = [...BASE_TOOLS, ...READ_TOOLS];
|
|
161
116
|
|
|
162
|
-
const KNOWN_TOOLS = new Set([...
|
|
117
|
+
const KNOWN_TOOLS = new Set([...TOOLS, ...PROGRAM_TOOLS].map((t) => t.name));
|
|
163
118
|
|
|
164
119
|
const text = (value) => ({ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }] });
|
|
165
120
|
|
|
166
121
|
|
|
167
122
|
const callTool = (name, args) => {
|
|
168
123
|
if (name === "search_corpus") {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
const hits = pool.map((d) => ({ d, m: match(d.text, q) })).filter((h) => h.m !== null);
|
|
173
|
-
const results = rank(hits, Number(args?.limit ?? 10)).map((h) => ({
|
|
174
|
-
...envelope(h.d),
|
|
175
|
-
genre: h.d.genre,
|
|
176
|
-
excerpt: h.m.excerpt,
|
|
177
|
-
// ⭐ HOW it matched, not just that it did: a "terms" excerpt need not
|
|
178
|
-
// contain the literal query, and a caller reading it should know
|
|
179
|
-
// which question the excerpt is answering.
|
|
180
|
-
match: h.m.mode
|
|
181
|
-
}));
|
|
182
|
-
// ⚠️ `matches` is the TOTAL found, not the number returned — it used to be
|
|
183
|
-
// capped at `limit`, which made "10 matches" and "at least 10 matches"
|
|
184
|
-
// indistinguishable. `returned` carries the capped count.
|
|
185
|
-
const absent = hits.length ? [] : absentTerms(pool, q);
|
|
186
|
-
const readable = results.length
|
|
187
|
-
? results.map((h) => `${h.slug} — ${h.title}${h.match === "terms" ? " [all terms, not the phrase]" : ""}\n ${h.excerpt}`).join("\n\n")
|
|
188
|
-
: absent.length
|
|
189
|
-
? `no document matches "${q}". No document contains: ${absent.join(", ")}.`
|
|
190
|
-
: `no document matches "${q}" — every term appears somewhere, but no single document holds them all. Try fewer terms.`;
|
|
191
|
-
return structuredWithText(readable, {
|
|
192
|
-
query: q,
|
|
193
|
-
matches: hits.length,
|
|
194
|
-
returned: results.length,
|
|
195
|
-
...(absent.length ? { absent_terms: absent } : {}),
|
|
196
|
-
results: results
|
|
197
|
-
});
|
|
124
|
+
// Shared with the other surface (search.mjs): the answer, the excerpts and the
|
|
125
|
+
// reading line cannot differ between them.
|
|
126
|
+
return searchCorpus({ documents: corpus.documents, envelope }, args);
|
|
198
127
|
}
|
|
199
128
|
|
|
200
129
|
if (name === "get_document") {
|
|
@@ -204,9 +133,11 @@ const callTool = (name, args) => {
|
|
|
204
133
|
// bytes resources/read returns, so the two doors into a document agree —
|
|
205
134
|
// and the envelope goes to `structuredContent` WITHOUT the body. Split by
|
|
206
135
|
// role; nothing is sent twice.
|
|
207
|
-
return structuredWithText(
|
|
136
|
+
return structuredWithText(documentText(d), {
|
|
208
137
|
...envelope(d),
|
|
209
138
|
genre: d.genre,
|
|
139
|
+
bytes: d.bytes,
|
|
140
|
+
words: d.words,
|
|
210
141
|
// ⚠️ category and voice were in list_documents and NOT here, so a
|
|
211
142
|
// caller who fetched ONE document could not see the facets it had
|
|
212
143
|
// just filtered on — the discovery call and the read call disagreed
|
|
@@ -229,16 +160,18 @@ const callTool = (name, args) => {
|
|
|
229
160
|
...(d.segments ? { segments: d.segments } : {}),
|
|
230
161
|
// ⚠️ Deliberately absent: the body is in `content`. Carrying it here
|
|
231
162
|
// too is the duplication this shape exists to avoid.
|
|
232
|
-
text_in: "content[0].text, prefixed by the provenance header"
|
|
163
|
+
text_in: "content[0].text, prefixed by the provenance header and ending at its [END OF DOCUMENT] line"
|
|
233
164
|
});
|
|
234
165
|
}
|
|
235
166
|
|
|
167
|
+
if (name === "read_documents") {
|
|
168
|
+
return readDocuments({ documents: corpus.documents, bySlug, envelope, version: String(corpus.package_version ?? "") }, args);
|
|
169
|
+
}
|
|
170
|
+
|
|
236
171
|
if (name === "list_documents") {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
(!args?.voice || d.voice === args.voice) &&
|
|
241
|
-
(!args?.category || d.category === args.category))
|
|
172
|
+
// ⭐ The SAME filter read_documents applies (read-tools.mjs), so "read what I
|
|
173
|
+
// just listed" cannot return a different set.
|
|
174
|
+
const list = filterDocuments(corpus.documents, args)
|
|
242
175
|
.map((d) => ({
|
|
243
176
|
slug: d.slug,
|
|
244
177
|
title: d.title,
|
|
@@ -255,10 +188,16 @@ const callTool = (name, args) => {
|
|
|
255
188
|
date: d.date,
|
|
256
189
|
licence: d.licence.id,
|
|
257
190
|
doi: d.provenance.doi,
|
|
258
|
-
opentimestamps: d.provenance.opentimestamps
|
|
191
|
+
opentimestamps: d.provenance.opentimestamps,
|
|
192
|
+
// A140: size before reading. Bytes and words, never tokens.
|
|
193
|
+
bytes: d.bytes,
|
|
194
|
+
words: d.words
|
|
259
195
|
}));
|
|
260
196
|
return structured({
|
|
261
197
|
count: list.length,
|
|
198
|
+
// For THIS filtered set — what reading it with read_documents would cost.
|
|
199
|
+
total_bytes: list.reduce((n, d) => n + (d.bytes ?? 0), 0),
|
|
200
|
+
total_words: list.reduce((n, d) => n + (d.words ?? 0), 0),
|
|
262
201
|
licences: corpus.licences,
|
|
263
202
|
// The shelves, so a caller can narrow without guessing the vocabulary.
|
|
264
203
|
categories: corpus.documents.reduce((a, d) => ((a[d.category ?? "uncategorised"] = (a[d.category ?? "uncategorised"] ?? 0) + 1), a), {}),
|
|
@@ -266,6 +205,10 @@ const callTool = (name, args) => {
|
|
|
266
205
|
// documents means nothing without the honest denominator beside it,
|
|
267
206
|
// the same reason list_predictions returns by_state unfiltered.
|
|
268
207
|
...(corpus.voices ? { voices: corpus.voices } : {}),
|
|
208
|
+
// A87: the manifest's counts and reasons beside the listing they account for.
|
|
209
|
+
...(corpus.manifest ? { completeness: completenessOf(corpus) } : {}),
|
|
210
|
+
// 2.4.1: what to call to read this listing in full — the same filters, as arguments.
|
|
211
|
+
...(readWith(args, list.length) ? { read_with: readWith(args, list.length) } : {}),
|
|
269
212
|
documents: list
|
|
270
213
|
});
|
|
271
214
|
}
|
|
@@ -292,6 +235,7 @@ const handlers = {
|
|
|
292
235
|
"carry a DOI and an OpenTimestamps proof anchored in Bitcoin, so you can check any passage you intend to " +
|
|
293
236
|
"cite rather than trusting this server. Documents under CC-BY carry attribute_to in their licence block; " +
|
|
294
237
|
"honour it. Text is returned verbatim and is never summarised, because a summary cannot be hash-verified." +
|
|
238
|
+
READ_INSTRUCTIONS +
|
|
295
239
|
(program ? PROGRAM_INSTRUCTIONS : "")
|
|
296
240
|
}),
|
|
297
241
|
// ⭐ withFacets fills list_documents' facet enumerations FROM THE CORPUS.
|