@littlebigbrain/mcp 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -114,23 +114,29 @@ An agent without a SPARQL query can pass the question in plain words:
114
114
  }
115
115
  ```
116
116
 
117
- The server selects the kind of query, writes the SPARQL query from a
118
- description of the graph, checks it, and runs it. The result holds the route,
119
- the rationale, the query, and its rows. `next` continues the same query with
120
- `mode: "sparql"`. Each question uses model tokens and counts toward a daily
121
- limit of the stack, so the tool does not retry a failed call. The server finds
122
- the names in the question in the graph and lists them in `linked`. When the
123
- question is about records you already know, pass their IRIs in `anchor` (at
124
- most 10): the query uses them directly. A question about a date reads the last
125
- commit written by the end of that day; pass `timeline` (`[{date,
126
- as_of_commit_seq, label?}]`) when the commits stand for other dates. A
127
- question that asks what changed returns `history.added` and
128
- `history.removed`, the rows that differ.
129
-
130
- A question can take some seconds. When the tool call carries a
131
- `_meta.progressToken`, the server sends a progress notification for each
132
- step, for example `Route: lookup (router, 0.92)` or `12 rows in 85 ms`. The
133
- result is the same. Cancel the call to stop the server's work.
117
+ The server answers in plain words (`POST /v1/query/ask`). A router model picks
118
+ the kind of question. A reasoning model runs queries in a bounded loop, reads
119
+ their rows, and answers. The result holds `answer` (`text`, `citations`, the
120
+ IRIs the answer names, and `chart`, how to draw the rows, when the server
121
+ gave one), `steps`, the route, and the query and rows the answer stands on. `next` continues that query with `mode: "sparql"`. A
122
+ question takes about 8 s. Each question uses model tokens and counts toward a
123
+ daily limit of the stack, so the tool does not retry a failed call.
124
+
125
+ The server finds the names in the question in the graph and lists them in
126
+ `linked`. When the question is about records you already know, pass their
127
+ IRIs in `anchor` (at most 10): the queries use them directly. A question
128
+ about a date reads the last commit written by the end of that day; pass
129
+ `timeline` (`[{date, as_of_commit_seq, label?}]`) when the commits stand for
130
+ other dates. A question that asks what changed returns `history.added`,
131
+ `history.removed` and `history.changed`, with `history.totals`.
132
+
133
+ When the tool call carries a `_meta.progressToken`, the server sends a
134
+ progress notification for each step, for example `Route: lookup (router,
135
+ 0.92)` or `Step 2, sparql SELECT ?db: 1 row`. The result is the same. Cancel
136
+ the call to stop the server's work.
137
+
138
+ To write the queries yourself, use `names`, `describe`, `commit_at` and
139
+ `compare`, then `mode: "sparql"`.
134
140
 
135
141
  For your own data, start with `lbb_inspect` using `action: "guide"` or
136
142
  `action: "ontology"`. Imported Resource Description Framework (RDF) data keeps
@@ -141,14 +147,19 @@ its original identifiers; inspect it with SPARQL when choosing query predicates.
141
147
  | Tool | Purpose |
142
148
  | --- | --- |
143
149
  | `lbb_inspect` | Read the schema, graph status, and entities. |
144
- | `lbb_query` | Ask a question in plain words, run SPARQL queries, search by meaning (also inside a SPARQL query with `search:similarTo`), or request summary statistics. |
150
+ | `lbb_query` | Ask a question in plain words, run SPARQL queries, search by meaning (also inside a SPARQL query with `search:similarTo`), or request summary statistics. Four modes help an agent write its own queries: `names` (the IRIs of the names in a text), `describe` (the classes and properties a question needs, with how many instances hold each property and the values of small classes), `commit_at` (the commit of a date) and `compare` (one query at two points, the rows paired by entity). |
145
151
  | `lbb_rdf` | Import RDF documents or add facts with SPARQL `INSERT DATA`. |
146
152
  | `lbb_embeddings` | Inspect search setup and preview the text to embed. |
147
153
  | `lbb_embeddings_manage` | Set up or refresh embeddings, or change the embedding model. |
148
154
  | `lbb_embeddings_delete` | Delete an embedding and its stored vectors. |
155
+ | `lbb_fit_sources` | Inspect fit from text and preview the text the fit reads. |
156
+ | `lbb_fit_sources_manage` | Declare, dry-run or refresh a fit source. |
157
+ | `lbb_fit_sources_delete` | Delete a fit source; its suggestions stay. |
149
158
  | `lbb_commit` | Write or retract JSON facts, or record search feedback. |
150
159
  | `lbb_configure` | Define record types and relationships, list and apply ontology starters (CRM, documents, work), publish validation rules, or keep the graph's notes and worked examples for questions in plain words (`get_rewrite_profile`, `set_rewrite_profile`). |
151
160
  | `lbb_evals` | Label query results and check whether later queries return the expected answers. Read the model checks of a graph, and agree with or correct a check that the user confirmed. |
161
+ | `lbb_model_choice` | Read which model each use runs (question answers, query routing, search rerank, ontology fit, eval labels), and the trials that test other models against the graph's ground truth. |
162
+ | `lbb_model_choice_manage` | Start or stop a trial, switch a use to a tested model that meets the bar, or revert to LBB's model. |
152
163
  | `lbb_models` | Compare retrieval settings, read model training datasets, or read a month of managed-model activity. |
153
164
 
154
165
  See [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning/)
@@ -0,0 +1,89 @@
1
+ import type { LbbClient } from "@littlebigbrain/client";
2
+ import { type Detail } from "./tool-contracts.js";
3
+ /** One value of a row, as SPARQL 1.1 Query Results JSON writes it. */
4
+ export interface Term {
5
+ type: string;
6
+ value: string;
7
+ datatype?: string | null;
8
+ "xml:lang"?: string | null;
9
+ }
10
+ /** A row as `{ variable: lexical value }`. */
11
+ export declare function lexicalRow(row: Record<string, Term>): Record<string, string>;
12
+ /** Entries of each list a page shows, by detail. */
13
+ export declare function listLimit(detail: Detail): number;
14
+ export interface NamesArgs {
15
+ text: string;
16
+ limit?: number;
17
+ detail?: string;
18
+ graph?: string;
19
+ }
20
+ /** `lbb_query mode=names`: the entities the text names, per name. */
21
+ export declare function findNames(client: LbbClient, args: NamesArgs): Promise<{
22
+ content: {
23
+ type: "text";
24
+ text: string;
25
+ }[];
26
+ structuredContent: Record<string, unknown>;
27
+ }>;
28
+ export interface DescribeArgs {
29
+ question?: string;
30
+ classes?: string[];
31
+ properties?: string[];
32
+ detail?: string;
33
+ graph?: string;
34
+ }
35
+ /**
36
+ * `lbb_query mode=describe`: the parts of the graph a question needs. The
37
+ * compact and standard answers are the server's text; full adds the same
38
+ * description as JSON.
39
+ */
40
+ export declare function describeGraph(client: LbbClient, args: DescribeArgs): Promise<{
41
+ content: {
42
+ type: "text";
43
+ text: string;
44
+ }[];
45
+ structuredContent: Record<string, unknown>;
46
+ }>;
47
+ export interface CommitAtArgs {
48
+ date?: string;
49
+ moment?: string;
50
+ graph?: string;
51
+ }
52
+ /** `lbb_query mode=commit_at`: the commit of a date or a moment. */
53
+ export declare function commitAt(client: LbbClient, args: CommitAtArgs): Promise<{
54
+ content: {
55
+ type: "text";
56
+ text: string;
57
+ }[];
58
+ structuredContent: Record<string, unknown>;
59
+ }>;
60
+ interface ComparePoint {
61
+ as_of_commit_seq?: number;
62
+ date?: string;
63
+ moment?: string;
64
+ }
65
+ export interface CompareArgs {
66
+ query: string;
67
+ before: ComparePoint;
68
+ after?: ComparePoint;
69
+ key?: string[];
70
+ entailment?: "none" | "subclass" | "rdfs" | "owl";
71
+ max_rows?: number;
72
+ limit?: number;
73
+ compare_cursor?: string;
74
+ detail?: string;
75
+ graph?: string;
76
+ }
77
+ /**
78
+ * `lbb_query mode=compare`: one SELECT at two points, the rows paired by
79
+ * `key`. Each list shows one page with its total; `next` continues with the
80
+ * server's cursor.
81
+ */
82
+ export declare function compareQuery(client: LbbClient, args: CompareArgs): Promise<{
83
+ content: {
84
+ type: "text";
85
+ text: string;
86
+ }[];
87
+ structuredContent: Record<string, unknown>;
88
+ }>;
89
+ export {};
@@ -0,0 +1,208 @@
1
+ import { HARD_OUTPUT_CHARS } from "./tool-contracts.js";
2
+ import { compactLimits, normalizeDetail, scoped, toolResult, truncateValue, } from "./tool-runtime.js";
3
+ /** A row as `{ variable: lexical value }`. */
4
+ export function lexicalRow(row) {
5
+ return Object.fromEntries(Object.entries(row).map(([name, term]) => [name, term.value]));
6
+ }
7
+ /** Entries of each list a page shows, by detail. */
8
+ export function listLimit(detail) {
9
+ switch (detail) {
10
+ case "full":
11
+ return 500;
12
+ case "standard":
13
+ return 100;
14
+ default:
15
+ return 20;
16
+ }
17
+ }
18
+ /** Long strings cut by detail; every entry stays, so no cursor skips one. */
19
+ function boundStrings(value, detail) {
20
+ return truncateValue(value, {
21
+ maxItems: Number.MAX_SAFE_INTEGER,
22
+ maxString: compactLimits(detail).maxString,
23
+ }, { truncated: false });
24
+ }
25
+ /** A tool result that must fit the MCP output budget whole. */
26
+ function fitted(value, narrow) {
27
+ const text = JSON.stringify(value, null, 2);
28
+ if (Buffer.byteLength(text, "utf8") > HARD_OUTPUT_CHARS) {
29
+ throw new Error(`The answer exceeds the MCP output budget. ${narrow}`);
30
+ }
31
+ return toolResult(value);
32
+ }
33
+ /** `lbb_query mode=names`: the entities the text names, per name. */
34
+ export async function findNames(client, args) {
35
+ const target = scoped(client, args.graph);
36
+ const response = await target.request("POST", "/v1/query/names", {
37
+ body: {
38
+ text: args.text,
39
+ ...(args.limit !== undefined ? { limit: args.limit } : {}),
40
+ },
41
+ retry: true,
42
+ });
43
+ const names = [];
44
+ for (const { text, ...candidate } of response.candidates) {
45
+ const last = names[names.length - 1];
46
+ if (last && last.text === text)
47
+ last.candidates.push(candidate);
48
+ else
49
+ names.push({ text, candidates: [candidate] });
50
+ }
51
+ const notes = [];
52
+ if (!response.index_ready) {
53
+ notes.push("The graph's name index is still building: ask again in a few seconds.");
54
+ }
55
+ else if (names.length === 0) {
56
+ notes.push('No name of the text matched an entity. Match the name in SPARQL instead, without case and on a short distinctive part, for example FILTER(CONTAINS(LCASE(STR(?label)), "quellm")).');
57
+ }
58
+ else {
59
+ notes.push("Use the first candidate of each name unless its class does not fit the question; put its IRI in the query (as a subject, an object or in VALUES) instead of matching the name.");
60
+ }
61
+ const count = response.candidates.length;
62
+ return fitted({
63
+ summary: `lbb_query.names: ${names.length} ${names.length === 1 ? "name" : "names"}, ${count} ${count === 1 ? "candidate" : "candidates"}`,
64
+ data: {
65
+ names,
66
+ index_ready: response.index_ready,
67
+ commit_seq: response.commit_seq,
68
+ },
69
+ notes,
70
+ }, "Pass a shorter text.");
71
+ }
72
+ /**
73
+ * `lbb_query mode=describe`: the parts of the graph a question needs. The
74
+ * compact and standard answers are the server's text; full adds the same
75
+ * description as JSON.
76
+ */
77
+ export async function describeGraph(client, args) {
78
+ const detail = normalizeDetail(args.detail);
79
+ const target = scoped(client, args.graph);
80
+ const response = await target.request("POST", "/v1/query/describe", {
81
+ body: {
82
+ ...(args.question !== undefined ? { question: args.question } : {}),
83
+ ...(args.classes?.length ? { classes: args.classes } : {}),
84
+ ...(args.properties?.length ? { properties: args.properties } : {}),
85
+ },
86
+ retry: true,
87
+ });
88
+ const notes = [];
89
+ if (response.partial) {
90
+ notes.push("The server is still sampling the graph: coverage and the values of small classes can be missing. Ask again in a minute for the whole description.");
91
+ }
92
+ if (response.unknown?.length) {
93
+ notes.push(`The graph has no class or property ${response.unknown.join(", ")}.`);
94
+ }
95
+ notes.push("A property that few sampled instances hold (n of m) is rare: a filter on it returns few rows. Use the listed values of small classes as they are written.");
96
+ const data = {
97
+ commit_seq: response.commit_seq,
98
+ partial: response.partial,
99
+ text: response.text,
100
+ ...(response.unknown?.length ? { unknown: response.unknown } : {}),
101
+ };
102
+ if (detail === "full") {
103
+ data.classes = response.classes;
104
+ data.properties = response.properties;
105
+ data.statements = response.statements;
106
+ data.prefixes = response.prefixes;
107
+ }
108
+ return fitted({
109
+ summary: `lbb_query.describe: ${response.classes.length} classes, ${response.properties.length} properties`,
110
+ data,
111
+ notes,
112
+ }, "Name fewer classes, or use detail=standard.");
113
+ }
114
+ /** `lbb_query mode=commit_at`: the commit of a date or a moment. */
115
+ export async function commitAt(client, args) {
116
+ if ((args.date === undefined) === (args.moment === undefined)) {
117
+ throw new Error("commit_at needs exactly one of date and moment");
118
+ }
119
+ const target = scoped(client, args.graph);
120
+ const response = await target.request("GET", "/v1/graph/commit-at", {
121
+ query: { date: args.date, moment: args.moment },
122
+ retry: true,
123
+ });
124
+ const notes = [];
125
+ if (typeof response.as_of_commit_seq === "number") {
126
+ notes.push(`Read the graph as it was then with lbb_query mode=sparql as_of_commit_seq=${response.as_of_commit_seq}, or compare two points with lbb_query mode=compare.`);
127
+ }
128
+ if (response.note)
129
+ notes.push(response.note);
130
+ return toolResult({
131
+ summary: typeof response.as_of_commit_seq === "number"
132
+ ? `lbb_query.commit_at: commit ${response.as_of_commit_seq}`
133
+ : "lbb_query.commit_at: no commit",
134
+ data: response,
135
+ notes,
136
+ });
137
+ }
138
+ /**
139
+ * `lbb_query mode=compare`: one SELECT at two points, the rows paired by
140
+ * `key`. Each list shows one page with its total; `next` continues with the
141
+ * server's cursor.
142
+ */
143
+ export async function compareQuery(client, args) {
144
+ const detail = normalizeDetail(args.detail);
145
+ const target = scoped(client, args.graph);
146
+ const limit = args.limit ?? listLimit(detail);
147
+ const response = await target.request("POST", "/v1/query/compare", {
148
+ body: {
149
+ query: args.query,
150
+ before: args.before,
151
+ ...(args.after ? { after: args.after } : {}),
152
+ ...(args.key?.length ? { key: args.key } : {}),
153
+ ...(args.entailment ? { entailment: args.entailment } : {}),
154
+ ...(args.max_rows !== undefined ? { max_rows: args.max_rows } : {}),
155
+ limit,
156
+ ...(args.compare_cursor ? { cursor: args.compare_cursor } : {}),
157
+ },
158
+ retry: true,
159
+ query: { consistency: target.defaultConsistency },
160
+ });
161
+ const notes = [...(response.notes ?? [])];
162
+ const totals = response.totals;
163
+ const shown = {
164
+ added: response.added.length,
165
+ removed: response.removed.length,
166
+ changed: response.changed.length,
167
+ };
168
+ const cut = Object.keys(shown).filter((name) => response.offset + shown[name] < totals[name]);
169
+ if (cut.length) {
170
+ notes.push(`This page shows entries ${response.offset + 1} to ${response.offset + limit} of each list (${cut.map((name) => `${name}: ${totals[name]}`).join(", ")}). Call again with the arguments in next for the next page.`);
171
+ }
172
+ const data = boundStrings({
173
+ before: response.before,
174
+ after: response.after,
175
+ key: response.key ?? [],
176
+ totals,
177
+ added: response.added.map(lexicalRow),
178
+ removed: response.removed.map(lexicalRow),
179
+ changed: response.changed.map((change) => ({
180
+ key: lexicalRow(change.key),
181
+ before: change.before.map(lexicalRow),
182
+ after: change.after.map(lexicalRow),
183
+ })),
184
+ offset: response.offset,
185
+ ...(response.truncated ? { truncated: true } : {}),
186
+ }, detail);
187
+ const next = response.next_cursor
188
+ ? {
189
+ mode: "compare",
190
+ query: args.query,
191
+ before: args.before,
192
+ ...(args.after ? { after: args.after } : {}),
193
+ ...(args.key?.length ? { key: args.key } : {}),
194
+ ...(args.entailment ? { entailment: args.entailment } : {}),
195
+ ...(args.max_rows !== undefined ? { max_rows: args.max_rows } : {}),
196
+ limit,
197
+ compare_cursor: response.next_cursor,
198
+ ...(args.detail ? { detail: args.detail } : {}),
199
+ ...(args.graph ? { graph: args.graph } : {}),
200
+ }
201
+ : undefined;
202
+ return fitted({
203
+ summary: `lbb_query.compare: added ${totals.added}, removed ${totals.removed}, changed ${totals.changed}${response.truncated ? " [not complete: a point was not read whole]" : ""}`,
204
+ data,
205
+ ...(next ? { next } : {}),
206
+ notes,
207
+ }, "Pass a smaller limit, or project fewer variables.");
208
+ }
@@ -11,7 +11,6 @@ export interface QuestionArgs {
11
11
  context?: string;
12
12
  route?: QuestionRoute;
13
13
  limit?: number;
14
- run?: boolean;
15
14
  anchor?: string[];
16
15
  timeline?: TimelinePoint[];
17
16
  detail?: string;
@@ -26,7 +25,7 @@ export interface QuestionProgress {
26
25
  signal?: AbortSignal;
27
26
  report(progress: number, message: string): Promise<void>;
28
27
  }
29
- /** One event of a streamed rewrite, as this tool reads it. */
28
+ /** One event of a streamed question, as this tool reads it. */
30
29
  type StreamEvent = {
31
30
  event: string;
32
31
  data: unknown;
@@ -34,11 +33,14 @@ type StreamEvent = {
34
33
  /** The progress message of a stream event, in short plain words. */
35
34
  export declare function progressMessage(event: StreamEvent): string | undefined;
36
35
  /**
37
- * `lbb_query mode=question`: the server turns the question into a SPARQL
38
- * query and, by default, runs it. The rows are bounded as `mode=sparql`
39
- * bounds them, and `next` continues the same query with `mode=sparql`.
40
- * With `progress`, the server streams its steps and each step is reported;
41
- * the result is the same.
36
+ * `lbb_query mode=question`: the server answers the question in plain words
37
+ * (`POST /v1/query/ask`). Its model runs queries in a bounded loop and
38
+ * reads their rows. The result holds the answer, its citations and chart
39
+ * hint, the steps, and the rows of the query the answer stands on, bounded
40
+ * as `mode=sparql`
41
+ * bounds them; `next` continues that query with `mode=sparql`. With
42
+ * `progress`, the server streams its steps and each step is reported; the
43
+ * result is the same.
42
44
  */
43
45
  export declare function answerQuestion(client: LbbClient, args: QuestionArgs, textFormat?: "compact" | "pretty", progress?: QuestionProgress): Promise<{
44
46
  content: {
package/dist/question.js CHANGED
@@ -1,6 +1,6 @@
1
+ import { lexicalRow, listLimit } from "./query-tools.js";
1
2
  import { QUESTION_MAX_ROWS } from "./tool-contracts.js";
2
3
  import { defaultRowLimit, normalizeDetail, queryEnvelope, queryToolResult, rowPageFrom, rowPageNext, scoped, } from "./tool-runtime.js";
3
- const REPAIR_ERROR_CHARS = 200;
4
4
  /** The progress message of a stream event, in short plain words. */
5
5
  export function progressMessage(event) {
6
6
  const data = (event.data ?? {});
@@ -13,41 +13,35 @@ export function progressMessage(event) {
13
13
  : "";
14
14
  return `Route: ${String(data.kind)} (${String(data.by)}${confidence})`;
15
15
  }
16
- case "query":
17
- return `Wrote query ${String(data.attempt ?? 1)}`;
18
- case "run":
19
- return data.point === "before"
20
- ? "Running the query at the earlier point"
21
- : data.point === "after"
22
- ? "Running the query at the later point"
23
- : "Running the query";
24
- case "rows": {
25
- const count = Number(data.count);
26
- return `${count} ${count === 1 ? "row" : "rows"} in ${String(data.ms)} ms`;
27
- }
28
- case "repair": {
29
- const error = String(data.error ?? "");
30
- const shown = error.length > REPAIR_ERROR_CHARS
31
- ? `${error.slice(0, REPAIR_ERROR_CHARS - 1)}…`
32
- : error;
33
- return `Correcting the query: ${shown}`;
16
+ case "step": {
17
+ // A compare step counts the entries that differ, not rows.
18
+ const unit = data.tool === "compare" ? "difference" : "row";
19
+ const rows = typeof data.rows === "number"
20
+ ? `: ${data.rows} ${unit}${data.rows === 1 ? "" : "s"}`
21
+ : "";
22
+ const outcome = data.ok === false ? ": failed" : rows;
23
+ return `Step ${String(data.n)}, ${String(data.tool)} ${String(data.input ?? "")}${outcome}`;
34
24
  }
25
+ case "answer":
26
+ return "Answered";
35
27
  default:
36
28
  return undefined;
37
29
  }
38
30
  }
39
31
  /**
40
- * The rewrite as a stream of progress events. Each event becomes one
32
+ * The question as a stream of progress events. Each event becomes one
41
33
  * progress notification; the `done` event is the same response as the call
42
- * without a stream. A client release without `query.rewriteStream` answers
34
+ * without a stream. A client release without `query.askStream` answers
43
35
  * without progress.
44
36
  */
45
- async function streamedRewrite(target, body, progress) {
37
+ async function streamedAnswer(target, body, progress) {
46
38
  const query = target.query;
47
- if (typeof query.rewriteStream !== "function")
39
+ if (typeof query.askStream !== "function")
48
40
  return undefined;
41
+ const { question, ...options } = body;
49
42
  let step = 0;
50
- for await (const event of query.rewriteStream.call(target.query, body, {
43
+ for await (const event of query.askStream.call(target.query, question, {
44
+ ...options,
51
45
  signal: progress.signal,
52
46
  })) {
53
47
  if (event.event === "done")
@@ -59,30 +53,50 @@ async function streamedRewrite(target, body, progress) {
59
53
  // A lost notification never fails the question.
60
54
  await progress.report(step, message).catch(() => undefined);
61
55
  }
62
- throw new Error("the rewrite stream ended before its done event");
56
+ throw new Error("the answer stream ended before its done event");
63
57
  }
64
- /** A row of a comparison as `{ variable: lexical value }`. */
65
- function lexicalRow(row) {
66
- return Object.fromEntries(Object.entries(row).map(([name, term]) => [name, term.value]));
58
+ /** The totals of a comparison: the server's, else the lengths of its lists. */
59
+ function historyTotals(history) {
60
+ return (history.totals ?? {
61
+ added: history.added?.length ?? 0,
62
+ removed: history.removed?.length ?? 0,
63
+ changed: history.changed?.length ?? 0,
64
+ });
67
65
  }
68
66
  /**
69
67
  * The history the tool returns: where the question read the graph, and for
70
- * a comparison the rows that differ as lexical values. The two runs stay
71
- * out: the later one is the result, and the rows that differ say what the
72
- * earlier one held.
68
+ * a comparison the rows that differ as lexical values, with the total of
69
+ * each list. Each list shows at most `limit` entries; `shown` says how many
70
+ * when a list is cut.
73
71
  */
74
- function historyView(history) {
72
+ function historyView(history, limit) {
75
73
  const view = { ...history };
76
- delete view.before;
77
- delete view.after;
78
- if (history.added)
79
- view.added = history.added.map(lexicalRow);
80
- if (history.removed)
81
- view.removed = history.removed.map(lexicalRow);
74
+ if (!history.added && !history.removed && !history.changed)
75
+ return view;
76
+ const totals = historyTotals(history);
77
+ view.totals = totals;
78
+ const shown = {};
79
+ const show = (name, list, map) => {
80
+ if (!list)
81
+ return;
82
+ const kept = list.slice(0, limit);
83
+ view[name] = kept.map(map);
84
+ if (kept.length < (totals[name] ?? list.length))
85
+ shown[name] = kept.length;
86
+ };
87
+ show("added", history.added, lexicalRow);
88
+ show("removed", history.removed, lexicalRow);
89
+ show("changed", history.changed, (change) => ({
90
+ key: lexicalRow(change.key),
91
+ before: change.before.map(lexicalRow),
92
+ after: change.after.map(lexicalRow),
93
+ }));
94
+ if (Object.keys(shown).length)
95
+ view.shown = shown;
82
96
  return view;
83
97
  }
84
98
  /** What the agent should know about the history of the answer. */
85
- function historyNotes(history) {
99
+ function historyNotes(history, limit, query) {
86
100
  if (!history)
87
101
  return [];
88
102
  const notes = [];
@@ -100,36 +114,55 @@ function historyNotes(history) {
100
114
  else if (history.as_of_date) {
101
115
  notes.push(`The server could not place ${history.as_of_date} (see error). Pass timeline=[{date, as_of_commit_seq}] to name the commit of each date, or run the query with lbb_query mode=sparql as_of_commit_seq=<commit>.`);
102
116
  }
103
- if (history.compare && history.added && point) {
104
- notes.push(`A comparison: the rows are the later run; history.added and history.removed hold the rows that changed since ${point}.${history.truncated ? " The difference is not complete: a run returned only its first rows, or more than 500 rows changed." : ""}`);
117
+ if (history.compare && (history.added || history.changed) && point) {
118
+ const totals = historyTotals(history);
119
+ // The compared query reads the later point.
120
+ const later = typeof query?.as_of_commit_seq === "number"
121
+ ? query.as_of_commit_seq
122
+ : undefined;
123
+ const keyed = history.key?.length
124
+ ? `paired by ${history.key.map((v) => `?${v}`).join(", ")}: history.added and history.removed hold the entities at one point only, history.changed the entities whose values differ`
125
+ : "history.added and history.removed hold the rows that differ (a changed value is a removed row and an added row)";
126
+ notes.push(`A comparison from ${point} to ${later === undefined ? "the latest commit" : `commit ${later}`}; ${keyed}. Totals: added ${totals.added}, removed ${totals.removed}, changed ${totals.changed}.`);
127
+ const cut = ["added", "removed", "changed"].filter((name) => totals[name] > limit);
128
+ if (cut.length && query) {
129
+ const after = later === undefined ? "" : ` after={"as_of_commit_seq": ${later}}`;
130
+ const key = history.key?.length
131
+ ? ` key=${JSON.stringify(history.key)}`
132
+ : "";
133
+ notes.push(`Each list shows at most ${limit} entries (${cut.map((name) => `${name}: ${totals[name]}`).join(", ")}; see history.shown). Read them all, page by page, with lbb_query mode=compare query=<the query> before={"as_of_commit_seq": ${history.as_of_commit_seq}}${after}${key}.`);
134
+ }
135
+ if (history.truncated) {
136
+ notes.push("The difference is not complete: a point was not read whole, or a list held more than 500 entries on the server. Use lbb_query mode=compare for the whole difference.");
137
+ }
105
138
  }
106
139
  return notes;
107
140
  }
108
141
  /**
109
- * `lbb_query mode=question`: the server turns the question into a SPARQL
110
- * query and, by default, runs it. The rows are bounded as `mode=sparql`
111
- * bounds them, and `next` continues the same query with `mode=sparql`.
112
- * With `progress`, the server streams its steps and each step is reported;
113
- * the result is the same.
142
+ * `lbb_query mode=question`: the server answers the question in plain words
143
+ * (`POST /v1/query/ask`). Its model runs queries in a bounded loop and
144
+ * reads their rows. The result holds the answer, its citations and chart
145
+ * hint, the steps, and the rows of the query the answer stands on, bounded
146
+ * as `mode=sparql`
147
+ * bounds them; `next` continues that query with `mode=sparql`. With
148
+ * `progress`, the server streams its steps and each step is reported; the
149
+ * result is the same.
114
150
  */
115
151
  export async function answerQuestion(client, args, textFormat, progress) {
116
152
  const detail = normalizeDetail(args.detail);
117
- const run = args.run ?? true;
118
153
  const rowLimit = Math.min(args.limit ?? defaultRowLimit(detail), QUESTION_MAX_ROWS);
119
154
  const target = scoped(client, args.graph);
120
155
  const body = {
121
156
  question: args.question,
122
157
  ...(args.context !== undefined ? { context: args.context } : {}),
123
158
  ...(args.route !== undefined ? { route: args.route } : {}),
124
- run,
125
- ...(run ? { limit: rowLimit } : {}),
159
+ limit: rowLimit,
126
160
  ...(args.anchor?.length ? { anchor: args.anchor } : {}),
127
161
  ...(args.timeline?.length ? { timeline: args.timeline } : {}),
128
162
  };
129
- // Each call uses model tokens, and the server corrects a failing query
130
- // once, so a failed call is not retried.
131
- const response = (progress ? await streamedRewrite(target, body, progress) : undefined) ??
132
- (await target.request("POST", "/v1/query/rewrite", {
163
+ // Each call uses model tokens, so a failed call is not retried.
164
+ const response = (progress ? await streamedAnswer(target, body, progress) : undefined) ??
165
+ (await target.request("POST", "/v1/query/ask", {
133
166
  body,
134
167
  retry: false,
135
168
  query: { consistency: target.defaultConsistency },
@@ -137,6 +170,20 @@ export async function answerQuestion(client, args, textFormat, progress) {
137
170
  const { route, query, result } = response;
138
171
  const traceId = result?.trace_id ?? null;
139
172
  const answer = {
173
+ answer: response.answer
174
+ ? {
175
+ text: response.answer.text,
176
+ citations: response.answer.citations ?? [],
177
+ ...(response.answer.chart ? { chart: response.answer.chart } : {}),
178
+ }
179
+ : null,
180
+ steps: (response.steps ?? []).map((step) => ({
181
+ n: step.n,
182
+ tool: step.tool,
183
+ ok: step.ok,
184
+ ...(typeof step.rows === "number" ? { rows: step.rows } : {}),
185
+ ...(step.error ? { error: step.error } : {}),
186
+ })),
140
187
  route: { kind: route.kind, confidence: route.confidence, by: route.by },
141
188
  rationale: response.rationale,
142
189
  sparql: query?.sparql ?? null,
@@ -144,7 +191,9 @@ export async function answerQuestion(client, args, textFormat, progress) {
144
191
  ...(typeof query?.as_of_commit_seq === "number"
145
192
  ? { as_of_commit_seq: query.as_of_commit_seq }
146
193
  : {}),
147
- ...(response.history ? { history: historyView(response.history) } : {}),
194
+ ...(response.history
195
+ ? { history: historyView(response.history, listLimit(detail)) }
196
+ : {}),
148
197
  error: response.error ?? null,
149
198
  trace_id: traceId,
150
199
  attempts: response.attempts,
@@ -160,9 +209,9 @@ export async function answerQuestion(client, args, textFormat, progress) {
160
209
  if (traceId) {
161
210
  notes.push(`eval trace ${traceId}: to label rows, read their item ids with lbb_evals action=trace trace_id=${traceId}, then call lbb_evals action=label trace_id=${traceId} item=<id> valid=true|false (or items=[…]).`);
162
211
  }
163
- notes.push(...historyNotes(response.history));
164
- if (response.error && query) {
165
- notes.push("The last query failed (see error). Correct it and run it with lbb_query mode=sparql, or ask again with more context.");
212
+ notes.push(...historyNotes(response.history, listLimit(detail), query));
213
+ if (!response.answer) {
214
+ notes.push("The answer loop stopped before it answered (see error); the rows are the best ones it read. Continue with lbb_query mode=sparql, or ask again with more context.");
166
215
  }
167
216
  const label = `lbb_query.question: route ${route.kind} (${route.by}, confidence ${route.confidence.toFixed(2)})`;
168
217
  const data = result
@@ -186,13 +235,15 @@ export async function answerQuestion(client, args, textFormat, progress) {
186
235
  };
187
236
  return queryToolResult(queryEnvelope(label, { ...answer, ...data }, detail, rowPage, rowPageNext(cursorBase, rowPage), cursorBase, { textFormat, notes }), textFormat);
188
237
  }
189
- // No rows to page: the query only, an ASK answer, or no query at all. The
238
+ // No rows to page: an ASK answer, a comparison, or no query at all. The
190
239
  // query text is returned whole.
191
240
  const summary = data
192
241
  ? `${label}: ran the query`
193
- : query
194
- ? `${label}: ${response.error ? "the query failed" : "the query, not run"}`
195
- : `${label}: no query`;
242
+ : response.history?.compare
243
+ ? `${label}: compared two points`
244
+ : query
245
+ ? `${label}: no rows`
246
+ : `${label}: no query`;
196
247
  return queryToolResult({
197
248
  summary,
198
249
  data: { ...answer, ...(data ?? {}) },
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  export const DEFAULT_DETAIL = "compact";
3
3
  export const HARD_OUTPUT_CHARS = 80_000;
4
4
  export const MAX_QUERY_ROW_LIMIT = 5_000;
5
- /** The most rows `POST /v1/query/rewrite` returns from a run. */
5
+ /** The most rows each query of `POST /v1/query/ask` returns. */
6
6
  export const QUESTION_MAX_ROWS = 1_000;
7
7
  /** Entity IRIs one question may anchor, and the characters of one. */
8
8
  export const QUESTION_MAX_ANCHORS = 10;
@@ -405,6 +405,17 @@ export const SPARQL_IRI_GUIDE = 'IRI scheme: relations are <https://littlebigbra
405
405
  // Search by meaning runs inside SPARQL text as a pattern, so an agent can
406
406
  // filter and join the hits in one query instead of a search then a lookup.
407
407
  export const SPARQL_SEARCH_GUIDE = 'Search by meaning inside the query (the graph needs an embedding; see lbb_embeddings action=list): PREFIX search: <https://littlebigbrain.com/search#> SELECT ?x ?score WHERE { ?x search:similarTo "card payments" ; search:score ?score . ?x <https://littlebigbrain.com/r/calls> ?y } ORDER BY DESC(?score) LIMIT 5. The other patterns filter and join the hits. One search:similarTo per query, with a variable subject, in the main group (not inside OPTIONAL, UNION, MINUS or a subquery). The object is text, an entity IRI (records like that one) or a vector literal. search:top N sets the hits (1 to 1000; default the query LIMIT, then 10) and search:embedding "name" searches one embedding. FILTER(?score > 0.8) keeps close hits. The result\'s search field reports the plan (nearest, filter_first or search_first), the hits and complete. For a question in words, add search:rerank true ; search:relevance ?r and ORDER BY DESC(?r): the rerank model (Jev) reads the best hits with the text, keeps the top that answer it and binds its answer (0 to 1); search.rerank reports the status. Only a text query reranks.';
408
+ /** One point of `lbb_query mode=compare`: a commit, a date or a moment. */
409
+ const comparePointSchema = z
410
+ .object({
411
+ as_of_commit_seq: z.number().int().min(0).optional(),
412
+ date: z
413
+ .string()
414
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
415
+ .optional(),
416
+ moment: z.string().optional(),
417
+ })
418
+ .strict();
408
419
  export const queryInputSchema = z.discriminatedUnion("mode", [
409
420
  z
410
421
  .object({
@@ -447,7 +458,7 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
447
458
  query: z
448
459
  .string()
449
460
  .optional()
450
- .describe(`SPARQL 1.1 query text (SELECT or ASK). Valid-time as_of is unsupported; use as_of_commit_seq for a retained commit snapshot. ${SPARQL_IRI_GUIDE} Example: SELECT ?service ?db WHERE { ?service <https://littlebigbrain.com/r/writes_to> ?db } LIMIT 10. ${SPARQL_SEARCH_GUIDE}`),
461
+ .describe(`SPARQL 1.1 query text (SELECT or ASK). Valid-time as_of is unsupported; use as_of_commit_seq for a retained commit snapshot. ${SPARQL_IRI_GUIDE} Example: SELECT ?service ?db WHERE { ?service <https://littlebigbrain.com/r/writes_to> ?db } LIMIT 10. ${SPARQL_SEARCH_GUIDE} compare: the SELECT to run at both points; leave out LIMIT.`),
451
462
  // Kept for an actionable error when an older connector sends this field.
452
463
  as_of: z
453
464
  .string()
@@ -474,7 +485,7 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
474
485
  question: z
475
486
  .string()
476
487
  .min(1)
477
- .describe("question: the question in plain words (required), 1 to 4,000 characters. The server writes the SPARQL query from a description of this graph, checks it, and runs it."),
488
+ .describe("question: the question in plain words (required), 1 to 4,000 characters. The server's model runs queries on this graph, reads their rows, and answers. describe: the question whose classes and properties to describe."),
478
489
  context: z
479
490
  .string()
480
491
  .optional()
@@ -489,23 +500,19 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
489
500
  "unanswerable",
490
501
  ])
