@littlebigbrain/mcp 0.15.1 → 0.17.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
@@ -67,6 +67,11 @@ The client starts the server and communicates through standard input and output
67
67
  (stdio). Keep the key in your local configuration. Set `LBB_GRAPH` to change
68
68
  the default graph from `main`.
69
69
 
70
+ A call that deletes or removes data asks you to approve it in the client. A
71
+ client that cannot show the question gets the error `approval_required`, and
72
+ nothing is sent. Set `LBB_MCP_DESTRUCTIVE=allow` in the server's environment
73
+ to run such calls without a question.
74
+
70
75
  ## Run a first query
71
76
 
72
77
  This example stores three facts in a graph named `quickstart`: a service writes
@@ -159,7 +164,7 @@ its original identifiers; inspect it with SPARQL when choosing query predicates.
159
164
  | `lbb_workflows_manage` | Create a workflow from a starter workflow on a class (a developer workflow with its workflow type, owned outputs and batch), ask it to run now, or pause and resume a developer workflow. Define a workflow with triggers of your worker (shards, routing field, window) and run it now. |
160
165
  | `lbb_workflows_delete` | Delete a triggered workflow, or a workflow with triggers by its name. What it wrote stays. |
161
166
  | `lbb_files` | List the files of a graph, all or by parse status or a part of the name, and read the parsed pages of one: each line with its id, its box on the page and its text. |
162
- | `lbb_commit` | Write or retract JSON facts, or record search feedback. |
167
+ | `lbb_commit` | Write legacy JSON facts, explicitly commit versioned records, retract facts, or record search feedback. |
163
168
  | `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`). |
164
169
  | `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. |
165
170
  | `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. |
