@protobase/server 0.51.3 → 0.52.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.
Files changed (96) 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/changes/capture-schema.js +1 -1
  32. package/dist/changes/resource-change.d.ts +4 -1
  33. package/dist/changes/resource-change.js +27 -3
  34. package/dist/comments/post-comment.js +1 -1
  35. package/dist/comments/read-comments.js +1 -1
  36. package/dist/comments/tool-comment-types.d.ts +28 -0
  37. package/dist/comments/tool-comments.js +30 -0
  38. package/dist/create-admin.d.ts +5 -2
  39. package/dist/create-admin.js +20 -10
  40. package/dist/deps.d.ts +1 -1
  41. package/dist/encryption/scan.js +1 -1
  42. package/dist/error-handler.js +3 -2
  43. package/dist/etag.js +1 -1
  44. package/dist/files/download.js +1 -1
  45. package/dist/files/file-cleanup.d.ts +1 -1
  46. package/dist/functions/records.d.ts +1 -1
  47. package/dist/functions/records.js +2 -2
  48. package/dist/history/read-history.js +2 -2
  49. package/dist/index.d.ts +9 -3
  50. package/dist/index.js +9 -4
  51. package/dist/jobs/run-job.d.ts +1 -1
  52. package/dist/list-photos.js +2 -2
  53. package/dist/list-service.js +6 -6
  54. package/dist/map-service.js +2 -2
  55. package/dist/mcp/mcp-context.d.ts +7 -4
  56. package/dist/mcp/mcp-context.js +6 -4
  57. package/dist/mcp/mcp-server.d.ts +3 -2
  58. package/dist/mcp/mcp-tools.d.ts +6 -1
  59. package/dist/mcp/mcp-tools.js +8 -4
  60. package/dist/package.js +1 -1
  61. package/dist/read-service.js +2 -2
  62. package/dist/realtime/broadcast-api.js +3 -3
  63. package/dist/realtime/broadcast-hub.js +15 -5
  64. package/dist/realtime/change-checker.js +1 -1
  65. package/dist/realtime/channel-access.js +1 -1
  66. package/dist/realtime/database-broadcasts.js +1 -1
  67. package/dist/realtime/presence-hub.js +74 -39
  68. package/dist/realtime/realtime-app.js +28 -4
  69. package/dist/realtime/realtime-routes.js +3 -5
  70. package/dist/realtime/realtime-socket.js +3 -1
  71. package/dist/realtime/relay/frame-cursor.js +38 -0
  72. package/dist/realtime/relay/presence-replica.js +57 -0
  73. package/dist/realtime/relay/realtime-options.d.ts +13 -0
  74. package/dist/realtime/relay/realtime-options.js +9 -0
  75. package/dist/realtime/relay/realtime-relay.js +104 -0
  76. package/dist/realtime/relay/relay-frame-sql.js +82 -0
  77. package/dist/realtime/relay/relay-presence-sql.js +71 -0
  78. package/dist/realtime/relay/relay-schema-sql.js +55 -0
  79. package/dist/realtime/relay/relay-schema.d.ts +7 -0
  80. package/dist/realtime/relay/relay-schema.js +18 -0
  81. package/dist/realtime/relay/relayed-hubs.js +61 -0
  82. package/dist/records.js +1 -1
  83. package/dist/related-labels.js +1 -1
  84. package/dist/rest/rest-context.js +1 -1
  85. package/dist/routes/batch.js +1 -1
  86. package/dist/routes/collection.js +2 -2
  87. package/dist/routes/record.js +3 -3
  88. package/dist/row-permissions.js +1 -1
  89. package/dist/sql-parts.js +1 -1
  90. package/dist/web-app/mcp-icons.d.ts +9 -0
  91. package/dist/web-app/mcp-icons.js +26 -0
  92. package/dist/webhooks/subscriber-view.js +27 -2
  93. package/dist/webhooks/webhooks.js +41 -12
  94. package/dist/write-pipeline.js +1 -1
  95. package/dist/write-shared.js +1 -1
  96. package/package.json +4 -4
