@theoremjs/react 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (258) hide show
  1. package/README.md +728 -202
  2. package/client/composer-hints.js +1 -1
  3. package/client/decision-images.d.ts +76 -0
  4. package/client/decision-images.js +139 -0
  5. package/client/decision-transport.d.ts +26 -4
  6. package/client/decision-transport.js +79 -6
  7. package/client/embedding-input.d.ts +84 -0
  8. package/client/embedding-input.js +194 -0
  9. package/client/embedding-space.d.ts +82 -0
  10. package/client/embedding-space.js +389 -0
  11. package/client/embedding-transport.d.ts +40 -0
  12. package/client/embedding-transport.js +71 -0
  13. package/client/failure.d.ts +7 -10
  14. package/client/failure.js +9 -9
  15. package/client/host-call.d.ts +1 -1
  16. package/client/host-call.js +7 -2
  17. package/client/host-transport.d.ts +2 -0
  18. package/client/host-transport.js +3 -2
  19. package/client/index.d.ts +19 -11
  20. package/client/index.js +14 -10
  21. package/client/ink-waveform.d.ts +1 -1
  22. package/client/ink-waveform.js +1 -2
  23. package/client/live/apply-live-turn-tool-event.js +1 -1
  24. package/client/live/live-captions.d.ts +30 -0
  25. package/client/live/live-captions.js +105 -1
  26. package/client/live/live-mic-forward.d.ts +1 -0
  27. package/client/live/live-mic-forward.js +11 -2
  28. package/client/live/live-page-tool.d.ts +34 -0
  29. package/client/live/live-page-tool.js +11 -0
  30. package/client/live/live-state.d.ts +4 -2
  31. package/client/live/live-state.js +2 -0
  32. package/client/live/live-video.js +1 -1
  33. package/client/live/run-live-tool-call.d.ts +7 -2
  34. package/client/live/run-live-tool-call.js +48 -10
  35. package/client/live/use-live-caption-log.d.ts +17 -0
  36. package/client/live/use-live-caption-log.js +51 -0
  37. package/client/live-client.d.ts +52 -4
  38. package/client/live-client.js +250 -59
  39. package/client/live-messages.d.ts +51 -13
  40. package/client/live-messages.js +42 -9
  41. package/client/page-dispatch.d.ts +15 -0
  42. package/client/page-dispatch.js +63 -0
  43. package/client/run-commit.d.ts +16 -43
  44. package/client/run-commit.js +26 -101
  45. package/client/run-session.d.ts +40 -41
  46. package/client/run-session.js +104 -112
  47. package/client/schema-fields.js +27 -6
  48. package/client/shaped-data.js +37 -8
  49. package/client/source-citations.js +1 -1
  50. package/client/space-geometry.d.ts +39 -0
  51. package/client/space-geometry.js +183 -0
  52. package/client/state-fields.js +1 -1
  53. package/client/tool-result.js +30 -12
  54. package/client/tool-resume.d.ts +18 -1
  55. package/client/tool-resume.js +4 -1
  56. package/client/trace-feed.d.ts +1 -1
  57. package/client/trace-story.d.ts +2 -2
  58. package/client/trace-story.js +43 -19
  59. package/client/trace-view.d.ts +2 -2
  60. package/client/trace-view.js +56 -16
  61. package/client/transcript-groups.d.ts +28 -2
  62. package/client/transcript-groups.js +63 -13
  63. package/client/transcript-schema.d.ts +10 -0
  64. package/client/transcript-schema.js +120 -0
  65. package/client/transport.d.ts +114 -26
  66. package/client/transport.js +106 -32
  67. package/client/turn-client.d.ts +17 -8
  68. package/client/turn-client.js +26 -31
  69. package/client/voice-recorder.d.ts +1 -1
  70. package/client/voice-recorder.js +2 -5
  71. package/client/wire-line.js +5 -5
  72. package/client/worklets/mic-capture.js +9 -9
  73. package/components/InkWaveform.d.ts +12 -1
  74. package/components/InkWaveform.js +65 -42
  75. package/components/live/use-live-runner-controls.d.ts +3 -4
  76. package/components/live/use-live-runner-controls.js +6 -10
  77. package/components/live/use-live-runner-model.d.ts +10 -6
  78. package/components/live/use-live-runner-model.js +29 -14
  79. package/components/live/use-live-runner-ui.d.ts +10 -8
  80. package/components/live/use-live-runner-ui.js +19 -11
  81. package/components/live/use-live-session-client.d.ts +4 -4
  82. package/components/live/use-live-session-client.js +11 -8
  83. package/components/use-composer-voice.d.ts +4 -4
  84. package/components/use-composer-voice.js +8 -8
  85. package/components/use-editor-selection.js +4 -1
  86. package/components/use-voice-playback.d.ts +2 -1
  87. package/components/use-voice-playback.js +4 -4
  88. package/hooks/use-described.d.ts +5 -1
  89. package/hooks/use-described.js +11 -9
  90. package/hooks/use-embedding-index.d.ts +27 -0
  91. package/hooks/use-embedding-index.js +43 -0
  92. package/hooks/use-one-call.d.ts +31 -0
  93. package/hooks/use-one-call.js +76 -0
  94. package/hooks/use-page-tool-warnings.d.ts +6 -0
  95. package/hooks/use-page-tool-warnings.js +25 -0
  96. package/hooks/use-second-ticker.d.ts +2 -0
  97. package/hooks/use-second-ticker.js +13 -0
  98. package/hooks/use-theorem-chat-actions.d.ts +25 -21
  99. package/hooks/use-theorem-chat-actions.js +67 -45
  100. package/hooks/use-theorem-chat-state.d.ts +6 -6
  101. package/hooks/use-theorem-chat-state.js +13 -9
  102. package/hooks/use-theorem-chat.d.ts +48 -14
  103. package/hooks/use-theorem-chat.js +329 -141
  104. package/hooks/use-theorem-decision.d.ts +5 -10
  105. package/hooks/use-theorem-decision.js +4 -67
  106. package/hooks/use-theorem-embedding.d.ts +18 -0
  107. package/hooks/use-theorem-embedding.js +12 -0
  108. package/hooks/use-theorem-host.d.ts +5 -4
  109. package/hooks/use-theorem-host.js +26 -8
  110. package/hooks/use-theorem-interface.d.ts +6 -3
  111. package/hooks/use-theorem-interface.js +12 -22
  112. package/index.d.ts +6 -3
  113. package/index.js +5 -2
  114. package/live.d.ts +1 -0
  115. package/live.js +1 -1
  116. package/package.json +6 -5
  117. package/server/checkpoint-ownership.d.ts +6 -0
  118. package/server/checkpoint-ownership.js +42 -0
  119. package/server/conversation-store.d.ts +92 -0
  120. package/server/conversation-store.js +131 -0
  121. package/server/conversation.d.ts +193 -0
  122. package/server/conversation.js +552 -0
  123. package/server/decision-handler.d.ts +12 -4
  124. package/server/decision-handler.js +29 -38
  125. package/server/embedding-handler.d.ts +33 -0
  126. package/server/embedding-handler.js +45 -0
  127. package/server/handler.d.ts +58 -20
  128. package/server/handler.js +441 -238
  129. package/server/mod.d.ts +15 -6
  130. package/server/mod.js +13 -5
  131. package/server/page-dispatch.d.ts +22 -0
  132. package/server/page-dispatch.js +127 -0
  133. package/server/request-body.d.ts +2 -0
  134. package/server/request-body.js +42 -0
  135. package/server/request-check.d.ts +6 -1
  136. package/server/request-check.js +27 -12
  137. package/server/session-authority.d.ts +8 -0
  138. package/server/session-authority.js +78 -0
  139. package/server/session-boundary.d.ts +26 -0
  140. package/server/session-boundary.js +64 -0
  141. package/server/session-identity.d.ts +16 -0
  142. package/server/session-identity.js +41 -0
  143. package/server/session-limits.d.ts +12 -0
  144. package/server/session-limits.js +59 -0
  145. package/server/session-store.d.ts +69 -20
  146. package/server/session-store.js +220 -5
  147. package/server/single-action.d.ts +21 -0
  148. package/server/single-action.js +30 -0
  149. package/server/steer-inbox.js +1 -1
  150. package/server/tool-operations.d.ts +6 -0
  151. package/server/tool-operations.js +132 -0
  152. package/server/turn-input.d.ts +10 -0
  153. package/server/turn-input.js +29 -0
  154. package/styles/ink-controls.css +78 -78
  155. package/ui/CardWall.d.ts +10 -0
  156. package/ui/CardWall.js +61 -0
  157. package/ui/ChatComposerBar.d.ts +28 -5
  158. package/ui/ChatComposerBar.js +85 -30
  159. package/ui/ChatTranscript.d.ts +14 -1
  160. package/ui/ChatTranscript.js +110 -57
  161. package/ui/ConsoleFrame.d.ts +61 -4
  162. package/ui/ConsoleFrame.js +109 -9
  163. package/ui/DecisionAnswers.d.ts +40 -2
  164. package/ui/DecisionAnswers.js +166 -35
  165. package/ui/DecisionImages.d.ts +16 -0
  166. package/ui/DecisionImages.js +46 -0
  167. package/ui/EmbeddingDocuments.d.ts +78 -0
  168. package/ui/EmbeddingDocuments.js +278 -0
  169. package/ui/EmbeddingQuery.d.ts +27 -0
  170. package/ui/EmbeddingQuery.js +95 -0
  171. package/ui/EmbeddingResults.d.ts +41 -0
  172. package/ui/EmbeddingResults.js +165 -0
  173. package/ui/EmbeddingSpace.d.ts +26 -0
  174. package/ui/EmbeddingSpace.js +106 -0
  175. package/ui/LiveCaptionsPanel.d.ts +22 -0
  176. package/ui/LiveCaptionsPanel.js +27 -0
  177. package/ui/LiveRunner.d.ts +6 -4
  178. package/ui/LiveRunner.js +42 -55
  179. package/ui/PaneState.d.ts +33 -0
  180. package/ui/PaneState.js +50 -0
  181. package/ui/SchemaFields.d.ts +1 -1
  182. package/ui/SchemaFields.js +54 -26
  183. package/ui/ShapedData.js +73 -28
  184. package/ui/SidePanel.d.ts +74 -5
  185. package/ui/SidePanel.js +82 -9
  186. package/ui/StateFields.d.ts +10 -2
  187. package/ui/StateFields.js +56 -13
  188. package/ui/TheoremChat.d.ts +65 -14
  189. package/ui/TheoremChat.js +66 -78
  190. package/ui/TheoremDecision.d.ts +12 -20
  191. package/ui/TheoremDecision.js +78 -35
  192. package/ui/TheoremEmbedding.d.ts +27 -0
  193. package/ui/TheoremEmbedding.js +122 -0
  194. package/ui/TheoremHost.d.ts +22 -5
  195. package/ui/TheoremHost.js +127 -37
  196. package/ui/ToolGateCard.d.ts +5 -1
  197. package/ui/ToolGateCard.js +30 -23
  198. package/ui/ToolResult.js +12 -10
  199. package/ui/ToolResultCharts.js +29 -12
  200. package/ui/TraceCharts.d.ts +2 -2
  201. package/ui/TraceCharts.js +48 -17
  202. package/ui/TraceGuardrails.d.ts +3 -2
  203. package/ui/TraceGuardrails.js +24 -6
  204. package/ui/TraceInspector.d.ts +11 -1
  205. package/ui/TraceInspector.js +45 -28
  206. package/ui/TraceInspectorPanel.d.ts +31 -9
  207. package/ui/TraceInspectorPanel.js +77 -39
  208. package/ui/TraceOverview.d.ts +7 -3
  209. package/ui/TraceOverview.js +44 -14
  210. package/ui/TraceSpanDetail.js +24 -22
  211. package/ui/TraceStory.js +9 -8
  212. package/ui/TraceValues.js +19 -9
  213. package/ui/TraceWaterfall.js +12 -9
  214. package/ui/VoiceNote.js +4 -4
  215. package/ui/approval-heading.d.ts +4 -0
  216. package/ui/approval-heading.js +9 -0
  217. package/ui/arrive.d.ts +18 -0
  218. package/ui/arrive.js +46 -0
  219. package/ui/built/theme.css +86 -68
  220. package/ui/built/theme.css.d.ts +9 -0
  221. package/ui/built/theorem.d.ts +3 -3
  222. package/ui/built/theorem.js +34 -5
  223. package/ui/built/theorem.variants.d.ts +8 -2
  224. package/ui/code-language.d.ts +12 -0
  225. package/ui/code-language.js +50 -0
  226. package/ui/disclosure-motion.js +53 -9
  227. package/ui/embedding-space-gl.d.ts +37 -0
  228. package/ui/embedding-space-gl.js +663 -0
  229. package/ui/embedding-space-view.d.ts +8 -0
  230. package/ui/embedding-space-view.js +8 -0
  231. package/ui/glide.d.ts +7 -0
  232. package/ui/glide.js +44 -0
  233. package/ui/icons.js +2 -1
  234. package/ui/index.d.ts +20 -9
  235. package/ui/index.js +20 -10
  236. package/ui/labels-provider.js +5 -2
  237. package/ui/labels.d.ts +374 -85
  238. package/ui/labels.js +1028 -188
  239. package/ui/markdown-plugins.d.ts +9 -0
  240. package/ui/markdown-plugins.js +13 -0
  241. package/ui/mcp-icon.d.ts +6 -0
  242. package/ui/mcp-icon.js +8 -0
  243. package/ui/row-keys.d.ts +19 -0
  244. package/ui/row-keys.js +33 -0
  245. package/ui/theme.d.ts +1 -1
  246. package/ui/theme.js +2 -2
  247. package/ui/theorem-theme.js +32 -10
  248. package/ui/trace-palette.d.ts +2 -16
  249. package/ui/trace-palette.js +2 -16
  250. package/ui/trace-patterns.d.ts +11 -3
  251. package/ui/trace-patterns.js +16 -3
  252. package/ui/transcript-copy-text.js +1 -0
  253. package/ui/wall-columns.d.ts +8 -0
  254. package/ui/wall-columns.js +19 -0
  255. package/client/walk-away.d.ts +0 -34
  256. package/client/walk-away.js +0 -48
  257. package/server/walk-away.d.ts +0 -22
  258. package/server/walk-away.js +0 -41
