agent-protocols 0.9.0 → 0.10.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 +67 -0
- package/dist/discourse.d.ts.map +1 -1
- package/dist/discourse.js +5 -5
- package/dist/discourse.js.map +1 -1
- package/dist/encoding.d.ts +8 -0
- package/dist/encoding.d.ts.map +1 -0
- package/dist/encoding.js +47 -0
- package/dist/encoding.js.map +1 -0
- package/dist/http-client.d.ts +22 -0
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +131 -2
- package/dist/http-client.js.map +1 -1
- package/dist/identity.d.ts +17 -0
- package/dist/identity.d.ts.map +1 -1
- package/dist/identity.js +42 -11
- package/dist/identity.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge-schema.d.ts +978 -0
- package/dist/knowledge-schema.d.ts.map +1 -0
- package/dist/knowledge-schema.js +1187 -0
- package/dist/knowledge-schema.js.map +1 -0
- package/dist/knowledge.d.ts +275 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +796 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/local-connector/engine.d.ts.map +1 -1
- package/dist/local-connector/engine.js +1 -3
- package/dist/local-connector/engine.js.map +1 -1
- package/package.json +9 -4
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Knowledge 1.0. Signatures establish attribution, never scientific truth.
|
|
3
|
+
* Knowledge events are immutable, portable objects: acceptance never consults a
|
|
4
|
+
* live-write nonce cache, and nothing is fetched or executed implicitly.
|
|
5
|
+
*/
|
|
6
|
+
import { sha3_256 } from "@noble/hashes/sha3.js";
|
|
7
|
+
import canonicalize from "canonicalize";
|
|
8
|
+
import { Validator } from "@cfworker/json-schema";
|
|
9
|
+
import { base64UrlDecodeCanonical, base64UrlEncode } from "./encoding.js";
|
|
10
|
+
import { protocolError } from "./errors.js";
|
|
11
|
+
import { createEvent, parseStrictJson, validateOrigin, verifyEnvelope, } from "./identity.js";
|
|
12
|
+
import { KNOWLEDGE_SCHEMA } from "./knowledge-schema.js";
|
|
13
|
+
export { KNOWLEDGE_SCHEMA } from "./knowledge-schema.js";
|
|
14
|
+
export const KNOWLEDGE_PROTOCOL = "agent-knowledge/1.0";
|
|
15
|
+
export const KNOWLEDGE_ERROR_CODES = [
|
|
16
|
+
"missing_dependency",
|
|
17
|
+
"invalid_target",
|
|
18
|
+
"invalid_cursor",
|
|
19
|
+
"query_too_broad",
|
|
20
|
+
"query_unavailable",
|
|
21
|
+
"unsupported_search_mode",
|
|
22
|
+
];
|
|
23
|
+
export const KNOWLEDGE_EVENT_TYPES = [
|
|
24
|
+
"knowledge.publish",
|
|
25
|
+
"knowledge.assess",
|
|
26
|
+
"knowledge.retract",
|
|
27
|
+
];
|
|
28
|
+
export const KNOWLEDGE_KINDS = [
|
|
29
|
+
"question",
|
|
30
|
+
"hypothesis",
|
|
31
|
+
"definition",
|
|
32
|
+
"observation",
|
|
33
|
+
"inference",
|
|
34
|
+
"procedure",
|
|
35
|
+
"resource",
|
|
36
|
+
"negative_result",
|
|
37
|
+
"synthesis",
|
|
38
|
+
"collection",
|
|
39
|
+
];
|
|
40
|
+
export const KNOWLEDGE_RELATIONS = [
|
|
41
|
+
"derived_from",
|
|
42
|
+
"addresses",
|
|
43
|
+
"tests",
|
|
44
|
+
"extends",
|
|
45
|
+
"supports",
|
|
46
|
+
"contradicts",
|
|
47
|
+
"supersedes",
|
|
48
|
+
"contains",
|
|
49
|
+
];
|
|
50
|
+
export const KNOWLEDGE_VERDICTS = [
|
|
51
|
+
"supports",
|
|
52
|
+
"challenges",
|
|
53
|
+
"reproduced",
|
|
54
|
+
"not_reproduced",
|
|
55
|
+
"applied",
|
|
56
|
+
"inconclusive",
|
|
57
|
+
];
|
|
58
|
+
export const KNOWLEDGE_SEARCH_MODES = [
|
|
59
|
+
"lexical",
|
|
60
|
+
"semantic",
|
|
61
|
+
"hybrid",
|
|
62
|
+
];
|
|
63
|
+
/** Default allowance for `created_at` ahead of the receiver clock. */
|
|
64
|
+
export const DEFAULT_FUTURE_SKEW_MS = 300_000;
|
|
65
|
+
export const DEFAULT_MAX_ENVELOPE_BYTES = 262_144;
|
|
66
|
+
/** Relationships that may point at an assessment as well as a publication. */
|
|
67
|
+
const ASSESSMENT_RELATIONS = [
|
|
68
|
+
"derived_from",
|
|
69
|
+
"supports",
|
|
70
|
+
"contradicts",
|
|
71
|
+
];
|
|
72
|
+
export function knowledgePublishEvent(actor, createdAt, nonce, payload) {
|
|
73
|
+
return createEvent(KNOWLEDGE_PROTOCOL, "knowledge.publish", actor, createdAt, nonce, payload);
|
|
74
|
+
}
|
|
75
|
+
export function knowledgeAssessEvent(actor, createdAt, nonce, payload) {
|
|
76
|
+
return createEvent(KNOWLEDGE_PROTOCOL, "knowledge.assess", actor, createdAt, nonce, payload);
|
|
77
|
+
}
|
|
78
|
+
export function knowledgeRetractEvent(actor, createdAt, nonce, payload) {
|
|
79
|
+
return createEvent(KNOWLEDGE_PROTOCOL, "knowledge.retract", actor, createdAt, nonce, payload);
|
|
80
|
+
}
|
|
81
|
+
const validators = new Map();
|
|
82
|
+
/** Structure only. Signatures, canonical encodings and references are separate checks. */
|
|
83
|
+
export function validateKnowledgeSchema(value, definition = "signedEnvelope", code = "invalid_event") {
|
|
84
|
+
let validator = validators.get(definition);
|
|
85
|
+
if (!validator) {
|
|
86
|
+
if (!Object.hasOwn(KNOWLEDGE_SCHEMA.$defs, definition))
|
|
87
|
+
throw protocolError(code, "unknown schema definition");
|
|
88
|
+
validator = new Validator({
|
|
89
|
+
$ref: `#/$defs/${definition}`,
|
|
90
|
+
$defs: KNOWLEDGE_SCHEMA.$defs,
|
|
91
|
+
}, "2020-12", true);
|
|
92
|
+
validators.set(definition, validator);
|
|
93
|
+
}
|
|
94
|
+
jsonCheck(value, code);
|
|
95
|
+
if (!validator.validate(value).valid)
|
|
96
|
+
throw protocolError(code, `invalid ${definition} structure`);
|
|
97
|
+
}
|
|
98
|
+
function jsonCheck(value, code = "invalid_event") {
|
|
99
|
+
try {
|
|
100
|
+
const visited = new Set();
|
|
101
|
+
const inspect = (input, depth) => {
|
|
102
|
+
if (depth > 256)
|
|
103
|
+
throw new Error("nesting limit");
|
|
104
|
+
if (input === null ||
|
|
105
|
+
typeof input === "boolean" ||
|
|
106
|
+
typeof input === "string")
|
|
107
|
+
return;
|
|
108
|
+
if (typeof input === "number") {
|
|
109
|
+
if (!Number.isFinite(input) ||
|
|
110
|
+
(Number.isInteger(input) && !Number.isSafeInteger(input)))
|
|
111
|
+
throw new Error("invalid number");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (typeof input !== "object" || visited.has(input))
|
|
115
|
+
throw new Error("non-JSON value");
|
|
116
|
+
const prototype = Object.getPrototypeOf(input);
|
|
117
|
+
if (!Array.isArray(input) &&
|
|
118
|
+
prototype !== null &&
|
|
119
|
+
prototype !== Object.prototype)
|
|
120
|
+
throw new Error("non-JSON object");
|
|
121
|
+
visited.add(input);
|
|
122
|
+
if (Array.isArray(input))
|
|
123
|
+
for (let i = 0; i < input.length; i++)
|
|
124
|
+
inspect(input[i], depth + 1);
|
|
125
|
+
else
|
|
126
|
+
for (const key of Reflect.ownKeys(input)) {
|
|
127
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, key);
|
|
128
|
+
if (typeof key !== "string" ||
|
|
129
|
+
!descriptor.enumerable ||
|
|
130
|
+
!("value" in descriptor))
|
|
131
|
+
throw new Error("non-JSON member");
|
|
132
|
+
inspect(descriptor.value, depth + 1);
|
|
133
|
+
}
|
|
134
|
+
visited.delete(input);
|
|
135
|
+
};
|
|
136
|
+
inspect(value, 0);
|
|
137
|
+
const text = JSON.stringify(value);
|
|
138
|
+
if (text === undefined)
|
|
139
|
+
throw new Error();
|
|
140
|
+
parseStrictJson(text);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
throw protocolError(code, "value violates strict I-JSON");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function validateKnowledgeId(value, code = "invalid_event") {
|
|
147
|
+
if (typeof value !== "string" ||
|
|
148
|
+
!/^[A-Za-z0-9_-]{43}$/.test(value) ||
|
|
149
|
+
base64UrlDecodeCanonical(value) === undefined)
|
|
150
|
+
throw protocolError(code, "ID must canonically encode 32 bytes");
|
|
151
|
+
}
|
|
152
|
+
function https(value, code = "invalid_event") {
|
|
153
|
+
try {
|
|
154
|
+
const u = new URL(value);
|
|
155
|
+
if (u.protocol !== "https:" ||
|
|
156
|
+
!u.hostname ||
|
|
157
|
+
u.username ||
|
|
158
|
+
u.password ||
|
|
159
|
+
/[\\\x00-\x20]/.test(value))
|
|
160
|
+
throw new Error();
|
|
161
|
+
return u;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
throw protocolError(code, "URL requires absolute HTTPS, a host and no userinfo");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
const sha3 = (bytes) => base64UrlEncode(sha3_256(typeof bytes === "string" ? new TextEncoder().encode(bytes) : bytes));
|
|
168
|
+
/** Sorted, distinct direct dependencies. */
|
|
169
|
+
export function knowledgeDependencies(item) {
|
|
170
|
+
return item.event.type === "knowledge.publish"
|
|
171
|
+
? [
|
|
172
|
+
...new Set(item.event.payload.relations?.map((link) => link.target) ?? []),
|
|
173
|
+
].sort()
|
|
174
|
+
: [
|
|
175
|
+
item.event.payload
|
|
176
|
+
.target,
|
|
177
|
+
];
|
|
178
|
+
}
|
|
179
|
+
/** Structure, Identity hash/signature, URLs, canonical IDs and profile uniqueness. Dependencies are separate. */
|
|
180
|
+
export function validateKnowledgeEnvelope(value) {
|
|
181
|
+
validateKnowledgeSchema(value);
|
|
182
|
+
const item = value;
|
|
183
|
+
verifyEnvelope(item);
|
|
184
|
+
validateKnowledgeId(item.hash);
|
|
185
|
+
const payload = item.event.payload;
|
|
186
|
+
https(payload.license);
|
|
187
|
+
if ("evidence" in payload)
|
|
188
|
+
for (const ref of payload.evidence ?? []) {
|
|
189
|
+
https(ref.url);
|
|
190
|
+
if (ref.digest !== undefined)
|
|
191
|
+
validateKnowledgeId(ref.digest);
|
|
192
|
+
}
|
|
193
|
+
if ("profiles" in payload) {
|
|
194
|
+
const seen = new Set();
|
|
195
|
+
for (const binding of payload.profiles ?? []) {
|
|
196
|
+
https(binding.profile.url);
|
|
197
|
+
validateKnowledgeId(binding.profile.digest);
|
|
198
|
+
if (seen.has(binding.profile.digest))
|
|
199
|
+
throw protocolError("invalid_event", "duplicate profile digest");
|
|
200
|
+
seen.add(binding.profile.digest);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const target of knowledgeDependencies(item))
|
|
204
|
+
validateKnowledgeId(target);
|
|
205
|
+
}
|
|
206
|
+
export function parseKnowledgeEnvelope(text) {
|
|
207
|
+
const value = parseStrictJson(text);
|
|
208
|
+
validateKnowledgeEnvelope(value);
|
|
209
|
+
return value;
|
|
210
|
+
}
|
|
211
|
+
function knownMap(known) {
|
|
212
|
+
return known instanceof Map ? known : new Map(Object.entries(known));
|
|
213
|
+
}
|
|
214
|
+
/** Check target rules against retained envelopes that already passed validation. */
|
|
215
|
+
export function validateKnowledgeDependencies(item, known) {
|
|
216
|
+
const retained = knownMap(known);
|
|
217
|
+
checkDependencies(item, (id) => retained.get(id));
|
|
218
|
+
}
|
|
219
|
+
function checkDependencies(item, lookup) {
|
|
220
|
+
const missing = knowledgeDependencies(item).filter((id) => lookup(id) === undefined);
|
|
221
|
+
if (missing.length)
|
|
222
|
+
throw protocolError("missing_dependency", "unresolved dependencies", {
|
|
223
|
+
missing,
|
|
224
|
+
});
|
|
225
|
+
const links = item.event.type === "knowledge.publish"
|
|
226
|
+
? (item.event.payload.relations ?? [])
|
|
227
|
+
: [
|
|
228
|
+
{
|
|
229
|
+
relation: item.event.type,
|
|
230
|
+
target: item.event.payload.target,
|
|
231
|
+
},
|
|
232
|
+
];
|
|
233
|
+
for (const link of links) {
|
|
234
|
+
const target = lookup(link.target).event;
|
|
235
|
+
const allowed = link.relation === "knowledge.retract" ||
|
|
236
|
+
ASSESSMENT_RELATIONS.includes(link.relation)
|
|
237
|
+
? ["knowledge.publish", "knowledge.assess"]
|
|
238
|
+
: ["knowledge.publish"];
|
|
239
|
+
if (!allowed.includes(target.type))
|
|
240
|
+
throw protocolError("invalid_target", "wrong target type");
|
|
241
|
+
const kind = link.relation === "addresses"
|
|
242
|
+
? "question"
|
|
243
|
+
: link.relation === "tests"
|
|
244
|
+
? "hypothesis"
|
|
245
|
+
: undefined;
|
|
246
|
+
if (kind && target.payload.kind !== kind)
|
|
247
|
+
throw protocolError("invalid_target", "wrong target contribution kind");
|
|
248
|
+
if (["supersedes", "knowledge.retract"].includes(link.relation) &&
|
|
249
|
+
(target.actor !== item.event.actor || target.nonce >= item.event.nonce))
|
|
250
|
+
throw protocolError("invalid_target", "target requires same actor and smaller nonce");
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
/** Validates a dependency-closed dataset, then derives order-independent local facts. */
|
|
254
|
+
export function materializeKnowledge(known) {
|
|
255
|
+
const retained = knownMap(known);
|
|
256
|
+
for (const [id, item] of retained) {
|
|
257
|
+
validateKnowledgeEnvelope(item);
|
|
258
|
+
if (id !== item.hash)
|
|
259
|
+
throw protocolError("invalid_event", "dataset ID mismatch");
|
|
260
|
+
}
|
|
261
|
+
for (const item of retained.values())
|
|
262
|
+
validateKnowledgeDependencies(item, retained);
|
|
263
|
+
const withdrawn = new Set();
|
|
264
|
+
const successors = new Map();
|
|
265
|
+
const assessments = new Map();
|
|
266
|
+
const append = (index, target, id) => {
|
|
267
|
+
let ids = index.get(target);
|
|
268
|
+
if (!ids) {
|
|
269
|
+
ids = new Set();
|
|
270
|
+
index.set(target, ids);
|
|
271
|
+
}
|
|
272
|
+
ids.add(id);
|
|
273
|
+
};
|
|
274
|
+
for (const [id, item] of retained) {
|
|
275
|
+
if (item.event.type === "knowledge.retract")
|
|
276
|
+
withdrawn.add(item.event.payload.target);
|
|
277
|
+
else if (item.event.type === "knowledge.assess")
|
|
278
|
+
append(assessments, item.event.payload.target, id);
|
|
279
|
+
else
|
|
280
|
+
for (const link of item.event.payload
|
|
281
|
+
.relations ?? [])
|
|
282
|
+
if (link.relation === "supersedes")
|
|
283
|
+
append(successors, link.target, id);
|
|
284
|
+
}
|
|
285
|
+
const result = {};
|
|
286
|
+
for (const [id, item] of [...retained.entries()].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
287
|
+
if (item.event.type === "knowledge.retract")
|
|
288
|
+
continue;
|
|
289
|
+
const view = {
|
|
290
|
+
status: withdrawn.has(id) ? "retracted" : "active",
|
|
291
|
+
};
|
|
292
|
+
if (item.event.type === "knowledge.publish") {
|
|
293
|
+
view.successors = [...(successors.get(id) ?? [])].sort();
|
|
294
|
+
view.assessments = [...(assessments.get(id) ?? [])].sort();
|
|
295
|
+
view.active_assessments = view.assessments.filter((key) => !withdrawn.has(key));
|
|
296
|
+
}
|
|
297
|
+
result[id] = view;
|
|
298
|
+
}
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Compare a digest with complete representation bytes (after transfer/content
|
|
303
|
+
* decoding, before text conversion). Pass `null` or `undefined` bytes when the
|
|
304
|
+
* complete representation could not be obtained. No fetching occurs.
|
|
305
|
+
*/
|
|
306
|
+
export function verifyKnowledgeEvidence(digest, bytes) {
|
|
307
|
+
if (digest === undefined)
|
|
308
|
+
return "unchecked";
|
|
309
|
+
validateKnowledgeId(digest);
|
|
310
|
+
if (bytes == null)
|
|
311
|
+
return "unavailable";
|
|
312
|
+
return sha3(bytes) === digest ? "matched" : "mismatched";
|
|
313
|
+
}
|
|
314
|
+
const fold = (value) => value.replace(/[A-Z]/g, (char) => char.toLowerCase());
|
|
315
|
+
export function knowledgeTextTerms(value, lexical = true) {
|
|
316
|
+
if (typeof value !== "string" ||
|
|
317
|
+
[...value].length < 1 ||
|
|
318
|
+
[...value].length > 1024 ||
|
|
319
|
+
/[\uD800-\uDFFF]/u.test(value))
|
|
320
|
+
throw protocolError("invalid_request", "text requires 1..1024 Unicode scalars");
|
|
321
|
+
const terms = value.split(/[\x09-\x0d\x20]+/).filter(Boolean);
|
|
322
|
+
if (!terms.length || (lexical && terms.length > 16))
|
|
323
|
+
throw protocolError("invalid_request", "empty text or too many lexical terms");
|
|
324
|
+
return terms.map(fold);
|
|
325
|
+
}
|
|
326
|
+
export function knowledgeTextMatches(item, text) {
|
|
327
|
+
const p = item.event.payload;
|
|
328
|
+
let fields;
|
|
329
|
+
if (item.event.type === "knowledge.retract")
|
|
330
|
+
fields = [p.reason];
|
|
331
|
+
else {
|
|
332
|
+
const r = p;
|
|
333
|
+
fields =
|
|
334
|
+
item.event.type === "knowledge.publish"
|
|
335
|
+
? [
|
|
336
|
+
p.title,
|
|
337
|
+
p.statement,
|
|
338
|
+
r.basis,
|
|
339
|
+
]
|
|
340
|
+
: [p.summary, r.basis];
|
|
341
|
+
fields.push(r.context.scope, ...r.context.conditions, ...r.context.limitations);
|
|
342
|
+
}
|
|
343
|
+
const folded = fields.map(fold);
|
|
344
|
+
return knowledgeTextTerms(text).every((term) => folded.some((field) => field.includes(term)));
|
|
345
|
+
}
|
|
346
|
+
function filterCheck(filters) {
|
|
347
|
+
validateKnowledgeSchema(filters, "searchFilters", "invalid_request");
|
|
348
|
+
for (const key of ["actor", "target", "profile"])
|
|
349
|
+
if (filters[key] !== undefined)
|
|
350
|
+
validateKnowledgeId(key === "actor" ? filters[key].slice(10) : filters[key], "invalid_request");
|
|
351
|
+
if (filters.created_from !== undefined &&
|
|
352
|
+
filters.created_before !== undefined &&
|
|
353
|
+
filters.created_from >= filters.created_before)
|
|
354
|
+
throw protocolError("invalid_request", "time range must be nonempty");
|
|
355
|
+
}
|
|
356
|
+
export function validateKnowledgeQuery(value) {
|
|
357
|
+
validateKnowledgeSchema(value, "queryRequest", "invalid_request");
|
|
358
|
+
const { q, limit: _l, cursor: _c, after_seq: _a, ...filters } = value;
|
|
359
|
+
filterCheck(filters);
|
|
360
|
+
if (q !== undefined)
|
|
361
|
+
knowledgeTextTerms(q);
|
|
362
|
+
}
|
|
363
|
+
const INTEGER_PARAMETERS = [
|
|
364
|
+
"created_from",
|
|
365
|
+
"created_before",
|
|
366
|
+
"after_seq",
|
|
367
|
+
"limit",
|
|
368
|
+
];
|
|
369
|
+
/** Parse decoded HTTP pairs without discarding duplicate parameter names. */
|
|
370
|
+
export function parseKnowledgeQuery(parameters) {
|
|
371
|
+
const result = {};
|
|
372
|
+
for (const [key, input] of parameters) {
|
|
373
|
+
if (Object.hasOwn(result, key) ||
|
|
374
|
+
!Object.hasOwn(KNOWLEDGE_SCHEMA.$defs.queryRequest.properties, key))
|
|
375
|
+
throw protocolError("invalid_request", "unknown or repeated query parameter");
|
|
376
|
+
let value = input;
|
|
377
|
+
if (INTEGER_PARAMETERS.includes(key)) {
|
|
378
|
+
if (!/^[0-9]+$/.test(input) || input.replace(/^0+/, "").length > 16)
|
|
379
|
+
throw protocolError("invalid_request", "HTTP integers require decimal digits");
|
|
380
|
+
value = Number(input);
|
|
381
|
+
}
|
|
382
|
+
Object.defineProperty(result, key, { value, enumerable: true });
|
|
383
|
+
}
|
|
384
|
+
validateKnowledgeQuery(result);
|
|
385
|
+
return result;
|
|
386
|
+
}
|
|
387
|
+
/** Exact filter and text predicate; `after_seq`, `limit` and `cursor` are not payload filters. */
|
|
388
|
+
export function knowledgeQueryMatches(item, filters) {
|
|
389
|
+
const event = item.event, p = event.payload, publication = event.type === "knowledge.publish", pub = p;
|
|
390
|
+
if ((filters.actor !== undefined && event.actor !== filters.actor) ||
|
|
391
|
+
(filters.type !== undefined && event.type !== filters.type))
|
|
392
|
+
return false;
|
|
393
|
+
if ("q" in filters &&
|
|
394
|
+
filters.q !== undefined &&
|
|
395
|
+
!knowledgeTextMatches(item, filters.q))
|
|
396
|
+
return false;
|
|
397
|
+
if (filters.language !== undefined &&
|
|
398
|
+
(!publication || fold(pub.language) !== fold(filters.language)))
|
|
399
|
+
return false;
|
|
400
|
+
if (filters.verdict !== undefined &&
|
|
401
|
+
(event.type !== "knowledge.assess" ||
|
|
402
|
+
p.verdict !== filters.verdict))
|
|
403
|
+
return false;
|
|
404
|
+
if ((filters.created_from !== undefined &&
|
|
405
|
+
event.created_at < filters.created_from) ||
|
|
406
|
+
(filters.created_before !== undefined &&
|
|
407
|
+
event.created_at >= filters.created_before))
|
|
408
|
+
return false;
|
|
409
|
+
if (filters.kind !== undefined && (!publication || pub.kind !== filters.kind))
|
|
410
|
+
return false;
|
|
411
|
+
if (filters.tag !== undefined &&
|
|
412
|
+
(!publication || !pub.tags?.includes(filters.tag)))
|
|
413
|
+
return false;
|
|
414
|
+
if (filters.profile !== undefined &&
|
|
415
|
+
!p.profiles?.some((b) => b.profile.digest === filters.profile))
|
|
416
|
+
return false;
|
|
417
|
+
if (filters.relation !== undefined) {
|
|
418
|
+
if (!publication ||
|
|
419
|
+
!pub.relations?.some((r) => r.relation === filters.relation &&
|
|
420
|
+
(filters.target === undefined || r.target === filters.target)))
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
else if (filters.target !== undefined &&
|
|
424
|
+
!knowledgeDependencies(item).includes(filters.target))
|
|
425
|
+
return false;
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
export function parseKnowledgeReadJson(raw) {
|
|
429
|
+
try {
|
|
430
|
+
return parseStrictJson(raw);
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
throw protocolError("invalid_request", "read body violates strict I-JSON");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
export function validateKnowledgeBatchRequest(value) {
|
|
437
|
+
validateKnowledgeSchema(value, "batchRequest", "invalid_request");
|
|
438
|
+
for (const id of value.hashes)
|
|
439
|
+
validateKnowledgeId(id, "invalid_request");
|
|
440
|
+
}
|
|
441
|
+
/** An unadvertised mode is `unsupported_search_mode` and is never substituted. */
|
|
442
|
+
export function validateKnowledgeSearchRequest(value, modes = KNOWLEDGE_SEARCH_MODES) {
|
|
443
|
+
jsonCheck(value, "invalid_request");
|
|
444
|
+
const mode = value?.mode;
|
|
445
|
+
if (typeof mode === "string" &&
|
|
446
|
+
(!KNOWLEDGE_SEARCH_MODES.includes(mode) ||
|
|
447
|
+
!modes.includes(mode)))
|
|
448
|
+
throw protocolError("unsupported_search_mode", "requested mode is not advertised");
|
|
449
|
+
validateKnowledgeSchema(value, "searchRequest", "invalid_request");
|
|
450
|
+
const request = value;
|
|
451
|
+
knowledgeTextTerms(request.text, mode === "lexical");
|
|
452
|
+
filterCheck(request.filters ?? {});
|
|
453
|
+
}
|
|
454
|
+
export function validateKnowledgeRecord(value, expectedId) {
|
|
455
|
+
validateKnowledgeSchema(value, "acceptanceRecord", "invalid_response");
|
|
456
|
+
const record = value;
|
|
457
|
+
try {
|
|
458
|
+
validateKnowledgeEnvelope(record.envelope);
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
throw protocolError("invalid_response", "invalid returned envelope");
|
|
462
|
+
}
|
|
463
|
+
if (expectedId !== undefined && record.envelope.hash !== expectedId)
|
|
464
|
+
throw protocolError("invalid_response", "returned envelope ID does not match request");
|
|
465
|
+
}
|
|
466
|
+
function responseRecords(value, definition, service) {
|
|
467
|
+
validateKnowledgeSchema(value, definition, "invalid_response");
|
|
468
|
+
const response = value;
|
|
469
|
+
try {
|
|
470
|
+
validateOrigin(response.service);
|
|
471
|
+
}
|
|
472
|
+
catch {
|
|
473
|
+
throw protocolError("invalid_response", "noncanonical service origin");
|
|
474
|
+
}
|
|
475
|
+
if (response.service !== service)
|
|
476
|
+
throw protocolError("invalid_response", "response service differs from receiving origin");
|
|
477
|
+
const records = definition === "searchResponse"
|
|
478
|
+
? response.result.map((hit) => hit.record)
|
|
479
|
+
: response.result;
|
|
480
|
+
const ids = new Set();
|
|
481
|
+
for (const record of records) {
|
|
482
|
+
if (record.seq > response.checkpoint || ids.has(record.envelope.hash))
|
|
483
|
+
throw protocolError("invalid_response", "duplicate event or record beyond checkpoint");
|
|
484
|
+
ids.add(record.envelope.hash);
|
|
485
|
+
validateKnowledgeRecord(record);
|
|
486
|
+
}
|
|
487
|
+
return records;
|
|
488
|
+
}
|
|
489
|
+
export function validateKnowledgeQueryResponse(value, request, service) {
|
|
490
|
+
const records = responseRecords(value, "queryResponse", service);
|
|
491
|
+
if (records.length > (request.limit ?? 100))
|
|
492
|
+
throw protocolError("invalid_response", "query page exceeds limit");
|
|
493
|
+
let previous = request.after_seq ?? 0;
|
|
494
|
+
for (const record of records) {
|
|
495
|
+
if (record.seq <= previous ||
|
|
496
|
+
!knowledgeQueryMatches(record.envelope, request))
|
|
497
|
+
throw protocolError("invalid_response", "query violated order or exact filters");
|
|
498
|
+
previous = record.seq;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
export function validateKnowledgeBatchResponse(value, hashes, service) {
|
|
502
|
+
const result = responseRecords(value, "batchResponse", service).map((r) => r.envelope.hash), missing = value.missing;
|
|
503
|
+
if (result.some((id) => missing.includes(id)) ||
|
|
504
|
+
new Set([...result, ...missing]).size !== hashes.length ||
|
|
505
|
+
[...result, ...missing].some((id) => !hashes.includes(id)) ||
|
|
506
|
+
canonicalize(result) !==
|
|
507
|
+
canonicalize(hashes.filter((id) => result.includes(id))) ||
|
|
508
|
+
canonicalize(missing) !==
|
|
509
|
+
canonicalize(hashes.filter((id) => missing.includes(id))))
|
|
510
|
+
throw protocolError("invalid_response", "batch must form complete ordered partition");
|
|
511
|
+
}
|
|
512
|
+
export function validateKnowledgeSearchResponse(value, request, service) {
|
|
513
|
+
const records = responseRecords(value, "searchResponse", service);
|
|
514
|
+
const response = value;
|
|
515
|
+
if (records.length > (request.limit ?? 20) ||
|
|
516
|
+
response.ranking.mode !== request.mode)
|
|
517
|
+
throw protocolError("invalid_response", "search limit or mode mismatch");
|
|
518
|
+
for (const record of records)
|
|
519
|
+
if (!knowledgeQueryMatches(record.envelope, request.filters ?? {}) ||
|
|
520
|
+
(request.mode === "lexical" &&
|
|
521
|
+
!knowledgeTextMatches(record.envelope, request.text)))
|
|
522
|
+
throw protocolError("invalid_response", "search violated exact filters or lexical text");
|
|
523
|
+
}
|
|
524
|
+
export function validateKnowledgeDiscovery(value, origin) {
|
|
525
|
+
validateKnowledgeSchema(value, "discoveryDocument", "invalid_discovery");
|
|
526
|
+
const d = value;
|
|
527
|
+
try {
|
|
528
|
+
validateOrigin(origin);
|
|
529
|
+
validateOrigin(d.service);
|
|
530
|
+
if (d.service !== origin)
|
|
531
|
+
throw new Error();
|
|
532
|
+
for (const endpoint of Object.values(d.endpoints ?? {})) {
|
|
533
|
+
const url = https(endpoint);
|
|
534
|
+
if (url.origin !== origin || url.search || url.hash)
|
|
535
|
+
throw new Error();
|
|
536
|
+
}
|
|
537
|
+
for (const peer of d.peers ?? []) {
|
|
538
|
+
validateOrigin(peer);
|
|
539
|
+
if (peer === origin)
|
|
540
|
+
throw new Error();
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
throw protocolError("invalid_discovery", "invalid service, endpoint or peer");
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** Effective request for cursor binding: no cursor, defaults applied. */
|
|
548
|
+
function queryBinding(request) {
|
|
549
|
+
const { cursor: _c, ...rest } = request;
|
|
550
|
+
return canonicalize({
|
|
551
|
+
...rest,
|
|
552
|
+
limit: request.limit ?? 100,
|
|
553
|
+
after_seq: request.after_seq ?? 0,
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
/** Verifies that query pages form one complete, consistent enumeration. */
|
|
557
|
+
export class KnowledgePageTracker {
|
|
558
|
+
service;
|
|
559
|
+
binding;
|
|
560
|
+
scope;
|
|
561
|
+
last = 0;
|
|
562
|
+
next;
|
|
563
|
+
seen = new Set();
|
|
564
|
+
constructor(service) {
|
|
565
|
+
this.service = service;
|
|
566
|
+
validateOrigin(service);
|
|
567
|
+
}
|
|
568
|
+
get complete() {
|
|
569
|
+
return this.binding !== undefined && this.next === undefined;
|
|
570
|
+
}
|
|
571
|
+
/** The next poll's `after_seq`, available only after every page was consumed. */
|
|
572
|
+
get checkpoint() {
|
|
573
|
+
return this.complete ? JSON.parse(this.scope).checkpoint : undefined;
|
|
574
|
+
}
|
|
575
|
+
accept(request, response) {
|
|
576
|
+
validateKnowledgeQuery(request);
|
|
577
|
+
validateKnowledgeQueryResponse(response, request, this.service);
|
|
578
|
+
const binding = queryBinding(request);
|
|
579
|
+
const scope = canonicalize({
|
|
580
|
+
service: response.service,
|
|
581
|
+
checkpoint: response.checkpoint,
|
|
582
|
+
as_of: response.as_of,
|
|
583
|
+
});
|
|
584
|
+
if (this.binding === undefined) {
|
|
585
|
+
if (request.cursor !== undefined)
|
|
586
|
+
throw protocolError("invalid_response", "a traversal must start without a cursor");
|
|
587
|
+
this.last = request.after_seq ?? 0;
|
|
588
|
+
}
|
|
589
|
+
else if (this.next === undefined ||
|
|
590
|
+
request.cursor !== this.next ||
|
|
591
|
+
binding !== this.binding ||
|
|
592
|
+
scope !== this.scope)
|
|
593
|
+
throw protocolError("invalid_response", "pagination request or checkpoint scope changed");
|
|
594
|
+
const first = response.result[0];
|
|
595
|
+
if ((first !== undefined && first.seq <= this.last) ||
|
|
596
|
+
response.result.some((record) => this.seen.has(record.envelope.hash)))
|
|
597
|
+
throw protocolError("invalid_response", "pagination repeated an event or moved backwards");
|
|
598
|
+
for (const record of response.result)
|
|
599
|
+
this.seen.add(record.envelope.hash);
|
|
600
|
+
if (response.result.length)
|
|
601
|
+
this.last = response.result.at(-1).seq;
|
|
602
|
+
this.binding = binding;
|
|
603
|
+
this.scope = scope;
|
|
604
|
+
this.next = response.next_cursor;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Synchronous in-memory reference service engine with checkpoint-bound,
|
|
609
|
+
* stateless query cursors. Applications supply persistence and HTTP policy.
|
|
610
|
+
*/
|
|
611
|
+
export class KnowledgeStore {
|
|
612
|
+
service;
|
|
613
|
+
clock;
|
|
614
|
+
futureSkewMs;
|
|
615
|
+
maxEnvelopeBytes;
|
|
616
|
+
admit;
|
|
617
|
+
/** Insertion order is ascending seq: new records always get a larger seq. */
|
|
618
|
+
records = new Map();
|
|
619
|
+
hidden = new Set();
|
|
620
|
+
highWater = 0;
|
|
621
|
+
constructor(options) {
|
|
622
|
+
validateOrigin(options.service);
|
|
623
|
+
this.service = options.service;
|
|
624
|
+
this.clock = options.clock ?? Date.now;
|
|
625
|
+
this.futureSkewMs = options.futureSkewMs ?? DEFAULT_FUTURE_SKEW_MS;
|
|
626
|
+
this.maxEnvelopeBytes =
|
|
627
|
+
options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
|
|
628
|
+
this.admit = options.admit;
|
|
629
|
+
for (const [name, value] of [
|
|
630
|
+
["futureSkewMs", this.futureSkewMs],
|
|
631
|
+
["maxEnvelopeBytes", this.maxEnvelopeBytes],
|
|
632
|
+
])
|
|
633
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
634
|
+
throw protocolError("invalid_request", `invalid store option ${name}`);
|
|
635
|
+
}
|
|
636
|
+
get checkpoint() {
|
|
637
|
+
return this.highWater;
|
|
638
|
+
}
|
|
639
|
+
now() {
|
|
640
|
+
const now = this.clock();
|
|
641
|
+
if (!Number.isSafeInteger(now) || now < 0)
|
|
642
|
+
throw protocolError("invalid_request", "invalid service clock");
|
|
643
|
+
return now;
|
|
644
|
+
}
|
|
645
|
+
visible(id) {
|
|
646
|
+
return this.records.has(id) && !this.hidden.has(id);
|
|
647
|
+
}
|
|
648
|
+
/** Publicly visible envelopes keyed by event ID; copies cannot mutate storage. */
|
|
649
|
+
knownEnvelopes() {
|
|
650
|
+
return new Map([...this.records]
|
|
651
|
+
.filter(([id]) => this.visible(id))
|
|
652
|
+
.map(([id, record]) => [id, structuredClone(record.envelope)]));
|
|
653
|
+
}
|
|
654
|
+
/** Accept a signed event, or return the original record of an exact resubmission. */
|
|
655
|
+
submit(envelope, now = this.now()) {
|
|
656
|
+
if (!Number.isSafeInteger(now) || now < 0)
|
|
657
|
+
throw protocolError("invalid_request", "invalid service clock");
|
|
658
|
+
const copy = structuredClone(envelope);
|
|
659
|
+
validateKnowledgeEnvelope(copy);
|
|
660
|
+
const retained = this.records.get(copy.hash);
|
|
661
|
+
if (retained)
|
|
662
|
+
return structuredClone(retained);
|
|
663
|
+
if (copy.event.created_at > now + this.futureSkewMs)
|
|
664
|
+
throw protocolError("timestamp_out_of_window", "created_at is too far in the future");
|
|
665
|
+
checkDependencies(copy, (id) => this.records.get(id)?.envelope);
|
|
666
|
+
if (new TextEncoder().encode(JSON.stringify(copy)).length > this.maxEnvelopeBytes)
|
|
667
|
+
throw protocolError("payload_too_large", "envelope exceeds configured byte limit");
|
|
668
|
+
if (this.highWater === Number.MAX_SAFE_INTEGER)
|
|
669
|
+
throw protocolError("permission_denied", "sequence space exhausted");
|
|
670
|
+
this.admit?.(structuredClone(copy));
|
|
671
|
+
const record = {
|
|
672
|
+
envelope: copy,
|
|
673
|
+
accepted_at: now,
|
|
674
|
+
seq: ++this.highWater,
|
|
675
|
+
};
|
|
676
|
+
this.records.set(copy.hash, record);
|
|
677
|
+
return structuredClone(record);
|
|
678
|
+
}
|
|
679
|
+
event(id) {
|
|
680
|
+
validateKnowledgeId(id, "invalid_request");
|
|
681
|
+
if (!this.visible(id))
|
|
682
|
+
throw protocolError("not_found", "event unavailable");
|
|
683
|
+
return structuredClone(this.records.get(id));
|
|
684
|
+
}
|
|
685
|
+
/** Withhold from public reads; the record still answers exact retries and resolves dependencies. */
|
|
686
|
+
hide(id) {
|
|
687
|
+
if (this.records.has(id))
|
|
688
|
+
this.hidden.add(id);
|
|
689
|
+
}
|
|
690
|
+
unhide(id) {
|
|
691
|
+
this.hidden.delete(id);
|
|
692
|
+
}
|
|
693
|
+
/** Drop content and record; the sequence high-water mark is preserved. */
|
|
694
|
+
prune(id) {
|
|
695
|
+
this.records.delete(id);
|
|
696
|
+
this.hidden.delete(id);
|
|
697
|
+
}
|
|
698
|
+
scope(checkpoint, asOf) {
|
|
699
|
+
return { service: this.service, checkpoint, as_of: asOf };
|
|
700
|
+
}
|
|
701
|
+
batch(request) {
|
|
702
|
+
validateKnowledgeBatchRequest(request);
|
|
703
|
+
const response = {
|
|
704
|
+
result: [],
|
|
705
|
+
missing: [],
|
|
706
|
+
...this.scope(this.highWater, this.now()),
|
|
707
|
+
};
|
|
708
|
+
for (const id of request.hashes)
|
|
709
|
+
if (this.visible(id))
|
|
710
|
+
response.result.push(structuredClone(this.records.get(id)));
|
|
711
|
+
else
|
|
712
|
+
response.missing.push(id);
|
|
713
|
+
return response;
|
|
714
|
+
}
|
|
715
|
+
query(request = {}) {
|
|
716
|
+
validateKnowledgeQuery(request);
|
|
717
|
+
const digest = sha3(queryBinding(request));
|
|
718
|
+
const after = request.after_seq ?? 0, limit = request.limit ?? 100;
|
|
719
|
+
let checkpoint, asOf, last;
|
|
720
|
+
if (request.cursor !== undefined)
|
|
721
|
+
[checkpoint, asOf, last] = this.decodeCursor(request.cursor, digest, after);
|
|
722
|
+
else {
|
|
723
|
+
if (after > this.highWater)
|
|
724
|
+
throw protocolError("invalid_request", "after_seq is greater than the current checkpoint");
|
|
725
|
+
[checkpoint, asOf, last] = [this.highWater, this.now(), after];
|
|
726
|
+
}
|
|
727
|
+
const response = {
|
|
728
|
+
result: [],
|
|
729
|
+
...this.scope(checkpoint, asOf),
|
|
730
|
+
};
|
|
731
|
+
for (const [id, record] of this.records) {
|
|
732
|
+
if (record.seq <= last || record.seq > checkpoint || this.hidden.has(id))
|
|
733
|
+
continue;
|
|
734
|
+
if (!knowledgeQueryMatches(record.envelope, request))
|
|
735
|
+
continue;
|
|
736
|
+
if (response.result.length === limit) {
|
|
737
|
+
response.next_cursor = `${checkpoint}.${asOf}.${response.result.at(-1).seq}.${digest}`;
|
|
738
|
+
break;
|
|
739
|
+
}
|
|
740
|
+
response.result.push(structuredClone(record));
|
|
741
|
+
}
|
|
742
|
+
return response;
|
|
743
|
+
}
|
|
744
|
+
decodeCursor(cursor, digest, after) {
|
|
745
|
+
const parts = cursor.split(".");
|
|
746
|
+
if (parts.length !== 4 ||
|
|
747
|
+
parts[3] !== digest ||
|
|
748
|
+
parts.slice(0, 3).some((part) => !/^[0-9]{1,16}$/.test(part)))
|
|
749
|
+
throw protocolError("invalid_cursor", "malformed cursor or different request");
|
|
750
|
+
const [checkpoint, asOf, last] = parts.slice(0, 3).map(Number);
|
|
751
|
+
if (!(after <= last && last <= checkpoint && checkpoint <= this.highWater) ||
|
|
752
|
+
!Number.isSafeInteger(asOf))
|
|
753
|
+
throw protocolError("invalid_cursor", "cursor does not belong to this service state");
|
|
754
|
+
return [checkpoint, asOf, last];
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Return one page of caller-ranked candidates; no ranking model is implied.
|
|
758
|
+
* Only a lexical page containing every match may claim exhaustive coverage.
|
|
759
|
+
*/
|
|
760
|
+
search(request, selection, modes = KNOWLEDGE_SEARCH_MODES) {
|
|
761
|
+
validateKnowledgeSearchRequest(request, modes);
|
|
762
|
+
const limit = request.limit ?? 20;
|
|
763
|
+
const eligible = new Set();
|
|
764
|
+
for (const [id, record] of this.records)
|
|
765
|
+
if (!this.hidden.has(id) &&
|
|
766
|
+
knowledgeQueryMatches(record.envelope, request.filters ?? {}) &&
|
|
767
|
+
(request.mode !== "lexical" ||
|
|
768
|
+
knowledgeTextMatches(record.envelope, request.text)))
|
|
769
|
+
eligible.add(id);
|
|
770
|
+
const ids = selection.candidates;
|
|
771
|
+
if (new Set(ids).size !== ids.length || ids.some((id) => !eligible.has(id)))
|
|
772
|
+
throw protocolError("invalid_response", "candidate list repeats IDs or violates exact filters");
|
|
773
|
+
if (selection.coverage.exhaustive &&
|
|
774
|
+
(ids.length !== eligible.size || ids.length > limit))
|
|
775
|
+
throw protocolError("invalid_response", "false exhaustive coverage");
|
|
776
|
+
const response = {
|
|
777
|
+
result: ids.slice(0, limit).map((id) => {
|
|
778
|
+
const hit = {
|
|
779
|
+
record: structuredClone(this.records.get(id)),
|
|
780
|
+
};
|
|
781
|
+
const explanation = selection.explanations?.[id];
|
|
782
|
+
if (explanation !== undefined)
|
|
783
|
+
hit.explanation = explanation;
|
|
784
|
+
return hit;
|
|
785
|
+
}),
|
|
786
|
+
...this.scope(this.highWater, this.now()),
|
|
787
|
+
ranking: structuredClone(selection.ranking),
|
|
788
|
+
coverage: structuredClone(selection.coverage),
|
|
789
|
+
};
|
|
790
|
+
validateKnowledgeSchema(response, "searchResponse", "invalid_response");
|
|
791
|
+
if (response.ranking.mode !== request.mode)
|
|
792
|
+
throw protocolError("invalid_response", "ranking mode differs from requested mode");
|
|
793
|
+
return response;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
//# sourceMappingURL=knowledge.js.map
|