@@ -0,0 +1,32 @@
1
+ import { budgetedJson, forcedJson, leadingFit } from "./result-budget.js";
2
+ //#region src/assistant/query-result-budget.ts
3
+ const cutCellNote = "The values in cut_fields were cut to fit the size limit, each with its full length (rows[r][c] is row r, column c); read the rest of one with substring(<column> from <start>), counting characters from 1.";
4
+ const fewerRowsNote = (count, more) => `Only the first ${count} rows fit the size limit${more}. ${cutCellNote}`;
5
+ /**
6
+ * A query's result as JSON within the size budget: its strings trimmed, then, when that does not fit, only as many
7
+ * leading rows as fit, marked truncated. `more` ends the note on the rows left out, such as how to read on.
8
+ */
9
+ const budgetedQueryResult = (result, more) => {
10
+ const json = budgetedJson(result, cutCellNote);
11
+ if (json) return json;
12
+ if (result.rows.length <= 1) return forcedJson(result, cutCellNote);
13
+ const leading = (count) => {
14
+ const note = fewerRowsNote(count, more(count));
15
+ return {
16
+ result: {
17
+ ...result,
18
+ rows: result.rows.slice(0, count),
19
+ truncated: true,
20
+ note
21
+ },
22
+ note
23
+ };
24
+ };
25
+ const { result: fewer, note } = leading(leadingFit(result.rows.length - 1, (count) => {
26
+ const { result, note } = leading(count);
27
+ return budgetedJson(result, note) !== void 0;
28
+ }));
29
+ return forcedJson(fewer, note);
30
+ };
31
+ //#endregion
32
+ export { budgetedQueryResult };
@@ -1,7 +1,7 @@
1
+ import { budgetedQueryResult } from "./query-result-budget.js";
1
2
  import { runReadOnlyQuery, runReadWriteQuery } from "./run-query.js";
2
3
  import { defineTool } from "./tools.js";
3
4
  //#region src/assistant/query-tools.ts
