@protobase/server 0.51.3 → 0.52.1

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.
Files changed (97) hide show
  1. package/dist/aggregate-groups.js +2 -2
  2. package/dist/aggregate-service.js +4 -4
  3. package/dist/aggregate-splits.js +1 -1
  4. package/dist/assistant/batch-approval.js +49 -51
  5. package/dist/assistant/batch-before.js +38 -0
  6. package/dist/assistant/batch-echo.js +36 -0
  7. package/dist/assistant/batch-write-args.js +14 -5
  8. package/dist/assistant/batch-write-tool.js +30 -11
  9. package/dist/assistant/budgeted-page.js +30 -0
  10. package/dist/assistant/comment-tools.d.ts +20 -0
  11. package/dist/assistant/comment-tools.js +120 -0
  12. package/dist/assistant/count-records-tool.d.ts +5 -0
  13. package/dist/assistant/count-records-tool.js +84 -0
  14. package/dist/assistant/query-result-budget.js +32 -0
  15. package/dist/assistant/query-tools.js +7 -7
  16. package/dist/assistant/record-before.js +2 -1
  17. package/dist/assistant/record-field-piece.js +54 -0
  18. package/dist/assistant/record-tool-args.js +6 -3
  19. package/dist/assistant/record-tools.d.ts +4 -1
  20. package/dist/assistant/record-tools.js +17 -6
  21. package/dist/assistant/records.d.ts +19 -0
  22. package/dist/assistant/records.js +20 -13
  23. package/dist/assistant/result-budget.js +72 -0
  24. package/dist/assistant/tools.d.ts +3 -0
  25. package/dist/assistant/write-echo.js +41 -0
  26. package/dist/assistant/write-messages.js +26 -6
  27. package/dist/assistant/write-prompts.js +37 -12
  28. package/dist/assistant/write-record-tools.js +15 -6
  29. package/dist/batch/run-batch.js +1 -1
  30. package/dist/better-auth/auth-settings.d.ts +1 -1
  31. package/dist/better-auth/status-route.js +2 -2
  32. package/dist/changes/capture-schema.js +1 -1
  33. package/dist/changes/resource-change.d.ts +4 -1
  34. package/dist/changes/resource-change.js +27 -3
  35. package/dist/comments/post-comment.js +1 -1
  36. package/dist/comments/read-comments.js +1 -1
  37. package/dist/comments/tool-comment-types.d.ts +28 -0
  38. package/dist/comments/tool-comments.js +30 -0
  39. package/dist/create-admin.d.ts +5 -2
  40. package/dist/create-admin.js +20 -10
  41. package/dist/deps.d.ts +1 -1
  42. package/dist/encryption/scan.js +1 -1
  43. package/dist/error-handler.js +3 -2
  44. package/dist/etag.js +1 -1
  45. package/dist/files/download.js +1 -1
  46. package/dist/files/file-cleanup.d.ts +1 -1
  47. package/dist/functions/records.d.ts +1 -1
  48. package/dist/functions/records.js +2 -2
  49. package/dist/history/read-history.js +2 -2
  50. package/dist/index.d.ts +9 -3
  51. package/dist/index.js +9 -4
  52. package/dist/jobs/run-job.d.ts +1 -1
  53. package/dist/list-photos.js +2 -2
  54. package/dist/list-service.js +6 -6
  55. package/dist/map-service.js +2 -2
  56. package/dist/mcp/mcp-context.d.ts +7 -4
  57. package/dist/mcp/mcp-context.js +6 -4
  58. package/dist/mcp/mcp-server.d.ts +3 -2
  59. package/dist/mcp/mcp-tools.d.ts +6 -1
  60. package/dist/mcp/mcp-tools.js +8 -4
  61. package/dist/package.js +1 -1
  62. package/dist/read-service.js +2 -2
  63. package/dist/realtime/broadcast-api.js +3 -3
  64. package/dist/realtime/broadcast-hub.js +15 -5
  65. package/dist/realtime/change-checker.js +1 -1
  66. package/dist/realtime/channel-access.js +1 -1
  67. package/dist/realtime/database-broadcasts.js +1 -1
  68. package/dist/realtime/presence-hub.js +74 -39
  69. package/dist/realtime/realtime-app.js +28 -4
  70. package/dist/realtime/realtime-routes.js +3 -5
  71. package/dist/realtime/realtime-socket.js +3 -1
  72. package/dist/realtime/relay/frame-cursor.js +38 -0
  73. package/dist/realtime/relay/presence-replica.js +57 -0
  74. package/dist/realtime/relay/realtime-options.d.ts +13 -0
  75. package/dist/realtime/relay/realtime-options.js +9 -0
  76. package/dist/realtime/relay/realtime-relay.js +104 -0
  77. package/dist/realtime/relay/relay-frame-sql.js +82 -0
  78. package/dist/realtime/relay/relay-presence-sql.js +71 -0
  79. package/dist/realtime/relay/relay-schema-sql.js +55 -0
  80. package/dist/realtime/relay/relay-schema.d.ts +7 -0
  81. package/dist/realtime/relay/relay-schema.js +18 -0
  82. package/dist/realtime/relay/relayed-hubs.js +61 -0
  83. package/dist/records.js +1 -1
  84. package/dist/related-labels.js +1 -1
  85. package/dist/rest/rest-context.js +1 -1
  86. package/dist/routes/batch.js +1 -1
  87. package/dist/routes/collection.js +2 -2
  88. package/dist/routes/record.js +3 -3
  89. package/dist/row-permissions.js +1 -1
  90. package/dist/sql-parts.js +1 -1
  91. package/dist/web-app/mcp-icons.d.ts +9 -0
  92. package/dist/web-app/mcp-icons.js +26 -0
  93. package/dist/webhooks/subscriber-view.js +27 -2
  94. package/dist/webhooks/webhooks.js +41 -12
  95. package/dist/write-pipeline.js +1 -1
  96. package/dist/write-shared.js +1 -1
  97. package/package.json +4 -4
