@ixo/oracle-runtime-workers 0.13.0 → 0.15.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 +267 -0
  18. package/src/channels/contract.ts +137 -0
  19. package/src/channels/turns.ts +270 -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 +1883 -264
  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 +529 -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,278 @@
1
+ /**
2
+ * A finished run → its Reply Plan. Built once, when the run finishes, from
3
+ * the turn's own messages (the model's checkpointed history is never
4
+ * rewritten; the plan is a projection of it for one chat surface):
5
+ *
6
+ * - every model step with text is delivered in order, not only the last
7
+ * one; a short line of narration before a tool call ("Checking your
8
+ * calendar.") is dropped when a later step speaks to the user, because
9
+ * the typing indicator already says so — a step whose short text is the
10
+ * whole answer ("Your flight is 9:40." + a memory save) keeps it;
11
+ * - each step goes through the shaper, which may spill it to an artefact;
12
+ * - a `create_artifact` call becomes its message, the link, its question.
13
+ */
14
+ import {
15
+ AIMessage,
16
+ ToolMessage,
17
+ type BaseMessage,
18
+ } from '@langchain/core/messages';
19
+ import { contentToText } from '../do/transcript';
20
+ import { CREATE_ARTIFACT_TOOL, createArtifactParts } from '../artifacts/tool';
21
+ import { isBlank, shapeStep, type SpillShape } from './shaper';
22
+ import type {
23
+ ArtifactRef,
24
+ ChatLimits,
25
+ ReplyContent,
26
+ ReplyPart,
27
+ ReplyPlan,
28
+ } from './types';
29
+
30
+ export interface TurnStep {
31
+ text: string;
32
+ toolCalls: Array<{ id: string; name: string; args: unknown }>;
33
+ }
34
+
35
+ export type DraftPart =
36
+ | ReplyContent
37
+ | { kind: 'spill'; key: string; spill: SpillShape };
38
+
39
+ const NARRATION_MAX_CHARS = 200;
40
+
41
+ /** The model steps of the turn that ended the thread, with its tool results. */
42
+ export function turnSteps(messages: readonly BaseMessage[]): {
43
+ steps: TurnStep[];
44
+ toolResults: Map<string, string>;
45
+ } {
46
+ let start = 0;
47
+ for (let i = messages.length - 1; i >= 0; i--) {
48
+ if (messages[i]?.type === 'human') {
49
+ start = i + 1;
50
+ break;
51
+ }
52
+ }
53
+ const steps: TurnStep[] = [];
54
+ const toolResults = new Map<string, string>();
55
+ for (const message of messages.slice(start)) {
56
+ if (ToolMessage.isInstance(message)) {
57
+ toolResults.set(message.tool_call_id, contentToText(message.content));
58
+ continue;
59
+ }
60
+ // Extraction-lane attachment text rides as hidden AI messages.
61
+ if (!AIMessage.isInstance(message) || message.additional_kwargs?.attachment)
62
+ continue;
63
+ steps.push({
64
+ text: contentToText(message.content).trim(),
65
+ toolCalls: (message.tool_calls ?? []).map((call) => ({
66
+ id: call.id ?? '',
67
+ name: call.name,
68
+ args: call.args,
69
+ })),
70
+ });
71
+ }
72
+ return { steps, toolResults };
73
+ }
74
+
75
+ /** One short paragraph of prose: what a model says before it calls a tool. */
76
+ export function isNarration(text: string): boolean {
77
+ return (
78
+ text.length <= NARRATION_MAX_CHARS &&
79
+ !text.includes('\n\n') &&
80
+ !text.includes('```') &&
81
+ !/^\s*([-*+]|\d+[.)])\s/m.test(text)
82
+ );
83
+ }
84
+
85
+ /**
86
+ * What a resumed run still has to deliver of `continuation`, the text it
87
+ * streamed before the reset. That text starts with the steps the checkpoint
88
+ * kept, verbatim, and those steps are in `steps` already; the rest was cut
89
+ * off mid-step and belongs in front of the first step after them, or after
90
+ * the last step when the checkpoint kept every step.
91
+ */
92
+ function pendingContinuation(
93
+ continuation: string,
94
+ steps: readonly TurnStep[],
95
+ ): { text: string; step: number; after: boolean } {
96
+ let rest = continuation;
97
+ let step = 0;
98
+ for (const { text } of steps) {
99
+ const trimmed = rest.trimStart();
100
+ if (!trimmed.startsWith(text)) break;
101
+ rest = trimmed.slice(text.length);
102
+ step++;
103
+ }
104
+ return step < steps.length
105
+ ? { text: rest, step, after: false }
106
+ : { text: rest, step: steps.length - 1, after: true };
107
+ }
108
+
109
+ /**
110
+ * The plan before any artefact exists: spills are still drafts. `continuation`
111
+ * is the text a run had streamed before a reset (see `pendingContinuation`);
112
+ * a run with no step of its own delivers it alone.
113
+ */
114
+ export function draftReplyPlan(input: {
115
+ steps: TurnStep[];
116
+ toolResults: ReadonlyMap<string, string>;
117
+ continuation?: string | null;
118
+ limits: ChatLimits;
119
+ canSpill: boolean;
120
+ }): DraftPart[] {
121
+ const parts: DraftPart[] = [];
122
+ const steps =
123
+ input.steps.length > 0 || !input.continuation
124
+ ? input.steps
125
+ : [{ text: input.continuation.trim(), toolCalls: [] }];
126
+ const pending =
127
+ input.continuation && input.steps.length > 0
128
+ ? pendingContinuation(input.continuation, input.steps)
129
+ : null;
130
+ const texts = steps.map((step, i) => {
131
+ if (!pending || i !== pending.step || isBlank(pending.text))
132
+ return step.text;
133
+ return (
134
+ pending.after
135
+ ? `${step.text}${pending.text}`
136
+ : `${pending.text}${step.text}`
137
+ ).trim();
138
+ });
139
+ // Whether the reply goes on after a step's text: a later step with text,
140
+ // or a `create_artifact` call (its message follows, even in the same step).
141
+ // Only then is a short line before a tool call narration to drop.
142
+ const createsArtifact = steps.map((step) =>
143
+ step.toolCalls.some((call) => call.name === CREATE_ARTIFACT_TOOL),
144
+ );
145
+ const saysMore: boolean[] = [];
146
+ let later = false;
147
+ for (let i = steps.length - 1; i >= 0; i--) {
148
+ saysMore[i] = later || createsArtifact[i] === true;
149
+ later ||= !isBlank(texts[i] ?? '') || createsArtifact[i] === true;
150
+ }
151
+ steps.forEach((step, i) => {
152
+ const text = texts[i] ?? '';
153
+ const narration =
154
+ step.toolCalls.length > 0 && saysMore[i] === true && isNarration(text);
155
+ if (!isBlank(text) && !narration) {
156
+ const shape = shapeStep(text, input.limits, input.canSpill);
157
+ if (shape.kind === 'messages')
158
+ parts.push(
159
+ ...shape.messages.map((m) => ({ kind: 'text' as const, text: m })),
160
+ );
161
+ else parts.push({ kind: 'spill', key: `step-${i}`, spill: shape.spill });
162
+ }
163
+ for (const call of step.toolCalls)
164
+ if (call.name === CREATE_ARTIFACT_TOOL)
165
+ parts.push(
166
+ ...createArtifactParts(call.args, input.toolResults.get(call.id)),
167
+ );
168
+ });
169
+ return parts;
170
+ }
171
+
172
+ /**
173
+ * A part before it is numbered. `framing` marks a spill's lead or closing
174
+ * question: its artefact holds the same text.
175
+ */
176
+ type PlanPart = ReplyContent & { framing?: boolean };
177
+
178
+ /**
179
+ * Over the run's cap: merge the shortest neighbouring messages while the
180
+ * result stays within `bubbleMax`, then drop spill framing, keeping the
181
+ * reply's last part for as long as possible. Order is kept, and text that
182
+ * exists nowhere else is never dropped: a reply with several documents, or a
183
+ * long one without artefact storage, can stay over the cap.
184
+ */
185
+ function capParts(parts: PlanPart[], limits: ChatLimits): ReplyContent[] {
186
+ const out = [...parts];
187
+ const merge = (): boolean => {
188
+ let best = -1;
189
+ let bestLength = Infinity;
190
+ for (let i = 0; i + 1 < out.length; i++) {
191
+ const a = out[i];
192
+ const b = out[i + 1];
193
+ if (a?.kind !== 'text' || b?.kind !== 'text') continue;
194
+ const length = a.text.length + 2 + b.text.length;
195
+ if (length <= limits.bubbleMax && length < bestLength) {
196
+ best = i;
197
+ bestLength = length;
198
+ }
199
+ }
200
+ const a = out[best];
201
+ const b = out[best + 1];
202
+ if (a?.kind !== 'text' || b?.kind !== 'text') return false;
203
+ out.splice(best, 2, {
204
+ kind: 'text',
205
+ text: `${a.text}\n\n${b.text}`,
206
+ framing: a.framing === true && b.framing === true,
207
+ });
208
+ return true;
209
+ };
210
+ const dropFraming = (): boolean => {
211
+ const framing = out.flatMap((part, i) => (part.framing ? [i] : []));
212
+ const i = framing.find((index) => index < out.length - 1) ?? framing[0];
213
+ if (i === undefined) return false;
214
+ out.splice(i, 1);
215
+ return true;
216
+ };
217
+ while (out.length > limits.maxPartsPerRun) {
218
+ if (!merge() && !dropFraming()) break;
219
+ }
220
+ return out.map(
221
+ (part): ReplyContent =>
222
+ part.kind === 'text'
223
+ ? { kind: 'text', text: part.text }
224
+ : { kind: 'artifact', artifact: part.artifact },
225
+ );
226
+ }
227
+
228
+ /**
229
+ * Turn spill drafts into artefacts. A spill whose artefact cannot be created
230
+ * falls back to plain messages: the reply is never lost to a storage error.
231
+ */
232
+ export async function materializeReplyPlan(
233
+ draft: DraftPart[],
234
+ options: {
235
+ limits: ChatLimits;
236
+ createSpill: (key: string, spill: SpillShape) => Promise<ArtifactRef>;
237
+ onSpillError?: (error: unknown) => void;
238
+ },
239
+ ): Promise<ReplyPlan> {
240
+ const parts: PlanPart[] = [];
241
+ for (const part of draft) {
242
+ if (part.kind !== 'spill') {
243
+ parts.push(part);
244
+ continue;
245
+ }
246
+ const ref = await options
247
+ .createSpill(part.key, part.spill)
248
+ .catch((error: unknown) => {
249
+ options.onSpillError?.(error);
250
+ return null;
251
+ });
252
+ if (ref) {
253
+ if (part.spill.lead)
254
+ parts.push({ kind: 'text', text: part.spill.lead, framing: true });
255
+ parts.push({ kind: 'artifact', artifact: ref });
256
+ if (part.spill.closing)
257
+ parts.push({ kind: 'text', text: part.spill.closing, framing: true });
258
+ } else {
259
+ const shape = shapeStep(part.spill.markdown, options.limits, false);
260
+ if (shape.kind === 'messages')
261
+ parts.push(
262
+ ...shape.messages.map((m) => ({ kind: 'text' as const, text: m })),
263
+ );
264
+ }
265
+ }
266
+ // A part that would look empty (only whitespace or zero-width characters)
267
+ // is never sent.
268
+ const capped = capParts(
269
+ parts.filter((part) => part.kind !== 'text' || !isBlank(part.text)),
270
+ options.limits,
271
+ );
272
+ return {
273
+ v: 1,
274
+ parts: capped.map(
275
+ (part, i): ReplyPart => ({ partId: `p${i + 1}`, ...part }),
276
+ ),
277
+ };
278
+ }
@@ -0,0 +1,161 @@
1
+ import type { TurnRequest } from '../do/contracts';
2
+ import { TASK_SESSION_PREFIX } from '../tasks/scheduler';
3
+ import type { ChatLimits, DeliveryProfile } from './types';
4
+
5
+ /**
6
+ * `OracleConfig.delivery`: per-oracle tuning of chat delivery. Matrix room
7
+ * turns use the chat style only when `matrixChat` is `true` (off by default;
8
+ * `MATRIX_CHAT_DELIVERY` sets it per deployment); `limits` overrides the
9
+ * defaults below per surface (`whatsapp`, `telegram`, `slack`, `matrix`,
10
+ * `generic`).
11
+ */
12
+ export interface DeliveryConfig {
13
+ matrixChat?: boolean;
14
+ limits?: Partial<Record<string, Partial<ChatLimits>>>;
15
+ }
16
+
17
+ const GENERIC: ChatLimits = {
18
+ bubbleTarget: 600,
19
+ bubbleMax: 1500,
20
+ minBubble: 60,
21
+ maxBubbles: 3,
22
+ maxPartsPerRun: 5,
23
+ spillChars: 1800,
24
+ maxListItems: 7,
25
+ previewItems: 3,
26
+ maxCodeLines: 12,
27
+ tables: false,
28
+ };
29
+
30
+ /**
31
+ * Well under each provider's hard limit (WhatsApp and Telegram 4,096
32
+ * characters, Slack 12,000 per message, Matrix 64 KiB per event): the
33
+ * gateway still enforces the hard limit, and never truncates.
34
+ */
35
+ const DEFAULT_LIMITS: Readonly<Record<string, ChatLimits>> = {
36
+ generic: GENERIC,
37
+ whatsapp: { ...GENERIC, maxBubbles: 4, maxPartsPerRun: 6 },
38
+ telegram: {
39
+ ...GENERIC,
40
+ bubbleTarget: 800,
41
+ bubbleMax: 2000,
42
+ maxBubbles: 4,
43
+ maxPartsPerRun: 6,
44
+ spillChars: 2400,
45
+ },
46
+ slack: {
47
+ ...GENERIC,
48
+ bubbleTarget: 1200,
49
+ bubbleMax: 3000,
50
+ spillChars: 3500,
51
+ },
52
+ matrix: {
53
+ ...GENERIC,
54
+ bubbleTarget: 1200,
55
+ bubbleMax: 4000,
56
+ spillChars: 4000,
57
+ },
58
+ };
59
+
60
+ /** A group room hears one short answer, not a series. */
61
+ const GROUP_LIMITS: Partial<ChatLimits> = { maxBubbles: 2, maxPartsPerRun: 3 };
62
+
63
+ const COUNTS = [
64
+ 'bubbleTarget',
65
+ 'bubbleMax',
66
+ 'minBubble',
67
+ 'maxBubbles',
68
+ 'maxPartsPerRun',
69
+ 'spillChars',
70
+ 'maxListItems',
71
+ 'previewItems',
72
+ 'maxCodeLines',
73
+ ] as const satisfies ReadonlyArray<keyof ChatLimits>;
74
+
75
+ /**
76
+ * The overrides an oracle may set: whole numbers of at least 1, and a
77
+ * boolean `tables`. Anything else keeps the default, since a size of zero
78
+ * would leave the shaper nothing to split into.
79
+ */
80
+ function validLimits(overrides: Partial<ChatLimits> = {}): Partial<ChatLimits> {
81
+ const out: Partial<ChatLimits> = {};
82
+ for (const key of COUNTS) {
83
+ const value = overrides[key];
84
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 1)
85
+ out[key] = value;
86
+ }
87
+ if (typeof overrides.tables === 'boolean') out.tables = overrides.tables;
88
+ return out;
89
+ }
90
+
91
+ const LABELS: Readonly<Record<string, string>> = {
92
+ whatsapp: 'WhatsApp',
93
+ telegram: 'Telegram',
94
+ slack: 'Slack',
95
+ matrix: 'a Matrix chat room',
96
+ generic: 'a chat app',
97
+ };
98
+
99
+ /**
100
+ * `OracleConfig.delivery` with the deployment's `MATRIX_CHAT_DELIVERY` applied:
101
+ * the variable, when set, decides whether Matrix rooms use the chat style.
102
+ */
103
+ export function deliveryConfigWithEnv(
104
+ config: DeliveryConfig | undefined,
105
+ matrixChatEnv: string | undefined,
106
+ ): DeliveryConfig {
107
+ return {
108
+ ...config,
109
+ matrixChat:
110
+ matrixChatEnv === 'true' || matrixChatEnv === 'false'
111
+ ? matrixChatEnv === 'true'
112
+ : config?.matrixChat === true,
113
+ };
114
+ }
115
+
116
+ /**
117
+ * The delivery profile of one turn, from where it came in. A scheduled task
118
+ * run streams whatever room it reports to: its result is stored and read
119
+ * later, so it keeps the model's whole text instead of a chat rendering with
120
+ * expiring links.
121
+ */
122
+ export function resolveDeliveryProfile(
123
+ turn: Pick<TurnRequest, 'client' | 'channel' | 'roomKind'> &
124
+ Partial<Pick<TurnRequest, 'sessionId' | 'taskRunId'>>,
125
+ config: DeliveryConfig = {},
126
+ ): DeliveryProfile {
127
+ if (turn.client === 'portal') return { kind: 'stream' };
128
+ if (
129
+ turn.taskRunId !== undefined ||
130
+ turn.sessionId?.startsWith(TASK_SESSION_PREFIX)
131
+ )
132
+ return { kind: 'stream' };
133
+ if (turn.client === 'matrix' && config.matrixChat !== true)
134
+ return { kind: 'stream' };
135
+ const surface =
136
+ turn.client === 'matrix'
137
+ ? 'matrix'
138
+ : turn.channel?.provider && DEFAULT_LIMITS[turn.channel.provider]
139
+ ? turn.channel.provider
140
+ : 'generic';
141
+ const merged: ChatLimits = {
142
+ ...(DEFAULT_LIMITS[surface] ?? GENERIC),
143
+ ...(turn.client === 'matrix' && turn.roomKind === 'group'
144
+ ? GROUP_LIMITS
145
+ : {}),
146
+ ...validLimits(config.limits?.[surface]),
147
+ };
148
+ // The soft target and the merge threshold never exceed the hard size.
149
+ const bubbleTarget = Math.min(merged.bubbleTarget, merged.bubbleMax);
150
+ const limits: ChatLimits = {
151
+ ...merged,
152
+ bubbleTarget,
153
+ minBubble: Math.min(merged.minBubble, bubbleTarget),
154
+ };
155
+ return {
156
+ kind: 'chat',
157
+ surface,
158
+ label: LABELS[surface] ?? 'a chat app',
159
+ limits,
160
+ };
161
+ }
@@ -0,0 +1,27 @@
1
+ import type { DeliveryProfile } from './types';
2
+
3
+ /**
4
+ * The prompt section a chat turn gets: write for the surface. Empty on the
5
+ * Portal. `artifacts` says whether `create_artifact` is bound this turn.
6
+ */
7
+ export function renderSurfaceSection(
8
+ profile: DeliveryProfile | undefined,
9
+ artifacts: boolean,
10
+ ): string {
11
+ if (!profile || profile.kind !== 'chat') return '';
12
+ const { label, limits } = profile;
13
+ return [
14
+ '## Where this conversation is happening',
15
+ '',
16
+ `You are replying in ${label}. The user reads your reply as chat messages, not as a document.`,
17
+ '',
18
+ '- Lead with the answer. Write like a sharp person texting: short sentences, no preamble, no sign-off.',
19
+ `- Aim for one to three short messages. A blank line starts a new message; keep each under about ${limits.bubbleTarget} characters.`,
20
+ `- No headings, tables or horizontal rules. Short lists (up to ${limits.maxListItems} items) and **bold** for the key fact are fine.`,
21
+ artifacts
22
+ ? '- Anything longer, such as a plan, report, comparison, draft, table or code, goes in `create_artifact`: the full Markdown as the document, a one-line message, and at most one follow-up question. The user gets the message, a link that opens the document in their browser, then the question.'
23
+ : '- When something is too long for chat, send the short version and offer the rest.',
24
+ '- The user sees a typing indicator while you work. Do not narrate your steps or announce tool calls.',
25
+ '- End with at most one question.',
26
+ ].join('\n');
27
+ }
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ import type { ReplyContent, ReplyPlan } from './types';
3
+
4
+ const ArtifactRefSchema = z.object({
5
+ artifactId: z.string(),
6
+ title: z.string(),
7
+ url: z.string(),
8
+ mime: z.literal('text/markdown'),
9
+ bytes: z.number(),
10
+ expiresAt: z.string(),
11
+ });
12
+
13
+ export const ReplyPlanSchema = z.object({
14
+ v: z.literal(1),
15
+ parts: z.array(
16
+ z.discriminatedUnion('kind', [
17
+ z.object({
18
+ partId: z.string(),
19
+ kind: z.literal('text'),
20
+ text: z.string(),
21
+ }),
22
+ z.object({
23
+ partId: z.string(),
24
+ kind: z.literal('artifact'),
25
+ artifact: ArtifactRefSchema,
26
+ }),
27
+ ]),
28
+ ),
29
+ });
30
+
31
+ /** A stored or transported plan, or null when it is absent or malformed. */
32
+ export function parseReplyPlan(
33
+ json: string | null | undefined,
34
+ ): ReplyPlan | null {
35
+ if (!json) return null;
36
+ try {
37
+ const parsed = ReplyPlanSchema.safeParse(JSON.parse(json));
38
+ return parsed.success ? parsed.data : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ function linkLabel(title: string): string {
45
+ return title.replace(/[[\]]/g, '');
46
+ }
47
+
48
+ /**
49
+ * The plan as one Markdown message: what the Companion room mirror of a
50
+ * channel turn and gateways that predate plans receive. Never stored as a
51
+ * run's text, which stays the model's own.
52
+ */
53
+ export function planText(plan: { parts: readonly ReplyContent[] }): string {
54
+ return plan.parts
55
+ .map((part) =>
56
+ part.kind === 'text'
57
+ ? part.text
58
+ : `[${linkLabel(part.artifact.title)}](${part.artifact.url})`,
59
+ )
60
+ .join('\n\n');
61
+ }