@ixo/oracle-runtime-workers 0.12.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 +14 -9
  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 +198 -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 +523 -111
  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,389 @@
1
+ /**
2
+ * The deterministic shaper: one model step's Markdown → chat messages, or a
3
+ * spill (lead message, artefact, closing question) when the step is too long
4
+ * or too structured for chat. No model call; the same input always gives the
5
+ * same output, which is what makes a Reply Plan safe to rebuild on recovery.
6
+ */
7
+ import { marked, type Token, type Tokens } from 'marked';
8
+ import type { ChatLimits } from './types';
9
+
10
+ export interface SpillShape {
11
+ title: string;
12
+ /** The whole step as the model wrote it: the artefact's content. */
13
+ markdown: string;
14
+ /** What stays in chat: the first block, or a list's lead-in and first items. */
15
+ lead: string;
16
+ /** The step's closing question, kept as its own message after the link. */
17
+ closing?: string;
18
+ }
19
+
20
+ export type StepShape =
21
+ | { kind: 'messages'; messages: string[] }
22
+ | { kind: 'spill'; spill: SpillShape };
23
+
24
+ type BlockKind = 'heading' | 'paragraph' | 'list' | 'code' | 'table' | 'quote';
25
+
26
+ interface Block {
27
+ kind: BlockKind;
28
+ md: string;
29
+ token: Token;
30
+ }
31
+
32
+ const TITLE_MAX = 60;
33
+
34
+ const isHeading = (t: Token): t is Tokens.Heading => t.type === 'heading';
35
+ const isList = (t: Token): t is Tokens.List => t.type === 'list';
36
+ const isCode = (t: Token): t is Tokens.Code => t.type === 'code';
37
+ const isTable = (t: Token): t is Tokens.Table => t.type === 'table';
38
+ const isHtml = (t: Token): t is Tokens.HTML => t.type === 'html';
39
+
40
+ const sentenceSegmenter = new Intl.Segmenter(undefined, {
41
+ granularity: 'sentence',
42
+ });
43
+
44
+ /**
45
+ * HTML elements a model mixes into Markdown. Anything else in angle brackets
46
+ * (`Promise<string>`, `<name>` placeholders) is text and stays.
47
+ */
48
+ const HTML_ELEMENTS = new Set(
49
+ (
50
+ 'a abbr address article aside b bdi bdo blockquote body br button ' +
51
+ 'caption center cite code col colgroup dd del details dfn div dl dt ' +
52
+ 'em figcaption figure font footer form h1 h2 h3 h4 h5 h6 head ' +
53
+ 'header hr html i iframe img input ins kbd label li main mark nav ' +
54
+ 'ol p picture pre q s samp script section select small source span ' +
55
+ 'strike strong style sub summary sup table tbody td textarea tfoot ' +
56
+ 'th thead tr tt u ul var video wbr'
57
+ ).split(' '),
58
+ );
59
+
60
+ /**
61
+ * Raw HTML → its text: HTML elements' tags and comments removed, `<br>` a
62
+ * line break. Only ever applied to what the lexer read as raw HTML, never to
63
+ * code. `a < b and c > d` is left alone.
64
+ */
65
+ function stripHtmlTags(html: string): string {
66
+ return html
67
+ .replace(/<!--[\s\S]*?-->/g, '')
68
+ .replace(/<br\s*\/?>/gi, '\n')
69
+ .replace(
70
+ /<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?\/?>/g,
71
+ (tag, name: string) => (HTML_ELEMENTS.has(name.toLowerCase()) ? '' : tag),
72
+ );
73
+ }
74
+
75
+ /** Characters that render as nothing: whitespace and zero-width marks. */
76
+ const INVISIBLE = /[\s\u200B-\u200D\u2060\uFEFF]/g;
77
+
78
+ /** True when a message would look empty: only whitespace or zero-width characters. */
79
+ export function isBlank(text: string): boolean {
80
+ return text.replace(INVISIBLE, '') === '';
81
+ }
82
+
83
+ function childTokens(token: Token): Token[] {
84
+ if (isList(token)) return token.items;
85
+ return 'tokens' in token && Array.isArray(token.tokens) ? token.tokens : [];
86
+ }
87
+
88
+ const isLinkOrImage = (t: Token): t is Tokens.Link | Tokens.Image =>
89
+ t.type === 'link' || t.type === 'image';
90
+
91
+ /** `[text][ref]`, `[text][]` or `[ref]`, inline: its definition is not delivered. */
92
+ function referenceLinkInline(token: Token): string | null {
93
+ if (!isLinkOrImage(token) || token.raw.endsWith(')')) return null;
94
+ const image = token.type === 'image';
95
+ if (!token.raw.startsWith(image ? '![' : '[')) return null;
96
+ const title = token.title ? ` "${token.title.replace(/"/g, '\\"')}"` : '';
97
+ return `${image ? '!' : ''}[${token.text}](${token.href}${title})`;
98
+ }
99
+
100
+ /**
101
+ * A block's Markdown for chat: `token.raw` with raw HTML reduced to its text
102
+ * and reference links written inline (their definitions are not delivered).
103
+ * Code spans and code blocks stay verbatim, so `` `<div>` `` survives.
104
+ */
105
+ function chatMarkdown(token: Token): string {
106
+ const raw = token.raw;
107
+ const edits: Array<{ start: number; end: number; text: string }> = [];
108
+ let cursor = 0;
109
+ const locate = (part: string): number => {
110
+ const at = part ? raw.indexOf(part, cursor) : -1;
111
+ if (at >= 0) cursor = at + part.length;
112
+ return at;
113
+ };
114
+ const visit = (node: Token): void => {
115
+ if (node.type === 'code' || node.type === 'codespan') {
116
+ locate(node.raw);
117
+ return;
118
+ }
119
+ const inline = referenceLinkInline(node);
120
+ const replacement = node.type === 'html' ? stripHtmlTags(node.raw) : inline;
121
+ if (replacement !== null) {
122
+ const at = locate(node.raw);
123
+ if (at >= 0)
124
+ edits.push({ start: at, end: at + node.raw.length, text: replacement });
125
+ return;
126
+ }
127
+ const children = childTokens(node);
128
+ if (children.length === 0) locate(node.raw);
129
+ else for (const child of children) visit(child);
130
+ };
131
+ for (const child of childTokens(token)) visit(child);
132
+ let out = '';
133
+ let from = 0;
134
+ for (const edit of edits) {
135
+ out += raw.slice(from, edit.start) + edit.text;
136
+ from = edit.end;
137
+ }
138
+ return out + raw.slice(from);
139
+ }
140
+
141
+ function plainText(text: string): string {
142
+ return stripHtmlTags(text)
143
+ .replace(/[*_`#>~]/g, '')
144
+ .replace(/\s+/g, ' ')
145
+ .trim();
146
+ }
147
+
148
+ function tableAsList(table: Tokens.Table): string {
149
+ return table.rows
150
+ .map((row) => {
151
+ const [first, ...rest] = row.map((cell) => cell.text.trim());
152
+ const pairs = rest.map((value, i) => {
153
+ const header = table.header[i + 1]?.text.trim();
154
+ return header ? `${header}: ${value}` : value;
155
+ });
156
+ return `- **${first ?? ''}**${pairs.length ? ` · ${pairs.join(' · ')}` : ''}`;
157
+ })
158
+ .join('\n');
159
+ }
160
+
161
+ function toBlocks(markdown: string, limits: ChatLimits): Block[] {
162
+ const blocks: Block[] = [];
163
+ for (const token of marked.lexer(markdown)) {
164
+ if (token.type === 'space' || token.type === 'hr' || token.type === 'def')
165
+ continue;
166
+ if (isHeading(token)) {
167
+ const text = plainText(token.text);
168
+ if (text) blocks.push({ kind: 'heading', md: `**${text}**`, token });
169
+ } else if (isList(token)) {
170
+ blocks.push({ kind: 'list', md: chatMarkdown(token).trim(), token });
171
+ } else if (isCode(token)) {
172
+ blocks.push({ kind: 'code', md: token.raw.trim(), token });
173
+ } else if (isTable(token)) {
174
+ blocks.push({
175
+ kind: 'table',
176
+ md: limits.tables ? token.raw.trim() : tableAsList(token),
177
+ token,
178
+ });
179
+ } else if (token.type === 'blockquote') {
180
+ blocks.push({ kind: 'quote', md: chatMarkdown(token).trim(), token });
181
+ } else {
182
+ const md = (
183
+ isHtml(token) ? stripHtmlTags(token.text) : chatMarkdown(token)
184
+ ).trim();
185
+ if (!isBlank(md)) blocks.push({ kind: 'paragraph', md, token });
186
+ }
187
+ }
188
+ return blocks;
189
+ }
190
+
191
+ function sentencesOf(text: string): string[] {
192
+ return Array.from(sentenceSegmenter.segment(text), (s) => s.segment);
193
+ }
194
+
195
+ function packPieces(
196
+ pieces: string[],
197
+ separator: string,
198
+ max: number,
199
+ ): string[] {
200
+ const out: string[] = [];
201
+ let current = '';
202
+ for (const piece of pieces) {
203
+ const candidate = current ? current + separator + piece : piece;
204
+ if (candidate.length <= max) {
205
+ current = candidate;
206
+ continue;
207
+ }
208
+ if (current) out.push(current);
209
+ if (piece.length > max) {
210
+ out.push(...splitText(piece, max));
211
+ current = '';
212
+ } else current = piece;
213
+ }
214
+ if (current) out.push(current);
215
+ return out.map((s) => s.trim()).filter((s) => !isBlank(s));
216
+ }
217
+
218
+ /** Split at the largest boundary that works: paragraph, line, sentence, word. */
219
+ export function splitText(text: string, max: number): string[] {
220
+ // A size below one character would never move through the text.
221
+ const size = Number.isFinite(max) && max >= 1 ? Math.floor(max) : 1;
222
+ if (text.length <= size) return [text];
223
+ for (const separator of ['\n\n', '\n']) {
224
+ const pieces = text.split(separator);
225
+ if (pieces.length > 1) return packPieces(pieces, separator, size);
226
+ }
227
+ const sentences = sentencesOf(text);
228
+ if (sentences.length > 1) return packPieces(sentences, '', size);
229
+ const words = text.split(/(?<=\s)/);
230
+ if (words.length > 1) return packPieces(words, '', size);
231
+ const out: string[] = [];
232
+ for (let i = 0; i < text.length; i += size) out.push(text.slice(i, i + size));
233
+ return out;
234
+ }
235
+
236
+ /** A code block too long for one message is re-fenced per piece. */
237
+ function splitBlock(block: Block, max: number): string[] {
238
+ if (block.md.length <= max) return [block.md];
239
+ if (isCode(block.token)) {
240
+ const fence = '```' + (block.token.lang ?? '');
241
+ const room = max - fence.length - 5;
242
+ if (room >= 1)
243
+ return splitText(block.token.text, room).map(
244
+ (piece) => `${fence}\n${piece}\n\`\`\``,
245
+ );
246
+ }
247
+ return splitText(block.md, max);
248
+ }
249
+
250
+ function trimToSentence(text: string, max: number): string {
251
+ if (text.length <= max) return text;
252
+ let out = '';
253
+ for (const sentence of sentencesOf(text)) {
254
+ if ((out + sentence).length > max) break;
255
+ out += sentence;
256
+ }
257
+ if (out.trim()) return out.trim();
258
+ const cut = text.slice(0, max - 1);
259
+ const space = cut.lastIndexOf(' ');
260
+ return `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
261
+ }
262
+
263
+ function titleOf(text: string): string {
264
+ const plain = plainText(sentencesOf(text)[0] ?? text).replace(/[:.]$/, '');
265
+ if (plain.length <= TITLE_MAX) return plain;
266
+ const cut = plain.slice(0, TITLE_MAX - 1);
267
+ const space = cut.lastIndexOf(' ');
268
+ return `${(space > TITLE_MAX / 2 ? cut.slice(0, space) : cut).trimEnd()}…`;
269
+ }
270
+
271
+ function firstLine(text: string): string {
272
+ return (text.split('\n')[0] ?? '').trim();
273
+ }
274
+
275
+ function preview(list: Tokens.List, count: number): string {
276
+ const start = typeof list.start === 'number' ? list.start : 1;
277
+ const items = list.items
278
+ .slice(0, count)
279
+ .map(
280
+ (item, i) =>
281
+ `${list.ordered ? `${start + i}.` : '-'} ${firstLine(item.text)}`,
282
+ );
283
+ const more = list.items.length - count;
284
+ return items.join('\n') + (more > 0 ? `\n…and ${more} more` : '');
285
+ }
286
+
287
+ const endsWithColon = (md: string): boolean => /:\s*$/.test(md);
288
+ const joinBlocks = (a: string, b: string): string => (a ? `${a}\n\n${b}` : b);
289
+
290
+ /** Blocks → messages: headings and "…:" lead-ins stay with what follows. */
291
+ function pack(blocks: Block[], limits: ChatLimits): string[] {
292
+ const units: string[] = [];
293
+ let carry = '';
294
+ blocks.forEach((block, i) => {
295
+ const next = blocks[i + 1];
296
+ const leadIn =
297
+ block.kind === 'paragraph' &&
298
+ endsWithColon(block.md) &&
299
+ next !== undefined &&
300
+ (next.kind === 'list' || next.kind === 'code' || next.kind === 'table');
301
+ if (block.kind === 'heading' || leadIn) {
302
+ carry = joinBlocks(carry, block.md);
303
+ return;
304
+ }
305
+ const pieces = splitBlock(block, limits.bubbleMax);
306
+ pieces.forEach((piece, j) => {
307
+ units.push(j === 0 ? joinBlocks(carry, piece) : piece);
308
+ });
309
+ carry = '';
310
+ });
311
+ if (carry) units.push(carry);
312
+ const merged: string[] = [];
313
+ for (let i = 0; i < units.length; i++) {
314
+ const unit = units[i]!;
315
+ if (unit.length < limits.minBubble && i + 1 < units.length)
316
+ units[i + 1] = joinBlocks(unit, units[i + 1]!);
317
+ else merged.push(unit);
318
+ }
319
+ return merged;
320
+ }
321
+
322
+ function spillOf(
323
+ markdown: string,
324
+ blocks: Block[],
325
+ limits: ChatLimits,
326
+ ): SpillShape {
327
+ const heading = blocks.find((b) => b.kind === 'heading');
328
+ const content = blocks.filter((b) => b.kind !== 'heading');
329
+ const [first, second] = content;
330
+ let lead = '';
331
+ if (first && isList(first.token))
332
+ lead = preview(first.token, limits.previewItems);
333
+ else if (
334
+ first?.kind === 'paragraph' &&
335
+ endsWithColon(first.md) &&
336
+ second &&
337
+ isList(second.token)
338
+ )
339
+ lead = joinBlocks(first.md, preview(second.token, limits.previewItems));
340
+ else if (first && (first.kind === 'paragraph' || first.kind === 'quote'))
341
+ lead = trimToSentence(first.md, limits.bubbleTarget);
342
+ const last = content.at(-1);
343
+ const closing =
344
+ last &&
345
+ last !== first &&
346
+ last.kind === 'paragraph' &&
347
+ /\?\s*$/.test(last.md) &&
348
+ last.md.length <= limits.bubbleMax
349
+ ? last.md
350
+ : undefined;
351
+ const titleSource =
352
+ heading?.md ?? (first?.kind === 'paragraph' ? first.md : undefined);
353
+ return {
354
+ title: (titleSource && titleOf(titleSource)) || 'Details',
355
+ markdown,
356
+ lead,
357
+ ...(closing ? { closing } : {}),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Shape one model step for a chat surface. With `canSpill` false (no
363
+ * artefact storage configured) the step is always split into messages:
364
+ * tables become lists and nothing is dropped.
365
+ */
366
+ export function shapeStep(
367
+ markdown: string,
368
+ limits: ChatLimits,
369
+ canSpill: boolean,
370
+ ): StepShape {
371
+ const blocks = toBlocks(markdown.trim(), limits);
372
+ const messages = pack(blocks, limits);
373
+ if (!canSpill) return { kind: 'messages', messages };
374
+ const length = blocks.reduce((n, b) => n + b.md.length + 2, 0);
375
+ const tooStructured = blocks.some(
376
+ (b) =>
377
+ (b.kind === 'table' && !limits.tables) ||
378
+ (isCode(b.token) &&
379
+ b.token.text.split('\n').length > limits.maxCodeLines) ||
380
+ (isList(b.token) && b.token.items.length > limits.maxListItems),
381
+ );
382
+ if (
383
+ length > limits.spillChars ||
384
+ tooStructured ||
385
+ messages.length > limits.maxBubbles
386
+ )
387
+ return { kind: 'spill', spill: spillOf(markdown.trim(), blocks, limits) };
388
+ return { kind: 'messages', messages };
389
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Chat delivery: how a finished turn reaches a chat surface (an IXO Channels
3
+ * provider such as WhatsApp, or a Matrix room) as a few short messages and
4
+ * artefact links instead of the one streamed document the Portal renders.
5
+ *
6
+ * Plain data only. The Matrix gateway script imports these types without the
7
+ * agent core, and IXO Channels reads the same shapes off `/channels/turn`.
8
+ */
9
+
10
+ /** Size and shape limits the shaper enforces on one chat surface. */
11
+ export interface ChatLimits {
12
+ /** Soft size of one message, in characters. */
13
+ bubbleTarget: number;
14
+ /** Hard size of one message before it is split at a sentence boundary. */
15
+ bubbleMax: number;
16
+ /** Fragments shorter than this merge into the message that follows. */
17
+ minBubble: number;
18
+ /** Messages one model step may become before it moves to an artefact. */
19
+ maxBubbles: number;
20
+ /**
21
+ * Parts one run delivers at most, met by merging text within `bubbleMax`
22
+ * and dropping spill framing. Text found nowhere else is never dropped.
23
+ */
24
+ maxPartsPerRun: number;
25
+ /** A step longer than this moves to an artefact. */
26
+ spillChars: number;
27
+ /** A longer list moves to an artefact, previewed by its first items. */
28
+ maxListItems: number;
29
+ /** List items kept in the message when a list moves to an artefact. */
30
+ previewItems: number;
31
+ /** A longer code block moves to an artefact. */
32
+ maxCodeLines: number;
33
+ /** Whether a Markdown table may stay inside a message. */
34
+ tables: boolean;
35
+ }
36
+
37
+ /**
38
+ * How one turn's reply is delivered. `stream` is the Portal: SSE frames,
39
+ * nothing reshaped. `chat` builds a Reply Plan under `limits`.
40
+ */
41
+ export type DeliveryProfile =
42
+ | { kind: 'stream' }
43
+ | {
44
+ kind: 'chat';
45
+ /** `whatsapp`, `telegram`, `slack`, `matrix` or `generic`. */
46
+ surface: string;
47
+ /** How the prompt names the surface: "WhatsApp", "a Matrix chat room". */
48
+ label: string;
49
+ limits: ChatLimits;
50
+ };
51
+
52
+ /** A browser-openable document delivered as a link. */
53
+ export interface ArtifactRef {
54
+ artifactId: string;
55
+ title: string;
56
+ /**
57
+ * Viewer link. Its decryption key sits after `#`, so R2 and the viewer host
58
+ * never receive it; the oracle stores it with the user's data.
59
+ */
60
+ url: string;
61
+ mime: 'text/markdown';
62
+ bytes: number;
63
+ expiresAt: string;
64
+ }
65
+
66
+ /**
67
+ * One delivery unit. `text` carries chat Markdown: bold, italic, strike,
68
+ * inline code, fenced code, `-` and `1.` lists, `>` quotes and links. No
69
+ * headings, tables or raw HTML reach a text part.
70
+ */
71
+ export type ReplyPart =
72
+ | { partId: string; kind: 'text'; text: string }
73
+ | { partId: string; kind: 'artifact'; artifact: ArtifactRef };
74
+
75
+ /** A part before it is numbered: what a plan is assembled from. */
76
+ export type ReplyContent =
77
+ | { kind: 'text'; text: string }
78
+ | { kind: 'artifact'; artifact: ArtifactRef };
79
+
80
+ /** What a chat surface delivers for one finished run, in delivery order. */
81
+ export interface ReplyPlan {
82
+ v: 1;
83
+ parts: ReplyPart[];
84
+ }
package/src/do/ambient.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * - emit → routed to the SSE writer of the turn that owns the
13
13
  * payload's `sessionId` (same shape as the Node `wsEmitter`).
14
14
  */
15
+ import type { DecisionEvaluator } from '@ixo/common/ai/decisions';
15
16
  import type {
16
17
  AmbientServices,
17
18
  DelegationLike,
@@ -19,12 +20,16 @@ import type {
19
20
  RawEventPayload,
20
21
  SecretsAdapter,
21
22
  } from '../core/runtime-context';
22
- import type { LlmAdapter } from '../core/runtime-context';
23
+ import {
24
+ delegationHasCapability,
25
+ type LlmAdapter,
26
+ } from '../core/runtime-context';
23
27
  import type {
24
28
  Logger,
25
29
  MatrixEvent,
26
30
  OracleIdentity,
27
31
  RoomStateSnapshot,
32
+ UserKvSurface,
28
33
  } from '../plugin-api/types';
29
34
  import type { MatrixGatewayObject } from './contracts';
30
35
  import { type WorkersUcanService } from './ucan-service';
@@ -127,6 +132,44 @@ export function createBlobStore(
127
132
  };
128
133
  }
129
134
 
135
+ /** Keys one blob sweep call reads at most (one `storage.list` page). */
136
+ export const BLOB_SWEEP_PAGE = 128;
137
+
138
+ /**
139
+ * Delete expired blobs (a plugin may store one and never read it back; a
140
+ * read deletes an expired blob, nothing else does). Bounded: one call reads
141
+ * at most `limit` entries in key order after `startAfter`, deletes the
142
+ * expired ones, and returns where the next call continues (`next`), or
143
+ * `null` once the end of the `blob:` keys was reached.
144
+ */
145
+ export async function sweepExpiredBlobs(
146
+ storage: Pick<DurableObjectStorage, 'list' | 'delete'>,
147
+ options: { startAfter?: string; limit?: number; now?: number } = {},
148
+ ): Promise<{ deleted: number; next: string | null }> {
149
+ const limit = Math.max(1, Math.min(options.limit ?? BLOB_SWEEP_PAGE, 128));
150
+ const now = options.now ?? Date.now();
151
+ const page = await storage.list<{ expiresAt?: unknown }>({
152
+ prefix: 'blob:',
153
+ limit,
154
+ ...(options.startAfter !== undefined
155
+ ? { startAfter: options.startAfter }
156
+ : {}),
157
+ });
158
+ const expired: string[] = [];
159
+ let last: string | null = null;
160
+ for (const [key, entry] of page) {
161
+ last = key;
162
+ const expiresAt = entry?.expiresAt;
163
+ if (typeof expiresAt !== 'number' || expiresAt <= now) expired.push(key);
164
+ }
165
+ // `delete` takes at most 128 keys, the page size cap.
166
+ if (expired.length > 0) await storage.delete(expired);
167
+ return {
168
+ deleted: expired.length,
169
+ next: page.size < limit ? null : last,
170
+ };
171
+ }
172
+
130
173
  export function createMatrixAdapter(
131
174
  gateway: DurableObjectStub<MatrixGatewayObject>,
132
175
  ): AmbientServices['matrix'] {
@@ -183,25 +226,12 @@ export function createMatrixAdapter(
183
226
  };
184
227
  }
185
228
 
186
- function abilityCovers(granted: string, required: string): boolean {
187
- if (granted === '*' || granted === required) return true;
188
- if (granted.endsWith('/*')) {
189
- const ns = granted.slice(0, -2);
190
- return required === ns || required.startsWith(`${ns}/`);
191
- }
192
- return false;
193
- }
194
-
195
229
  export function createUcanAdapter(
196
230
  service: WorkersUcanService,
197
231
  delegationFor: (userDid: string) => { raw?: string } | undefined,
198
232
  ): AmbientServices['ucan'] {
199
233
  const has = (delegation: DelegationLike, resource: string, action: string) =>
200
- (delegation?.capabilities ?? []).some(
201
- (c) =>
202
- (c.resource === resource || c.resource.startsWith(resource)) &&
203
- abilityCovers(c.action, action),
204
- );
234
+ delegationHasCapability(delegation, resource, action);
205
235
  return {
206
236
  hasCapability: has,
207
237
  requireCapability: (delegation, resource, action) => {
@@ -239,6 +269,8 @@ export interface CreateAmbientInput {
239
269
  /** Host task scheduler surface for this user, when tasks are enabled. */
240
270
  tasks?: import('../plugin-api/types').OracleTasksSurface;
241
271
  preferences?: import('../plugin-api/types').UserPreferencesSurface;
272
+ /** `ctx.kv` over the user's working copy (`SqliteUserKv`). */
273
+ kv?: UserKvSurface;
242
274
  /** Host bridge to the user's browser (realtime channel), when terminated here. */
243
275
  frontend?: import('../plugin-api/types').FrontendCallSurface;
244
276
  /** Per-turn cleanup registry (see `RuntimeContext.onTurnEnd`). */
@@ -249,6 +281,8 @@ export interface CreateAmbientInput {
249
281
  identity: OracleIdentity;
250
282
  availablePlugins: ReadonlySet<string>;
251
283
  llm: LlmAdapter;
284
+ /** The core's Decision evaluator (`RuntimeCore.decisions`), behind `ctx.decisions`. */
285
+ decisions?: DecisionEvaluator;
252
286
  logger: Logger;
253
287
  storage: DurableObjectStorage;
254
288
  gateway: DurableObjectStub<MatrixGatewayObject>;
@@ -269,6 +303,7 @@ export function createAmbientServices(
269
303
  return {
270
304
  ...(input.tasks ? { tasks: input.tasks } : {}),
271
305
  ...(input.preferences ? { preferences: input.preferences } : {}),
306
+ ...(input.kv ? { kv: input.kv } : {}),
272
307
  ...(input.frontend ? { frontend: input.frontend } : {}),
273
308
  ...(input.onTurnEnd ? { onTurnEnd: input.onTurnEnd } : {}),
274
309
  ...(input.background ? { background: input.background } : {}),
@@ -276,6 +311,7 @@ export function createAmbientServices(
276
311
  identity: input.identity,
277
312
  availablePlugins: input.availablePlugins,
278
313
  llm: input.llm,
314
+ ...(input.decisions ? { decisions: input.decisions } : {}),
279
315
  logger: input.logger,
280
316
  emit: input.events,
281
317
  blobStore: createBlobStore(input.storage),