@@ -1,8 +1,8 @@
1
1
  import { badRequest } from "./problem.js";
2
- import { readTransaction } from "./transactions.js";
3
- import { parseParams } from "./params.js";
4
2
  import { labelRequests, relatedLabels } from "./related-labels.js";
3
+ import { readTransaction } from "./transactions.js";
5
4
  import { splitGroups } from "./aggregate-splits.js";
5
+ import { parseParams } from "./params.js";
6
6
  import { scoped, seriesRange } from "./aggregate-service.js";
7
7
  import { z } from "zod";
8
8
  import { groupTotals, series } from "@protobase/query";
@@ -1,15 +1,15 @@
1
1
  import { badRequest } from "./problem.js";
2
2
  import { checkedFilter } from "./filter-input.js";
3
3
  import { requestAccess, requireOperation } from "./request-access.js";
4
- import { readTransaction } from "./transactions.js";
5
- import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
6
- import { parseParams } from "./params.js";
7
4
  import { readScope } from "./read-scope.js";
8
5
  import { labelRequests, relatedLabels } from "./related-labels.js";
6
+ import { readTransaction } from "./transactions.js";
7
+ import { parseParams } from "./params.js";
9
8
  import { rangeEnd, rangeStart } from "./range-ends.js";
9
+ import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
10
10
  import { z } from "zod";
11
- import { sql } from "kysely";
12
11
  import { facetCounts, histogram, series, tenantCondition, withoutField } from "@protobase/query";
12
+ import { sql } from "kysely";
13
13
  //#region src/aggregate-service.ts
14
14
  const field = z.string().min(1);
15
15
  const filter = z.string().optional();
@@ -1,5 +1,5 @@
1
- import { readTransaction } from "./transactions.js";
2
1
  import { labelRequests, relatedLabels } from "./related-labels.js";
2
+ import { readTransaction } from "./transactions.js";
3
3
  import { series, splitSeries } from "@protobase/query";
4
4
  //#region src/aggregate-splits.ts
