@superpanel-sdk/runtime 0.5.0 → 0.7.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 (189) hide show
  1. package/README.md +3 -3
  2. package/dist/builder/codegen/emit.d.ts +5 -10
  3. package/dist/builder/codegen/emit.js +100 -106
  4. package/dist/builder/codegen/generate.d.ts +3 -5
  5. package/dist/builder/codegen/generate.js +13 -32
  6. package/dist/builder/company-imports.d.ts +5 -0
  7. package/dist/builder/company-imports.js +9 -1
  8. package/dist/builder/compile/compile-sequence.d.ts +13 -11
  9. package/dist/builder/compile/compile-sequence.js +135 -83
  10. package/dist/builder/compile/function-code.d.ts +15 -9
  11. package/dist/builder/compile/function-code.js +59 -29
  12. package/dist/builder/compile/registry.d.ts +4 -4
  13. package/dist/builder/compile/registry.js +14 -12
  14. package/dist/builder/errors.d.ts +9 -0
  15. package/dist/builder/errors.js +17 -0
  16. package/dist/builder/function-params.d.ts +10 -0
  17. package/dist/builder/function-params.js +203 -0
  18. package/dist/builder/load.d.ts +17 -17
  19. package/dist/builder/load.js +27 -25
  20. package/dist/builder/paths.d.ts +6 -1
  21. package/dist/builder/paths.js +16 -2
  22. package/dist/builder/schedules.d.ts +9 -7
  23. package/dist/builder/schedules.js +20 -21
  24. package/dist/builder/typecheck.d.ts +1 -1
  25. package/dist/builder/typecheck.js +3 -6
  26. package/dist/builder/types.d.ts +41 -5
  27. package/dist/builder/types.js +19 -0
  28. package/dist/cli/calls/index.d.ts +177 -0
  29. package/dist/cli/calls/index.js +96 -0
  30. package/dist/cli/debug/index.d.ts +31 -0
  31. package/dist/cli/debug/index.js +119 -0
  32. package/dist/cli/exec/index.d.ts +39 -0
  33. package/dist/cli/exec/index.js +138 -0
  34. package/dist/cli/exec/session.d.ts +48 -0
  35. package/dist/cli/exec/session.js +82 -0
  36. package/dist/cli/exec/trace.d.ts +18 -0
  37. package/dist/cli/exec/trace.js +52 -0
  38. package/dist/cli/index.js +84 -1
  39. package/dist/cli/logs/index.d.ts +47 -0
  40. package/dist/cli/logs/index.js +113 -0
  41. package/dist/cli/mobile/index.d.ts +12 -0
  42. package/dist/cli/mobile/index.js +405 -0
  43. package/dist/cli/serve/index.d.ts +40 -4
  44. package/dist/cli/serve/index.js +167 -28
  45. package/dist/cli/turns/index.d.ts +105 -0
  46. package/dist/cli/turns/index.js +128 -0
  47. package/dist/cli/turns/run.d.ts +13 -0
  48. package/dist/cli/turns/run.js +57 -0
  49. package/dist/cli/turns/shadow.d.ts +112 -0
  50. package/dist/cli/turns/shadow.js +54 -0
  51. package/dist/cli/type-gen/index.js +0 -1
  52. package/dist/compute-host/compute-host.d.ts +5 -6
  53. package/dist/compute-host/compute-host.js +4 -13
  54. package/dist/compute-host/source-compute-host.d.ts +5 -7
  55. package/dist/compute-host/source-compute-host.js +12 -33
  56. package/dist/core/case-orchestrator/contact-hours.d.ts +26 -6
  57. package/dist/core/case-orchestrator/contact-hours.js +27 -8
  58. package/dist/core/case-orchestrator/index.d.ts +1 -4
  59. package/dist/core/case-orchestrator/index.js +1 -4
  60. package/dist/core/case-orchestrator/render.d.ts +1 -1
  61. package/dist/core/case-orchestrator/render.js +15 -10
  62. package/dist/core/case-orchestrator/types.d.ts +83 -128
  63. package/dist/core/case-orchestrator/types.js +0 -4
  64. package/dist/core/context/email-message.d.ts +40 -1
  65. package/dist/core/context/email-message.js +27 -0
  66. package/dist/core/context/email.d.ts +13 -1
  67. package/dist/core/context/email.js +2 -3
  68. package/dist/core/context/exchanges.d.ts +25 -0
  69. package/dist/core/context/exchanges.js +25 -7
  70. package/dist/core/context/instances.d.ts +31 -2
  71. package/dist/core/context/instances.js +18 -4
  72. package/dist/core/context/platform-registry.d.ts +35 -0
  73. package/dist/core/context/sms.d.ts +30 -0
  74. package/dist/core/context/sms.js +20 -0
  75. package/dist/core/context/types.d.ts +29 -1
  76. package/dist/core/conversation/index.d.ts +58 -4
  77. package/dist/core/conversation/index.js +123 -4
  78. package/dist/core/functions/errors.d.ts +74 -0
  79. package/dist/core/functions/errors.js +26 -0
  80. package/dist/core/functions/events.d.ts +23 -78
  81. package/dist/core/functions/events.js +8 -17
  82. package/dist/core/functions/schedules.d.ts +9 -10
  83. package/dist/core/functions/schedules.js +2 -2
  84. package/dist/core/functions/types.d.ts +17 -72
  85. package/dist/core/functions/types.js +1 -9
  86. package/dist/core/page/define.d.ts +230 -160
  87. package/dist/core/page/define.js +145 -104
  88. package/dist/core/page/envelope.d.ts +55 -0
  89. package/dist/core/page/envelope.js +4 -0
  90. package/dist/core/page/fns.d.ts +448 -0
  91. package/dist/core/page/fns.js +539 -0
  92. package/dist/core/page/memory-reader.d.ts +27 -0
  93. package/dist/core/page/memory-reader.js +299 -0
  94. package/dist/core/page/page-host.d.ts +20 -0
  95. package/dist/core/page/page-host.js +57 -0
  96. package/dist/core/page/records-query.d.ts +234 -0
  97. package/dist/core/page/records-query.js +780 -0
  98. package/dist/core/page/render.d.ts +67 -25
  99. package/dist/core/page/render.js +492 -272
  100. package/dist/core/page/sandbox.d.ts +85 -0
  101. package/dist/core/page/sandbox.js +357 -0
  102. package/dist/core/page/time-format.d.ts +27 -0
  103. package/dist/core/page/time-format.js +84 -0
  104. package/dist/core/page/typed.d.ts +165 -159
  105. package/dist/core/page/workspace.d.ts +25 -1
  106. package/dist/core/page/workspace.js +48 -13
  107. package/dist/core/party/schema.d.ts +74 -3
  108. package/dist/core/party/schema.js +39 -6
  109. package/dist/core/party/types.d.ts +8 -13
  110. package/dist/core/sequence/define.d.ts +1 -2
  111. package/dist/core/slot/company-metadata.d.ts +11 -22
  112. package/dist/core/slot/company-metadata.js +12 -39
  113. package/dist/core/slot/compile-schema.js +6 -6
  114. package/dist/core/slot/field.d.ts +1 -2
  115. package/dist/core/slot/field.js +0 -2
  116. package/dist/core/slot/index.d.ts +3 -3
  117. package/dist/core/slot/index.js +3 -3
  118. package/dist/core/slot/naming.d.ts +1 -1
  119. package/dist/core/slot/naming.js +1 -1
  120. package/dist/core/slot/platform-fields.d.ts +4 -4
  121. package/dist/core/slot/platform-fields.js +5 -5
  122. package/dist/core/slot/schema-compat.d.ts +8 -7
  123. package/dist/core/slot/schema-compat.js +22 -31
  124. package/dist/core/slot/schema.d.ts +3 -59
  125. package/dist/core/slot/schema.js +0 -15
  126. package/dist/core/slot/types.d.ts +4 -10
  127. package/dist/core/slot/value.d.ts +2 -0
  128. package/dist/core/slot/value.js +6 -2
  129. package/dist/dev-overlay/protocol.d.ts +38 -5
  130. package/dist/fn-context/build-context.js +10 -4
  131. package/dist/fn-context/fake/index.d.ts +2 -0
  132. package/dist/fn-context/fake/index.js +5 -3
  133. package/dist/fn-context/fn-params.d.ts +2 -5
  134. package/dist/fn-context/fn-params.js +6 -9
  135. package/dist/fn-context/index.d.ts +1 -1
  136. package/dist/fn-context/make-ctx.js +5 -4
  137. package/dist/fn-context/rpc/case-orchestrator-tools.d.ts +11 -3
  138. package/dist/fn-context/rpc/case-orchestrator-tools.js +0 -1
  139. package/dist/fn-context/rpc/errors.d.ts +13 -0
  140. package/dist/fn-context/rpc/errors.js +22 -0
  141. package/dist/fn-context/rpc/index.d.ts +1 -1
  142. package/dist/fn-context/rpc/index.js +1 -1
  143. package/dist/fn-context/rpc/integration/index.d.ts +11 -1
  144. package/dist/fn-context/rpc/log.d.ts +11 -1
  145. package/dist/fn-context/rpc/platform.d.ts +11 -1
  146. package/dist/fn-context/rpc/records.d.ts +13 -6
  147. package/dist/fn-context/rpc/records.js +2 -4
  148. package/dist/fn-context/rpc/trpc-client.d.ts +42 -0
  149. package/dist/fn-context/rpc/trpc-client.js +151 -22
  150. package/dist/fn-context/rpc/trpc-init.d.ts +19 -1
  151. package/dist/fn-context/rpc/trpc-init.js +10 -4
  152. package/dist/fn-context/rpc/trpc-router-factory.d.ts +66 -8
  153. package/dist/fn-context/run-scope.js +1 -1
  154. package/dist/fn-context/types.d.ts +20 -6
  155. package/dist/index.d.ts +11 -12
  156. package/dist/index.js +5 -8
  157. package/dist/logger.d.ts +4 -0
  158. package/dist/logger.js +8 -0
  159. package/dist/platform.d.ts +32 -33
  160. package/dist/platform.js +23 -22
  161. package/dist/testing-compile-child.d.ts +2 -0
  162. package/dist/testing-compile-child.js +20 -0
  163. package/dist/testing.d.ts +368 -0
  164. package/dist/testing.js +439 -0
  165. package/dist/voice-service/actions.d.ts +12 -35
  166. package/dist/voice-service/index.d.ts +2 -0
  167. package/dist/voice-service/index.js +2 -0
  168. package/dist/voice-service/protocol.d.ts +604 -0
  169. package/dist/voice-service/protocol.js +67 -0
  170. package/dist/voice-service/types.d.ts +16 -6
  171. package/dist/worker.d.ts +2 -1
  172. package/dist/worker.js +2 -1
  173. package/package.json +20 -16
  174. package/dist/core/case-orchestrator/follow-ups.d.ts +0 -31
  175. package/dist/core/case-orchestrator/follow-ups.js +0 -103
  176. package/dist/core/case-orchestrator/tools.d.ts +0 -29
  177. package/dist/core/case-orchestrator/tools.js +0 -31
  178. package/dist/core/case-orchestrator/validate.d.ts +0 -11
  179. package/dist/core/case-orchestrator/validate.js +0 -63
  180. package/dist/core/functions/define/bind.d.ts +0 -25
  181. package/dist/core/functions/define/bind.js +0 -14
  182. package/dist/core/functions/define/case.d.ts +0 -9
  183. package/dist/core/functions/define/case.js +0 -13
  184. package/dist/core/functions/define/events.d.ts +0 -20
  185. package/dist/core/functions/define/events.js +0 -21
  186. package/dist/core/functions/define/no-case.d.ts +0 -4
  187. package/dist/core/functions/define/no-case.js +0 -9
  188. package/dist/core/functions/func-ref.d.ts +0 -25
  189. package/dist/core/functions/func-ref.js +0 -20
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Page document → `RenderedPage` (15 §10.9 output). Runs on the platform, after the page script
3
- * returned its document from the sandbox.
2
+ * A page's envelope → `RenderedPage` (15 §10.9 output). Runs on the platform, after the page script
3
+ * returned its page from the sandbox: the envelope the page module made of its JSX (31 §8).
4
4
  *