491
502
  .optional()
492
- .describe("question: the kind of query, when you know it. Omit it and the router model selects it."),
503
+ .describe("question: the kind of question, when you know it. Omit it and the router model picks it."),
493
504
  limit: z
494
505
  .number()
495
506
  .int()
496
507
  .min(1)
497
508
  .max(QUESTION_MAX_ROWS)
498
509
  .optional()
499
- .describe("question: rows the run returns, 1 to 1,000. Defaults by detail: compact=20, standard=100, full=1000."),
500
- run: z
501
- .boolean()
502
- .optional()
503
- .describe("question: run the query and return its rows. Defaults to true; false returns the query only."),
510
+ .describe("question: rows each query of the server's loop returns, 1 to 1,000. Defaults by detail: compact=20, standard=100, full=1000. names: candidates per name, 1 to 10 (default 5). compare: entries of each list per page, 1 to 1,000 (defaults by detail: 20, 100, 500)."),
504
511
  anchor: z
505
512
  .array(z.string().min(1).max(QUESTION_MAX_ANCHOR_CHARS))
506
513
  .max(QUESTION_MAX_ANCHORS)
507
514
  .optional()
508
- .describe("question: entity IRIs the question is about, at most 10 (for example the record the user has open). The server reads each one, and the query uses these IRIs directly instead of matching their names."),
515
+ .describe("question: entity IRIs the question is about, at most 10 (for example the record the user has open). The server reads each one, and its queries use these IRIs directly instead of matching their names."),
509
516
  timeline: z
