@littlebigbrain/mcp 0.15.0 → 0.16.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/dist/rdf-tool.js CHANGED
@@ -1,6 +1,13 @@
1
1
  import { z } from "zod";
2
2
  import { graphScope, detailSchema, advertiseUnion } from "./tool-contracts.js";
3
3
  import { contentHashKey, errorResult, run, scoped } from "./tool-runtime.js";
4
+ import { APPROVAL_TEXT, graphLabel, sparqlUpdateMessage, } from "./approval.js";
5
+ /**
6
+ * An update form that can remove statements: DELETE (DATA, WHERE, or with
7
+ * INSERT), CLEAR, DROP, and MOVE and COPY, which empty their target first.
8
+ * A word in a literal or an IRI also matches; that only asks once more.
9
+ */
10
+ const REMOVING_UPDATE = /\b(DELETE|CLEAR|DROP|MOVE|COPY)\b/i;
4
11
  const input = z.discriminatedUnion("action", [
5
12
  z
6
13
  .object({
@@ -33,9 +40,9 @@ const input = z.discriminatedUnion("action", [
33
40
  })
34
41
  .strict(),
35
42
  ]);
36
- export function registerRdfTool(server, client) {
37
- server.registerTool("lbb_rdf", {
38
- description: "Store and extend complete RDF/OWL documents through MCP. import accepts Turtle/N-Triples/N-Quads/TriG without conversion; update executes INSERT DATA for additive edits. Replacing/removing axioms is unsupported; use a new versioned LBB graph for a revised document. Named RDF graphs are unsupported; Turtle/N-Triples use the default graph, and dataset formats must contain only default-graph quads. These write graph facts, distinct from lbb_configure's native schema metadata. A first RDF write selects RDF-native storage, which refuses later property-graph commits; choose the write workflow before bootstrap. Retries deduplicate by content unless idempotency_key is supplied. A completed write schedules publication; inspect action=publication and verify using lbb_query entailment=owl.",
43
+ export function registerRdfTool(host, client) {
44
+ host.register("lbb_rdf", {
45
+ description: `Store and extend complete RDF/OWL documents through MCP. import accepts Turtle/N-Triples/N-Quads/TriG without conversion; update executes INSERT DATA for additive edits. Replacing/removing axioms is unsupported; use a new versioned LBB graph for a revised document. Named RDF graphs are unsupported; Turtle/N-Triples use the default graph, and dataset formats must contain only default-graph quads. These write graph facts, distinct from lbb_configure's native schema metadata. A first RDF write selects RDF-native storage, which refuses later property-graph commits; choose the write workflow before bootstrap. Retries deduplicate by content unless idempotency_key is supplied. A completed write schedules publication; inspect action=publication and verify using lbb_query entailment=owl. An update with DELETE, CLEAR, DROP, MOVE or COPY asks the person to approve it first; the question quotes the operations that remove. ${APPROVAL_TEXT}`,
39
46
  inputSchema: advertiseUnion("action", input),
40
47
  annotations: {
41
48
  readOnlyHint: false,
@@ -43,11 +50,18 @@ export function registerRdfTool(server, client) {
43
50
  idempotentHint: true,
44
51
  openWorldHint: false,
45
52
  },
46
- }, (raw) => {
53
+ }, async (raw, extra) => {
47
54
  const parsed = input.safeParse(raw);
48
55
  if (!parsed.success)
49
56
  return errorResult(parsed.error);
50
57
  const args = parsed.data;
58
+ if (args.action === "update" && REMOVING_UPDATE.test(args.update)) {
59
+ const refused = await host.approve(extra, {
60
+ message: sparqlUpdateMessage(graphLabel(client, args.graph), args.update),
61
+ });
62
+ if (refused)
63
+ return refused;
64
+ }
51
65
  return run(client, `lbb_rdf.${args.action}`, args.detail, async () => {
52
66
  const target = scoped(client, args.graph);
53
67
  const { idempotency_key } = args;
@@ -0,0 +1,3 @@
1
+ import type { LbbClient } from "@littlebigbrain/client";
2
+ import { type ToolHost } from "./approval.js";
3
+ export declare function registerReviewsTools(host: ToolHost, client: LbbClient): void;
@@ -0,0 +1,420 @@
1
+ import { z } from "zod";
2
+ import { APPROVAL_TEXT, graphLabel, reviewAnswerMessage, } from "./approval.js";
3
+ import { MUTATING, READ_ONLY, detailSchema, graphScope, regionSchema, reviewAnswerSchema, suggestedFactsBody, suggestedFactsSchema, } from "./tool-contracts.js";
4
+ import { enrichError, errorResult, pageLimit, run, runPage, scoped, } from "./tool-runtime.js";
5
+ /** The most items `GET /v1/reviews` returns in one page. */
6
+ const REVIEWS_PAGE_MAX = 500;
7
+ /** Reads of one answer's items (and their changes), at most this many at a time. */
8
+ const READS_AT_ONCE = 8;
9
+ /** `fn` over `items`, at most `limit` at a time, results in order. */
10
+ async function mapLimit(items, limit, fn) {
11
+ const results = new Array(items.length);
12
+ let next = 0;
13
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
14
+ while (next < items.length) {
15
+ const i = next;
16
+ next += 1;
17
+ results[i] = await fn(items[i]);
18
+ }
19
+ }));
20
+ return results;
21
+ }
22
+ /**
23
+ * Whether an answer to `item` commits facts or applies an ontology change:
24
+ * the one rule for which answers ask for approval. An item Evals filed (a
25
+ * grade or a choice) commits nothing: its answer is its record's label.
26
+ */
27
+ function commits(item) {
28
+ return (item.status === "open" &&
29
+ item.scope !== "test" &&
30
+ (item.kind === "value" || item.kind === "change"));
31
+ }
32
+ /** The producer of an item an agent files without its own `origin`. */
33
+ const MCP_ORIGIN = {
34
+ kind: "agent",
35
+ id: "mcp agent",
36
+ label: "mcp agent",
37
+ };
38
+ const kinds = ["change", "value", "choice", "grade", "answer"];
39
+ const KINDS_TEXT = "Kinds and their decisions: change (accept an ontology change, identities or facts: accept, reject, cant_tell), value (confirm one value of a property, question.property and question.value: accept, correct with the right value, reject, cant_tell; accept and correct commit the value), choice (pick one of question.options: choose with option, cant_tell), grade (grade question.output on the scale question.options: choose with option, cant_tell), answer (answer question.prompt in words: accept with the text in note, cant_tell). Status is open, answered or withdrawn. Scope is live (default) or test: a test item writes nothing.";
40
+ const questionSchema = z
41
+ .object({
42
+ property: z
43
+ .string()
44
+ .optional()
45
+ .describe("value: the property the value is for"),
46
+ value: z
47
+ .union([z.number(), z.string(), z.boolean()])
48
+ .optional()
49
+ .describe("value: the proposed value"),
50
+ unit: z.string().optional().describe("value: the unit of a number"),
51
+ confidence: z
52
+ .number()
53
+ .min(0)
54
+ .max(1)
55
+ .optional()
56
+ .describe("Your confidence in the proposal, 0 to 1"),
57
+ options: z
58
+ .array(z
59
+ .object({
60
+ id: z.string().describe("1 to 64 characters of [A-Za-z0-9-_.:]"),
61
+ label: z.string().max(200),
62
+ })
63
+ .strict())
64
+ .optional()
65
+ .describe("choice: the options; grade: the grades"),
66
+ proposed: z
67
+ .string()
68
+ .optional()
69
+ .describe("choice, grade: the id of the option you propose"),
70
+ output: z.string().optional().describe("grade: the output to grade"),
71
+ prompt: z.string().optional().describe("answer: the question in words"),
72
+ })
73
+ .strict();
74
+ export function registerReviewsTools(host, client) {
75
+ host.register("lbb_reviews", {
76
+ description: `Review items, read only: questions for a person (human in the loop) that workflows, integrations, agents and people file. ${KINDS_TEXT} list returns one page as the server sends it ({object, data, has_more, next_cursor}), the latest update first: pass next_cursor as cursor, with the same filters, for the next page. get reads one item (rev_…) with its question, evidence, answer and effects. patterns groups the live items that ask one decision, the largest first. Evals files the questions of model checks (origin_id evals:checks), calibration sessions (evals:calibration) and golden queries (evals:golden) as grade and choice items; eval_id keeps the items of one record, and their answer is the record's label. File, answer, comment on or withdraw items with lbb_reviews_manage.`,
77
+ inputSchema: {
78
+ action: z.enum(["list", "get", "patterns"]),
79
+ status: z
80
+ .enum(["open", "answered", "withdrawn"])
81
+ .optional()
82
+ .describe("list, patterns: only the items in this state."),
83
+ kind: z
84
+ .union([z.enum(kinds), z.array(z.enum(kinds)).min(1)])
85
+ .optional()
86
+ .describe("list: one kind, or a list of kinds."),
87
+ scope: z
88
+ .enum(["live", "test"])
89
+ .optional()
90
+ .describe("list: live (the default) or test."),
91
+ origin_id: z
92
+ .string()
93
+ .optional()
94
+ .describe("list, patterns: the producer's id, for example starter:areas."),
95
+ eval_id: z
96
+ .string()
97
+ .optional()
98
+ .describe("list, patterns: the items Evals filed for one record (eval.id): a calibration session, a model call's check, a golden."),
99
+ workflow: z
100
+ .string()
101
+ .optional()
102
+ .describe("list: the items of the workflow that asked."),
103
+ run: z
104
+ .string()
105
+ .optional()
106
+ .describe("list: the items of the run that asked."),
107
+ key: z
108
+ .union([z.string(), z.array(z.string()).min(1).max(100)])
109
+ .optional()
110
+ .describe("list: the items filed under this producer key, or these keys (at most 100)."),
111
+ limit: z
112
+ .number()
113
+ .int()
114
+ .min(1)
115
+ .max(REVIEWS_PAGE_MAX)
116
+ .optional()
117
+ .describe("list: items per page, at most 5, 20 or 100 by detail (compact, standard, full) and by default that many, so the page comes back whole."),
118
+ cursor: z
119
+ .string()
120
+ .optional()
121
+ .describe("list: the next_cursor of the page before."),
122
+ id: z
123
+ .string()
124
+ .optional()
125
+ .describe("get: the item's id, rev_… (or the sg_… of a change)."),
126
+ type: z
127
+ .enum(["facts", "links", "ontology", "other"])
128
+ .optional()
129
+ .describe("patterns: only the patterns of this kind of suggestion."),
130
+ detail: detailSchema,
131
+ ...graphScope,
132
+ },
133
+ annotations: READ_ONLY,
134
+ }, (args) => {
135
+ const reviews = scoped(client, args.graph).reviews;
136
+ if (args.action === "list") {
137
+ return runPage(client, "lbb_reviews.list", args.detail, pageLimit(args.detail, args.limit, REVIEWS_PAGE_MAX), (limit) => reviews.list({
138
+ status: args.status,
139
+ kind: args.kind,
140
+ scope: args.scope,
141
+ originId: args.origin_id,
142
+ workflow: args.workflow,
143
+ run: args.run,
144
+ key: args.key,
145
+ evalId: args.eval_id,
146
+ limit,
147
+ cursor: args.cursor,
148
+ }));
149
+ }
150
+ const action = args.action;
151
+ return run(client, `lbb_reviews.${action}`, args.detail, () => {
152
+ switch (action) {
153
+ case "get":
154
+ if (!args.id)
155
+ throw new Error("get requires id");
156
+ return reviews.get(args.id);
157
+ case "patterns":
158
+ return reviews.patterns({
159
+ status: args.status,
160
+ originId: args.origin_id,
161
+ type: args.type,
162
+ evalId: args.eval_id,
163
+ });
164
+ }
165
+ });
166
+ });
167
+ host.register("lbb_reviews_manage", {
168
+ description: `File, answer, comment on or withdraw review items. Show the item to the user and answer only what the user decided. ${KINDS_TEXT} An answer that accepts or corrects a value item, or accepts a change item, commits facts or applies an ontology change (which can remove types and relations): the server reads the items and asks the person to approve first, with the count and the effect. Any other answer, such as the grade or choice of an item Evals filed, asks nothing. ${APPROVAL_TEXT} create files an item of kind value, choice, grade or answer (kind, title, question, and optionally subject, evidence, rationale, key, scope, facts, origin); the same key again updates it, and an answered item comes back unchanged. File an ontology change with lbb_configure action=suggest_ontology_change. answer records the person's answer (decision, with value for correct, option for choose, note). An item has one answer: the same answer again returns the item, another one answers 409 review_answered. revision is the revision the person saw; a later one answers 409 review_stale. answer_batch gives one answer to up to 500 ids. comment adds text to an item's discussion. withdraw takes back an item you filed, with a reason. Each write sends an idempotency key, and a retry after a network failure or a 5xx sends the same key, so the write happens once.`,
169
+ inputSchema: {
170
+ action: z.enum([
171
+ "create",
172
+ "answer",
173
+ "answer_batch",
174
+ "comment",
175
+ "withdraw",
176
+ ]),
177
+ id: z
178
+ .string()
179
+ .optional()
180
+ .describe("answer, comment, withdraw: the item's id (rev_…)."),
181
+ ids: z
182
+ .array(z.string())
183
+ .min(1)
184
+ .max(500)
185
+ .optional()
186
+ .describe("answer_batch: 1 to 500 item ids."),
187
+ answer: reviewAnswerSchema
188
+ .optional()
189
+ .describe("answer, answer_batch: the answer, { decision, value, option, note }."),
190
+ revision: z
191
+ .number()
192
+ .int()
193
+ .nonnegative()
194
+ .optional()
195
+ .describe("answer: the revision of the item the person saw."),
196
+ text: z
197
+ .string()
198
+ .min(1)
199
+ .max(4000)
200
+ .optional()
201
+ .describe("comment: the comment, 1 to 4,000 characters."),
202
+ author: z.string().optional().describe("comment: who writes it."),
203
+ reason: z
204
+ .string()
205
+ .min(1)
206
+ .max(1000)
207
+ .optional()
208
+ .describe("withdraw: why, at most 1,000 characters."),
209
+ kind: z
210
+ .enum(["value", "choice", "grade", "answer"])
211
+ .optional()
212
+ .describe("create: the kind of question."),
213
+ title: z
214
+ .string()
215
+ .min(1)
216
+ .max(200)
217
+ .optional()
218
+ .describe("create: the question in words, at most 200 characters."),
219
+ question: questionSchema
220
+ .optional()
221
+ .describe("create: what the item asks, by kind."),
222
+ subject: z
223
+ .object({
224
+ type: z.string().optional().describe("Its class"),
225
+ name: z.string().optional().describe("Its display name"),
226
+ key: z.string().optional().describe("Its stable import key"),
227
+ iri: z.string().optional().describe("Its IRI"),
228
+ })
229
+ .strict()
230
+ .optional()
231
+ .describe("create: the record the question is about."),
232
+ evidence: z
233
+ .array(z
234
+ .object({
235
+ quote: z.string(),
236
+ source_id: z
237
+ .string()
238
+ .nullish()
239
+ .describe("The Document IRI with a place (#page=3, #row=5), or a record's name"),
240
+ confidence: z.number().min(0).max(1).nullish(),
241
+ region: regionSchema.nullish(),
242
+ })
243
+ .passthrough())
244
+ .optional()
245
+ .describe("create: the quotes you read, and where they are."),
246
+ evidence_hash: z
247
+ .string()
248
+ .max(128)
249
+ .optional()
250
+ .describe("create: a mark of the evidence (a document version, a hash of the text). Asking again with other evidence and another proposal reopens an answered item."),
251
+ facts: suggestedFactsSchema
252
+ .optional()
253
+ .describe("create, a value item: the facts that accept or correct commits, as lbb_commit takes them (at most 200 triplets and property rows together)."),
254
+ key: z
255
+ .string()
256
+ .max(255)
257
+ .optional()
258
+ .describe("create: your key for the item; the same key again updates it. Default: from the kind, subject and question."),
259
+ origin: z
260
+ .object({
261
+ kind: z.enum(["integration", "agent", "person"]),
262
+ id: z.string().optional(),
263
+ label: z.string().optional(),
264
+ })
265
+ .strict()
266
+ .optional()
267
+ .describe('create: the producer. Default {"kind": "agent", "id": "mcp agent"}.'),
268
+ rationale: z
269
+ .string()
270
+ .max(4000)
271
+ .optional()
272
+ .describe("create: why a person sees it, at most 4,000 characters."),
273
+ scope: z
274
+ .enum(["live", "test"])
275
+ .optional()
276
+ .describe("create: live (default) or test (writes nothing)."),
277
+ workflow: z
278
+ .object({
279
+ name: z.string(),
280
+ run: z.string().optional(),
281
+ step: z.string().optional(),
282
+ identity: z.string().optional(),
283
+ turn: z.number().int().nonnegative().optional(),
284
+ })
285
+ .strict()
286
+ .optional()
287
+ .describe("create: the workflow run and step that asks."),
288
+ eval: z
289
+ .object({
290
+ kind: z.enum(["check", "calibration", "test"]),
291
+ id: z.string(),
292
+ })
293
+ .strict()
294
+ .optional()
295
+ .describe("create: the model check, calibration or test that asks."),
296
+ wait: z
297
+ .object({
298
+ identity: z.string(),
299
+ turn: z.number().int().nonnegative(),
300
+ })
301
+ .strict()
302
+ .optional()
303
+ .describe("create: the engine identity and turn of a run that waits for the answer as the signal review.<id>."),
304
+ detail: detailSchema,
305
+ ...graphScope,
306
+ },
307
+ // Accepting an item commits facts or applies an ontology change, which
308
+ // can remove types and relations, so the tool may destroy.
309
+ annotations: { ...MUTATING, destructiveHint: true },
310
+ }, async (args, extra) => {
311
+ const decision = args.answer?.decision;
312
+ if ((args.action === "answer" || args.action === "answer_batch") &&
313
+ (decision === "accept" || decision === "correct")) {
314
+ const ids = [
315
+ ...new Set(args.action === "answer"
316
+ ? args.id
317
+ ? [args.id]
318
+ : []
319
+ : (args.ids ?? [])),
320
+ ];
321
+ const target = scoped(client, args.graph);
322
+ let items;
323
+ try {
324
+ items = await mapLimit(ids, READS_AT_ONCE, (id) => target.reviews.get(id));
325
+ }
326
+ catch (error) {
327
+ return errorResult(await enrichError(client, error));
328
+ }
329
+ const committing = items.filter(commits);
330
+ if (committing.length) {
331
+ const refused = await host.approve(extra, {
332
+ message: async () => {
333
+ const changes = new Map();
334
+ await mapLimit(committing.filter((item) => item.kind === "change"), READS_AT_ONCE, async (item) => {
335
+ try {
336
+ const suggestion = await target.ontology.suggestions.get(item.suggestion_id ?? item.id.replace(/^rev_/, "sg_"));
337
+ changes.set(item.id, {
338
+ ops: suggestion.change ?? [],
339
+ facts: suggestion.facts,
340
+ });
341
+ }
342
+ catch {
343
+ changes.set(item.id, undefined);
344
+ }
345
+ });
346
+ return reviewAnswerMessage(graphLabel(client, args.graph), { decision, value: args.answer?.value }, committing, changes, ids.length - committing.length);
347
+ },
348
+ });
349
+ if (refused)
350
+ return refused;
351
+ }
352
+ }
353
+ return run(client, `lbb_reviews_manage.${args.action}`, args.detail, () => {
354
+ const target = scoped(client, args.graph);
355
+ const opts = {
356
+ idempotencyKey: target.idempotencyKey(`reviews.${args.action}`),
357
+ };
358
+ const id = () => {
359
+ if (!args.id)
360
+ throw new Error(`${args.action} requires id`);
361
+ return args.id;
362
+ };
363
+ const answer = () => {
364
+ if (!args.answer)
365
+ throw new Error(`${args.action} requires answer`);
366
+ return args.answer;
367
+ };
368
+ switch (args.action) {
369
+ case "create": {
370
+ if (!args.kind || !args.title || !args.question) {
371
+ throw new Error("create requires kind, title and question");
372
+ }
373
+ const body = {
374
+ kind: args.kind,
375
+ title: args.title,
376
+ question: args.question,
377
+ origin: args.origin ?? MCP_ORIGIN,
378
+ ...(args.subject ? { subject: args.subject } : {}),
379
+ ...(args.evidence ? { evidence: args.evidence } : {}),
380
+ ...(args.evidence_hash
381
+ ? { evidence_hash: args.evidence_hash }
382
+ : {}),
383
+ ...(args.facts
384
+ ? { facts: suggestedFactsBody(args.facts) }
385
+ : {}),
386
+ ...(args.key ? { key: args.key } : {}),
387
+ ...(args.rationale ? { rationale: args.rationale } : {}),
388
+ ...(args.scope ? { scope: args.scope } : {}),
389
+ ...(args.workflow ? { workflow: args.workflow } : {}),
390
+ ...(args.eval ? { eval: args.eval } : {}),
391
+ ...(args.wait ? { wait: args.wait } : {}),
392
+ };
393
+ return target.reviews.create(body, opts);
394
+ }
395
+ case "answer":
396
+ return target.reviews.answer(id(), {
397
+ answer: answer(),
398
+ ...(args.revision !== undefined
399
+ ? { revision: args.revision }
400
+ : {}),
401
+ }, opts);
402
+ case "answer_batch":
403
+ if (!args.ids)
404
+ throw new Error("answer_batch requires ids");
405
+ return target.reviews.answerBatch({ ids: args.ids, answer: answer() }, opts);
406
+ case "comment":
407
+ if (!args.text)
408
+ throw new Error("comment requires text");
409
+ return target.reviews.comment(id(), {
410
+ text: args.text,
411
+ ...(args.author ? { author: args.author } : {}),
412
+ }, opts);
413
+ case "withdraw":
414
+ if (!args.reason)
415
+ throw new Error("withdraw requires reason");
416
+ return target.reviews.withdraw(id(), args.reason, opts);
417
+ }
418
+ });
419
+ });
420
+ }
@@ -0,0 +1,15 @@
1
+ /** One operation of an update. */
2
+ export interface UpdateOperation {
3
+ /** Its form: DELETE DATA, DELETE WHERE, DELETE/INSERT, INSERT DATA, CLEAR, … */
4
+ form: string;
5
+ /** Its text from its first keyword on, without its PREFIX and BASE lines. */
6
+ text: string;
7
+ /** Whether it can remove statements. */
8
+ removes: boolean;
9
+ }
10
+ /**
11
+ * Split an update into its operations. Each one's top-level words (at
12
+ * brace depth 0) give its form; the words inside `{ … }` are data or
13
+ * patterns.
14
+ */
15
+ export declare function updateOperations(update: string): UpdateOperation[];
@@ -0,0 +1,146 @@
1
+ // The operations of a SPARQL Update request, read well enough to name the
2
+ // ones that remove statements in an approval question. The server parses
3
+ // the update; this reader only splits it at its top-level `;`, skips
4
+ // strings, IRIs and comments, and reads each operation's first keywords.
5
+ /** The characters an IRI reference cannot hold (SPARQL 1.1, IRIREF). */
6
+ // eslint-disable-next-line no-control-regex -- IRIREF excludes the control characters by grammar
7
+ const IRI = /^<[^<>"{}|^`\\\u0000-\u0020]*>/;
8
+ /** Skip a string literal at `i`; returns the index after it. */
9
+ function skipString(text, i) {
10
+ const quote = text[i];
11
+ const long = text.startsWith(quote.repeat(3), i);
12
+ let j = i + (long ? 3 : 1);
13
+ while (j < text.length) {
14
+ if (text[j] === "\\") {
15
+ j += 2;
16
+ continue;
17
+ }
18
+ if (long ? text.startsWith(quote.repeat(3), j) : text[j] === quote) {
19
+ return j + (long ? 3 : 1);
20
+ }
21
+ // A short string ends at its line.
22
+ if (!long && (text[j] === "\n" || text[j] === "\r"))
23
+ return j;
24
+ j += 1;
25
+ }
26
+ return j;
27
+ }
28
+ /**
29
+ * Split an update into its operations. Each one's top-level words (at
30
+ * brace depth 0) give its form; the words inside `{ … }` are data or
31
+ * patterns.
32
+ */
33
+ export function updateOperations(update) {
34
+ const operations = [];
35
+ let depth = 0;
36
+ let words = [];
37
+ const finish = (end) => {
38
+ const operation = classify(update, words, end);
39
+ if (operation)
40
+ operations.push(operation);
41
+ words = [];
42
+ };
43
+ let i = 0;
44
+ while (i < update.length) {
45
+ const c = update[i];
46
+ if (c === "#") {
47
+ while (i < update.length && update[i] !== "\n")
48
+ i += 1;
49
+ continue;
50
+ }
51
+ if (c === '"' || c === "'") {
52
+ i = skipString(update, i);
53
+ continue;
54
+ }
55
+ if (c === "<") {
56
+ const iri = IRI.exec(update.slice(i, i + 4_096));
57
+ if (iri) {
58
+ if (depth === 0)
59
+ words.push({ word: iri[0], start: i });
60
+ i += iri[0].length;
61
+ continue;
62
+ }
63
+ i += 1;
64
+ continue;
65
+ }
66
+ if (c === "{") {
67
+ // A top-level brace tells DELETE WHERE { … } from DELETE { … } WHERE.
68
+ if (depth === 0)
69
+ words.push({ word: "{", start: i });
70
+ depth += 1;
71
+ }
72
+ else if (c === "}")
73
+ depth = Math.max(0, depth - 1);
74
+ else if (c === ";" && depth === 0) {
75
+ finish(i);
76
+ i += 1;
77
+ continue;
78
+ }
79
+ if (/[A-Za-z_:]/.test(c)) {
80
+ let j = i + 1;
81
+ while (j < update.length && /[A-Za-z0-9_:.-]/.test(update[j]))
82
+ j += 1;
83
+ if (depth === 0)
84
+ words.push({ word: update.slice(i, j), start: i });
85
+ i = j;
86
+ continue;
87
+ }
88
+ i += 1;
89
+ }
90
+ finish(update.length);
91
+ return operations;
92
+ }
93
+ /** An update form that removes statements, by its first keyword. */
94
+ const REMOVING = new Set(["DELETE", "CLEAR", "DROP", "MOVE", "COPY"]);
95
+ function classify(update, words, end) {
96
+ let k = 0;
97
+ // The prologue: PREFIX name: <iri> and BASE <iri>.
98
+ while (k < words.length) {
99
+ const word = words[k].word.toUpperCase();
100
+ if (word === "PREFIX")
101
+ k += 3;
102
+ else if (word === "BASE")
103
+ k += 2;
104
+ else
105
+ break;
106
+ }
107
+ if (k >= words.length)
108
+ return undefined;
109
+ const begin = words[k].start;
110
+ const text = update.slice(begin, end).trim();
111
+ const upper = words.slice(k).map((w) => w.word.toUpperCase());
112
+ let first = upper[0];
113
+ let rest = upper.slice(1);
114
+ let prefix = "";
115
+ if (first === "WITH") {
116
+ // WITH <iri> DELETE … or WITH <iri> INSERT …
117
+ prefix = "WITH ";
118
+ first = upper[2] ?? "";
119
+ rest = upper.slice(3);
120
+ }
121
+ let form = `${prefix}${first}`;
122
+ if (first === "INSERT" || first === "DELETE") {
123
+ if (rest[0] === "DATA" || rest[0] === "WHERE")
124
+ form += ` ${rest[0]}`;
125
+ else if (first === "DELETE" && rest.includes("INSERT"))
126
+ form = `${prefix}DELETE/INSERT`;
127
+ }
128
+ else if (first === "CLEAR" || first === "DROP") {
129
+ form += rest[0] === "SILENT" ? ` SILENT` : "";
130
+ }
131
+ const removes = REMOVING.has(first) ||
132
+ // An unknown form that names a removing keyword at its top level.
133
+ (!KNOWN.has(first) && upper.some((word) => REMOVING.has(word)));
134
+ return { form, text, removes };
135
+ }
136
+ const KNOWN = new Set([
137
+ "INSERT",
138
+ "DELETE",
139
+ "LOAD",
140
+ "CLEAR",
141
+ "DROP",
142
+ "CREATE",
143
+ "ADD",
144
+ "MOVE",
145
+ "COPY",
146
+ ]);