@@ -170,13 +175,79 @@ See [search by meaning](https://docs.littlebigbrain.com/guides/search-by-meaning
170
175
  for embedding setup. To define constraints with the Shapes Constraint Language
171
176
  (SHACL), see [query and validation](https://docs.littlebigbrain.com/guides/sparql-and-shacl/).
172
177
 
173
- RDF imports and JSON facts use different write workflows. A graph first written
174
- through `lbb_rdf` does not accept JSON fact writes through `lbb_commit`. Choose
175
- the workflow when creating the graph. RDF updates
176
- support additive `INSERT DATA`;
178
+ Legacy `mode=facts` follows the graph's existing first-write restrictions; a
179
+ graph first written through `lbb_rdf` refuses that legacy JSON path. Explicit
180
+ `mode=records` uses the separate versioned contract described below and does not
181
+ activate or convert an existing graph. RDF updates support additive `INSERT DATA`;
177
182
  see the [RDF guide](https://docs.littlebigbrain.com/guides/load-rdf/) for format and
178
183
  update limits.
179
184
 
185
+
186
+ ## Versioned records commits
187
+
188
+ Use explicit `lbb_commit` `mode: "records"` with a nested `records` body whose
189
+ `version` is `records/1`. The server must recognize `records_commit_v1` and the
190
+ graph must already have qualified support; protocol recognition does not
191
+ activate a graph or offer a graph-type choice. Ordinary/missing/unactivated
192
+ graphs keep their typed refusal. There is no creation, conversion or activation
193
+ option here. This requires the matching client release containing
194
+ `records.commit`; publish that client before releasing this MCP update.
195
+
196
+ ```json
197
+ {
198
+ "mode": "records",
199
+ "records": {
200
+ "version": "records/1",
201
+ "entities": [{
202
+ "id": "https://example.org/service/auth",
203
+ "add": {"https://example.org/title": {
204
+ "@value": "Auth Service",
205
+ "@type": "http://www.w3.org/2001/XMLSchema#string"
206
+ }}
207
+ }]
208
+ },
209
+ "idempotency_key": "auth-title:v1"
210
+ }
211
+ ```
212
+
213
+ The complete object is forwarded without dropping unknown fields, lowercasing
214
+ IRIs, coercing literals or resolving schema keys locally. The server validates
215
+ it strictly. Do not mix legacy facts/embeddings/properties/retract/feedback or
216
+ `dry_run`/backfill arguments into records mode, even with empty or false values.
217
+ `expected_graph_epoch` adds an exact decimal incarnation guard.
218
+
219
+ An explicit idempotency key wins. When omitted, MCP derives a stable content key
220
+ from the original body, graph and epoch guard. The snapshot and key are fixed
221
+ before approval and transport retries. Keep the same original body/key after a
222
+ lost reply; do not use a new key to investigate an unknown result. An exhausted
223
+ POST transport failure or malformed success returns `record_outcome_unknown`,
224
+ `outcome: "unknown"` and the original `idempotency_key`, without legacy fallback.
225
+ A capability failure sends no POST and cannot become a write acceptance.
226
+ Typed server errors keep their original code/status and also retain the recovery
227
+ key; the tool does not invent an acceptance or refusal verdict for them.
228
+
229
+ Edits that may remove support use the existing destructive approval policy:
230
+ `set`, `unset`, `remove`, `delete`, `replace_scope`, or a relationship with
231
+ `asserted: false`. A set may replace an existing field. The question names the
232
+ graph and source owner/scope; refusal sends no API request. Add-only work needs
233
+ no destructive approval. The existing person-controlled
234
+ `LBB_MCP_DESTRUCTIVE=allow` setting applies; no body field can bypass approval.
235
+ The server remains responsible for current authority and reserved source owners.
236
+
237
+ Success returns the complete original receipt as compact JSON and structured
238
+ content. Resolved IDs, schema, projection counts, no-op and replay are never
239
+ compacted. Support can change with zero visible additions/removals. Acceptance,
240
+ publication and class embedding readiness are separate observations. MCP keeps
241
+ its 80,000 UTF-8 byte output ceiling: an unexpected oversized valid receipt reports
242
+ `record_accepted_output_limit` with `outcome: "accepted"`, sequence and key so the
243
+ full receipt can be recovered through the SDK/HTTP API. It never calls that
244
+ known acceptance a failed mutation or unknown outcome. Oversized error messages
245
+ are explicitly flagged while preserving ordinary typed codes/status.
246
+
247
+ The records body is bounded to one MiB and its escaped recovery key to 8 KiB at
248
+ the MCP boundary; the server's smaller total-request/idempotency limits still
249
+ apply. These limits do not expand any graph/source budget or enable admin erasure.
250
+
180
251
  ## Pages and saved queries
181
252
 
182
253
  When a result includes `next`, pass those arguments to the same tool to read the
@@ -0,0 +1,139 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
3
+ import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
4
+ import type { LbbClient } from "@littlebigbrain/client";
5
+ import type { LbbServerOptions } from "./query-observer.js";
6
+ import { errorResult } from "./tool-runtime.js";
7
+ /** The `extra` argument of a tool callback. */
8
+ export type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
9
+ type ErrorResult = ReturnType<typeof errorResult>;
10
+ /** The environment setting that lets destructive calls run without a question. */
11
+ export declare const DESTRUCTIVE_SETTING = "LBB_MCP_DESTRUCTIVE";
12
+ /** The approval rule, for the description of a destructive tool. */
13
+ export declare const APPROVAL_TEXT = "The server asks the person to approve it through the client (MCP form elicitation) and sends nothing when they decline (approval_declined). A client that cannot show the question gets approval_required and nothing is sent, unless the MCP server's environment sets LBB_MCP_DESTRUCTIVE=allow.";
14
+ /** What a destructive call asks the person. */
15
+ export interface ApprovalRequest {
16
+ /**
17
+ * The question, naming what the call deletes or removes first. A
18
+ * function runs only when the person is asked, so the call can read the
19
+ * records it names; its error is the tool's error.
20
+ */
21
+ message: string | (() => Promise<string>);
22
+ }
23
+ /**
24
+ * A name or a value on one line of a question: newlines and other control
25
+ * characters escaped, so a name cannot add lines of its own; at most `max`
26
+ * characters.
27
+ */
28
+ export declare function oneLine(text: unknown, max?: number): string;
29
+ /**
30
+ * Text quoted under a line of a question: every line indented by four
31
+ * spaces, other control characters escaped, at most `max` characters.
32
+ */
33
+ export declare function quoteBlock(text: string, max: number): string;
34
+ /** `graph "<name>"`: the graph a call targets, for a person to read. */
35
+ export declare function graphLabel(client: LbbClient, graph?: string): string;
36
+ /** The tool registry of one MCP server: a call log, and the approval step. */
37
+ export interface ToolHost {
38
+ readonly server: McpServer;
39
+ /** `server.registerTool`, with one log event per call. */
40
+ readonly register: McpServer["registerTool"];
41
+ /**
42
+ * Ask the person before a destructive call (principle 13: destructive
43
+ * writes need a person's approval). A client with form elicitation shows
44
+ * the question with one confirm box; a decline, a cancel or an unticked
45
+ * box sends nothing. A client without form elicitation cannot ask, so
46
+ * the call is refused with approval_required, unless the server's
47
+ * environment sets LBB_MCP_DESTRUCTIVE=allow. No tool argument lets a
48
+ * call through: an agent sets the arguments. Returns undefined when the
49
+ * call may go ahead, else the error result to return.
50
+ */
51
+ approve(extra: ToolExtra, request: ApprovalRequest): Promise<ErrorResult | undefined>;
52
+ }
53
+ /** The host of one server's tools; `options` name the approval setting and the log. */
54
+ export declare function toolHost(server: McpServer, options?: LbbServerOptions): ToolHost;
55
+ /** `1 link`, `2 links`. */
56
+ export declare function counted(n: number, noun: string, plural?: string): string;
57
+ type NamedRecord = {
58
+ type: string;
59
+ name: string;
60
+ };
61
+ /** The retract question: the counts, then every link and record it removes, the first 20 of each. */
62
+ export declare function retractMessage(where: string, edges: {
63
+ source: NamedRecord;
64
+ relation: string;
65
+ target: NamedRecord;
66
+ }[], entities: NamedRecord[]): string;
67
+ /**
68
+ * An `evolve_ontology` operation, as the tool or a suggestion holds it.
69
+ * Only the fields a removal names are read.
70
+ */
71
+ export type OntologyOpLike = {
72
+ op: string;
73
+ name?: unknown;
74
+ relation?: unknown;
75
+ remove_domain?: unknown;
76
+ remove_range?: unknown;
77
+ };
78
+ /** Whether an operation removes: a type, a relation, or a relation's source or target types. */
79
+ export declare function removesFromOntology(op: OntologyOpLike): boolean;
80
+ /**
81
+ * The question of an `evolve_ontology` call that removes: the counts, then
82
+ * what `remove_entity_type`, `remove_relation` and the `remove_domain` and
83
+ * `remove_range` of `narrow_relation` remove, the first 20. Undefined when
84
+ * the call removes nothing.
85
+ */
86
+ export declare function ontologyRemovalMessage(where: string, ops: OntologyOpLike[]): string | undefined;
87
+ /** The facts a review item commits, as far as a question names them. */
88
+ export type FactsLike = {
89
+ triplets?: unknown[] | null;
90
+ entity_properties?: unknown[] | null;
91
+ replace_owned?: {
92
+ subjects?: unknown[] | null;
93
+ retract_entities?: unknown[] | null;
94
+ } | null;
95
+ };
96
+ /** `2 links and 1 field row; …`: what committing `facts` writes and removes. */
97
+ export declare function factsSummary(facts: FactsLike): string | undefined;
98
+ /** A review item an answer would commit or apply. */
99
+ export interface ReviewTarget {
100
+ id: string;
101
+ title: string;
102
+ kind: string;
103
+ question?: {
104
+ property?: string | null;
105
+ value?: unknown;
106
+ unit?: string | null;
107
+ } | null;
108
+ subject?: {
109
+ name?: string | null;
110
+ } | null;
111
+ facts?: FactsLike | null;
112
+ }
113
+ /** A change item's suggestion: its operations and facts, or undefined when it could not be read. */
114
+ export type ChangeDetail = {
115
+ ops: OntologyOpLike[];
116
+ facts?: FactsLike | null;
117
+ } | undefined;
118
+ /**
119
+ * The question of a review answer that commits or applies: the count and
120
+ * the effect first (how many value items commit a value, how many change
121
+ * items apply an ontology change, and what those changes remove), then
122
+ * each item, the first 20. `unchanged` counts the other answered items.
123
+ */
124
+ export declare function reviewAnswerMessage(where: string, answer: {
125
+ decision: "accept" | "correct";
126
+ value?: unknown;
127
+ }, items: ReviewTarget[], changes: Map<string, ChangeDetail>, unchanged: number): string;
128
+ /** The question of a shape publish in reject mode. */
129
+ export declare function shapesRejectMessage(where: string, shapes: {
130
+ source: string;
131
+ format?: string;
132
+ }): string;
133
+ /**
134
+ * The question of a SPARQL Update that can remove statements: the
135
+ * operations that remove come first (DELETE, CLEAR, DROP, MOVE, COPY),
136
+ * each quoted on lines of its own, then the forms of the other operations.
137
+ */
138
+ export declare function sparqlUpdateMessage(where: string, update: string): string;
139
+ export {};
@@ -0,0 +1,426 @@
1
+ import { updateOperations } from "./sparql-update.js";
2
+ import { ToolError, errorResult } from "./tool-runtime.js";
3
+ /** How long the person has to answer an approval request. */
4
+ const APPROVAL_TIMEOUT_MS = 10 * 60_000;
5
+ /** The environment setting that lets destructive calls run without a question. */
6
+ export const DESTRUCTIVE_SETTING = "LBB_MCP_DESTRUCTIVE";
7
+ /** The approval rule, for the description of a destructive tool. */
8
+ export const APPROVAL_TEXT = `The server asks the person to approve it through the client (MCP form elicitation) and sends nothing when they decline (approval_declined). A client that cannot show the question gets approval_required and nothing is sent, unless the MCP server's environment sets ${DESTRUCTIVE_SETTING}=allow.`;
9
+ /** Control characters, line and paragraph separators, and bidirectional controls. */
10
+ const UNSAFE =
11
+ // eslint-disable-next-line no-control-regex -- these are the characters a name must not carry into a question
12
+ /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
13
+ function escapeChar(c) {
14
+ switch (c) {
15
+ case "\n":
16
+ return "\\n";
17
+ case "\r":
18
+ return "\\r";
19
+ case "\t":
20
+ return "\\t";
21
+ default:
22
+ return `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`;
23
+ }
24
+ }
25
+ /**
26
+ * A name or a value on one line of a question: newlines and other control
27
+ * characters escaped, so a name cannot add lines of its own; at most `max`
28
+ * characters.
29
+ */
30
+ export function oneLine(text, max = 200) {
31
+ const escaped = String(text).replace(UNSAFE, escapeChar);
32
+ return escaped.length > max ? `${escaped.slice(0, max)} …` : escaped;
33
+ }
34
+ /**
35
+ * Text quoted under a line of a question: every line indented by four
36
+ * spaces, other control characters escaped, at most `max` characters.
37
+ */
38
+ export function quoteBlock(text, max) {
39
+ const normal = text.replace(/\r\n?/g, "\n").replace(/\t/g, " ");
40
+ const shown = normal.length > max ? normal.slice(0, max) : normal;
41
+ const body = shown
42
+ .split("\n")
43
+ .map((line) => ` ${line.replace(UNSAFE, escapeChar)}`)
44
+ .join("\n");
45
+ return normal.length > max
46
+ ? `${body}\n … ${(normal.length - max).toLocaleString("en-US")} more characters`
47
+ : body;
48
+ }
49
+ /** `graph "<name>"`: the graph a call targets, for a person to read. */
50
+ export function graphLabel(client, graph) {
51
+ const name = graph ?? client.graphName;
52
+ return name ? `graph "${oneLine(name)}"` : "the connection's graph";
53
+ }
54
+ /** A word of the log: an action or an error code, never free text. */
55
+ function token(value, pattern) {
56
+ return typeof value === "string" && pattern.test(value) ? value : undefined;
57
+ }
58
+ function defaultLog(event) {
59
+ process.stderr.write(`${JSON.stringify(event)}\n`);
60
+ }
61
+ /** The host of one server's tools; `options` name the approval setting and the log. */
62
+ export function toolHost(server, options = {}) {
63
+ const setting = options.destructiveWithoutApproval ??
64
+ (process.env[DESTRUCTIVE_SETTING]?.trim().toLowerCase() === "allow"
65
+ ? "allow"
66
+ : "refuse");
67
+ const log = options.log ?? defaultLog;
68
+ const calls = new WeakMap();
69
+ const registerTool = server.registerTool.bind(server);
70
+ const register = ((name, config, callback) => registerTool(name, config, async (...params) => {
71
+ const extra = params[params.length - 1];
72
+ const record = {};
73
+ calls.set(extra, record);
74
+ const args = (params.length > 1 ? params[0] : undefined);
75
+ const action = token(args?.action ?? args?.mode, /^[a-z_]{1,40}$/);
76
+ const emit = (outcome, code) => {
77
+ try {
78
+ log({
79
+ event: "mcp_tool_call",
80
+ tool: name,
81
+ ...(action ? { action } : {}),
82
+ outcome,
83
+ ...(code ? { code } : {}),
84
+ ...(record.approval ? { approval: record.approval } : {}),
85
+ request_id: extra.requestId,
86
+ });
87
+ }
88
+ catch {
89
+ // A log that fails never fails the call.
90
+ }
91
+ };
92
+ try {
93
+ const result = (await callback(...params));
94
+ emit(result?.isError ? "error" : "ok", result?.isError
95
+ ? token(result.structuredContent?.error?.code, /^[a-z0-9_.]{1,64}$/)
96
+ : undefined);
97
+ return result;
98
+ }
99
+ catch (error) {
100
+ emit("error", "exception");
101
+ throw error;
102
+ }
103
+ }));
104
+ const approve = async (extra, request) => {
105
+ const record = calls.get(extra);
106
+ const decide = (decision) => {
107
+ if (record)
108
+ record.approval = decision;
109
+ };
110
+ if (!server.server.getClientCapabilities()?.elicitation?.form) {
111
+ if (setting === "allow") {
112
+ decide("allowed_by_setting");
113
+ return undefined;
114
+ }
115
+ decide("unavailable");
116
+ return errorResult(new ToolError("approval_required", `This call deletes or removes data, and this client cannot ask the person to approve it (it has no MCP form elicitation), so nothing was sent. Make this change in the console or with the TypeScript or Python SDK, or from an MCP client that can show the question. On an MCP server you run yourself, the person can set ${DESTRUCTIVE_SETTING}=allow in its environment; the hosted endpoint never allows it.`));
117
+ }
118
+ let message;
119
+ try {
120
+ message =
121
+ typeof request.message === "string"
122
+ ? request.message
123
+ : await request.message();
124
+ }
125
+ catch (error) {
126
+ return errorResult(error);
127
+ }
128
+ try {
129
+ const answer = await server.server.elicitInput({
130
+ mode: "form",
131
+ message,
132
+ requestedSchema: {
133
+ type: "object",
134
+ properties: {
135
+ confirm: {
136
+ type: "boolean",
137
+ title: "Approve",
138
+ description: "Tick to run this call. It cannot be undone.",
139
+ default: false,
140
+ },
141
+ },
142
+ required: ["confirm"],
143
+ },
144
+ }, {
145
+ relatedRequestId: extra.requestId,
146
+ signal: extra.signal,
147
+ timeout: APPROVAL_TIMEOUT_MS,
148
+ });
149
+ if (answer.action === "accept" && answer.content?.confirm === true) {
150
+ decide("approved");
151
+ return undefined;
152
+ }
153
+ decide(answer.action === "cancel" ? "cancelled" : "declined");
154
+ }
155
+ catch (error) {
156
+ decide("failed");
157
+ return errorResult(new ToolError("approval_failed", `The approval request failed, so nothing was sent: ${error instanceof Error ? error.message : String(error)}`));
158
+ }
159
+ return errorResult(new ToolError("approval_declined", "The person declined, so nothing was sent."));
160
+ };
161
+ return { server, register, approve };
162
+ }
163
+ /** `1 link`, `2 links`. */
164
+ export function counted(n, noun, plural = `${noun}s`) {
165
+ return `${n.toLocaleString("en-US")} ${n === 1 ? noun : plural}`;
166
+ }
167
+ /** `a`, `a and b`, `a, b and c`. */
168
+ function joined(parts) {
169
+ return parts.length <= 1
170
+ ? (parts[0] ?? "")
171
+ : `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
172
+ }
173
+ /** The first 20 lines, then a count of the rest. */
174
+ function listed(lines, noun) {
175
+ return lines.length > 20
176
+ ? [...lines.slice(0, 20), `- and ${lines.length - 20} more ${noun}`]
177
+ : lines;
178
+ }
179
+ /** The retract question: the counts, then every link and record it removes, the first 20 of each. */
180
+ export function retractMessage(where, edges, entities) {
181
+ const record = (r) => `${oneLine(r.name)} (${oneLine(r.type)})`;
182
+ const what = joined([
183
+ ...(edges.length ? [counted(edges.length, "link")] : []),
184
+ ...(entities.length
185
+ ? [`every current link of ${counted(entities.length, "record")}`]
186
+ : []),
187
+ ]);
188
+ return [
189
+ `Retract ${what} from ${where}?`,
190
+ ...listed(edges.map((edge) => `- the link ${record(edge.source)} ${oneLine(edge.relation)} ${record(edge.target)}`), "links"),
191
+ ...listed(entities.map((entity) => `- every current link of ${record(entity)}`), "records"),
192
+ "The records and their history stay readable in as_of reads.",
193
+ ].join("\n");
194
+ }
195
+ function names(value) {
196
+ return Array.isArray(value) ? value.map((name) => oneLine(name)) : [];
197
+ }
198
+ /** What one operation removes, one line each. */
199
+ function removals(op) {
200
+ switch (op.op) {
201
+ case "remove_entity_type":
202
+ return [`- the entity type ${oneLine(op.name)}`];
203
+ case "remove_relation":
204
+ return [`- the relation ${oneLine(op.name)}`];
205
+ case "narrow_relation": {
206
+ const domain = names(op.remove_domain);
207
+ const range = names(op.remove_range);
208
+ return [
209
+ ...(domain.length
210
+ ? [
211
+ `- ${domain.join(", ")} from the source types (domain) of the relation ${oneLine(op.relation)}`,
212
+ ]
213
+ : []),
214
+ ...(range.length
215
+ ? [
216
+ `- ${range.join(", ")} from the target types (range) of the relation ${oneLine(op.relation)}`,
217
+ ]
218
+ : []),
219
+ ];
220
+ }
221
+ default:
222
+ return [];
223
+ }
224
+ }
225
+ /** Whether an operation removes: a type, a relation, or a relation's source or target types. */
226
+ export function removesFromOntology(op) {
227
+ return removals(op).length > 0;
228
+ }
229
+ /** `1 entity type, 1 relation and 3 source or target types`. */
230
+ function removalCounts(ops) {
231
+ const of = (kind) => ops.filter((op) => op.op === kind).length;
232
+ const types = ops.reduce((sum, op) => op.op === "narrow_relation"
233
+ ? sum + names(op.remove_domain).length + names(op.remove_range).length
234
+ : sum, 0);
235
+ return joined([
236
+ ...(of("remove_entity_type")
237
+ ? [counted(of("remove_entity_type"), "entity type")]
238
+ : []),
239
+ ...(of("remove_relation")
240
+ ? [counted(of("remove_relation"), "relation")]
241
+ : []),
242
+ ...(types
243
+ ? [
244
+ counted(types, "source or target type of a relation", "source or target types of relations"),
245
+ ]
246
+ : []),
247
+ ]);
248
+ }
249
+ /**
250
+ * The question of an `evolve_ontology` call that removes: the counts, then
251
+ * what `remove_entity_type`, `remove_relation` and the `remove_domain` and
252
+ * `remove_range` of `narrow_relation` remove, the first 20. Undefined when
253
+ * the call removes nothing.
254
+ */
255
+ export function ontologyRemovalMessage(where, ops) {
256
+ const removing = ops.filter(removesFromOntology);
257
+ if (!removing.length)
258
+ return undefined;
259
+ const others = ops.length - removing.length;
260
+ const tombstones = removing.some((op) => op.op === "remove_entity_type" || op.op === "remove_relation");
261
+ return [
262
+ `Change the ontology of ${where}? The change removes ${removalCounts(removing)}:`,
263
+ ...listed(removing.flatMap(removals), "removals"),
264
+ ...(others
265
+ ? [`It also applies ${counted(others, "other operation")}.`]
266
+ : []),
267
+ ...(tombstones
268
+ ? [
269
+ "A removed type or relation stays readable in old records. New commits that use it fail.",
270
+ ]
271
+ : []),
272
+ ].join("\n");
273
+ }
274
+ /** `2 links and 1 field row; …`: what committing `facts` writes and removes. */
275
+ export function factsSummary(facts) {
276
+ const writes = joined([
277
+ ...(facts.triplets?.length ? [counted(facts.triplets.length, "link")] : []),
278
+ ...(facts.entity_properties?.length
279
+ ? [counted(facts.entity_properties.length, "field row")]
280
+ : []),
281
+ ]);
282
+ const owned = facts.replace_owned;
283
+ const parts = [
284
+ ...(writes ? [`commits ${writes}`] : []),
285
+ ...(owned?.subjects?.length
286
+ ? [
287
+ `removes the owned links and fields it does not write again on ${counted(owned.subjects.length, "record")}`,
288
+ ]
289
+ : []),
290
+ ...(owned?.retract_entities?.length
291
+ ? [`retracts ${counted(owned.retract_entities.length, "record")}`]
292
+ : []),
293
+ ];
294
+ return parts.length ? parts.join("; ") : undefined;
295
+ }
296
+ /**
297
+ * The question of a review answer that commits or applies: the count and
298
+ * the effect first (how many value items commit a value, how many change
299
+ * items apply an ontology change, and what those changes remove), then
300
+ * each item, the first 20. `unchanged` counts the other answered items.
301
+ */
302
+ export function reviewAnswerMessage(where, answer, items, changes, unchanged) {
303
+ const values = items.filter((item) => item.kind === "value");
304
+ const changeItems = items.filter((item) => item.kind === "change");
305
+ const total = items.length + unchanged;
306
+ const correct = answer.decision === "correct";
307
+ const effects = [
308
+ ...(values.length
309
+ ? [
310
+ values.length === 1
311
+ ? `1 value item commits ${correct ? "the corrected value" : "its value"}`
312
+ : `${counted(values.length, "value item")} commit ${correct ? "the corrected value" : "their values"}`,
313
+ ]
314
+ : []),
315
+ ...(changeItems.length
316
+ ? [
317
+ changeItems.length === 1
318
+ ? "1 change item applies its ontology change"
319
+ : `${counted(changeItems.length, "change item")} apply their ontology changes`,
320
+ ]
321
+ : []),
322
+ ];
323
+ const head = `${correct ? "Correct" : "Accept"} ${counted(total, "review item")} on ${where}${correct ? ` to ${oneLine(JSON.stringify(answer.value ?? null))}` : ""}? ${joined(effects)}.`;
324
+ const lines = [];
325
+ for (const item of items.slice(0, 20)) {
326
+ const label = `- "${oneLine(item.title)}" (${oneLine(item.id)})`;
327
+ const facts = item.facts ? factsSummary(item.facts) : undefined;
328
+ if (item.kind === "value") {
329
+ const property = item.question?.property;
330
+ const value = correct ? answer.value : item.question?.value;
331
+ const unit = !correct && item.question?.unit ? ` ${item.question.unit}` : "";
332
+ const subject = item.subject?.name
333
+ ? ` on ${oneLine(item.subject.name)}`
334
+ : "";
335
+ const what = property
336
+ ? `${oneLine(property)} = ${oneLine(JSON.stringify(value ?? null))}${oneLine(unit)}${subject}`
337
+ : "its value";
338
+ lines.push(`${label}: ${what}${facts ? `; ${facts}` : ""}`);
339
+ continue;
340
+ }
341
+ const detail = changes.get(item.id);
342
+ if (!detail) {
343
+ lines.push(`${label}: its ontology change and facts could not be read here`);
344
+ continue;
345
+ }
346
+ const removing = detail.ops.flatMap(removals);
347
+ const changeFacts = detail.facts ? factsSummary(detail.facts) : undefined;
348
+ lines.push(`${label}: ${counted(detail.ops.length, "ontology operation")}${changeFacts ? `; ${changeFacts}` : ""}${removing.length ? "; the change removes:" : ""}`);
349
+ lines.push(...listed(removing, "removals").map((line) => ` ${line}`));
350
+ }
351
+ if (items.length > 20)
352
+ lines.push(`- and ${counted(items.length - 20, "more item")}`);
353
+ return [
354
+ head,
355
+ ...lines,
356
+ ...(unchanged
357
+ ? [
358
+ unchanged === 1
359
+ ? "1 other item changes nothing in the graph."
360
+ : `${counted(unchanged, "other item")} change nothing in the graph.`,
361
+ ]
362
+ : []),
363
+ ].join("\n");
364
+ }
365
+ /** The target classes a SHACL source names, as written. */
366
+ function targetClasses(source) {
367
+ const found = source.matchAll(/(?:\bsh:targetClass|<http:\/\/www\.w3\.org\/ns\/shacl#targetClass>)\s+(<[^>\s]*>|[A-Za-z][\w.-]*:[\w.-]*|:[\w.-]*)/g);
368
+ return [...new Set([...found].map((match) => oneLine(match[1])))];
369
+ }
370
+ /** The question of a shape publish in reject mode. */
371
+ export function shapesRejectMessage(where, shapes) {
372
+ const targets = targetClasses(shapes.source);
373
+ return [
374
+ `Publish these SHACL rules on ${where} in reject mode? From then on, a write that breaks a rule fails, and the error lists the violations.`,
375
+ ...(targets.length
376
+ ? [
377
+ `The rules name ${counted(targets.length, "target class", "target classes")}:`,
378
+ ...listed(targets.map((target) => `- ${target}`), "target classes"),
379
+ ]
380
+ : ["No target class was read from the rules."]),
381
+ `The rules are ${counted(shapes.source.length, "character")} of ${oneLine(shapes.format ?? "auto")} text.`,
382
+ ].join("\n");
383
+ }
384
+ /** The most characters of one removing operation a question quotes. */
385
+ const OPERATION_CHARS = 1_500;
386
+ /** The most characters of removing operations one question quotes. */
387
+ const UPDATE_CHARS = 6_000;
388
+ /**
389
+ * The question of a SPARQL Update that can remove statements: the
390
+ * operations that remove come first (DELETE, CLEAR, DROP, MOVE, COPY),
391
+ * each quoted on lines of its own, then the forms of the other operations.
392
+ */
393
+ export function sparqlUpdateMessage(where, update) {
394
+ const operations = updateOperations(update);
395
+ const removing = operations
396
+ .map((operation, i) => ({ operation, n: i + 1 }))
397
+ .filter(({ operation }) => operation.removes);
398
+ if (!removing.length) {
399
+ return [
400
+ `Run this SPARQL Update on ${where}? Its text names DELETE, CLEAR, DROP, MOVE or COPY, so it may remove statements:`,
401
+ quoteBlock(update, 2_000),
402
+ ].join("\n");
403
+ }
404
+ const lines = [
405
+ `Run this SPARQL Update on ${where}? ${operations.length === 1
406
+ ? "It removes statements:"
407
+ : removing.length === operations.length
408
+ ? `All ${operations.length} of its operations remove statements:`
409
+ : `${removing.length} of its ${operations.length} operations remove statements:`}`,
410
+ ];
411
+ let budget = UPDATE_CHARS;
412
+ for (const { operation, n } of removing.slice(0, 20)) {
413
+ const shown = Math.max(200, Math.min(OPERATION_CHARS, budget));
414
+ lines.push(`- operation ${n}, ${oneLine(operation.form)}:`);
415
+ lines.push(quoteBlock(operation.text, shown));
416
+ budget -= Math.min(operation.text.length, shown);
417
+ }
418
+ if (removing.length > 20)
419
+ lines.push(`- and ${counted(removing.length - 20, "more operation")} that remove`);
420
+ const others = operations.filter((operation) => !operation.removes);
421
+ if (others.length)
422
+ lines.push(`The other ${others.length === 1 ? "operation" : `${others.length} operations`}: ${[
423
+ ...new Set(others.map((operation) => oneLine(operation.form))),
424
+ ].join(", ")}.`);
425
+ return lines.join("\n");
426
+ }