@ixo/oracle-runtime-workers 0.13.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. package/README.md +32 -10
  2. package/package.json +13 -8
  3. package/src/artifacts/config.ts +158 -0
  4. package/src/artifacts/crypto.ts +94 -0
  5. package/src/artifacts/routes.ts +73 -0
  6. package/src/artifacts/store.ts +238 -0
  7. package/src/artifacts/sweep.ts +200 -0
  8. package/src/artifacts/tool.ts +103 -0
  9. package/src/artifacts/viewer-page.ts +614 -0
  10. package/src/attachments/download.ts +168 -45
  11. package/src/attachments/index.ts +9 -0
  12. package/src/attachments/magic.ts +33 -0
  13. package/src/attachments/pipeline.ts +105 -49
  14. package/src/attachments/sandbox-archive.ts +9 -8
  15. package/src/attachments/view-cache.ts +142 -0
  16. package/src/attachments/view.ts +53 -12
  17. package/src/channels/auth.ts +310 -0
  18. package/src/channels/contract.ts +246 -0
  19. package/src/channels/turns.ts +280 -0
  20. package/src/core/budgeted-llm.ts +223 -0
  21. package/src/core/capability-router.ts +273 -0
  22. package/src/core/context-budget.ts +97 -10
  23. package/src/core/env.ts +111 -0
  24. package/src/core/execution-profile.ts +17 -0
  25. package/src/core/index.ts +267 -9
  26. package/src/core/llm.ts +30 -9
  27. package/src/core/main-agent-types.ts +41 -1
  28. package/src/core/main-agent.ts +367 -65
  29. package/src/core/manifest.ts +34 -0
  30. package/src/core/meta-tools.ts +128 -18
  31. package/src/core/middlewares/capability-gate.ts +130 -12
  32. package/src/core/middlewares/context-guard.ts +55 -24
  33. package/src/core/middlewares/index.ts +20 -2
  34. package/src/core/middlewares/page-context.ts +46 -2
  35. package/src/core/middlewares/return-direct-first.ts +47 -0
  36. package/src/core/middlewares/summarization.ts +152 -10
  37. package/src/core/middlewares/tool-execution.ts +323 -0
  38. package/src/core/middlewares/tool-repetition-guard.ts +202 -35
  39. package/src/core/middlewares/turn-boundary.ts +162 -0
  40. package/src/core/openrouter-pricing.ts +155 -22
  41. package/src/core/prompt-composer.ts +112 -7
  42. package/src/core/registries.ts +253 -48
  43. package/src/core/request-admission.ts +159 -0
  44. package/src/core/runtime-context.ts +107 -8
  45. package/src/core/state.ts +1 -1
  46. package/src/core/sub-agent-fallback.ts +21 -2
  47. package/src/core/subagent-as-tool.ts +78 -43
  48. package/src/core/tool-access.ts +189 -0
  49. package/src/core/tool-scheduler.ts +125 -0
  50. package/src/core/turn-budget.ts +205 -0
  51. package/src/core/turn-time-note.ts +43 -0
  52. package/src/core/user-kv.ts +446 -0
  53. package/src/core/wrap-plugin-tool.ts +20 -0
  54. package/src/delivery/plan.ts +278 -0
  55. package/src/delivery/profile.ts +161 -0
  56. package/src/delivery/prompt.ts +27 -0
  57. package/src/delivery/schema.ts +61 -0
  58. package/src/delivery/shaper.ts +389 -0
  59. package/src/delivery/types.ts +84 -0
  60. package/src/do/ambient.ts +51 -15
  61. package/src/do/contracts.ts +170 -6
  62. package/src/do/idle-eviction.ts +38 -0
  63. package/src/do/matrix-turn-ledger.ts +24 -5
  64. package/src/do/result-store-test-do.ts +137 -3
  65. package/src/do/result-store.ts +239 -58
  66. package/src/do/room-mirror.ts +29 -0
  67. package/src/do/run-buffer.ts +64 -9
  68. package/src/do/run-coordinator.ts +451 -76
  69. package/src/do/run-request.ts +2 -0
  70. package/src/do/run-store-test-do.ts +301 -1
  71. package/src/do/run-store.ts +385 -43
  72. package/src/do/secrets-adapter.ts +22 -3
  73. package/src/do/sse-stream.ts +60 -9
  74. package/src/do/transcript.ts +48 -5
  75. package/src/do/turn-body.ts +105 -5
  76. package/src/do/ucan-service.ts +204 -34
  77. package/src/do/user-oracle-do.ts +2003 -274
  78. package/src/feedback/config.ts +127 -0
  79. package/src/feedback/contract.ts +148 -0
  80. package/src/feedback/linear-sink.ts +285 -0
  81. package/src/feedback/privacy.ts +150 -0
  82. package/src/feedback/reservation.ts +298 -0
  83. package/src/feedback/submit.ts +247 -0
  84. package/src/index.ts +138 -6
  85. package/src/llm/byo-adapter.ts +27 -6
  86. package/src/llm/byo-catalog.ts +13 -0
  87. package/src/llm/byo-model-fallback.ts +248 -0
  88. package/src/llm/byo-service.ts +45 -8
  89. package/src/llm/provider-error.ts +139 -2
  90. package/src/matrix/gateway-do.ts +504 -108
  91. package/src/matrix/group-chat.ts +66 -23
  92. package/src/matrix/inbox-store.ts +16 -1
  93. package/src/matrix/ingest.ts +79 -14
  94. package/src/matrix/media-deadline.ts +130 -0
  95. package/src/matrix/replay-format.ts +5 -3
  96. package/src/matrix/reply-parts.ts +92 -0
  97. package/src/matrix/room-alias.ts +96 -0
  98. package/src/matrix/user-homeserver.ts +166 -7
  99. package/src/owner-store/ixo-vfs-store.ts +170 -43
  100. package/src/owner-store/migrating-store.ts +31 -15
  101. package/src/owner-store/types.ts +18 -3
  102. package/src/plugin-api/index.ts +43 -2
  103. package/src/plugin-api/oracle-plugin.ts +35 -0
  104. package/src/plugin-api/request-admission.ts +41 -0
  105. package/src/plugin-api/tool-helper.ts +25 -1
  106. package/src/plugin-api/tool-plane.ts +92 -0
  107. package/src/plugin-api/types.ts +194 -6
  108. package/src/plugins/agui/agui.plugin.ts +31 -16
  109. package/src/plugins/composio/composio-tools.ts +57 -6
  110. package/src/plugins/composio/composio-ucan.ts +3 -2
  111. package/src/plugins/composio/composio.plugin.ts +2 -0
  112. package/src/plugins/delegated-capabilities.ts +25 -0
  113. package/src/plugins/editor/content-session.ts +173 -83
  114. package/src/plugins/editor/content-tools.ts +76 -17
  115. package/src/plugins/editor/editor-mx.ts +28 -9
  116. package/src/plugins/editor/editor.plugin.ts +3 -1
  117. package/src/plugins/editor/failures.ts +28 -7
  118. package/src/plugins/editor/prompts.ts +2 -1
  119. package/src/plugins/editor/provider.ts +553 -40
  120. package/src/plugins/editor/standalone-editor-tool.ts +61 -18
  121. package/src/plugins/firecrawl/firecrawl-tools.ts +13 -5
  122. package/src/plugins/flows/action-metadata.ts +111 -11
  123. package/src/plugins/flows/actions.ts +18 -0
  124. package/src/plugins/flows/decision-context.ts +31 -0
  125. package/src/plugins/flows/edit.ts +223 -52
  126. package/src/plugins/flows/errors.ts +22 -1
  127. package/src/plugins/flows/flow-doc.ts +92 -9
  128. package/src/plugins/flows/flows.plugin.ts +11 -5
  129. package/src/plugins/flows/forms.ts +34 -0
  130. package/src/plugins/flows/index.ts +2 -0
  131. package/src/plugins/flows/input-policy.ts +140 -0
  132. package/src/plugins/flows/prompts.ts +14 -11
  133. package/src/plugins/flows/read.ts +49 -9
  134. package/src/plugins/flows/ref-syntax.ts +129 -0
  135. package/src/plugins/flows/test-homeserver.ts +245 -0
  136. package/src/plugins/flows/tools/authoring.ts +216 -112
  137. package/src/plugins/flows/tools/discovery.ts +4 -1
  138. package/src/plugins/flows/tools/forms.ts +21 -2
  139. package/src/plugins/flows/tools/settings.ts +34 -13
  140. package/src/plugins/flows/translator.ts +13 -10
  141. package/src/plugins/flows/types.ts +167 -42
  142. package/src/plugins/index.ts +11 -1
  143. package/src/plugins/ixo-transaction/chain-ids.ts +43 -0
  144. package/src/plugins/ixo-transaction/index.ts +2 -0
  145. package/src/plugins/ixo-transaction/ixo-transaction.plugin.ts +119 -0
  146. package/src/plugins/ixo-transaction/receipts.ts +99 -0
  147. package/src/plugins/ixo-transaction/tools.ts +329 -0
  148. package/src/plugins/ixo-transaction/wallet-signing.ts +202 -0
  149. package/src/plugins/mcp-tool-adapter.ts +23 -2
  150. package/src/plugins/memory/memory-tools.ts +7 -2
  151. package/src/plugins/memory/memory-ucan.ts +3 -2
  152. package/src/plugins/memory/memory.plugin.ts +2 -0
  153. package/src/plugins/pod-creator/blueprint-store.ts +163 -0
  154. package/src/plugins/pod-creator/blueprint-types.ts +61 -0
  155. package/src/plugins/pod-creator/bounded-map.ts +87 -0
  156. package/src/plugins/pod-creator/capsule-content-client.ts +285 -0
  157. package/src/plugins/pod-creator/chain-gateway.ts +110 -0
  158. package/src/plugins/pod-creator/config.ts +85 -0
  159. package/src/plugins/pod-creator/create-session-store.ts +180 -0
  160. package/src/plugins/pod-creator/create-tools.ts +538 -0
  161. package/src/plugins/pod-creator/design-pod-roles.ts +111 -0
  162. package/src/plugins/pod-creator/index.ts +65 -0
  163. package/src/plugins/pod-creator/orchestration-tools.ts +172 -0
  164. package/src/plugins/pod-creator/pod-creator.plugin.ts +190 -0
  165. package/src/plugins/pod-creator/stage.ts +201 -0
  166. package/src/plugins/pod-creator/sub-agents.ts +189 -0
  167. package/src/plugins/portal/action-log.ts +21 -1
  168. package/src/plugins/portal/declared-tools.ts +90 -0
  169. package/src/plugins/portal/portal.plugin.ts +42 -16
  170. package/src/plugins/sandbox/sandbox-bridge.ts +79 -10
  171. package/src/plugins/sandbox/sandbox-mcp.ts +3 -2
  172. package/src/plugins/sandbox/sandbox-write-blob.ts +11 -1
  173. package/src/plugins/sandbox/sandbox.plugin.ts +8 -16
  174. package/src/plugins/tasks/manifest.ts +1 -1
  175. package/src/plugins/tasks/middleware.ts +20 -7
  176. package/src/plugins/tasks/tasks-tools.ts +30 -3
  177. package/src/plugins/tasks/tasks.plugin.ts +64 -11
  178. package/src/plugins/user-preferences/schema.ts +12 -1
  179. package/src/plugins/user-preferences/user-preferences-store.ts +38 -13
  180. package/src/plugins/user-preferences/user-preferences-tool.ts +3 -2
  181. package/src/plugins/vfs/vfs-client.ts +250 -71
  182. package/src/plugins/vfs/vfs-content.ts +30 -11
  183. package/src/plugins/vfs/vfs-errors.ts +22 -0
  184. package/src/plugins/vfs/vfs-sandbox-tools.ts +12 -5
  185. package/src/plugins/vfs/vfs-tools.ts +60 -20
  186. package/src/realtime/frontend-call-registry.ts +228 -81
  187. package/src/realtime/realtime-endpoint.ts +215 -44
  188. package/src/realtime/session-socket-hub.ts +47 -2
  189. package/src/secrets/secrets-service.ts +9 -3
  190. package/src/shell/app.ts +562 -82
  191. package/src/shell/auth.ts +131 -37
  192. package/src/shell/turn-body-cap.ts +11 -13
  193. package/src/sqlite/blob-compactor.ts +61 -28
  194. package/src/sqlite/database.ts +156 -90
  195. package/src/sqlite/do-vfs.ts +416 -129
  196. package/src/sqlite/page-tier.ts +133 -40
  197. package/src/sqlite/serialization.ts +40 -0
  198. package/src/sqlite/sessions-store.ts +10 -2
  199. package/src/sqlite/sqlite-saver.ts +139 -19
  200. package/src/sqlite/user-kv-store.ts +317 -0
  201. package/src/tasks/schedule.ts +201 -12
  202. package/src/tasks/scheduler.ts +586 -98
  203. package/src/tasks/spec.ts +67 -3
  204. package/src/tasks/store.ts +292 -27
  205. package/src/tasks/topic-deliverables.ts +70 -0