510
517
  .array(z
511
518
  .object({
@@ -541,7 +548,7 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
541
548
  text: z
542
549
  .string()
543
550
  .optional()
544
- .describe("The query text (required); embedded with the graph's model."),
551
+ .describe("search: the query text (required); embedded with the graph's model. names: the question, or the names, to find (required)."),
545
552
  top_k: z.number().int().positive().max(200).optional(),
546
553
  probe: z
547
554
  .number()
@@ -605,6 +612,82 @@ export const queryInputSchema = z.discriminatedUnion("mode", [
605
612
  ...readScope,
606
613
  })
607
614
  .strict(),
615
+ z
616
+ .object({
617
+ mode: z.literal("names"),
618
+ text: z
619
+ .string()
620
+ .min(1)
621
+ .max(4_000)
622
+ .describe("names: the question, or the names, to find (required)."),
623
+ limit: z.number().int().min(1).max(10).optional(),
624
+ ...readScope,
625
+ })
626
+ .strict(),
627
+ z
628
+ .object({
629
+ mode: z.literal("describe"),
630
+ question: z.string().max(4_000).optional(),
631
+ classes: z
632
+ .array(z.string().min(1))
633
+ .max(20)
634
+ .optional()
635
+ .describe("describe: class IRIs to describe, at most 20."),
636
+ properties: z
637
+ .array(z.string().min(1))
638
+ .max(50)
639
+ .optional()
640
+ .describe("describe: property IRIs to describe, at most 50."),
641
+ ...readScope,
642
+ })
643
+ .strict(),
644
+ z
645
+ .object({
646
+ mode: z.literal("commit_at"),
647
+ date: z
648
+ .string()
649
+ .regex(/^\d{4}-\d{2}-\d{2}$/)
650
+ .optional()
651
+ .describe("commit_at: YYYY-MM-DD; the last commit written by the end of that day (UTC)."),
652
+ moment: z
653
+ .string()
654
+ .optional()
655
+ .describe("commit_at: RFC 3339, e.g. 2026-06-18T12:00:00Z; the last commit written at or before it."),
656
+ ...readScope,
657
+ })
658
+ .strict(),
659
+ z
660
+ .object({
661
+ mode: z.literal("compare"),
662
+ query: z
663
+ .string()
664
+ .min(1)
665
+ .describe("compare: the SELECT to run at both points. Leave out LIMIT: the server reads up to max_rows rows a point, page by page."),
666
+ before: comparePointSchema.describe('compare: the earlier point (required): {"as_of_commit_seq": n}, {"date": "YYYY-MM-DD"} or {"moment": "RFC 3339"}.'),
667
+ after: comparePointSchema
668
+ .optional()
669
+ .describe("compare: the later point; defaults to the latest commit."),
670
+ key: z
671
+ .array(z.string().min(1))
672
+ .max(8)
673
+ .optional()
674
+ .describe('compare: the variables that identify a row\'s entity, e.g. ["contact"]. With a key the rows are paired: added and removed hold the entities at one point only, changed the entities whose values differ. Without one whole rows are compared.'),
675
+ entailment: z.enum(["none", "subclass", "rdfs", "owl"]).optional(),
676
+ max_rows: z
677
+ .number()
678
+ .int()
679
+ .min(1)
680
+ .max(50_000)
681
+ .optional()
682
+ .describe("compare: rows to read per point, 1 to 50,000 (default 20,000). Past it the result says truncated."),
683
+ limit: z.number().int().min(1).max(1_000).optional(),
684
+ compare_cursor: z
685
+ .string()
686
+ .optional()
687
+ .describe("compare: the cursor of the page before, from next; pass it with the same arguments."),
688
+ ...readScope,
689
+ })
690
+ .strict(),
608
691
  ]);
609
692
  export const configureInputSchema = z.discriminatedUnion("action", [
610
693
  z
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { metadataPage } from "./metadata-pages.js";
3
3
  import { answerQuestion, } from "./question.js";
4
+ import { commitAt, compareQuery, describeGraph, findNames, } from "./query-tools.js";
4
5
  import { registerRdfTool } from "./rdf-tool.js";
5
6
  import { queryTiming } from "./query-observer.js";
6
7
  import { DESTRUCTIVE, IDEMPOTENT_WRITE, MUTATING, READ_ONLY, configureInputSchema, configureWireSchema, detailSchema, graphScope, inspectInputSchema, inspectWireSchema, jsonObjectSchema, queryInputSchema, queryWireSchema, searchFeedbackSchema, yearMonthSchema, } from "./tool-contracts.js";
@@ -63,7 +64,7 @@ export function registerLbbTools(server, client, options = {}) {
63
64
  });
64
65
  });