5
5
  * The document is walked once. Every slot becomes a cell joined with its schema definition; its value
6
6
  * is taken from what the platform itself read (`PageReads`), never from the document, and a slot
@@ -12,57 +12,67 @@
12
12
  * choose among the rows of their table there (15 §10.15 point 9). So lookups and actions are rendered
13
13
  * after the walk, once every row on the page is known.
14
14
  *
15
- * Cells are addressed by slot id `table#n.field` (`RowNames`: stable for a conversation, layout-free);
15
+ * Cells are addressed by slot id `table:<uuid tail>.field` (`RowNames`: the row's own, layout-free);
16
16
  * actions and blocks by their page path (`injuries.rows.0.markError`), resolved against the very page
17
17
  * the AI saw.
18
18
  */
19
19
  import { parentOf } from '../slot/company-metadata.js';
20
20
  import { ROW_ID_FIELD } from '../slot/schema.js';
21
21
  import { getCompiledTable, getTableField } from '../slot/compile-schema.js';
22
- import { isChoose } from './define.js';
22
+ import { isChoose, PAGE_TOOLS } from './define.js';
23
+ import { isEnvelope } from './envelope.js';
24
+ import { CONTACT_TABLE } from '../slot/company-metadata.js';
25
+ import { fallbackSpoken } from '../conversation/index.js';
23
26
  // ---------------------------------------------------------------------------------------------
24
27
  // Row names (15 §10.6.2)
25
28
  /**
26
- * `table#n` for each row a conversation has seen: the same row keeps its name on every page of the
27
- * conversation; names only grow and are never reused, so a late output naming a deleted row's name
28
- * cannot reach another row. Serialisable (`toJSON` / `fromJSON`) so a session can keep it.
29
+ * `table:` and the last 12 hex digits of the row's uuid (33): what the row is called in logs, the
30
+ * timeline and the database too, so the same in every conversation and every process; nothing to keep.
31
+ * A name comes from the id alone, never from which row came first: two rows of a table alike in those
32
+ * digits (one page of n rows: about n² / 2⁴⁹) are both named by their whole id from then on, and the
33
+ * short name they share resolves to neither. So a name from an earlier conversation, or another
34
+ * process, means the row it meant or no row (the AI is told), never another row. A name resolves only
35
+ * to a row named here, and the AI's names are checked against the page's own (`one-of`, `enum`) first.
29
36
  */
30
37
  export class RowNames {
31
- byId = new Map();
32
- byName = new Map();
33
- counters = new Map();
38
+ /** The rows named here: id → table. */
39
+ tables = new Map();
40
+ /** A short name → the ids of its table ending in its digits. */
41
+ byShort = new Map();
34
42
  name(table, id) {
35
- const known = this.byId.get(id);
36
- if (known)
37
- return known;
38
- const n = (this.counters.get(table) ?? 0) + 1;
39
- this.counters.set(table, n);
40
- const name = `${table}#${n}`;
41
- this.byId.set(id, name);
42
- this.byName.set(name, { table, id });
43
- return name;
43
+ this.tables.set(id, table);
44
+ const short = shortRowName(table, id);
45
+ let ids = this.byShort.get(short);
46
+ if (!ids)
47
+ this.byShort.set(short, (ids = new Set()));
48
+ ids.add(id);
49
+ return ids.size > 1 ? `${table}:${id}` : short;
44
50
  }
45
51
  resolve(name) {
46
- return this.byName.get(name);
52
+ const at = name.indexOf(':');
53
+ if (at < 0)
54
+ return undefined;
55
+ const table = name.slice(0, at);
56
+ const rest = name.slice(at + 1);
57
+ if (this.tables.get(rest) === table)
58
+ return { table, id: rest };
59
+ const ids = this.byShort.get(name);
60
+ if (ids?.size !== 1)
61
+ return undefined;
62
+ const [id] = ids;
63
+ return { table, id: id };
47
64
  }
48
65
  nameOf(id) {
49
- return this.byId.get(id);
50
- }
51
- toJSON() {
52
- return [...this.byName.entries()].map(([name, { table, id }]) => [name, table, id]);
53
- }
54
- static fromJSON(entries) {
55
- const names = new RowNames();
56
- for (const [name, table, id] of entries) {
57
- names.byId.set(id, name);
58
- names.byName.set(name, { table, id });
59
- const n = Number(name.slice(name.lastIndexOf('#') + 1));
60
- if (n > (names.counters.get(table) ?? 0))
61
- names.counters.set(table, n);
62
- }
63
- return names;
66
+ const table = this.tables.get(id);
67
+ if (table === undefined)
68
+ return undefined;
69
+ const short = shortRowName(table, id);
70
+ return this.byShort.get(short).size > 1 ? `${table}:${id}` : short;
64
71
  }
65
72
  }
73
+ function shortRowName(table, id) {
74
+ return `${table}:${id.slice(-12)}`;
75
+ }
66
76
  export class PageDocumentError extends Error {
67
77
  constructor(message) {
68
78
  super(message);
@@ -72,30 +82,23 @@ export class PageDocumentError extends Error {
72
82
  const MAX_DEPTH = 32;
73
83
  /** Channel actions only a call can do; by text only `end` (17b §9.8). */
74
84
  const CALL_ONLY = new Set(['transfer', 'keypad', 'announce', 'voicemail']);
85
+ /** Tools only a call can use (76 §2.18). */
86
+ const CALL_ONLY_TOOLS = new Set(['pressKeypad']);
75
87
  export function renderDocument(input) {
76
- const perform = performOf(input.document);
77
- if (perform?.action.$action === 'channel' && input.channel !== undefined && input.channel !== 'call' && CALL_ONLY.has(perform.action.channel)) {
78
- throw new PageDocumentError(`the page function returned ${perform.action.channel}, which only a call can do (this is ${input.channel})`);
79
- }
80
- if (perform) {
81
- // An action to run now: rendered as a button on a page of its own, so its rows are checked the same way.
82
- const inner = renderDocument({ ...input, document: { action: perform.action } });
83
- const action = inner.actions['action'];
84
- if (!action)
85
- throw new PageDocumentError(`the action the page function returned cannot run: ${inner.warnings.join('; ') || 'no row to run it on'}`);
86
- return { ...inner, auto: { action, key: perform.key ?? actionKey(perform.action), wait: perform.wait ?? true } };
87
- }
88
+ // Only what the page module marked (31 §8): an object shaped like a button is not one.
89
+ if (!isEnvelope(input.document))
90
+ throw new PageDocumentError('the page function returns page(…) or doNow(…) (76 §2.1)');
91
+ const envelope = input.document;
92
+ if ('do' in envelope)
93
+ return renderDo(input, envelope.do);
88
94
  const { schema, reads, names } = input;
89
- if (!isPlainObject(input.document))
90
- throw new PageDocumentError('the page script must return an object');
95
+ const envelopePage = envelope.page;
91
96
  const cells = {};
92
97
  const actions = {};
93
98
  const rows = {};
94
99
  const warnings = [];
95
- /** Rows with a cell on the page, by table (page names). */
96
- const cellRows = new Map();
97
100
  const pendingActions = [];
98
- const extractions = pageExtractions(input.document, schema, warnings);
101
+ const extractions = pageExtractions(envelopePage.extraction, schema, warnings);
99
102
  const rowName = (table, id) => {
100
103
  const name = names.name(table, id);
101
104
  if (!rows[name]) {
@@ -105,7 +108,6 @@ export function renderDocument(input) {
105
108
  }
106
109
  return name;
107
110
  };
108
- const rowsOnPage = (table) => [...(cellRows.get(table) ?? [])];
109
111
  const cellOf = (key, path, value) => {
110
112
  const refCell = isRefFieldCell(value) ? value : null;
111
113
  const fieldCell = isFieldCell(value) ? value : refCell ? { $field: true, slot: refCell.slot, ...(refCell.readOnly ? { readOnly: true } : {}) } : null;
@@ -116,33 +118,30 @@ export function renderDocument(input) {
116
118
  const read = reads.row(ref.id);
117
119
  const table = getCompiledTable(schema, ref.table);
118
120
  const def = table?.fields[ref.field];
121
+ // The row id and the parent never change and never are empty: nothing a field could show or open.
122
+ // Who the row belongs to is said in words, `text(…)` (31 §4).
123
+ const isParent = table ? parentOf(table)?.field === ref.field : false;
124
+ if (isParent || ref.field === ROW_ID_FIELD) {
125
+ throw new PageDocumentError(`${path}: ${ref.table}.${ref.field} is ${isParent ? 'the parent' : 'the row id'}: it never changes, so it is not a field on the page; say who the row belongs to with text(…), e.g. case: text("Zhang San's car accident case")`);
126
+ }
119
127
  if (!read || read.table !== ref.table || !def || !(ref.field in read.slots)) {
120
128
  warnings.push(`${path}: a slot this run did not read (${ref.table}.${ref.field}); shown as read-only text`);
121
129
  return { kind: 'text', key, path, value: displayable(slot.value) };
122
130
  }
123
131
  const truth = read.slots[ref.field];
124
132
  const name = rowName(ref.table, ref.id);
125
- const onPage = cellRows.get(ref.table) ?? new Set();
126
- onPage.add(name);
127
- cellRows.set(ref.table, onPage);
128
133
  const slotId = `${name}.${ref.field}`;
129
- const isParent = parentOf(table)?.field === ref.field;
130
- const isLookup = Boolean(def.ref) && !isParent;
131
- // A ref field is placed with refField: its candidates and their labels are the page's (26 §16.2 #7).
132
- if (isLookup && fieldCell && !refCell)
133
- throw new PageDocumentError(`${path}: ${ref.table}.${ref.field} is a ref field: place it with refField(slot, { options })`);
134
+ const isLookup = Boolean(def.ref);
135
+ // A ref field is placed with refField, read-only too: its candidates and their labels, or who its value
136
+ // is, are the page's (26 §16.2 #7); a bare row name says nothing (31 §4).
137
+ if (isLookup && !refCell)
138
+ throw new PageDocumentError(`${path}: ${ref.table}.${ref.field} is a ref field: place it with refField(slot, { options }) (read-only, refField(slot, { readOnly: true, label: '…' }))`);
134
139
  if (refCell && !isLookup)
135
- throw new PageDocumentError(`${path}: refField(...) is for a ref field; ${ref.table}.${ref.field} is ${isParent ? 'the parent' : 'not a ref'}`);
140
+ throw new PageDocumentError(`${path}: refField(...) is for a ref field; ${ref.table}.${ref.field} is not a ref`);
136
141
  const offered = refCell ? refOptions(path, refCell, def.ref.table) : null;
137
- // The page decides (19-page-writable-cells): a slot placed as is is read-only, `field(...)` opens
138
- // it. Never the row id or the parent. A lookup opens where its table's rows are on the page
139
- // (settled below): the AI cannot make up a row id.
140
- const fixed = isParent || ref.field === ROW_ID_FIELD;
141
- if (fieldCell && !fieldCell.readOnly && fixed) {
142
- warnings.push(`${path}: field(...) cannot open ${ref.table}.${ref.field} (${isParent ? 'the parent' : 'the row id'}); it stays read-only`);
143
- }
142
+ // The page decides (19-page-writable-cells): `field(slot, { readOnly: true })` is read-only, `field(slot)` opens it.
144
143
  // A ref field with no row to offer is shown, not opened: nothing to choose (data, not a page mistake).
145
- const placeWritable = fieldCell !== null && !fieldCell.readOnly && !fixed && (!offered || offered.length > 0);
144
+ const placeWritable = fieldCell !== null && !fieldCell.readOnly && (!offered || offered.length > 0);
146
145
  const existing = cells[slotId];
147
146
  if (existing) {
148
147
  existing.writable ||= placeWritable;
@@ -170,6 +169,11 @@ export function renderDocument(input) {
170
169
  if (isLookup)
171
170
  cell.type = 'ref';
172
171
  cells[slotId] = cell;
172
+ // The row a ref holds is named even when nothing else of it is on the page (33): the AI reads
173
+ // `case_contact:3f9a2c1d7e04`, never a bare id. Named only: it does not join `rows`, which is
174
+ // what the page's actions may write.
175
+ if (def.ref && truth.state === 'filled' && typeof truth.value === 'string')
176
+ cell.valueRow = names.name(def.ref.table, truth.value);
173
177
  }
174
178
  if (offered) {
175
179
  const target = cells[slotId];
@@ -191,59 +195,104 @@ export function renderDocument(input) {
191
195
  throw new PageDocumentError(`${path}: refField offers ${option.id}, not a ${target} row this run read`);
192
196
  return { name: rowName(target, option.id), label: option.label };
193
197
  });
194
- const walk = (key, path, value, depth) => {
198
+ /**
199
+ * Data (31 §4): shown as it is, blocks, lists and text, whatever keys it has (`$action` too). Nothing in
200
+ * it is a field or a button: the page module marked those, and data cannot carry the mark.
201
+ */
202
+ const walkData = (key, path, value, depth) => {
195
203
  if (depth > MAX_DEPTH)
196
204
  throw new PageDocumentError(`${path || 'page'}: nested deeper than ${MAX_DEPTH}`);
197
- if (value === undefined || typeof value === 'function')
198
- return null;
199
- if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
205
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean')
200
206
  return { kind: 'text', key, path, value };
201
- }
202
- if (isAction(value)) {
203
- pendingActions.push({ path, value });
204
- return { kind: 'action', key, path, actionId: path };
205
- }
206
- const cell = cellOf(key, path, value);
207
- if (cell)
208
- return cell;
209
- if (Array.isArray(value)) {
210
- const children = value
211
- .map((item, i) => walk(String(i), join(path, String(i)), item, depth + 1))
212
- .filter((n) => n !== null);
213
- return { kind: 'block', key, path, list: true, children };
214
- }
207
+ if (Array.isArray(value))
208
+ return { kind: 'block', key, path, list: true, children: value.map((item, i) => walkData(String(i), join(path, String(i)), item, depth + 1)) };
215
209
  if (isPlainObject(value)) {
216
- if (isRowLike(value)) {
217
- warnings.push(`${path}: a whole row is not a cell; put its slots on the page`);
210
+ return { kind: 'block', key, path, list: false, children: Object.entries(value).map(([k, item]) => walkData(k, join(path, k), item, depth + 1)) };
211
+ }
212
+ return { kind: 'text', key, path, value: String(value) };
213
+ };
214
+ /** One node of the envelope (31 §8): what the page module marked as a block, a field, a button, or data. */
215
+ const walkEnvelope = (node, parent, place, depth) => {
216
+ if (depth > MAX_DEPTH)
217
+ throw new PageDocumentError(`${parent || 'page'}: nested deeper than ${MAX_DEPTH}`);
218
+ // Where it is, for messages: its name, or its place when it has none (46 §2.2).
219
+ const path = join(parent, node.key || `#${place}`);
220
+ switch (node.k) {
221
+ case 'block': {
222
+ const block = { kind: 'block', key: node.key, path, list: node.list, children: [] };
223
+ if (node.title !== undefined)
224
+ block.title = node.title;
225
+ if (node.description !== undefined)
226
+ block.description = node.description;
227
+ node.children.forEach((child, i) => {
228
+ const out = walkEnvelope(child, path, i, depth + 1);
229
+ if (out)
230
+ block.children.push(out);
231
+ });
232
+ return block;
218
233
  }
219
- const block = { kind: 'block', key, path, list: false, children: [] };
220
- if (typeof value.$title === 'string')
221
- block.title = value.$title;
222
- if (typeof value.$description === 'string')
223
- block.description = value.$description;
224
- pageMarks(block, value, path, depth === 0, warnings);
225
- for (const [childKey, child] of Object.entries(value)) {
226
- if (childKey.startsWith('$'))
227
- continue;
228
- // A button's id is the keys joined with '.': a key with a '.' in it would collide with a nested one (26 §16.2 #3).
229
- if (childKey.includes('.'))
230
- throw new PageDocumentError(`${join(path, childKey) || 'page'}: a key on the page cannot contain '.' (${JSON.stringify(childKey)})`);
231
- const node = walk(childKey, join(path, childKey), child, depth + 1);
232
- if (node)
233
- block.children.push(node);
234
+ case 'field': {
235
+ const cell = cellOf(node.key, path, node.cell);
236
+ if (!cell)
237
+ throw new PageDocumentError(`${path}: not a field`);
238
+ return cell;
239
+ }
240
+ case 'button': {
241
+ // Its id comes once every button is known (46 §2.1).
242
+ const action = { kind: 'action', key: '', path, actionId: '' };
243
+ pendingActions.push({ path, value: node.action, node: action });
244
+ return action;
245
+ }
246
+ case 'data':
247
+ return walkData(node.key, path, node.value, depth);
248
+ case 'toolbox': {
249
+ // The tools as buttons in their own block (76 §2.18): the AI presses one like any button.
250
+ const block = { kind: 'block', key: node.key, path, list: false, title: 'Tools', children: [] };
251
+ node.tools.forEach((tool, i) => {
252
+ if (!PAGE_TOOLS.includes(tool))
253
+ throw new PageDocumentError(`${path}: ${tool} is not a platform tool (${PAGE_TOOLS.join(', ')})`);
254
+ const at = join(path, String(i));
255
+ // A call tool on a text or an email page (76 §2.12): left off, said in the log.
256
+ if (input.channel !== undefined && input.channel !== 'call' && CALL_ONLY_TOOLS.has(tool)) {
257
+ warnings.push(`${at}: ${tool}() is a call tool, and this is ${input.channel}: left off the page (place it only on a call)`);
258
+ return;
259
+ }
260
+ const action = { kind: 'action', key: '', path: at, actionId: '' };
261
+ pendingActions.push({ path: action.path, value: { $action: 'tool', tool: tool }, node: action });
262
+ block.children.push(action);
263
+ });
264
+ // Every tool left off (or none given): no toolbox on the page (76 §2.12).
265
+ return block.children.length ? block : null;
234
266
  }
235
- return block;
236
267
  }
237
- return { kind: 'text', key, path, value: String(value) };
238
268
  };
239
- const root = walk('', '', input.document, 0);
269
+ const root = { kind: 'block', key: '', path: '', list: false, children: [] };
270
+ if (envelopePage.title !== undefined)
271
+ root.title = envelopePage.title;
272
+ if (envelopePage.description !== undefined)
273
+ root.description = envelopePage.description;
274
+ if (envelopePage.guide?.trim())
275
+ root.guide = envelopePage.guide;
276
+ envelopePage.children.forEach((child, i) => {
277
+ const out = walkEnvelope(child, '', i, 1);
278
+ if (out)
279
+ root.children.push(out);
280
+ });
281
+ const voice = envelopePage.voice;
240
282
  // Every row on the page is known now: what lookups and row parameters may choose.
241
- const env = { schema, reads, rowName, rowsOnPage, warnings, ...(input.channel ? { channel: input.channel } : {}) };
242
- for (const { path, value } of pendingActions) {
283
+ const env = { schema, reads, rowName, warnings, ...(input.channel ? { channel: input.channel } : {}), ...(input.functions ? { functions: input.functions } : {}), ...(input.actions ? { actions: input.actions } : {}) };
284
+ const placed = [];
285
+ for (const { path, value, node } of pendingActions) {
243
286
  const rendered = renderAction(path, value, env);
244
287
  if (rendered)
245
- actions[path] = rendered;
288
+ placed.push({ action: rendered, node });
246
289
  }
290
+ const ids = buttonIds(placed.map((p) => p.action), rows);
291
+ placed.forEach(({ action, node }, i) => {
292
+ const id = ids[i];
293
+ node.actionId = id;
294
+ actions[id] = { ...action, id };
295
+ });
247
296
  // An action that cannot be pressed is not on the page.
248
297
  const prune = (block) => {
249
298
  block.children = block.children.filter((child) => child.kind !== 'action' || child.actionId in actions);
@@ -255,8 +304,27 @@ export function renderDocument(input) {
255
304
  const page = { root, cells, actions, rows, warnings };
256
305
  if (extractions.size)
257
306
  page.extractions = Object.fromEntries(extractions);
307
+ if (voice !== undefined)
308
+ page.voice = voice;
258
309
  return page;
259
310
  }
311
+ /**
312
+ * `doNow()` (76): what the page does now, rendered as a button would be so its rows are checked the same
313
+ * way. Nobody fills what the page left out: a required parameter it did not prefill is an error (30 §7.2).
314
+ */
315
+ function renderDo(input, run) {
316
+ if (run.action.$action === 'channel' && input.channel !== undefined && input.channel !== 'call' && CALL_ONLY.has(run.action.channel)) {
317
+ throw new PageDocumentError(`the page function returned ${run.action.channel}, which only a call can do (this is ${input.channel})`);
318
+ }
319
+ const inner = renderDocument({ ...input, document: { $envelope: 1, page: { children: [{ k: 'button', key: '', action: run.action }] } } });
320
+ const action = Object.values(inner.actions)[0];
321
+ if (!action)
322
+ throw new PageDocumentError(`the action the page function returned cannot run: ${inner.warnings.join('; ') || 'no row to run it on'}`);
323
+ const missing = action.input?.required ?? [];
324
+ if (missing.length)
325
+ throw new PageDocumentError(`doNow(): ${action.name} runs with nobody to fill ${missing.join(', ')}: prefill ${missing.length > 1 ? 'them' : 'it'}`);
326
+ return { ...inner, auto: { action, key: run.key ?? actionKey(run.action), wait: run.wait ?? true } };
327
+ }
260
328
  /**
261
329
  * The action as the AI presses it, or null when it cannot be pressed on this page: with a warning when
262
330
  * the page placed it wrong; silently when a required row choice has no row on the page (no injury yet
@@ -290,30 +358,65 @@ function unfillable(properties, required, prefix = '') {
290
358
  function renderActionOf(path, action, env) {
291
359
  const { schema, reads, rowName, warnings } = env;
292
360
  if (action.$action === 'channel') {
293
- // Not on this channel (a transfer by text): the types keep it to `ctx.call`, so reaching here is a bug.
361
+ // Not on this channel (a transfer by text, 76 §2.12): left off the page, said in the log; the page
362
+ // should check the channel itself.
294
363
  if (env.channel !== undefined && env.channel !== 'call' && CALL_ONLY.has(action.channel)) {
295
- throw new PageDocumentError(`${path}: ${action.channel} is a call action, and this is ${env.channel} (use ctx.call only on the call page)`);
364
+ env.warnings.push(`${path}: ${action.channel} is a call action, and this is ${env.channel}: left off the page (place it only on a call)`);
365
+ return null;
296
366
  }
297
367
  return channelButton(path, action);
298
368
  }
369
+ if (action.$action === 'tool') {
370
+ if (env.channel !== undefined && env.channel !== 'call' && CALL_ONLY_TOOLS.has(action.tool)) {
371
+ env.warnings.push(`${path}: ${action.tool}() is a call tool, and this is ${env.channel}: left off the page (place it only on a call)`);
372
+ return null;
373
+ }
374
+ return toolButton(path, action.tool);
375
+ }
299
376
  if (action.$action === 'createRow') {
300
377
  const table = getCompiledTable(schema, action.table);
301
378
  if (!table)
302
379
  throw new PageDocumentError(`${path}: createRow on ${action.table}, not a table of this sequence`);
303
380
  const parent = parentOf(table);
304
- for (const key of Object.keys(action.prefill)) {
381
+ const fieldParam = (field) => ({ $schemaField: `${action.table}.${field}` });
382
+ const properties = {};
383
+ // A ref field is chosen among the rows the page gives (`options`, 35 §2), or fixed (`prefill`).
384
+ const chosen = [];
385
+ for (const [key, value] of Object.entries(action.prefill)) {
305
386
  if (!getTableField(table, key))
306
387
  throw new PageDocumentError(`${path}: createRow(${action.table}) prefills ${key}, not a field of it`);
388
+ if (isChoose(value)) {
389
+ if (parent?.field === key)
390
+ throw new PageDocumentError(`${path}: the parent ${key} is where the row goes, not the AI's choice: prefill it`);
391
+ const offered = chooseParam(`${path}: options.${key}`, fieldParam(key), value, env);
392
+ if (emptyOptional(offered, key, action.required))
393
+ continue;
394
+ properties[key] = offered;
395
+ chosen.push(key);
396
+ }
397
+ else if (parent?.field !== key) {
398
+ checkPrefill(`${path}: prefill.${key}`, fieldParam(key), value, env);
399
+ }
400
+ }
401
+ // A required field the page prefilled empty (null or '', as the write counts it): nobody fills it,
402
+ // and the write would fail (76 §2.14).
403
+ for (const key of action.required) {
404
+ const value = Object.hasOwn(action.prefill, key) ? action.prefill[key] : undefined;
405
+ if (value === null || value === '') {
406
+ throw new PageDocumentError(`${path}: createRow(${action.table}) ${key} is required: prefill a value, not ${value === null ? 'null' : "''"}, or leave it to the AI (fields)`);
407
+ }
307
408
  }
308
- const properties = {};
309
409
  for (const fieldName of action.fields) {
310
- if (!getTableField(table, fieldName))
410
+ const def = getTableField(table, fieldName);
411
+ if (!def)
311
412
  throw new PageDocumentError(`${path}: createRow field ${action.table}.${fieldName} is not in this sequence`);
312
413
  if (parent?.field === fieldName)
313
414
  throw new PageDocumentError(`${path}: the parent ${fieldName} is prefilled, not a field to fill`);
314
415
  if (fieldName in action.prefill)
315
416
  continue;
316
- properties[fieldName] = expandParam(path, { $schemaField: `${action.table}.${fieldName}` }, env);
417
+ if (def.ref)
418
+ throw new PageDocumentError(`${path}: ${action.table}.${fieldName} is a ref field: give the rows the AI chooses among, options: { ${fieldName}: choose(rows, label) }, or prefill it`);
419
+ properties[fieldName] = expandParam(path, fieldParam(fieldName), env);
317
420
  }
318
421
  // Where the new row hangs: prefilled, else the one parent this run's reads of the table were anchored on.
319
422
  let parentId = null;
@@ -334,9 +437,7 @@ function renderActionOf(path, action, env) {
334
437
  }
335
438
  rowName(parentRow.table, parentId);
336
439
  }
337
- const set = { ...action.prefill };
338
- if (parent)
339
- delete set[parent.field];
440
+ const set = Object.fromEntries(Object.entries(action.prefill).filter(([key, value]) => !isChoose(value) && key !== parent?.field));
340
441
  const under = parent && parentId ? ` under ${rowName(parent.table, parentId)}` : '';
341
442
  return {
342
443
  id: path,
@@ -344,7 +445,7 @@ function renderActionOf(path, action, env) {
344
445
  name: action.label ?? `Add ${table.label}`,
345
446
  description: action.description ?? `Adds a new ${table.label} (${action.table})${under}.`,
346
447
  input: { type: 'object', properties, required: action.required.filter((r) => r in properties) },
347
- server: { kind: 'createRow', table: action.table, parentId, set, fields: action.fields, required: action.required },
448
+ server: { kind: 'createRow', table: action.table, parentId, set, fields: [...action.fields, ...chosen], required: action.required, ...(action.key ? { key: action.key } : {}) },
348
449
  };
349
450
  }
350
451
  if (action.$action === 'deleteRow') {
@@ -360,40 +461,50 @@ function renderActionOf(path, action, env) {
360
461
  name: action.label ?? `Delete ${name}`,
361
462
  description: action.description ?? `Delete ${name} and everything under it.`,
362
463
  input: null,
363
- server: { kind: 'deleteRow', table: read.table, rowId: action.rowId, ...(action.reason ? { reason: action.reason } : {}) },
464
+ server: { kind: 'deleteRow', table: read.table, rowId: action.rowId, ...(action.reason ? { reason: action.reason } : {}), ...(action.key ? { key: action.key } : {}) },
364
465
  };
365
466
  }
366
- if (action.$action === 'message')
367
- return messageButton(path, action, env);
368
467
  if (action.$action === 'followUp')
369
- return followUpButton(path, action);
468
+ return followUpButton(path, action, env);
469
+ if (action.$action === 'cancelFollowUp')
470
+ return cancelFollowUpButton(path, action);
370
471
  if (action.$action === 'function') {
371
- const { input, prefill } = actionInput(path, action.name, action.parameters, action.prefill ?? {}, env);
472
+ // The function's parameters are its definition's (31 §7); a page may still declare them on the button.
473
+ const parameters = action.parameters ?? env.functions?.[action.name]?.parameters;
474
+ const { input, prefill } = actionInput(path, action.name, parameters, action.prefill ?? {}, env, action.args);
372
475
  return {
373
476
  id: path,
374
477
  kind: 'function',
375
478
  name: action.label ?? action.name,
376
479
  description: action.description ?? `Runs ${action.name}.`,
377
480
  input,
378
- server: { kind: 'function', name: action.name, ...(action.say ? { say: action.say } : {}), ...(Object.keys(prefill).length ? { prefill } : {}) },
481
+ server: { kind: 'function', name: action.name, ...(action.say ? { say: action.say } : {}), ...(Object.keys(prefill).length ? { prefill } : {}), ...(action.key ? { key: action.key } : {}) },
379
482
  };
380
483
  }
381
- const { input, prefill } = actionInput(path, action.name, action.parameters, action.prefill, env);
484
+ // A page action's parameters are its `args` type's, as the build read it (38 §2.9).
485
+ const parameters = action.parameters ?? env.actions?.[action.name]?.parameters;
486
+ const { input, prefill } = actionInput(path, action.name, parameters, action.prefill, env, action.args);
382
487
  return {
383
488
  id: path,
384
489
  kind: 'custom',
385
- name: action.name,
386
- description: action.description,
490
+ name: action.label ?? action.name,
491
+ description: action.description ?? `Runs ${action.name}.`,
387
492
  input,
388
- server: { kind: 'custom', name: action.name, prefill },
493
+ server: { kind: 'custom', name: action.name, prefill, ...(action.key ? { key: action.key } : {}) },
389
494
  };
390
495
  }
496
+ /** An optional row with no row to choose: not asked (a required one leaves its button off, `renderAction`). */
497
+ function emptyOptional(offered, key, required) {
498
+ return offered.type === 'ref' && offered.enum.length === 0 && !required.includes(key);
499
+ }
391
500
  /**
392
- * What the AI fills of an action's parameters, and what the page fixed. A prefilled row is checked and
393
- * put on the page; a `choose(...)` (25 §10) becomes the AI's choice among exactly those rows, each shown
394
- * with its label.
501
+ * What the AI fills of an action's parameters, and what the page fixed (35 §2). A prefilled row is
502
+ * checked and put on the page; a row in `options` (`choose(...)`) becomes the AI's choice among exactly
503
+ * those rows, each shown with its label. A row parameter with neither is the page's mistake: the AI is
504
+ * never left to guess among unlabelled rows. A required row with no row to choose stays, and
505
+ * `renderAction` leaves the button off; an optional one is not asked.
395
506
  */
396
- function actionInput(path, name, parameters, given, env) {
507
+ function actionInput(path, name, parameters, given, env, args = {}) {
397
508
  const declared = parameters?.properties ?? {};
398
509
  const properties = {};
399
510
  const prefill = {};
@@ -401,29 +512,55 @@ function actionInput(path, name, parameters, given, env) {
401
512
  if (!(key in declared))
402
513
  throw new PageDocumentError(`${path}: ${name} prefills ${key}, not one of its parameters`);
403
514
  }
515
+ // How to fill each one on this page is the page's (31 §7): the AI fills only what is not prefilled.
516
+ for (const [key, text] of Object.entries(args)) {
517
+ if (!(key in declared))
518
+ throw new PageDocumentError(`${path}: ${name} has no parameter ${key} to describe`);
519
+ if (key in given && !isChoose(given[key]))
520
+ throw new PageDocumentError(`${path}: ${name}'s ${key} is prefilled: the AI does not fill it, so it needs no description`);
521
+ if (typeof text !== 'string')
522
+ throw new PageDocumentError(`${path}: the description of ${key} must be text`);
523
+ }
524
+ const required = parameters?.required ?? [];
404
525
  for (const [key, param] of Object.entries(declared)) {
405
526
  const value = given[key];
406
527
  if (isChoose(value)) {
407
- properties[key] = chooseParam(`${path}: ${name}({ ${key} })`, param, value, env);
528
+ const chosen = chooseParam(`${path}: ${name} options.${key}`, param, value, env);
529
+ if (!emptyOptional(chosen, key, required))
530
+ properties[key] = chosen;
408
531
  continue;
409
532
  }
410
533
  if (key in given) {
534
+ // Prefilled, nobody fills it: a required one must have a value (30 §7.2).
535
+ if ((value === null || value === undefined) && (parameters?.required ?? []).includes(key)) {
536
+ throw new PageDocumentError(`${path}: ${name} prefills its required ${key} with ${value === null ? 'null' : 'nothing'}: nobody fills it after the page`);
537
+ }
538
+ // An optional one fixed as none: not asked, and not passed (its parameter is `T | undefined`).
539
+ if (value === null || value === undefined)
540
+ continue;
411
541
  checkPrefill(`${path}: ${name}({ ${key} })`, param, value, env);
412
542
  prefill[key] = value;
413
543
  continue;
414
544
  }
545
+ if (isRefParam(param) || (isSchemaFieldParam(param) && rowTableOf(`${path}.${key}`, param, env.schema))) {
546
+ throw new PageDocumentError(`${path}: ${name}'s ${key} is a row: give the rows the AI chooses among, options: { ${key}: choose(rows, label) }, or prefill it`);
547
+ }
415
548
  properties[key] = expandParam(`${path}.${key}`, param, env);
416
549
  }
550
+ for (const [key, text] of Object.entries(args)) {
551
+ if (properties[key] && text.trim())
552
+ properties[key] = { ...properties[key], description: text };
553
+ }
417
554
  const input = Object.keys(properties).length
418
- ? { type: 'object', properties, required: (parameters?.required ?? []).filter((r) => r in properties) }
555
+ ? { type: 'object', properties, required: required.filter((r) => r in properties) }
419
556
  : null;
420
557
  return { input, prefill };
421
558
  }
422
559
  /** A row parameter's candidates, as `choose(...)` gave them: rows this run read, of the parameter's table. */
423
560
  function chooseParam(where, param, chosen, env) {
424
- const table = isSchemaFieldParam(param) ? rowTableOf(where, param, env.schema) : null;
561
+ const table = isRefParam(param) ? param.table : isSchemaFieldParam(param) ? rowTableOf(where, param, env.schema) : null;
425
562
  if (!table)
426
- throw new PageDocumentError(`${where}: choose(...) is for a row parameter (schemaField('table.id') or a lookup)`);
563
+ throw new PageDocumentError(`${where}: choose(...) is for a row parameter (Id<'table'>)`);
427
564
  if (chosen.table !== null && chosen.table !== table)
428
565
  throw new PageDocumentError(`${where}: choose(...) gives ${chosen.table} rows, the parameter takes ${table}`);
429
566
  const names = [];
@@ -445,12 +582,12 @@ function schemaFieldOf(where, param, schema) {
445
582
  const [table, field, ...rest] = param.$schemaField.split('.');
446
583
  const def = table ? getCompiledTable(schema, table) : undefined;
447
584
  if (!table || !field || rest.length || !def)
448
- throw new PageDocumentError(`${where}: schemaField('${param.$schemaField}') is not a table.field of this sequence`);
585
+ throw new PageDocumentError(`${where}: '${param.$schemaField}' is not a table.field of this sequence`);
449
586
  if (field === 'id')
450
587
  return { table, field, def: null };
451
588
  const fieldDef = getTableField(def, field);
452
589
  if (!fieldDef)
453
- throw new PageDocumentError(`${where}: schemaField('${param.$schemaField}') is not a field of this sequence`);
590
+ throw new PageDocumentError(`${where}: '${param.$schemaField}' is not a field of this sequence`);
454
591
  return { table, field, def: fieldDef };
455
592
  }
456
593
  /** The table a parameter chooses a row of (`table.id`, a lookup), or null. */
@@ -462,17 +599,17 @@ function rowTableOf(where, param, schema) {
462
599
  }
463
600
  /** A parameter as JSON Schema for the AI: schema fields filled in from the schema, row choices as enums. */
464
601
  function expandParam(where, param, env) {
602
+ if (isRefParam(param))
603
+ throw new PageDocumentError(`${where}: a row inside a list or an object cannot be chosen: make it a parameter of its own, chosen with options`);
465
604
  if (isSchemaFieldParam(param)) {
466
605
  const rowTable = rowTableOf(where, param, env.schema);
467
606
  const { table, def } = schemaFieldOf(where, param, env.schema);
468
- if (rowTable) {
469
- const names = env.rowsOnPage(rowTable);
470
- const help = param.extraction ?? def?.extraction;
471
- return { type: 'ref', table: rowTable, enum: names, ...(help ? { description: help } : {}) };
472
- }
607
+ // A row inside a list or an object: `options` cannot reach it (35 §2).
608
+ if (rowTable)
609
+ throw new PageDocumentError(`${where}: a row inside a list or an object cannot be chosen: make it a parameter of its own, chosen with options`);
473
610
  // The page's extraction replaces the schema's; type and allowed values stay the schema's.
474
611
  const fieldDef = param.extraction !== undefined ? { ...def, extraction: param.extraction } : def;
475
- return fieldDef.dataType === 'contact_schedule' ? scheduleProperty(fieldDef) : scalarProperty(fieldDef);
612
+ return fieldDef.dataType === 'contact_schedule' ? scheduleProperty(fieldDef.extraction || undefined) : scalarProperty(fieldDef);
476
613
  }
477
614
  if (param.type === 'array') {
478
615
  const out = { type: 'array', items: expandParam(`${where}[]`, param.items, env) };
@@ -491,6 +628,8 @@ function expandParam(where, param, env) {
491
628
  out.description = param.description;
492
629
  return out;
493
630
  }
631
+ if (param.type === 'contact_schedule')
632
+ return scheduleProperty(param.description);
494
633
  return { ...param };
495
634
  }
496
635
  /**
@@ -500,6 +639,13 @@ function expandParam(where, param, env) {
500
639
  function checkPrefill(where, param, value, env) {
501
640
  if (value === null || value === undefined)
502
641
  return;
642
+ if (isRefParam(param)) {
643
+ const read = typeof value === 'string' ? env.reads.row(value) : undefined;
644
+ if (!read || read.table !== param.table)
645
+ throw new PageDocumentError(`${where} is not a ${param.table} row this run read`);
646
+ env.rowName(read.table, value);
647
+ return;
648
+ }
503
649
  if (isSchemaFieldParam(param)) {
504
650
  const rowTable = rowTableOf(where, param, env.schema);
505
651
  if (!rowTable)
@@ -519,6 +665,10 @@ function checkPrefill(where, param, value, env) {
519
665
  if (param.type === 'object') {
520
666
  if (!isPlainObject(value))
521
667
  throw new PageDocumentError(`${where} must be an object`);
668
+ // A prefilled object is the page's whole: the AI cannot fill a part of it (30 §7.2).
669
+ const missing = (param.required ?? []).filter((key) => value[key] === undefined || value[key] === null);
670
+ if (missing.length)
671
+ throw new PageDocumentError(`${where} is prefilled without ${missing.join(', ')}: a prefilled object needs every required part, nobody fills the rest`);
522
672
  for (const [key, child] of Object.entries(param.properties))
523
673
  checkPrefill(`${where}.${key}`, child, value[key], env);
524
674
  }
@@ -532,8 +682,8 @@ function scalarProperty(def) {
532
682
  return { type: 'select', enum: def.options ?? [], ...help };
533
683
  return { type: def.dataType, ...help };
534
684
  }
535
- /** A `contact_schedule` field: its own structure (`ContactSchedule`), not text. */
536
- function scheduleProperty(def) {
685
+ /** A contact's hours (a `contact_schedule` field, a `ContactSchedule` argument): its own structure, not text. */
686
+ function scheduleProperty(help) {
537
687
  const window = {
538
688
  type: 'object',
539
689
  properties: {
@@ -547,7 +697,7 @@ function scheduleProperty(def) {
547
697
  return {
548
698
  type: 'object',
549
699
  'x-type': 'contact_schedule',
550
- ...describeField(def),
700
+ ...(help ? { description: help } : {}),
551
701
  properties: {
552
702
  weekly: {
553
703
  type: 'object',
@@ -570,7 +720,7 @@ function scheduleProperty(def) {
570
720
  };
571
721
  }
572
722
  /**
573
- * An argument's help (26 §16.2 #5a): how to fill it, the page's (`schemaField(…, { extraction })`) or else
723
+ * An argument's help (26 §16.2 #5a): how to fill it, the page's (`page({ extraction })`) or else
574
724
  * the field's; none when neither says. The label is not repeated in it; how its type is written is the
575
725
  * system message's (26 §15.8).
576
726
  */
@@ -578,6 +728,94 @@ function describeField(def) {
578
728
  return def.extraction ? { description: def.extraction } : {};
579
729
  }
580
730
  // ---------------------------------------------------------------------------------------------
731
+ /**
732
+ * The buttons' ids (46 §2.1), in page order: what each does (its run key's words: a function's name,
733
+ * `add:injury`, `remove`, `transfer`, …); where several on the page do the same, the rows that tell them
734
+ * apart, by page name (`voidInjury@injury:3f9a2c1d7e04`); where that still leaves two alike, `#2`, `#3`
735
+ * after the first. Unique on the page, and the same from one render to the next while the button does
736
+ * the same thing to the same rows.
737
+ */
738
+ export function buttonIds(buttons, rows) {
739
+ const nameOf = new Map(Object.entries(rows).map(([name, row]) => [row.id, name]));
740
+ const bases = buttons.map(buttonBase);
741
+ const rowsOf = buttons.map((button) => boundRows(button, nameOf));
742
+ const ids = buttons.map((_, i) => {
743
+ const alike = bases.flatMap((base, j) => (base === bases[i] ? [j] : []));
744
+ if (alike.length === 1)
745
+ return bases[i];
746
+ // Only the rows that differ among them: the case or contact they all share says nothing.
747
+ const shared = rowsOf[alike[0]].filter((name) => alike.every((j) => rowsOf[j].includes(name)));
748
+ const own = rowsOf[i].filter((name) => !shared.includes(name));
749
+ return own.length ? `${bases[i]}@${own.join(',')}` : bases[i];
750
+ });
751
+ const seen = new Map();
752
+ return ids.map((id) => {
753
+ const n = (seen.get(id) ?? 0) + 1;
754
+ seen.set(id, n);
755
+ return n === 1 ? id : `${id}#${n}`;
756
+ });
757
+ }
758
+ /**
759
+ * The button the AI means by `id` on this page (46 §2.1), or null. Its own id first; else an id it read
760
+ * on an earlier page, where alike buttons needed their rows (`bookSlot@slot_option:d12f648904ec`) and now
761
+ * one is left (`bookSlot`): the one button doing that to those rows. Never a guess among several.
762
+ */
763
+ export function pressedButton(page, id) {
764
+ if (id in page.actions)
765
+ return id;
766
+ const plain = id.replace(/#\d+$/, '');
767
+ const at = plain.lastIndexOf('@');
768
+ if (at < 0)
769
+ return null;
770
+ const base = plain.slice(0, at);
771
+ const rows = plain.slice(at + 1).split(',').filter(Boolean);
772
+ const nameOf = new Map(Object.entries(page.rows).map(([name, row]) => [row.id, name]));
773
+ const found = Object.values(page.actions).filter((button) => {
774
+ if (buttonBase(button) !== base)
775
+ return false;
776
+ const bound = boundRows(button, nameOf);
777
+ return rows.every((row) => bound.includes(row));
778
+ });
779
+ return found.length === 1 ? found[0].id : null;
780
+ }
781
+ /** What a button does, as its id says it (46 §2.1): its run key, in words the AI reads. */
782
+ function buttonBase(button) {
783
+ const server = button.server;
784
+ if (server.kind === 'createRow')
785
+ return server.key ?? `add:${server.table}`;
786
+ if (server.kind === 'deleteRow')
787
+ return server.key ?? 'remove';
788
+ if (server.kind === 'channel' && server.spec.channel === 'keypad')
789
+ return `keypad:${server.spec.key}`;
790
+ return runKeyOf(button);
791
+ }
792
+ /** The page names of the rows a button works on: the row it removes, where it adds, the rows it is prefilled with. */
793
+ function boundRows(button, nameOf) {
794
+ const server = button.server;
795
+ const values = [];
796
+ if (server.kind === 'deleteRow')
797
+ values.push(server.rowId);
798
+ if (server.kind === 'createRow')
799
+ values.push(server.parentId, ...Object.values(server.set));
800
+ if (server.kind === 'custom' || server.kind === 'function')
801
+ values.push(...Object.values(server.prefill ?? {}));
802
+ if (server.kind === 'followUp')
803
+ values.push(server.prefill.contactId);
804
+ const names = [];
805
+ const visit = (value) => {
806
+ if (typeof value === 'string') {
807
+ const name = nameOf.get(value);
808
+ if (name && !names.includes(name))
809
+ names.push(name);
810
+ }
811
+ else if (Array.isArray(value))
812
+ value.forEach(visit);
813
+ else if (isPlainObject(value))
814
+ Object.values(value).forEach(visit);
815
+ };
816
+ values.forEach(visit);
817
+ return names;
818
+ }
581
819
  function join(path, key) {
582
820
  return path ? `${path}.${key}` : key;
583
821
  }
@@ -596,24 +834,6 @@ function isFieldCell(value) {
596
834
  function isRefFieldCell(value) {
597
835
  return isPlainObject(value) && value.$refField === true && Array.isArray(value.options);
598
836
  }
599
- function isAction(value) {
600
- return (isPlainObject(value) &&
601
- (value.$action === 'createRow' ||
602
- value.$action === 'deleteRow' ||
603
- value.$action === 'custom' ||
604
- (value.$action === 'function' && typeof value.name === 'string') ||
605
- (value.$action === 'message' && typeof value.key === 'string') ||
606
- (value.$action === 'followUp' && Array.isArray(value.channels)) ||
607
- (value.$action === 'channel' && typeof value.channel === 'string')));
608
- }
609
- /** The page function returned an action to run now (`perform(…)`, or the action itself). */
610
- function performOf(document) {
611
- if (isPlainObject(document) && document.$perform === true && isAction(document.action))
612
- return document;
613
- if (isAction(document))
614
- return { $perform: true, action: document };
615
- return null;
616
- }
617
837
  /**
618
838
  * The run key of a button on the page (17b §7): the action's own key, whoever runs it — the model
619
839
  * pressing it, or the page function returning it — not its place on the page, which moves with rows.
@@ -621,90 +841,62 @@ function performOf(document) {
621
841
  export function runKeyOf(action) {
622
842
  const server = action.server;
623
843
  if (server.kind === 'custom')
624
- return server.name;
844
+ return server.key ?? server.name;
625
845
  if (server.kind === 'createRow')
626
- return `createRow:${server.table}`;
846
+ return server.key ?? `createRow:${server.table}`;
627
847
  if (server.kind === 'deleteRow')
628
- return `deleteRow:${server.rowId}`;
848
+ return server.key ?? `deleteRow:${server.rowId}`;
629
849
  if (server.kind === 'function')
630
- return server.name;
631
- if (server.kind === 'message' || server.kind === 'followUp')
850
+ return server.key ?? server.name;
851
+ if (server.kind === 'followUp' || server.kind === 'cancelFollowUp')
632
852
  return server.key;
853
+ // A tool's run key is its name, fixed (76 §2.18): a page has one toolbox, each tool once.
854
+ if (server.kind === 'tool')
855
+ return server.tool;
633
856
  return actionKey(server.spec);
634
857
  }
635
858
  /** The run key of an action the page function returned, when it names none. */
636
859
  export function actionKey(action) {
637
860
  if (action.$action === 'custom')
638
- return action.name;
861
+ return action.key ?? action.name;
639
862
  if (action.$action === 'function')
640
- return action.name;
641
- if (action.$action === 'message' || action.$action === 'followUp')
863
+ return action.key ?? action.name;
864
+ if (action.$action === 'followUp' || action.$action === 'cancelFollowUp')
642
865
  return action.key;
643
866
  if (action.$action === 'createRow')
644
- return `createRow:${action.table}`;
867
+ return action.key ?? `createRow:${action.table}`;
645
868
  if (action.$action === 'deleteRow')
646
- return `deleteRow:${action.rowId}`;
869
+ return action.key ?? `deleteRow:${action.rowId}`;
870
+ if (action.$action === 'tool')
871
+ return action.tool;
647
872
  if (action.channel === 'keypad' || action.channel === 'announce')
648
873
  return action.key;
649
- return action.channel;
650
- }
651
- /** Marks removed with the todos (24 §2.3): a page still writing one is told what replaced it. */
652
- /** The marks a page object may carry: `$title`, `$description` on any block; `$guide`, `$extraction` on the root. */
653
- const KNOWN_MARKS = new Set(['$title', '$description', '$guide', '$extraction']);
654
- const REMOVED_MARKS = {
655
- $say: 'write $guide on the page\'s root',
656
- $done: 'show what is done as a result, or stop guiding to it',
657
- $locked: 'leave out what the AI must not touch; say why in plain text',
658
- $summary: 'nothing is folded any more',
659
- $recommend: 'write $guide on the page\'s root',
660
- };
874
+ return action.key ?? action.channel;
875
+ }
661
876
  /**
662
877
  * The page's `$extraction` (26 §15.3): how to fill a field on this page, by `table.field`, replacing the
663
878
  * schema's for every place it is on the page. One per field for the whole page, so a field means the
664
879
  * same everywhere on it. A `table.field` the schema does not have is warned and left out.
665
880
  */
666
- function pageExtractions(document, schema, warnings) {
881
+ /** `page({ extraction })` (26 §15.3): how to fill a field on this page, by `table.field`. */
882
+ function pageExtractions(value, schema, warnings) {
667
883
  const out = new Map();
668
- const value = isPlainObject(document) ? document.$extraction : undefined;
669
884
  if (value === undefined || value === null)
670
885
  return out;
671
886
  if (!isPlainObject(value))
672
- throw new PageDocumentError('page: $extraction must be an object of table.field → text');
887
+ throw new PageDocumentError('page({ extraction }): an object of table.field → text');
673
888
  for (const [key, text] of Object.entries(value)) {
674
889
  if (typeof text !== 'string')
675
- throw new PageDocumentError(`page: $extraction['${key}'] must be text`);
890
+ throw new PageDocumentError(`page({ extraction }): '${key}' must be text`);
676
891
  const [table, field, ...rest] = key.split('.');
677
892
  if (!table || !field || rest.length || !getCompiledTable(schema, table)?.fields[field]) {
678
- warnings.push(`page: $extraction['${key}'] is not a table.field of the schema; left out`);
893
+ warnings.push(`page({ extraction }): '${key}' is not a table.field of the schema; left out`);
679
894
  continue;
680
895
  }
681
896
  out.set(key, text);
682
897
  }
683
898
  return out;
684
899
  }
685
- /** `$guide` and `$extraction` on the root (24 §2.1, 26 §15.3); on any other block they are refused, as the removed marks are. */
686
- function pageMarks(block, value, path, isRoot, warnings) {
687
- const where = path || 'page';
688
- for (const [mark, instead] of Object.entries(REMOVED_MARKS)) {
689
- if (value[mark] !== undefined)
690
- throw new PageDocumentError(`${where}: ${mark} was removed (24 §2.3): ${instead}`);
691
- }
692
- // A misspelt mark (`$titel`) would otherwise do nothing, silently (26 §16.2 #4).
693
- for (const mark of Object.keys(value)) {
694
- if (mark.startsWith('$') && !KNOWN_MARKS.has(mark))
695
- warnings.push(`${where}: ${mark} is not a page mark (${[...KNOWN_MARKS].join(', ')}); ignored`);
696
- }
697
- if (!isRoot && value.$extraction !== undefined)
698
- throw new PageDocumentError(`${where}: $extraction is the page's: write it on the page's root, one per page (26 §15.3)`);
699
- if (value.$guide === undefined || value.$guide === null)
700
- return;
701
- if (typeof value.$guide !== 'string')
702
- throw new PageDocumentError(`${where}: $guide must be text`);
703
- if (!isRoot)
704
- throw new PageDocumentError(`${where}: $guide is the page's: write it on the page's root, one per page (24 §2.1)`);
705
- if (value.$guide.trim())
706
- block.guide = value.$guide;
707
- }
708
900
  const CHANNEL_NAMES = {
709
901
  transfer: ['Transfer to a person', 'Hand the call to a person. Press it only when the customer wants a person or the page says so.'],
710
902
  keypad: ['Collect a keypress', 'The customer answers by pressing a key on their phone.'],
@@ -716,6 +908,32 @@ const BACKGROUND_TRANSFER = [
716
908
  'Call a person in the background',
717
909
  'Calls a person while you keep helping the customer; the customer is put through once the person takes the call. Press it only when the customer wants a person or the page says so.',
718
910
  ];
911
+ /** What the AI sees of each tool (76 §2.18). */
912
+ const TOOL_BUTTONS = {
913
+ pressKeypad: {
914
+ name: 'Press keys on their phone menu',
915
+ description: "Press keys on the other side's automated phone menu, e.g. 'Press 1 for prescriptions'. Give the keys as one string: digits 0–9, * and #; w waits half a second (\"1w2\"). Only for an automated menu, never to talk to a person.",
916
+ input: { type: 'object', properties: { keys: { type: 'string', description: 'The keys to press, in order: 0–9, *, #, w (wait half a second)' } }, required: ['keys'] },
917
+ },
918
+ convertTime: {
919
+ name: 'Convert a time',
920
+ description: 'Convert between a local time in a time zone and an instant in UTC. Give local and time_zone to get the instant in UTC; give at and time_zone to get the weekday, date and time there.',
921
+ input: {
922
+ type: 'object',
923
+ properties: {
924
+ time_zone: { type: 'string', description: 'IANA time zone, e.g. America/Chicago' },
925
+ local: { type: 'string', description: 'A local date and time there, YYYY-MM-DDTHH:mm' },
926
+ at: { type: 'datetime', description: 'An instant, UTC (…Z)' },
927
+ },
928
+ required: ['time_zone'],
929
+ },
930
+ },
931
+ };
932
+ /** A platform tool as a button (76 §2.18): the platform runs it with what the AI gave. */
933
+ function toolButton(path, tool) {
934
+ const { name, description, input } = TOOL_BUTTONS[tool];
935
+ return { id: path, kind: 'tool', name, description, input: structuredClone(input), server: { kind: 'tool', tool } };
936
+ }
719
937
  /** A channel action as a button: nothing for the AI to fill; what it does stays on the server. */
720
938
  function channelButton(path, action) {
721
939
  const [name, description] = action.channel === 'transfer' && action.mode === 'background' ? BACKGROUND_TRANSFER : CHANNEL_NAMES[action.channel];
@@ -725,79 +943,81 @@ function channelButton(path, action) {
725
943
  id: path,
726
944
  kind: 'channel',
727
945
  name: label ?? name,
728
- description: text ?? (action.channel === 'keypad' ? `${description} It says: "${action.say}"` : description),
946
+ description: text ?? (action.channel === 'keypad' ? `${description} It says: "${fallbackSpoken(action.say).text}"` : description),
729
947
  input: null,
730
948
  server: { kind: 'channel', spec: action },
731
949
  };
732
950
  }
951
+ const CHANNEL_WORDS = { call: 'a call', sms: 'a text', email: 'an email' };
733
952
  /**
734
- * A text or an email as a button (25 §8): the contact it goes to must be a contact row this run read (it
735
- * goes on the page); what the page left out (`body`, an email's `subject`) the AI fills.
953
+ * The next-step button (40 §2.2): the AI gives when, the channel, why, and the customer's own words when
954
+ * they asked for it (an agreed follow-up, 09). Whom: the conversation's contact, unless the page fixed one
955
+ * (`prefill.contactId`) or gave the contacts the AI chooses among (`choose(...)`). Independent
956
+ * appointments coexist; an optional explicit target edits one (51).
736
957
  */
737
- function messageButton(path, action, env) {
738
- if (action.to !== undefined) {
739
- const read = env.reads.row(action.to);
740
- if (!read || read.table !== 'contact') {
741
- env.warnings.push(`${path}: ${action.key} goes to ${action.to}, not a contact this run read; left off the page`);
742
- return null;
743
- }
744
- env.rowName('contact', action.to);
745
- }
746
- const what = action.channel === 'sms' ? 'text' : 'email';
958
+ function followUpButton(path, action, env) {
959
+ const given = action.prefill ?? {};
960
+ // One channel offered, or the page fixed it: nothing for the AI to choose.
961
+ const channel = given.channel ?? (action.channels.length === 1 ? action.channels[0] : undefined);
962
+ const ways = (channel ? [channel] : action.channels).map((c) => CHANNEL_WORDS[c]).join(' or ');
747
963
  const properties = {};
748
- if (action.channel === 'email' && action.subject === undefined)
749
- properties.subject = { type: 'string', description: 'The email\'s subject line' };
750
- if (action.body === undefined)
751
- properties.body = { type: 'string', description: `The ${what} to send, in full, in the customer's language` };
752
- const to = action.to !== undefined ? env.rowName('contact', action.to) : 'the customer';
753
- const server = { kind: 'message', channel: action.channel, key: action.key, to: action.to ?? null };
754
- if (action.body !== undefined)
755
- server.body = action.body;
756
- if (action.subject !== undefined)
757
- server.subject = action.subject;
758
- if (action.say !== undefined)
759
- server.say = action.say;
964
+ if (!channel)
965
+ properties.channel = { type: 'select', enum: [...action.channels], description: 'How to reach them' };
966
+ properties.scheduled_action = { type: 'string', description: 'To edit one existing appointment, its step-… ID shown below; leave out to ADD an independent appointment' };
967
+ properties.name = { type: 'string', description: 'Optional business label, only when the page gives a naming convention; do not invent one for deduplication' };
968
+ // Every time the AI writes is UTC (76 §2.19): it asks the customer's time and time zone and converts.
969
+ properties.at = {
970
+ type: 'datetime',
971
+ description: 'When, in UTC (ending in Z): ask them the time and their time zone (unless the page says it), then convert; for a time the customer said vaguely ("tomorrow afternoon") a time inside it',
972
+ };
973
+ properties.reason = { type: 'string', description: 'Why, in a few words: what the contact is for' };
974
+ properties.customer_quote = { type: 'string', description: 'When the customer asked for it or agreed to it: their own words, as said; leave out otherwise' };
975
+ const prefill = channel ? { channel } : {};
976
+ const contact = given.contactId;
977
+ if (typeof contact === 'string') {
978
+ checkPrefill(`${path}: prefill.contactId`, { type: 'ref', table: CONTACT_TABLE }, contact, env);
979
+ prefill.contactId = contact;
980
+ }
981
+ else if (contact) {
982
+ const offered = chooseParam(`${path}: options.contactId`, { type: 'ref', table: CONTACT_TABLE }, contact, env);
983
+ // The choice adds people besides the conversation's contact (always the default): none to add, and
984
+ // the button books for that contact, as when the AI leaves the choice out.
985
+ if (offered.type === 'ref' && offered.enum.length)
986
+ properties.contact = { ...offered, description: 'Whom to contact; leave out for the person in this conversation' };
987
+ }
760
988
  return {
761
989
  id: path,
762
- kind: 'message',
763
- name: action.label ?? (action.channel === 'sms' ? 'Send a text' : 'Send an email'),
990
+ kind: 'followUp',
991
+ name: action.label ?? 'Book the next step',
764
992
  description: action.description ??
765
- (action.body !== undefined
766
- ? `Sends the fixed ${what} "${action.label ?? action.key}" to ${to} now.`
767
- : `Sends a ${what} you write to ${to} now.`),
768
- input: Object.keys(properties).length ? { type: 'object', properties, required: Object.keys(properties) } : null,
769
- server,
993
+ [
994
+ `Books the case's next step: ${ways} at a set time, and why.`,
995
+ "Press it when the customer asks to be contacted at a time or agrees to one you offered (give their words), or when the case needs a contact at a set time; tell them it is booked only once it is.",
996
+ "Independent appointments can coexist, even for the same person and channel. Adding does not replace any appointment.",
997
+ "To change one appointment, give its scheduled_action ID from the list below. Only that appointment changes; leave contact out to keep its contact.",
998
+ ].join(' '),
999
+ input: { type: 'object', properties, required: [...(channel ? [] : ['channel']), 'at', 'reason'] },
1000
+ server: { kind: 'followUp', key: action.key, channels: [...action.channels], prefill },
770
1001
  };
771
1002
  }
772
- const CHANNEL_WORDS = { call: 'a call', sms: 'a text', email: 'an email' };
773
- /** The follow-up button (25 §5): the AI gives the channel, the time and the customer's own words. */
774
- function followUpButton(path, action) {
775
- const ways = action.channels.map((c) => CHANNEL_WORDS[c]).join(' or ');
1003
+ /** The cancel-next-step button (40 §2.2): the AI says why. */
1004
+ function cancelFollowUpButton(path, action) {
776
1005
  return {
777
1006
  id: path,
778
- kind: 'followUp',
779
- name: action.label ?? 'Book the follow-up they asked for',
1007
+ kind: 'cancelFollowUp',
1008
+ name: action.label ?? 'Cancel the next step',
780
1009
  description: action.description ??
781
- `The customer asked for ${ways} later, or agreed to one, at a time they said: press it then, and tell them it is booked. Not for a follow-up they did not ask for.`,
782
- input: {
783
- type: 'object',
784
- properties: {
785
- channel: { type: 'select', enum: [...action.channels], description: 'How they want to be reached' },
786
- at: { type: 'datetime', description: 'When, as they said it, with the offset of their time zone; for a vague time ("tomorrow afternoon") a time inside it' },
787
- customer_quote: { type: 'string', description: 'Their own words asking for it or agreeing to it, as said' },
788
- note: { type: 'string', description: 'What it is for, in a few words' },
789
- },
790
- required: ['channel', 'at', 'customer_quote'],
791
- },
792
- server: { kind: 'followUp', key: action.key, channels: [...action.channels] },
1010
+ "Cancels ONE appointment by its scheduled_action ID from the list below, with a reason. Other appointments stay. Talking to someone does not automatically complete their appointments; cancel only what is no longer needed.",
1011
+ input: { type: 'object', properties: { scheduled_action: { type: 'string', description: 'The step-… ID of the appointment to cancel' }, reason: { type: 'string', description: 'Why it is no longer needed' } }, required: ['scheduled_action', 'reason'] },
1012
+ server: { kind: 'cancelFollowUp', key: action.key },
793
1013
  };
794
1014
  }
1015
+ function isRefParam(param) {
1016
+ return !isSchemaFieldParam(param) && param.type === 'ref';
1017
+ }
795
1018
  function isSchemaFieldParam(value) {
796
1019
  return isPlainObject(value) && typeof value.$schemaField === 'string';
797
1020
  }
798
- function isRowLike(value) {
799
- return typeof value.id === 'string' && Object.values(value).some(isSlotLike);
800
- }
801
1021
  function displayable(value) {
802
1022
  if (value === null || value === undefined)
803
1023
  return null;