@theoremjs/web 0.0.0-stage → 0.3.4-next.375.ga2d1878

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +493 -2
  3. package/client/audio-level.d.ts +5 -0
  4. package/client/audio-level.js +24 -0
  5. package/client/chat.d.ts +144 -0
  6. package/client/chat.js +760 -0
  7. package/client/composer-drawer.d.ts +19 -0
  8. package/client/composer-drawer.js +15 -0
  9. package/client/composer-hints.d.ts +25 -0
  10. package/client/composer-hints.js +20 -0
  11. package/client/composer-primary.d.ts +18 -0
  12. package/client/composer-primary.js +29 -0
  13. package/client/decision-transport.d.ts +70 -0
  14. package/client/decision-transport.js +175 -0
  15. package/client/decode-composer-draft.d.ts +11 -0
  16. package/client/decode-composer-draft.js +12 -0
  17. package/client/embedding-input.d.ts +84 -0
  18. package/client/embedding-input.js +194 -0
  19. package/client/embedding-space.d.ts +82 -0
  20. package/client/embedding-space.js +389 -0
  21. package/client/embedding-transport.d.ts +40 -0
  22. package/client/embedding-transport.js +71 -0
  23. package/client/encode-composer-draft.d.ts +9 -0
  24. package/client/encode-composer-draft.js +34 -0
  25. package/client/encode-files.d.ts +18 -0
  26. package/client/encode-files.js +54 -0
  27. package/client/failure.d.ts +29 -0
  28. package/client/failure.js +34 -0
  29. package/client/generation-selection.d.ts +15 -0
  30. package/client/generation-selection.js +24 -0
  31. package/client/host-transport.d.ts +70 -0
  32. package/client/host-transport.js +75 -0
  33. package/client/index.d.ts +37 -0
  34. package/client/index.js +31 -0
  35. package/client/live/live-mic-forward.d.ts +30 -0
  36. package/client/live/live-mic-forward.js +46 -0
  37. package/client/live/live-page-tool.d.ts +39 -0
  38. package/client/live/live-page-tool.js +26 -0
  39. package/client/live/live-state.d.ts +13 -0
  40. package/client/live/live-state.js +22 -0
  41. package/client/live/live-tool.d.ts +11 -0
  42. package/client/live/live-tool.js +1 -0
  43. package/client/live-client.d.ts +189 -0
  44. package/client/live-client.js +829 -0
  45. package/client/live-errors.d.ts +2 -0
  46. package/client/live-errors.js +9 -0
  47. package/client/live-messages.d.ts +148 -0
  48. package/client/live-messages.js +100 -0
  49. package/client/mic-capture.d.ts +4 -0
  50. package/client/mic-capture.js +6 -0
  51. package/client/oauth-popup.d.ts +26 -0
  52. package/client/oauth-popup.js +30 -0
  53. package/client/page-dispatch.d.ts +15 -0
  54. package/client/page-dispatch.js +63 -0
  55. package/client/pcm-downsample.d.ts +4 -0
  56. package/client/pcm-downsample.js +45 -0
  57. package/client/run-commit.d.ts +34 -0
  58. package/client/run-commit.js +49 -0
  59. package/client/run-session.d.ts +79 -0
  60. package/client/run-session.js +207 -0
  61. package/client/schema-fields.d.ts +63 -0
  62. package/client/schema-fields.js +199 -0
  63. package/client/shaped-data.d.ts +118 -0
  64. package/client/shaped-data.js +343 -0
  65. package/client/source-citations.d.ts +13 -0
  66. package/client/source-citations.js +56 -0
  67. package/client/space-geometry.d.ts +39 -0
  68. package/client/space-geometry.js +183 -0
  69. package/client/tool-result.d.ts +78 -0
  70. package/client/tool-result.js +293 -0
  71. package/client/tool-resume.d.ts +50 -0
  72. package/client/tool-resume.js +14 -0
  73. package/client/trace-feed.d.ts +13 -0
  74. package/client/trace-feed.js +18 -0
  75. package/client/transcript-schema.d.ts +10 -0
  76. package/client/transcript-schema.js +120 -0
  77. package/client/transport.d.ts +264 -0
  78. package/client/transport.js +330 -0
  79. package/client/turn-client.d.ts +44 -0
  80. package/client/turn-client.js +88 -0
  81. package/client/wire-line.d.ts +56 -0
  82. package/client/wire-line.js +90 -0
  83. package/client/worklets/mic-capture.js +16 -0
  84. package/package.json +28 -4
  85. package/server/checkpoint-ownership.d.ts +6 -0
  86. package/server/checkpoint-ownership.js +42 -0
  87. package/server/conversation-store.d.ts +92 -0
  88. package/server/conversation-store.js +131 -0
  89. package/server/conversation.d.ts +193 -0
  90. package/server/conversation.js +552 -0
  91. package/server/credential-store.d.ts +24 -0
  92. package/server/credential-store.js +14 -0
  93. package/server/decision-handler.d.ts +42 -0
  94. package/server/decision-handler.js +46 -0
  95. package/server/embedding-handler.d.ts +33 -0
  96. package/server/embedding-handler.js +45 -0
  97. package/server/handler.d.ts +158 -0
  98. package/server/handler.js +775 -0
  99. package/server/json-response.d.ts +2 -0
  100. package/server/json-response.js +7 -0
  101. package/server/mcp-handler.d.ts +46 -0
  102. package/server/mcp-handler.js +312 -0
  103. package/server/mod.d.ts +27 -0
  104. package/server/mod.js +25 -0
  105. package/server/page-dispatch.d.ts +22 -0
  106. package/server/page-dispatch.js +127 -0
  107. package/server/request-body.d.ts +2 -0
  108. package/server/request-body.js +42 -0
  109. package/server/request-check.d.ts +21 -0
  110. package/server/request-check.js +49 -0
  111. package/server/session-authority.d.ts +8 -0
  112. package/server/session-authority.js +78 -0
  113. package/server/session-boundary.d.ts +26 -0
  114. package/server/session-boundary.js +64 -0
  115. package/server/session-identity.d.ts +16 -0
  116. package/server/session-identity.js +41 -0
  117. package/server/session-limits.d.ts +12 -0
  118. package/server/session-limits.js +59 -0
  119. package/server/session-store.d.ts +123 -0
  120. package/server/session-store.js +264 -0
  121. package/server/single-action.d.ts +21 -0
  122. package/server/single-action.js +30 -0
  123. package/server/steer-inbox.d.ts +35 -0
  124. package/server/steer-inbox.js +52 -0
  125. package/server/tool-operations.d.ts +6 -0
  126. package/server/tool-operations.js +132 -0
  127. package/server/turn-input.d.ts +10 -0
  128. package/server/turn-input.js +29 -0