65
66
  server.registerTool("lbb_query", {
66
- description: "Analytical and expert reads. Modes: question (a question in plain words), structured (SPARQL-subset JSON body), sparql (SPARQL text), search (instances by meaning over every searchable class; filter narrows by class and relationship; every hit checked against the graph), analyze. Use mode=question when you have a question in plain words and no SPARQL query: the server selects the kind of query (route), writes the SPARQL query from a description of the graph, checks it, runs it, and returns the route, the rationale, the query and its rows. Continue or correct that query with mode=sparql. Each question uses model tokens and counts toward a daily limit of the stack. SPARQL is the query language; search finds what the words describe. SPARQL text can also search by meaning inside the query with ?x <https://littlebigbrain.com/search#similarTo> \"words\", so the other patterns filter and join the hits in one query; the result's search field reports the plan. To plan a search: lbb_embeddings action=list, then SPARQL for a class's relationships on a sample, then mode=search with explain=true to check the resolved filter before the real search (lbb_inspect action=guide has the queries). 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. Structured and text queries pin one published watermark for the request.",
67
+ description: "Analytical and expert reads. Modes: question (a question in plain words), structured (SPARQL-subset JSON body), sparql (SPARQL text), search (instances by meaning over every searchable class; filter narrows by class and relationship; every hit checked against the graph), analyze, and four tools for writing your own queries: names, describe, commit_at, compare. names: before a query that names an entity (a person, a company), find its IRI with text=<the question or the names>; use the first candidate, not a CONTAINS match on the name. describe: before a query on classes or properties you have not seen, read them with question=<the question> (or classes/properties as IRIs): how many sampled instances hold each property (a filter on a rare one returns few rows), the values of small classes such as stages, examples and schema statements. commit_at: for a question about a date, find the commit of date=YYYY-MM-DD (or moment=RFC 3339), then run mode=sparql as_of_commit_seq=<it>. compare: for what changed between two points, run one SELECT at before and after (default the latest) with key=[the entity variable]; added, removed and changed come with totals and pages (next). Use mode=question when you have a question in plain words and no SPARQL query: the server picks the kind of question (route), runs queries in a bounded loop, reads their rows, and answers in plain words (about 8 s). The result holds the answer with citations and a chart hint (kind, x, y: columns of the rows), the steps, the route, and the query and rows the answer stands on. Continue or correct that query with mode=sparql. Each question uses model tokens and counts toward a daily limit of the stack. SPARQL is the query language; search finds what the words describe. SPARQL text can also search by meaning inside the query with ?x <https://littlebigbrain.com/search#similarTo> \"words\", so the other patterns filter and join the hits in one query; the result's search field reports the plan. To plan a search: lbb_embeddings action=list, then SPARQL for a class's relationships on a sample, then mode=search with explain=true to check the resolved filter before the real search (lbb_inspect action=guide has the queries). 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. Structured and text queries pin one published watermark for the request.",
67
68
  inputSchema: queryWireSchema,
68
69
  annotations: READ_ONLY,
69
70
  }, (rawArgs, extra) => {
@@ -300,6 +301,29 @@ export function registerLbbTools(server, client, options = {}) {
300
301
  }
301
302
  })();
