@ixo/oracle-runtime-workers 0.2.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 (215) hide show
  1. package/README.md +110 -0
  2. package/package.json +77 -0
  3. package/src/attachments/classify.ts +114 -0
  4. package/src/attachments/content-blocks.ts +51 -0
  5. package/src/attachments/download.ts +235 -0
  6. package/src/attachments/extract.ts +241 -0
  7. package/src/attachments/index.ts +84 -0
  8. package/src/attachments/magic.ts +133 -0
  9. package/src/attachments/pipeline.ts +409 -0
  10. package/src/attachments/retention.ts +205 -0
  11. package/src/attachments/route.ts +41 -0
  12. package/src/attachments/sandbox-archive.ts +90 -0
  13. package/src/attachments/sanitize.ts +35 -0
  14. package/src/attachments/types.ts +86 -0
  15. package/src/attachments/view.ts +107 -0
  16. package/src/core/env.ts +200 -0
  17. package/src/core/index.ts +513 -0
  18. package/src/core/llm.ts +842 -0
  19. package/src/core/main-agent-types.ts +123 -0
  20. package/src/core/main-agent.ts +399 -0
  21. package/src/core/manifest.ts +365 -0
  22. package/src/core/meta-tools.ts +232 -0
  23. package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
  24. package/src/core/middlewares/capability-gate.ts +76 -0
  25. package/src/core/middlewares/index.ts +18 -0
  26. package/src/core/middlewares/page-context.ts +98 -0
  27. package/src/core/middlewares/safety-guardrail.ts +117 -0
  28. package/src/core/middlewares/summarization.ts +99 -0
  29. package/src/core/middlewares/tool-repetition-guard.ts +125 -0
  30. package/src/core/middlewares/tool-validation.ts +83 -0
  31. package/src/core/middlewares/work-status.ts +57 -0
  32. package/src/core/openrouter-pricing.ts +124 -0
  33. package/src/core/plugin-loader.ts +192 -0
  34. package/src/core/plugins/index.ts +6 -0
  35. package/src/core/plugins/skills/index.ts +8 -0
  36. package/src/core/plugins/skills/skills-tools.ts +356 -0
  37. package/src/core/plugins/skills/skills.plugin.ts +113 -0
  38. package/src/core/plugins/weather/index.ts +8 -0
  39. package/src/core/plugins/weather/weather-client.ts +153 -0
  40. package/src/core/plugins/weather/weather-middleware.ts +27 -0
  41. package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
  42. package/src/core/plugins/weather/weather-tools.ts +103 -0
  43. package/src/core/plugins/weather/weather.plugin.ts +227 -0
  44. package/src/core/prompt-composer.ts +469 -0
  45. package/src/core/registries.ts +642 -0
  46. package/src/core/runtime-context.ts +619 -0
  47. package/src/core/state.ts +108 -0
  48. package/src/core/sub-agent-fallback.ts +187 -0
  49. package/src/core/subagent-as-tool.ts +322 -0
  50. package/src/core/template.ts +245 -0
  51. package/src/core/utils.ts +246 -0
  52. package/src/core/wrap-plugin-tool.ts +100 -0
  53. package/src/do/ambient.ts +266 -0
  54. package/src/do/contracts.ts +562 -0
  55. package/src/do/debug-timers.ts +120 -0
  56. package/src/do/fresh-stub.ts +32 -0
  57. package/src/do/gateway-retry.ts +73 -0
  58. package/src/do/secrets-adapter.ts +32 -0
  59. package/src/do/size-alerts.ts +76 -0
  60. package/src/do/sse-stream.ts +472 -0
  61. package/src/do/transcript.ts +192 -0
  62. package/src/do/turn-body.ts +84 -0
  63. package/src/do/turn-metadata.ts +146 -0
  64. package/src/do/ucan-service.ts +339 -0
  65. package/src/do/user-oracle-do.ts +3035 -0
  66. package/src/gateway-worker.ts +82 -0
  67. package/src/index.ts +237 -0
  68. package/src/llm/byo-adapter.ts +59 -0
  69. package/src/llm/byo-catalog.ts +520 -0
  70. package/src/llm/byo-client.ts +280 -0
  71. package/src/llm/byo-routes.ts +255 -0
  72. package/src/llm/byo-service.ts +836 -0
  73. package/src/llm/chatgpt-oauth.ts +430 -0
  74. package/src/llm/provider-error.ts +283 -0
  75. package/src/matrix/gateway-do.ts +918 -0
  76. package/src/matrix/ingest.ts +209 -0
  77. package/src/matrix/replay-format.ts +34 -0
  78. package/src/matrix/reply-chain.ts +109 -0
  79. package/src/matrix/room-state-codec.ts +115 -0
  80. package/src/matrix/user-homeserver.ts +106 -0
  81. package/src/matrix/work-status.ts +294 -0
  82. package/src/memory/session-history-indexer.ts +328 -0
  83. package/src/owner-store/ixo-vfs-store.ts +570 -0
  84. package/src/owner-store/matrix-media-store.ts +136 -0
  85. package/src/owner-store/migrating-store.ts +185 -0
  86. package/src/owner-store/owner-copy-errors.ts +175 -0
  87. package/src/owner-store/retry.ts +45 -0
  88. package/src/owner-store/tus-upload.ts +333 -0
  89. package/src/owner-store/types.ts +0 -0
  90. package/src/plugin-api/define-plugin.ts +45 -0
  91. package/src/plugin-api/index.ts +56 -0
  92. package/src/plugin-api/oracle-plugin.ts +145 -0
  93. package/src/plugin-api/tool-helper.ts +82 -0
  94. package/src/plugin-api/types.ts +816 -0
  95. package/src/plugin-api/ucan-errors.ts +16 -0
  96. package/src/plugins/agui/agui-agent.ts +115 -0
  97. package/src/plugins/agui/agui.plugin.ts +148 -0
  98. package/src/plugins/agui/index.ts +2 -0
  99. package/src/plugins/attachments/attachments.plugin.ts +54 -0
  100. package/src/plugins/attachments/index.ts +6 -0
  101. package/src/plugins/attachments/view-attachment-tool.ts +91 -0
  102. package/src/plugins/base64.ts +26 -0
  103. package/src/plugins/composio/composio-tools.ts +365 -0
  104. package/src/plugins/composio/composio-ucan.ts +43 -0
  105. package/src/plugins/composio/composio.plugin.ts +214 -0
  106. package/src/plugins/composio/index.ts +15 -0
  107. package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
  108. package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
  109. package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
  110. package/src/plugins/domain-indexer/index.ts +3 -0
  111. package/src/plugins/editor/blocknote-bridge.ts +120 -0
  112. package/src/plugins/editor/content-session.ts +331 -0
  113. package/src/plugins/editor/content-tools.ts +686 -0
  114. package/src/plugins/editor/document-model.ts +0 -0
  115. package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
  116. package/src/plugins/editor/editor-agent.ts +34 -0
  117. package/src/plugins/editor/editor-config.ts +64 -0
  118. package/src/plugins/editor/editor-mx.ts +128 -0
  119. package/src/plugins/editor/editor.plugin.ts +143 -0
  120. package/src/plugins/editor/failures.ts +154 -0
  121. package/src/plugins/editor/index.ts +31 -0
  122. package/src/plugins/editor/jsdom-shim.ts +55 -0
  123. package/src/plugins/editor/prompts.ts +59 -0
  124. package/src/plugins/editor/prop-policy.ts +147 -0
  125. package/src/plugins/editor/provider.ts +386 -0
  126. package/src/plugins/editor/room-membership.ts +95 -0
  127. package/src/plugins/editor/standalone-editor-tool.ts +200 -0
  128. package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
  129. package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
  130. package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
  131. package/src/plugins/firecrawl/index.ts +15 -0
  132. package/src/plugins/flows/action-metadata.ts +3505 -0
  133. package/src/plugins/flows/actions.ts +190 -0
  134. package/src/plugins/flows/edit.ts +314 -0
  135. package/src/plugins/flows/errors.ts +38 -0
  136. package/src/plugins/flows/explain.ts +43 -0
  137. package/src/plugins/flows/flow-doc.ts +111 -0
  138. package/src/plugins/flows/flows.plugin.ts +129 -0
  139. package/src/plugins/flows/forms.ts +330 -0
  140. package/src/plugins/flows/index.ts +2 -0
  141. package/src/plugins/flows/linkage.ts +147 -0
  142. package/src/plugins/flows/port-types.ts +53 -0
  143. package/src/plugins/flows/prompts.ts +65 -0
  144. package/src/plugins/flows/read.ts +392 -0
  145. package/src/plugins/flows/references.ts +66 -0
  146. package/src/plugins/flows/test-support.ts +165 -0
  147. package/src/plugins/flows/tools/authoring.ts +516 -0
  148. package/src/plugins/flows/tools/discovery.ts +119 -0
  149. package/src/plugins/flows/tools/forms.ts +142 -0
  150. package/src/plugins/flows/tools/inspect.ts +165 -0
  151. package/src/plugins/flows/tools/linkage.ts +117 -0
  152. package/src/plugins/flows/tools/settings.ts +184 -0
  153. package/src/plugins/flows/translator.ts +328 -0
  154. package/src/plugins/flows/types.ts +210 -0
  155. package/src/plugins/flows/ydoc-helpers.ts +543 -0
  156. package/src/plugins/index.ts +70 -0
  157. package/src/plugins/mcp-call-timeout.ts +38 -0
  158. package/src/plugins/mcp-tool-adapter.ts +87 -0
  159. package/src/plugins/memory/index.ts +17 -0
  160. package/src/plugins/memory/memory-tools.ts +463 -0
  161. package/src/plugins/memory/memory-ucan.ts +51 -0
  162. package/src/plugins/memory/memory.plugin.ts +122 -0
  163. package/src/plugins/memory/types.ts +147 -0
  164. package/src/plugins/portal/action-log.ts +34 -0
  165. package/src/plugins/portal/index.ts +2 -0
  166. package/src/plugins/portal/portal.plugin.ts +203 -0
  167. package/src/plugins/sandbox/index.ts +34 -0
  168. package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
  169. package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
  170. package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
  171. package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
  172. package/src/plugins/tasks/index.ts +17 -0
  173. package/src/plugins/tasks/manifest.ts +81 -0
  174. package/src/plugins/tasks/middleware.ts +219 -0
  175. package/src/plugins/tasks/tasks-tools.ts +414 -0
  176. package/src/plugins/tasks/tasks.plugin.ts +74 -0
  177. package/src/plugins/ucan-failure.ts +133 -0
  178. package/src/plugins/user-preferences/index.ts +22 -0
  179. package/src/plugins/user-preferences/schema.ts +31 -0
  180. package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
  181. package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
  182. package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
  183. package/src/plugins/vfs/index.ts +40 -0
  184. package/src/plugins/vfs/vfs-auth.ts +56 -0
  185. package/src/plugins/vfs/vfs-client.ts +655 -0
  186. package/src/plugins/vfs/vfs-content.ts +181 -0
  187. package/src/plugins/vfs/vfs-errors.ts +163 -0
  188. package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
  189. package/src/plugins/vfs/vfs-tools.ts +635 -0
  190. package/src/plugins/vfs/vfs.plugin.ts +201 -0
  191. package/src/realtime/frontend-call-registry.ts +163 -0
  192. package/src/realtime/realtime-endpoint.ts +619 -0
  193. package/src/realtime/session-socket-hub.ts +249 -0
  194. package/src/realtime/socket-io-codec.ts +187 -0
  195. package/src/secrets/jwe.ts +74 -0
  196. package/src/secrets/pin-cipher.ts +43 -0
  197. package/src/secrets/secrets-service.ts +319 -0
  198. package/src/shell/app.ts +564 -0
  199. package/src/shell/auth.ts +290 -0
  200. package/src/sqlite/blob-codec.ts +100 -0
  201. package/src/sqlite/blob-compactor.ts +146 -0
  202. package/src/sqlite/cache-config.ts +45 -0
  203. package/src/sqlite/database.ts +643 -0
  204. package/src/sqlite/do-vfs.ts +1485 -0
  205. package/src/sqlite/index.ts +61 -0
  206. package/src/sqlite/serialization.ts +370 -0
  207. package/src/sqlite/sessions-store.ts +311 -0
  208. package/src/sqlite/sqlite-saver.ts +919 -0
  209. package/src/sqlite/vacuum-policy.ts +77 -0
  210. package/src/sqlite/wa-sqlite-loader.ts +123 -0
  211. package/src/sqlite/wasm.d.ts +10 -0
  212. package/src/tasks/schedule.ts +174 -0
  213. package/src/tasks/scheduler.ts +753 -0
  214. package/src/tasks/spec.ts +125 -0
  215. package/src/tasks/store.ts +351 -0
