@littlebigbrain/mcp 0.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/LICENSE +56 -0
- package/README.md +369 -0
- package/dist/http-server.d.ts +33 -0
- package/dist/http-server.js +181 -0
- package/dist/http.js +33 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +8 -0
- package/dist/stdio.js +27 -0
- package/dist/tool-contracts.js +702 -0
- package/dist/tool-runtime.js +1071 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.js +799 -0
- package/package.json +65 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, } from "./tool-contracts.js";
|
|
3
|
+
import { analyze, assertCursorScope, contentHashKey, decodeQueryCursor, effectiveRowLimit, entityEdgeCapHint, enrichError, errorResult, guide, normalizeDetail, normalizeLbbIris, ontologyDefineBody, queryCommitPin, queryEnvelope, requireString, resolveProfile, rowPageFrom, rowPageNext, run, schemaPreview, schemaPublishBody, scoped, searchBody, searchFeedbackHint, stableJson, toolResult, } from "./tool-runtime.js";
|
|
4
|
+
export function registerLbbTools(server, client) {
|
|
5
|
+
server.registerTool("lbb_search", {
|
|
6
|
+
description: "Natural-language retrieval over Little Big Brain. Use `query` for one phrasing, `queries` for reciprocal-rank fusion across phrasings, and `follow_paths: true` when you want bounded graph paths from text-resolved seed entities. When you can judge returned results, call lbb_commit mode=search_feedback with good=3, partial=1, bad=0 so Little Big Brain can build customer-specific qrels for embedding training.",
|
|
7
|
+
inputSchema: {
|
|
8
|
+
query: z.string().optional().describe("Natural-language query"),
|
|
9
|
+
queries: z
|
|
10
|
+
.array(z.string())
|
|
11
|
+
.min(1)
|
|
12
|
+
.optional()
|
|
13
|
+
.describe("Multiple phrasings to fuse"),
|
|
14
|
+
mode: z.enum(["hybrid", "bm25", "vector", "lexical"]).optional(),
|
|
15
|
+
follow_paths: z.boolean().optional(),
|
|
16
|
+
top_k: z.number().int().positive().optional(),
|
|
17
|
+
max_hops: z.number().int().positive().max(6).optional(),
|
|
18
|
+
direction: z.enum(["out", "in", "both"]).optional(),
|
|
19
|
+
profile: z
|
|
20
|
+
.enum(["ndcg_v1", "graph_aware_v1", "baseline", "scored_atom_v1"])
|
|
21
|
+
.optional(),
|
|
22
|
+
as_of: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe("Valid-time cursor (RFC 3339): results reflect facts true at this instant"),
|
|
26
|
+
as_of_commit_seq: z
|
|
27
|
+
.number()
|
|
28
|
+
.int()
|
|
29
|
+
.nonnegative()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Snapshot pin: results reproduce the graph as of this commit sequence (echoed back in snapshot.as_of_commit_seq); a pin past head is an error"),
|
|
32
|
+
detail: detailSchema,
|
|
33
|
+
...graphScope,
|
|
34
|
+
},
|
|
35
|
+
annotations: READ_ONLY,
|
|
36
|
+
}, ({ query, queries, mode, follow_paths, top_k, max_hops, direction, profile, as_of, as_of_commit_seq, detail, graph, branch, }) => run(client, "lbb_search", detail, () => {
|
|
37
|
+
const target = scoped(client, graph, branch);
|
|
38
|
+
if (queries?.length) {
|
|
39
|
+
return target.multiSearch({
|
|
40
|
+
subqueries: queries.map((q, index) => ({
|
|
41
|
+
id: `q${index}`,
|
|
42
|
+
weight: 1.0,
|
|
43
|
+
request: searchBody({
|
|
44
|
+
query: q,
|
|
45
|
+
mode,
|
|
46
|
+
top_k,
|
|
47
|
+
profile,
|
|
48
|
+
as_of,
|
|
49
|
+
as_of_commit_seq,
|
|
50
|
+
}),
|
|
51
|
+
})),
|
|
52
|
+
top_k: top_k ?? 10,
|
|
53
|
+
explain: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
const q = requireString(query, "query");
|
|
57
|
+
if (follow_paths) {
|
|
58
|
+
return target.semanticTraverse({
|
|
59
|
+
query: q,
|
|
60
|
+
seed_top_k: Math.min(top_k ?? 3, 10),
|
|
61
|
+
search: {
|
|
62
|
+
lexical: true,
|
|
63
|
+
bm25: true,
|
|
64
|
+
vector: true,
|
|
65
|
+
bm25_source: "persisted",
|
|
66
|
+
vector_source: "persisted",
|
|
67
|
+
consistency: "strong",
|
|
68
|
+
profile: resolveProfile(profile),
|
|
69
|
+
},
|
|
70
|
+
direction: direction ?? "both",
|
|
71
|
+
max_hops: max_hops ?? 2,
|
|
72
|
+
max_frontier_entities: 50,
|
|
73
|
+
max_paths: top_k ?? 25,
|
|
74
|
+
explain: false,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return target.graphSearch(searchBody({
|
|
78
|
+
query: q,
|
|
79
|
+
mode,
|
|
80
|
+
top_k,
|
|
81
|
+
profile,
|
|
82
|
+
as_of,
|
|
83
|
+
as_of_commit_seq,
|
|
84
|
+
}));
|
|
85
|
+
}, (data) => ({
|
|
86
|
+
feedback: searchFeedbackHint(data, { query, queries, graph, branch }),
|
|
87
|
+
})));
|
|
88
|
+
server.registerTool("lbb_ask", {
|
|
89
|
+
description: "Ask a natural-language question about the graph and get a grounded answer with citations. The database snaps the question to its real vocabulary (never invented), retrieves against the pinned snapshot, and answers. `mode` is `resident_planner` when a small resident model synthesized the prose, or `grounding_only` when it returns the grounded evidence for your own model to finish. Prefer this over lbb_search when you want a direct, cited answer rather than a ranked list; set `execute: false` to get only the grounding — the real vocabulary the question maps to — without retrieval. The response `explain` block reports how much the database narrowed (vocabulary candidates the question snapped to, plus retrieved entity/assertion counts) and the per-stage latency (ground / retrieve / synth / total ms), so you can see the pipeline that produced the answer.",
|
|
90
|
+
inputSchema: {
|
|
91
|
+
question: z
|
|
92
|
+
.string()
|
|
93
|
+
.describe("The natural-language question to answer from the graph"),
|
|
94
|
+
top_k: z
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.positive()
|
|
98
|
+
.max(25)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe("Max citations to return (default 8)"),
|
|
101
|
+
execute: z
|
|
102
|
+
.boolean()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Run retrieval and answer (default true); false returns only the grounding"),
|
|
105
|
+
as_of: z
|
|
106
|
+
.string()
|
|
107
|
+
.optional()
|
|
108
|
+
.describe("Valid-time cursor (RFC 3339): retrieval reflects facts true at this instant"),
|
|
109
|
+
as_of_commit_seq: z
|
|
110
|
+
.number()
|
|
111
|
+
.int()
|
|
112
|
+
.nonnegative()
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Snapshot pin: retrieval and citations reproduce the graph as of this commit sequence"),
|
|
115
|
+
detail: detailSchema,
|
|
116
|
+
...graphScope,
|
|
117
|
+
},
|
|
118
|
+
annotations: READ_ONLY,
|
|
119
|
+
}, ({ question, top_k, execute, as_of, as_of_commit_seq, detail, graph, branch, }) => run(client, "lbb_ask", detail, () => scoped(client, graph, branch).ask({
|
|
120
|
+
question,
|
|
121
|
+
top_k,
|
|
122
|
+
execute,
|
|
123
|
+
...(as_of !== undefined ? { as_of_valid_time: as_of } : {}),
|
|
124
|
+
...(as_of_commit_seq !== undefined ? { as_of_commit_seq } : {}),
|
|
125
|
+
})));
|
|
126
|
+
server.registerTool("lbb_decode", {
|
|
127
|
+
description: "Name the relation between two entities. The database narrows the candidates to the relations its type signatures admit for the (source type, target type) pair; if the pair admits exactly one, the database answers alone (`mode: forced`, no model call). Otherwise a small model fine-tuned on this graph's own edges picks from the narrowed set (`mode: model_narrowed`), constrained to real vocabulary so it can only emit a relation that can exist. You can OMIT the types — pass just the names and the database recovers each type by resolving the name to a real entity (echoed in `resolved_source`/`resolved_target`). Use it to fill a missing edge label, verify a relationship, or assemble structured triples. Returns the `relation`, the admissible `candidates`, and `signature_forced`.",
|
|
128
|
+
inputSchema: {
|
|
129
|
+
source_name: z.string().describe("Source entity display name"),
|
|
130
|
+
source_type: z
|
|
131
|
+
.string()
|
|
132
|
+
.optional()
|
|
133
|
+
.describe("Source entity type; omit to have the DB recover it from the name"),
|
|
134
|
+
target_name: z.string().describe("Target entity display name"),
|
|
135
|
+
target_type: z
|
|
136
|
+
.string()
|
|
137
|
+
.optional()
|
|
138
|
+
.describe("Target entity type; omit to have the DB recover it from the name"),
|
|
139
|
+
use_model_when_forced: z
|
|
140
|
+
.boolean()
|
|
141
|
+
.optional()
|
|
142
|
+
.describe("Call the model even when the type pair forces one relation (default false — a forced pair is answered by the DB alone)"),
|
|
143
|
+
detail: detailSchema,
|
|
144
|
+
...graphScope,
|
|
145
|
+
},
|
|
146
|
+
annotations: READ_ONLY,
|
|
147
|
+
}, ({ source_name, source_type, target_name, target_type, use_model_when_forced, detail, graph, branch, }) => run(client, "lbb_decode", detail, () => scoped(client, graph, branch).decode({
|
|
148
|
+
source: { name: source_name, type: source_type },
|
|
149
|
+
target: { name: target_name, type: target_type },
|
|
150
|
+
use_model_when_forced,
|
|
151
|
+
})));
|
|
152
|
+
server.registerTool("lbb_ground", {
|
|
153
|
+
description: "Ground your terms to the graph's real vocabulary before you query or write, so you never guess a type, relation, or property name. Actions: `complete` — narrowed autocomplete: completes a prefix against the real vocabulary, optionally narrowed to the relations a (src_type, dst_type) pair actually admits (so you only propose relations that can exist); `resolve` — snap free text to the single nearest real vocabulary item by embedding/lexical similarity, never fabricating a name; `audit` — groundability report: signature sparsity, name semantics, sampled narrowing recall, and a narrow / narrow+finetune / lexical recommendation for this graph.",
|
|
154
|
+
inputSchema: {
|
|
155
|
+
action: z
|
|
156
|
+
.enum(["complete", "resolve", "audit"])
|
|
157
|
+
.describe("complete = narrowed vocabulary autocomplete; resolve = snap free text to the nearest real vocabulary; audit = groundability report"),
|
|
158
|
+
prefix: z
|
|
159
|
+
.string()
|
|
160
|
+
.optional()
|
|
161
|
+
.describe("[complete] Text prefix to complete against the real vocabulary"),
|
|
162
|
+
text: z
|
|
163
|
+
.string()
|
|
164
|
+
.optional()
|
|
165
|
+
.describe("[resolve] Free text to snap to the nearest real vocabulary item"),
|
|
166
|
+
src_type: z
|
|
167
|
+
.string()
|
|
168
|
+
.optional()
|
|
169
|
+
.describe("[complete] Narrow relation completions to those admitted FROM this source type"),
|
|
170
|
+
dst_type: z
|
|
171
|
+
.string()
|
|
172
|
+
.optional()
|
|
173
|
+
.describe("[complete] Narrow relation completions to those admitted INTO this target type"),
|
|
174
|
+
kinds: z
|
|
175
|
+
.array(z.enum([
|
|
176
|
+
"term",
|
|
177
|
+
"attribute_value",
|
|
178
|
+
"attribute_field",
|
|
179
|
+
"class",
|
|
180
|
+
"relation",
|
|
181
|
+
"property",
|
|
182
|
+
]))
|
|
183
|
+
.optional()
|
|
184
|
+
.describe("[complete/resolve] Restrict to these vocabulary kinds (default: all)"),
|
|
185
|
+
top_k: z
|
|
186
|
+
.number()
|
|
187
|
+
.int()
|
|
188
|
+
.positive()
|
|
189
|
+
.max(50)
|
|
190
|
+
.optional()
|
|
191
|
+
.describe("[complete/resolve] Max results (default 8)"),
|
|
192
|
+
sample: z
|
|
193
|
+
.number()
|
|
194
|
+
.int()
|
|
195
|
+
.positive()
|
|
196
|
+
.optional()
|
|
197
|
+
.describe("[audit] Entities to sample for narrowing-recall"),
|
|
198
|
+
detail: detailSchema,
|
|
199
|
+
...graphScope,
|
|
200
|
+
},
|
|
201
|
+
annotations: READ_ONLY,
|
|
202
|
+
}, ({ action, prefix, text, src_type, dst_type, kinds, top_k, sample, detail, graph, branch, }) => run(client, "lbb_ground", detail, () => {
|
|
203
|
+
const target = scoped(client, graph, branch);
|
|
204
|
+
if (action === "resolve") {
|
|
205
|
+
return target.resolveTerm({
|
|
206
|
+
text: requireString(text, "text"),
|
|
207
|
+
kinds: (kinds ?? ["class", "relation", "property"]),
|
|
208
|
+
top_k,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
if (action === "audit") {
|
|
212
|
+
return target.groundability(sample != null ? { sample } : {});
|
|
213
|
+
}
|
|
214
|
+
const context = src_type !== undefined || dst_type !== undefined
|
|
215
|
+
? { src_type, dst_type }
|
|
216
|
+
: undefined;
|
|
217
|
+
return target.suggest({
|
|
218
|
+
prefix: requireString(prefix, "prefix"),
|
|
219
|
+
kinds: kinds,
|
|
220
|
+
context,
|
|
221
|
+
limit: top_k,
|
|
222
|
+
});
|
|
223
|
+
}));
|
|
224
|
+
server.registerTool("lbb_inspect", {
|
|
225
|
+
description: "Read graph context and exact graph facts. Actions: guide, ontology, ontology_conformance, schema, schema_preview, schema_audit, rules, ontology_search, metadata, entity, edges, state, history, transitions, why, traverse. entity returns one node's metadata, scalar attributes, current state, edges, history, and observations — its edge/history arrays are a display sample capped at the detail limit (counts holds the true totals), so on a high-degree node the response carries an `edge_sample` block with the capped totals and ready-to-run paged reads; follow those (or call edges/history directly, paged by row_limit/offset with direction/relation filters and an as_of/as_of_commit_seq pin) to read the full set — total_count tells you when to stop. transitions returns the ordered state-transition log of an entity's relation with dwell time at each value (cycle-time/process analysis). metadata includes temporal_coverage — check it before attempting as-of/daily views: as_of_valid_time_degenerate or single_commit_time means every point-in-time query returns the same snapshot. Use ontology_conformance to check the live data against the ontology's own capped-cardinality rules (derived SHACL sh:maxCount, whole-snapshot, never blocks a write) — distinct from schema_audit, which runs the separately-published SHACL shape bundle.",
|
|
226
|
+
inputSchema: inspectWireSchema,
|
|
227
|
+
annotations: READ_ONLY,
|
|
228
|
+
}, (rawArgs) => {
|
|
229
|
+
const parsed = inspectInputSchema.safeParse(rawArgs);
|
|
230
|
+
if (!parsed.success)
|
|
231
|
+
return errorResult(parsed.error);
|
|
232
|
+
const args = parsed.data;
|
|
233
|
+
return run(client, `lbb_inspect.${args.action}`, args.detail, () => {
|
|
234
|
+
const target = scoped(client, args.graph, args.branch);
|
|
235
|
+
switch (args.action) {
|
|
236
|
+
case "guide":
|
|
237
|
+
return guide(target);
|
|
238
|
+
case "ontology":
|
|
239
|
+
// Request per-relation edge counts so the listing flags which of the
|
|
240
|
+
// declared relations are actually populated (edge_count: 0 = unused).
|
|
241
|
+
return target.ontologyView({ counts: true });
|
|
242
|
+
case "ontology_conformance":
|
|
243
|
+
return target.ontologyConformance();
|
|
244
|
+
case "schema":
|
|
245
|
+
return target.schema.view();
|
|
246
|
+
case "schema_preview":
|
|
247
|
+
return schemaPreview(target, {
|
|
248
|
+
graph: args.graph,
|
|
249
|
+
branch: args.branch,
|
|
250
|
+
ontology: args.ontology,
|
|
251
|
+
shapes: args.shapes,
|
|
252
|
+
base_ontology_version: args.base_ontology_version,
|
|
253
|
+
base_shapes_version: args.base_shapes_version,
|
|
254
|
+
desired_mode: args.desired_mode,
|
|
255
|
+
});
|
|
256
|
+
case "schema_audit":
|
|
257
|
+
return target.schema.audit();
|
|
258
|
+
case "rules":
|
|
259
|
+
return target.graphRules();
|
|
260
|
+
case "ontology_search":
|
|
261
|
+
return target.ontologySearch({
|
|
262
|
+
query: args.query,
|
|
263
|
+
search: { concepts: true, terms: true, relations: true },
|
|
264
|
+
top_k: args.top_k ?? 10,
|
|
265
|
+
explain: false,
|
|
266
|
+
});
|
|
267
|
+
case "metadata":
|
|
268
|
+
return target.metadata();
|
|
269
|
+
case "entity":
|
|
270
|
+
return target.entityDetail({
|
|
271
|
+
...(args.entity_id
|
|
272
|
+
? { id: args.entity_id }
|
|
273
|
+
: {
|
|
274
|
+
type: requireString(args.entity_type, "entity_type"),
|
|
275
|
+
name: requireString(args.name, "name"),
|
|
276
|
+
}),
|
|
277
|
+
asOf: args.as_of,
|
|
278
|
+
asOfCommitSeq: args.as_of_commit_seq,
|
|
279
|
+
});
|
|
280
|
+
case "edges":
|
|
281
|
+
// Paged edge listing — the way to read every edge of a high-degree
|
|
282
|
+
// node, which `entity` hard-caps. Returns the unified list envelope;
|
|
283
|
+
// page by feeding next_cursor back as `cursor` until has_more=false.
|
|
284
|
+
return target.graphEdges({
|
|
285
|
+
id: args.entity_id,
|
|
286
|
+
type: args.entity_type,
|
|
287
|
+
name: args.name,
|
|
288
|
+
direction: args.direction,
|
|
289
|
+
relation: args.relation,
|
|
290
|
+
limit: args.row_limit,
|
|
291
|
+
cursor: args.cursor,
|
|
292
|
+
offset: args.offset,
|
|
293
|
+
asOf: args.as_of,
|
|
294
|
+
asOfCommitSeq: args.as_of_commit_seq,
|
|
295
|
+
});
|
|
296
|
+
case "state":
|
|
297
|
+
return target.currentState({
|
|
298
|
+
entity: {
|
|
299
|
+
entity_type: args.entity_type,
|
|
300
|
+
name: args.name,
|
|
301
|
+
},
|
|
302
|
+
relations: args.relation ? [args.relation] : null,
|
|
303
|
+
as_of_valid_time: args.as_of ?? null,
|
|
304
|
+
as_of_commit_seq: args.as_of_commit_seq ?? null,
|
|
305
|
+
});
|
|
306
|
+
case "history":
|
|
307
|
+
return target.history({
|
|
308
|
+
source: {
|
|
309
|
+
entity_type: args.entity_type,
|
|
310
|
+
name: args.name,
|
|
311
|
+
},
|
|
312
|
+
relation: args.relation ?? null,
|
|
313
|
+
});
|
|
314
|
+
case "why":
|
|
315
|
+
return target.why({
|
|
316
|
+
source: {
|
|
317
|
+
entity_type: args.source_type,
|
|
318
|
+
name: args.source_name,
|
|
319
|
+
},
|
|
320
|
+
relation: args.relation,
|
|
321
|
+
target: {
|
|
322
|
+
entity_type: args.target_type,
|
|
323
|
+
name: args.target_name,
|
|
324
|
+
},
|
|
325
|
+
});
|
|
326
|
+
case "traverse":
|
|
327
|
+
return target.traverse({
|
|
328
|
+
start: {
|
|
329
|
+
entity_type: args.entity_type,
|
|
330
|
+
name: args.name,
|
|
331
|
+
},
|
|
332
|
+
relations: args.relations ?? null,
|
|
333
|
+
direction: args.direction ?? "both",
|
|
334
|
+
max_hops: args.max_hops ?? 2,
|
|
335
|
+
max_frontier_entities: 50,
|
|
336
|
+
max_paths: args.top_k ?? 25,
|
|
337
|
+
});
|
|
338
|
+
case "transitions":
|
|
339
|
+
return target.transitions({
|
|
340
|
+
entity: {
|
|
341
|
+
entity_type: args.entity_type,
|
|
342
|
+
name: args.name,
|
|
343
|
+
},
|
|
344
|
+
relation: args.relation,
|
|
345
|
+
as_of_valid_time: args.as_of ?? null,
|
|
346
|
+
as_of_commit_seq: args.as_of_commit_seq ?? null,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}, (data) => args.action === "entity"
|
|
350
|
+
? entityEdgeCapHint(data, args.detail, args.entity_id
|
|
351
|
+
? { entity_id: args.entity_id }
|
|
352
|
+
: { entity_type: args.entity_type, name: args.name })
|
|
353
|
+
: {});
|
|
354
|
+
});
|
|
355
|
+
server.registerTool("lbb_query", {
|
|
356
|
+
description: "Analytical and expert reads. Modes: structured (SPARQL-subset JSON body), sparql (SPARQL text), shacl, infer, retrieval_premises, analyze. Relations are <https://littlebigbrain.com/r/NAME> and types <https://littlebigbrain.com/class/NAME> (both lowercased); entities are content-addressed, so anchor a named one by its rdfs:label rather than building its IRI (see the sparql/structured field hints, and lbb_inspect action=ontology for exact names). For structured/sparql row paging, MCP owns limit/offset via row_limit/cursor; a cursor reuses the original query/body and pins continuation pages to the original head commit. When a single page's rows are complete server-side (row_page.has_more false) but too large for one MCP result, the envelope reports `rows_shown` (fewer than row_page.returned), keeps `truncated: true`, and hands back a `next` cursor that pages the same result set at a smaller row_limit — so a large fully-returned result is never silently cut without a way to read the rest.",
|
|
357
|
+
inputSchema: queryWireSchema,
|
|
358
|
+
annotations: READ_ONLY,
|
|
359
|
+
}, (rawArgs) => {
|
|
360
|
+
const parsed = queryInputSchema.safeParse(rawArgs);
|
|
361
|
+
if (!parsed.success)
|
|
362
|
+
return errorResult(parsed.error);
|
|
363
|
+
const args = parsed.data;
|
|
364
|
+
if (args.mode === "structured" || args.mode === "sparql") {
|
|
365
|
+
return (async () => {
|
|
366
|
+
try {
|
|
367
|
+
const cursor = decodeQueryCursor(args.cursor);
|
|
368
|
+
if (cursor && cursor.mode !== args.mode) {
|
|
369
|
+
throw new Error(`cursor is for ${cursor.mode}, not ${args.mode}`);
|
|
370
|
+
}
|
|
371
|
+
assertCursorScope({ graph: args.graph, branch: args.branch }, cursor);
|
|
372
|
+
if (cursor &&
|
|
373
|
+
args.row_limit !== undefined &&
|
|
374
|
+
args.row_limit !== cursor.row_limit) {
|
|
375
|
+
throw new Error("cursor row_limit does not match the supplied row_limit argument");
|
|
376
|
+
}
|
|
377
|
+
const detail = normalizeDetail(args.detail ?? cursor?.detail);
|
|
378
|
+
const rowLimit = effectiveRowLimit(detail, args.row_limit ?? cursor?.row_limit);
|
|
379
|
+
const graph = cursor?.graph ?? args.graph;
|
|
380
|
+
const branch = cursor?.branch ?? args.branch;
|
|
381
|
+
const offset = cursor?.offset ?? 0;
|
|
382
|
+
const target = scoped(client, graph, branch);
|
|
383
|
+
if (args.mode === "structured") {
|
|
384
|
+
const body = (cursor?.body ?? args.body);
|
|
385
|
+
if (body === undefined)
|
|
386
|
+
throw new Error("body is required unless cursor is supplied");
|
|
387
|
+
if (cursor &&
|
|
388
|
+
args.body !== undefined &&
|
|
389
|
+
stableJson(args.body) !== stableJson(cursor.body)) {
|
|
390
|
+
throw new Error("cursor body does not match the supplied body argument");
|
|
391
|
+
}
|
|
392
|
+
// The body's valid-time field is `as_of_valid_time`; the server
|
|
393
|
+
// ignores a bare `as_of` key, so a naive caller would chart
|
|
394
|
+
// head-snapshot data and never know. Turn that silent no-op into a
|
|
395
|
+
// clear error pointing at the right spelling.
|
|
396
|
+
if (body.as_of !== undefined) {
|
|
397
|
+
throw new Error("the structured body has an `as_of` key, which the server ignores — use the top-level `as_of` argument (valid-time, RFC3339) or rename it to `as_of_valid_time` inside the body");
|
|
398
|
+
}
|
|
399
|
+
if (cursor &&
|
|
400
|
+
args.as_of !== undefined &&
|
|
401
|
+
args.as_of !== cursor.as_of) {
|
|
402
|
+
throw new Error("cursor as_of does not match the supplied as_of argument");
|
|
403
|
+
}
|
|
404
|
+
// Commit-seq pin: top-level arg, else the body field, pinned for
|
|
405
|
+
// continuation. Valid-time pin: cursor, else top-level arg, else the
|
|
406
|
+
// body's `as_of_valid_time`. Both are resolved here and set
|
|
407
|
+
// explicitly so the request never depends on the body's spelling.
|
|
408
|
+
const requestedCommitSeq = args.as_of_commit_seq ??
|
|
409
|
+
(typeof body.as_of_commit_seq === "number"
|
|
410
|
+
? body.as_of_commit_seq
|
|
411
|
+
: undefined);
|
|
412
|
+
const asOfCommitSeq = await queryCommitPin(target, requestedCommitSeq, cursor);
|
|
413
|
+
const asOfValidTime = cursor?.as_of ??
|
|
414
|
+
args.as_of ??
|
|
415
|
+
(typeof body.as_of_valid_time === "string"
|
|
416
|
+
? body.as_of_valid_time
|
|
417
|
+
: undefined);
|
|
418
|
+
const request = {
|
|
419
|
+
...body,
|
|
420
|
+
limit: rowLimit,
|
|
421
|
+
offset,
|
|
422
|
+
as_of_commit_seq: asOfCommitSeq,
|
|
423
|
+
as_of_valid_time: asOfValidTime ?? null,
|
|
424
|
+
};
|
|
425
|
+
const combinators = request.combinators;
|
|
426
|
+
const hasCombinators = Array.isArray(combinators) && combinators.length > 0;
|
|
427
|
+
// HAVING runs on the SPARQL-select path; the combinator/analytics
|
|
428
|
+
// path does not evaluate it. Routing a having+combinators body to
|
|
429
|
+
// analytics would silently drop the filter, so reject it loudly
|
|
430
|
+
// instead (the schema/runtime mismatch the feedback hit).
|
|
431
|
+
const hasHaving = Array.isArray(request.having) && request.having.length > 0;
|
|
432
|
+
if (hasCombinators && hasHaving) {
|
|
433
|
+
throw new Error("HAVING is not evaluated alongside UNION/OPTIONAL/MINUS combinators — remove the combinators to use HAVING (grouped aggregation), or apply the threshold client-side.");
|
|
434
|
+
}
|
|
435
|
+
const response = hasCombinators
|
|
436
|
+
? await target.analytics(request)
|
|
437
|
+
: await target.sparql(request);
|
|
438
|
+
const rowPage = rowPageFrom(response);
|
|
439
|
+
const cursorBase = {
|
|
440
|
+
v: 1,
|
|
441
|
+
mode: "structured",
|
|
442
|
+
graph,
|
|
443
|
+
branch,
|
|
444
|
+
detail,
|
|
445
|
+
row_limit: rowLimit,
|
|
446
|
+
body,
|
|
447
|
+
as_of: asOfValidTime,
|
|
448
|
+
as_of_commit_seq: asOfCommitSeq,
|
|
449
|
+
};
|
|
450
|
+
const next = rowPageNext(cursorBase, rowPage);
|
|
451
|
+
return toolResult(queryEnvelope(`lbb_query.${args.mode}`, response, detail, rowPage, next, cursorBase));
|
|
452
|
+
}
|
|
453
|
+
// Canonicalize Little Big Brain relation/class/property IRI local-name case up
|
|
454
|
+
// front, then use the normalized text everywhere (mismatch check,
|
|
455
|
+
// request, cursor) so a continuation page that re-passes the raw
|
|
456
|
+
// query still matches the already-normalized cursor query. A cursor's
|
|
457
|
+
// stored query is already normalized, so paging never repeats the note.
|
|
458
|
+
const rawQuery = cursor?.query ?? requireString(args.query, "query");
|
|
459
|
+
const { query, notes } = normalizeLbbIris(rawQuery);
|
|
460
|
+
if (cursor &&
|
|
461
|
+
args.query !== undefined &&
|
|
462
|
+
normalizeLbbIris(args.query).query !== cursor.query) {
|
|
463
|
+
throw new Error("cursor query does not match the supplied query argument");
|
|
464
|
+
}
|
|
465
|
+
if (cursor &&
|
|
466
|
+
args.as_of !== undefined &&
|
|
467
|
+
args.as_of !== cursor.as_of) {
|
|
468
|
+
throw new Error("cursor as_of does not match the supplied as_of argument");
|
|
469
|
+
}
|
|
470
|
+
if (cursor &&
|
|
471
|
+
args.as_of_commit_seq !== undefined &&
|
|
472
|
+
args.as_of_commit_seq !== cursor.as_of_commit_seq) {
|
|
473
|
+
throw new Error("cursor as_of_commit_seq does not match the supplied as_of_commit_seq argument");
|
|
474
|
+
}
|
|
475
|
+
const asOf = cursor?.as_of ?? args.as_of;
|
|
476
|
+
const asOfCommitSeq = await queryCommitPin(target, args.as_of_commit_seq, cursor);
|
|
477
|
+
const response = await target.sparqlText({
|
|
478
|
+
query,
|
|
479
|
+
as_of_valid_time: asOf ?? null,
|
|
480
|
+
as_of_commit_seq: asOfCommitSeq ?? null,
|
|
481
|
+
limit: rowLimit,
|
|
482
|
+
offset,
|
|
483
|
+
});
|
|
484
|
+
const data = JSON.parse(response.results);
|
|
485
|
+
const rowPage = rowPageFrom(response);
|
|
486
|
+
const cursorBase = {
|
|
487
|
+
v: 1,
|
|
488
|
+
mode: "sparql",
|
|
489
|
+
graph,
|
|
490
|
+
branch,
|
|
491
|
+
detail,
|
|
492
|
+
row_limit: rowLimit,
|
|
493
|
+
query,
|
|
494
|
+
as_of: asOf,
|
|
495
|
+
as_of_commit_seq: asOfCommitSeq,
|
|
496
|
+
};
|
|
497
|
+
const next = rowPageNext(cursorBase, rowPage);
|
|
498
|
+
const sparqlEnvelope = queryEnvelope(`lbb_query.${args.mode}`, data, detail, rowPage, next, cursorBase);
|
|
499
|
+
return toolResult(notes.length > 0 ? { ...sparqlEnvelope, notes } : sparqlEnvelope);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
return errorResult(await enrichError(client, error));
|
|
503
|
+
}
|
|
504
|
+
})();
|
|
505
|
+
}
|
|
506
|
+
return run(client, `lbb_query.${args.mode}`, args.detail, async () => {
|
|
507
|
+
const target = scoped(client, args.graph, args.branch);
|
|
508
|
+
switch (args.mode) {
|
|
509
|
+
case "shacl":
|
|
510
|
+
return target.shacl({
|
|
511
|
+
shapes: args.shapes ?? [],
|
|
512
|
+
mode: args.shacl_mode ?? "select",
|
|
513
|
+
include_derived: args.include_derived ?? false,
|
|
514
|
+
rules: args.rules ?? [],
|
|
515
|
+
as_of_valid_time: args.as_of ?? null,
|
|
516
|
+
top_k: args.top_k ?? 10,
|
|
517
|
+
explain: args.explain ?? false,
|
|
518
|
+
});
|
|
519
|
+
case "infer":
|
|
520
|
+
return target.infer({
|
|
521
|
+
rules: args.rules ?? [],
|
|
522
|
+
max_rounds: args.max_rounds ?? null,
|
|
523
|
+
max_derived: args.max_derived ?? null,
|
|
524
|
+
max_solutions: args.max_solutions ?? null,
|
|
525
|
+
as_of_valid_time: args.as_of ?? null,
|
|
526
|
+
as_of_commit_seq: args.as_of_commit_seq ?? null,
|
|
527
|
+
});
|
|
528
|
+
case "retrieval_premises":
|
|
529
|
+
return target.retrievalPremises({
|
|
530
|
+
anchor: {
|
|
531
|
+
entity_type: args.anchor_type,
|
|
532
|
+
name: args.anchor_name,
|
|
533
|
+
},
|
|
534
|
+
relation: args.relation,
|
|
535
|
+
model_id: "lbb-hash-lexical-v1",
|
|
536
|
+
target_kind: "entity",
|
|
537
|
+
calibration: { a: -1, b: 0 },
|
|
538
|
+
threshold: args.threshold ?? 0.5,
|
|
539
|
+
max_premises: args.max_premises ?? 50,
|
|
540
|
+
query: args.query,
|
|
541
|
+
query_top_k: args.query_top_k ?? 50,
|
|
542
|
+
});
|
|
543
|
+
case "analyze":
|
|
544
|
+
return analyze(target, {
|
|
545
|
+
metric: args.metric,
|
|
546
|
+
chart: args.chart,
|
|
547
|
+
top_k: args.top_k,
|
|
548
|
+
query: args.query,
|
|
549
|
+
field: args.field,
|
|
550
|
+
sparql: args.sparql,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
});
|
|
554
|
+
});
|
|
555
|
+
server.registerTool("lbb_commit", {
|
|
556
|
+
description: "Write graph facts, retract them, or label search results. mode=facts writes triplets/embeddings/properties; mode=retract removes a wrongly-added fact (by edge or by entity) without a full reset; mode=search_feedback labels query/result relevance after lbb_search (Feedback grades: 3=ideal/good, 1=partial, 0=bad; include query, search_id when available, target, rank, score). Explicit idempotency_key wins; when omitted, MCP derives a stable content hash so content-identical retries dedupe. Facts mode defaults edge_idempotency to append; pass skip_unchanged for re-runnable backfills.",
|
|
557
|
+
inputSchema: {
|
|
558
|
+
idempotency_key: z.string().optional(),
|
|
559
|
+
mode: z.enum(["facts", "retract", "search_feedback"]).optional(),
|
|
560
|
+
triplets: z
|
|
561
|
+
.array(z.object({
|
|
562
|
+
source: z.object({ type: z.string(), name: z.string() }),
|
|
563
|
+
relation: z.string(),
|
|
564
|
+
target: z.object({ type: z.string(), name: z.string() }),
|
|
565
|
+
confidence: z.number().min(0).max(1).optional(),
|
|
566
|
+
evidence: z.unknown().optional(),
|
|
567
|
+
valid_time: z
|
|
568
|
+
.object({
|
|
569
|
+
start: z.string().optional(),
|
|
570
|
+
end: z.string().optional(),
|
|
571
|
+
granularity: z
|
|
572
|
+
.enum(["instant", "day", "month", "year", "unknown"])
|
|
573
|
+
.optional(),
|
|
574
|
+
source_text: z.string().optional(),
|
|
575
|
+
})
|
|
576
|
+
.optional(),
|
|
577
|
+
}))
|
|
578
|
+
.optional(),
|
|
579
|
+
entity_embeddings: z
|
|
580
|
+
.array(z.record(z.string(), z.unknown()))
|
|
581
|
+
.optional(),
|
|
582
|
+
entity_properties: z
|
|
583
|
+
.array(z.record(z.string(), z.unknown()))
|
|
584
|
+
.optional()
|
|
585
|
+
.describe("Typed scalar attributes per entity. Each item is { type, name, properties }. " +
|
|
586
|
+
"`properties` is a flat map of field -> value, e.g. " +
|
|
587
|
+
'{ "type": "PERSON", "name": "Ada Lovelace", "properties": { "h_index": 52, "title": "VP", "last_contact": "2026-06-26" } }. ' +
|
|
588
|
+
"Values are coerced to each field's declared type, so a string like " +
|
|
589
|
+
'"2026-06-26" lands in a date_time field and "52" in an i64 field. ' +
|
|
590
|
+
"(The verbose form [{ field, value: { i64: 52 } }] is also accepted.) " +
|
|
591
|
+
"Register a field first with lbb_configure evolve_ontology add_property; " +
|
|
592
|
+
"the commit response echoes written_properties so you can confirm what landed."),
|
|
593
|
+
search_feedback: searchFeedbackSchema.optional(),
|
|
594
|
+
observed_at: z
|
|
595
|
+
.string()
|
|
596
|
+
.optional()
|
|
597
|
+
.describe("Backfill timestamp (RFC3339). Records this commit AS OF that instant: stamps transaction time and defaults each triplet's valid_time.start. Replay history in order with observed_at per commit so as-of reads by date work. Omit for live writes."),
|
|
598
|
+
edge_idempotency: z
|
|
599
|
+
.enum(["skip_unchanged", "append"])
|
|
600
|
+
.optional()
|
|
601
|
+
.describe("Defaults to append in MCP. Use skip_unchanged for backfills; it skips exact current-edge duplicates and drops evidence-only repeats."),
|
|
602
|
+
retract_edges: z
|
|
603
|
+
.array(z.object({
|
|
604
|
+
source: z.object({ type: z.string(), name: z.string() }),
|
|
605
|
+
relation: z.string(),
|
|
606
|
+
target: z.object({ type: z.string(), name: z.string() }),
|
|
607
|
+
}))
|
|
608
|
+
.optional()
|
|
609
|
+
.describe("mode=retract: specific edges to remove, matched by (source, relation, target)."),
|
|
610
|
+
retract_entities: z
|
|
611
|
+
.array(z.object({ type: z.string(), name: z.string() }))
|
|
612
|
+
.optional()
|
|
613
|
+
.describe("mode=retract: entities whose every current edge is removed (a current-state tombstone; the record and its history are kept for as_of reads)."),
|
|
614
|
+
...graphScope,
|
|
615
|
+
},
|
|
616
|
+
annotations: IDEMPOTENT_WRITE,
|
|
617
|
+
}, ({ idempotency_key, mode, triplets, entity_embeddings, entity_properties, search_feedback, observed_at, edge_idempotency, retract_edges, retract_entities, graph, branch, }) => run(client, "lbb_commit", "standard", () => {
|
|
618
|
+
const commitMode = mode ??
|
|
619
|
+
(search_feedback
|
|
620
|
+
? "search_feedback"
|
|
621
|
+
: retract_edges || retract_entities
|
|
622
|
+
? "retract"
|
|
623
|
+
: "facts");
|
|
624
|
+
if (commitMode === "retract") {
|
|
625
|
+
const edges = retract_edges ?? [];
|
|
626
|
+
const entities = retract_entities ?? [];
|
|
627
|
+
if (edges.length === 0 && entities.length === 0) {
|
|
628
|
+
throw new Error("lbb_commit mode=retract requires retract_edges or retract_entities");
|
|
629
|
+
}
|
|
630
|
+
const key = idempotency_key ??
|
|
631
|
+
contentHashKey({ graph, branch }, { mode: "retract", edges, entities });
|
|
632
|
+
return scoped(client, graph, branch).retract({ edges, entities }, {
|
|
633
|
+
idempotencyKey: key,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
if (commitMode === "search_feedback") {
|
|
637
|
+
if (!search_feedback)
|
|
638
|
+
throw new Error("lbb_commit mode=search_feedback requires search_feedback");
|
|
639
|
+
const key = idempotency_key ??
|
|
640
|
+
contentHashKey({ graph, branch }, { mode: "search_feedback", search_feedback });
|
|
641
|
+
return scoped(client, graph, branch).searchFeedback(search_feedback, { idempotencyKey: key });
|
|
642
|
+
}
|
|
643
|
+
if (search_feedback) {
|
|
644
|
+
throw new Error("lbb_commit facts mode cannot include search_feedback");
|
|
645
|
+
}
|
|
646
|
+
const payload = {
|
|
647
|
+
triplets: triplets ?? [],
|
|
648
|
+
entity_embeddings: entity_embeddings ?? [],
|
|
649
|
+
entity_properties: entity_properties ?? [],
|
|
650
|
+
...(observed_at ? { observed_at } : {}),
|
|
651
|
+
edge_idempotency: edge_idempotency ?? "append",
|
|
652
|
+
};
|
|
653
|
+
if (payload.triplets.length === 0 &&
|
|
654
|
+
payload.entity_embeddings.length === 0 &&
|
|
655
|
+
payload.entity_properties.length === 0) {
|
|
656
|
+
throw new Error("lbb_commit requires at least one triplet, entity embedding, or entity property");
|
|
657
|
+
}
|
|
658
|
+
const key = idempotency_key ?? contentHashKey({ graph, branch }, payload);
|
|
659
|
+
return scoped(client, graph, branch).commit(payload, {
|
|
660
|
+
idempotencyKey: key,
|
|
661
|
+
});
|
|
662
|
+
}));
|
|
663
|
+
server.registerTool("lbb_configure", {
|
|
664
|
+
description: "Mutate stored graph configuration. Actions: define_ontology (create a new graph ontology), evolve_ontology (evolve an existing graph's ontology in place by name — add_entity_type / add_relation / add_property (a typed scalar field so entity_properties can write it) / widen_relation, rename and set-inverse/cardinality, and data-gated narrow/remove; bumps the ontology version, preserves every record, no migration), publish_schema (activate a previewed RDF/SHACL shape bundle), and define_rules (replace the branch's stored inference rules — body/head terms may be variables or fixed entities, and not_exists combinators add stratified negation for universal conditions).",
|
|
665
|
+
inputSchema: configureWireSchema,
|
|
666
|
+
annotations: MUTATING,
|
|
667
|
+
}, (rawArgs) => {
|
|
668
|
+
const parsed = configureInputSchema.safeParse(rawArgs);
|
|
669
|
+
if (!parsed.success)
|
|
670
|
+
return errorResult(parsed.error);
|
|
671
|
+
const args = parsed.data;
|
|
672
|
+
return run(client, `lbb_configure.${args.action}`, "standard", () => {
|
|
673
|
+
if (args.action === "define_ontology") {
|
|
674
|
+
return client
|
|
675
|
+
.withScope({ graph: args.graph, branch: args.branch })
|
|
676
|
+
.ontologyDefine(ontologyDefineBody({
|
|
677
|
+
entity_types: args.entity_types,
|
|
678
|
+
relations: args.relations,
|
|
679
|
+
source: args.source,
|
|
680
|
+
format: args.format,
|
|
681
|
+
merge_default: args.merge_default,
|
|
682
|
+
}));
|
|
683
|
+
}
|
|
684
|
+
if (args.action === "evolve_ontology") {
|
|
685
|
+
return scoped(client, args.graph, args.branch).evolveOntology({
|
|
686
|
+
ops: args.ops,
|
|
687
|
+
allow_data_conflicts: args.allow_data_conflicts ?? false,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
if (args.action === "publish_schema") {
|
|
691
|
+
return scoped(client, args.graph, args.branch).schema.publish(schemaPublishBody({
|
|
692
|
+
preview_digest: args.preview_digest,
|
|
693
|
+
ontology: args.ontology,
|
|
694
|
+
shapes: args.shapes,
|
|
695
|
+
desired_mode: args.desired_mode,
|
|
696
|
+
confirm_restrictive: args.confirm_restrictive,
|
|
697
|
+
}));
|
|
698
|
+
}
|
|
699
|
+
if (args.rules.length === 0 && args.confirm_empty !== true) {
|
|
700
|
+
throw new Error("define_rules with an empty rules array requires confirm_empty=true");
|
|
701
|
+
}
|
|
702
|
+
return scoped(client, args.graph, args.branch).defineRules({
|
|
703
|
+
rules: args.rules,
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
});
|
|
707
|
+
server.registerTool("lbb_index", {
|
|
708
|
+
description: "Build or refresh persisted BM25, vector, and adjacency indexes so recently committed facts become searchable.",
|
|
709
|
+
inputSchema: {
|
|
710
|
+
background: z
|
|
711
|
+
.boolean()
|
|
712
|
+
.optional()
|
|
713
|
+
.describe("Run detached and poll metadata for completion"),
|
|
714
|
+
...graphScope,
|
|
715
|
+
},
|
|
716
|
+
annotations: MUTATING,
|
|
717
|
+
}, ({ background, graph, branch }) => run(client, "lbb_index", "standard", () => scoped(client, graph, branch).indexRun({ background })));
|
|
718
|
+
server.registerTool("lbb_branch", {
|
|
719
|
+
description: "Branch lifecycle. Actions: create (fork a new branch off from_branch — the tool's `branch` argument names the NEW branch) and merge (validate-then-merge: replay from_branch's post-fork commits onto the scoped target branch — its fork parent — as ONE commit with event ids preserved; SHACL-validates the would-be merged state first and refuses with the report on violations; a fact superseded on the target after the fork wins over the branch's version, reported as a supersedure_race conflict; delete_source consumes the merged branch).",
|
|
720
|
+
inputSchema: {
|
|
721
|
+
action: z
|
|
722
|
+
.enum(["create", "merge"])
|
|
723
|
+
.describe("create = fork a new branch; merge = replay a child branch onto its fork parent"),
|
|
724
|
+
from_branch: z
|
|
725
|
+
.string()
|
|
726
|
+
.describe("create: the branch to fork from; merge: the child branch whose commits are replayed"),
|
|
727
|
+
validate: z
|
|
728
|
+
.boolean()
|
|
729
|
+
.optional()
|
|
730
|
+
.describe("merge only: refuse on SHACL violations of the would-be merged state (default true)"),
|
|
731
|
+
delete_source: z
|
|
732
|
+
.boolean()
|
|
733
|
+
.optional()
|
|
734
|
+
.describe("merge only: delete every object under the merged branch after success"),
|
|
735
|
+
...graphScope,
|
|
736
|
+
},
|
|
737
|
+
annotations: MUTATING,
|
|
738
|
+
}, ({ action, from_branch, validate, delete_source, graph, branch }) => run(client, `lbb_branch.${action}`, "standard", () => {
|
|
739
|
+
const target = scoped(client, graph, branch);
|
|
740
|
+
if (action === "create")
|
|
741
|
+
return target.createBranch({ from_branch });
|
|
742
|
+
return target.mergeBranch({
|
|
743
|
+
from_branch,
|
|
744
|
+
validate: validate ?? true,
|
|
745
|
+
delete_source: delete_source ?? false,
|
|
746
|
+
});
|
|
747
|
+
}));
|
|
748
|
+
server.registerTool("lbb_observe", {
|
|
749
|
+
description: "Remember a conversation: store the turns verbatim as an EPISODE evidence entity, then anchor + gate the supplied facts on an observe branch (LLM extraction cannot poison the main graph). Facts with both endpoints already in the graph are anchored; unanchored facts need confidence >= 0.8 to mint new entities, else they come back needs_review. auto_merge merges the branch onto the scoped branch when SHACL validation is clean (the validate-then-merge). Server flag-gated (--enable-observe). This build takes caller-extracted facts (each with a structured triplet); bare statements come back needs_review.",
|
|
750
|
+
inputSchema: {
|
|
751
|
+
session_id: z
|
|
752
|
+
.string()
|
|
753
|
+
.describe("Caller's conversation id (drives the default observe branch name)"),
|
|
754
|
+
turns: z
|
|
755
|
+
.array(z.object({
|
|
756
|
+
role: z.string().describe("user | assistant | tool"),
|
|
757
|
+
content: z.string(),
|
|
758
|
+
name: z.string().optional(),
|
|
759
|
+
ts: z.string().optional().describe("RFC 3339 timestamp"),
|
|
760
|
+
}))
|
|
761
|
+
.min(1)
|
|
762
|
+
.describe("The conversation slice to remember (stored verbatim)"),
|
|
763
|
+
source: z
|
|
764
|
+
.string()
|
|
765
|
+
.optional()
|
|
766
|
+
.describe("Source label, e.g. support-bot"),
|
|
767
|
+
facts: z
|
|
768
|
+
.array(z.object({
|
|
769
|
+
fact: z.string().describe("Natural-language statement"),
|
|
770
|
+
confidence: z.number().optional().describe("0..1 (default 0.9)"),
|
|
771
|
+
triplet: jsonObjectSchema
|
|
772
|
+
.optional()
|
|
773
|
+
.describe("Structured form {source:{type,name}, relation, target:{type,name}} — required for the fact to commit"),
|
|
774
|
+
}))
|
|
775
|
+
.optional()
|
|
776
|
+
.describe("Caller-extracted candidate facts; omit with extract:false to store the episode only"),
|
|
777
|
+
extract: z
|
|
778
|
+
.boolean()
|
|
779
|
+
.optional()
|
|
780
|
+
.describe("false = store the episode only (default true)"),
|
|
781
|
+
observe_branch: z
|
|
782
|
+
.string()
|
|
783
|
+
.optional()
|
|
784
|
+
.describe("Branch for the facts (default observe-<hash12(session_id)>)"),
|
|
785
|
+
auto_merge: z
|
|
786
|
+
.boolean()
|
|
787
|
+
.optional()
|
|
788
|
+
.describe("Merge onto the scoped branch when validation is clean"),
|
|
789
|
+
...graphScope,
|
|
790
|
+
},
|
|
791
|
+
annotations: MUTATING,
|
|
792
|
+
}, ({ session_id, turns, source, facts, extract, observe_branch, auto_merge, graph, branch, }) => run(client, "lbb_observe", "standard", () => scoped(client, graph, branch).observe({
|
|
793
|
+
episode: { turns, session_id, source },
|
|
794
|
+
extract: extract ?? true,
|
|
795
|
+
extraction: { byo_completion: (facts ?? []) },
|
|
796
|
+
branch: observe_branch,
|
|
797
|
+
auto_merge: auto_merge ?? false,
|
|
798
|
+
})));
|
|
799
|
+
}
|