302
303
  }
304
+ if (args.mode === "names" ||
305
+ args.mode === "describe" ||
306
+ args.mode === "commit_at" ||
307
+ args.mode === "compare") {
308
+ const toolArgs = args;
309
+ return (async () => {
310
+ try {
311
+ switch (args.mode) {
312
+ case "names":
313
+ return await findNames(client, toolArgs);
314
+ case "describe":
315
+ return await describeGraph(client, toolArgs);
316
+ case "commit_at":
317
+ return await commitAt(client, toolArgs);
318
+ default:
319
+ return await compareQuery(client, toolArgs);
320
+ }
321
+ }
322
+ catch (error) {
323
+ return errorResult(await enrichError(client, error));
324
+ }
325
+ })();
326
+ }
303
327
  if (args.mode === "search") {
304
328
  const searchArgs = args;
305
329
  // The caller's detail shapes the hits like every other mode: compact
@@ -476,8 +500,242 @@ export function registerLbbTools(server, client, options = {}) {
476
500
  }
477
501
  return scoped(client, graph).embeddings.delete(name);
478
502
  }));
503
+ // Fit from text: fit sources declared on classes, like embeddings.
504
+ const fitSetup = {
505
+ class: z
506
+ .string()
507
+ .optional()
508
+ .describe("preview/declare/dry_run: the class IRI whose instances hold the text."),
509
+ from: z
510
+ .array(z.string())
511
+ .optional()
512
+ .describe("preview/declare/dry_run: the fields that hold the text, as embedding paths (transcript, label, about/label, <https://…>). Omit for the long text facts of the class."),
513
+ exclude: z
514
+ .array(z.string())
515
+ .optional()
516
+ .describe("preview/declare/dry_run: fields to drop from the automatic choice."),
517
+ context: z
518
+ .string()
519
+ .optional()
520
+ .describe('preview/declare/dry_run: one sentence about the text, e.g. "interviews with employees about their work processes" (at most 500 characters).'),
521
+ };
522
+ const fitDeclaration = (args) => {
523
+ if (!args.class)
524
+ throw new Error("this action requires class");
525
+ return {
526
+ class: args.class,
527
+ ...(args.name ? { name: args.name } : {}),
528
+ ...(args.from ? { from: args.from } : {}),
529
+ ...(args.exclude ? { exclude: args.exclude } : {}),
530
+ ...(args.context ? { context: args.context } : {}),
531
+ };
532
+ };
533
+ server.registerTool("lbb_fit_sources", {
534
+ description: "Fit from text, read only: fit sources declared on classes. A fit source names the fields of a class that hold text (transcripts, documents); the server reads every instance, proposes ontology changes (classes, properties, relations) with verbatim quotes, checks them, and files them as ontology suggestions with origin id fit:<name>. list shows each source's status (progress, lag, instances read, proposals kept and dropped, suggestions per status, this month's spend, last error) and the stack's monthly budget; get shows one; preview shows the fields, every candidate field of the class and the text of sample instances, calls no model and stores nothing. Declare, refresh or dry-run with lbb_fit_sources_manage; list the suggestions with lbb_inspect action=ontology_suggestions.",
535
+ inputSchema: {
536
+ action: z.enum(["list", "get", "preview"]),
537
+ name: z.string().optional().describe("get: the fit source name."),
538
+ ...fitSetup,
539
+ sample: z.number().int().positive().max(20).optional(),
540
+ iris: z
541
+ .array(z.string())
542
+ .optional()
543
+ .describe("preview: the instances to show instead of a sample."),
544
+ detail: detailSchema,
545
+ ...graphScope,
546
+ },
547
+ annotations: READ_ONLY,
548
+ }, (args) => run(client, `lbb_fit_sources.${args.action}`, args.detail, () => {
549
+ const fit = scoped(client, args.graph).ontology.fitSources;
550
+ switch (args.action) {
551
+ case "list":
552
+ return fit.list();
553
+ case "get":
554
+ if (!args.name)
555
+ throw new Error("get requires name");
556
+ return fit.get(args.name);
557
+ case "preview":
558
+ return fit.preview({
559
+ ...fitDeclaration(args),
560
+ ...(args.sample ? { sample: args.sample } : {}),
561
+ ...(args.iris ? { iris: args.iris } : {}),
562
+ });
563
+ }
564
+ }));
565
+ server.registerTool("lbb_fit_sources_manage", {
566
+ description: "Declare, dry-run or refresh a fit source (fit from text). Run lbb_fit_sources action=preview first and show the user the text the fit reads. dry_run runs the models on the first part of up to 3 instances (at most 60 s; truncated when one did not finish) and returns every proposal with its quote, its support score and what the checks made of it; it files nothing but spends model budget. declare sets the class, the fields and the context; the job then reads every instance and files suggestions (an existing source changes only what you name). refresh asks the job to run now and returns the status at once; the job also runs by itself after each published commit, so poll lbb_fit_sources action=get for progress. Nothing changes the ontology until a person accepts a suggestion.",
567
+ inputSchema: {
568
+ action: z.enum(["declare", "dry_run", "refresh"]),
569
+ name: z
570
+ .string()
571
+ .optional()
572
+ .describe("The fit source name (refresh: required)."),
573
+ ...fitSetup,
574
+ detail: detailSchema,
575
+ ...graphScope,
576
+ },
577
+ annotations: MUTATING,
578
+ }, (args) => run(client, `lbb_fit_sources_manage.${args.action}`, args.detail, () => {
579
+ const fit = scoped(client, args.graph).ontology.fitSources;
580
+ switch (args.action) {
581
+ case "declare":
582
+ return fit.declare(fitDeclaration(args));
583
+ case "dry_run":
584
+ return fit.preview({ ...fitDeclaration(args), propose: true });
585
+ case "refresh":
586
+ if (!args.name)
587
+ throw new Error("refresh requires name");
588
+ return fit.refresh(args.name);
589
+ }
590
+ }));
591
+ server.registerTool("lbb_fit_sources_delete", {
592
+ description: "Delete a fit source: the fit stops reading its class. The suggestions it filed stay. Ask the user first; confirm must repeat the name.",
593
+ inputSchema: {
594
+ name: z.string().describe("The fit source name."),
595
+ confirm: z.string().describe("The same name again, to confirm."),
596
+ detail: detailSchema,
597
+ ...graphScope,
598
+ },
599
+ annotations: DESTRUCTIVE,
600
+ }, ({ name, confirm, detail, graph }) => run(client, "lbb_fit_sources_delete", detail, () => {
601
+ if (confirm !== name) {
602
+ throw new Error("confirm must repeat the fit source name");
603
+ }
604
+ return scoped(client, graph).ontology.fitSources.delete(name);
605
+ }));
606
+ const modelUse = z
607
+ .enum(["ask", "route", "rerank", "fit", "label"])
608
+ .describe("A use of a model: ask = question answers, route = query routing, rerank = search rerank, fit = ontology fit, label = eval labels.");
609
+ server.registerTool("lbb_model_choice", {
610
+ description: "Model choice, read only: which model each use of a model on the graph runs, and trials that test other models against the graph's ground truth (the checked calls: a person's review, else the judge's verdict). options shows per use the model it runs now (current), LBB's model (default), a switch (switched), its checked calls, and the models a trial can test with their efforts, prices and whether the server holds their key; available=false with a reason when trials cannot run. trials lists the trials, newest first, each with its report: calls compared, both models' right share, mean score (0 to 1), cost per call and median time, the difference with its 95% interval (delta, ci_low, ci_high), outcome (too_few, better, same, worse) and qualifies (meets the bar: at least 20 calls, at most 5 points worse at the low end, no more failures, cheaper or better). trial reads one trial with its compared calls; trial_call reads one call with both answers and the ground truth. switches lists the switched uses. Start, stop, switch or revert with lbb_model_choice_manage.",
611
+ inputSchema: {
612
+ action: z.enum([
613
+ "options",
614
+ "trials",
615
+ "trial",
616
+ "trial_call",
617
+ "switches",
618
+ ]),
619
+ trial_id: z
620
+ .string()
621
+ .optional()
622
+ .describe("trial / trial_call: the trial id."),
623
+ call_id: z
624
+ .string()
625
+ .optional()
626
+ .describe("trial_call: a call id from the trial's calls."),
627
+ job: modelUse
628
+ .optional()
629
+ .describe("trials: only the trials of this use."),
630
+ limit: z
631
+ .number()
632
+ .int()
633
+ .positive()
634
+ .max(50)
635
+ .optional()
636
+ .describe("trials: at most this many, newest first (default 20)."),
637
+ detail: detailSchema,
638
+ ...graphScope,
639
+ },
640
+ annotations: READ_ONLY,
641
+ }, (args) => run(client, `lbb_model_choice.${args.action}`, args.detail, () => {
642
+ const models = scoped(client, args.graph).models;
643
+ switch (args.action) {
644
+ case "options":
645
+ return models.trials.options();
646
+ case "trials":
647
+ return models.trials.list({ job: args.job, limit: args.limit });
648
+ case "trial":
649
+ if (!args.trial_id)
650
+ throw new Error("trial requires trial_id");
651
+ return models.trials.get(args.trial_id);
652
+ case "trial_call":
653
+ if (!args.trial_id || !args.call_id) {
654
+ throw new Error("trial_call requires trial_id and call_id");
655
+ }
656
+ return models.trials.call(args.trial_id, args.call_id);
657
+ case "switches":
658
+ return models.switches.list();
659
+ }
660
+ }));
661
+ server.registerTool("lbb_model_choice_manage", {
662
+ description: "Model choice, writes. start tests a candidate model on the uses in jobs (without jobs, every use it can do that has checked calls): the server answers those checked calls again with the candidate and scores it and the model in use against the same ground truth. It changes no model and spends at most the trial budget ($3.00 by default) of model and judge cost; an open trial of the same use and candidate comes back as it is. Pick candidates from lbb_model_choice action=options (provider anthropic with an effort, or typesafe jev-latest). A trial runs by itself: poll lbb_model_choice action=trial until its status is no longer running. stop ends a trial; it keeps what it compared. switch makes the trial's use run its candidate on this graph from the next call on; only a trial that qualifies and compared with the model in use switches (ask to a Claude model, route to a Jev model). Show the user the trial's report (score, difference and interval, cost per call, time) and switch only what the user confirmed. revert puts the use back on LBB's model.",
663
+ inputSchema: {
664
+ action: z.enum(["start", "stop", "switch", "revert"]),
665
+ provider: z
666
+ .enum(["anthropic", "typesafe"])
667
+ .optional()
668
+ .describe("start: the candidate's provider."),
669
+ model: z
670
+ .string()
671
+ .optional()
672
+ .describe("start: the candidate model id, e.g. claude-haiku-5-5 or jev-latest."),
673
+ effort: z
674
+ .enum(["low", "medium", "high", "xhigh", "max"])
675
+ .optional()
676
+ .describe("start: the effort of a Claude candidate."),
677
+ jobs: z
678
+ .array(modelUse)
679
+ .optional()
680
+ .describe("start: the uses to test it on."),
681
+ target: z
682
+ .number()
683
+ .int()
684
+ .positive()
685
+ .max(100)
686
+ .optional()
687
+ .describe("start: the calls to compare (default 40)."),
688
+ days: z
689
+ .number()
690
+ .int()
691
+ .positive()
692
+ .max(30)
693
+ .optional()
694
+ .describe("start: the days the trial takes new checks (default 14)."),
695
+ trial_id: z
696
+ .string()
697
+ .optional()
698
+ .describe("stop / switch: the trial id."),
699
+ job: modelUse
700
+ .optional()
701
+ .describe("revert: the use to put back on LBB's model."),
702
+ detail: detailSchema,
703
+ ...graphScope,
704
+ },
705
+ annotations: MUTATING,
706
+ }, (args) => run(client, `lbb_model_choice_manage.${args.action}`, args.detail, () => {
707
+ const models = scoped(client, args.graph).models;
708
+ switch (args.action) {
709
+ case "start":
710
+ if (!args.provider || !args.model) {
711
+ throw new Error("start requires provider and model");
712
+ }
713
+ return models.trials.create({
714
+ candidate: {
715
+ provider: args.provider,
716
+ model: args.model,
717
+ ...(args.effort ? { effort: args.effort } : {}),
718
+ },
719
+ ...(args.jobs ? { jobs: args.jobs } : {}),
720
+ ...(args.target ? { target: args.target } : {}),
721
+ ...(args.days ? { days: args.days } : {}),
722
+ });
723
+ case "stop":
724
+ if (!args.trial_id)
725
+ throw new Error("stop requires trial_id");
726
+ return models.trials.stop(args.trial_id);
727
+ case "switch":
728
+ if (!args.trial_id)
729
+ throw new Error("switch requires trial_id");
730
+ return models.switches.create({ trial: args.trial_id });
731
+ case "revert":
732
+ if (!args.job)
733
+ throw new Error("revert requires job");
734
+ return models.switches.revert(args.job);
735
+ }
736
+ }));
479
737
  server.registerTool("lbb_evals", {
480
- description: "Managed evals: the thumbs up / thumbs down of the graph, per result. A query run with `request` (lbb_query) records a trace with one item per result (hit or row); trace reads it back with the item ids. label marks one result (item + valid) or several (items) relevant or not; the labels become the golden's ground truth. golden freezes a query (every result it returns now is relevant). run replays every golden at the current commit: pass when every relevant result is back and no wrong one is; new results open a review trace. judge lets the platform's judge model (the hosted frontier model) label unlabeled results. summary, traces, goldens, results, and settings read state. Model checks: a judge model checks a sample of the model calls LBB makes for the graph (rerank, route, rewrite, fit, propose, label). checks_summary reads a month per job and model (checks, score, right, partly, wrong, reviews) and the judge's agreement with people; checks lists the month's checks, newest first, with the judge's verdict, score and reason and the ground truth (`truth`). review_check records a person's review of one check (call_id): agree=true keeps the judge's verdict; agree=false with verdict (and an optional score, reference and note) corrects it. The review becomes the call's ground truth, so show the check to the user and review only what the user confirmed.",
738
+ description: "Managed evals: ground truth for the questions an app asks and the searches it runs. A query run with `request` (lbb_query, also mode=question) records a trace: the question, the query that answered it, the type of that query (`query_type`: sparql, hybrid = search by meaning with conditions, search = search by meaning alone) and one item per result (hit or row); trace reads it back with the item ids. label has two forms. On a question's trace, valid alone judges the whole answer: valid=true makes the trace's query the golden query of the question; valid=false with sparql gives the right query, which becomes the golden query; valid=false alone marks the answer wrong. item + valid (or items) judges one result, a citation of the answer: the ground truth of the hits of a search by meaning, and a result marked wrong must not come back for any type. golden freezes a stored query or a search (every result it returns now is relevant). run checks every golden at the current commit: a question is asked again through the query rewriter, then the type of the query it wrote is compared with the expected type, its rows with the rows of the golden query at the same commit (`query_check`), and its results with the judged results; a search is done again; new results open a review trace. judge lets the platform's judge model (the hosted frontier model) label unlabeled results. summary, traces, goldens, results, and settings read state. Model checks: a judge model checks a sample of the model calls LBB makes for the graph (rerank, route, ask = the answer of a question, rewrite, fit, propose, label). checks_summary reads a month per job and model (checks, score, right, partly, wrong, reviews) and the judge's agreement with people; checks lists the month's checks, newest first, with the judge's verdict, score and reason and the ground truth (`truth`). review_check records a person's review of one check (call_id): agree=true keeps the judge's verdict; agree=false with verdict (and an optional score, reference and note) corrects it. The review becomes the call's ground truth, so show the check to the user and review only what the user confirmed.",
481
739
  inputSchema: {
482
740
  action: z.enum([
483
741
  "summary",
@@ -507,7 +765,7 @@ export function registerLbbTools(server, client, options = {}) {
507
765
  valid: z
508
766
  .boolean()
509
767
  .optional()
510
- .describe("label: true = the result answers the request (thumbs up), false = it does not."),
768
+ .describe("label: true = the result answers the request (thumbs up), false = it does not. Without item or items, on a question's trace: the whole answer is right (true) or wrong (false)."),
511
769
  items: z
512
770
  .array(z
513
771
  .object({
@@ -529,7 +787,7 @@ export function registerLbbTools(server, client, options = {}) {
529
787
  sparql: z
530
788
  .string()
531
789
  .optional()
532
- .describe("golden: the query to freeze (the search text for surface=search)."),
790
+ .describe("golden: the query to freeze (the search text for surface=search). label: with valid=false and no item, the right query for the question; the server checks and runs it once."),
533
791
  surface: z
534
792
  .enum(["sparql", "search"])
535
793
  .optional()
@@ -566,7 +824,15 @@ export function registerLbbTools(server, client, options = {}) {
566
824
  .optional()
567
825
  .describe("checks_summary / checks: the month, yyyy-mm (UTC). Defaults to the current month."),
568
826
  job: z
569
- .enum(["rerank", "route", "rewrite", "fit", "propose", "label"])
827
+ .enum([
828
+ "rerank",
829
+ "route",
830
+ "ask",
831
+ "rewrite",
832
+ "fit",
833
+ "propose",
834
+ "label",
835
+ ])
570
836
  .optional()
571
837
  .describe("checks: only the checks of one job."),
572
838
  verdict: z
@@ -616,13 +882,19 @@ export function registerLbbTools(server, client, options = {}) {
616
882
  case "label":
617
883
  if (!trace_id)
618
884
  throw new Error("label requires trace_id");
619
- if (!item && !items?.length)
620
- throw new Error("label requires item (with valid) or items");
621
885
  if (item && valid === undefined)
622
886
  throw new Error("label requires valid with item");
887
+ if (!item && !items?.length && valid === undefined)
888
+ throw new Error("label requires item (with valid), items, or valid alone for the answer of a question");
889
+ if (sparql !== undefined &&
890
+ (item || items?.length || valid !== false))
891
+ throw new Error("label takes sparql (the right query) only with valid=false and no item");
623
892
  return target.evals.label(trace_id, {
624
893
  ...(item ? { item, valid } : {}),
625
894
  ...(items?.length ? { items } : {}),
895
+ // The answer of a question as a whole.
896
+ ...(!item && !items?.length ? { valid } : {}),
897
+ ...(sparql !== undefined ? { sparql } : {}),
626
898
  by,
627
899
  note,
628
900
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@littlebigbrain/mcp",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for little big brain: search, graph queries, and data import",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -52,7 +52,7 @@
52
52
  "test:coverage": "npm run clean:test && tsc -p tsconfig.test.json && node --test --experimental-test-coverage --test-coverage-include=test-dist/*.js --test-coverage-exclude=test-dist/*.test.js --test-coverage-exclude=test-dist/stdio.js --test-coverage-exclude=test-dist/test-support.js --test-coverage-lines=90 --test-coverage-branches=70 --test-coverage-functions=85 \"test-dist/**/*.test.js\""
53
53
  },
54
54
  "dependencies": {
55
- "@littlebigbrain/client": "^0.20.0",
55
+ "@littlebigbrain/client": "^0.22.0",
56
56
  "@modelcontextprotocol/sdk": "^1",
57
57
  "zod": "^3"
58
58
  },