@@ -0,0 +1,246 @@
1
+ import { z } from 'zod';
2
+ import type { ReplyPlan } from '../delivery/types';
3
+
4
+ export const ChannelTurnBody = z.strictObject({
5
+ provider: z.literal('whatsapp'),
6
+ bindingId: z.string().regex(/^chb_[A-Za-z0-9_-]{1,120}$/),
7
+ bindingRevision: z.number().int().positive().safe(),
8
+ requestId: z.string().min(1).max(180),
9
+ remoteMessageRef: z.string().regex(/^hmac:[a-f0-9]{64}$/),
10
+ sessionId: z.string().min(1).max(255).optional(),
11
+ message: z.string().min(1).max(16_000),
12
+ context: z.strictObject({ kind: z.literal('companion') }),
13
+ });
14
+
15
+ export type ChannelTurnInput = z.infer<typeof ChannelTurnBody>;
16
+
17
+ export const ChannelOrigin = z.strictObject({
18
+ v: z.literal(1),
19
+ transport: z.literal('whatsapp'),
20
+ binding_id: ChannelTurnBody.shape.bindingId,
21
+ remote_ref: ChannelTurnBody.shape.remoteMessageRef,
22
+ });
23
+
24
+ export type ChannelOrigin = z.infer<typeof ChannelOrigin>;
25
+
26
+ export function channelOrigin(
27
+ channel: Pick<
28
+ ChannelTurnInput,
29
+ 'provider' | 'bindingId' | 'remoteMessageRef'
30
+ >,
31
+ ): ChannelOrigin {
32
+ return {
33
+ v: 1,
34
+ transport: channel.provider,
35
+ binding_id: channel.bindingId,
36
+ remote_ref: channel.remoteMessageRef,
37
+ };
38
+ }
39
+
40
+ export interface ChannelIdentity {
41
+ callerDid: string;
42
+ provider: 'whatsapp';
43
+ bindingId: string;
44
+ bindingRevision: number;
45
+ }
46
+
47
+ export interface ChannelTurnResponse {
48
+ requestId: string;
49
+ runId: string;
50
+ sessionId: string;
51
+ status:
52
+ | 'queued'
53
+ | 'running'
54
+ | 'recovering'
55
+ | 'finished'
56
+ | 'aborted'
57
+ | 'interrupted'
58
+ | 'failed';
59
+ messageId?: string;
60
+ text?: string;
61
+ /**
62
+ * A finished turn's Reply Plan: the parts to deliver in order (short
63
+ * messages and artefact links). `text` stays the whole reply as one
64
+ * message for gateways that predate plans.
65
+ */
66
+ plan?: ReplyPlan;
67
+ }
68
+
69
+ export type ChannelErrorStatus =
70
+ | 400
71
+ | 401
72
+ | 403
73
+ | 404
74
+ | 409
75
+ | 410
76
+ | 413
77
+ | 428
78
+ | 429
79
+ | 503;
80
+
81
+ /**
82
+ * The machine-readable reason a channel turn was refused. The gateway maps
83
+ * a refusal by `code` first and by HTTP status second. 428 (Precondition
84
+ * Required) means a precondition on the user's side is missing: fix it
85
+ * (authorize the oracle again, finish setting up the Companion room) and
86
+ * retry; it is never a transient outage.
87
+ *
88
+ * | code | status | meaning |
89
+ * | --------------------------------- | ------ | ----------------------------------------------------- |
90
+ * | `invalid_request` | 400 | body missing, not UTF-8, not JSON, or the wrong shape |
91
+ * | `unauthorized` | 401 | no valid user-rooted channel invocation |
92
+ * | `scope_mismatch` | 403 | the invocation does not cover this binding or body |
93
+ * | `identity_mismatch` | 403 | the channel identity does not match the request |
94
+ * | `identity_required` | 403 | the binding check got no channel identity |
95
+ * | `binding_inactive` | 403 | the Auth Hub reports the binding inactive |
96
+ * | `channel_grant_not_tool_authority`| 403 | a channel request carried a delegation header |
97
+ * | `session_not_found` | 404 | the session is not the user's Companion session |
98
+ * | `request_conflict` | 409 | the request id already belongs to another message |
99
+ * | `session_conflict` | 409 | the channel is already bound to another session |
100
+ * | `room_not_encrypted` | 409 | the Companion room is not end-to-end encrypted |
101
+ * | `response_expired` | 410 | the run was pruned; the request cannot run again |
102
+ * | `request_too_large` | 413 | the body is over 64 kB |
103
+ * | `room_not_ready` | 428 | the user has no Companion room yet |
104
+ * | `delegation_required` | 428 | no usable delegation to this oracle (see below) |
105
+ * | `rate_limited` | 429 | too many requests for this user |
106
+ * | `not_configured` | 503 | channels or binding validation are not configured |
107
+ * | `binding_check_unavailable` | 503 | the Auth Hub binding check failed or answered badly |
108
+ * | `unavailable` | 503 | any other failure (the shell logs it) |
109
+ */
110
+ export type ChannelErrorCode =
111
+ | 'invalid_request'
112
+ | 'unauthorized'
113
+ | 'scope_mismatch'
114
+ | 'identity_mismatch'
115
+ | 'identity_required'
116
+ | 'binding_inactive'
117
+ | 'channel_grant_not_tool_authority'
118
+ | 'session_not_found'
119
+ | 'request_conflict'
120
+ | 'session_conflict'
121
+ | 'room_not_encrypted'
122
+ | 'response_expired'
123
+ | 'request_too_large'
124
+ | 'room_not_ready'
125
+ | 'delegation_required'
126
+ | 'rate_limited'
127
+ | 'not_configured'
128
+ | 'binding_check_unavailable'
129
+ | 'unavailable';
130
+
131
+ export type ChannelTurnOutcome =
132
+ | { ok: true; result: ChannelTurnResponse }
133
+ | {
134
+ ok: false;
135
+ status: ChannelErrorStatus;
136
+ code: ChannelErrorCode;
137
+ message: string;
138
+ };
139
+
140
+ export class ChannelError extends Error {
141
+ constructor(
142
+ readonly status: ChannelErrorStatus,
143
+ message: string,
144
+ readonly code: ChannelErrorCode,
145
+ ) {
146
+ super(message);
147
+ this.name = 'ChannelError';
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The least lifetime a delegation must have left for a channel turn to be
153
+ * admitted (seconds): a turn, its tool calls and its owner-copy flush must
154
+ * not outlive the delegation they run under.
155
+ */
156
+ export const CHANNEL_DELEGATION_MIN_REMAINING_SECONDS = 900;
157
+
158
+ /** The user's delegation to this oracle as the user object holds it. */
159
+ export interface ChannelDelegation {
160
+ raw?: string;
161
+ /** Unix seconds: the delegation's effective expiry; absent = unknown. */
162
+ expiration?: number;
163
+ }
164
+
165
+ export function delegationRequiredError(): ChannelError {
166
+ return new ChannelError(
167
+ 428,
168
+ 'Companion delegation required: the user must authorize this oracle again',
169
+ 'delegation_required',
170
+ );
171
+ }
172
+
173
+ /**
174
+ * A channel turn runs under the user's stored delegation to this oracle,
175
+ * like a Matrix turn. Without one, with an unknown expiry, or with less than
176
+ * `CHANNEL_DELEGATION_MIN_REMAINING_SECONDS` left, there is no tool
177
+ * authority to run under: the turn is refused with an explicit 428
178
+ * `delegation_required` instead of running degraded (memory, files and the
179
+ * sandbox would fail silently).
180
+ */
181
+ export function requireChannelDelegation(
182
+ delegation: ChannelDelegation | undefined,
183
+ nowSeconds: number,
184
+ ): void {
185
+ if (
186
+ !delegation?.raw ||
187
+ typeof delegation.expiration !== 'number' ||
188
+ !Number.isFinite(delegation.expiration) ||
189
+ delegation.expiration <=
190
+ nowSeconds + CHANNEL_DELEGATION_MIN_REMAINING_SECONDS
191
+ )
192
+ throw delegationRequiredError();
193
+ }
194
+
195
+ export async function channelRequestHash(raw: string): Promise<string> {
196
+ const digest = await crypto.subtle.digest(
197
+ 'SHA-256',
198
+ new TextEncoder().encode(raw),
199
+ );
200
+ return Array.from(new Uint8Array(digest), (byte) =>
201
+ byte.toString(16).padStart(2, '0'),
202
+ ).join('');
203
+ }
204
+
205
+ export async function readChannelBody(request: Request): Promise<string> {
206
+ const reader = request.body?.getReader();
207
+ if (!reader)
208
+ throw new ChannelError(
209
+ 400,
210
+ 'A channel turn is required',
211
+ 'invalid_request',
212
+ );
213
+ const chunks: Uint8Array[] = [];
214
+ let size = 0;
215
+ while (true) {
216
+ const chunk = await reader.read();
217
+ if (chunk.done) break;
218
+ size += chunk.value.byteLength;
219
+ if (size > 64_000) {
220
+ await reader.cancel();
221
+ throw new ChannelError(
222
+ 413,
223
+ 'Channel request is too large',
224
+ 'request_too_large',
225
+ );
226
+ }
227
+ chunks.push(chunk.value);
228
+ }
229
+ const bytes = new Uint8Array(size);
230
+ let offset = 0;
231
+ for (const chunk of chunks) {
232
+ bytes.set(chunk, offset);
233
+ offset += chunk.byteLength;
234
+ }
235
+ try {
236
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(
237
+ bytes,
238
+ );
239
+ } catch {
240
+ throw new ChannelError(
241
+ 400,
242
+ 'Channel request must be UTF-8',
243
+ 'invalid_request',
244
+ );
245
+ }
246
+ }
@@ -0,0 +1,280 @@
1
+ import type { DoSqliteDatabase } from '../sqlite/database';
2
+ import type { RunRecord } from '../do/run-store';
3
+ import type { TurnIdentity, TurnRequest } from '../do/contracts';
4
+ import { parseReplyPlan, planText } from '../delivery/schema';
5
+ import type { ReplyPlan } from '../delivery/types';
6
+ import {
7
+ ChannelError,
8
+ channelRequestHash,
9
+ type ChannelTurnInput,
10
+ type ChannelTurnResponse,
11
+ } from './contract';
12
+
13
+ interface ReceiptRow extends Record<string, string | number | null> {
14
+ binding_id: string;
15
+ request_id: string;
16
+ request_hash: string;
17
+ run_id: string;
18
+ session_id: string | null;
19
+ /** 1 once the assistant reply was mirrored into the Companion room. */
20
+ reply_mirrored: number;
21
+ }
22
+
23
+ export interface ChannelTurnsHost {
24
+ createSession(identity: TurnIdentity, markerTxnId: string): Promise<string>;
25
+ assertSession(identity: TurnIdentity, sessionId: string): Promise<void>;
26
+ /**
27
+ * Throws a `ChannelError` (428 `delegation_required`) when the user has no
28
+ * usable delegation for this oracle: none, or one too close to expiry.
29
+ */
30
+ requireDelegation(identity: TurnIdentity): Promise<void>;
31
+ getRun(runId: string): Promise<RunRecord | undefined>;
32
+ /** The Reply Plan (JSON) a finished run was built into, if any. */
33
+ getPlan(runId: string): Promise<string | null>;
34
+ wasPruned(runId: string): Promise<boolean>;
35
+ begin(runId: string, request: TurnRequest): Promise<RunRecord>;
36
+ mirror(
37
+ request: TurnRequest,
38
+ text: string,
39
+ author: 'user' | 'oracle',
40
+ ): Promise<void>;
41
+ changed(): Promise<void>;
42
+ }
43
+
44
+ export class ChannelTurns {
45
+ private admission: Promise<unknown> = Promise.resolve();
46
+ private setupPromise: Promise<void> | undefined;
47
+
48
+ constructor(
49
+ private readonly db: DoSqliteDatabase,
50
+ private readonly host: ChannelTurnsHost,
51
+ ) {}
52
+
53
+ private setup(): Promise<void> {
54
+ this.setupPromise ??= (async () => {
55
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_requests (
56
+ binding_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
57
+ run_id TEXT NOT NULL UNIQUE, session_id TEXT,
58
+ reply_mirrored INTEGER NOT NULL DEFAULT 0,
59
+ PRIMARY KEY (binding_id, request_id)
60
+ )`);
61
+ // Tables created before the column existed (CREATE TABLE IF NOT
62
+ // EXISTS leaves them as they were).
63
+ const columns = await this.db.exec<{ name: string }>(
64
+ 'PRAGMA table_info(channel_requests)',
65
+ );
66
+ if (!columns.some((c) => c.name === 'reply_mirrored'))
67
+ await this.db.run(
68
+ 'ALTER TABLE channel_requests ADD COLUMN reply_mirrored INTEGER NOT NULL DEFAULT 0',
69
+ );
70
+ await this.db.run(`CREATE TABLE IF NOT EXISTS channel_sessions (
71
+ binding_id TEXT PRIMARY KEY, session_id TEXT NOT NULL
72
+ )`);
73
+ })().catch((error: unknown) => {
74
+ this.setupPromise = undefined;
75
+ throw error;
76
+ });
77
+ return this.setupPromise;
78
+ }
79
+
80
+ /** Admissions, reply deliveries and session releases run one at a time. */
81
+ private serialize<T>(work: () => Promise<T>): Promise<T> {
82
+ const next = this.admission.then(work);
83
+ this.admission = next.catch(() => undefined);
84
+ return next;
85
+ }
86
+
87
+ submit(
88
+ identity: TurnIdentity,
89
+ input: ChannelTurnInput,
90
+ requestHash: string,
91
+ ): Promise<ChannelTurnResponse> {
92
+ return this.serialize(() => this.admit(identity, input, requestHash));
93
+ }
94
+
95
+ /**
96
+ * Mirror a finished run's reply into the Companion room once: the receipt
97
+ * records the delivery, so later polls of the same request do not send it
98
+ * again. Called when the run ends; a poll retries a delivery that failed.
99
+ * `text` is the model's reply; a run with a Reply Plan mirrors the plan
100
+ * instead, as the channel user received it.
101
+ */
102
+ deliverReply(
103
+ request: TurnRequest,
104
+ runId: string,
105
+ text: string,
106
+ ): Promise<void> {
107
+ return this.serialize(async () => {
108
+ await this.deliver(request, runId, await this.reply(runId, text));
109
+ });
110
+ }
111
+
112
+ /** A finished run's Reply Plan, and the reply as the channel user sees it. */
113
+ private async reply(
114
+ runId: string,
115
+ text: string,
116
+ ): Promise<{ plan: ReplyPlan | null; rendered: string }> {
117
+ const plan = parseReplyPlan(await this.host.getPlan(runId));
118
+ return { plan, rendered: plan ? planText(plan) : text };
119
+ }
120
+
121
+ /**
122
+ * The session was deleted: release every binding that pointed at it, so
123
+ * the binding's next message opens a new Companion session. Returns
124
+ * whether a binding was released.
125
+ */
126
+ forgetSession(sessionId: string): Promise<boolean> {
127
+ return this.serialize(async () => {
128
+ await this.setup();
129
+ const released = await this.db.run(
130
+ 'DELETE FROM channel_sessions WHERE session_id = ?',
131
+ [sessionId],
132
+ );
133
+ if (released.changes === 0) return false;
134
+ await this.host.changed();
135
+ return true;
136
+ });
137
+ }
138
+
139
+ private async deliver(
140
+ request: TurnRequest,
141
+ runId: string,
142
+ reply: { rendered: string },
143
+ ): Promise<void> {
144
+ await this.setup();
145
+ const receipt = await this.db.get<{ reply_mirrored: number }>(
146
+ 'SELECT reply_mirrored FROM channel_requests WHERE run_id = ?',
147
+ [runId],
148
+ );
149
+ if (Number(receipt?.reply_mirrored ?? 0) === 1) return;
150
+ await this.host.mirror(request, reply.rendered, 'oracle');
151
+ await this.db.run(
152
+ 'UPDATE channel_requests SET reply_mirrored = 1 WHERE run_id = ?',
153
+ [runId],
154
+ );
155
+ await this.host.changed();
156
+ }
157
+
158
+ private async admit(
159
+ identity: TurnIdentity,
160
+ input: ChannelTurnInput,
161
+ requestHash: string,
162
+ ): Promise<ChannelTurnResponse> {
163
+ await this.setup();
164
+ if (
165
+ !identity.channel ||
166
+ identity.channel.bindingId !== input.bindingId ||
167
+ identity.channel.bindingRevision !== input.bindingRevision ||
168
+ identity.channel.provider !== input.provider
169
+ )
170
+ throw new ChannelError(
171
+ 403,
172
+ 'Channel identity does not match this request',
173
+ 'identity_mismatch',
174
+ );
175
+ const runId = `channel_${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`;
176
+ // Checked before a receipt is written: the receipt of a pruned run is
177
+ // gone (run-store.ts), and a new one must not take its place.
178
+ if (await this.host.wasPruned(runId))
179
+ throw new ChannelError(
180
+ 410,
181
+ 'Channel response has expired; this request cannot execute again',
182
+ 'response_expired',
183
+ );
184
+ const admitted = await this.db.run(
185
+ `INSERT OR IGNORE INTO channel_requests
186
+ (binding_id, request_id, request_hash, run_id) VALUES (?, ?, ?, ?)`,
187
+ [input.bindingId, input.requestId, requestHash, runId],
188
+ );
189
+ // A poll of an admitted request changes nothing.
190
+ if (admitted.changes === 1) await this.host.changed();
191
+ const receipt = await this.db.get<ReceiptRow>(
192
+ 'SELECT * FROM channel_requests WHERE binding_id = ? AND request_id = ?',
193
+ [input.bindingId, input.requestId],
194
+ );
195
+ if (!receipt || receipt.request_hash !== requestHash)
196
+ throw new ChannelError(
197
+ 409,
198
+ 'This request ID already belongs to another message',
199
+ 'request_conflict',
200
+ );
201
+ let sessionId = receipt.session_id;
202
+ // The session is checked when this request is bound to it, and again
203
+ // before the request starts a run; a poll of a run that exists already
204
+ // only reads its receipt and row.
205
+ let sessionChecked = false;
206
+ if (!sessionId) {
207
+ const binding = await this.db.get<{ session_id: string }>(
208
+ 'SELECT session_id FROM channel_sessions WHERE binding_id = ?',
209
+ [input.bindingId],
210
+ );
211
+ if (binding && input.sessionId && binding.session_id !== input.sessionId)
212
+ throw new ChannelError(
213
+ 409,
214
+ 'This channel already has another session',
215
+ 'session_conflict',
216
+ );
217
+ sessionId =
218
+ binding?.session_id ??
219
+ input.sessionId ??
220
+ // The marker transaction id is fixed per binding AND the request that
221
+ // opens the session: a retry of that request reuses the marker event,
222
+ // while a binding whose session was deleted gets a new one.
223
+ (await this.host.createSession(
224
+ identity,
225
+ `channel-session-${await channelRequestHash(JSON.stringify([input.bindingId, input.requestId]))}`,
226
+ ));
227
+ await this.host.assertSession(identity, sessionId);
228
+ sessionChecked = true;
229
+ await this.db.transaction(async () => {
230
+ await this.db.run(
231
+ 'INSERT OR IGNORE INTO channel_sessions (binding_id, session_id) VALUES (?, ?)',
232
+ [input.bindingId, sessionId],
233
+ );
234
+ await this.db.run(
235
+ 'UPDATE channel_requests SET session_id = ? WHERE run_id = ?',
236
+ [sessionId, runId],
237
+ );
238
+ });
239
+ await this.host.changed();
240
+ }
241
+ const request: TurnRequest = {
242
+ identity,
243
+ sessionId,
244
+ message: input.message,
245
+ client: 'channel',
246
+ requestId: input.requestId,
247
+ multitask: 'enqueue',
248
+ channel: {
249
+ provider: input.provider,
250
+ bindingId: input.bindingId,
251
+ remoteMessageRef: input.remoteMessageRef,
252
+ },
253
+ };
254
+ let record = await this.host.getRun(runId);
255
+ if (!record) {
256
+ if (!sessionChecked) await this.host.assertSession(identity, sessionId);
257
+ await this.host.requireDelegation(identity);
258
+ await this.host.mirror(request, request.message, 'user');
259
+ record = await this.host.begin(runId, request);
260
+ }
261
+ const reply =
262
+ record.status === 'finished'
263
+ ? await this.reply(runId, record.partialText ?? '')
264
+ : null;
265
+ // The Companion room records the reply the channel user received, once.
266
+ if (reply) await this.deliver(request, runId, reply);
267
+ return {
268
+ requestId: input.requestId,
269
+ runId,
270
+ sessionId,
271
+ status: record.status,
272
+ ...(record.messageId ? { messageId: record.messageId } : {}),
273
+ // `text` is the whole reply as one message for gateways that predate
274
+ // plans: the rendered plan when there is one (it carries the artefact
275
+ // links), else the model's text.
276
+ ...(reply ? { text: reply.rendered } : {}),
277
+ ...(reply?.plan ? { plan: reply.plan } : {}),
278
+ };
279
+ }
280
+ }