package/client/chat.js ADDED
@@ -0,0 +1,760 @@
1
+ import { TheoremError } from '@theoremjs/agents';
2
+ import { branchInterfaceTurnSession, consumeNextComposerQueue, convertSteersToFrontQueued, createComposerPendingMessage, defaultInterfaceEffort, emptyInterfaceTurnSession, orderComposerPendingMessages, promoteComposerPendingKind, removeComposerPendingMessage, removeLandedSteers, userDraftHasPayload, userDraftToSteerInject, } from '@theoremjs/agents/interface';
3
+ import { composerHasPayload } from "./composer-primary.js";
4
+ import { composerFieldsFromDraft } from "./decode-composer-draft.js";
5
+ import { encodeComposerDraft } from "./encode-composer-draft.js";
6
+ import { clientFailure, turnFailure } from "./failure.js";
7
+ import { followGenerationDefaults } from "./generation-selection.js";
8
+ import { warnPageToolMismatch } from "./live/live-page-tool.js";
9
+ import { withPageToolDispatch } from "./page-dispatch.js";
10
+ import { sessionAfter, stampWorked } from "./run-commit.js";
11
+ import { keepsConversation, resumeInterfaceTool, streamInterfaceDraftTurn, streamInterfaceTurn, } from "./run-session.js";
12
+ import { foldAssistantTurn, issuedOperationId } from "./turn-client.js";
13
+ const DELIVERY_ORDER = ['sending', 'sent', 'delivered', 'read'];
14
+ /** The server's own reports (bookkeeping, a failure, the end), which say nothing of the model having the message. */
15
+ const SERVER_EVENTS = new Set(['stage', 'guardrail', 'session', 'error', 'done']);
16
+ /** The reply itself (its thinking and tool calls come before it). */
17
+ const REPLY_EVENTS = new Set(['text', 'structured', 'media']);
18
+ /**
19
+ * How far a line shows the message got: the host saving it, or any event,
20
+ * means the server took it; one from the model (thinking, a tool call) means
21
+ * the model has it, and the reply means it was read. A save without the
22
+ * message (a walk settling calls) says nothing of it.
23
+ */
24
+ function deliveryOf(line) {
25
+ if (line.type === 'conversation')
26
+ return line.posted ? 'sent' : undefined;
27
+ if (REPLY_EVENTS.has(line.type))
28
+ return 'read';
29
+ return SERVER_EVENTS.has(line.type) ? 'sent' : 'delivered';
30
+ }
31
+ /** The streamed blocks, ending on the turn's error unless the stream already carried it. */
32
+ function withTurnError(blocks, error) {
33
+ if (blocks.some((block) => block.kind === 'error'))
34
+ return blocks;
35
+ return [...blocks, { id: crypto.randomUUID(), kind: 'error', message: error }];
36
+ }
37
+ function slotsOf(iface, fixed, picked) {
38
+ const declared = iface?.inputs.slots ?? {};
39
+ const values = { ...fixed };
40
+ for (const [name, allowed] of Object.entries(declared)) {
41
+ const value = fixed?.[name] ?? picked[name] ?? allowed[0];
42
+ if (value !== undefined)
43
+ values[name] = value;
44
+ }
45
+ const chosen = Object.keys(declared).length > 0;
46
+ return {
47
+ values: chosen || fixed ? values : undefined,
48
+ slots: chosen ? { declared, values } : undefined,
49
+ };
50
+ }
51
+ function sameSlots(a, b) {
52
+ if (a === undefined || b === undefined)
53
+ return a === b;
54
+ const keys = Object.keys(a.values);
55
+ return (a.declared === b.declared &&
56
+ keys.length === Object.keys(b.values).length &&
57
+ keys.every((key) => a.values[key] === b.values[key]));
58
+ }
59
+ /** One frame later in a browser; a timer's tick elsewhere. Returns the cancel. */
60
+ function nextFrame(run) {
61
+ // why: The client also runs where there are no frames (a server, a test), so it asks before it uses them.
62
+ const frames = globalThis;
63
+ if (frames.requestAnimationFrame && frames.cancelAnimationFrame) {
64
+ const id = frames.requestAnimationFrame(run);
65
+ return () => frames.cancelAnimationFrame?.(id);
66
+ }
67
+ const id = setTimeout(run, 16);
68
+ return () => clearTimeout(id);
69
+ }
70
+ function newTurnId() {
71
+ return crypto.randomUUID();
72
+ }
73
+ class Chat {
74
+ #options;
75
+ #state;
76
+ #notified;
77
+ #listeners = new Set();
78
+ #flushQueued = false;
79
+ #busy = false;
80
+ #abort = null;
81
+ #turnId = null;
82
+ /** The run in flight; it settles, never rejects. */
83
+ #run = null;
84
+ // why: A stateless reply's work across its runs: a gate splits a reply into runs, and the wait between doesn't count.
85
+ #replyWorkedMs = 0;
86
+ /** The run that ended let the queue send its next message. */
87
+ #drainArmed = false;
88
+ #draining = false;
89
+ #conversation;
90
+ // why: One object per posted message, so a run can tell whether it posted one.
91
+ #delivery = null;
92
+ #picked = {};
93
+ #resumed = false;
94
+ #defaultsFrom;
95
+ #answeredPage = null;
96
+ #pageToolsWarned;
97
+ #frame = null;
98
+ #frameBlocks = null;
99
+ constructor(options) {
100
+ this.#options = options;
101
+ this.#conversation = options.conversation;
102
+ this.#state = {
103
+ iface: options.iface,
104
+ blocks: [],
105
+ streamBlocks: [],
106
+ chatStarted: false,
107
+ draftText: options.initialText ?? '',
108
+ pendingFiles: [],
109
+ pendingVoice: [],
110
+ pendingMessages: [],
111
+ issues: [],
112
+ failure: null,
113
+ answering: null,
114
+ phase: 'idle',
115
+ session: emptyInterfaceTurnSession(),
116
+ delivery: null,
117
+ streaming: false,
118
+ slots: slotsOf(options.iface, options.slots, {}).slots,
119
+ };
120
+ this.#notified = this.#state;
121
+ this.#warnPageTools();
122
+ this.#settle();
123
+ }
124
+ getState = () => this.#state;
125
+ subscribe = (listener) => {
126
+ this.#listeners.add(listener);
127
+ return () => this.#listeners.delete(listener);
128
+ };
129
+ update = (options) => {
130
+ this.#options = options;
131
+ this.#warnPageTools();
132
+ const { slots } = slotsOf(options.iface, options.slots, this.#picked);
133
+ const patch = {};
134
+ if (options.iface !== this.#state.iface)
135
+ patch.iface = options.iface;
136
+ if (!sameSlots(slots, this.#state.slots))
137
+ patch.slots = slots;
138
+ if (Object.keys(patch).length > 0)
139
+ this.#set(patch);
140
+ };
141
+ setDraftText = (value) => this.#apply('draftText', value);
142
+ setPendingFiles = (value) => this.#apply('pendingFiles', value);
143
+ setPendingVoice = (value) => this.#apply('pendingVoice', value);
144
+ setPendingMessages = (value) => this.#apply('pendingMessages', value);
145
+ setIssues = (value) => this.#apply('issues', value);
146
+ submit = async () => {
147
+ const { phase, draftText, pendingFiles, pendingVoice } = this.#state;
148
+ if (phase !== 'idle') {
149
+ await this.enqueue('queue');
150
+ return;
151
+ }
152
+ this.#set({ issues: [] });
153
+ await this.#sendFields({ text: draftText, files: [...pendingFiles], voice: [...pendingVoice] });
154
+ };
155
+ stop = () => {
156
+ this.#abort?.abort();
157
+ };
158
+ enqueue = async (kind) => {
159
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
160
+ if (!composerHasPayload(draftText, pendingFiles, pendingVoice))
161
+ return;
162
+ this.#set({ issues: [] });
163
+ try {
164
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
165
+ const message = createComposerPendingMessage({ kind, draft });
166
+ this.#set({
167
+ pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, message]),
168
+ ...this.#clearedComposer(),
169
+ });
170
+ if (kind !== 'steer')
171
+ return;
172
+ const turnId = this.#turnId;
173
+ if (!turnId) {
174
+ this.#steerAfterTurnEnded();
175
+ return;
176
+ }
177
+ const inject = userDraftToSteerInject(draft);
178
+ if (inject.length === 0)
179
+ return;
180
+ await this.#options.transport.steer({ turnId, id: message.id, inject });
181
+ }
182
+ catch (err) {
183
+ this.#set({ failure: clientFailure(err, this.#options.iface?.labels) });
184
+ }
185
+ };
186
+ sendNow = async (source) => {
187
+ if (!this.#options.iface)
188
+ return;
189
+ const draft = source ? source.draft : await this.#composerDraft();
190
+ if (!draft)
191
+ return;
192
+ if (this.#busy) {
193
+ this.#abort?.abort();
194
+ await this.#run;
195
+ }
196
+ this.#set({ pendingMessages: convertSteersToFrontQueued(this.#state.pendingMessages) });
197
+ this.#drainArmed = false;
198
+ // why: Sent while the reply waits on gates, the message walks away from them in its own request.
199
+ await this.#sendDraft(draft, {
200
+ walkAway: this.#state.session.gatedTool !== null,
201
+ // why: The sent message leaves the queue, or the composer it came from.
202
+ onPosted: () => this.#set(source
203
+ ? {
204
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, source.id),
205
+ }
206
+ : this.#clearedComposer()),
207
+ });
208
+ };
209
+ menuAction = (action) => {
210
+ if (action === 'queue' || action === 'steer' || action === 'stash') {
211
+ void this.enqueue(action);
212
+ return;
213
+ }
214
+ if (action === 'send_now')
215
+ void this.sendNow();
216
+ };
217
+ restorePending = async (id) => {
218
+ const message = this.#state.pendingMessages.find((m) => m.id === id);
219
+ if (!message)
220
+ return;
221
+ try {
222
+ await this.#restore(composerFieldsFromDraft(message.draft));
223
+ this.#set({
224
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, id),
225
+ });
226
+ }
227
+ catch (err) {
228
+ this.#set({ failure: clientFailure(err, this.#options.iface?.labels) });
229
+ }
230
+ };
231
+ queuePending = (id) => {
232
+ const message = this.#state.pendingMessages.find((m) => m.id === id);
233
+ if (message?.kind !== 'stash')
234
+ return;
235
+ if (this.#state.phase === 'idle')
236
+ this.#drainArmed = true;
237
+ this.#set({
238
+ pendingMessages: promoteComposerPendingKind(this.#state.pendingMessages, id, 'queue'),
239
+ });
240
+ };
241
+ decide = (action) => this.#answerGate({ action });
242
+ authenticate = (secret) => this.#answerGate({ action: 'auth', ...(secret === undefined ? {} : { secret }) });
243
+ branch = async (index) => {
244
+ const { iface } = this.#options;
245
+ if (!iface || this.#busy)
246
+ return;
247
+ const ref = this.#conversation;
248
+ if (!keepsConversation(iface) || ref === undefined) {
249
+ const kept = [...this.#state.blocks, ...this.#state.streamBlocks].slice(0, index + 1);
250
+ // why: The kept transcript's last message isn't the one whose delivery was tracked.
251
+ this.#setDelivery(null);
252
+ this.#set({
253
+ blocks: kept,
254
+ streamBlocks: [],
255
+ chatStarted: kept.length > 0,
256
+ session: branchInterfaceTurnSession(this.#state.session, kept),
257
+ });
258
+ return;
259
+ }
260
+ this.#set({ failure: null });
261
+ try {
262
+ // why: The paused reply is not in the host's transcript; a cut inside it keeps the transcript whole.
263
+ const at = Math.min(index, this.#state.blocks.length - 1);
264
+ const rewound = await this.#options.transport.rewind({ conversation: ref, at });
265
+ this.#adopt(rewound.conversation);
266
+ this.#draw(rewound.view, this.#state.session, []);
267
+ this.#setDelivery(null);
268
+ }
269
+ catch (err) {
270
+ this.#showFailure(turnFailure(err, iface.labels));
271
+ }
272
+ };
273
+ setGeneration = (next) => {
274
+ const { iface } = this.#options;
275
+ const effort = next.effort ?? (iface ? defaultInterfaceEffort(iface, next.modelId) : undefined);
276
+ this.#set({
277
+ session: { ...this.#state.session, selectedModel: next.modelId, selectedEffort: effort },
278
+ });
279
+ };
280
+ pickSlot = (name, value) => {
281
+ const before = slotsOf(this.#options.iface, this.#options.slots, this.#picked).values;
282
+ this.#picked = { ...this.#picked, [name]: value };
283
+ this.#set({ slots: slotsOf(this.#options.iface, this.#options.slots, this.#picked).slots });
284
+ this.#options.onSlotsChange?.({ ...before, [name]: value });
285
+ };
286
+ sendText = async (text, files = []) => {
287
+ if (!this.#options.iface || this.#state.phase !== 'idle')
288
+ return null;
289
+ const before = this.#state.blocks.length;
290
+ await this.#sendFields({ text, files: [...files], voice: [] });
291
+ return { blocks: this.#state.blocks.slice(before) };
292
+ };
293
+ #set(patch) {
294
+ const next = { ...this.#state, ...patch };
295
+ next.phase = this.#busy ? 'streaming' : next.session.gatedTool ? 'gated' : 'idle';
296
+ this.#state = next;
297
+ if (this.#flushQueued)
298
+ return;
299
+ this.#flushQueued = true;
300
+ // why: Changes made together reach listeners once, after the code that made them has run.
301
+ queueMicrotask(() => this.#flush());
302
+ }
303
+ #apply(key, value) {
304
+ const next = typeof value === 'function'
305
+ ? value(this.#state[key])
306
+ : value;
307
+ this.#set({ [key]: next });
308
+ }
309
+ #flush() {
310
+ this.#flushQueued = false;
311
+ this.#settle();
312
+ if (this.#flushQueued || this.#notified === this.#state)
313
+ return;
314
+ this.#notified = this.#state;
315
+ for (const listener of [...this.#listeners])
316
+ listener();
317
+ }
318
+ /**
319
+ * What follows from the state as it now stands: the profile's default model, the conversation
320
+ * the chat resumes, a page gate that has no answer, and the queue's next message. Each is done
321
+ * once, so settling again changes nothing.
322
+ */
323
+ #settle() {
324
+ const { iface } = this.#options;
325
+ if (!iface)
326
+ return;
327
+ this.#followDefaults(iface);
328
+ this.#resume(iface);
329
+ this.#answerPageGate();
330
+ this.#drain();
331
+ }
332
+ #clearedComposer() {
333
+ return { draftText: '', pendingFiles: [], pendingVoice: [] };
334
+ }
335
+ #setDelivery(next) {
336
+ this.#delivery = next;
337
+ this.#set({ delivery: next?.status ?? null });
338
+ }
339
+ #adopt(ref) {
340
+ if (this.#conversation === ref)
341
+ return;
342
+ this.#conversation = ref;
343
+ this.#options.onConversationChange?.(ref);
344
+ }
345
+ #showFailure(failure) {
346
+ if (!failure || failure.aborted)
347
+ return;
348
+ const { error, errorKind, errorInternal } = failure;
349
+ this.#set({
350
+ failure: { error, errorKind, ...(errorInternal ? { errorInternal } : {}) },
351
+ ...(failure.issues ? { issues: failure.issues } : {}),
352
+ });
353
+ }
354
+ /** Draws the conversation as the host shows it: its transcript, and the reply that waits on gates. */
355
+ #draw(view, session, events) {
356
+ this.#set({
357
+ blocks: view.transcript,
358
+ streamBlocks: view.paused?.blocks ?? [],
359
+ // why: A reply at rest leaves the run's own events (tokens, an ask); a paused one, the host's.
360
+ session: sessionAfter(session, view.paused?.events ?? events),
361
+ chatStarted: view.transcript.length > 0 || view.paused !== undefined,
362
+ });
363
+ }
364
+ /**
365
+ * Keeps the model and effort valid as the interface changes: a pick still on the old defaults
366
+ * follows the new ones, and a pick the profile no longer has falls back to them.
367
+ */
368
+ #followDefaults(iface) {
369
+ const { session } = this.#state;
370
+ const next = followGenerationDefaults(iface, { model: session.selectedModel, effort: session.selectedEffort }, this.#defaultsFrom);
371
+ this.#defaultsFrom = iface;
372
+ if (next.model === session.selectedModel && next.effort === session.selectedEffort)
373
+ return;
374
+ this.#set({ session: { ...session, selectedModel: next.model, selectedEffort: next.effort } });
375
+ }
376
+ /** Draws the conversation the chat resumes, once. A run that starts first draws it itself. */
377
+ #resume(iface) {
378
+ if (this.#resumed)
379
+ return;
380
+ this.#resumed = true;
381
+ const ref = this.#conversation;
382
+ if (ref === undefined || !keepsConversation(iface))
383
+ return;
384
+ const shown = this.#options.conversationView;
385
+ if (shown) {
386
+ this.#draw(shown, this.#state.session, []);
387
+ return;
388
+ }
389
+ const before = this.#state.blocks;
390
+ void this.#options.transport.conversation({ conversation: ref }).then((view) => {
391
+ // why: A message sent while it loaded posted onto the host's conversation; its run draws it.
392
+ if (this.#state.blocks !== before || this.#busy)
393
+ return;
394
+ this.#draw(view, this.#state.session, []);
395
+ }, (err) => this.#showFailure(turnFailure(err, iface.labels)));
396
+ }
397
+ /**
398
+ * Settles a restored page gate as unanswered.
399
+ * invariant: A restored page gate cannot authorize callback execution. Only active dispatch grants do.
400
+ */
401
+ #answerPageGate() {
402
+ const gated = this.#state.phase === 'gated' ? this.#state.session.gatedTool : null;
403
+ if (gated?.gateKind !== 'page' || !gated.operationId)
404
+ return;
405
+ if (this.#answeredPage === gated.operationId)
406
+ return;
407
+ this.#answeredPage = gated.operationId;
408
+ void this.#answerGate({ action: 'page', page: { unanswered: true } });
409
+ }
410
+ /** Sends the next queued message once the chat is idle, when the run that ended allowed it. */
411
+ #drain() {
412
+ if (this.#state.phase !== 'idle' || !this.#drainArmed)
413
+ return;
414
+ this.#drainArmed = false;
415
+ if (this.#draining)
416
+ return;
417
+ const { message } = consumeNextComposerQueue(this.#state.pendingMessages);
418
+ if (!message)
419
+ return;
420
+ this.#draining = true;
421
+ // why: The message leaves the queue once it posts; one that never starts stays queued.
422
+ void this.#sendDraft(message.draft, {
423
+ onPosted: () => this.#set({
424
+ pendingMessages: removeComposerPendingMessage(this.#state.pendingMessages, message.id),
425
+ }),
426
+ }).finally(() => {
427
+ this.#draining = false;
428
+ });
429
+ }
430
+ /** Says in the console, once for each set of names, where the page's tools and the profile's disagree. */
431
+ #warnPageTools() {
432
+ const { iface, pageTools } = this.#options;
433
+ const declared = iface && 'tools' in iface ? iface.tools.page : undefined;
434
+ if (declared === undefined)
435
+ return;
436
+ const handled = Object.keys(pageTools ?? {}).sort();
437
+ const key = JSON.stringify([declared, handled]);
438
+ if (key === this.#pageToolsWarned)
439
+ return;
440
+ this.#pageToolsWarned = key;
441
+ warnPageToolMismatch(declared, handled);
442
+ }
443
+ /** The composer's draft, when it holds something to send. */
444
+ async #composerDraft() {
445
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
446
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
447
+ return userDraftHasPayload(draft) ? draft : undefined;
448
+ }
449
+ /** Puts a message back in the composer. What the composer holds now is stashed first, so nothing typed is lost. */
450
+ async #restore(fields) {
451
+ const { draftText, pendingFiles, pendingVoice } = this.#state;
452
+ if (composerHasPayload(draftText, pendingFiles, pendingVoice)) {
453
+ const draft = await encodeComposerDraft({ text: draftText, pendingFiles, pendingVoice });
454
+ const stash = createComposerPendingMessage({ kind: 'stash', draft });
455
+ this.#set({
456
+ pendingMessages: orderComposerPendingMessages([...this.#state.pendingMessages, stash]),
457
+ });
458
+ }
459
+ this.#set({
460
+ draftText: fields.text,
461
+ pendingFiles: fields.files,
462
+ pendingVoice: fields.voice,
463
+ issues: [],
464
+ });
465
+ }
466
+ /** A steer with no running turn: the visitor reads `session.turn_ended`; steers, this one too, move to the front of the queue. */
467
+ #steerAfterTurnEnded() {
468
+ this.#set({
469
+ failure: clientFailure(
470
+ // labels-exempt: internal diagnostic; the user reads session.turn_ended
471
+ new TheoremError('request', 'steer: no active turn', {
472
+ copy: { key: 'session.turn_ended' },
473
+ }), this.#options.iface?.labels),
474
+ // why: The composer already gave the message up, so it waits as the next to send, not lost.
475
+ pendingMessages: orderComposerPendingMessages(convertSteersToFrontQueued(this.#state.pendingMessages)),
476
+ });
477
+ }
478
+ /** The message posted: it joins the transcript, and the reply starts streaming. */
479
+ #posted(blocks, patch) {
480
+ this.#setDelivery({ status: 'sending' });
481
+ this.#set({
482
+ blocks: [...this.#state.blocks, ...blocks],
483
+ chatStarted: true,
484
+ streaming: true,
485
+ ...patch,
486
+ });
487
+ }
488
+ #sendFields(fields) {
489
+ return this.#runTurn(({ iface, view, transport, scope, signal }) => streamInterfaceTurn({
490
+ iface,
491
+ transport,
492
+ session: this.#state.session,
493
+ text: fields.text,
494
+ pendingFiles: fields.files,
495
+ pendingVoice: fields.voice,
496
+ signal,
497
+ scope,
498
+ view,
499
+ transcript: this.#state.blocks,
500
+ onUserBlocks: (blocks) => this.#posted(blocks, this.#clearedComposer()),
501
+ }), { turn: true, message: { restore: () => this.#restore(fields) } });
502
+ }
503
+ #sendDraft(draft, options) {
504
+ return this.#runTurn(({ iface, view, transport, scope, signal }) => streamInterfaceDraftTurn({
505
+ iface,
506
+ transport,
507
+ session: this.#state.session,
508
+ draft,
509
+ signal,
510
+ scope,
511
+ view,
512
+ transcript: this.#state.blocks,
513
+ walkAway: options.walkAway === true,
514
+ onUserBlocks: (blocks) => {
515
+ this.#posted(blocks, { streamBlocks: [] });
516
+ options.onPosted();
517
+ },
518
+ }), { turn: true, message: { restore: () => this.#restore(composerFieldsFromDraft(draft)) } });
519
+ }
520
+ async #answerGate(resolution) {
521
+ // why: Set once the run starts (not while another runs), and cleared once it has ended.
522
+ let started = false;
523
+ try {
524
+ await this.#runTurn(({ iface, view, transport, scope }) => {
525
+ started = true;
526
+ const { session } = this.#state;
527
+ // why: A person's answer shows on its gate while it settles; the page's has no gate to show on.
528
+ if (session.gatedTool && resolution.action !== 'page') {
529
+ this.#set({
530
+ answering: {
531
+ callId: session.gatedTool.callId,
532
+ operationId: issuedOperationId(session.gatedTool),
533
+ action: resolution.action,
534
+ },
535
+ });
536
+ }
537
+ return resumeInterfaceTool({ iface, transport, session, scope, resolution, view });
538
+ });
539
+ }
540
+ finally {
541
+ if (started)
542
+ this.#set({ answering: null });
543
+ }
544
+ }
545
+ /**
546
+ * The transport each run uses: the page's tools answer their calls, each turn carries the
547
+ * page's slots and context as they are when it is sent, and each line lands the steers it
548
+ * reports, moves the message's delivery on, and hands the chat the conversation's new ref
549
+ * (so a page that reloads mid-reply keeps the posted message).
550
+ */
551
+ #runTransport() {
552
+ const { transport, pageTools } = this.#options;
553
+ const base = withPageToolDispatch(transport, pageTools);
554
+ const tap = (onLine) => (line) => {
555
+ this.#took(line);
556
+ onLine(line);
557
+ };
558
+ return {
559
+ ...base,
560
+ turn: (request, onLine, signal) => base.turn(this.#withValues(request), tap(onLine), signal),
561
+ invoke: (request, onLine, signal) => base.invoke(request, tap(onLine), signal),
562
+ };
563
+ }
564
+ #withValues(request) {
565
+ const { iface, slots: fixed, context } = this.#options;
566
+ const slots = slotsOf(iface, fixed, this.#picked).values;
567
+ if (slots === undefined && context === undefined)
568
+ return request;
569
+ return {
570
+ ...request,
571
+ input: {
572
+ ...request.input,
573
+ ...(slots === undefined ? {} : { slots }),
574
+ ...(context === undefined ? {} : { context }),
575
+ },
576
+ };
577
+ }
578
+ /** One line of a run's stream, before the run folds it. */
579
+ #took(line) {
580
+ if (line.type === 'stage' && line.injected?.length) {
581
+ this.#set({ pendingMessages: removeLandedSteers(this.#state.pendingMessages, line) });
582
+ }
583
+ const reached = deliveryOf(line);
584
+ const delivery = this.#delivery;
585
+ if (delivery &&
586
+ reached &&
587
+ DELIVERY_ORDER.indexOf(reached) > DELIVERY_ORDER.indexOf(delivery.status)) {
588
+ this.#setDelivery({ status: reached });
589
+ }
590
+ if (line.type === 'conversation')
591
+ this.#adopt(line.ref);
592
+ else
593
+ this.#options.onTurnEvent?.(line);
594
+ }
595
+ #streamFrame(partial) {
596
+ this.#frameBlocks = partial;
597
+ if (this.#frame)
598
+ return;
599
+ // why: A stream can carry many lines a frame; the chat draws the latest once per frame.
600
+ this.#frame = nextFrame(() => {
601
+ this.#frame = null;
602
+ const next = this.#frameBlocks;
603
+ this.#frameBlocks = null;
604
+ if (next)
605
+ this.#set({ streamBlocks: next });
606
+ });
607
+ }
608
+ /**
609
+ * Runs one stream into the transcript: live partials while it streams, then what the run left
610
+ * (or its failure) and the queue's next step. Does nothing while another run is in flight.
611
+ */
612
+ async #runTurn(run, options = {}) {
613
+ const { iface } = this.#options;
614
+ if (!iface || this.#busy)
615
+ return;
616
+ const { message } = options;
617
+ const priorDelivery = this.#delivery;
618
+ const abort = options.turn ? new AbortController() : null;
619
+ const turnId = options.turn ? newTurnId() : undefined;
620
+ this.#busy = true;
621
+ this.#abort = abort;
622
+ this.#turnId = turnId ?? null;
623
+ this.#drainArmed = false;
624
+ // why: A message streams once it posts, so the previous reply never shows as streaming before it.
625
+ this.#set({ failure: null, ...(message ? {} : { streaming: true }) });
626
+ const start = { blocks: this.#state.blocks, streamBlocks: this.#state.streamBlocks };
627
+ // why: A run that answers a gate continues its reply; any other starts one.
628
+ if (!this.#state.session.gatedTool)
629
+ this.#replyWorkedMs = 0;
630
+ const startedAt = Date.now();
631
+ const done = (async () => {
632
+ try {
633
+ const outcome = await run({
634
+ iface,
635
+ transport: this.#runTransport(),
636
+ scope: { conversation: this.#conversation, turnId },
637
+ signal: abort?.signal,
638
+ view: {
639
+ blocks: (partial) => this.#streamFrame(partial),
640
+ // why: The reply goes on; the composer names what it left out.
641
+ skipped: (error) => this.#set({ failure: clientFailure(error, iface.labels) }),
642
+ },
643
+ });
644
+ const endedAt = Date.now();
645
+ this.#replyWorkedMs += endedAt - startedAt;
646
+ this.#endRun();
647
+ const unposted = message !== undefined && !outcome.posted;
648
+ // why: A message that posts itself (onUserBlocks) gets a fresh delivery.
649
+ if (unposted && this.#delivery !== priorDelivery)
650
+ await this.#takeBack(start, message);
651
+ let waits;
652
+ if (keepsConversation(iface)) {
653
+ waits = await this.#settleHosted(outcome, start);
654
+ }
655
+ else if (unposted) {
656
+ this.#showFailure(outcome.failure);
657
+ waits = this.#state.session.gatedTool !== null;
658
+ }
659
+ else {
660
+ waits = this.#settleLocal(iface, outcome, { workedMs: this.#replyWorkedMs, endedAt });
661
+ }
662
+ this.#runEnded(!outcome.failure && !waits);
663
+ }
664
+ catch (err) {
665
+ // why: The run left no view to draw; the composer says so, and the chat stays as it stood.
666
+ this.#endRun();
667
+ this.#showFailure(turnFailure(err, iface.labels));
668
+ this.#runEnded(false);
669
+ }
670
+ })();
671
+ this.#run = done;
672
+ await done;
673
+ if (this.#run === done)
674
+ this.#run = null;
675
+ }
676
+ /** The run is over: nothing streams, and nothing can stop or steer it. */
677
+ #endRun() {
678
+ this.#frame?.();
679
+ this.#frame = null;
680
+ this.#frameBlocks = null;
681
+ this.#busy = false;
682
+ this.#abort = null;
683
+ this.#turnId = null;
684
+ this.#set({ streaming: false });
685
+ }
686
+ /** Steers the reply never took wait at the front of the queue; the queue sends on when `drain`. */
687
+ #runEnded(drain) {
688
+ this.#drainArmed = drain;
689
+ this.#set({
690
+ pendingMessages: orderComposerPendingMessages(convertSteersToFrontQueued(this.#state.pendingMessages)),
691
+ });
692
+ }
693
+ /** A message that never reached the conversation leaves the transcript and goes back in the composer. */
694
+ async #takeBack(start, message) {
695
+ this.#setDelivery(null);
696
+ this.#set({
697
+ blocks: start.blocks,
698
+ streamBlocks: start.streamBlocks,
699
+ chatStarted: start.blocks.length > 0 || start.streamBlocks.length > 0,
700
+ });
701
+ await message.restore();
702
+ }
703
+ /**
704
+ * A host that keeps the conversation is the truth: the chat draws it again from its ref, even
705
+ * after a message that never posted (a walk can settle calls before it fails). A failure shows
706
+ * in the composer unless the host's transcript closed on it. Returns whether the reply waits
707
+ * on gates.
708
+ */
709
+ async #settleHosted(outcome, start) {
710
+ const { failure } = outcome;
711
+ if (failure)
712
+ this.#setDelivery(null);
713
+ const ref = this.#conversation;
714
+ if (ref === undefined) {
715
+ this.#set({ streamBlocks: start.streamBlocks });
716
+ this.#showFailure(failure);
717
+ return this.#state.session.gatedTool !== null;
718
+ }
719
+ const view = await this.#options.transport.conversation({ conversation: ref });
720
+ this.#draw(view, outcome.session, outcome.events);
721
+ if (!view.transcript.slice(start.blocks.length).some((b) => b.kind === 'error')) {
722
+ this.#showFailure(failure);
723
+ }
724
+ return view.paused !== undefined;
725
+ }
726
+ /**
727
+ * A stateless chat keeps the reply as the run streamed it: in the transcript once it ends
728
+ * (closed by its failure, or by the visitor's stop), or beside it while it waits on a gate.
729
+ * Returns whether it waits.
730
+ */
731
+ #settleLocal(iface, outcome, worked) {
732
+ const session = sessionAfter(outcome.session, outcome.events);
733
+ const { failure } = outcome;
734
+ const reply = stampWorked(foldAssistantTurn(iface, outcome.events), worked);
735
+ if (session.gatedTool !== null) {
736
+ this.#set({ session, streamBlocks: reply });
737
+ this.#showFailure(failure);
738
+ return true;
739
+ }
740
+ if (!failure) {
741
+ this.#set({ session, streamBlocks: [], blocks: [...this.#state.blocks, ...reply] });
742
+ return false;
743
+ }
744
+ this.#setDelivery(null);
745
+ const closing = failure.aborted
746
+ ? [{ id: crypto.randomUUID(), kind: 'turn-done', stop: { kind: 'cancelled' } }]
747
+ : withTurnError(reply, failure.error);
748
+ this.#set({
749
+ session,
750
+ streamBlocks: [],
751
+ blocks: [...this.#state.blocks, ...closing],
752
+ ...(failure.issues ? { issues: failure.issues } : {}),
753
+ });
754
+ return false;
755
+ }
756
+ }
757
+ /** A chat for `options`. Framework-free: see `ChatController`. */
758
+ export function createChatController(options) {
759
+ return new Chat(options);
760
+ }