4
- const maxResultLength = 2e4;
5
5
  const sqlParameter = (description) => ({
6
6
  type: "string",
7
7
  description
@@ -11,7 +11,7 @@ const refused = (error) => {
11
11
  if (isDatabaseError(error)) return `Postgres refused the statement: ${error.message}`;
12
12
  throw error;
13
13
  };
14
- const shown = (result, statement, context) => {
14
+ const shown = (result, statement, context, more) => {
15
15
  context.show({
16
16
  type: "table",
17
17
  id: crypto.randomUUID(),
@@ -20,9 +20,9 @@ const shown = (result, statement, context) => {
20
20
  caption: statement,
21
21
  ...result.truncated && { truncated: true }
22
22
  });
23
- const json = JSON.stringify(result);
24
- return json.length > maxResultLength ? `${json.slice(0, maxResultLength)}... (cut off)` : json;
23
+ return budgetedQueryResult(result, more);
25
24
  };
25
+ const readOn = (count) => `; add OFFSET ${count} to the statement to read on`;
26
26
  const statementOf = (args) => typeof args.sql === "string" && args.sql.trim() ? args.sql : void 0;
27
27
  /**
28
28
  * Lets the model read the app's database: one SELECT per call, in a read-only transaction (see `runReadOnlyQuery`).
@@ -31,7 +31,7 @@ const statementOf = (args) => typeof args.sql === "string" && args.sql.trim() ?
31
31
  */
32
32
  const readOnlyQueryTool = (db, options = {}) => defineTool({
33
33
  name: "run_read_only_query",
34
- description: `Runs one PostgreSQL SELECT on the app's database in a read-only transaction and returns up to ${options.rowLimit ?? 200} rows as JSON ({ columns, rows, truncated }). The user sees the rows as a table. Use it to answer questions about the data. It reads the app's resource tables only. It cannot change anything.`,
34
+ description: `Runs one PostgreSQL SELECT on the app's database in a read-only transaction and returns up to ${options.rowLimit ?? 200} rows as JSON ({ columns, rows, truncated }); a result too large for one answer holds fewer rows, with long values cut and named in cut_fields, and a note on reading the rest. The user sees the rows as a table. Use it to answer questions about the data. It reads the app's resource tables only. It cannot change anything.`,
35
35
  parameters: {
36
36
  type: "object",
37
37
  properties: { sql: sqlParameter("One SELECT statement, without a trailing semicolon.") },
@@ -44,7 +44,7 @@ const readOnlyQueryTool = (db, options = {}) => defineTool({
44
44
  run: async (args, context) => {
45
45
  const statement = statementOf(args);
46
46
  if (!statement) return "Pass the statement as { \"sql\": \"select ...\" }.";
47
- return runReadOnlyQuery(db, statement, options).then((result) => shown(result, statement, context), refused);
47
+ return runReadOnlyQuery(db, statement, options).then((result) => shown(result, statement, context, readOn), refused);
48
48
  }
49
49
  });
50
50
  /**
@@ -79,7 +79,7 @@ const readWriteQueryTool = (db, options = {}) => defineTool({
79
79
  code: statement
80
80
  });
81
81
  if (answer !== "approved") return `The user did not approve the statement (${answer}); nothing ran.`;
82
- return runReadWriteQuery(db, statement, options).then((result) => shown(result, statement, context), refused);
82
+ return runReadWriteQuery(db, statement, options).then((result) => shown(result, statement, context, () => ""), refused);
83
83
  }
84
84
  });
85
85
  //#endregion
@@ -4,6 +4,7 @@ import { refused } from "./record-tool-args.js";
4
4
  import { writeText } from "./write-messages.js";
5
5
  import { describedResource } from "./write-prompts.js";
6
6
  //#region src/assistant/record-before.ts
7
+ /** Whether the caller's read rules hide the record from them, which leaves the write's own rules to decide. */
7
8
  const hidden = (error) => error instanceof HttpProblem && (error.status === 403 || error.status === 404);
8
9
  /**
9
10
  * The record as the caller reads it now, for what an approval names and shows before a write: undefined when they may
@@ -17,4 +18,4 @@ const approvalTexts = async (context, resource) => ({
17
18
  described: describedResource(await context.resources(), resource)
18
19
  });
19
20
  //#endregion
20
- export { approvalTexts, recordBefore };
21
+ export { approvalTexts, hidden, recordBefore };
@@ -0,0 +1,54 @@
1
+ import { forcedJson } from "./result-budget.js";
2
+ //#region src/assistant/record-field-piece.ts
3
+ /** get_record's arguments that read one field in pieces, such as a field a result cut to fit its size limit. */
4
+ const pieceParameters = {
5
+ field: {
6
+ type: "string",
7
+ description: "One field to read alone, in pieces: max_chars characters of its value from offset. A value that is not text reads as its JSON."
8
+ },
9
+ offset: {
10
+ type: "integer",
11
+ minimum: 0,
12
+ description: "With field: the character to start at, 0 by default."
13
+ },
14
+ max_chars: {
15
+ type: "integer",
16
+ minimum: 1,
17
+ maximum: 3e4,
18
+ description: "With field: the characters to read, 20000 by default."
19
+ }
20
+ };
21
+ const defaultChars = 2e4;
22
+ const maxChars = 3e4;
23
+ const invalid = (message) => JSON.stringify({
24
+ error: "invalid-parameter",
25
+ message
26
+ });
27
+ const countOf = (value, fallback) => value === void 0 ? fallback : typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
28
+ /** Whether the call reads one field in pieces rather than the whole record. */
29
+ const readsPiece = (args) => args.field !== void 0;
30
+ /**
31
+ * The piece of `args.field` of `found` the call asks for, as JSON ({ field, offset, length, value, next_offset }), where
32
+ * length is the whole value's and next_offset is absent once the piece reaches its end.
33
+ */
34
+ const fieldPiece = (found, args) => {
35
+ const field = args.field;
36
+ if (typeof field !== "string" || !Object.hasOwn(found.record, field)) return invalid(`Invalid parameter field: pass one of ${Object.keys(found.record).join(", ")}`);
37
+ const offset = countOf(args.offset, 0);
38
+ if (offset === void 0) return invalid("Invalid parameter offset: pass a whole number from 0");
39
+ const chars = countOf(args.max_chars, defaultChars);
40
+ if (!chars) return invalid(`Invalid parameter max_chars: pass a whole number from 1 to ${maxChars}`);
41
+ const raw = found.record[field];
42
+ const text = typeof raw === "string" ? raw : JSON.stringify(raw) ?? "null";
43
+ const end = offset + Math.min(chars, maxChars);
44
+ const piece = {
45
+ field,
46
+ offset,
47
+ length: text.length,
48
+ value: text.slice(offset, end),
49
+ ...end < text.length && { next_offset: end }
50
+ };
51
+ return forcedJson(piece, "The value was cut to fit the size limit; pass a smaller max_chars.");
52
+ };
53
+ //#endregion
54
+ export { fieldPiece, pieceParameters, readsPiece };
@@ -1,10 +1,13 @@
1
1
  import { HttpProblem } from "../problem.js";
2
+ import { queryProblem } from "../error-handler.js";
3
+ import { QueryError } from "@protobase/query";
2
4
  //#region src/assistant/record-tool-args.ts
3
5
  /** A refusal the model can act on (no such resource, not allowed, a bad filter) is its to read; anything else fails the call. */
4
6
  const refused = (error) => {
5
- if (error instanceof HttpProblem && error.status < 500) return JSON.stringify({
6
- error: error.slug,
7
- message: error.detail
7
+ const problem = error instanceof QueryError ? queryProblem(error) : error;
8
+ if (problem instanceof HttpProblem && problem.status < 500) return JSON.stringify({
9
+ error: problem.slug,
10
+ message: problem.detail
8
11
  });
9
12
  throw error;
10
13
  };
@@ -6,6 +6,9 @@ export declare const listResourcesTool: () => AssistantTool;
6
6
  export declare const listRecordsTool: () => AssistantTool;
7
7
  /** Reads one record by its key, under the caller's access rules. */
8
8
  export declare const getRecordTool: () => AssistantTool;
9
- /** `list_resources`, `list_records` and `get_record`: reading the app's records as the caller, through the REST API's rules. */
9
+ /**
10
+ * `list_resources`, `list_records`, `count_records` and `get_record`: reading and counting the app's records as the
11
+ * caller, through the REST API's rules.
12
+ */
10
13
  export declare const readRecordTools: () => AssistantTool[];
11
14
  //#endregion
@@ -1,5 +1,9 @@
1
+ import { forcedJson } from "./result-budget.js";
1
2
  import { defineTool } from "./tools.js";
2
3
  import { invalidShowDeleted, keyOf, keyParameter, refused, resourceOf, resourceParameter, showDeletedOf, showDeletedParameter } from "./record-tool-args.js";
4
+ import { budgetedPage, cutRecordNote } from "./budgeted-page.js";
5
+ import { fieldPiece, pieceParameters, readsPiece } from "./record-field-piece.js";
6
+ import { countRecordsTool } from "./count-records-tool.js";
3
7
  //#region src/assistant/record-tools.ts
4
8
  const readOnly = {
5
9
  readOnlyHint: true,
@@ -45,7 +49,7 @@ const listResourcesTool = () => defineTool({
45
49
  /** Finds records of a resource with the REST API's filters, sorting and pages, under the caller's access rules. */
46
50
  const listRecordsTool = () => defineTool({
47
51
  name: "list_records",
48
- description: `Finds records of a resource and returns a page of them as JSON ({ items, next_page_token, total_size_estimate }). filter uses AIP-160 syntax, such as status = "open" AND amount > 10, and order_by a comma-separated list of fields, each optionally followed by desc. Pass next_page_token back as page_token for the next page; it is empty on the last. show_deleted: true also returns soft-deleted records.`,
52
+ description: `Finds records of a resource and returns a page of them as JSON ({ items, next_page_token, total_size_estimate }). filter uses AIP-160 syntax, such as status = "open" AND amount > 10, and order_by a comma-separated list of fields, each optionally followed by desc. Pass next_page_token back as page_token for the next page; it is empty on the last. A page too large for one result holds fewer records, with long values cut and named in cut_fields, and a note on reading the rest. show_deleted: true also returns soft-deleted records.`,
49
53
  parameters: {
50
54
  type: "object",
51
55
  properties: {
@@ -85,19 +89,21 @@ const listRecordsTool = () => defineTool({
85
89
  page_size: typeof args.page_size === "number" ? Math.min(args.page_size, maxPageSize) : defaultPageSize,
86
90
  ...showDeleted && { show_deleted: true }
87
91
  };
88
- return context.records.list(resource, params).then((page) => JSON.stringify(page), refused);
92
+ const list = (paged) => context.records.list(resource, paged);
93
+ return list(params).then((page) => budgetedPage(page, params, list)).catch(refused);
89
94
  }
90
95
  });
91
96
  /** Reads one record by its key, under the caller's access rules. */
92
97
  const getRecordTool = () => defineTool({
93
98
  name: "get_record",
94
- description: "Reads one record of a resource by its key and returns it as JSON ({ record, etag }). show_deleted: true also finds a soft-deleted record.",
99
+ description: "Reads one record of a resource by its key and returns it as JSON ({ record, etag }), with long values cut and named in cut_fields when it is too large for one result. field reads one field alone, in pieces from offset. show_deleted: true also finds a soft-deleted record.",
95
100
  parameters: {
96
101
  type: "object",
97
102
  properties: {
98
103
  resource: resourceParameter,
99
104
  key: keyParameter,
100
- show_deleted: showDeletedParameter
105
+ show_deleted: showDeletedParameter,
106
+ ...pieceParameters
101
107
  },
102
108
  required: ["resource", "key"]
103
109
  },
@@ -108,13 +114,18 @@ const getRecordTool = () => defineTool({
108
114
  if (!resource || key === void 0) return "Pass the resource and key as { \"resource\": \"<name>\", \"key\": \"<key>\" }.";
109
115
  const showDeleted = showDeletedOf(args);
110
116
  if (showDeleted === void 0) return invalidShowDeleted;
111
- return context.records.get(resource, key, { show_deleted: showDeleted }).then((record) => JSON.stringify(record), refused);
117
+ const shown = (found) => readsPiece(args) ? fieldPiece(found, args) : forcedJson(found, cutRecordNote);
118
+ return context.records.get(resource, key, { show_deleted: showDeleted }).then(shown, refused);
112
119
  }
113
120
  });
114
- /** `list_resources`, `list_records` and `get_record`: reading the app's records as the caller, through the REST API's rules. */
121
+ /**
122
+ * `list_resources`, `list_records`, `count_records` and `get_record`: reading and counting the app's records as the
123
+ * caller, through the REST API's rules.
124
+ */
115
125
  const readRecordTools = () => [
116
126
  listResourcesTool(),
117
127
  listRecordsTool(),
128
+ countRecordsTool(),
118
129
  getRecordTool()
119
130
  ];
120
131
  //#endregion
@@ -1,4 +1,5 @@
1
1
  import { Row } from "../types.js";
2
+ import "../registry.js";
2
3
  import "../deps.js";
3
4
  import { BatchOperation } from "../batch/schema.js";
4
5
  import { BatchResult } from "../batch/run-batch.js";
@@ -36,12 +37,30 @@ export type RecordPage = {
36
37
  total_size_estimate: number;
37
38
  total_size?: number;
38
39
  };
40
+ /** The parameters of the REST API's `:aggregate` that count records per value of a field, as values. */
41
+ export type AggregateParams = {
42
+ group_by: string;
43
+ filter?: string;
44
+ limit?: number;
45
+ label?: string;
46
+ };
47
+ /** Records per value of `group_by` as the REST API's `:aggregate` gives them, largest group first. */
48
+ export type RecordGroups = {
49
+ group_by: string;
50
+ groups: Array<{
51
+ value: unknown;
52
+ count: number;
53
+ label?: string;
54
+ }>;
55
+ };
39
56
  /**
40
57
  * The app's resources as the session's user: their access rules, tenant scope, validation and write hooks apply as
41
58
  * in the REST API. A refusal or a missing record rejects with an `HttpProblem`, which a tool can turn into text.
42
59
  */
43
60
  export type ToolRecords = {
44
61
  list: (resource: string, params?: ListParams) => Promise<RecordPage>;
62
+ /** Counts per value of a field, as `GET /{resource}:aggregate` does, under the same access rules as `list`. */
63
+ aggregate: (resource: string, params: AggregateParams) => Promise<RecordGroups>;
45
64
  /** With `show_deleted`, also finds a soft-deleted record, as the REST API's `show_deleted` does. */
46
65
  get: (resource: string, key: RecordKeyInput, options?: {
47
66
  show_deleted?: boolean;
@@ -1,4 +1,5 @@
1
1
  import { notFound } from "../problem.js";
2
+ import { aggregate } from "../aggregate-groups.js";
2
3
  import { runWrite } from "../write-pipeline.js";
3
4
  import { decodeKeyText } from "../key.js";
4
5
  import { runBatch } from "../batch/run-batch.js";
@@ -7,22 +8,27 @@ import { readRecord } from "../read-service.js";
7
8
  import { encodeKey } from "@protobase/schema/internal";
8
9
  //#region src/assistant/records.ts
9
10
  const filterLength = 2e5;
11
+ /** The admin's resource named `resource`; a 404 when there is none. */
12
+ const toolEntry = (deps, resource) => {
13
+ const entry = deps.registry.find(resource);
14
+ if (!entry) throw notFound(`There is no resource "${resource}"`);
15
+ return entry;
16
+ };
17
+ /** The resource named `resource` and a record's key in it, as a tool passes them. */
18
+ const toolKey = (deps, resource, key) => {
19
+ const entry = toolEntry(deps, resource);
20
+ return {
21
+ entry,
22
+ key: decodeKeyText(entry, Array.isArray(key) ? encodeKey(key) : String(key))
23
+ };
24
+ };
10
25
  /** `ToolRecords` over the admin's resources for `session`. */
11
26
  const assistantRecords = (deps, session) => {
12
- const entryOf = (resource) => {
13
- const entry = deps.registry.find(resource);
14
- if (!entry) throw notFound(`There is no resource "${resource}"`);
15
- return entry;
16
- };
17
- const keyOf = (resource, key) => {
18
- const entry = entryOf(resource);
19
- return {
20
- entry,
21
- key: decodeKeyText(entry, Array.isArray(key) ? encodeKey(key) : String(key))
22
- };
23
- };
27
+ const entryOf = (resource) => toolEntry(deps, resource);
28
+ const keyOf = (resource, key) => toolKey(deps, resource, key);
24
29
  return {
25
30
  list: async (resource, params = {}) => (await listRecords(deps, entryOf(resource), session, params, filterLength)).body,
31
+ aggregate: async (resource, params) => (await aggregate(deps, entryOf(resource), session, params)).body,
26
32
  get: async (resource, input, { show_deleted } = {}) => {
27
33
  const { entry, key } = keyOf(resource, input);
28
34
  const { record, etag } = await readRecord(deps, entry, session, key, show_deleted);
@@ -60,6 +66,7 @@ const withoutAdmin = () => Promise.reject(/* @__PURE__ */ new Error("This assist
60
66
  /** Records for a turn without an admin around it, such as in a backend of its own: every call fails. */
61
67
  const noRecords = {
62
68
  list: withoutAdmin,
69
+ aggregate: withoutAdmin,
63
70
  get: withoutAdmin,
64
71
  create: withoutAdmin,
65
72
  update: withoutAdmin,
@@ -68,4 +75,4 @@ const noRecords = {
68
75
  batch: withoutAdmin
69
76
  };
70
77
  //#endregion
71
- export { assistantRecords, noRecords };
78
+ export { assistantRecords, noRecords, toolEntry, toolKey };
@@ -0,0 +1,72 @@
1
+ //#region src/assistant/result-budget.ts
2
+ /**
3
+ * Keeps a tool result within what a model's client takes: over the budget, its longest string values are trimmed, each
4
+ * named in `cut_fields` with its full length, and a `note` says how to read the rest. A tool with a list returns fewer
5
+ * items when trimming alone does not fit (see `leadingFit`).
6
+ */
7
+ /** The characters of JSON one tool result may hold. */
8
+ const resultBudget = 4e4;
9
+ const stringFloor = 500;
10
+ const neverCut = /* @__PURE__ */ new Set(["next_page_token", "etag"]);
11
+ const pathOf = (path, key) => path ? `${path}.${key}` : key;
12
+ const trimmed = (value, cap, path, cuts) => {
13
+ if (typeof value === "string") {
14
+ if (value.length <= cap) return value;
15
+ cuts.push({
16
+ field: path,
17
+ length: value.length
18
+ });
19
+ return `${value.slice(0, cap)}…`;
20
+ }
21
+ if (Array.isArray(value)) return value.map((item, index) => trimmed(item, cap, `${path}[${index}]`, cuts));
22
+ if (value !== null && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, !path && neverCut.has(key) ? item : trimmed(item, cap, pathOf(path, key), cuts)]));
23
+ return value;
24
+ };
25
+ const longestString = (value) => {
26
+ if (typeof value === "string") return value.length;
27
+ if (value !== null && typeof value === "object") return Math.max(0, ...Object.values(value).map(longestString));
28
+ return 0;
29
+ };
30
+ const cutAt = (result, cap, note) => {
31
+ const cuts = [];
32
+ const value = trimmed(result, cap, "", cuts);
33
+ return JSON.stringify(cuts.length ? {
34
+ ...value,
35
+ cut_fields: cuts,
36
+ note
37
+ } : value);
38
+ };
39
+ /**
40
+ * `result` as JSON within `budget`, its strings trimmed as little as fits but to no fewer than `floor` characters;
41
+ * undefined when even that does not fit. `note` says how to read a cut field's rest.
42
+ */
43
+ const budgetedJson = (result, note, budget = resultBudget, floor = stringFloor) => {
44
+ const json = JSON.stringify(result);
45
+ if (json.length <= budget) return json;
46
+ const plain = JSON.parse(json);
47
+ let low = floor;
48
+ let high = longestString(plain);
49
+ if (low >= high) return void 0;
50
+ if (cutAt(plain, low, note).length > budget) return void 0;
51
+ while (high - low > 1) {
52
+ const cap = Math.floor((low + high) / 2);
53
+ if (cutAt(plain, cap, note).length <= budget) low = cap;
54
+ else high = cap;
55
+ }
56
+ return cutAt(plain, low, note);
57
+ };
58
+ /** `result` as JSON within `budget` if it can be, its strings trimmed down to nothing when they must; never undefined. */
59
+ const forcedJson = (result, note, budget = resultBudget) => budgetedJson(result, note, budget) ?? budgetedJson(result, note, budget, 0) ?? cutAt(JSON.parse(JSON.stringify(result)), 0, note);
60
+ /** The most leading items of `count`, at least 1, for which `fits` holds, `fits` holding for fewer when it does for more. */
61
+ const leadingFit = (count, fits) => {
62
+ let low = 1;
63
+ let high = count;
64
+ while (low < high) {
65
+ const middle = Math.ceil((low + high) / 2);
66
+ if (fits(middle)) low = middle;
67
+ else high = middle - 1;
68
+ }
69
+ return low;
70
+ };
71
+ //#endregion
72
+ export { budgetedJson, forcedJson, leadingFit, resultBudget };
@@ -1,5 +1,6 @@
1
1
  import { Session } from "../types.js";
2
2
  import { ToolRecords } from "./records.js";
3
+ import { ToolComments } from "../comments/tool-comment-types.js";
3
4
  import { Conversation } from "./conversations.js";
4
5
  import { ApprovalAnswer, ApprovalRequest } from "./approval.js";
5
6
  import { Locale } from "@protobase/schema/internal";
@@ -24,6 +25,8 @@ export type ToolContext = {
24
25
  records: ToolRecords;
25
26
  /** The resources the caller may read, each restricted to their fields, with their views. */
26
27
  resources: () => Promise<ReadableResources>;
28
+ /** Records' comments as the session's user, which the MCP endpoint gives its tools; absent elsewhere. */
29
+ comments?: ToolComments;
27
30
  };
28
31
  /** MCP's tool hints, which an MCP client may show or act on; the dock ignores them. */
29
32
  export type ToolAnnotations = {
@@ -0,0 +1,41 @@
1
+ //#region src/assistant/write-echo.ts
2
+ const fieldsParameter = {
3
+ type: "array",
4
+ items: { type: "string" },
5
+ description: "More fields of the written record to return than its key, etag, the fields written and the read-only fields that changed; [\"*\"] returns all of them."
6
+ };
7
+ /** `fields` as a list of field names, absent being none; undefined for any other value. */
8
+ const echoFieldsOf = (value) => {
9
+ if (value === void 0) return [];
10
+ if (Array.isArray(value) && value.every((name) => typeof name === "string" && name !== "")) return value;
11
+ };
12
+ const invalidFields = JSON.stringify({
13
+ error: "invalid-parameter",
14
+ message: "Invalid parameter fields: pass a list of field names, or [\"*\"] for all"
15
+ });
16
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
17
+ const readOnlyChanged = (model, record, before) => Object.values(model?.fields ?? {}).filter((field) => field.readOnly && (before ? !same(before[field.name], record[field.name]) : record[field.name] !== null)).map((field) => field.name);
18
+ const keyOf = (model, record) => {
19
+ const parts = model.primaryKey.map((name) => record[name]);
20
+ return parts.length === 1 ? parts[0] : parts;
21
+ };
22
+ const pick = (record, names) => Object.fromEntries(names.filter((name) => Object.hasOwn(record, name)).map((name) => [name, record[name]]));
23
+ /**
24
+ * The written record, small: `before` is the record as the caller read it before the write, undefined for a create or
25
+ * one they could not read. Without the resource's model, the key and the read-only fields are unknown and left out.
26
+ */
27
+ const writeEcho = (model, written, data, before, fields) => {
28
+ const { record, etag } = written;
29
+ const echoed = fields.includes("*") ? record : pick(record, [
30
+ ...Object.keys(data),
31
+ ...readOnlyChanged(model, record, before),
32
+ ...fields
33
+ ]);
34
+ return {
35
+ ...model && { key: keyOf(model, record) },
36
+ etag,
37
+ record: echoed
38
+ };
39
+ };
40
+ //#endregion
41
+ export { echoFieldsOf, fieldsParameter, invalidFields, writeEcho };
@@ -3,9 +3,19 @@ import { defineMessages } from "../locale/messages.js";
3
3
  /** The texts of the approvals the write tools ask for, which the user reads in their client. */
4
4
  const writeText = defineMessages({
5
5
  en: {
6
- createTitle: (resource, title) => `Create ${resource}${title === void 0 ? "" : ` "${title}"`}?`,
7
- updateTitle: (record) => `Update ${record}?`,
8
- deleteTitle: (record) => `Delete ${record}?`,
6
+ createAction: (resource, title) => `Create ${resource}${title === void 0 ? "" : ` "${title}"`}`,
7
+ updateAction: (record) => `Update ${record}`,
8
+ deleteAction: (record) => `Delete ${record}`,
9
+ asked: (action) => `${action}?`,
10
+ batchTitle: (count) => `Run ${count} ${count === 1 ? "write" : "writes"} in one transaction?`,
11
+ allOrNone: "Either all of them are written or none.",
12
+ permanentDeletes: (count) => `${count} ${count === 1 ? "delete cannot" : "deletes cannot"} be undone: those records are removed for good.`,
13
+ moreWrites: (count) => `And ${count} more ${count === 1 ? "write" : "writes"}, not listed.`,
14
+ fromWrite: (resource, write) => `the ${resource} from write ${write}`,
15
+ writeAll: "Write all",
16
+ noFields: "No fields.",
17
+ commentTitle: (record) => `Comment on ${record}?`,
18
+ comment: "Comment",
9
19
  create: "Create",
10
20
  update: "Update",
11
21
  delete: "Delete",
@@ -20,9 +30,19 @@ const writeText = defineMessages({
20
30
  no: "No"
21
31
  },
22
32
  nl: {
23
- createTitle: (resource, title) => `${resource}${title === void 0 ? "" : ` "${title}"`} aanmaken?`,
24
- updateTitle: (record) => `${record} bijwerken?`,
25
- deleteTitle: (record) => `${record} verwijderen?`,
33
+ createAction: (resource, title) => `${resource}${title === void 0 ? "" : ` "${title}"`} aanmaken`,
34
+ updateAction: (record) => `${record} bijwerken`,
35
+ deleteAction: (record) => `${record} verwijderen`,
36
+ asked: (action) => `${action}?`,
37
+ batchTitle: (count) => `${count} ${count === 1 ? "schrijfactie" : "schrijfacties"} in één transactie uitvoeren?`,
38
+ allOrNone: "Ze worden allemaal opgeslagen of geen enkele.",
39
+ permanentDeletes: (count) => `${count} ${count === 1 ? "verwijdering kan" : "verwijderingen kunnen"} niet ongedaan worden gemaakt: die records worden definitief verwijderd.`,
40
+ moreWrites: (count) => `En nog ${count} ${count === 1 ? "schrijfactie" : "schrijfacties"}, niet vermeld.`,
41
+ fromWrite: (resource, write) => `de ${resource} uit schrijfactie ${write}`,
42
+ writeAll: "Alles opslaan",
43
+ noFields: "Geen velden.",
44
+ commentTitle: (record) => `Reageren op ${record}?`,
45
+ comment: "Reageren",
26
46
  create: "Aanmaken",
27
47
  update: "Bijwerken",
28
48
  delete: "Verwijderen",
@@ -13,6 +13,7 @@ const shown = (text) => {
13
13
  const line = text.replace(hiddenCharacters, " ");
14
14
  return line.length > longest ? `${line.slice(0, 199)}…` : line;
15
15
  };
16
+ /** The resource's singular label, as a record page names it. */
16
17
  const resourceLabel = ({ name, view }) => shown(view?.names?.singular ?? humanize(name));
17
18
  const fieldLabel = ({ view }, name) => shown(view?.fields[name]?.label ?? humanize(name));
18
19
  const shownValue = (text, described, name, value) => {
@@ -37,21 +38,19 @@ const titleOfNew = (described, data) => {
37
38
  return title === "" ? void 0 : title;
38
39
  };
39
40
  const same = (before, after) => JSON.stringify(before ?? null) === JSON.stringify(after ?? null);
40
- /** `Create Task "Fix the sign-in lockout"?`, with each field the new record is given by its label. */
41
- const createApproval = (text, described, data) => ({
42
- title: text.createTitle(resourceLabel(described), titleOfNew(described, data)),
41
+ /** A create as one change: `Create Task "Fix the sign-in lockout"`, and each field the new record is given by its label. */
42
+ const createChange = (text, described, data) => ({
43
+ action: text.createAction(resourceLabel(described), titleOfNew(described, data)),
43
44
  fields: Object.entries(data).map(([name, value]) => ({
44
45
  label: fieldLabel(described, name),
45
46
  value: shownValue(text, described, name, value)
46
- })),
47
- approveLabel: text.create,
48
- rejectLabel: text.reject
47
+ }))
49
48
  });
50
49
  /**
51
- * `Update Task #42 "Fix the sign-in lockout"?`, with each field that changes as `In flight -> Done`. A field the
52
- * caller cannot read before, such as a sensitive one, shows its new value alone.
50
+ * An update of the record named `record` as one change: `Update Task #42 "Fix the sign-in lockout"`, and each field that
51
+ * changes as `In flight -> Done`. A field the caller cannot read before, such as a sensitive one, shows its new value alone.
53
52
  */
54
- const updateApproval = (text, described, key, current, data) => {
53
+ const updateChange = (text, described, record, current, data) => {
55
54
  const fields = Object.entries(data).filter(([name, value]) => !Object.hasOwn(current, name) || !same(current[name], value)).map(([name, value]) => {
56
55
  const after = shownValue(text, described, name, value);
57
56
  const before = Object.hasOwn(current, name) ? `${shownValue(text, described, name, current[name])} -> ` : "";
@@ -61,7 +60,25 @@ const updateApproval = (text, described, key, current, data) => {
61
60
  };
62
61
  });
63
62
  return {
64
- title: text.updateTitle(recordName(described, key, current)),
63
+ action: text.updateAction(record),
64
+ fields
65
+ };
66
+ };
67
+ /** `Create Task "Fix the sign-in lockout"?`, with each field the new record is given by its label. */
68
+ const createApproval = (text, described, data) => {
69
+ const { action, fields } = createChange(text, described, data);
70
+ return {
71
+ title: text.asked(action),
72
+ fields,
73
+ approveLabel: text.create,
74
+ rejectLabel: text.reject
75
+ };
76
+ };
77
+ /** `Update Task #42 "Fix the sign-in lockout"?`, with each field that changes, or saying none does. */
78
+ const updateApproval = (text, described, key, current, data) => {
79
+ const { action, fields } = updateChange(text, described, recordName(described, key, current), current, data);
80
+ return {
81
+ title: text.asked(action),
65
82
  ...fields.length > 0 ? { fields } : { body: text.noChanges },
66
83
  approveLabel: text.update,
67
84
  rejectLabel: text.reject
@@ -69,11 +86,19 @@ const updateApproval = (text, described, key, current, data) => {
69
86
  };
70
87
  /** `Delete Task #42 "Fix the sign-in lockout"?`, saying whether it can be restored (a soft delete) or is gone for good. */
71
88
  const deleteApproval = (text, described, key, current, restorable) => ({
72
- title: text.deleteTitle(recordName(described, key, current)),
89
+ title: text.asked(text.deleteAction(recordName(described, key, current))),
73
90
  body: restorable ? text.restorable : text.permanent,
74
91
  tone: restorable ? "warning" : "danger",
75
92
  approveLabel: restorable ? text.delete : text.deleteForGood,
76
93
  rejectLabel: text.reject
77
94
  });
95
+ const hiddenInComment = /[\p{Cf}\p{Zl}\p{Zp}]|[^\P{Cc}\n]/gu;
96
+ /** `Comment on Task #42 "Fix the sign-in lockout"?`, with the whole comment, as it will be posted. */
97
+ const commentApproval = (text, described, key, current, comment) => ({
98
+ title: text.commentTitle(recordName(described, key, current)),
99
+ body: comment.trim().replace(hiddenInComment, " "),
100
+ approveLabel: text.comment,
101
+ rejectLabel: text.reject
102
+ });
78
103
  //#endregion
79
- export { createApproval, deleteApproval, describedResource, updateApproval };
104
+ export { commentApproval, createApproval, createChange, deleteApproval, describedResource, recordName, resourceLabel, shown, updateApproval, updateChange };