package/server/handler.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { withPublicLabels } from '@theoremjs/agents/guardrails';
2
+ import { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
1
3
  /**
2
4
  * One Web-standard handler that serves a Theorem chat UI for a single profile.
3
5
  *
@@ -9,36 +11,47 @@
9
11
  * waits on gates walks away from them first (`abandon`)
10
12
  * - `POST <base>/invoke` → NDJSON events for the user's answer to a paused tool call
11
13
  * - `POST <base>/steer` → inject messages into a running turn
14
+ * - `POST <base>/conversation` → the transcript and the paused reply, to draw
15
+ * - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
12
16
  *
13
17
  * `createTheoremHostHandler` serves a host profile the same way, with no model:
14
18
  *
15
19
  * - `GET <base>` → `{ interface }` the tools the page may call, with their schemas
16
20
  * - `POST <base>/call` → NDJSON events for one call of one of those tools
17
21
  * - `POST <base>/invoke` → NDJSON events for the user's answer to a paused call
22
+ * - `POST <base>/page-result` → grant-correlated browser result receipt
18
23
  *
19
- * Trust boundary: the browser owns the conversation text; the server owns every
20
- * decision that grants authority. Tool permissions, which paused calls may run
21
- * (and with what input), and which provider interactions may be continued are
22
- * kept in a server-side session — request bodies can't grant any of them.
24
+ * Trust boundary: the host owns the conversation and every decision that grants
25
+ * authority. Two stores keep them. The `conversationStore` owns the transcript,
26
+ * the history the model reads and the paused reply; the browser sends only what
27
+ * the person did now, with the opaque ref the store issued. The session store
28
+ * owns which paused calls may run (and with what input) and the tool
29
+ * permissions — request bodies can't grant any of them. A profile whose `providerContinuation.history` is
30
+ * `'stateless'` keeps no conversation, and takes no store.
23
31
  * Tool credentials are held on the server too: a key the user types at a gate
24
32
  * is sent once and saved, and no credential is ever sent back to the browser.
25
33
  *
26
34
  * @module
27
35
  */
28
- import { createProvider, defaultKernelScope, defineProfile, errorKind, invokeTool, lexiconText, publicError, registerProfile, runTurn, TheoremError, } from '@theoremjs/agents';
29
- import { caughtStatus, forClient, HTTP_METHOD, } from '@theoremjs/agents/host';
30
- import { answerGatedCall, resolveGateTtlMs, toBase64Url, } from '@theoremjs/agents/kernel';
36
+ import { authorizeToolReplay, compactHistory, defaultKernelScope, defineProfile, invokeTool, jsonObjectSchema, labelText, registerProfile, runTurn, TheoremError, toolInvocationHash, } from '@theoremjs/agents';
37
+ import { caughtStatus, clientError, forClient, HTTP_METHOD, } from '@theoremjs/agents/host';
38
+ import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
39
+ import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
31
40
  import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
32
- import { gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, settlesToolCall, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
33
- import { theoremInvokeRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
41
+ import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
42
+ import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
43
+ import { unknownConversation } from "./conversation-store.js";
34
44
  import { createMemoryCredentialStore, } from "./credential-store.js";
45
+ import { sessionStateStore } from "./session-authority.js";
46
+ import { theoremSession } from "./session-identity.js";
47
+ export { theoremSession, theoremSessionId } from "./session-identity.js";
48
+ import { jsonResponse } from "./json-response.js";
49
+ import { boundedRequestJson, requestByteLimit } from "./request-body.js";
35
50
  import { checkRequest } from "./request-check.js";
36
- import { createMemorySessionStore, emptySessionState, pruneGates, } from "./session-store.js";
51
+ import { resolveSessionLimits } from "./session-limits.js";
52
+ import { createMemorySessionStore, DEFAULT_OPERATION_RETENTION_MS, } from "./session-store.js";
37
53
  import { createMemorySteerInbox, steerStage, steerUnitOf } from "./steer-inbox.js";
38
- import { checkWalkAway, walkAway } from "./walk-away.js";
39
- import { jsonResponse } from "./json-response.js";
40
- const SESSION_COOKIE = 'theorem_session';
41
- const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{32,128}$/;
54
+ import { sameAcceptedAnswer, sessionToolCoordinator } from "./tool-operations.js";
42
55
  const NDJSON_HEADERS = {
43
56
  'content-type': 'application/x-ndjson; charset=utf-8',
44
57
  'cache-control': 'no-store',
@@ -48,82 +61,41 @@ function withSessionCookie(response, session) {
48
61
  response.headers.append('set-cookie', session.setCookie);
49
62
  return response;
50
63
  }
51
- /** Strip server-only identity (system prompts) from the projected interface. */
52
- function clientInterface(profile) {
53
- const iface = interfaceFromProfile(profile, defaultKernelScope.tools);
54
- return { ...iface, identity: { handle: iface.identity.handle } };
55
- }
56
64
  /** A JSON body checked against `schema`; a missing or malformed field is a `request` error (400). */
57
- export async function readBody(request, schema) {
58
- // JSON-only POSTs can't be sent by a cross-site form, and force a CORS preflight.
65
+ export async function readBody(request, schema, maxBytes) {
66
+ // why: JSON-only POSTs can't be sent by a cross-site form, and force a CORS preflight.
59
67
  const type = request.headers.get('content-type') ?? '';
60
68
  if (!type.toLowerCase().startsWith('application/json')) {
61
- // lexicon-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
69
+ // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
62
70
  throw new TheoremError('request', 'Content-Type must be application/json');
63
71
  }
64
72
  let raw;
65
73
  try {
66
- raw = await request.json();
74
+ raw = await boundedRequestJson(request, requestByteLimit(maxBytes));
67
75
  }
68
- catch (cause) {
69
- // lexicon-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
70
- throw new TheoremError('request', 'Request body must be JSON', { cause });
76
+ catch (error) {
77
+ if (error instanceof TheoremError)
78
+ throw error;
79
+ // invariant: A JSON parser's message can contain request values and must not enter diagnostics.
80
+ throw new TheoremError('request', labelText('error.request'), {
81
+ cause: { code: 'request_json' },
82
+ });
71
83
  }
72
84
  return checkRequest(schema, raw, 'request body');
73
85
  }
74
- /** Conversation messages a browser may supply — never system instructions. */
75
- function conversationOnly(messages) {
76
- return messages.filter((message) => message.role === 'user' || message.role === 'assistant' || message.role === 'tool');
77
- }
78
- /**
79
- * Keep the user-authored parts of a turn input. Drops fields that would let a
80
- * client speak as the host: system-role history, `role`, `repair` guidance,
81
- * token-meter overrides, and live resumption handles.
82
- */
83
- function userTurnInput(input) {
84
- return {
85
- ...(input.text !== undefined ? { text: input.text } : {}),
86
- ...(input.attachments ? { attachments: input.attachments } : {}),
87
- ...(input.voice ? { voice: input.voice } : {}),
88
- ...(input.history ? { history: conversationOnly(input.history) } : {}),
89
- };
90
- }
91
- /** Last path segment after the mount point: '', 'turn', 'invoke', 'steer', or a host's 'call'. */
86
+ /** Last path segment after the mount point: '', a chat's route, or a host's 'call'. */
92
87
  function routeOf(request) {
93
88
  const segments = new URL(request.url).pathname.split('/').filter(Boolean);
94
89
  const last = segments.at(-1) ?? '';
95
- return last === 'turn' || last === 'invoke' || last === 'steer' || last === 'call' ? last : '';
96
- }
97
- function readCookie(request, name) {
98
- for (const part of (request.headers.get('cookie') ?? '').split(';')) {
99
- const [key, ...rest] = part.trim().split('=');
100
- if (key === name)
101
- return rest.join('=');
102
- }
103
- return undefined;
104
- }
105
- function newSessionId() {
106
- return toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
107
- }
108
- /**
109
- * The session id the handler issued in its cookie, for a host route outside the
110
- * handler (an OAuth callback) that saves credentials under it. Hosts that pass
111
- * a `session` resolver use their own id instead.
112
- */
113
- export function theoremSessionId(request) {
114
- const existing = readCookie(request, SESSION_COOKIE);
115
- return existing && SESSION_ID_PATTERN.test(existing) ? existing : undefined;
116
- }
117
- function cookieSession(request) {
118
- const existing = theoremSessionId(request);
119
- if (existing)
120
- return { id: existing };
121
- const id = newSessionId();
122
- const secure = new URL(request.url).protocol === 'https:' ? '; Secure' : '';
123
- return {
124
- id,
125
- setCookie: `${SESSION_COOKIE}=${id}; Path=/; HttpOnly; SameSite=Lax${secure}`,
126
- };
90
+ return last === 'turn' ||
91
+ last === 'invoke' ||
92
+ last === 'steer' ||
93
+ last === 'conversation' ||
94
+ last === 'rewind' ||
95
+ last === 'call' ||
96
+ last === 'page-result'
97
+ ? last
98
+ : '';
127
99
  }
128
100
  function lockedStore(store, loaded) {
129
101
  const locks = new Map();
@@ -147,59 +119,64 @@ function lockedStore(store, loaded) {
147
119
  },
148
120
  };
149
121
  }
150
- /** Session state, with expired gates dropped as it loads so they can't be answered. */
151
- function sessionStateStore(store, gateTtlMs) {
152
- return lockedStore(store, (state = emptySessionState()) => ({
153
- ...state,
154
- gates: pruneGates(state.gates, Date.now(), gateTtlMs),
155
- settled: pruneGates(state.settled, Date.now(), gateTtlMs),
156
- }));
122
+ /** The profile as the server reads a message against it, its tools as registered now. */
123
+ function chatInterface(chat) {
124
+ return interfaceFromProfile(chat.profile, defaultKernelScope.tools);
157
125
  }
158
126
  async function sessionOf(ctx, request) {
159
127
  if (!ctx.options.session)
160
- return cookieSession(request);
128
+ return theoremSession(request);
161
129
  const id = (await ctx.options.session(request))?.trim();
162
130
  if (!id) {
163
- // lexicon-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
131
+ // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
164
132
  throw new TheoremError('auth', 'the session resolver refused the request', {
165
133
  copy: { key: 'session.sign_in' },
166
134
  });
167
135
  }
168
136
  return { id };
169
137
  }
170
- /** Reports the error to the host, then words it for the user from the profile's lexicon. */
171
- function publicMessage(ctx, err, request) {
138
+ /** Reports the error to the host, then words it for whoever reads this host's replies. */
139
+ function caughtError(ctx, err, request) {
172
140
  ctx.options.onError?.(err, { request });
173
- return publicError(err, ctx.profile.lexicon);
141
+ return clientError(err, ctx.profile.labels, ctx.options.clientEvents);
174
142
  }
175
143
  function eventStream(ctx, request, source) {
176
144
  const encoder = new TextEncoder();
177
145
  const line = (value) => encoder.encode(`${JSON.stringify(value)}\n`);
146
+ // invariant: The source runs to its end after the browser goes, so a stopped reply still reaches its save.
147
+ let cancelled = false;
178
148
  const stream = new ReadableStream({
179
149
  async start(controller) {
150
+ const send = (value) => {
151
+ if (!cancelled)
152
+ controller.enqueue(line(value));
153
+ };
180
154
  try {
181
155
  for await (const event of source()) {
182
- controller.enqueue(line(forClient(event, ctx.options.clientEvents)));
156
+ send(event.type === 'conversation' ? event : forClient(event, ctx.options.clientEvents));
183
157
  }
184
158
  }
185
159
  catch (err) {
186
- if (!request.signal.aborted) {
187
- controller.enqueue(line({ type: 'error', error: publicMessage(ctx, err, request), errorKind: errorKind(err) }));
188
- }
160
+ if (!request.signal.aborted)
161
+ send({ type: 'error', ...caughtError(ctx, err, request) });
189
162
  }
190
- controller.close();
163
+ if (!cancelled)
164
+ controller.close();
165
+ },
166
+ cancel() {
167
+ cancelled = true;
191
168
  },
192
169
  });
193
170
  return new Response(stream, { headers: NDJSON_HEADERS });
194
171
  }
195
172
  function providerFor(ctx, request, model) {
196
173
  const { provider } = ctx.options;
197
- // lexicon-exempt: builder config error; the user reads error.config
174
+ // labels-exempt: builder config error; the user reads error.config
198
175
  if (!provider)
199
176
  throw new TheoremError('config', `profile ${ctx.profile.id} has no provider`);
200
177
  if (typeof provider === 'function')
201
178
  return provider({ request, model });
202
- return createProvider(ctx.profile, provider, model);
179
+ return provider;
203
180
  }
204
181
  /** Steer inboxes are scoped to the session, so a turn id alone can't reach another user's turn. */
205
182
  function inboxKey(sessionId, turnId) {
@@ -210,9 +187,16 @@ function pendingGatesFrom(events, context) {
210
187
  return gatedToolsFromEvents(events).map((gated) => ({
211
188
  callId: gated.callId,
212
189
  gate: {
190
+ profileId: context.profileId,
191
+ operationId: requiredOperationId(gated),
192
+ callId: gated.callId,
213
193
  name: gated.name,
214
194
  arguments: gated.arguments,
215
- gate: { kind: gated.gateKind, permission: gated.permission, ...(gated.auth ? { auth: gated.auth } : {}) },
195
+ gate: {
196
+ kind: gated.gateKind,
197
+ permission: gated.permission,
198
+ ...(gated.auth ? { auth: gated.auth } : {}),
199
+ },
216
200
  snapshot: toolSnapshotFromEvents(events),
217
201
  promoted: [...new Set([...(context.promoted ?? []), ...promotedToolIdsFromEvents(events)])],
218
202
  turnInput: context.turnInput,
@@ -221,26 +205,31 @@ function pendingGatesFrom(events, context) {
221
205
  },
222
206
  }));
223
207
  }
224
- /**
225
- * Record what the stream established: interaction ids for continuation and,
226
- * when it paused on gates, the exact calls the user may now approve.
227
- */
208
+ /** When the stream paused on gates, record the exact calls the user may now approve. */
228
209
  async function recordOutcome(sessions, sessionId, events, context) {
229
- const interactionIds = events.flatMap((event) => event.type === 'done' && event.interactionId ? [event.interactionId] : []);
230
210
  const pending = pendingGatesFrom(events, context);
231
- if (!interactionIds.length && !pending.length)
211
+ if (!pending.length)
232
212
  return;
233
213
  await sessions.mutate(sessionId, (state) => {
234
- state.interactions.push(...interactionIds);
235
- for (const { callId, gate } of pending)
236
- state.gates[callId] = gate;
214
+ for (const { gate } of pending) {
215
+ const operation = state.operations[gate.operationId];
216
+ if (operation && operation.status !== 'ready')
217
+ throw new TheoremError('unavailable', labelText('error.unavailable'));
218
+ delete state.operations[gate.operationId];
219
+ Object.defineProperty(state.gates, gate.operationId, {
220
+ value: gate,
221
+ writable: true,
222
+ enumerable: true,
223
+ configurable: true,
224
+ });
225
+ }
237
226
  });
238
227
  }
239
- async function* recorded(sessions, sessionId, events, context) {
240
- const seen = [];
228
+ async function* recorded(sessions, sessionId, events, context, seed = []) {
229
+ const seen = [...seed];
241
230
  for await (const event of events) {
242
231
  seen.push(event);
243
- if (event.type === 'done')
232
+ if (event.type === 'done' || event.type === 'provider_checkpoint')
244
233
  await recordOutcome(sessions, sessionId, seen, context);
245
234
  yield event;
246
235
  }
@@ -268,7 +257,7 @@ async function withAuthorizationUrl(ctx, request, sessionId, event) {
268
257
  return event;
269
258
  const { tool } = event;
270
259
  const gate = tool.gate.kind === 'auth' ? tool.gate : undefined;
271
- if (!gate || gate.authChallenge.authType !== 'oauth2' || !ctx.options.authorizationUrl)
260
+ if (gate?.authChallenge.authType !== 'oauth2' || !ctx.options.authorizationUrl)
272
261
  return event;
273
262
  const challenge = gate.authChallenge;
274
263
  const authorizationUrl = await ctx.options.authorizationUrl(challenge, { request, sessionId });
@@ -282,78 +271,106 @@ async function* withAuthorizationUrls(ctx, request, sessionId, events) {
282
271
  for await (const event of events)
283
272
  yield await withAuthorizationUrl(ctx, request, sessionId, event);
284
273
  }
285
- function answerPendingGate(state, request) {
286
- const pending = state.gates[request.callId];
287
- if (!pending) {
288
- // lexicon-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
274
+ function answerPendingGate(state, request, profileId) {
275
+ const operation = Object.hasOwn(state.operations, request.operationId)
276
+ ? state.operations[request.operationId]
277
+ : undefined;
278
+ if (operation?.status === 'ready' &&
279
+ operation.profileId === profileId &&
280
+ operation.pending.callId === request.callId) {
281
+ const accepted = answerGatedCall(request, {
282
+ name: operation.pending.name,
283
+ arguments: operation.pending.arguments,
284
+ permission: operation.pending.gate.permission,
285
+ gateKind: operation.pending.gate.kind,
286
+ ...(operation.pending.gate.kind === 'auth' && operation.pending.gate.auth
287
+ ? { auth: operation.pending.gate.auth }
288
+ : {}),
289
+ }, operation.answer.sessionPermissions);
290
+ const { typed: _typed, ...plain } = accepted;
291
+ if (!sameAcceptedAnswer(plain, operation.answer))
292
+ throw new TheoremError('request', labelText('error.request'));
293
+ return {
294
+ callId: request.callId,
295
+ pending: operation.pending,
296
+ answer: { ...operation.answer, ...(accepted.typed ? { typed: accepted.typed } : {}) },
297
+ };
298
+ }
299
+ const pending = Object.hasOwn(state.gates, request.operationId)
300
+ ? state.gates[request.operationId]
301
+ : undefined;
302
+ if (!pending || pending.profileId !== profileId || pending.callId !== request.callId) {
303
+ // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
289
304
  throw new TheoremError('request', `gate ${request.callId} is not pending`, {
290
305
  copy: { key: 'session.gate_expired' },
291
306
  });
292
307
  }
293
- // A refused answer (a refused secret, an edit without an approval) throws here and leaves the gate pending.
308
+ // why: A refused answer (a refused secret, an edit without an approval) throws here and leaves the gate pending.
294
309
  const answer = answerGatedCall(request, {
295
310
  name: pending.name,
296
311
  arguments: pending.arguments,
297
312
  permission: pending.gate.permission,
313
+ gateKind: pending.gate.kind,
298
314
  ...(pending.gate.kind === 'auth' && pending.gate.auth ? { auth: pending.gate.auth } : {}),
299
315
  }, state.permissions);
300
- delete state.gates[request.callId];
316
+ delete state.gates[request.operationId];
317
+ const { typed: _typed, ...accepted } = answer;
318
+ state.operations[request.operationId] = {
319
+ status: 'ready',
320
+ profileId,
321
+ pending,
322
+ answer: accepted,
323
+ readyAt: Date.now(),
324
+ };
301
325
  state.permissions = answer.sessionPermissions;
302
326
  return { callId: request.callId, pending, answer };
303
327
  }
304
- function walkedCallOf(state, callId) {
305
- const settled = state.settled[callId];
306
- return settled ? { callId, settled } : answerPendingGate(state, { callId, decision: 'abandon' });
307
- }
308
- /**
309
- * Take the calls a message walks away from out of the session, answered
310
- * `abandon`: every one of them, or none when one is neither waiting nor
311
- * settled. The message's history must leave exactly those calls open.
312
- */
313
- function takeWalkedAway(ctx, session, input, abandon) {
314
- checkWalkAway(input, abandon);
315
- // A throw leaves the session unsaved, so no call is taken unless all are.
316
- return ctx.sessions.mutate(session.id, (state) => abandon.map((callId) => walkedCallOf(state, callId)));
328
+ function walkedCallOf(state, reference, profileId) {
329
+ const settled = Object.hasOwn(state.operations, reference.operationId)
330
+ ? state.operations[reference.operationId]
331
+ : undefined;
332
+ return settled?.status === 'settled' &&
333
+ settled.callId === reference.callId &&
334
+ settled.profileId === profileId
335
+ ? { callId: reference.callId, settled }
336
+ : answerPendingGate(state, { ...reference, decision: 'abandon' }, profileId);
317
337
  }
318
338
  /**
319
- * The run that settles an answered call, as the model asked it. The session
320
- * keeps the event that settles it, and `ended` names the call once it has
321
- * (see `restoreUnended`).
339
+ * Take the calls a message or a rewind walks away from out of the session,
340
+ * answered `abandon`: every one of them, or none when one is neither waiting
341
+ * nor settled. Each comes back with the run that settles it.
322
342
  */
323
- async function* settleAnswered(ctx, request, session, answered, ended) {
324
- const { callId, pending } = answered;
325
- for await (const event of invokeAnswered(ctx, request, session.id, answered)) {
326
- if (!ended.has(callId) && settlesToolCall(event, callId)) {
327
- ended.add(callId);
328
- await ctx.sessions.mutate(session.id, (state) => {
329
- state.settled[callId] = { event, createdAt: pending.createdAt };
330
- });
331
- }
332
- yield event;
333
- }
343
+ async function takeWalkedAway(ctx, request, session, abandon) {
344
+ // why: A throw leaves the session unsaved, so no call is taken unless all are.
345
+ const calls = await ctx.sessions.mutate(session.id, (state) => abandon.map((reference) => ({
346
+ reference,
347
+ call: walkedCallOf(state, reference, ctx.profile.id),
348
+ })));
349
+ return calls.map(({ reference, call }) => ({
350
+ ...reference,
351
+ events: 'settled' in call
352
+ ? replayOperation(ctx, request, call.settled)
353
+ : settleAnswered(ctx, request, session, call),
354
+ }));
334
355
  }
335
- /**
336
- * A request that took calls and ended before they settled (the client went
337
- * away, the host failed, an earlier call's run threw) puts each back to wait
338
- * as it did, to be answered again, unless it paused on a gate again: the run
339
- * recorded that gate with its outcome.
340
- */
341
- async function restoreUnended(ctx, session, calls, ended) {
342
- const unended = calls.flatMap((call) => ('pending' in call && !ended.has(call.callId) ? [call] : []));
343
- if (!unended.length)
344
- return;
345
- await ctx.sessions.mutate(session.id, (state) => {
346
- for (const { callId, pending } of unended)
347
- state.gates[callId] ??= pending;
348
- });
356
+ async function* settleAnswered(ctx, request, session, answered) {
357
+ yield* invokeAnswered(ctx, request, session.id, answered);
349
358
  }
350
- function invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
351
- return invokeTool({
359
+ async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
360
+ const host = await ctx.options.host?.(request);
361
+ yield* invokeTool({
352
362
  profile: ctx.profile.id,
363
+ execution: sessionToolCoordinator(ctx.sessions, sessionId),
364
+ pageExecution: sessionPageDispatch(ctx.sessions, sessionId, ctx.pageToolBroker).execution,
353
365
  name: pending.name,
354
366
  callId,
367
+ operationId: pending.operationId,
355
368
  input: answer.input,
356
369
  resume: answer.resume,
370
+ // why: An approval that names no answer is the page saying it has none, so the call is not held again.
371
+ ...(pending.gate.kind === 'page' && answer.resume.granted
372
+ ? { page: answer.page ?? { unanswered: true } }
373
+ : {}),
357
374
  sessionPermissions: answer.sessionPermissions,
358
375
  credentials: sessionCredentials(ctx, sessionId),
359
376
  turnInput: pending.turnInput,
@@ -361,58 +378,40 @@ function invokeAnswered(ctx, request, sessionId, { callId, pending, answer }) {
361
378
  promoted: pending.promoted,
362
379
  model: pending.model,
363
380
  signal: request.signal,
364
- host: ctx.options.host?.(request),
381
+ host,
365
382
  });
366
383
  }
367
- /** Settles each walked-away call, and returns the input with their answers. */
368
- async function* walkAwayFrom(ctx, request, session, walked, ended) {
369
- return yield* walkAway(walked.input, walked.calls.map((call) => ({
370
- callId: call.callId,
371
- events: 'settled' in call
372
- ? once(call.settled.event)
373
- : settleAnswered(ctx, request, session, call, ended),
374
- })));
375
- }
376
384
  /** A settled call's answer, streamed again. */
377
- async function* once(event) {
378
- yield event;
385
+ async function* once(event, labels) {
386
+ yield withPublicLabels(event, labels);
379
387
  }
380
- async function* turnEvents(ctx, request, session, body, walked) {
381
- const ended = new Set();
382
- let input;
383
- try {
384
- input = walked.calls.length ? yield* walkAwayFrom(ctx, request, session, walked, ended) : walked.input;
385
- }
386
- finally {
387
- await restoreUnended(ctx, session, walked.calls, ended);
388
- }
389
- if (!input)
390
- return;
388
+ /** One model run of a chat's reply, its gates recorded in the session as they pause. */
389
+ async function* chatTurn(ctx, request, session, turn, turnId) {
391
390
  const state = await ctx.sessions.read(session.id);
392
- const provider = await providerFor(ctx, request, body.model);
393
- // Only continue provider-side conversations this session started.
394
- const previousInteractionId = body.previousInteractionId && state.interactions.includes(body.previousInteractionId)
395
- ? body.previousInteractionId
396
- : undefined;
397
- const key = body.turnId ? inboxKey(session.id, body.turnId) : undefined;
391
+ const provider = await providerFor(ctx, request, turn.model);
392
+ const host = await ctx.options.host?.(request);
393
+ const key = turnId ? inboxKey(session.id, turnId) : undefined;
398
394
  if (key)
399
395
  await ctx.inbox.open(key);
400
396
  try {
401
397
  const events = runTurn({
402
398
  profile: ctx.profile.id,
403
- input,
404
- previousInteractionId,
399
+ execution: sessionToolCoordinator(ctx.sessions, session.id),
400
+ pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
401
+ input: turn.input,
402
+ ...(turn.providerState ? { providerState: turn.providerState } : {}),
405
403
  sessionPermissions: state.permissions,
406
404
  credentials: sessionCredentials(ctx, session.id),
407
405
  signal: request.signal,
408
- host: ctx.options.host?.(request),
409
- ...(body.model ? { model: body.model } : {}),
410
- ...(body.effort ? { effort: body.effort } : {}),
406
+ host,
407
+ ...(turn.model ? { model: turn.model } : {}),
408
+ ...(turn.effort ? { effort: turn.effort } : {}),
411
409
  ...(key ? { onStage: steerStage(ctx.inbox, key) } : {}),
412
410
  }, provider);
413
411
  yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
414
- turnInput: input,
415
- model: body.model,
412
+ profileId: ctx.profile.id,
413
+ turnInput: turn.input,
414
+ model: turn.model,
416
415
  });
417
416
  }
418
417
  finally {
@@ -420,27 +419,122 @@ async function* turnEvents(ctx, request, session, body, walked) {
420
419
  await ctx.inbox.close(key);
421
420
  }
422
421
  }
422
+ /** How this request runs the model for a reply: its generation, and the request's abort. */
423
+ function chatRunner(ctx, request, session, generation) {
424
+ return {
425
+ run: (turn) => chatTurn(ctx, request, session, turn, generation.turnId),
426
+ async compact(history, tokens, model) {
427
+ const provider = await providerFor(ctx, request, model);
428
+ const compacted = await compactHistory({
429
+ profile: ctx.profile.id,
430
+ history,
431
+ tokens,
432
+ signal: request.signal,
433
+ ...(model ? { model } : {}),
434
+ }, provider);
435
+ return compacted?.history;
436
+ },
437
+ ...(generation.model ? { model: generation.model } : {}),
438
+ ...(generation.effort ? { effort: generation.effort } : {}),
439
+ signal: request.signal,
440
+ };
441
+ }
442
+ /** The chat's conversation as this session's runs read and write it; undefined when the profile keeps none. */
443
+ function conversationHost(ctx, session) {
444
+ const store = ctx.chat?.conversations;
445
+ if (!ctx.chat || !store)
446
+ return undefined;
447
+ return readerConversationHost({
448
+ sessionId: session.id,
449
+ store,
450
+ profile: { outputs: ctx.chat.profile.outputs, labels: ctx.profile.labels },
451
+ clientEvents: ctx.options.clientEvents,
452
+ });
453
+ }
454
+ /** The person's message: a new reply, run against the conversation the host keeps. */
455
+ async function turn(ctx, chat, request, session) {
456
+ const body = await readBody(request, theoremTurnRequestSchema, ctx.options.maxRequestBytes);
457
+ const message = postedMessage(chatInterface(chat), body.input, await ctx.options.context?.(request));
458
+ const runner = chatRunner(ctx, request, session, body);
459
+ const host = conversationHost(ctx, session);
460
+ if (!host) {
461
+ if (body.conversation !== undefined)
462
+ throw statelessRefusal('continue');
463
+ // labels-exempt: internal diagnostic; the user reads error.request
464
+ if (body.abandon)
465
+ throw statelessRefusal('walk away from');
466
+ return eventStream(ctx, request, () => runner.run({ input: message.input, model: body.model, effort: body.effort }));
467
+ }
468
+ const opened = await openConversation(host, body.conversation, {
469
+ kind: 'message',
470
+ ...(body.abandon ? { abandon: body.abandon } : {}),
471
+ });
472
+ // why: Take the walked-away calls before streaming, so a stale one is a reply status, not a stream error.
473
+ const walkAway = body.abandon
474
+ ? await takeWalkedAway(ctx, request, session, body.abandon)
475
+ : undefined;
476
+ return eventStream(ctx, request, () => messageReply({ host, opened, message, ...(walkAway ? { walkAway } : {}), runner }));
477
+ }
478
+ /** The conversation as the browser draws it. */
479
+ async function conversation(ctx, request, session) {
480
+ const body = await readBody(request, theoremConversationRequestSchema, ctx.options.maxRequestBytes);
481
+ const host = conversationHost(ctx, session);
482
+ if (!host)
483
+ throw statelessRefusal('read');
484
+ const opened = await openConversation(host, body.conversation, { kind: 'read' });
485
+ return jsonResponse(200, conversationView(host, opened.conversation));
486
+ }
487
+ /** The conversation cut after one block; a reply that waits on gates is walked away from first. */
488
+ async function rewind(ctx, request, session) {
489
+ const body = await readBody(request, theoremRewindRequestSchema, ctx.options.maxRequestBytes);
490
+ const host = conversationHost(ctx, session);
491
+ if (!host)
492
+ throw statelessRefusal('rewind');
493
+ const opened = await openConversation(host, body.conversation, { kind: 'rewind', at: body.at });
494
+ const waiting = waitingCalls(opened.conversation);
495
+ const walkAway = waiting.length
496
+ ? await takeWalkedAway(ctx, request, session, waiting)
497
+ : undefined;
498
+ const next = await rewindConversation({
499
+ host,
500
+ opened,
501
+ at: body.at,
502
+ ...(walkAway ? { walkAway } : {}),
503
+ });
504
+ if (next.ref === null)
505
+ throw new TheoremError('internal', labelText('error.internal'));
506
+ const rewound = {
507
+ conversation: next.ref,
508
+ view: conversationView(host, next.conversation),
509
+ };
510
+ return jsonResponse(200, rewound);
511
+ }
423
512
  async function* invokeEvents(ctx, request, session, answered) {
424
513
  const { pending, answer } = answered;
425
- const ended = new Set();
426
- try {
427
- if (answer.typed)
428
- await saveCredential(ctx, session.id, answer.typed.slot, answer.typed.credential);
429
- const events = settleAnswered(ctx, request, session, answered, ended);
430
- yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
431
- turnInput: pending.turnInput,
432
- model: pending.model,
433
- promoted: pending.promoted,
434
- });
435
- }
436
- finally {
437
- await restoreUnended(ctx, session, [answered], ended);
438
- }
514
+ if (answer.typed)
515
+ await saveCredential(ctx, session.id, answer.typed.slot, answer.typed.credential);
516
+ const events = settleAnswered(ctx, request, session, answered);
517
+ yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
518
+ profileId: ctx.profile.id,
519
+ turnInput: pending.turnInput,
520
+ model: pending.model,
521
+ promoted: pending.promoted,
522
+ }, [
523
+ {
524
+ type: 'tool',
525
+ tool: {
526
+ name: pending.name,
527
+ callId: pending.callId,
528
+ operationId: pending.operationId,
529
+ arguments: jsonObjectSchema.parse(answer.input),
530
+ },
531
+ },
532
+ ]);
439
533
  }
440
534
  async function steer(ctx, request, session) {
441
- const body = await readBody(request, theoremSteerRequestSchema);
535
+ const body = await readBody(request, theoremSteerRequestSchema, ctx.options.maxRequestBytes);
442
536
  if (!(await ctx.inbox.enqueue(inboxKey(session.id, body.turnId), steerUnitOf(body)))) {
443
- // lexicon-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
537
+ // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
444
538
  throw new TheoremError('request', `turn ${body.turnId} is not running`, {
445
539
  copy: { key: 'session.turn_ended' },
446
540
  });
@@ -450,35 +544,111 @@ async function steer(ctx, request, session) {
450
544
  /** One call of one of a host's tools, run as the page asked it; a gate it pauses on waits in the session. */
451
545
  async function* callEvents(ctx, request, session, body) {
452
546
  const state = await ctx.sessions.read(session.id);
547
+ const host = await ctx.options.host?.(request);
453
548
  const events = invokeTool({
454
549
  profile: ctx.profile.id,
550
+ execution: sessionToolCoordinator(ctx.sessions, session.id),
551
+ pageExecution: sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).execution,
455
552
  name: body.name,
456
553
  input: body.input,
457
554
  sessionPermissions: state.permissions,
458
555
  credentials: sessionCredentials(ctx, session.id),
459
556
  signal: request.signal,
460
- host: ctx.options.host?.(request),
557
+ host,
461
558
  });
462
559
  yield* recorded(ctx.sessions, session.id, withAuthorizationUrls(ctx, request, session.id, events), {
560
+ profileId: ctx.profile.id,
463
561
  turnInput: {},
464
562
  });
465
563
  }
466
564
  function answerRequest(body) {
467
- return { callId: body.gateId, decision: body.decision, input: body.input, secret: body.secret };
565
+ return {
566
+ callId: body.gateId,
567
+ operationId: body.operationId,
568
+ decision: body.decision,
569
+ input: body.input,
570
+ secret: body.secret,
571
+ page: body.page,
572
+ };
468
573
  }
469
574
  /** A route this profile doesn't serve. */
470
575
  function refused(ctx) {
471
576
  return jsonResponse(HTTP_METHOD, {
472
- error: lexiconText('error.request', {}, ctx.profile.lexicon),
577
+ error: labelText('error.request', {}, ctx.profile.labels),
473
578
  errorKind: 'request',
474
579
  });
475
580
  }
476
- /** The user's answer to a paused call. */
581
+ async function* replayOperation(ctx, request, operation) {
582
+ const tool = defaultKernelScope.tools.get(operation.toolId);
583
+ if (operation.profileId !== ctx.profile.id ||
584
+ !tool ||
585
+ tool.type === 'builtin' ||
586
+ !('tools' in ctx.profile) ||
587
+ !ctx.profile.tools?.allow.includes(operation.toolId) ||
588
+ (ctx.options.authorizeToolReplay &&
589
+ !(await ctx.options.authorizeToolReplay({ request, operation }))))
590
+ throw new TheoremError('blocked', labelText('error.blocked'));
591
+ authorizeToolReplay({
592
+ tools: defaultKernelScope.tools,
593
+ profile: ctx.profile,
594
+ name: operation.toolId,
595
+ resume: undefined,
596
+ outcome: operation.outcome,
597
+ });
598
+ yield* once(operation.outcome.event, ctx.profile.labels);
599
+ }
600
+ /**
601
+ * The user's answer to a paused call. In a kept conversation the reply goes on
602
+ * in the same stream once no call waits; otherwise the stream is the call's.
603
+ */
477
604
  async function invoke(ctx, request, session) {
478
- const body = await readBody(request, theoremInvokeRequestSchema);
479
- // Take the answered call before streaming, so a stale answer is a reply status, not a stream error.
480
- const answered = await ctx.sessions.mutate(session.id, (state) => answerPendingGate(state, answerRequest(body)));
481
- return eventStream(ctx, request, () => invokeEvents(ctx, request, session, answered));
605
+ const body = await readBody(request, theoremInvokeRequestSchema, ctx.options.maxRequestBytes);
606
+ if (body.page && 'output' in body.page)
607
+ throw new TheoremError('request', labelText('error.request'));
608
+ const host = conversationHost(ctx, session);
609
+ if (!host) {
610
+ if (body.conversation !== undefined)
611
+ throw statelessRefusal('continue');
612
+ return eventStream(ctx, request, await settlingOf(ctx, request, session, body));
613
+ }
614
+ if (body.conversation === undefined)
615
+ throw unknownConversation();
616
+ const call = { callId: body.gateId, operationId: body.operationId };
617
+ const opened = await openConversation(host, body.conversation, { kind: 'answer', call });
618
+ const settling = await settlingOf(ctx, request, session, body);
619
+ const runner = chatRunner(ctx, request, session, body);
620
+ return eventStream(ctx, request, () => answerReply({ host, opened, call, settling: settling(), runner }));
621
+ }
622
+ /** The answered call's events: its saved outcome again, or the call run as answered. */
623
+ async function settlingOf(ctx, request, session, body) {
624
+ const saved = await ctx.sessions.read(session.id);
625
+ const operation = Object.hasOwn(saved.operations, body.operationId)
626
+ ? saved.operations[body.operationId]
627
+ : undefined;
628
+ if (operation &&
629
+ operation.status !== 'ready' &&
630
+ operation.callId === body.gateId &&
631
+ operation.profileId === ctx.profile.id &&
632
+ (operation.status === 'running' || operation.status === 'uncertain')) {
633
+ throw new TheoremError('unavailable', labelText('tool.execution_uncertain'), {
634
+ copy: { key: 'tool.execution_uncertain' },
635
+ });
636
+ }
637
+ if (operation?.status === 'settled' &&
638
+ operation.profileId === ctx.profile.id &&
639
+ operation.callId === body.gateId) {
640
+ if (body.input !== undefined &&
641
+ (!operation.invocation ||
642
+ (await toolInvocationHash(body.input)) !==
643
+ (await toolInvocationHash(operation.invocation.accepted.input))))
644
+ throw new TheoremError('request', labelText('error.request'), {
645
+ copy: { key: 'error.request' },
646
+ });
647
+ return () => replayOperation(ctx, request, operation);
648
+ }
649
+ // why: Take the answered call before streaming, so a stale answer is a reply status, not a stream error.
650
+ const answered = await ctx.sessions.mutate(session.id, (state) => answerPendingGate(state, answerRequest(body), ctx.profile.id));
651
+ return () => invokeEvents(ctx, request, session, answered);
482
652
  }
483
653
  async function hostRoute(ctx, request, session) {
484
654
  const target = routeOf(request);
@@ -490,7 +660,7 @@ async function hostRoute(ctx, request, session) {
490
660
  return jsonResponse(200, { interface: hostInterface(ctx.profile, tool) });
491
661
  }
492
662
  if (request.method === 'POST' && target === 'call') {
493
- const body = await readBody(request, theoremHostCallRequestSchema);
663
+ const body = await readBody(request, theoremHostCallRequestSchema, ctx.options.maxRequestBytes);
494
664
  return eventStream(ctx, request, () => callEvents(ctx, request, session, body));
495
665
  }
496
666
  if (request.method === 'POST' && target === 'invoke')
@@ -498,39 +668,63 @@ async function hostRoute(ctx, request, session) {
498
668
  return refused(ctx);
499
669
  }
500
670
  async function route(ctx, request, session) {
671
+ if (request.method === 'POST' && routeOf(request) === 'page-result') {
672
+ const body = await readBody(request, pageToolResultSchema, ctx.options.maxRequestBytes);
673
+ await sessionPageDispatch(ctx.sessions, session.id, ctx.pageToolBroker).answer(body);
674
+ return jsonResponse(200, { accepted: true });
675
+ }
501
676
  if (ctx.profile.type === 'host')
502
677
  return hostRoute({ ...ctx, profile: ctx.profile }, request, session);
678
+ const { chat } = ctx;
503
679
  const target = routeOf(request);
680
+ if (!chat)
681
+ return refused(ctx);
504
682
  if (request.method === 'GET' && target === '')
505
- return jsonResponse(200, { interface: clientInterface(ctx.profile) });
683
+ return jsonResponse(200, {
684
+ interface: clientProfileInterface(chat.profile, defaultKernelScope.tools),
685
+ });
506
686
  if (request.method !== 'POST' || target === '' || target === 'call')
507
687
  return refused(ctx);
508
- if (target === 'turn') {
509
- const body = await readBody(request, theoremTurnRequestSchema);
510
- const input = userTurnInput(body.input);
511
- // Take the walked-away calls before streaming, so a stale one is a reply status, not a stream error.
512
- const calls = body.abandon ? await takeWalkedAway(ctx, session, input, body.abandon) : [];
513
- return eventStream(ctx, request, () => turnEvents(ctx, request, session, body, { input, calls }));
514
- }
688
+ if (target === 'turn')
689
+ return turn(ctx, chat, request, session);
515
690
  if (target === 'invoke')
516
691
  return invoke(ctx, request, session);
692
+ if (target === 'conversation')
693
+ return conversation(ctx, request, session);
694
+ if (target === 'rewind')
695
+ return rewind(ctx, request, session);
517
696
  return steer(ctx, request, session);
518
697
  }
519
698
  export function createTheoremHandler(options) {
520
699
  const profile = defineProfile(options.profile);
521
700
  if (profile.type === 'decision') {
522
- // lexicon-exempt: builder config error at setup; no user sees it
701
+ // labels-exempt: builder config error at setup; no user sees it
523
702
  throw new Error("createTheoremHandler serves turn-based profiles; serve type 'decision' with createTheoremDecisionHandler.");
524
703
  }
525
704
  if (profile.type === 'host') {
526
- // lexicon-exempt: builder config error at setup; no user sees it
705
+ // labels-exempt: builder config error at setup; no user sees it
527
706
  throw new Error("createTheoremHandler serves turn-based profiles; serve type 'host' with createTheoremHostHandler.");
528
707
  }
529
- if (profile.type === 'live') {
530
- // lexicon-exempt: builder config error at setup; no user sees it
708
+ if (profile.type === 'live' || profile.type === 'embedding') {
709
+ // labels-exempt: builder config error at setup; no user sees it
531
710
  throw new Error(`createTheoremHandler serves turn-based profiles; got type '${profile.type}'.`);
532
711
  }
533
- return serve('createTheoremHandler', profile, options, options.steerInbox);
712
+ const stateless = profile.providerContinuation?.history === 'stateless';
713
+ if (stateless === (options.conversationStore !== undefined)) {
714
+ throw new TheoremError('config', labelText('error.config'), {
715
+ cause: {
716
+ code: stateless ? 'conversation_store_stateless' : 'conversation_store_missing',
717
+ profile: profile.id,
718
+ },
719
+ });
720
+ }
721
+ return serve('createTheoremHandler', profile, options, {
722
+ steerInbox: options.steerInbox,
723
+ chat: {
724
+ profile,
725
+ ...(options.conversationStore ? { conversations: options.conversationStore } : {}),
726
+ },
727
+ });
534
728
  }
535
729
  /**
536
730
  * Serves a host profile to `<TheoremHost />`: the page calls the profile's tools
@@ -539,19 +733,29 @@ export function createTheoremHandler(options) {
539
733
  export function createTheoremHostHandler(options) {
540
734
  const profile = defineProfile(options.profile);
541
735
  if (profile.type !== 'host') {
542
- // lexicon-exempt: builder config error at setup; no user sees it
736
+ // labels-exempt: builder config error at setup; no user sees it
543
737
  throw new Error(`createTheoremHostHandler serves type 'host'; got type '${profile.type}'.`);
544
738
  }
545
739
  return serve('createTheoremHostHandler', profile, options);
546
740
  }
547
- function serve(name, profile, options, steerInbox) {
741
+ function serve(name, profile, options, chatOptions = {}) {
548
742
  const gateTtlMs = resolveGateTtlMs(name, options.gateTtlMs);
743
+ const operationRetentionMs = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
744
+ if (!Number.isSafeInteger(operationRetentionMs) || operationRetentionMs <= 0) {
745
+ throw new TheoremError('config', labelText('error.config'), {
746
+ cause: { code: 'operation_retention' },
747
+ });
748
+ }
749
+ const limits = resolveSessionLimits(options.sessionLimits);
750
+ requestByteLimit(options.maxRequestBytes);
549
751
  registerProfile(profile);
550
752
  const ctx = {
551
753
  options,
754
+ pageToolBroker: options.pageToolBroker ?? createMemoryPageToolBroker(),
552
755
  profile,
553
- inbox: steerInbox ?? createMemorySteerInbox(),
554
- sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore(), gateTtlMs),
756
+ ...(chatOptions.chat ? { chat: chatOptions.chat } : {}),
757
+ inbox: chatOptions.steerInbox ?? createMemorySteerInbox(),
758
+ sessions: sessionStateStore(options.sessionStore ?? createMemorySessionStore({ sessionLimits: limits }), gateTtlMs, operationRetentionMs, limits),
555
759
  credentials: lockedStore(options.credentialStore ?? createMemoryCredentialStore(), (saved = {}) => saved),
556
760
  };
557
761
  return async (request) => {
@@ -561,8 +765,7 @@ function serve(name, profile, options, steerInbox) {
561
765
  return withSessionCookie(await route(ctx, request, session), session);
562
766
  }
563
767
  catch (err) {
564
- const error = publicMessage(ctx, err, request);
565
- return withSessionCookie(jsonResponse(caughtStatus(err), { error, errorKind: errorKind(err) }), session);
768
+ return withSessionCookie(jsonResponse(caughtStatus(err), caughtError(ctx, err, request)), session);
566
769
  }
567
770
  };
568
771
  }