@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
|
@@ -0,0 +1,1071 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { LbbError } from "@littlebigbrain/client";
|
|
4
|
+
import { DEFAULT_DETAIL, HARD_OUTPUT_CHARS, MAX_QUERY_ROW_LIMIT, } from "./tool-contracts.js";
|
|
5
|
+
export const scoped = (client, graph, branch) => graph !== undefined || branch !== undefined
|
|
6
|
+
? client.withScope({ graph, branch })
|
|
7
|
+
: client;
|
|
8
|
+
export function normalizeDetail(detail) {
|
|
9
|
+
return detail === "standard" || detail === "full" ? detail : DEFAULT_DETAIL;
|
|
10
|
+
}
|
|
11
|
+
export function defaultRowLimit(detail) {
|
|
12
|
+
switch (detail) {
|
|
13
|
+
case "full":
|
|
14
|
+
return 1_000;
|
|
15
|
+
case "standard":
|
|
16
|
+
return 100;
|
|
17
|
+
default:
|
|
18
|
+
return 20;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function effectiveRowLimit(detail, requested) {
|
|
22
|
+
return Math.min(requested ?? defaultRowLimit(detail), MAX_QUERY_ROW_LIMIT);
|
|
23
|
+
}
|
|
24
|
+
export function encodeQueryCursor(cursor) {
|
|
25
|
+
return Buffer.from(JSON.stringify(stable(cursor))).toString("base64url");
|
|
26
|
+
}
|
|
27
|
+
export function decodeQueryCursor(cursor) {
|
|
28
|
+
if (cursor === undefined)
|
|
29
|
+
return undefined;
|
|
30
|
+
try {
|
|
31
|
+
const decoded = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
32
|
+
if (decoded.v !== 1 ||
|
|
33
|
+
(decoded.mode !== "sparql" && decoded.mode !== "structured")) {
|
|
34
|
+
throw new Error("unsupported cursor");
|
|
35
|
+
}
|
|
36
|
+
if (!Number.isInteger(decoded.offset) || decoded.offset < 0)
|
|
37
|
+
throw new Error("invalid cursor offset");
|
|
38
|
+
decoded.row_limit = effectiveRowLimit(decoded.detail, decoded.row_limit);
|
|
39
|
+
return decoded;
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw new Error(`invalid lbb_query cursor: ${error instanceof Error ? error.message : String(error)}`, {
|
|
43
|
+
cause: error,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function assertCursorScope(args, cursor) {
|
|
48
|
+
if (!cursor)
|
|
49
|
+
return;
|
|
50
|
+
if (args.graph !== undefined && args.graph !== cursor.graph) {
|
|
51
|
+
throw new Error("cursor graph does not match the supplied graph argument");
|
|
52
|
+
}
|
|
53
|
+
if (args.branch !== undefined && args.branch !== cursor.branch) {
|
|
54
|
+
throw new Error("cursor branch does not match the supplied branch argument");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function rowPageFrom(value) {
|
|
58
|
+
if (!value || typeof value !== "object")
|
|
59
|
+
return undefined;
|
|
60
|
+
const rowPage = value.row_page;
|
|
61
|
+
if (!rowPage || typeof rowPage !== "object")
|
|
62
|
+
return undefined;
|
|
63
|
+
const page = rowPage;
|
|
64
|
+
if (typeof page.returned !== "number" ||
|
|
65
|
+
typeof page.total !== "number" ||
|
|
66
|
+
typeof page.offset !== "number" ||
|
|
67
|
+
typeof page.limit !== "number" ||
|
|
68
|
+
typeof page.has_more !== "boolean") {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
returned: page.returned,
|
|
73
|
+
total: page.total,
|
|
74
|
+
offset: page.offset,
|
|
75
|
+
limit: page.limit,
|
|
76
|
+
has_more: page.has_more,
|
|
77
|
+
next_offset: typeof page.next_offset === "number" ? page.next_offset : undefined,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function serverTruncationFlags(value) {
|
|
81
|
+
if (!value || typeof value !== "object")
|
|
82
|
+
return [];
|
|
83
|
+
const flags = [
|
|
84
|
+
["truncated", "solution cap"],
|
|
85
|
+
["truncated_by_read_budget", "read budget"],
|
|
86
|
+
];
|
|
87
|
+
return flags.flatMap(([key, label]) => value[key] === true ? [label] : []);
|
|
88
|
+
}
|
|
89
|
+
export function headCommitSeqFromMetadata(metadata) {
|
|
90
|
+
const commitSeq = metadata.snapshot?.commit_seq;
|
|
91
|
+
if (typeof commitSeq !== "number" ||
|
|
92
|
+
!Number.isInteger(commitSeq) ||
|
|
93
|
+
commitSeq < 0) {
|
|
94
|
+
throw new Error("graph metadata did not include a valid snapshot.commit_seq");
|
|
95
|
+
}
|
|
96
|
+
return commitSeq;
|
|
97
|
+
}
|
|
98
|
+
export async function queryCommitPin(target, requested, cursor) {
|
|
99
|
+
if (cursor)
|
|
100
|
+
return cursor.as_of_commit_seq;
|
|
101
|
+
if (requested !== undefined)
|
|
102
|
+
return requested;
|
|
103
|
+
return headCommitSeqFromMetadata(await target.metadata());
|
|
104
|
+
}
|
|
105
|
+
export function continuationNext(cursor, offset, rowLimit) {
|
|
106
|
+
return {
|
|
107
|
+
mode: cursor.mode,
|
|
108
|
+
cursor: encodeQueryCursor({ ...cursor, row_limit: rowLimit, offset }),
|
|
109
|
+
row_limit: rowLimit,
|
|
110
|
+
detail: cursor.detail,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export function rowPageNext(cursor, page) {
|
|
114
|
+
if (!page?.has_more)
|
|
115
|
+
return undefined;
|
|
116
|
+
return continuationNext(cursor, page.next_offset ?? page.offset + page.returned, cursor.row_limit);
|
|
117
|
+
}
|
|
118
|
+
export function nextDetail(detail) {
|
|
119
|
+
if (detail === "compact")
|
|
120
|
+
return "standard";
|
|
121
|
+
if (detail === "standard")
|
|
122
|
+
return "full";
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
export function stable(value) {
|
|
126
|
+
if (Array.isArray(value))
|
|
127
|
+
return value.map(stable);
|
|
128
|
+
if (value && typeof value === "object") {
|
|
129
|
+
return Object.fromEntries(Object.entries(value)
|
|
130
|
+
.filter(([, v]) => v !== undefined)
|
|
131
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
132
|
+
.map(([k, v]) => [k, stable(v)]));
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
export function stableJson(value) {
|
|
137
|
+
return JSON.stringify(stable(value));
|
|
138
|
+
}
|
|
139
|
+
// Little Big Brain's RDF projection mints relation/class/property predicate IRIs from the
|
|
140
|
+
// *normalized* (lowercased) name, so the canonical local part is always
|
|
141
|
+
// lowercase. A SPARQL term like <https://littlebigbrain.com/r/FOR_CLIENT> is therefore a
|
|
142
|
+
// different — and non-existent — IRI than the real <…/r/for_client>, and it
|
|
143
|
+
// matches nothing while returning **no error** (the silent-0 trap: structured
|
|
144
|
+
// mode is case-insensitive, SPARQL text is not). These helpers canonicalize the
|
|
145
|
+
// local-name case for the three Little Big Brain namespaces so an MCP SPARQL query just works.
|
|
146
|
+
export const LBB_IRI_RE = /<https:\/\/littlebigbrain\.com\/(r|class|p)\/([^>]*)>/g;
|
|
147
|
+
/**
|
|
148
|
+
* Lowercase the ASCII letters of a Little Big Brain IRI local part while leaving `%XX`
|
|
149
|
+
* percent-escapes byte-for-byte (the projection's `encode_segment` emits *upper*
|
|
150
|
+
* hex, e.g. `a%2Fb`, so lowercasing the escape would break the match). Real
|
|
151
|
+
* relation names are identifiers like `FOR_CLIENT` with no escapes, so this is a
|
|
152
|
+
* plain lowercase in the common case.
|
|
153
|
+
*/
|
|
154
|
+
export function lowercaseLocalName(local) {
|
|
155
|
+
let out = "";
|
|
156
|
+
for (let i = 0; i < local.length; i += 1) {
|
|
157
|
+
const ch = local[i];
|
|
158
|
+
if (ch === "%" && /^[0-9A-Fa-f]{2}/.test(local.slice(i + 1, i + 3))) {
|
|
159
|
+
out += local.slice(i, i + 3);
|
|
160
|
+
i += 2;
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
out += ch.toLowerCase();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Canonicalize the case of Little Big Brain relation/class/property IRI local names in a
|
|
170
|
+
* SPARQL text query, and report each distinct rewrite so the change is never
|
|
171
|
+
* hidden. Only touches angle-bracket IRIs under the three Little Big Brain namespaces, so
|
|
172
|
+
* string literals and foreign IRIs (rdfs:label, foaf, …) are untouched. Because
|
|
173
|
+
* the canonical form is already lowercase, an already-lowercase query is an
|
|
174
|
+
* exact no-op (no rewrite, no note).
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeLbbIris(query) {
|
|
177
|
+
const rewrites = new Map();
|
|
178
|
+
const normalized = query.replace(LBB_IRI_RE, (match, ns, local) => {
|
|
179
|
+
const lowered = lowercaseLocalName(local);
|
|
180
|
+
if (lowered === local)
|
|
181
|
+
return match;
|
|
182
|
+
const to = `<https://littlebigbrain.com/${ns}/${lowered}>`;
|
|
183
|
+
rewrites.set(match, to);
|
|
184
|
+
return to;
|
|
185
|
+
});
|
|
186
|
+
const notes = [...rewrites.entries()].map(([from, to]) => `Normalized a Little Big Brain IRI to its canonical lowercase local name: ${from} → ${to}. ` +
|
|
187
|
+
"Little Big Brain relation/class/property IRIs are always lowercased; the original case would have matched nothing.");
|
|
188
|
+
return { query: normalized, notes };
|
|
189
|
+
}
|
|
190
|
+
export function contentHashKey(scope, payload) {
|
|
191
|
+
const digest = createHash("sha256")
|
|
192
|
+
.update(stableJson({
|
|
193
|
+
scope: { graph: scope.graph ?? null, branch: scope.branch ?? null },
|
|
194
|
+
payload,
|
|
195
|
+
}))
|
|
196
|
+
.digest("hex");
|
|
197
|
+
return `mcp.commit:${digest}`;
|
|
198
|
+
}
|
|
199
|
+
export function requireString(value, name) {
|
|
200
|
+
if (typeof value === "string" && value.trim().length > 0)
|
|
201
|
+
return value;
|
|
202
|
+
throw new Error(`${name} is required`);
|
|
203
|
+
}
|
|
204
|
+
export function requireObject(value, name) {
|
|
205
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
206
|
+
return value;
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`${name} is required`);
|
|
209
|
+
}
|
|
210
|
+
export function compactLimits(detail) {
|
|
211
|
+
switch (detail) {
|
|
212
|
+
case "full":
|
|
213
|
+
return { maxItems: 100, maxString: 5_000 };
|
|
214
|
+
case "standard":
|
|
215
|
+
return { maxItems: 20, maxString: 1_000 };
|
|
216
|
+
default:
|
|
217
|
+
return { maxItems: 5, maxString: 300 };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function truncateValue(value, limits, state) {
|
|
221
|
+
if (typeof value === "string") {
|
|
222
|
+
if (value.length <= limits.maxString)
|
|
223
|
+
return value;
|
|
224
|
+
state.truncated = true;
|
|
225
|
+
return `${value.slice(0, limits.maxString)}... [truncated ${value.length - limits.maxString} chars]`;
|
|
226
|
+
}
|
|
227
|
+
if (Array.isArray(value)) {
|
|
228
|
+
const items = value.length > limits.maxItems ? value.slice(0, limits.maxItems) : value;
|
|
229
|
+
if (items.length !== value.length)
|
|
230
|
+
state.truncated = true;
|
|
231
|
+
return items.map((item) => truncateValue(item, limits, state));
|
|
232
|
+
}
|
|
233
|
+
if (value && typeof value === "object") {
|
|
234
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [
|
|
235
|
+
k,
|
|
236
|
+
truncateValue(v, limits, state),
|
|
237
|
+
]));
|
|
238
|
+
}
|
|
239
|
+
return value;
|
|
240
|
+
}
|
|
241
|
+
export function countsFor(value) {
|
|
242
|
+
const counts = {};
|
|
243
|
+
if (Array.isArray(value)) {
|
|
244
|
+
counts.items = value.length;
|
|
245
|
+
}
|
|
246
|
+
else if (value && typeof value === "object") {
|
|
247
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
248
|
+
if (Array.isArray(nested))
|
|
249
|
+
counts[key] = nested.length;
|
|
250
|
+
if (nested && typeof nested === "object" && !Array.isArray(nested)) {
|
|
251
|
+
for (const [childKey, child] of Object.entries(nested)) {
|
|
252
|
+
if (Array.isArray(child))
|
|
253
|
+
counts[`${key}.${childKey}`] = child.length;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return Object.keys(counts).length > 0 ? counts : undefined;
|
|
259
|
+
}
|
|
260
|
+
export function defaultSummary(label, value, truncated) {
|
|
261
|
+
if (value &&
|
|
262
|
+
typeof value === "object" &&
|
|
263
|
+
typeof value.summary === "string") {
|
|
264
|
+
return value.summary;
|
|
265
|
+
}
|
|
266
|
+
const counts = countsFor(value);
|
|
267
|
+
const countText = counts
|
|
268
|
+
? ` (${Object.entries(counts)
|
|
269
|
+
.slice(0, 3)
|
|
270
|
+
.map(([k, v]) => `${k}: ${v}`)
|
|
271
|
+
.join(", ")})`
|
|
272
|
+
: "";
|
|
273
|
+
return `${label}${countText}${truncated ? " [truncated]" : ""}`;
|
|
274
|
+
}
|
|
275
|
+
export function envelope(label, value, detailArg, next) {
|
|
276
|
+
const detail = normalizeDetail(detailArg);
|
|
277
|
+
const state = { truncated: false };
|
|
278
|
+
let data = truncateValue(value, compactLimits(detail), state);
|
|
279
|
+
let result = {
|
|
280
|
+
summary: defaultSummary(label, value, state.truncated),
|
|
281
|
+
data,
|
|
282
|
+
counts: countsFor(value),
|
|
283
|
+
truncated: state.truncated || undefined,
|
|
284
|
+
next: state.truncated && nextDetail(detail)
|
|
285
|
+
? { ...(next ?? {}), detail: nextDetail(detail) }
|
|
286
|
+
: next,
|
|
287
|
+
};
|
|
288
|
+
let text = JSON.stringify(result, null, 2);
|
|
289
|
+
if (text.length <= HARD_OUTPUT_CHARS)
|
|
290
|
+
return result;
|
|
291
|
+
const hardState = { truncated: true };
|
|
292
|
+
data = truncateValue(value, { maxItems: 3, maxString: 120 }, hardState);
|
|
293
|
+
result = {
|
|
294
|
+
summary: `${label} [hard-capped for MCP output]`,
|
|
295
|
+
data,
|
|
296
|
+
counts: countsFor(value),
|
|
297
|
+
truncated: true,
|
|
298
|
+
next: { ...(next ?? {}), detail: "full" },
|
|
299
|
+
};
|
|
300
|
+
text = JSON.stringify(result, null, 2);
|
|
301
|
+
if (text.length <= HARD_OUTPUT_CHARS)
|
|
302
|
+
return result;
|
|
303
|
+
return {
|
|
304
|
+
summary: `${label} [hard-capped for MCP output]`,
|
|
305
|
+
data: {
|
|
306
|
+
note: "The response was too large for the MCP tool result. Narrow the query or request a smaller top_k.",
|
|
307
|
+
preview: text.slice(0, 20_000),
|
|
308
|
+
},
|
|
309
|
+
counts: countsFor(value),
|
|
310
|
+
truncated: true,
|
|
311
|
+
next: { ...(next ?? {}), detail: "full" },
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
export function queryEnvelope(label, value, detailArg, rowPage, next, repage) {
|
|
315
|
+
const detail = normalizeDetail(detailArg);
|
|
316
|
+
const limits = compactLimits(detail);
|
|
317
|
+
const state = { truncated: false };
|
|
318
|
+
const returned = rowPage?.returned;
|
|
319
|
+
const rowCap = rowPage
|
|
320
|
+
? Math.max(limits.maxItems, rowPage.returned)
|
|
321
|
+
: limits.maxItems;
|
|
322
|
+
let data = truncateValue(value, { ...limits, maxItems: rowCap }, state);
|
|
323
|
+
const partialRows = rowPage
|
|
324
|
+
? rowPage.returned < rowPage.total || rowPage.has_more
|
|
325
|
+
: false;
|
|
326
|
+
const rowText = rowPage
|
|
327
|
+
? rowPage.returned < rowPage.total
|
|
328
|
+
? `returned ${rowPage.returned} of ${rowPage.total} rows`
|
|
329
|
+
: `returned ${rowPage.returned} rows`
|
|
330
|
+
: undefined;
|
|
331
|
+
const serverFlags = serverTruncationFlags(value);
|
|
332
|
+
const serverTruncated = serverFlags.length > 0;
|
|
333
|
+
const serverText = serverTruncated
|
|
334
|
+
? ` [server-truncated: ${serverFlags.join(", ")}]`
|
|
335
|
+
: "";
|
|
336
|
+
let result = {
|
|
337
|
+
summary: rowText
|
|
338
|
+
? `${label}: ${rowText}${serverText}${state.truncated ? " [truncated output]" : ""}`
|
|
339
|
+
: defaultSummary(label, value, state.truncated),
|
|
340
|
+
data,
|
|
341
|
+
counts: countsFor(value),
|
|
342
|
+
row_page: rowPage,
|
|
343
|
+
truncated: state.truncated || partialRows || serverTruncated || undefined,
|
|
344
|
+
next: partialRows && next
|
|
345
|
+
? next
|
|
346
|
+
: state.truncated && nextDetail(detail)
|
|
347
|
+
? { detail: nextDetail(detail) }
|
|
348
|
+
: next,
|
|
349
|
+
};
|
|
350
|
+
let text = JSON.stringify(result, null, 2);
|
|
351
|
+
if (text.length <= HARD_OUTPUT_CHARS)
|
|
352
|
+
return result;
|
|
353
|
+
// The full result overflows one MCP tool result, so the displayed rows are
|
|
354
|
+
// capped to fit. `row_page`/`counts` still describe the *server* page, so
|
|
355
|
+
// reporting only those reads as "every row delivered" even when the display
|
|
356
|
+
// was cut — the recurring MCP false-positive. So: state shown-vs-returned
|
|
357
|
+
// explicitly (`rows_shown`), and give advice that matches reality —
|
|
358
|
+
// * server itself withheld rows (partialRows): page with the existing cursor;
|
|
359
|
+
// * server returned the complete set but it is too big: page the same set at
|
|
360
|
+
// a smaller row_limit via a fresh cursor (offered here as `next`) or narrow
|
|
361
|
+
// with HAVING.
|
|
362
|
+
// "page with the cursor" is never suggested unless a cursor is actually given.
|
|
363
|
+
const remedy = partialRows
|
|
364
|
+
? " page with the cursor for the remaining rows"
|
|
365
|
+
: repage
|
|
366
|
+
? " re-run with the returned cursor to page the full set at a smaller row_limit, or add a HAVING filter to narrow the groups"
|
|
367
|
+
: " re-run with a lower row_limit to page the full set, or add a HAVING filter to narrow the groups";
|
|
368
|
+
const capNote = (shown) => returned !== undefined && shown < returned
|
|
369
|
+
? ` [MCP showed ${shown} of ${returned} rows — over the ${HARD_OUTPUT_CHARS}-char output budget]`
|
|
370
|
+
: " [hard-capped for MCP output]";
|
|
371
|
+
for (const cap of [200, 100, 50, 25, 10, 5, 3]) {
|
|
372
|
+
const hardState = { truncated: true };
|
|
373
|
+
data = truncateValue(value, { maxItems: cap, maxString: 160 }, hardState);
|
|
374
|
+
const shown = returned !== undefined ? Math.min(cap, returned) : cap;
|
|
375
|
+
const hardNext = partialRows
|
|
376
|
+
? next
|
|
377
|
+
: repage
|
|
378
|
+
? continuationNext(repage, shown, Math.max(1, shown))
|
|
379
|
+
: undefined;
|
|
380
|
+
result = {
|
|
381
|
+
summary: rowText
|
|
382
|
+
? `${label}: ${rowText}${serverText}${capNote(shown)} —${remedy}`
|
|
383
|
+
: `${label}${capNote(shown)} —${remedy}`,
|
|
384
|
+
data,
|
|
385
|
+
counts: countsFor(value),
|
|
386
|
+
row_page: rowPage,
|
|
387
|
+
rows_shown: returned !== undefined ? shown : undefined,
|
|
388
|
+
truncated: true,
|
|
389
|
+
next: hardNext,
|
|
390
|
+
};
|
|
391
|
+
text = JSON.stringify(result, null, 2);
|
|
392
|
+
if (text.length <= HARD_OUTPUT_CHARS)
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
return {
|
|
396
|
+
summary: rowText
|
|
397
|
+
? `${label}: ${rowText}${serverText}${capNote(0)} —${remedy}`
|
|
398
|
+
: `${label}${capNote(0)} —${remedy}`,
|
|
399
|
+
data: {
|
|
400
|
+
note: "The response was too large for the MCP tool result even at the minimum row cap. Page with a smaller row_limit or add a HAVING filter to narrow the groups.",
|
|
401
|
+
preview: text.slice(0, 20_000),
|
|
402
|
+
},
|
|
403
|
+
counts: countsFor(value),
|
|
404
|
+
row_page: rowPage,
|
|
405
|
+
rows_shown: returned !== undefined ? 0 : undefined,
|
|
406
|
+
truncated: true,
|
|
407
|
+
next: partialRows
|
|
408
|
+
? next
|
|
409
|
+
: repage
|
|
410
|
+
? continuationNext(repage, 0, 1)
|
|
411
|
+
: undefined,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
export function toolResult(value) {
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
417
|
+
structuredContent: value,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
// `lbb_inspect action=entity` returns the full incoming/outgoing/history arrays,
|
|
421
|
+
// but the display truncates them to the detail cap (compact shows 5) while
|
|
422
|
+
// `counts` still reports the true totals — so a high-degree node reads as "5
|
|
423
|
+
// edges" unless the caller already knows to switch to the paged `edges`/`history`
|
|
424
|
+
// reads. Make that self-documenting: when a sample is capped, attach the true
|
|
425
|
+
// counts plus ready-to-run paged reads so the workaround is discoverable in the
|
|
426
|
+
// response instead of tribal knowledge.
|
|
427
|
+
export function entityEdgeCapHint(data, detail, identity) {
|
|
428
|
+
if (!data || typeof data !== "object")
|
|
429
|
+
return {};
|
|
430
|
+
const record = data;
|
|
431
|
+
const cap = compactLimits(normalizeDetail(detail)).maxItems;
|
|
432
|
+
const lengthOf = (field) => {
|
|
433
|
+
const value = record[field];
|
|
434
|
+
return Array.isArray(value) ? value.length : 0;
|
|
435
|
+
};
|
|
436
|
+
const capped = {};
|
|
437
|
+
const fullReads = [];
|
|
438
|
+
const edgeReads = [
|
|
439
|
+
{ field: "incoming", direction: "in" },
|
|
440
|
+
{ field: "outgoing", direction: "out" },
|
|
441
|
+
];
|
|
442
|
+
for (const { field, direction } of edgeReads) {
|
|
443
|
+
const total = lengthOf(field);
|
|
444
|
+
if (total > cap) {
|
|
445
|
+
capped[field] = total;
|
|
446
|
+
fullReads.push({
|
|
447
|
+
tool: "lbb_inspect",
|
|
448
|
+
arguments: { action: "edges", direction, ...identity },
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (lengthOf("history") > cap) {
|
|
453
|
+
capped.history = lengthOf("history");
|
|
454
|
+
fullReads.push({
|
|
455
|
+
tool: "lbb_inspect",
|
|
456
|
+
arguments: { action: "history", ...identity },
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
if (Object.keys(capped).length === 0)
|
|
460
|
+
return {};
|
|
461
|
+
return {
|
|
462
|
+
edge_sample: {
|
|
463
|
+
note: `This node's edge/history arrays are a display sample capped at ${cap} per field; counts holds the true totals. Read the full set with these paged reads (cursor through them until has_more=false).`,
|
|
464
|
+
capped_totals: capped,
|
|
465
|
+
full_reads: fullReads,
|
|
466
|
+
},
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
export function errorResult(error) {
|
|
470
|
+
const payload = error instanceof LbbError
|
|
471
|
+
? {
|
|
472
|
+
type: error.type ?? "api_error",
|
|
473
|
+
code: error.code ?? "unstructured_error",
|
|
474
|
+
message: error.message,
|
|
475
|
+
param: error.param ?? null,
|
|
476
|
+
request_id: error.requestId ?? null,
|
|
477
|
+
doc_url: error.docUrl ?? null,
|
|
478
|
+
status: error.status,
|
|
479
|
+
}
|
|
480
|
+
: {
|
|
481
|
+
type: "tool_error",
|
|
482
|
+
code: "tool_error",
|
|
483
|
+
message: error instanceof Error ? error.message : String(error),
|
|
484
|
+
param: null,
|
|
485
|
+
request_id: null,
|
|
486
|
+
doc_url: null,
|
|
487
|
+
status: null,
|
|
488
|
+
};
|
|
489
|
+
const structuredContent = { error: payload };
|
|
490
|
+
return {
|
|
491
|
+
content: [
|
|
492
|
+
{
|
|
493
|
+
type: "text",
|
|
494
|
+
text: JSON.stringify(structuredContent, null, 2),
|
|
495
|
+
},
|
|
496
|
+
],
|
|
497
|
+
structuredContent,
|
|
498
|
+
isError: true,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* A graph-scope 404 surfaces as a raw object-storage key
|
|
503
|
+
* (`not found: tenants/<t>/graphs/<g>/branches/<b>/heads/current.json`), which
|
|
504
|
+
* only means something if you already know the graph's real name. Rewrite it
|
|
505
|
+
* into an actionable message: name the graph/branch this request targeted, and —
|
|
506
|
+
* via the tenant-scoped `GET /v1/graphs`, which resolves even when the scoped
|
|
507
|
+
* graph is absent — list the graphs (or branches) that do exist and tell the
|
|
508
|
+
* caller to pass `graph=`/`branch=`. Every other error passes through untouched.
|
|
509
|
+
*/
|
|
510
|
+
export async function enrichError(client, error) {
|
|
511
|
+
if (!(error instanceof LbbError) || error.status !== 404)
|
|
512
|
+
return error;
|
|
513
|
+
const match = /graphs\/([^/]+)\/branches\/([^/]+)\/heads\/current\.json/.exec(error.message);
|
|
514
|
+
if (!match)
|
|
515
|
+
return error;
|
|
516
|
+
const [, graph, branch] = match;
|
|
517
|
+
let graphs = [];
|
|
518
|
+
try {
|
|
519
|
+
const listed = (await client.listGraphs());
|
|
520
|
+
graphs = (listed.data ?? []).flatMap((g) => typeof g.graph_id === "string"
|
|
521
|
+
? [
|
|
522
|
+
{
|
|
523
|
+
graph_id: g.graph_id,
|
|
524
|
+
branches: Array.isArray(g.branches)
|
|
525
|
+
? g.branches
|
|
526
|
+
: undefined,
|
|
527
|
+
},
|
|
528
|
+
]
|
|
529
|
+
: []);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
// Listing failed too (auth, transport); fall back to the generic-but-actionable hint.
|
|
533
|
+
}
|
|
534
|
+
const existing = graphs.find((g) => g.graph_id === graph);
|
|
535
|
+
let message;
|
|
536
|
+
if (existing) {
|
|
537
|
+
const branches = existing.branches ?? [];
|
|
538
|
+
const list = branches.length > 0
|
|
539
|
+
? ` Existing branches: ${branches.slice(0, 50).join(", ")}.`
|
|
540
|
+
: "";
|
|
541
|
+
message =
|
|
542
|
+
`branch "${branch}" was not found on graph "${graph}" in this tenant.${list} ` +
|
|
543
|
+
"Pass an existing branch as the `branch` argument.";
|
|
544
|
+
}
|
|
545
|
+
else {
|
|
546
|
+
const names = graphs.map((g) => g.graph_id);
|
|
547
|
+
const list = names.length > 0
|
|
548
|
+
? ` Available graphs in this tenant: ${names.slice(0, 50).join(", ")}.`
|
|
549
|
+
: "";
|
|
550
|
+
const example = names.length > 0 ? ` (e.g. graph="${names[0]}")` : "";
|
|
551
|
+
message =
|
|
552
|
+
`graph "${graph}" was not found in this tenant — this request targeted graph "${graph}", branch "${branch}" ` +
|
|
553
|
+
`(either you passed it or it is the connection default).${list} ` +
|
|
554
|
+
`Pass an existing graph as the \`graph\` argument${example}.`;
|
|
555
|
+
}
|
|
556
|
+
return new LbbError(error.status, error.body, {
|
|
557
|
+
type: error.type,
|
|
558
|
+
code: error.code,
|
|
559
|
+
message,
|
|
560
|
+
param: error.param,
|
|
561
|
+
request_id: error.requestId,
|
|
562
|
+
doc_url: error.docUrl,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
export async function run(client, label, detail, fn, augment) {
|
|
566
|
+
try {
|
|
567
|
+
const data = await fn();
|
|
568
|
+
const result = envelope(label, data, detail);
|
|
569
|
+
return toolResult(augment ? { ...result, ...augment(data) } : result);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
return errorResult(await enrichError(client, error));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* Point-of-use feedback affordance attached to every `lbb_search` result: an
|
|
577
|
+
* agent looking at results it can judge gets a ready-to-run
|
|
578
|
+
* `lbb_commit mode=search_feedback` template (the standout "guide ships runnable
|
|
579
|
+
* possibilities" pattern, applied to the moment of judgment). The `search_id` is
|
|
580
|
+
* pre-filled from the response when present so the label set ties back to this
|
|
581
|
+
* exact ranked run.
|
|
582
|
+
*/
|
|
583
|
+
export function searchFeedbackHint(data, ctx) {
|
|
584
|
+
const searchId = data &&
|
|
585
|
+
typeof data === "object" &&
|
|
586
|
+
typeof data.search_id === "string"
|
|
587
|
+
? data.search_id
|
|
588
|
+
: undefined;
|
|
589
|
+
return {
|
|
590
|
+
how: "If you can judge these results, rate them with lbb_commit mode=search_feedback. Little Big Brain stores the labels as customer-specific qrels (in __lbb_feedback) and exports them as training/eval data for embedding fine-tuning — they improve retrieval, and are kept separate from customer facts. Skip it when you have no basis to judge.",
|
|
591
|
+
grades: { ideal_or_good: 3, partially_relevant: 1, bad: 0 },
|
|
592
|
+
example: {
|
|
593
|
+
tool: "lbb_commit",
|
|
594
|
+
args: {
|
|
595
|
+
mode: "search_feedback",
|
|
596
|
+
...(ctx.graph !== undefined ? { graph: ctx.graph } : {}),
|
|
597
|
+
...(ctx.branch !== undefined ? { branch: ctx.branch } : {}),
|
|
598
|
+
search_feedback: {
|
|
599
|
+
query: ctx.query ?? ctx.queries?.[0] ?? "<the query you ran>",
|
|
600
|
+
...(searchId !== undefined ? { search_id: searchId } : {}),
|
|
601
|
+
labels: [
|
|
602
|
+
{
|
|
603
|
+
target: {
|
|
604
|
+
kind: "entity",
|
|
605
|
+
entity: { entity_type: "<type>", name: "<name>" },
|
|
606
|
+
},
|
|
607
|
+
rank: 1,
|
|
608
|
+
score: 0.0,
|
|
609
|
+
grade: 3,
|
|
610
|
+
},
|
|
611
|
+
],
|
|
612
|
+
split: "unspecified",
|
|
613
|
+
},
|
|
614
|
+
},
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
}
|
|
618
|
+
export function resolveProfile(profile) {
|
|
619
|
+
switch (profile) {
|
|
620
|
+
case "ndcg_v1":
|
|
621
|
+
case "graph_aware_v1":
|
|
622
|
+
case "scored_atom_v1":
|
|
623
|
+
case "baseline":
|
|
624
|
+
return profile;
|
|
625
|
+
default:
|
|
626
|
+
return undefined;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
export function searchBody(p) {
|
|
630
|
+
const mode = p.mode ?? "hybrid";
|
|
631
|
+
return {
|
|
632
|
+
query: p.query,
|
|
633
|
+
targets: ["concepts", "entities", "assertions", "paths", "observations"],
|
|
634
|
+
search: {
|
|
635
|
+
lexical: mode === "hybrid" || mode === "lexical",
|
|
636
|
+
bm25: mode === "hybrid" || mode === "bm25",
|
|
637
|
+
vector: mode === "hybrid" || mode === "vector",
|
|
638
|
+
bm25_source: "persisted",
|
|
639
|
+
vector_source: "persisted",
|
|
640
|
+
consistency: "strong",
|
|
641
|
+
profile: resolveProfile(p.profile),
|
|
642
|
+
},
|
|
643
|
+
max_hops: 2,
|
|
644
|
+
top_k: p.top_k ?? 10,
|
|
645
|
+
...(p.as_of !== undefined ? { as_of_valid_time: p.as_of } : {}),
|
|
646
|
+
...(p.as_of_commit_seq !== undefined
|
|
647
|
+
? { as_of_commit_seq: p.as_of_commit_seq }
|
|
648
|
+
: {}),
|
|
649
|
+
explain: false,
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
export function vegaChart(kind, title, points, categoryTitle, valueTitle) {
|
|
653
|
+
const base = {
|
|
654
|
+
$schema: "https://vega.github.io/schema/vega-lite/v5.json",
|
|
655
|
+
title,
|
|
656
|
+
data: { values: points },
|
|
657
|
+
};
|
|
658
|
+
if (kind === "pie") {
|
|
659
|
+
return {
|
|
660
|
+
...base,
|
|
661
|
+
mark: { type: "arc", tooltip: true },
|
|
662
|
+
encoding: {
|
|
663
|
+
theta: { field: "value", type: "quantitative", title: valueTitle },
|
|
664
|
+
color: { field: "label", type: "nominal", title: categoryTitle },
|
|
665
|
+
},
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
return {
|
|
669
|
+
...base,
|
|
670
|
+
mark: { type: "bar", tooltip: true },
|
|
671
|
+
encoding: {
|
|
672
|
+
x: { field: "label", type: "nominal", sort: "-y", title: categoryTitle },
|
|
673
|
+
y: { field: "value", type: "quantitative", title: valueTitle },
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
export function sparqlScalarNumber(scalar) {
|
|
678
|
+
if (scalar == null || scalar === "null")
|
|
679
|
+
return 0;
|
|
680
|
+
if (typeof scalar === "object") {
|
|
681
|
+
const s = scalar;
|
|
682
|
+
if (typeof s.i64 === "number")
|
|
683
|
+
return s.i64;
|
|
684
|
+
if (typeof s.f64 === "number")
|
|
685
|
+
return s.f64;
|
|
686
|
+
}
|
|
687
|
+
return 0;
|
|
688
|
+
}
|
|
689
|
+
export function entityLabel(view) {
|
|
690
|
+
if (view && typeof view === "object") {
|
|
691
|
+
const v = view;
|
|
692
|
+
return v.name ?? v.id ?? "(unnamed)";
|
|
693
|
+
}
|
|
694
|
+
return String(view ?? "(unnamed)");
|
|
695
|
+
}
|
|
696
|
+
/** Label for a typed scalar GROUP BY key (`value_keys[as]`), an externally
|
|
697
|
+
* tagged `SparqlKeyValue` such as `{ str: "search" }`, `{ i64: 5 }`,
|
|
698
|
+
* `{ date_time: "2026-06" }`, or the unit `"null"`. */
|
|
699
|
+
export function sparqlKeyLabel(value) {
|
|
700
|
+
if (value == null || value === "null")
|
|
701
|
+
return "null";
|
|
702
|
+
if (typeof value === "object") {
|
|
703
|
+
const entry = Object.entries(value)[0];
|
|
704
|
+
if (entry)
|
|
705
|
+
return String(entry[1]);
|
|
706
|
+
}
|
|
707
|
+
return String(value);
|
|
708
|
+
}
|
|
709
|
+
export function buildPossibilities(relations, entityTypes = []) {
|
|
710
|
+
const possibilities = [
|
|
711
|
+
{
|
|
712
|
+
name: "Entity composition",
|
|
713
|
+
description: "How many entities of each type - the overall shape of the graph.",
|
|
714
|
+
chart: "bar",
|
|
715
|
+
run: {
|
|
716
|
+
tool: "lbb_query",
|
|
717
|
+
args: { mode: "analyze", metric: "entity_types" },
|
|
718
|
+
},
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
name: "Relation usage",
|
|
722
|
+
description: "How many edges per relation - which connections dominate.",
|
|
723
|
+
chart: "bar",
|
|
724
|
+
run: {
|
|
725
|
+
tool: "lbb_query",
|
|
726
|
+
args: { mode: "analyze", metric: "relations" },
|
|
727
|
+
},
|
|
728
|
+
},
|
|
729
|
+
{
|
|
730
|
+
name: "Overview",
|
|
731
|
+
description: "Top-line totals: entities, current edges, observations.",
|
|
732
|
+
chart: "bar",
|
|
733
|
+
run: { tool: "lbb_query", args: { mode: "analyze", metric: "overview" } },
|
|
734
|
+
},
|
|
735
|
+
];
|
|
736
|
+
const top = relations[0]?.name;
|
|
737
|
+
if (top) {
|
|
738
|
+
possibilities.push({
|
|
739
|
+
name: `Hubs in ${top}`,
|
|
740
|
+
description: `For the relation ${top}, how many distinct targets each source connects to (top 20).`,
|
|
741
|
+
chart: "bar",
|
|
742
|
+
run: {
|
|
743
|
+
tool: "lbb_query",
|
|
744
|
+
args: {
|
|
745
|
+
mode: "structured",
|
|
746
|
+
body: {
|
|
747
|
+
patterns: [
|
|
748
|
+
{ subject: { var: "s" }, predicate: top, object: { var: "o" } },
|
|
749
|
+
],
|
|
750
|
+
group_by: ["s"],
|
|
751
|
+
aggregates: [
|
|
752
|
+
{
|
|
753
|
+
func: "count",
|
|
754
|
+
distinct: true,
|
|
755
|
+
operand: { var: "o" },
|
|
756
|
+
as: "n",
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
order_by: [{ var: "n", descending: true }],
|
|
760
|
+
limit: 20,
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
}, {
|
|
765
|
+
name: `Most-referenced targets in ${top}`,
|
|
766
|
+
description: `For the relation ${top}, which targets are pointed at by the most sources (top 20).`,
|
|
767
|
+
chart: "bar",
|
|
768
|
+
run: {
|
|
769
|
+
tool: "lbb_query",
|
|
770
|
+
args: {
|
|
771
|
+
mode: "structured",
|
|
772
|
+
body: {
|
|
773
|
+
patterns: [
|
|
774
|
+
{ subject: { var: "s" }, predicate: top, object: { var: "o" } },
|
|
775
|
+
],
|
|
776
|
+
group_by: ["o"],
|
|
777
|
+
aggregates: [
|
|
778
|
+
{
|
|
779
|
+
func: "count",
|
|
780
|
+
distinct: true,
|
|
781
|
+
operand: { var: "s" },
|
|
782
|
+
as: "n",
|
|
783
|
+
},
|
|
784
|
+
],
|
|
785
|
+
order_by: [{ var: "n", descending: true }],
|
|
786
|
+
limit: 20,
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
},
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
const topType = entityTypes[0]?.name;
|
|
793
|
+
if (top && topType) {
|
|
794
|
+
possibilities.push({
|
|
795
|
+
name: `${topType} nodes that participate in ${top}`,
|
|
796
|
+
description: `Select every ${topType} that is the source of at least one ${top} edge.`,
|
|
797
|
+
chart: "bar",
|
|
798
|
+
run: {
|
|
799
|
+
tool: "lbb_query",
|
|
800
|
+
args: {
|
|
801
|
+
mode: "shacl",
|
|
802
|
+
shapes: [
|
|
803
|
+
{
|
|
804
|
+
targetClass: topType,
|
|
805
|
+
property: [{ path: top, min_count: 1, bind: "targets" }],
|
|
806
|
+
},
|
|
807
|
+
],
|
|
808
|
+
},
|
|
809
|
+
},
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return possibilities;
|
|
813
|
+
}
|
|
814
|
+
export async function guide(scopedClient) {
|
|
815
|
+
const s = (await scopedClient.summary());
|
|
816
|
+
const entityTypes = [...(s.entity_types ?? [])].sort((a, b) => b.count - a.count);
|
|
817
|
+
const relations = [...(s.relations ?? [])].sort((a, b) => b.count - a.count);
|
|
818
|
+
return {
|
|
819
|
+
overview: {
|
|
820
|
+
entities: s.entity_count ?? 0,
|
|
821
|
+
current_edges: s.current_edge_count ?? 0,
|
|
822
|
+
observations: s.observation_count ?? 0,
|
|
823
|
+
edge_events: s.edge_event_count ?? 0,
|
|
824
|
+
},
|
|
825
|
+
entity_types: entityTypes,
|
|
826
|
+
relations,
|
|
827
|
+
capability: {
|
|
828
|
+
search: "Use lbb_search for natural-language retrieval, optional multi-query fusion, and optional path following.",
|
|
829
|
+
search_feedback: "After a lbb_search result set is useful or clearly wrong, write relevance labels with lbb_commit mode=search_feedback. Use grade 3 for ideal/good results, grade 1 for partially relevant results, and grade 0 for bad results. Include the original query, search_id when present, target identity, rank, score, and an optional split train/eval/unspecified. These labels are stored in __lbb_feedback/main and later exported as qrels-style training/eval data; they are not customer facts.",
|
|
830
|
+
inspect: "Use lbb_inspect for ontology, RDF/SHACL schema, stored rules, metadata, state/history/why, exact traversals, and this guide.",
|
|
831
|
+
ontology_decorations: "lbb_inspect action=ontology returns a decoration_status catalog: each ontology decoration is enforced (the engine acts on it — state_reducer, value_type, super_types, properties, supernode_policy; cardinality, which GET /v1/ontology/conformance audits as sh:maxCount; and inverse_name/symmetric, which SPARQL resolves as relation aliases — an inverse name is queryable directly (lowered to ^forward, no stored inverse triple) and a symmetric relation matches both directions), advisory (transitive, temporal_semantics, required), or reserved (stored but unwired — default_weight, resolvable, alias/embedding_fields). You can also always reverse any relation in SPARQL by flipping the triple pattern or using ^forward. Each relation_def also carries edge_count — the number of current edges of that relation in this branch's snapshot — so you can tell at a glance which declared relations are actually populated (edge_count 0 = declared but unused) without a separate summary call.",
|
|
832
|
+
query: 'Use lbb_query for structured SPARQL-subset bodies, SPARQL text, SHACL shapes, inference previews, retrieval premises, and canned analysis. A mode=structured body is { patterns: [{ subject, predicate, object }], filters?, group_by?, group_keys?, aggregates?, having? }; a pattern `predicate` is a relation name and is case-insensitive. mode=structured GROUP BY is not limited to entity identity: group_keys can key on a typed scalar property ({ property: { var, field, as } }) or a calendar bucket of a datetime property ({ date_bucket: { var, field, granularity: month|day|…, as } }), with scalar keys returned per group under value_keys[as] — so per-category breakdowns (e.g. by area) and time series (e.g. commits per month) are single server-side queries over typed attributes, not 700 entity fetches bucketed by hand. These typed scalar attributes are set via entity_properties and read back flat under attributes on entity/list reads (there is no nested metadata.attributes blob); discover the queryable field names via lbb_inspect action=ontology (property_defs) or action=schema, and see the lbb_query body field for a copy-paste commits-per-area-per-month example. A FILTER entry has the exact shape { compare: { op: eq|ne|lt|le|gt|ge, left: <term>, right: <term> } } (also and/or/not), where a <term> is { var }, { property: { var, field } }, or { value: { str|i64|f64|bool|date_time|entity } } — e.g. filters: [{ compare: { op: "ge", left: { property: { var: "d", field: "amount" } }, right: { value: { f64: 1000000 } } } }]. In SPARQL text, relations are <https://littlebigbrain.com/r/NAME> (NAME lowercased; reverse with ^) and types <https://littlebigbrain.com/class/NAME> used as `?x a <…/class/NAME>` — the local name is always lowercase, and the tool auto-lowercases /r/, /class/, /p/ IRI local names (noting each rewrite) so a stray uppercase does not silently match nothing; entities are content-addressed, so match a named entity by `?e <http://www.w3.org/2000/01/rdf-schema#label> "Name"` rather than constructing its IRI. Off-graph, a stack also serves the native SPARQL 1.1 Protocol at /sparql for off-the-shelf SPARQL clients.',
|
|
833
|
+
write: "Use lbb_commit for fact writes and search relevance feedback; omitted idempotency keys are content-derived so retries dedupe. Set typed scalar attributes via entity_properties once the field is registered (add it on a live graph with lbb_configure evolve_ontology add_property). For feedback, use mode=search_feedback rather than fact triplets.",
|
|
834
|
+
configure: "Use lbb_configure to define a new ontology, evolve an existing one in place (add_entity_type / add_relation / add_property / widen, rename, narrow/remove), publish a previewed RDF/SHACL schema bundle, or replace stored rules after previewing them with lbb_query mode=infer.",
|
|
835
|
+
inference: "Rule body/head terms are { var } or { entity: { entity_type, name } } — a fixed entity lets a rule match or derive a constant value (e.g. a status). A not_exists combinator adds stratified negation for universal conditions. Roll-up example: rule 1 head { var: phase } HAS_INCOMPLETE_DELIVERABLE { var: d }, body phase HAS_DELIVERABLE d, not_exists [ d HAS_DELIVERY_STATUS { entity: DeliveryStatus/Complete } ]; rule 2 head phase HAS_ROLLUP_STATUS { entity: DeliveryStatus/Complete }, body phase HAS_DELIVERABLE any, not_exists [ phase HAS_INCOMPLETE_DELIVERABLE x ] — derives complete only when every deliverable is complete.",
|
|
836
|
+
},
|
|
837
|
+
possibilities: buildPossibilities(relations, entityTypes),
|
|
838
|
+
how_to: "Ground with lbb_inspect action=guide, retrieve with lbb_search, rate useful/partial/bad retrieval results with lbb_commit mode=search_feedback when you have a judgment, inspect exact entities/schema/rules with lbb_inspect, preview analysis or inference with lbb_query, then write graph facts with lbb_commit or configuration with lbb_configure only when intended.",
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
export async function analyze(scopedClient, p) {
|
|
842
|
+
const metric = requireString(p.metric, "metric");
|
|
843
|
+
const chart = p.chart === "pie" ? "pie" : "bar";
|
|
844
|
+
let title;
|
|
845
|
+
let categoryTitle;
|
|
846
|
+
let valueTitle = "count";
|
|
847
|
+
let points;
|
|
848
|
+
if (metric === "entity_types" ||
|
|
849
|
+
metric === "relations" ||
|
|
850
|
+
metric === "overview") {
|
|
851
|
+
const s = (await scopedClient.summary());
|
|
852
|
+
if (metric === "entity_types") {
|
|
853
|
+
title = "Entities by type";
|
|
854
|
+
categoryTitle = "entity type";
|
|
855
|
+
points = (s.entity_types ?? []).map((r) => ({
|
|
856
|
+
label: r.name,
|
|
857
|
+
value: r.count,
|
|
858
|
+
}));
|
|
859
|
+
}
|
|
860
|
+
else if (metric === "relations") {
|
|
861
|
+
title = "Edges by relation";
|
|
862
|
+
categoryTitle = "relation";
|
|
863
|
+
points = (s.relations ?? []).map((r) => ({
|
|
864
|
+
label: r.name,
|
|
865
|
+
value: r.count,
|
|
866
|
+
}));
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
title = "Graph overview";
|
|
870
|
+
categoryTitle = "metric";
|
|
871
|
+
points = [
|
|
872
|
+
{ label: "entities", value: s.entity_count ?? 0 },
|
|
873
|
+
{ label: "current edges", value: s.current_edge_count ?? 0 },
|
|
874
|
+
{ label: "observations", value: s.observation_count ?? 0 },
|
|
875
|
+
{ label: "edge events", value: s.edge_event_count ?? 0 },
|
|
876
|
+
];
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
else if (metric === "facets") {
|
|
880
|
+
const field = requireString(p.field, "field");
|
|
881
|
+
const res = (await scopedClient.graphSearch({
|
|
882
|
+
query: p.query ?? "",
|
|
883
|
+
targets: ["entities", "assertions", "observations"],
|
|
884
|
+
search: {
|
|
885
|
+
lexical: true,
|
|
886
|
+
bm25: true,
|
|
887
|
+
vector: true,
|
|
888
|
+
bm25_source: "persisted",
|
|
889
|
+
vector_source: "persisted",
|
|
890
|
+
consistency: "strong",
|
|
891
|
+
},
|
|
892
|
+
facets: [{ field }],
|
|
893
|
+
max_hops: 1,
|
|
894
|
+
top_k: 50,
|
|
895
|
+
explain: false,
|
|
896
|
+
}));
|
|
897
|
+
const facet = (res.facets ?? []).find((f) => f.field === field) ??
|
|
898
|
+
(res.facets ?? [])[0];
|
|
899
|
+
title = `${p.query ? `"${p.query}"` : "All"} by ${field}`;
|
|
900
|
+
categoryTitle = field;
|
|
901
|
+
points = (facet?.buckets ?? []).map((b) => ({
|
|
902
|
+
label: b.value,
|
|
903
|
+
value: b.count,
|
|
904
|
+
}));
|
|
905
|
+
}
|
|
906
|
+
else if (metric === "sparql") {
|
|
907
|
+
const body = requireObject(p.sparql, "sparql");
|
|
908
|
+
const res = (await scopedClient.sparql(body));
|
|
909
|
+
const groups = res.groups ?? [];
|
|
910
|
+
const first = groups[0];
|
|
911
|
+
// Prefer an entity-identity key; otherwise a typed scalar key — property and
|
|
912
|
+
// date-bucket grouping return the key under value_keys, not keys.
|
|
913
|
+
const entityKeyVar = Object.keys(first?.keys ?? {})[0];
|
|
914
|
+
const scalarKeyVar = entityKeyVar
|
|
915
|
+
? undefined
|
|
916
|
+
: Object.keys(first?.value_keys ?? {})[0];
|
|
917
|
+
const keyVar = entityKeyVar ?? scalarKeyVar;
|
|
918
|
+
const aggVar = Object.keys(first?.aggregates ?? {})[0];
|
|
919
|
+
title = aggVar ? `${aggVar} by ${keyVar ?? "group"}` : "Aggregation";
|
|
920
|
+
categoryTitle = keyVar ?? "group";
|
|
921
|
+
valueTitle = aggVar ?? "value";
|
|
922
|
+
points = groups.map((g) => ({
|
|
923
|
+
label: entityKeyVar
|
|
924
|
+
? entityLabel(g.keys[entityKeyVar])
|
|
925
|
+
: scalarKeyVar
|
|
926
|
+
? sparqlKeyLabel(g.value_keys?.[scalarKeyVar])
|
|
927
|
+
: "(all)",
|
|
928
|
+
value: aggVar ? sparqlScalarNumber(g.aggregates[aggVar]) : 0,
|
|
929
|
+
}));
|
|
930
|
+
}
|
|
931
|
+
else {
|
|
932
|
+
throw new Error("metric must be one of entity_types, relations, overview, facets, sparql");
|
|
933
|
+
}
|
|
934
|
+
points.sort((a, b) => b.value - a.value);
|
|
935
|
+
if (typeof p.top_k === "number" && p.top_k > 0)
|
|
936
|
+
points = points.slice(0, p.top_k);
|
|
937
|
+
const total = points.reduce((sum, point) => sum + point.value, 0);
|
|
938
|
+
const summary = points.length
|
|
939
|
+
? `${title}: ${points.length} categories, total ${total.toLocaleString()}. Top: ${points
|
|
940
|
+
.slice(0, 3)
|
|
941
|
+
.map((point) => `${point.label} (${point.value.toLocaleString()})`)
|
|
942
|
+
.join(", ")}.`
|
|
943
|
+
: `${title}: no data.`;
|
|
944
|
+
return {
|
|
945
|
+
title,
|
|
946
|
+
metric,
|
|
947
|
+
summary,
|
|
948
|
+
data: points,
|
|
949
|
+
chart: vegaChart(chart, title, points, categoryTitle, valueTitle),
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
export function ontologyDefineBody(p) {
|
|
953
|
+
if (p.source !== undefined) {
|
|
954
|
+
return {
|
|
955
|
+
source: p.source,
|
|
956
|
+
format: p.format ?? "auto",
|
|
957
|
+
merge_default: p.merge_default ?? false,
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
if (!p.entity_types?.length || !p.relations?.length) {
|
|
961
|
+
throw new Error("provide both entity_types and relations, or a raw `source` document");
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
source: JSON.stringify({
|
|
965
|
+
entity_types: p.entity_types,
|
|
966
|
+
relation_types: p.relations,
|
|
967
|
+
}),
|
|
968
|
+
format: "spec",
|
|
969
|
+
merge_default: p.merge_default ?? false,
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
export function schemaSourceBody(source) {
|
|
973
|
+
if (source === undefined)
|
|
974
|
+
return undefined;
|
|
975
|
+
return {
|
|
976
|
+
source: source.source,
|
|
977
|
+
format: source.format ?? "auto",
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
export function schemaPreviewBody(p) {
|
|
981
|
+
const ontology = schemaSourceBody(p.ontology);
|
|
982
|
+
const shapes = schemaSourceBody(p.shapes);
|
|
983
|
+
if (ontology === undefined && shapes === undefined) {
|
|
984
|
+
throw new Error("schema_preview requires an ontology or shapes source");
|
|
985
|
+
}
|
|
986
|
+
return {
|
|
987
|
+
ontology,
|
|
988
|
+
shapes,
|
|
989
|
+
base_ontology_version: p.base_ontology_version ?? null,
|
|
990
|
+
base_shapes_version: p.base_shapes_version ?? null,
|
|
991
|
+
desired_mode: p.desired_mode ?? "warn",
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
export function schemaPublishBody(p) {
|
|
995
|
+
return {
|
|
996
|
+
preview_digest: p.preview_digest,
|
|
997
|
+
ontology: schemaSourceBody(p.ontology),
|
|
998
|
+
shapes: schemaSourceBody(p.shapes),
|
|
999
|
+
desired_mode: p.desired_mode,
|
|
1000
|
+
confirm_restrictive: p.confirm_restrictive ?? false,
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
export function choosePublishMode(response, requested) {
|
|
1004
|
+
const modes = Array.isArray(response.publish_mode_allowed)
|
|
1005
|
+
? response.publish_mode_allowed
|
|
1006
|
+
: [];
|
|
1007
|
+
if (modes.includes(requested))
|
|
1008
|
+
return requested === "reject" ? "reject" : "warn";
|
|
1009
|
+
if (modes.includes("warn"))
|
|
1010
|
+
return "warn";
|
|
1011
|
+
if (modes.includes("reject"))
|
|
1012
|
+
return "reject";
|
|
1013
|
+
return undefined;
|
|
1014
|
+
}
|
|
1015
|
+
export function auditSummary(audit) {
|
|
1016
|
+
if (!audit || typeof audit !== "object")
|
|
1017
|
+
return undefined;
|
|
1018
|
+
const a = audit;
|
|
1019
|
+
return {
|
|
1020
|
+
conforms: a.conforms,
|
|
1021
|
+
result_count: a.result_count,
|
|
1022
|
+
messages: a.messages,
|
|
1023
|
+
sample_results: Array.isArray(a.results)
|
|
1024
|
+
? a.results.slice(0, 5)
|
|
1025
|
+
: undefined,
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
export async function schemaPreview(target, p) {
|
|
1029
|
+
const body = schemaPreviewBody(p);
|
|
1030
|
+
const response = (await target.schema.preview(body));
|
|
1031
|
+
const desiredMode = typeof response.desired_mode === "string"
|
|
1032
|
+
? response.desired_mode
|
|
1033
|
+
: String(body.desired_mode);
|
|
1034
|
+
const publishMode = choosePublishMode(response, desiredMode);
|
|
1035
|
+
const suggestedPublish = publishMode && body.shapes
|
|
1036
|
+
? {
|
|
1037
|
+
tool: "lbb_configure",
|
|
1038
|
+
args: {
|
|
1039
|
+
action: "publish_schema",
|
|
1040
|
+
graph: p.graph,
|
|
1041
|
+
branch: p.branch,
|
|
1042
|
+
preview_digest: response.preview_digest,
|
|
1043
|
+
desired_mode: publishMode,
|
|
1044
|
+
confirm_restrictive: response.verdict === "restrictive" && publishMode === "warn"
|
|
1045
|
+
? true
|
|
1046
|
+
: undefined,
|
|
1047
|
+
ontology: body.ontology,
|
|
1048
|
+
shapes: body.shapes,
|
|
1049
|
+
},
|
|
1050
|
+
}
|
|
1051
|
+
: undefined;
|
|
1052
|
+
return {
|
|
1053
|
+
graph: response.graph,
|
|
1054
|
+
verdict: response.verdict,
|
|
1055
|
+
can_publish: response.can_publish,
|
|
1056
|
+
publish_mode_allowed: response.publish_mode_allowed,
|
|
1057
|
+
preview_digest: response.preview_digest,
|
|
1058
|
+
desired_mode: response.desired_mode,
|
|
1059
|
+
proposed_ontology_version: response.proposed_ontology_version,
|
|
1060
|
+
proposed_shapes_version: response.proposed_shapes_version,
|
|
1061
|
+
diff: response.diff,
|
|
1062
|
+
audit: auditSummary(response.audit),
|
|
1063
|
+
messages: response.messages,
|
|
1064
|
+
suggested_publish_schema: suggestedPublish,
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Register the hard-break v2 Little Big Brain tool belt on an MCP server. The surface is
|
|
1069
|
+
* task-oriented for agents; each tool dispatches to the existing @littlebigbrain/client
|
|
1070
|
+
* routes without adding new HTTP or SDK APIs.
|
|
1071
|
+
*/
|