@@ -0,0 +1,294 @@
1
+ /**
2
+ * The per-turn `work_status` liveness card — the port of the Node runtime's
3
+ * `matrix/work-status-producer.ts` + `matrix/oracle-component-event.ts`.
4
+ *
5
+ * A Matrix turn posts ONE `ixo.oracle.component` event (`component:
6
+ * 'work_status'`) into the user's thread when it starts, then edits it in
7
+ * place (`m.replace`) as the agent moves through phases:
8
+ * routing → working (one "Step n · <action>" per model/tool call) →
9
+ * delivering → done, or superseded when a newer message restarts the turn.
10
+ * Clients that render oracle components show a live card; everything else
11
+ * sees the plain `body` fallback ("Status: Working…").
12
+ *
13
+ * Frames are coalesced per turn: anything staged behind an in-flight post is
14
+ * stale by the time the homeserver would accept it, so the newest frame
15
+ * replaces it — which also guarantees a terminal phase is never dropped.
16
+ * Fire-and-forget throughout: a failed post is logged, never thrown.
17
+ */
18
+
19
+ export const ORACLE_COMPONENT_EVENT_TYPE = 'ixo.oracle.component';
20
+
21
+ export type OracleComponentName =
22
+ | 'list_services'
23
+ | 'show_contract'
24
+ | 'work_status'
25
+ | 'work_delivered'
26
+ | 'payment_update';
27
+
28
+ export interface OracleComponentThreadRelation {
29
+ rel_type: 'm.thread';
30
+ event_id: string;
31
+ }
32
+
33
+ export interface OracleComponentReplaceRelation {
34
+ rel_type: 'm.replace';
35
+ event_id: string;
36
+ }
37
+
38
+ export interface OracleComponentEnvelope {
39
+ component: OracleComponentName;
40
+ props: Record<string, unknown>;
41
+ body: string;
42
+ sessionId: string;
43
+ requestId: string;
44
+ toolCallId?: string;
45
+ }
46
+
47
+ export interface OracleComponentEventContent extends OracleComponentEnvelope {
48
+ 'm.new_content'?: OracleComponentEnvelope;
49
+ 'm.relates_to'?:
50
+ | OracleComponentThreadRelation
51
+ | OracleComponentReplaceRelation;
52
+ }
53
+
54
+ export interface OracleComponentInput {
55
+ component: OracleComponentName;
56
+ props: Record<string, unknown>;
57
+ body: string;
58
+ sessionId: string;
59
+ requestId: string;
60
+ toolCallId?: string;
61
+ threadId?: string;
62
+ replacesEventId?: string;
63
+ }
64
+
65
+ /** Byte-compatible with the Node runtime's `buildOracleComponentContent`. */
66
+ export function buildOracleComponentContent(
67
+ input: OracleComponentInput,
68
+ ): OracleComponentEventContent {
69
+ const { threadId, replacesEventId, toolCallId, ...rest } = input;
70
+ const envelope: OracleComponentEnvelope = {
71
+ ...rest,
72
+ ...(toolCallId !== undefined && { toolCallId }),
73
+ };
74
+ const relation: OracleComponentEventContent['m.relates_to'] | undefined =
75
+ replacesEventId !== undefined
76
+ ? { rel_type: 'm.replace', event_id: replacesEventId }
77
+ : threadId !== undefined
78
+ ? { rel_type: 'm.thread', event_id: threadId }
79
+ : undefined;
80
+ return {
81
+ ...envelope,
82
+ ...(replacesEventId !== undefined && { 'm.new_content': envelope }),
83
+ ...(relation !== undefined && { 'm.relates_to': relation }),
84
+ };
85
+ }
86
+
87
+ export type WorkStatusPhase =
88
+ | 'routing'
89
+ | 'working'
90
+ | 'delivering'
91
+ | 'done'
92
+ | 'superseded';
93
+
94
+ const ANCHOR_RETRY_DELAY_MS = 1000;
95
+
96
+ const ANCHOR_PHASES: ReadonlySet<WorkStatusPhase> = new Set([
97
+ 'routing',
98
+ 'working',
99
+ ]);
100
+
101
+ const DEFAULT_LABELS: Record<WorkStatusPhase, string> = {
102
+ routing: 'Routing your request…',
103
+ working: 'Working…',
104
+ delivering: 'Sending your reply…',
105
+ done: 'Done',
106
+ superseded: 'Got your new message — restarting',
107
+ };
108
+
109
+ export interface WorkStatusTurn {
110
+ requestId: string;
111
+ roomId: string;
112
+ /** Thread the card is posted into (the user's message thread root). */
113
+ threadId: string;
114
+ sessionId: string;
115
+ /** The user's message event the card reports on. */
116
+ forEventId: string;
117
+ }
118
+
119
+ interface StatusFrame {
120
+ phase: WorkStatusPhase;
121
+ label?: string;
122
+ }
123
+
124
+ interface TurnEntry extends WorkStatusTurn {
125
+ anchorEventId?: string;
126
+ steps: number;
127
+ pending?: StatusFrame;
128
+ draining: boolean;
129
+ }
130
+
131
+ export interface WorkStatusProducerDeps {
132
+ /** Posts a room event and resolves its event id (the gateway's `sendEvent`). */
133
+ postEvent: (
134
+ roomId: string,
135
+ eventType: string,
136
+ content: object,
137
+ ) => Promise<string>;
138
+ clock?: () => Date;
139
+ logger?: { warn: (message: string) => void };
140
+ /** Delay before the single retry of a failed anchor post (tests: 0). */
141
+ anchorRetryDelayMs?: number;
142
+ }
143
+
144
+ export class WorkStatusProducer {
145
+ private readonly turns = new Map<string, TurnEntry>();
146
+
147
+ private readonly postEvent: WorkStatusProducerDeps['postEvent'];
148
+
149
+ private readonly clock: () => Date;
150
+
151
+ private readonly logger: { warn: (message: string) => void };
152
+
153
+ private readonly anchorRetryDelayMs: number;
154
+
155
+ constructor(deps: WorkStatusProducerDeps) {
156
+ this.postEvent = deps.postEvent;
157
+ this.clock = deps.clock ?? (() => new Date());
158
+ this.logger = deps.logger ?? { warn: () => undefined };
159
+ this.anchorRetryDelayMs = deps.anchorRetryDelayMs ?? ANCHOR_RETRY_DELAY_MS;
160
+ }
161
+
162
+ /** Register a turn so later `emit`/`step`/`finish` calls have a card to drive. */
163
+ beginTurn(turn: WorkStatusTurn): void {
164
+ this.turns.set(turn.requestId, { ...turn, steps: 0, draining: false });
165
+ }
166
+
167
+ /**
168
+ * Post a status phase for a registered turn. The first anchor-capable phase
169
+ * (`routing`/`working`) creates the card; every later phase updates it via
170
+ * `m.replace`. Unregistered requestIds — and closing phases with no card to
171
+ * close — are no-ops.
172
+ */
173
+ emit(requestId: string, phase: WorkStatusPhase, label?: string): void {
174
+ const entry = this.turns.get(requestId);
175
+ if (!entry) return;
176
+ this.enqueue(entry, phase, label);
177
+ }
178
+
179
+ /**
180
+ * Post the next agent step: bumps the turn's counter and emits `working`
181
+ * with a `Step n · <action>` label. The counter can outrun the numbers the
182
+ * room actually sees (coalesced frames are skipped, not renumbered) so the
183
+ * line always moves forward.
184
+ */
185
+ step(requestId: string, action: string): void {
186
+ const entry = this.turns.get(requestId);
187
+ if (!entry) return;
188
+ entry.steps += 1;
189
+ this.enqueue(entry, 'working', `Step ${entry.steps} · ${action}`);
190
+ }
191
+
192
+ /**
193
+ * Post a final phase (`done`/`superseded`) and unregister the turn. Later
194
+ * emissions for this requestId become no-ops immediately.
195
+ */
196
+ finish(requestId: string, phase: WorkStatusPhase, label?: string): void {
197
+ const entry = this.turns.get(requestId);
198
+ if (!entry) return;
199
+ this.turns.delete(requestId);
200
+ this.enqueue(entry, phase, label);
201
+ }
202
+
203
+ private async postWithRetry(
204
+ roomId: string,
205
+ content: object,
206
+ retryOnce: boolean,
207
+ ): Promise<string> {
208
+ try {
209
+ return await this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
210
+ } catch (error) {
211
+ if (!retryOnce) throw error;
212
+ await new Promise((resolve) =>
213
+ setTimeout(resolve, this.anchorRetryDelayMs),
214
+ );
215
+ return this.postEvent(roomId, ORACLE_COMPONENT_EVENT_TYPE, content);
216
+ }
217
+ }
218
+
219
+ /** Unregister a turn without posting (error paths, turns with no card). */
220
+ endTurn(requestId: string): void {
221
+ this.turns.delete(requestId);
222
+ }
223
+
224
+ /** Whether a turn is registered (tests + the ingest supersede path). */
225
+ has(requestId: string): boolean {
226
+ return this.turns.has(requestId);
227
+ }
228
+
229
+ private enqueue(
230
+ entry: TurnEntry,
231
+ phase: WorkStatusPhase,
232
+ label?: string,
233
+ ): void {
234
+ entry.pending = { phase, label };
235
+ if (entry.draining) return;
236
+ entry.draining = true;
237
+ void this.drain(entry);
238
+ }
239
+
240
+ private async drain(entry: TurnEntry): Promise<void> {
241
+ try {
242
+ while (entry.pending) {
243
+ const frame = entry.pending;
244
+ entry.pending = undefined;
245
+ await this.post(entry, frame);
246
+ }
247
+ } finally {
248
+ entry.draining = false;
249
+ }
250
+ }
251
+
252
+ private async post(entry: TurnEntry, frame: StatusFrame): Promise<void> {
253
+ const { phase, label } = frame;
254
+ if (!entry.anchorEventId && !ANCHOR_PHASES.has(phase)) return;
255
+ const resolvedLabel = label ?? DEFAULT_LABELS[phase];
256
+ try {
257
+ const content = buildOracleComponentContent({
258
+ component: 'work_status',
259
+ props: {
260
+ forEventId: entry.forEventId,
261
+ phase,
262
+ label: resolvedLabel,
263
+ updatedAt: this.clock().toISOString(),
264
+ },
265
+ body: `Status: ${resolvedLabel}`,
266
+ sessionId: entry.sessionId,
267
+ requestId: entry.requestId,
268
+ ...(entry.anchorEventId
269
+ ? { replacesEventId: entry.anchorEventId }
270
+ : { threadId: entry.threadId }),
271
+ });
272
+ const eventId = await this.postWithRetry(
273
+ entry.roomId,
274
+ content,
275
+ // The anchor decides whether the turn gets a card at all: retry it.
276
+ !entry.anchorEventId,
277
+ );
278
+ if (!entry.anchorEventId) entry.anchorEventId = eventId;
279
+ } catch (error) {
280
+ this.logger.warn(
281
+ `work_status post failed (room=${entry.roomId}, phase=${phase}): ${
282
+ error instanceof Error ? error.message : String(error)
283
+ }`,
284
+ );
285
+ }
286
+ }
287
+ }
288
+
289
+ /** `generate_tax_report` → `Generate tax report…`. */
290
+ export function humanizeToolLabel(toolName: string): string {
291
+ const words = toolName.replace(/[_-]+/g, ' ').trim();
292
+ if (words.length === 0) return DEFAULT_LABELS.working;
293
+ return `${words.charAt(0).toUpperCase()}${words.slice(1)}…`;
294
+ }
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Session-history → memory-engine indexing — the Workers port of the Node
3
+ * runtime's `SessionHistoryProcessor` (`modules/sessions/session-history-
4
+ * processor.service.ts`).
5
+ *
6
+ * Policy (identical to Node):
7
+ * - Runs in the background when a session is CREATED (indexes the most
8
+ * recent previous session) and when a session is DELETED (indexes it
9
+ * before it goes). The request never waits for, or fails on, indexing.
10
+ * - Only messages after the session's `lastProcessedCount` are sent, then
11
+ * the count is advanced — a session is never indexed twice.
12
+ * - Speakers get real identities (the user's preferred name / the oracle's
13
+ * preferred or configured name) so Graphiti's extractor does not pin
14
+ * facts to literal "user"/"assistant" nodes. Tool replies fold into the
15
+ * assistant role (the engine has no tool role).
16
+ * - Auth is the oracle's two-hop UCAN invocation minted from the user's
17
+ * delegation (`ixo:memory` / `memory/*`) — no delegation, no indexing.
18
+ * - Endpoint: `POST <MEMORY_ENGINE_URL>/messages` with `x-room-id`, as
19
+ * `@ixo/common`'s `MemoryEngineService.processConversationHistory`.
20
+ * - A per-session lock prevents overlapping runs; a failed upload is
21
+ * retried 3× with a 10 s pause (Node's numbers).
22
+ *
23
+ * Not ported: Node also asks Matrix for the user's display name when no
24
+ * preferred name is set; here the fallback is the literal "Me" straight away.
25
+ */
26
+ import type { Logger } from '../plugin-api/types';
27
+
28
+ export interface HistoryMessage {
29
+ /** LangChain message type: `human` | `ai` | `tool` | `system`. */
30
+ type: string;
31
+ content: string;
32
+ }
33
+
34
+ export interface MemoryEngineMessage {
35
+ content: string;
36
+ role_type: 'user' | 'assistant' | 'system';
37
+ role?: string;
38
+ name?: string;
39
+ source_description?: string;
40
+ }
41
+
42
+ export interface IndexableSession {
43
+ title?: string;
44
+ roomId?: string;
45
+ lastProcessedCount?: number;
46
+ }
47
+
48
+ export interface SpeakerLabels {
49
+ user: string;
50
+ oracle: string;
51
+ }
52
+
53
+ export interface SessionHistoryIndexerDeps {
54
+ /** `MEMORY_ENGINE_URL`; undefined disables indexing (memory plugin absent). */
55
+ memoryEngineUrl: string | undefined;
56
+ getSession(sessionId: string): Promise<IndexableSession | undefined>;
57
+ /** The session transcript, summarisation bookkeeping already removed. */
58
+ listMessages(sessionId: string): Promise<HistoryMessage[]>;
59
+ setProcessedCount(sessionId: string, count: number): Promise<void>;
60
+ /** The user↔oracle room, for sessions whose row carries none. */
61
+ resolveUserRoom(): Promise<{ roomId: string } | null>;
62
+ ucan: {
63
+ hasSigningKey(): boolean;
64
+ resolveServiceDid(serviceUrl: string): Promise<string | null>;
65
+ mintInvocation(
66
+ target: { did: string; capability: string },
67
+ opts?: { can?: string },
68
+ ): Promise<string>;
69
+ };
70
+ speakerLabels(roomId: string): Promise<SpeakerLabels>;
71
+ logger: Logger;
72
+ fetchImpl?: typeof fetch;
73
+ /** Test hooks. */
74
+ sleep?: (ms: number) => Promise<void>;
75
+ maxRetries?: number;
76
+ retryDelayMs?: number;
77
+ /** Hard timeout for the memory-engine request (default 20 s). */
78
+ requestTimeoutMs?: number;
79
+ }
80
+
81
+ export type IndexOutcome = 'processed' | 'skipped' | 'failed';
82
+
83
+ /**
84
+ * Bounded background work: the indexer runs under `ctx.waitUntil`, so the
85
+ * whole retry envelope keeps the user object resident (and un-hibernatable
86
+ * with sockets attached). Two attempts, a short pause, and a hard timeout on
87
+ * the memory-engine request keep that under a minute; a failed session is
88
+ * simply retried on the next session create (the watermark did not move).
89
+ */
90
+ const DEFAULT_MAX_RETRIES = 2;
91
+ const DEFAULT_RETRY_DELAY_MS = 3_000;
92
+ const DEFAULT_REQUEST_TIMEOUT_MS = 20_000;
93
+
94
+ /** Node's `transformMessagesToMemoryEngineFormat`, verbatim in mapping. */
95
+ export function transformMessagesToMemoryEngineFormat(
96
+ messages: readonly HistoryMessage[],
97
+ sessionTitle: string,
98
+ userSpeakerLabel: string,
99
+ oracleSpeakerLabel: string,
100
+ ): MemoryEngineMessage[] {
101
+ return messages.map((message) => {
102
+ let role_type: MemoryEngineMessage['role_type'];
103
+ let label: string;
104
+ switch (message.type) {
105
+ case 'human':
106
+ role_type = 'user';
107
+ label = userSpeakerLabel;
108
+ break;
109
+ case 'ai':
110
+ role_type = 'assistant';
111
+ label = oracleSpeakerLabel;
112
+ break;
113
+ case 'system':
114
+ role_type = 'system';
115
+ label = 'System';
116
+ break;
117
+ case 'tool':
118
+ // The engine has no tool role — tool replies are the oracle
119
+ // reporting back, so they share the oracle speaker label.
120
+ role_type = 'assistant';
121
+ label = oracleSpeakerLabel;
122
+ break;
123
+ default:
124
+ role_type = 'user';
125
+ label = userSpeakerLabel;
126
+ }
127
+ return {
128
+ content: message.content,
129
+ role_type,
130
+ role: label,
131
+ name: label,
132
+ source_description: `Chat Session: ${sessionTitle}`,
133
+ };
134
+ });
135
+ }
136
+
137
+ function describe(err: unknown): string {
138
+ return err instanceof Error ? err.message : String(err);
139
+ }
140
+
141
+ export class SessionHistoryIndexer {
142
+ private readonly inFlight = new Set<string>();
143
+
144
+ private readonly fetchImpl: typeof fetch;
145
+
146
+ private readonly sleep: (ms: number) => Promise<void>;
147
+
148
+ private readonly maxRetries: number;
149
+
150
+ private readonly retryDelayMs: number;
151
+
152
+ private readonly requestTimeoutMs: number;
153
+
154
+ constructor(private readonly deps: SessionHistoryIndexerDeps) {
155
+ this.fetchImpl = deps.fetchImpl ?? fetch.bind(globalThis);
156
+ this.sleep =
157
+ deps.sleep ??
158
+ ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
159
+ this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
160
+ this.retryDelayMs = deps.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
161
+ this.requestTimeoutMs = deps.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
162
+ }
163
+
164
+ /** Sessions currently being indexed (background work keeping the object busy). */
165
+ get inFlightCount(): number {
166
+ return this.inFlight.size;
167
+ }
168
+
169
+ /** Whether indexing is configured at all (memory engine URL present). */
170
+ get enabled(): boolean {
171
+ return typeof this.deps.memoryEngineUrl === 'string';
172
+ }
173
+
174
+ /**
175
+ * Index a session's unprocessed messages. Never throws: outcomes are
176
+ * `processed`, `skipped` (nothing to do / prerequisites missing) or
177
+ * `failed` (upload failed after retries). Concurrent calls for the same
178
+ * session collapse into one.
179
+ */
180
+ async process(sessionId: string): Promise<IndexOutcome> {
181
+ const { logger } = this.deps;
182
+ if (!this.enabled) {
183
+ logger.log(
184
+ `[history-indexer] MEMORY_ENGINE_URL not configured; not indexing session ${sessionId}`,
185
+ );
186
+ return 'skipped';
187
+ }
188
+ if (this.inFlight.has(sessionId)) {
189
+ logger.log(
190
+ `[history-indexer] session ${sessionId} is already being indexed, skipping`,
191
+ );
192
+ return 'skipped';
193
+ }
194
+ this.inFlight.add(sessionId);
195
+ try {
196
+ for (let attempt = 1; attempt <= this.maxRetries; attempt += 1) {
197
+ try {
198
+ const outcome = await this.processOnce(sessionId);
199
+ if (outcome === 'processed') {
200
+ logger.log(
201
+ `[history-indexer] indexed session ${sessionId} into the memory engine`,
202
+ );
203
+ }
204
+ return outcome;
205
+ } catch (err) {
206
+ logger.warn(
207
+ `[history-indexer] attempt ${attempt}/${this.maxRetries} failed for session ${sessionId}: ${describe(err)}`,
208
+ );
209
+ if (attempt === this.maxRetries) {
210
+ logger.error(
211
+ `[history-indexer] giving up on session ${sessionId} after ${this.maxRetries} attempts`,
212
+ );
213
+ return 'failed';
214
+ }
215
+ await this.sleep(this.retryDelayMs);
216
+ }
217
+ }
218
+ return 'failed';
219
+ } finally {
220
+ this.inFlight.delete(sessionId);
221
+ }
222
+ }
223
+
224
+ private async processOnce(sessionId: string): Promise<IndexOutcome> {
225
+ const { deps } = this;
226
+ const { logger } = deps;
227
+ const memoryEngineUrl = deps.memoryEngineUrl!.replace(/\/+$/, '');
228
+
229
+ const session = await deps.getSession(sessionId);
230
+ if (!session) {
231
+ logger.warn(`[history-indexer] session ${sessionId} not found, skipping`);
232
+ return 'skipped';
233
+ }
234
+
235
+ const roomId =
236
+ session.roomId ?? (await deps.resolveUserRoom())?.roomId ?? null;
237
+ if (!roomId) {
238
+ logger.warn(
239
+ `[history-indexer] no oracle room for session ${sessionId}, skipping`,
240
+ );
241
+ return 'skipped';
242
+ }
243
+
244
+ const messages = await deps.listMessages(sessionId);
245
+ if (messages.length === 0) {
246
+ logger.log(`[history-indexer] no messages in session ${sessionId}`);
247
+ return 'skipped';
248
+ }
249
+
250
+ const lastProcessedCount = session.lastProcessedCount ?? 0;
251
+ const newMessages = messages.slice(lastProcessedCount);
252
+ if (newMessages.length === 0) {
253
+ logger.log(
254
+ `[history-indexer] no new messages for session ${sessionId} (lastProcessedCount: ${lastProcessedCount})`,
255
+ );
256
+ return 'skipped';
257
+ }
258
+
259
+ const labels = await deps.speakerLabels(roomId);
260
+ const transformed = transformMessagesToMemoryEngineFormat(
261
+ // Tool-call-only AI turns carry no text; the engine has nothing to
262
+ // extract from them and rejects empty episodes.
263
+ newMessages.filter((m) => m.content.trim().length > 0),
264
+ session.title ?? '',
265
+ labels.user,
266
+ labels.oracle,
267
+ );
268
+
269
+ if (!deps.ucan.hasSigningKey()) {
270
+ logger.warn(
271
+ `[history-indexer] no UCAN signing key; skipping memory engine processing for session ${sessionId}`,
272
+ );
273
+ return 'skipped';
274
+ }
275
+
276
+ // Node: a failed mint is a warn + skip, not a retry — the user simply has
277
+ // not delegated `ixo:memory` to this oracle (yet).
278
+ let invocation: string | null = null;
279
+ try {
280
+ const memoryDid = await deps.ucan.resolveServiceDid(memoryEngineUrl);
281
+ if (memoryDid) {
282
+ invocation = await deps.ucan.mintInvocation(
283
+ { did: memoryDid, capability: 'ixo:memory' },
284
+ // Claim the ability the user's delegation actually grants. A `'*'`
285
+ // claim is satisfiable only by a `'*'` grant.
286
+ { can: 'memory/*' },
287
+ );
288
+ }
289
+ } catch (err) {
290
+ logger.warn(
291
+ `[history-indexer] could not mint a memory engine invocation: ${describe(err)}`,
292
+ );
293
+ }
294
+ if (!invocation) {
295
+ logger.warn(
296
+ `[history-indexer] no memory engine invocation for session ${sessionId}; skipping`,
297
+ );
298
+ return 'skipped';
299
+ }
300
+
301
+ if (transformed.length > 0) {
302
+ const res = await this.fetchImpl(`${memoryEngineUrl}/messages`, {
303
+ method: 'POST',
304
+ headers: {
305
+ Authorization: `Bearer ${invocation}`,
306
+ 'X-Auth-Type': 'ucan',
307
+ 'x-room-id': roomId,
308
+ 'Content-Type': 'application/json',
309
+ },
310
+ body: JSON.stringify({ messages: transformed }),
311
+ signal: AbortSignal.timeout(this.requestTimeoutMs),
312
+ });
313
+ if (!res.ok) {
314
+ const text = await res.text().catch(() => '');
315
+ throw new Error(
316
+ `memory engine rejected the conversation (${res.status}): ${text.slice(0, 200)}`,
317
+ );
318
+ }
319
+ }
320
+
321
+ const newCount = lastProcessedCount + newMessages.length;
322
+ await deps.setProcessedCount(sessionId, newCount);
323
+ logger.log(
324
+ `[history-indexer] processed ${newMessages.length} new messages for session ${sessionId} (lastProcessedCount → ${newCount})`,
325
+ );
326
+ return 'processed';
327
+ }
328
+ }