5
5
  /**
@@ -1,59 +1,57 @@
1
- const shownResources = 20;
2
- const valueLength = 80;
3
- const nameLength = 60;
4
- const unicodeEscape = (char) => {
5
- const code = char.codePointAt(0);
6
- return code > 65535 ? `\\u{${code.toString(16)}}` : `\\u${code.toString(16).padStart(4, "0")}`;
7
- };
8
- const escaped = (json) => json.replace(/[\p{Cc}\p{Cf}\u2028\u2029`<>]/gu, unicodeEscape).replace(/"(?:[^"\\]|\\.)*"/g, (text) => text.replace(/[[\]]/g, unicodeEscape));
9
- const cut = (text, length) => text.length > length ? `${text.slice(0, length - 1)}…` : text;
10
- const quoted = (value, length) => cut(escaped(JSON.stringify(value) ?? "null"), length);
11
- const plural = (count, word) => `${count} ${word}${count === 1 ? "" : "s"}`;
12
- const counted = (value, length) => {
13
- const text = escaped(JSON.stringify(value) ?? "null");
14
- return text.length > length ? `${text.slice(0, length)}… (${plural(text.length - length, "more character")})` : text;
15
- };
16
- const fieldsOf = (data) => `{${Object.entries(data).map(([name, value]) => `${counted(name, nameLength)}:${counted(value, valueLength)}`).join(",")}}`;
17
- const lineOf = (operation, index) => {
18
- const target = operation.op === "create" ? quoted(operation.resource, nameLength) : `${quoted(operation.resource, nameLength)} ${quoted(operation.key, nameLength)}`;
19
- const values = operation.op === "delete" ? "" : ` ${fieldsOf(operation.data)}`;
20
- return `${index + 1}. ${operation.op} ${target}${values}`;
21
- };
22
- const countsOf = (operations) => {
23
- const counts = /* @__PURE__ */ new Map();
24
- for (const { op, resource } of operations) {
25
- const known = counts.get(resource) ?? {
26
- create: 0,
27
- update: 0,
28
- delete: 0
1
+ import { createChange, recordName, resourceLabel, shown, updateChange } from "./write-prompts.js";
2
+ const isRef = (value) => typeof value === "object" && value !== null && typeof value.$ref === "string";
3
+ const refOfKey = (key) => Array.isArray(key) ? key.find(isRef) : isRef(key) ? key : void 0;
4
+ const joined = (fields, none) => fields.length > 0 ? fields.map(({ label, value }) => `${label}: ${value}`).join("; ") : none;
5
+ /** The approval of `operations` as one transaction. */
6
+ const batchApproval = ({ text, described, operations, before, softDeletes }) => {
7
+ const creates = new Map(operations.flatMap((operation, index) => operation.op === "create" && operation.ref ? [[operation.ref, {
8
+ index,
9
+ resource: operation.resource
10
+ }]] : []));
11
+ const refName = ({ $ref }) => {
12
+ const created = creates.get($ref);
13
+ return created ? text.fromWrite(resourceLabel(described(created.resource)), created.index + 1) : `"${shown($ref)}"`;
14
+ };
15
+ const dataOf = (data) => Object.fromEntries(Object.entries(data).map(([name, value]) => [name, isRef(value) ? refName(value) : value]));
16
+ const nameOf = (operation, index) => {
17
+ const ref = refOfKey(operation.key);
18
+ return ref ? refName(ref) : recordName(described(operation.resource), operation.key, before.get(index) ?? {});
19
+ };
20
+ const entryOf = (operation, index) => {
21
+ const resource = described(operation.resource);
22
+ const label = (action) => `${index + 1}. ${action}`;
23
+ if (operation.op === "create") {
24
+ const { action, fields } = createChange(text, resource, dataOf(operation.data));
25
+ return {
26
+ label: label(action),
27
+ value: joined(fields, text.noFields)
28
+ };
29
+ }
30
+ if (operation.op === "update") {
31
+ const { action, fields } = updateChange(text, resource, nameOf(operation, index), before.get(index) ?? {}, dataOf(operation.data));
32
+ return {
33
+ label: label(action),
34
+ value: joined(fields, text.noChanges)
35
+ };
36
+ }
37
+ return {
38
+ label: label(text.deleteAction(nameOf(operation, index))),
39
+ value: softDeletes.has(operation.resource) ? text.restorable : text.permanent
29
40
  };
30
- known[op] += 1;
31
- counts.set(resource, known);
32
- }
33
- return [...counts];
34
- };
35
- const capped = (lines) => lines.length > shownResources ? [...lines.slice(0, shownResources), `and ${plural(lines.length - shownResources, "more resource")}`] : lines;
36
- /**
37
- * The approval of `operations`; `softDeletes` names the resources whose deletes can be restored, and a delete on any
38
- * other is said to be permanent.
39
- */
40
- const batchApproval = (operations, softDeletes) => {
41
- const counts = countsOf(operations);
42
- const permanent = counts.filter(([resource, { delete: deletes }]) => deletes > 0 && !softDeletes.has(resource));
43
- const lasting = permanent.reduce((sum, [, { delete: deletes }]) => sum + deletes, 0);
44
- const warning = lasting > 0 ? [`${plural(lasting, "delete")} cannot be undone:`, ...capped(permanent.map(([resource, { delete: deletes }]) => `${plural(deletes, "delete")} in ${quoted(resource, nameLength)}`))] : [];
45
- const summary = capped(counts.map(([resource, count]) => `${quoted(resource, nameLength)}: ${plural(count.create, "create")}, ${plural(count.update, "update")}, ${plural(count.delete, "delete")}`));
41
+ };
42
+ const permanent = operations.filter((operation) => operation.op === "delete" && !softDeletes.has(operation.resource)).length;
46
43
  const rest = operations.length - 50;
47
- const lines = operations.slice(0, 50).map(lineOf);
48
44
  return {
49
- title: `Run ${plural(operations.length, "write")} in one transaction?`,
45
+ title: text.batchTitle(operations.length),
50
46
  body: [
51
- ...warning,
52
- "Either all of them are written or none.",
53
- ...summary
47
+ ...permanent > 0 ? [text.permanentDeletes(permanent)] : [],
48
+ text.allOrNone,
49
+ ...rest > 0 ? [text.moreWrites(rest)] : []
54
50
  ].join("\n"),
55
- code: [...lines, ...rest > 0 ? [`and ${plural(rest, "more operation")}, counted above`] : []].join("\n"),
56
- approveLabel: "Write all"
51
+ fields: operations.slice(0, 50).map(entryOf),
52
+ tone: permanent > 0 ? "danger" : "warning",
53
+ approveLabel: text.writeAll,
54
+ rejectLabel: text.reject
57
55
  };
58
56
  };
59
57
  //#endregion
@@ -0,0 +1,38 @@
1
+ import { HttpProblem, preconditionFailed } from "../problem.js";
2
+ import { ifMatchSatisfied } from "../etag.js";
3
+ import { keyOf } from "./record-tool-args.js";
4
+ import { hidden } from "./record-before.js";
5
+ //#region src/assistant/batch-before.ts
6
+ const atOperation = (error, index, { op, resource }) => error instanceof HttpProblem ? new HttpProblem(error.status, error.slug, error.title, error.detail, {
7
+ ...error.extras,
8
+ operation: index,
9
+ op,
10
+ resource
11
+ }) : error;
12
+ const recordAt = async (context, operation, key, index) => {
13
+ const current = await context.records.get(operation.resource, key).catch((error) => {
14
+ if (hidden(error)) return void 0;
15
+ throw atOperation(error, index, operation);
16
+ });
17
+ if (current && operation.etag && !ifMatchSatisfied(operation.etag, current.etag)) throw atOperation(preconditionFailed("The record changed since it was read; fetch it again"), index, operation);
18
+ return current?.record;
19
+ };
20
+ /**
21
+ * The records the first `count` operations update or delete, by index, as the caller reads them now, for the approval
22
+ * to name and show what changes; one they may not read, or whose key points at a create in the batch, is left out. A
23
+ * record changed since its operation's etag rejects before asking, as the batch would fail after the user approved;
24
+ * so does any other refusal of the read, naming the operation.
25
+ */
26
+ const batchBefore = async (context, operations, count) => {
27
+ const before = /* @__PURE__ */ new Map();
28
+ for (const [index, operation] of operations.slice(0, count).entries()) {
29
+ if (operation.op === "create") continue;
30
+ const key = keyOf(operation.key);
31
+ if (key === void 0) continue;
32
+ const record = await recordAt(context, operation, key, index);
33
+ if (record) before.set(index, record);
34
+ }
35
+ return before;
36
+ };
37
+ //#endregion
38
+ export { batchBefore };
@@ -0,0 +1,36 @@
1
+ import { HttpProblem } from "../problem.js";
2
+ import { keyOf } from "./record-tool-args.js";
3
+ import { writeEcho } from "./write-echo.js";
4
+ //#region src/assistant/batch-echo.ts
5
+ const unread = (error) => {
6
+ if (error instanceof HttpProblem && error.status < 500) return void 0;
7
+ throw error;
8
+ };
9
+ /**
10
+ * Each update's record as the caller reads it before the batch, for the read-only fields the batch changes; undefined
11
+ * for a create, a delete, and an update of a record created earlier in the batch, whose key is a `$ref`.
12
+ */
13
+ const recordsBefore = async (context, operations) => {
14
+ const befores = [];
15
+ for (const operation of operations) {
16
+ const key = operation.op === "update" ? keyOf(operation.key) : void 0;
17
+ befores.push(key === void 0 ? void 0 : await context.records.get(operation.resource, key).then((current) => current.record, unread));
18
+ }
19
+ return befores;
20
+ };
21
+ /** The batch's results, each create's and update's record cut to its key, etag, written and changed read-only fields, and `fields`. */
22
+ const batchEcho = async (context, operations, results, befores, fields) => {
23
+ const { models } = await context.resources();
24
+ return results.map((result, index) => {
25
+ if (result.op !== "create" && result.op !== "update") return result;
26
+ const operation = operations[index];
27
+ const { record: _record, etag: _etag, ...rest } = result;
28
+ const model = models.find((candidate) => candidate.name === result.resource);
29
+ return {
30
+ ...rest,
31
+ ...writeEcho(model, result, operation.data, befores[index], fields[index])
32
+ };
33
+ });
34
+ };
35
+ //#endregion
36
+ export { batchEcho, recordsBefore };
@@ -1,22 +1,26 @@
1
+ import { echoFieldsOf } from "./write-echo.js";
1
2
  //#region src/assistant/batch-write-args.ts
2
3
  /**
3
4
  * The `batch_write` tool's arguments, checked before anything is asked or written: only creates, updates and deletes,
4
5
  * only the fields each takes, an update's ETag always given, and no more operations than the REST batch takes. A
5
- * delete's ETag passes exactly as sent, so one left out stays left out.
6
+ * delete's ETag passes exactly as sent, so one left out stays left out. A create's or update's `fields`, what to give
7
+ * back of its record, comes apart from the operation the REST batch runs.
6
8
  */
7
9
  const fieldsOf = {
8
10
  create: [
9
11
  "op",
10
12
  "resource",
11
13
  "data",
12
- "ref"
14
+ "ref",
15
+ "fields"
13
16
  ],
14
17
  update: [
15
18
  "op",
16
19
  "resource",
17
20
  "key",
18
21
  "etag",
19
- "data"
22
+ "data",
23
+ "fields"
20
24
  ],
21
25
  delete: [
22
26
  "op",
@@ -39,12 +43,13 @@ const problemOf = (value, index) => {
39
43
  if (unknown.length > 0) return `${at} has fields a ${op} does not take: ${unknown.map((name) => JSON.stringify(name)).join(", ")}`;
40
44
  if (!isText(value.resource)) return `${at} needs the resource's name`;
41
45
  if (op !== "delete" && !isObject(value.data)) return `${at} needs its data as an object of field names to values`;
46
+ if (op !== "delete" && !echoFieldsOf(value.fields)) return `${at} has fields that are not a list of field names`;
42
47
  if (op === "create") return value.ref === void 0 || isText(value.ref) ? void 0 : `${at} has a ref that is not a name`;
43
48
  if (!isKey(value.key)) return `${at} needs the record's key: a string, a number, a list of them, or { "$ref": "<name>" }`;
44
49
  if (op === "update" && !isText(value.etag)) return `${at} needs the etag the record was read with, or "*" to update any version`;
45
50
  if (op === "delete" && "etag" in value && typeof value.etag !== "string") return `${at} has an etag that is not a string`;
46
51
  };
47
- /** The operations to run, or why the batch is refused, naming the operation at fault. */
52
+ /** The operations to run, each with the fields to give back of its record, or why the batch is refused, naming the operation at fault. */
48
53
  const batchOperationsOf = (args) => {
49
54
  const unknown = Object.keys(args).filter((name) => name !== "operations");
50
55
  if (unknown.length > 0) return { problem: `The batch takes only "operations", not ${unknown.map((name) => JSON.stringify(name)).join(", ")}` };
@@ -57,7 +62,11 @@ const batchOperationsOf = (args) => {
57
62
  }
58
63
  const refs = operations.flatMap((operation) => operation.ref === void 0 ? [] : [operation.ref]);
59
64
  if (new Set(refs).size !== refs.length) return { problem: "Each create may use a ref name once" };
60
- return { operations };
65
+ const fields = operations.map((operation) => echoFieldsOf(operation.fields));
66
+ return {
67
+ operations: operations.map(({ fields: _fields, ...operation }) => operation),
68
+ fields
69
+ };
61
70
  };
62
71
  //#endregion
63
72
  export { batchOperationsOf };
@@ -1,12 +1,20 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { forcedJson } from "./result-budget.js";
2
3
  import { defineTool } from "./tools.js";
4
+ import { cutRecordNote } from "./budgeted-page.js";
5
+ import { writeText } from "./write-messages.js";
6
+ import { describedResource } from "./write-prompts.js";
7
+ import { fieldsParameter } from "./write-echo.js";
3
8
  import { batchApproval } from "./batch-approval.js";
9
+ import { batchBefore } from "./batch-before.js";
10
+ import { batchEcho, recordsBefore } from "./batch-echo.js";
4
11
  import { batchOperationsOf } from "./batch-write-args.js";
5
12
  //#region src/assistant/batch-write-tool.ts
6
13
  /**
7
14
  * `batch_write`: many creates, updates and deletes across resources in one transaction, through the REST API's batch
8
- * write, after the user approves the batch once. Over MCP the tool runs again from the start with the user's answer, so
9
- * everything before `approve` only checks the arguments and builds the approval text: nothing is read or written.
15
+ * write, after the user approves the batch once, seeing each write in their language. Over MCP the tool runs again from
16
+ * the start with the user's answer, so everything before `approve` only checks the arguments and reads what the approval
17
+ * names: nothing is written.
10
18
  */
11
19
  const writes = {
12
20
  readOnlyHint: false,
@@ -44,10 +52,17 @@ const softDeleteSet = async (context, operations) => {
44
52
  });
45
53
  }
46
54
  };
47
- const softDeletesFor = async (context, operations) => {
48
- if (!operations.some((operation) => operation.op === "delete")) return /* @__PURE__ */ new Set();
49
- const { models } = await context.resources();
50
- return new Set(models.filter((model) => model.softDelete).map((model) => model.name));
55
+ const approvalOf = async (context, operations) => {
56
+ const before = await batchBefore(context, operations, 50).catch(refused);
57
+ if (typeof before === "string") return before;
58
+ const readable = await context.resources();
59
+ return batchApproval({
60
+ text: writeText(context.locale ?? "en"),
61
+ described: (resource) => describedResource(readable, resource),
62
+ operations,
63
+ before,
64
+ softDeletes: new Set(readable.models.filter((model) => model.softDelete).map((model) => model.name))
65
+ });
51
66
  };
52
67
  const operationsParameter = (input) => ({
53
68
  type: "array",
@@ -84,14 +99,15 @@ const operationsParameter = (input) => ({
84
99
  ref: {
85
100
  type: "string",
86
101
  description: "A create's name, for later operations to point at."
87
- }
102
+ },
103
+ fields: fieldsParameter
88
104
  }
89
105
  }
90
106
  });
91
107
  /** Runs many writes in one transaction once the user approves them, under the caller's access rules. */
92
108
  const batchWriteTool = (input = {}) => defineTool({
93
109
  name: "batch_write",
94
- description: `Creates, updates and deletes records across resources in one transaction: either all are written or none, and an update or delete with an etag fails the batch when its record changed since. Returns { results } with each created or updated record and its new etag. The user approves the batch in their client first; nothing is written without it.${input.resources ? ` It writes only ${input.resources.join(", ")}.` : ""}`,
110
+ description: `Creates, updates and deletes records across resources in one transaction: either all are written or none, and an update or delete with an etag fails the batch when its record changed since. Returns { results }: each created or updated record's key, new etag and, in record, the fields written and the read-only fields the batch changed. The user approves the batch in their client first; nothing is written without it.${input.resources ? ` It writes only ${input.resources.join(", ")}.` : ""}`,
95
111
  parameters: {
96
112
  type: "object",
97
113
  properties: { operations: operationsParameter(input) },
@@ -101,14 +117,17 @@ const batchWriteTool = (input = {}) => defineTool({
101
117
  run: async (args, context) => {
102
118
  const parsed = batchOperationsOf(args);
103
119
  if ("problem" in parsed) return refusal("invalid-argument", `${parsed.problem}; nothing was written.`);
104
- const { operations } = parsed;
120
+ const { operations, fields } = parsed;
105
121
  const outsideRefusal = outside(input, operations);
106
122
  if (outsideRefusal) return outsideRefusal;
107
123
  const softDeleteRefusal = await softDeleteSet(context, operations).catch(refused);
108
124
  if (softDeleteRefusal) return softDeleteRefusal;
109
- const answer = await context.approve(batchApproval(operations, await softDeletesFor(context, operations)));
125
+ const approval = await approvalOf(context, operations);
126
+ if (typeof approval === "string") return approval;
127
+ const answer = await context.approve(approval);
110
128
  if (answer !== "approved") return `The user did not approve this batch (${answer}); nothing was written.`;
111
- return context.records.batch(operations).then((results) => JSON.stringify({ results }), refused);
129
+ const befores = await recordsBefore(context, operations);
130
+ return context.records.batch(operations).then(async (results) => forcedJson({ results: await batchEcho(context, operations, results, befores, fields) }, cutRecordNote), refused);
112
131
  }
113
132
  });
114
133
  //#endregion
@@ -0,0 +1,30 @@
1
+ import { budgetedJson, forcedJson, leadingFit } from "./result-budget.js";
2
+ //#region src/assistant/budgeted-page.ts
3
+ /** How a record tool's result says to read a field it cut. */
4
+ const cutRecordNote = "The fields in cut_fields were cut to fit the size limit, each with its full length; read the rest of one with get_record and its field, offset and max_chars arguments.";
5
+ const fewerNote = (count) => `Only ${count} records fit the size limit; pass next_page_token as page_token for the next ones. ${cutRecordNote}`;
6
+ /**
7
+ * A page of records as JSON within the size budget: its strings trimmed, then, when that does not fit, the page
8
+ * listed again with as many records as fit, so its next_page_token goes on from the last one returned.
9
+ */
10
+ const budgetedPage = async (page, params, list) => {
11
+ const json = budgetedJson(page, cutRecordNote);
12
+ if (json) return json;
13
+ if (page.items.length <= 1) return forcedJson(page, cutRecordNote);
14
+ const fits = (count) => budgetedJson({
15
+ ...page,
16
+ items: page.items.slice(0, count),
17
+ note: fewerNote(count)
18
+ }, fewerNote(count)) !== void 0;
19
+ const count = leadingFit(page.items.length - 1, fits);
20
+ const fewer = await list({
21
+ ...params,
22
+ page_size: count
23
+ });
24
+ return forcedJson({
25
+ ...fewer,
26
+ note: fewerNote(fewer.items.length)
27
+ }, fewerNote(fewer.items.length));
28
+ };
29
+ //#endregion
30
+ export { budgetedPage, cutRecordNote };
@@ -0,0 +1,20 @@
1
+ import { AssistantTool } from "./tools.js";
2
+ import { WriteRecordToolsInput } from "./write-tool-limits.js";
3
+ //#region src/assistant/comment-tools.d.ts
4
+ /** The resources whose records take comments (`.comments()`), the only ones the comment tools reach. */
5
+ export type CommentToolsInput = {
6
+ commented: string[];
7
+ };
8
+ /** Reads a page of a record's comments, newest first, under the caller's access rules. */
9
+ export declare const listCommentsTool: (input: CommentToolsInput) => AssistantTool;
10
+ /**
11
+ * Adds the caller's comment to a record once the user approves it, under the caller's access rules. Given `resources`,
12
+ * it comments only on those, and refuses any other before asking.
13
+ */
14
+ export declare const addCommentTool: (input: CommentToolsInput & WriteRecordToolsInput) => AssistantTool;
15
+ /**
16
+ * `list_comments`, and `add_comment` when `writes` allows writing: on the resources with comments, and of those only
17
+ * the ones `writes` lists when it lists some. None when no resource takes comments.
18
+ */
19
+ export declare const commentTools: ({ commented }: CommentToolsInput, writes: false | WriteRecordToolsInput) => AssistantTool[];
20
+ //#endregion
@@ -0,0 +1,120 @@
1
+ import { notFound } from "../problem.js";
2
+ import { defineTool } from "./tools.js";
3
+ import { parseCommentInput } from "../comments/comment-input.js";
4
+ import { keyOf, keyParameter, refused, resourceOf, resourceParameter } from "./record-tool-args.js";
5
+ import { commentApproval } from "./write-prompts.js";
6
+ import { approvalTexts } from "./record-before.js";
7
+ import { notApproved, onlyNote, outside } from "./write-tool-limits.js";
8
+ //#region src/assistant/comment-tools.ts
9
+ const defaultPageSize = 20;
10
+ const maxPageSize = 100;
11
+ const resourceParameterOf = (resources) => ({
12
+ ...resourceParameter,
13
+ description: `The resource's name: one of ${resources.join(", ")}, whose records take comments.`
14
+ });
15
+ const commentsOf = (context) => {
16
+ if (!context.comments) throw new Error("The comment tools need ToolContext.comments, which the MCP endpoint gives");
17
+ return context.comments;
18
+ };
19
+ const uncommented = ({ commented }, resource) => commented.includes(resource) ? void 0 : refused(notFound(`${resource} takes no comments`));
20
+ /** Reads a page of a record's comments, newest first, under the caller's access rules. */
21
+ const listCommentsTool = (input) => defineTool({
22
+ name: "list_comments",
23
+ description: "Lists the comments on one record, newest first, as JSON ({ items: [{ author, created_at, text }], next_page_token }). Pass next_page_token back as page_token for the next page; it is empty on the last.",
24
+ parameters: {
25
+ type: "object",
26
+ properties: {
27
+ resource: resourceParameterOf(input.commented),
28
+ key: keyParameter,
29
+ page_size: {
30
+ type: "integer",
31
+ minimum: 1,
32
+ maximum: maxPageSize,
33
+ description: `Comments per page, ${defaultPageSize} by default.`
34
+ },
35
+ page_token: {
36
+ type: "string",
37
+ description: "The next_page_token of the previous page."
38
+ }
39
+ },
40
+ required: ["resource", "key"]
41
+ },
42
+ annotations: {
43
+ readOnlyHint: true,
44
+ openWorldHint: false
45
+ },
46
+ run: async (args, context) => {
47
+ const resource = resourceOf(args);
48
+ const key = keyOf(args.key);
49
+ if (!resource || key === void 0) return "Pass the resource and key as { \"resource\": \"<name>\", \"key\": \"<key>\" }.";
50
+ const refusal = uncommented(input, resource);
51
+ if (refusal) return refusal;
52
+ const params = {
53
+ page_size: typeof args.page_size === "number" ? Math.min(args.page_size, maxPageSize) : defaultPageSize,
54
+ ...typeof args.page_token === "string" && args.page_token && { page_token: args.page_token }
55
+ };
56
+ return commentsOf(context).list(resource, key, params).then((page) => JSON.stringify(page), refused);
57
+ }
58
+ });
59
+ /**
60
+ * Adds the caller's comment to a record once the user approves it, under the caller's access rules. Given `resources`,
61
+ * it comments only on those, and refuses any other before asking.
62
+ */
63
+ const addCommentTool = (input) => defineTool({
64
+ name: "add_comment",
65
+ description: `Adds a comment by you to one record and returns it as JSON ({ author, created_at, text }). The user approves each comment in their client first; nothing is written without it.${onlyNote(input)}`,
66
+ parameters: {
67
+ type: "object",
68
+ properties: {
69
+ resource: resourceParameterOf(input.resources ?? input.commented),
70
+ key: keyParameter,
71
+ text: {
72
+ type: "string",
73
+ description: "The comment, plain text."
74
+ }
75
+ },
76
+ required: [
77
+ "resource",
78
+ "key",
79
+ "text"
80
+ ]
81
+ },
82
+ annotations: {
83
+ readOnlyHint: false,
84
+ destructiveHint: false,
85
+ openWorldHint: false
86
+ },
87
+ run: async (args, context) => {
88
+ const resource = resourceOf(args);
89
+ const key = keyOf(args.key);
90
+ const text = typeof args.text === "string" ? args.text : void 0;
91
+ if (!resource || key === void 0 || text === void 0) return "Pass the resource, key and text as { \"resource\": \"<name>\", \"key\": \"<key>\", \"text\": \"<comment>\" }.";
92
+ const refusal = outside(input, resource) ?? uncommented(input, resource);
93
+ if (refusal) return refusal;
94
+ const invalid = await Promise.resolve().then(() => parseCommentInput({ body: text }, String(context.session.user.id))).then(() => void 0, refused);
95
+ if (invalid) return invalid;
96
+ const current = await context.records.get(resource, key).catch(refused);
97
+ if (typeof current === "string") return current;
98
+ const { text: texts, described } = await approvalTexts(context, resource);
99
+ const answer = await context.approve(commentApproval(texts, described, key, current.record, text));
100
+ if (answer !== "approved") return notApproved(answer);
101
+ return commentsOf(context).add(resource, key, text).then((comment) => JSON.stringify(comment), refused);
102
+ }
103
+ });
104
+ /**
105
+ * `list_comments`, and `add_comment` when `writes` allows writing: on the resources with comments, and of those only
106
+ * the ones `writes` lists when it lists some. None when no resource takes comments.
107
+ */
108
+ const commentTools = ({ commented }, writes) => {
109
+ if (commented.length === 0) return [];
110
+ const list = listCommentsTool({ commented });
111
+ if (writes === false) return [list];
112
+ const resources = writes.resources?.filter((name) => commented.includes(name));
113
+ if (resources?.length === 0) return [list];
114
+ return [list, addCommentTool({
115
+ commented,
116
+ ...resources && { resources }
117
+ })];
118
+ };
119
+ //#endregion
120
+ export { addCommentTool, commentTools, listCommentsTool };
@@ -0,0 +1,5 @@
1
+ import { AssistantTool } from "./tools.js";
2
+ //#region src/assistant/count-records-tool.d.ts
3
+ /** Counts a resource's records, in all or per value of a field, through the REST API's `count=exact` and `:aggregate`. */
4
+ export declare const countRecordsTool: () => AssistantTool;
5
+ //#endregion
@@ -0,0 +1,84 @@
1
+ import { exactCountLimit } from "../list-service.js";
2
+ import { defineTool } from "./tools.js";
3
+ import { invalidShowDeleted, refused, resourceOf, resourceParameter, showDeletedOf, showDeletedParameter } from "./record-tool-args.js";
4
+ //#region src/assistant/count-records-tool.ts
5
+ const maxGroups = 1e3;
6
+ const invalid = (parameter, message) => JSON.stringify({
7
+ error: "invalid-parameter",
8
+ message: `Invalid parameter ${parameter}: ${message}`
9
+ });
10
+ const total = async (context, resource, filter, showDeleted) => {
11
+ const page = await context.records.list(resource, {
12
+ ...filter !== void 0 && { filter },
13
+ page_size: 1,
14
+ count: "exact",
15
+ ...showDeleted && { show_deleted: true }
16
+ });
17
+ if (page.total_size !== void 0) return { count: page.total_size };
18
+ return {
19
+ more_than: exactCountLimit,
20
+ estimate: page.total_size_estimate
21
+ };
22
+ };
23
+ /** Counts a resource's records, in all or per value of a field, through the REST API's `count=exact` and `:aggregate`. */
24
+ const countRecordsTool = () => defineTool({
25
+ name: "count_records",
26
+ description: `Counts the records of a resource that a filter finds, without returning them, as JSON. Without group_by it answers { count }, or { more_than: ${exactCountLimit}, estimate } when more match. With group_by, a filterable field, it answers { group_by, groups: [{ value, count, label? }] }: the records per value, largest first, adding up to what the filter finds; a date or timestamp group_by counts per day over the last 30 days instead. filter uses the AIP-160 syntax of list_records.`,
27
+ parameters: {
28
+ type: "object",
29
+ properties: {
30
+ resource: resourceParameter,
31
+ filter: {
32
+ type: "string",
33
+ description: "An AIP-160 filter on filterable fields, such as status = \"open\" AND amount > 10."
34
+ },
35
+ group_by: {
36
+ type: "string",
37
+ description: "A filterable field to count the records of per value, such as status."
38
+ },
39
+ limit: {
40
+ type: "integer",
41
+ minimum: 1,
42
+ maximum: maxGroups,
43
+ description: "With group_by, the most groups to return, 100 by default."
44
+ },
45
+ label: {
46
+ type: "string",
47
+ description: "With a relation group_by, a field of the linked resource to name each value by, such as name."
48
+ },
49
+ show_deleted: {
50
+ ...showDeletedParameter,
51
+ description: "true also counts soft-deleted records; only without group_by, on a resource list_resources marks soft_delete."
52
+ }
53
+ },
54
+ required: ["resource"]
55
+ },
56
+ annotations: {
57
+ readOnlyHint: true,
58
+ openWorldHint: false
59
+ },
60
+ run: async (args, context) => {
61
+ const resource = resourceOf(args);
62
+ if (!resource) return "Pass the resource as { \"resource\": \"<name>\" }.";
63
+ const showDeleted = showDeletedOf(args);
64
+ if (showDeleted === void 0) return invalidShowDeleted;
65
+ const filter = typeof args.filter === "string" ? args.filter : void 0;
66
+ const groupBy = typeof args.group_by === "string" && args.group_by ? args.group_by : void 0;
67
+ if (groupBy === void 0) {
68
+ if (args.limit !== void 0 || args.label !== void 0) return invalid(args.limit !== void 0 ? "limit" : "label", "Only with group_by");
69
+ return total(context, resource, filter, showDeleted).then((counted) => JSON.stringify(counted), refused);
70
+ }
71
+ if (showDeleted) return invalid("show_deleted", "Not with group_by");
72
+ if (args.limit !== void 0 && !Number.isInteger(args.limit)) return invalid("limit", "Pass a whole number");
73
+ if (args.label !== void 0 && typeof args.label !== "string") return invalid("label", "Pass a field name");
74
+ const params = {
75
+ group_by: groupBy,
76
+ ...filter !== void 0 && { filter },
77
+ ...typeof args.limit === "number" && { limit: args.limit },
78
+ ...typeof args.label === "string" && { label: args.label }
79
+ };
80
+ return context.records.aggregate(resource, params).then((groups) => JSON.stringify(groups), refused);
81
+ }
82
+ });
83
+ //#endregion
84
+ export { countRecordsTool };