@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,3035 @@
1
+ /* eslint-disable no-console -- console IS the logger on Workers (Logs/observability). */
2
+ /**
3
+ * `UserOracleDO` — one Durable Object per (user DID, oracle).
4
+ *
5
+ * Owns, for exactly one user:
6
+ * - the SQLite **working copy** (LangGraph checkpoints, transcript,
7
+ * sessions) as 4 KB pages in this object's storage, opened through
8
+ * wa-sqlite — bounded by DO storage (10 GB), not isolate memory;
9
+ * - the **owner copy** sync: on first access the file is imported from the
10
+ * user's `OwnerStore` (their Matrix room today, their IXO VFS when
11
+ * `OWNER_STORE=vfs`); after every turn a debounced alarm exports it back.
12
+ * The object is a cache — the user's file is the truth;
13
+ * - the **agent turn** itself: `createMainAgent` from the runtime core with
14
+ * this user's checkpointer, streamed as SSE straight from the object.
15
+ *
16
+ * A Durable Object is single-threaded, which replaces the Node runtime's
17
+ * per-user ref-counting, busy timeouts and cron locks outright.
18
+ */
19
+ import { DurableObject } from 'cloudflare:workers';
20
+ import {
21
+ AIMessage,
22
+ HumanMessage,
23
+ type BaseMessage,
24
+ } from '@langchain/core/messages';
25
+ import {
26
+ applyAttachmentRetention,
27
+ ATTACHMENT_PAYLOAD_TURNS,
28
+ ATTACHMENT_PLACEHOLDER_PREFIX,
29
+ attachmentMetas,
30
+ attachmentRef,
31
+ INLINE_PAYLOAD_NEEDLE,
32
+ isAttachmentViewMessage,
33
+ offloadInlinePayloads,
34
+ parseAttachmentInputs,
35
+ prepareAttachments,
36
+ viewAttachment,
37
+ type AttachmentViewSurface,
38
+ type MatrixMediaSource,
39
+ type SandboxUploadConfig,
40
+ } from '../attachments';
41
+ import type { RuntimeCore } from '../core';
42
+ import {
43
+ createLlmAdapter,
44
+ getDefaultModelId,
45
+ getModelCapabilities,
46
+ isAllowedModel,
47
+ langsmithEnvFromWorkerEnv,
48
+ llmEnvFromWorkerEnv,
49
+ resolveLangsmithTracing,
50
+ type OpenRouterLlmAdapter,
51
+ } from '../core/llm';
52
+ import { createMainAgent } from '../core/main-agent';
53
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
54
+ import type { AmbientServices, LlmAdapter } from '../core/runtime-context';
55
+ import { chatGptBackendFromEnv } from '../llm/byo-client';
56
+ import { createByoLlmAdapter } from '../llm/byo-adapter';
57
+ import { isByoModelId } from '../llm/byo-catalog';
58
+ import { handleByoRequest } from '../llm/byo-routes';
59
+ import {
60
+ WorkersByoService,
61
+ type ByoStateStore,
62
+ type ByoTurnState,
63
+ } from '../llm/byo-service';
64
+ import { buildByoFallbackNotice } from '../llm/provider-error';
65
+ import { parseJwk } from '../secrets/jwe';
66
+ import { WorkersSecretsService } from '../secrets/secrets-service';
67
+ import { createSecretsAdapter } from './secrets-adapter';
68
+ import {
69
+ metadataBuildState,
70
+ metadataGraphInput,
71
+ parseTurnMetadata,
72
+ priorMetadataState,
73
+ } from './turn-metadata';
74
+ import {
75
+ RealtimeEndpoint,
76
+ type RealtimeStatus,
77
+ } from '../realtime/realtime-endpoint';
78
+ import { authenticate } from '../shell/auth';
79
+ import {
80
+ compactStep,
81
+ finishCompaction,
82
+ type CompactCursors,
83
+ } from '../sqlite/blob-compactor';
84
+ import { createHash } from 'node:crypto';
85
+ import {
86
+ cachePagesForBytes,
87
+ parseChunkCacheBytes,
88
+ } from '../sqlite/cache-config';
89
+ import { DoSqliteDatabase } from '../sqlite/database';
90
+ import {
91
+ shouldVacuum,
92
+ vacuumWanted,
93
+ VACUUM_IDLE_MS,
94
+ } from '../sqlite/vacuum-policy';
95
+ import { SqliteSaver } from '../sqlite/sqlite-saver';
96
+ import {
97
+ crossedGbThreshold,
98
+ postSlackAlert,
99
+ storageAlertText,
100
+ } from './size-alerts';
101
+ import {
102
+ SessionsStore,
103
+ UNTITLED_SESSION,
104
+ type SessionRecord,
105
+ } from '../sqlite/sessions-store';
106
+ import { MatrixMediaOwnerStore } from '../owner-store/matrix-media-store';
107
+ import { MigratingOwnerStore } from '../owner-store/migrating-store';
108
+ import type { OwnerCopy } from '../owner-store/types';
109
+ import {
110
+ isRetryableOwnerCopyError,
111
+ OWNER_COPY_LOAD_RETRY_DELAYS_MS,
112
+ OwnerCopyUnavailableError,
113
+ parseOwnerCopyFailure,
114
+ toRpcError,
115
+ } from '../owner-store/owner-copy-errors';
116
+ import { withRetry } from '../owner-store/retry';
117
+ import {
118
+ installTimerTracker,
119
+ pendingTimers,
120
+ timerTrackerInstalled,
121
+ } from './debug-timers';
122
+ import {
123
+ IxoVfsOwnerStore,
124
+ UCAN_STORE_DEFAULT_URLS,
125
+ VFS_DEFAULT_BASE_URLS,
126
+ } from '../owner-store/ixo-vfs-store';
127
+ import type { OwnerStore } from '../owner-store/types';
128
+ import { createAmbientServices, SessionEventRouter } from './ambient';
129
+ import {
130
+ checkpointStorageKey,
131
+ type MatrixGatewayObject,
132
+ type OracleWorkerEnv,
133
+ type SessionSummary,
134
+ type StorageStatus,
135
+ type TurnIdentity,
136
+ type TurnRequest,
137
+ type TurnResult,
138
+ type MemorySchemaDebug,
139
+ } from './contracts';
140
+ import {
141
+ createTaskScheduler,
142
+ TASK_SESSION_PREFIX,
143
+ type TaskScheduler,
144
+ } from '../tasks/scheduler';
145
+ import { decodeRoomStateContent } from '../matrix/room-state-codec';
146
+ import { freshStub } from './fresh-stub';
147
+ import { WorkStatusProducer } from '../matrix/work-status';
148
+ import { fetchUserMatrixServerName } from '../matrix/user-homeserver';
149
+ import { createWorkStatusMiddleware } from '../core/middlewares/work-status';
150
+ import { UserPreferencesStore } from '../plugins/user-preferences/user-preferences-store';
151
+ import {
152
+ SessionHistoryIndexer,
153
+ type HistoryMessage,
154
+ type IndexableSession,
155
+ } from '../memory/session-history-indexer';
156
+ import { createSseTurnStream, formatSSE, SSE_HEADERS } from './sse-stream';
157
+ import { parseTurnBody, type TurnBody } from './turn-body';
158
+ import { formatReplay } from '../matrix/replay-format';
159
+ import { retryGateway } from './gateway-retry';
160
+ import {
161
+ fetchMemorySchemaDump,
162
+ getMemorySchemaDump,
163
+ } from '../plugins/memory/memory-tools';
164
+ import {
165
+ contentToText,
166
+ isSummarizationMessage,
167
+ transformTranscript,
168
+ } from './transcript';
169
+ import { WorkersUcanService } from './ucan-service';
170
+
171
+ const DB_FILE = 'oracle.db';
172
+ /**
173
+ * Owner-store flush debounce after a write: hourly. A flush streams the
174
+ * working copy to the user's VFS (constant memory, see `flushToOwnerStore`)
175
+ * and is skipped outright when nothing was written since the last upload,
176
+ * so the cost of a tick is small — but every upload is still a full-file
177
+ * transfer, and the Node runtime uploads on a cron of similar order. The
178
+ * idle path flushes before any eviction, so nothing waits longer than this
179
+ * plus one retry window.
180
+ */
181
+ const FLUSH_DEBOUNCE_MS = 24 * 60 * 60_000;
182
+ /** A failed flush is retried this soon (not at the next daily tick). */
183
+ const FLUSH_RETRY_DELAY_MS = 10 * 60_000;
184
+ /** From this many consecutive failed flushes on, the failure is logged at error level. */
185
+ const FLUSH_FAILURES_ERROR_THRESHOLD = 3;
186
+ /**
187
+ * Idle time after which the working copy is dropped from Durable Object
188
+ * storage (rebuilt from the VFS copy on the next touch). Five days: a
189
+ * wipe + re-import costs about as much as keeping the file for ~5 days
190
+ * (see the README's "Object lifetime and cost"), so wiping sooner costs
191
+ * more for anyone who comes back within that window.
192
+ */
193
+ const IDLE_EVICT_MS = 5 * 24 * 60 * 60 * 1000;
194
+ /** Set once the legacy Matrix copy has been removed (or found absent): no more lookups. */
195
+ const META_LEGACY_CLEARED = 'meta:legacyCleared';
196
+ const META_USER_DID = 'meta:userDid';
197
+ /** VFS write generation of the file at the last successful upload (see `DoVfs.writeGeneration`). */
198
+ const META_UPLOADED_GEN = 'meta:uploadedGen';
199
+ /**
200
+ * The next housekeeping deadline (flush, tasks, compaction, vacuum, idle
201
+ * eviction) the alarm was armed for. Lets a wake that is only due for a
202
+ * realtime heartbeat round skip booting the database.
203
+ */
204
+ const META_HOUSEKEEPING_AT = 'meta:housekeepingAt';
205
+ const META_MATRIX_USER_ID = 'meta:matrixUserId';
206
+ /** A fallback-built Matrix id (homeserver not resolvable) is retried after this long. */
207
+ const MATRIX_USER_ID_FALLBACK_TTL_MS = 60 * 60_000;
208
+
209
+ interface MatrixUserIdCache {
210
+ userDid: string;
211
+ matrixUserId: string;
212
+ /** True when the homeserver came from the DID document (never re-resolved). */
213
+ resolved: boolean;
214
+ at: number;
215
+ }
216
+ /** Consecutive flush failures (cleared on success). */
217
+ const META_FLUSH_FAILURES = 'meta:flushFailures';
218
+ const META_LAST_VACUUM = 'meta:lastVacuumAt';
219
+ /**
220
+ * Persisted twin of the in-memory `dirty` flag. The flag alone dies with an
221
+ * eviction, and evictions are routine — without the marker an alarm waking a
222
+ * cold object would see "nothing to flush" and the user's owner copy would go
223
+ * silently stale.
224
+ */
225
+ const META_DIRTY = 'meta:dirty';
226
+ const META_OWNER_ETAG = 'meta:ownerEtag';
227
+ const META_LAST_FLUSH = 'meta:lastFlushAt';
228
+ const META_LAST_CHECKSUM = 'meta:lastChecksum';
229
+ const META_LAST_ACCESS = 'meta:lastAccessAt';
230
+ /**
231
+ * The user's most recent delegation to this oracle, cached so header-less
232
+ * turns (Matrix ingress, tasks, clients that authenticate with the
233
+ * invocation alone) can still mint downstream invocations. The durable
234
+ * source is the `ucan_delegation` room state the client deposits via
235
+ * `POST /delegation` — the same place the Node runtime reads it from.
236
+ */
237
+ const META_DELEGATION = 'meta:delegation';
238
+ /** How long a "no deposited delegation" verdict is trusted before re-checking the room. */
239
+ const DELEGATION_MISS_TTL_MS = 60_000;
240
+ const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
241
+ /** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
242
+ const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
243
+ /** Highest whole-GB size watermark already alerted to Slack (0 = none). */
244
+ const META_SIZE_ALERT_GB = 'meta:sizeAlertGb';
245
+ /** Blob re-compression bookkeeping (see sqlite/blob-compactor.ts). */
246
+ const META_COMPACT_DONE = 'meta:compactDone';
247
+ const META_COMPACT_CURSORS = 'meta:compactCursors';
248
+ const META_COMPACT_SAVED = 'meta:compactSavedBytes';
249
+ /** Rows examined per compaction alarm tick. */
250
+ const COMPACT_BATCH = 300;
251
+ const NEW_CONVERSATION_TEXT = 'New Conversation Started';
252
+ /** Node's `SYNTHETIC_SESSION_PREFIX`: background-task sessions, no Matrix root event. */
253
+ const SYNTHETIC_SESSION_PREFIX = '$task-';
254
+
255
+ /**
256
+ * Host hooks for the per-turn agent build — the Workers form of the Node
257
+ * runtime's `opts.hooks.getRoomTitle` / `opts.hooks.safetyModel`. Both are
258
+ * resolved per turn against the user object's ambient services (a Worker has
259
+ * no process-wide model instance or Matrix client to capture at module
260
+ * scope). Absent hooks leave the corresponding middleware uninstalled, as on
261
+ * Node.
262
+ */
263
+ export interface OracleWorkerHooks {
264
+ /**
265
+ * Page title for the page-context middleware (`state.editorRoomId`).
266
+ * Return `undefined` when unknown; the block then shows the bare room id.
267
+ */
268
+ getRoomTitle?: (
269
+ roomId: string,
270
+ ambient: AmbientServices,
271
+ ) => Promise<string | undefined>;
272
+ /** Cheap classification model for the safety-guardrail middleware. */
273
+ safetyModel?: (ambient: AmbientServices) => BaseChatModel;
274
+ }
275
+
276
+ export interface UserOracleDOOptions {
277
+ core: (env: OracleWorkerEnv) => RuntimeCore;
278
+ hooks?: OracleWorkerHooks;
279
+ }
280
+
281
+ interface StoredDelegation {
282
+ raw: string;
283
+ /** UCAN expiration (seconds) when known — deposits carry it, headers don't. */
284
+ expiration?: number;
285
+ at: number;
286
+ }
287
+
288
+ interface FlushResult {
289
+ uploaded: boolean;
290
+ /** Raw file size (skips) or bytes sent upstream (uploads). */
291
+ bytes: number;
292
+ etag?: string;
293
+ skipped?: 'unchanged-generation' | 'unchanged-checksum';
294
+ /** True when this flush also removed the user's legacy Matrix copy. */
295
+ legacyRemoved?: boolean;
296
+ }
297
+
298
+ /** Hex SHA-256 of a byte stream, hashed as it flows. */
299
+ async function sha256OfStream(
300
+ stream: ReadableStream<Uint8Array>,
301
+ ): Promise<string> {
302
+ const hash = createHash('sha256');
303
+ const reader = stream.getReader();
304
+ try {
305
+ for (;;) {
306
+ const { done, value } = await reader.read();
307
+ if (done) break;
308
+ hash.update(value);
309
+ }
310
+ } finally {
311
+ reader.releaseLock();
312
+ }
313
+ return hash.digest('hex');
314
+ }
315
+
316
+ /** Does this adapter expose provider config + role resolution (platform adapters do)? */
317
+ function isProviderAdapter(llm: LlmAdapter): llm is OpenRouterLlmAdapter {
318
+ return 'providerConfig' in llm && 'modelForRole' in llm;
319
+ }
320
+
321
+ /**
322
+ * The object could not boot for this user: answer with the owner-copy code
323
+ * (403 no grant / rejected credentials, 503 transient store failure) or a
324
+ * plain 500 for anything else. Same mapping as the shell's `onError`.
325
+ */
326
+ function readyFailureResponse(err: unknown): Response {
327
+ const failure = parseOwnerCopyFailure(err);
328
+ if (failure) {
329
+ return Response.json(
330
+ {
331
+ statusCode: failure.httpStatus,
332
+ code: failure.code,
333
+ message: failure.message,
334
+ retryable: failure.retryable,
335
+ },
336
+ { status: failure.httpStatus },
337
+ );
338
+ }
339
+ return Response.json(
340
+ {
341
+ statusCode: 500,
342
+ message: err instanceof Error ? err.message : String(err),
343
+ },
344
+ { status: 500 },
345
+ );
346
+ }
347
+
348
+ function toSummary(row: SessionRecord): SessionSummary {
349
+ return {
350
+ sessionId: row.sessionId,
351
+ title: row.title,
352
+ createdAt: row.createdAt,
353
+ lastUpdatedAt: row.lastUpdatedAt,
354
+ roomId: row.roomId,
355
+ };
356
+ }
357
+
358
+ function lastAiText(messages: BaseMessage[]): string {
359
+ for (let i = messages.length - 1; i >= 0; i--) {
360
+ const m = messages[i];
361
+ if (m && m.type === 'ai') return contentToText(m.content);
362
+ }
363
+ return '';
364
+ }
365
+
366
+ /** Id of the final assistant message (what `POST /messages` reports as `message.id`). */
367
+ function lastAiMessageId(messages: BaseMessage[]): string | undefined {
368
+ for (let i = messages.length - 1; i >= 0; i--) {
369
+ const m = messages[i];
370
+ if (m && m.type === 'ai' && typeof m.id === 'string' && m.id.length > 0)
371
+ return m.id;
372
+ }
373
+ return undefined;
374
+ }
375
+
376
+ export function createUserOracleDO(opts: UserOracleDOOptions) {
377
+ return class UserOracleDO extends DurableObject<OracleWorkerEnv> {
378
+ private db: DoSqliteDatabase | null = null;
379
+ private saver: SqliteSaver | null = null;
380
+ private sessions: SessionsStore | null = null;
381
+ private ownerStore: OwnerStore | null = null;
382
+ private ambient: AmbientServices | null = null;
383
+ private ucan: WorkersUcanService | null = null;
384
+ private secretsService: WorkersSecretsService | null = null;
385
+ private byo: WorkersByoService | null = null;
386
+ private userDid: string | null = null;
387
+ private readonly events = new SessionEventRouter();
388
+ /** socket.io endpoint (built on first use; restores hibernated sockets). */
389
+ private realtimeEndpoint: RealtimeEndpoint | null = null;
390
+
391
+ constructor(ctx: DurableObjectState, env: OracleWorkerEnv) {
392
+ super(ctx, env);
393
+ this.adoptHibernatedSockets();
394
+ }
395
+
396
+ /**
397
+ * Woken with hibernated sockets attached: build the realtime endpoint
398
+ * NOW so it re-adopts them and taps the event router before any turn
399
+ * runs. Left lazy, a turn arriving on a fresh instance (nothing touched
400
+ * the realtime surface yet) would emit into an untapped router and the
401
+ * still-connected browser would see none of its events. Called from the
402
+ * constructor and again from `ready()` — the per-request boot every RPC
403
+ * goes through — because the hibernated sockets are only guaranteed to
404
+ * be listed once a request is being handled.
405
+ */
406
+ private adoptHibernatedSockets(): void {
407
+ if (this.realtimeEndpoint) return;
408
+ if (this.ctx.getWebSockets().length > 0) this.realtime.nextPingAt();
409
+ }
410
+ private readonly aborts = new Map<string, AbortController>();
411
+ private readonly delegations = new Map<string, { raw: string }>();
412
+ /** Epoch-ms until which a room-state delegation lookup is not retried. */
413
+ private delegationMissUntil = 0;
414
+ private dirty = false;
415
+ /** The flush in progress, if any (single-flight; a VFS snapshot is open while it runs). */
416
+ private flushInFlight: Promise<FlushResult> | null = null;
417
+
418
+ /** Identifies this in-memory instance: a new id means the platform unloaded the object in between. */
419
+ private readonly instanceId = crypto.randomUUID();
420
+
421
+ private readonly bootedAt = Date.now();
422
+ /** Memoised `META_COMPACT_DONE` (null = not yet read from storage). */
423
+ private compactDone: boolean | null = null;
424
+ private compactCursors: CompactCursors | null = null;
425
+ /** Memoised `META_SIZE_ALERT_GB` (null = not yet read from storage). */
426
+ private sizeAlertGb: number | null = null;
427
+ private taskScheduler: TaskScheduler | null = null;
428
+
429
+ /** Per-room user preferences (room state, Node-compatible envelope). */
430
+ private preferences: UserPreferencesStore | null = null;
431
+
432
+ /** Matrix `work_status` liveness cards, one per in-flight Matrix turn. */
433
+ private workStatus: WorkStatusProducer | null = null;
434
+
435
+ /** In-flight Matrix turn per session (sessionId → requestId), for supersede. */
436
+ private readonly matrixTurns = new Map<string, string>();
437
+
438
+ /** Session-history → memory-engine indexing (built lazily, see `scheduleHistoryIndexing`). */
439
+ private historyIndexer: SessionHistoryIndexer | null = null;
440
+
441
+ /**
442
+ * Transcripts captured BEFORE a session is deleted, keyed by session id:
443
+ * the indexer runs after the RPC returned, when the rows are gone.
444
+ */
445
+ private readonly historySnapshots = new Map<
446
+ string,
447
+ { session: IndexableSession; messages: HistoryMessage[] }
448
+ >();
449
+ private initPromise: Promise<void> | null = null;
450
+ private reloadedFromOwnerStore = false;
451
+
452
+ // ── lifecycle ───────────────────────────────────────────────────────────
453
+
454
+ private get core(): RuntimeCore {
455
+ return opts.core(this.env);
456
+ }
457
+
458
+ /**
459
+ * The user's Matrix id for a turn that did not come through Matrix (HTTP,
460
+ * socket, alarm): `@did-ixo-<address>:<their homeserver>`, the way the
461
+ * Node runtime's `didToMatrixUserId(did, homeServerName)` builds it. The
462
+ * homeserver is the DID document's MatrixHomeServer service (Blocksync),
463
+ * falling back to this oracle's own server; the answer is cached in the
464
+ * object's storage because a DID's homeserver does not change. Room
465
+ * membership checks (the editor) and task invites depend on this being
466
+ * right, so an unresolvable id is `undefined`, never a guess.
467
+ */
468
+ private async resolveMatrixUserId(
469
+ userDid: string,
470
+ ): Promise<string | undefined> {
471
+ if (!userDid.startsWith('did:ixo:')) return undefined;
472
+ const cached =
473
+ await this.ctx.storage.get<MatrixUserIdCache>(META_MATRIX_USER_ID);
474
+ if (
475
+ cached &&
476
+ cached.userDid === userDid &&
477
+ (cached.resolved ||
478
+ Date.now() - cached.at < MATRIX_USER_ID_FALLBACK_TTL_MS)
479
+ ) {
480
+ return cached.matrixUserId;
481
+ }
482
+ let server: string | undefined;
483
+ let resolved = false;
484
+ const blocksync = this.env.BLOCKSYNC_GRAPHQL_URL;
485
+ if (blocksync) {
486
+ try {
487
+ const fromDid = await fetchUserMatrixServerName(blocksync, userDid);
488
+ if (fromDid) {
489
+ server = fromDid;
490
+ resolved = true;
491
+ }
492
+ } catch (err) {
493
+ console.warn(
494
+ `[user-do] could not resolve the Matrix homeserver of ${userDid} — using this oracle's: ${
495
+ err instanceof Error ? err.message : String(err)
496
+ }`,
497
+ );
498
+ }
499
+ }
500
+ server ??=
501
+ typeof this.env.MATRIX_HOMESERVER_NAME === 'string'
502
+ ? this.env.MATRIX_HOMESERVER_NAME
503
+ : undefined;
504
+ if (!server) return undefined;
505
+ const matrixUserId = `@${userDid.replace(/:/g, '-')}:${server}`;
506
+ await this.ctx.storage.put<MatrixUserIdCache>(META_MATRIX_USER_ID, {
507
+ userDid,
508
+ matrixUserId,
509
+ resolved,
510
+ at: Date.now(),
511
+ });
512
+ return matrixUserId;
513
+ }
514
+
515
+ /**
516
+ * The oracle's gateway. A self-refreshing handle (see `fresh-stub.ts`):
517
+ * helpers that keep it for the object's lifetime (owner store,
518
+ * preferences, ambient adapter) keep working across gateway restarts.
519
+ */
520
+ private get gateway(): DurableObjectStub<MatrixGatewayObject> {
521
+ return freshStub(() =>
522
+ this.env.MATRIX_GATEWAY.get(
523
+ this.env.MATRIX_GATEWAY.idFromName(this.env.ORACLE_DID),
524
+ ),
525
+ );
526
+ }
527
+
528
+ /** Idempotent boot: bind user, open SQLite (importing the owner copy on a cold object). */
529
+ private async ready(identity: TurnIdentity): Promise<void> {
530
+ this.installDebugTimerTracker();
531
+ this.adoptHibernatedSockets();
532
+ if (identity.ucanDelegation) {
533
+ this.delegations.set(identity.userDid, {
534
+ raw: identity.ucanDelegation,
535
+ });
536
+ // Remember it for turns that arrive without the header.
537
+ void this.ctx.storage.put(META_DELEGATION, {
538
+ raw: identity.ucanDelegation,
539
+ at: Date.now(),
540
+ ...(typeof identity.ucanDelegationExpiration === 'number'
541
+ ? { expiration: identity.ucanDelegationExpiration }
542
+ : {}),
543
+ } satisfies StoredDelegation);
544
+ } else if (!this.delegations.has(identity.userDid)) {
545
+ await this.hydrateDelegation(identity.userDid);
546
+ }
547
+ if (!this.initPromise) {
548
+ this.initPromise = this.boot(identity.userDid).catch((err) => {
549
+ this.initPromise = null;
550
+ throw err;
551
+ });
552
+ }
553
+ await this.initPromise;
554
+ if (this.userDid && this.userDid !== identity.userDid) {
555
+ throw new Error(
556
+ `UserOracleDO bound to ${this.userDid} received a request for ${identity.userDid}`,
557
+ );
558
+ }
559
+ await this.ctx.storage.put(META_LAST_ACCESS, Date.now());
560
+ }
561
+
562
+ /**
563
+ * Populate the in-memory delegation for a turn that carried none: first
564
+ * this object's cache, then the `ucan_delegation` room state the client
565
+ * deposited. Nothing found → plugins degrade exactly as before (no
566
+ * downstream auth), re-checked after `DELEGATION_MISS_TTL_MS`.
567
+ */
568
+ private async hydrateDelegation(userDid: string): Promise<void> {
569
+ const nowSeconds = Math.floor(Date.now() / 1000);
570
+ const live = (
571
+ d: StoredDelegation | null | undefined,
572
+ ): d is StoredDelegation =>
573
+ Boolean(d?.raw) &&
574
+ (typeof d?.expiration !== 'number' || d.expiration > nowSeconds);
575
+ const cached =
576
+ await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
577
+ if (live(cached)) {
578
+ this.delegations.set(userDid, { raw: cached.raw });
579
+ return;
580
+ }
581
+ if (Date.now() < this.delegationMissUntil) return;
582
+ try {
583
+ const room = await this.gateway.resolveUserRoom(userDid);
584
+ const json = room
585
+ ? await this.gateway.getRoomStateEvent(
586
+ room.roomId,
587
+ 'ixo.room.state',
588
+ 'ucan_delegation',
589
+ )
590
+ : null;
591
+ // Node writes this key through its compressed room-state codec;
592
+ // earlier builds of this runtime wrote plain JSON. Read both.
593
+ const state = json
594
+ ? ((await decodeRoomStateContent(JSON.parse(json) as unknown)) as {
595
+ raw?: string;
596
+ expiration?: number;
597
+ } | null)
598
+ : null;
599
+ const deposited: StoredDelegation | null = state?.raw
600
+ ? {
601
+ raw: state.raw,
602
+ at: Date.now(),
603
+ ...(typeof state.expiration === 'number'
604
+ ? { expiration: state.expiration }
605
+ : {}),
606
+ }
607
+ : null;
608
+ if (live(deposited)) {
609
+ this.delegations.set(userDid, { raw: deposited.raw });
610
+ await this.ctx.storage.put(META_DELEGATION, deposited);
611
+ console.log(
612
+ `[user-do] delegation for ${userDid} loaded from the deposited room state (header-less turn)`,
613
+ );
614
+ return;
615
+ }
616
+ } catch (err) {
617
+ console.warn(
618
+ `[user-do] could not load ${userDid}'s deposited delegation: ${err instanceof Error ? err.message : String(err)}`,
619
+ );
620
+ }
621
+ this.delegationMissUntil = Date.now() + DELEGATION_MISS_TTL_MS;
622
+ }
623
+
624
+ private async boot(userDid: string): Promise<void> {
625
+ const stored = await this.ctx.storage.get<string>(META_USER_DID);
626
+ if (stored && stored !== userDid)
627
+ throw new Error(`Object bound to ${stored}, not ${userDid}`);
628
+ if (!stored) await this.ctx.storage.put(META_USER_DID, userDid);
629
+ this.userDid = userDid;
630
+
631
+ const env = this.env;
632
+ this.ucan = new WorkersUcanService({
633
+ oracleDid: env.ORACLE_DID,
634
+ signingMnemonic:
635
+ typeof env.ORACLE_SIGNING_MNEMONIC === 'string'
636
+ ? env.ORACLE_SIGNING_MNEMONIC
637
+ : undefined,
638
+ logger: console,
639
+ });
640
+ this.ownerStore = await this.createOwnerStore(userDid);
641
+ // The gateway lazily starts on first use; make sure it is up before the
642
+ // owner store or room lookups need it (idempotent, cheap once running).
643
+ await this.gateway.ensureStarted().catch((err) => {
644
+ console.warn(
645
+ `[user-do] matrix gateway not available yet: ${err instanceof Error ? err.message : String(err)}`,
646
+ );
647
+ });
648
+
649
+ // Per-room JWE secrets: seat the oracle's P-256 key (fetched once from
650
+ // the gateway, which reads the Matrix account room). Without a key the
651
+ // service still lists the index; values/writes degrade as on Node.
652
+ const secretsService = new WorkersSecretsService({
653
+ gateway: this.gateway,
654
+ logger: console,
655
+ });
656
+ this.secretsService = secretsService;
657
+ try {
658
+ const keyJson = await this.gateway.getOracleSecretsKey();
659
+ if (keyJson) {
660
+ const jwk = parseJwk(keyJson);
661
+ if (jwk) secretsService.setEncryptionKey(jwk);
662
+ else
663
+ console.error(
664
+ '[user-do] oracle secrets key is not a valid JWK — secrets stay unavailable',
665
+ );
666
+ }
667
+ } catch (err) {
668
+ console.warn(
669
+ `[user-do] oracle secrets key unavailable: ${err instanceof Error ? err.message : String(err)}`,
670
+ );
671
+ }
672
+
673
+ // Bring-your-own-credential LLMs (gated by BYO_LLM_ENABLED). The
674
+ // service lives here because this object is single-threaded per user —
675
+ // refresh single-flight, credential epochs and pending rotated tokens
676
+ // need exactly that.
677
+ this.byo = new WorkersByoService({
678
+ enabled: env.BYO_LLM_ENABLED === 'true',
679
+ chatGptClientId:
680
+ typeof env.BYO_CHATGPT_CLIENT_ID === 'string' &&
681
+ env.BYO_CHATGPT_CLIENT_ID.length > 0
682
+ ? env.BYO_CHATGPT_CLIENT_ID
683
+ : undefined,
684
+ secrets: secretsService,
685
+ resolveRoomId: async (did) => {
686
+ try {
687
+ return (await this.gateway.resolveUserRoom(did))?.roomId ?? null;
688
+ } catch (err) {
689
+ console.warn(
690
+ `[user-do] could not resolve oracle room for ${did}: ${err instanceof Error ? err.message : String(err)}`,
691
+ );
692
+ return null;
693
+ }
694
+ },
695
+ store: createByoStateStore(this.ctx.storage),
696
+ logger: console,
697
+ chatGptBackend: chatGptBackendFromEnv(env),
698
+ });
699
+
700
+ const db = await DoSqliteDatabase.open(
701
+ this.ctx,
702
+ DB_FILE,
703
+ this.sqliteOpenOptions(),
704
+ );
705
+ // Cold object (no pages yet) → pull the user's file if they have one.
706
+ // `null` means the owner store PROVABLY holds no file (a new user): an
707
+ // empty working copy is right. A load that FAILS is different: there is
708
+ // no local copy to fall back on, and starting empty would hide the
709
+ // user's history and — once a turn dirtied it — get flushed over the
710
+ // real copy. So the request fails instead; the boot is not memoised on
711
+ // failure, and the next request simply retries the load.
712
+ if (db.fileSize === 0) {
713
+ const store = this.ownerStore;
714
+ let loaded: OwnerCopy | null;
715
+ let attempts = 0;
716
+ try {
717
+ loaded = await withRetry(
718
+ () => {
719
+ attempts += 1;
720
+ return store.load();
721
+ },
722
+ {
723
+ delaysMs: OWNER_COPY_LOAD_RETRY_DELAYS_MS,
724
+ isRetryable: isRetryableOwnerCopyError,
725
+ onRetry: (err, attempt, delayMs) =>
726
+ console.warn(
727
+ `[user-do] owner copy load for ${userDid} failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${
728
+ err instanceof Error ? err.message : String(err)
729
+ }`,
730
+ ),
731
+ },
732
+ );
733
+ } catch (err) {
734
+ const failure = new OwnerCopyUnavailableError(userDid, err, attempts);
735
+ console.error(
736
+ `[user-do] owner copy load failed for ${userDid} (${failure.code}, ${attempts} attempt(s)) — refusing to start an empty working copy: ${
737
+ err instanceof Error ? err.message : String(err)
738
+ }`,
739
+ );
740
+ throw toRpcError(failure);
741
+ }
742
+ if (loaded) {
743
+ const imported = await this.adoptOwnerCopy(db, loaded);
744
+ this.reloadedFromOwnerStore = true;
745
+ console.log(
746
+ `[user-do] imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
747
+ );
748
+ }
749
+ } else {
750
+ // Warm object: notice if the user replaced or deleted their file
751
+ // upstream. A `head()` that FAILS (a VFS error/timeout, or our own
752
+ // delete→move replace window where the real path momentarily does
753
+ // not exist) must NEVER be read as "the file is gone" — swallowing
754
+ // the error to null and wiping the working copy on it would destroy
755
+ // the durable local history over a transient blip. So distinguish a
756
+ // proven absence (null) from an unknown (the call threw).
757
+ const knownEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
758
+ let head: { etag: string } | null | undefined;
759
+ try {
760
+ head = await this.ownerStore.head();
761
+ } catch (err) {
762
+ head = undefined; // unknown — leave the working copy untouched
763
+ console.warn(
764
+ `[user-do] could not check the owner copy for ${userDid} (keeping the working copy): ${
765
+ err instanceof Error ? err.message : String(err)
766
+ }`,
767
+ );
768
+ }
769
+ if (head === null && knownEtag) {
770
+ // Proven gone upstream. The durable working copy is the live source
771
+ // of truth: only DROP it when there is nothing to lose — a
772
+ // zero-turn file. A working copy that holds turns is KEPT and
773
+ // re-flushed, re-establishing the owner copy; a genuine "forget me"
774
+ // goes through the explicit `remove()` path, never through an
775
+ // auto-wipe that could fire on a mid-replace crash or a stale list.
776
+ if ((await this.localTurnCount(db)) === 0) {
777
+ console.warn(
778
+ `[user-do] owner copy for ${userDid} is gone upstream and the working copy is empty — dropping it`,
779
+ );
780
+ await this.wipeWorkingCopy(db);
781
+ this.db = await DoSqliteDatabase.open(
782
+ this.ctx,
783
+ DB_FILE,
784
+ this.sqliteOpenOptions(),
785
+ );
786
+ } else {
787
+ console.warn(
788
+ `[user-do] owner copy for ${userDid} is gone upstream but the working copy holds turns — keeping it and re-uploading on the next flush`,
789
+ );
790
+ this.markDirty();
791
+ }
792
+ } else if (head && knownEtag && head.etag !== knownEtag) {
793
+ const loaded = await this.ownerStore.load().catch(() => null);
794
+ if (loaded) {
795
+ await this.adoptOwnerCopy(db, loaded);
796
+ this.reloadedFromOwnerStore = true;
797
+ console.log(
798
+ `[user-do] owner copy changed upstream (${knownEtag} → ${head.etag}); re-imported`,
799
+ );
800
+ }
801
+ } else if (head && !knownEtag) {
802
+ // This object first booted before the user's owner file became
803
+ // readable — e.g. a migrating Node oracle uploaded its final
804
+ // checkpoint AFTER this object's first touch, or that first load
805
+ // failed. The owner file is the system of record: adopt it while
806
+ // nothing has been chatted here (a client may have created an
807
+ // empty session, but no turn ever ran → no checkpoints); once local
808
+ // turns exist neither side can win automatically, so keep local
809
+ // and say so loudly (operators resolve via /debug/storage/reset).
810
+ const localTurns = await this.localTurnCount(db);
811
+ if (localTurns === 0) {
812
+ const loaded = await this.ownerStore.load().catch((err) => {
813
+ console.warn(
814
+ `[user-do] late owner copy load failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
815
+ );
816
+ return null;
817
+ });
818
+ if (loaded) {
819
+ const imported = await this.adoptOwnerCopy(db, loaded);
820
+ this.reloadedFromOwnerStore = true;
821
+ console.log(
822
+ `[user-do] owner copy appeared upstream after first boot; imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
823
+ );
824
+ }
825
+ } else {
826
+ console.warn(
827
+ `[user-do] owner copy exists upstream (${head.etag}) but was never imported and ${localTurns} local checkpoint(s) already exist for ${userDid} — keeping the local copy`,
828
+ );
829
+ }
830
+ }
831
+ }
832
+ // A working copy that never held a single turn cannot be the user's
833
+ // history — even when it was just imported from the system of record.
834
+ // The system of record CAN legitimately hold a zero-turn file: a client
835
+ // (the Portal) that created a session but never chatted flushes an empty
836
+ // DB to VFS, which then shadows the user's real Node-runtime history in
837
+ // Matrix media. So this runs regardless of `reloadedFromOwnerStore`: if
838
+ // the working copy has zero turns and a legacy (Matrix) copy WITH turns
839
+ // exists, adopt it and flush it over the empty file. Guard on the legacy
840
+ // copy actually having turns so a genuinely new user (empty everywhere)
841
+ // is left empty, not churned.
842
+ const activeDb = this.db ?? db;
843
+ if (
844
+ this.ownerStore.loadLegacy &&
845
+ (await this.localTurnCount(activeDb)) === 0
846
+ ) {
847
+ const legacy = await this.ownerStore.loadLegacy();
848
+ if (legacy) {
849
+ // Trial import: only the checkpoint count tells us whether the
850
+ // legacy copy holds history, so keep the (tiny, zero-turn) working
851
+ // copy to put back if it does not.
852
+ const before = await activeDb.export();
853
+ const adoptedBytes = await activeDb.importFromStream(legacy.stream);
854
+ const adoptedTurns = await this.localTurnCount(activeDb);
855
+ if (adoptedTurns > 0) {
856
+ // The adopted bytes are NOT what the system of record holds:
857
+ // forget the last upload so the flush below actually sends them.
858
+ await this.ctx.storage.delete([
859
+ META_LAST_CHECKSUM,
860
+ META_UPLOADED_GEN,
861
+ ]);
862
+ this.reloadedFromOwnerStore = true;
863
+ // Flush the adopted history over the empty file in the system of
864
+ // record so the next cold boot imports it directly.
865
+ this.markDirty();
866
+ console.log(
867
+ `[user-do] working copy had no turns; adopted the legacy Matrix copy (${adoptedBytes} bytes, ${adoptedTurns} checkpoint(s)) for ${userDid} — flushing it to the system of record`,
868
+ );
869
+ } else {
870
+ await activeDb.import(before);
871
+ console.log(
872
+ `[user-do] legacy Matrix copy for ${userDid} holds no turns either; working copy left as is`,
873
+ );
874
+ }
875
+ }
876
+ }
877
+
878
+ this.db ??= db;
879
+ const liveDb = this.db;
880
+ {
881
+ // One line per boot so an operator can tell what a user's object
882
+ // actually holds without a debug route: which store, how big, how
883
+ // many turns/sessions, and which upstream version it tracks.
884
+ const sessionRow = await liveDb
885
+ .get<{ n: number }>('SELECT count(*) AS n FROM sessions')
886
+ .catch(() => undefined);
887
+ // An already-migrated user (VFS holds the file, Matrix still has the
888
+ // old copy): remove the legacy copy — but ONLY once the VFS copy
889
+ // PROVABLY holds this working copy's exact bytes
890
+ // (`ownerCopyIsCurrent` re-hashes and compares to the last upload),
891
+ // never merely because a VFS file with the right etag exists.
892
+ // Redacting on etag alone would drop the last backstop while the VFS
893
+ // copy could still be a smaller or older export. A dirty or
894
+ // not-yet-verified copy waits: the flush that lands it does the
895
+ // removal itself.
896
+ if (
897
+ (await this.localTurnCount(liveDb)) > 0 &&
898
+ (await this.ownerCopyIsCurrent(liveDb))
899
+ ) {
900
+ const ownerEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
901
+ if (ownerEtag) await this.removeLegacyCopyIfAny(ownerEtag);
902
+ }
903
+ console.log(
904
+ `[user-do] ready ${userDid}: store=${this.ownerStore.kind} legacy=${this.ownerStore.loadLegacy ? 'yes' : 'no'} file=${liveDb.fileSize}B turns=${await this.localTurnCount(liveDb)} sessions=${sessionRow?.n ?? 0} ownerEtag=${(await this.ctx.storage.get<string>(META_OWNER_ETAG)) ?? '-'} reloaded=${this.reloadedFromOwnerStore}`,
905
+ );
906
+ }
907
+ this.saver = new SqliteSaver(liveDb, undefined, {
908
+ oracleName: this.core.identity.name,
909
+ });
910
+ await this.saver.setup();
911
+ this.sessions = new SessionsStore(liveDb);
912
+ await this.sessions.setup();
913
+
914
+ const core = this.core;
915
+ // Boot-time plugin hooks + collision checks, once per object (memoised).
916
+ await core.warm();
917
+
918
+ const maxTasks = Number(core.validatedEnv.TASKS_MAX_PER_USER);
919
+ const minCron = Number(core.validatedEnv.TASKS_MIN_CRON_INTERVAL_SEC);
920
+ this.taskScheduler = await createTaskScheduler({
921
+ db: liveDb,
922
+ userDid,
923
+ oracleDid: env.ORACLE_DID,
924
+ oracleName: core.identity.name,
925
+ matrixUserId: await this.resolveMatrixUserId(userDid),
926
+ gateway: this.gateway,
927
+ runTurn: (req) => this.runTurn(req),
928
+ requestAlarm: (at) => this.requestAlarm(at),
929
+ log: console,
930
+ ...(Number.isFinite(maxTasks) ? { maxTasksPerUser: maxTasks } : {}),
931
+ ...(Number.isFinite(minCron) ? { minCronIntervalSec: minCron } : {}),
932
+ });
933
+ if (this.taskScheduler) {
934
+ const next = await this.taskScheduler.nextWakeAt().catch(() => null);
935
+ if (next !== null) this.requestAlarm(Math.max(next, Date.now() + 1000));
936
+ }
937
+
938
+ // Resume (or start) legacy-blob compaction in the background: files
939
+ // imported from the Node runtime arrive with uncompressed blobs.
940
+ this.compactDone ??=
941
+ (await this.ctx.storage.get<boolean>(META_COMPACT_DONE)) ?? false;
942
+ if (!this.compactDone) this.requestAlarm(Date.now() + 3000);
943
+
944
+ // Provider selection: `core.llm` is the OpenRouter adapter built from
945
+ // the validated base env; `LLM_PROVIDER=nebius` (read from the raw
946
+ // Worker env — it is not part of the base schema) swaps in the Nebius
947
+ // adapter for this object's ambient services.
948
+ const llm: LlmAdapter =
949
+ env.LLM_PROVIDER === 'nebius'
950
+ ? createLlmAdapter(llmEnvFromWorkerEnv(env), console)
951
+ : core.llm;
952
+
953
+ this.preferences = new UserPreferencesStore(this.gateway, {
954
+ logger: console,
955
+ });
956
+ this.workStatus = new WorkStatusProducer({
957
+ postEvent: (roomId, type, content) =>
958
+ this.gateway.sendEvent(roomId, type, JSON.stringify(content)),
959
+ logger: console,
960
+ });
961
+ this.ambient = createAmbientServices({
962
+ ...(this.taskScheduler ? { tasks: this.taskScheduler.surface } : {}),
963
+ preferences: this.preferences,
964
+ frontend: this.realtime.frontend,
965
+ config: core.validatedEnv,
966
+ identity: core.identity,
967
+ availablePlugins: core.availablePlugins,
968
+ llm,
969
+ logger: console,
970
+ storage: this.ctx.storage,
971
+ gateway: this.gateway,
972
+ ucan: this.ucan,
973
+ delegationFor: (did) => this.delegations.get(did),
974
+ events: this.events,
975
+ secrets: createSecretsAdapter(secretsService),
976
+ });
977
+ }
978
+
979
+ /**
980
+ * IXO VFS is the system of record for the user's file. The Matrix room
981
+ * media copy is a read-only LEGACY source: consulted only when VFS has no
982
+ * file yet (users coming from the Node runtime), migrated into VFS on
983
+ * first touch, never written again. `OWNER_STORE=matrix` remains as an
984
+ * explicit legacy/dev override (e.g. environments with no VFS worker).
985
+ */
986
+ private async createOwnerStore(userDid: string): Promise<OwnerStore> {
987
+ const env = this.env;
988
+ const storageKey = await checkpointStorageKey(userDid, env.ORACLE_DID);
989
+ const matrixStore = new MatrixMediaOwnerStore({
990
+ gateway: this.gateway,
991
+ userDid,
992
+ storageKey,
993
+ });
994
+ if (env.OWNER_STORE === 'matrix') return matrixStore;
995
+
996
+ const network = env.NETWORK ?? 'devnet';
997
+ if (!this.ucan) throw new Error('ucan service not initialised');
998
+ const vfsStore = new IxoVfsOwnerStore({
999
+ ucan: this.ucan,
1000
+ userDid,
1001
+ oracleDid: env.ORACLE_DID,
1002
+ vfsBaseUrl:
1003
+ env.VFS_BASE_URL ??
1004
+ VFS_DEFAULT_BASE_URLS[network] ??
1005
+ VFS_DEFAULT_BASE_URLS.devnet!,
1006
+ ucanStoreUrl:
1007
+ env.UCAN_STORE_URL ??
1008
+ UCAN_STORE_DEFAULT_URLS[network] ??
1009
+ UCAN_STORE_DEFAULT_URLS.devnet!,
1010
+ });
1011
+ return new MigratingOwnerStore({
1012
+ primary: vfsStore,
1013
+ legacy: matrixStore,
1014
+ });
1015
+ }
1016
+
1017
+ /** Drop the working copy entirely; the caller reopens/reboots afterwards. */
1018
+ private async wipeWorkingCopy(db: DoSqliteDatabase | null): Promise<void> {
1019
+ if (db) {
1020
+ await db.close().catch(() => undefined);
1021
+ }
1022
+ await DoSqliteDatabase.wipe(this.ctx, DB_FILE);
1023
+ await this.ctx.storage.delete([
1024
+ META_OWNER_ETAG,
1025
+ META_LAST_CHECKSUM,
1026
+ META_LAST_FLUSH,
1027
+ META_UPLOADED_GEN,
1028
+ META_FLUSH_FAILURES,
1029
+ META_LAST_VACUUM,
1030
+ ]);
1031
+ }
1032
+
1033
+ /** `CHUNK_CACHE_BYTES` → the VFS clean-chunk budget for this object. */
1034
+ private sqliteOpenOptions(): { cachePages: number } {
1035
+ const bytes = parseChunkCacheBytes(this.env.CHUNK_CACHE_BYTES, (m) =>
1036
+ console.warn(`[user-do] ${m}`),
1037
+ );
1038
+ return { cachePages: cachePagesForBytes(bytes) };
1039
+ }
1040
+
1041
+ /**
1042
+ * Stream an owner copy into the working copy and record what it is:
1043
+ * upstream etag, its checksum, and the VFS generation that now matches
1044
+ * the upstream file (so the next flush knows there is nothing to send
1045
+ * until a turn writes). Returns the byte length imported.
1046
+ */
1047
+ private async adoptOwnerCopy(
1048
+ db: DoSqliteDatabase,
1049
+ copy: { stream: ReadableStream<Uint8Array>; etag: string },
1050
+ ): Promise<number> {
1051
+ const bytes = await db.importFromStream(copy.stream);
1052
+ await this.ctx.storage.put(META_OWNER_ETAG, copy.etag);
1053
+ await this.ctx.storage.put(META_LAST_CHECKSUM, await db.checksum());
1054
+ await this.ctx.storage.put(META_UPLOADED_GEN, db.writeGeneration);
1055
+ return bytes;
1056
+ }
1057
+
1058
+ /**
1059
+ * Whether the upstream copy provably holds the working copy's content:
1060
+ * nothing was written since the last successful upload AND the file
1061
+ * still hashes to what was uploaded. A working copy that never ran a
1062
+ * turn holds nothing worth keeping either way.
1063
+ */
1064
+ private async ownerCopyIsCurrent(db: DoSqliteDatabase): Promise<boolean> {
1065
+ if (this.dirty) return false;
1066
+ const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
1067
+ const lastChecksum =
1068
+ await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
1069
+ if (lastChecksum === undefined)
1070
+ return (await this.localTurnCount(db)) === 0;
1071
+ // Objects that last flushed before generations were recorded verify
1072
+ // by hash alone; everyone else gets the cheap generation gate first.
1073
+ if (uploadedGen !== undefined && uploadedGen !== db.writeGeneration)
1074
+ return false;
1075
+ return (await db.checksum()) === lastChecksum;
1076
+ }
1077
+
1078
+ /** Checkpoint rows in the working copy — zero means no turn ever ran here. */
1079
+ private async localTurnCount(db: DoSqliteDatabase): Promise<number> {
1080
+ const row = await db
1081
+ .get<{ n: number }>('SELECT count(*) AS n FROM checkpoints')
1082
+ .catch(() => undefined);
1083
+ return row?.n ?? 0;
1084
+ }
1085
+
1086
+ private markDirty(): void {
1087
+ this.dirty = true;
1088
+ void this.ctx.storage.put(META_DIRTY, true);
1089
+ this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
1090
+ }
1091
+
1092
+ /** Arm the object's single alarm no later than `at` (multiplexed). */
1093
+ private requestAlarm(at: number): void {
1094
+ void this.ctx.storage.getAlarm().then((existing) => {
1095
+ if (existing === null || existing > at)
1096
+ return this.ctx.storage.setAlarm(at);
1097
+ return undefined;
1098
+ });
1099
+ }
1100
+
1101
+ /**
1102
+ * One alarm, three clients: owner-store flush (debounced), the task
1103
+ * scheduler (when active), and week-idle eviction. Each tick runs what is
1104
+ * due and re-arms at the earliest future deadline.
1105
+ */
1106
+ async alarm(): Promise<void> {
1107
+ // Realtime heartbeat first, on the bare object: a round only needs
1108
+ // the hibernated sockets and their attachments. When nothing else is
1109
+ // due yet, re-arm and return WITHOUT opening the database — that is
1110
+ // what lets a user with an open tab cost a few milliseconds every
1111
+ // PING_INTERVAL_MS instead of a resident object.
1112
+ const wakeAt = Date.now();
1113
+ const nextPingAt =
1114
+ this.ctx.getWebSockets().length > 0 ? this.realtime.pingTick() : null;
1115
+ const housekeepingAt =
1116
+ await this.ctx.storage.get<number>(META_HOUSEKEEPING_AT);
1117
+ if (
1118
+ nextPingAt !== null &&
1119
+ housekeepingAt !== undefined &&
1120
+ housekeepingAt > wakeAt + 1000
1121
+ ) {
1122
+ await this.ctx.storage.setAlarm(Math.min(nextPingAt, housekeepingAt));
1123
+ return;
1124
+ }
1125
+
1126
+ // Alarms routinely wake an EVICTED object: no db, no task scheduler,
1127
+ // dirty flag gone. Boot from the persisted identity first — a tick on
1128
+ // the un-booted object would skip every client and re-arm at the idle
1129
+ // horizon, silently dropping pending task runs and flushes.
1130
+ if (!this.db) {
1131
+ const storedDid = await this.ctx.storage.get<string>(META_USER_DID);
1132
+ if (storedDid) {
1133
+ try {
1134
+ await this.ready({ userDid: storedDid });
1135
+ } catch (err) {
1136
+ console.error(
1137
+ `[user-do] alarm boot failed for ${storedDid}: ${err instanceof Error ? err.message : String(err)}`,
1138
+ );
1139
+ // State exists but could not be opened — retry soon instead of
1140
+ // falling through to a schedulerless re-arm.
1141
+ await this.ctx.storage.setAlarm(Date.now() + 60_000);
1142
+ return;
1143
+ }
1144
+ }
1145
+ }
1146
+ if (
1147
+ this.db &&
1148
+ !this.dirty &&
1149
+ (await this.ctx.storage.get<boolean>(META_DIRTY)) === true
1150
+ ) {
1151
+ this.dirty = true;
1152
+ }
1153
+
1154
+ const now = Date.now();
1155
+ const deadlines: number[] = [];
1156
+
1157
+ if (this.dirty && this.db) {
1158
+ try {
1159
+ await this.flushToOwnerStore();
1160
+ } catch (err) {
1161
+ console.error(
1162
+ `[user-do] flush failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1163
+ );
1164
+ deadlines.push(now + FLUSH_RETRY_DELAY_MS);
1165
+ }
1166
+ }
1167
+
1168
+ if (this.taskScheduler) {
1169
+ try {
1170
+ await this.taskScheduler.onAlarm(now);
1171
+ } catch (err) {
1172
+ console.error(
1173
+ `[user-do] task scheduler tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1174
+ );
1175
+ }
1176
+ const next = await this.taskScheduler.nextWakeAt().catch(() => null);
1177
+ if (next !== null) deadlines.push(Math.max(next, now + 1000));
1178
+ }
1179
+
1180
+ // Legacy-blob compaction: one bounded batch per tick until the whole
1181
+ // file is codec-compressed (see sqlite/blob-compactor.ts).
1182
+ if (this.db) {
1183
+ try {
1184
+ if (await this.compactionTick(this.db)) deadlines.push(now + 2000);
1185
+ } catch (err) {
1186
+ console.error(
1187
+ `[user-do] blob compaction tick failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1188
+ );
1189
+ deadlines.push(now + 60_000);
1190
+ }
1191
+ }
1192
+
1193
+ const lastAccess =
1194
+ (await this.ctx.storage.get<number>(META_LAST_ACCESS)) ?? now;
1195
+
1196
+ // Free-page reclaim on a quiet object (policy in sqlite/vacuum-policy.ts):
1197
+ // runs after the flush above so the upload holds the pre-rebuild file,
1198
+ // and marks dirty so the smaller file goes up on the next tick.
1199
+ if (this.db && !this.dirty) {
1200
+ const next = await this.vacuumTick(this.db, now, lastAccess);
1201
+ if (next !== null) deadlines.push(next);
1202
+ }
1203
+
1204
+ // Idle housekeeping: a user silent for a day loses the cached pages —
1205
+ // their file is safe upstream, and we stop paying to store a copy. Never
1206
+ // evict while tasks are scheduled: their runs ARE activity. The flush
1207
+ // above ran first; the wipe additionally verifies the upstream copy
1208
+ // matches the working copy, else the copy stays until it does.
1209
+ const idle = now - lastAccess > IDLE_EVICT_MS;
1210
+ if (idle && !this.dirty && this.db && deadlines.length === 0) {
1211
+ if (await this.ownerCopyIsCurrent(this.db)) {
1212
+ await this.wipeWorkingCopy(this.db);
1213
+ await this.ctx.storage.delete(META_HOUSEKEEPING_AT);
1214
+ // An attached (idle) socket still needs its heartbeat rounds.
1215
+ if (nextPingAt !== null) await this.ctx.storage.setAlarm(nextPingAt);
1216
+ else await this.ctx.storage.deleteAlarm();
1217
+ return;
1218
+ }
1219
+ console.warn(
1220
+ `[user-do] ${this.userDid} is idle but the owner copy is not verified current — keeping the working copy`,
1221
+ );
1222
+ deadlines.push(now + FLUSH_RETRY_DELAY_MS);
1223
+ }
1224
+ deadlines.push(now + IDLE_EVICT_MS);
1225
+ const housekeeping = Math.min(...deadlines);
1226
+ await this.ctx.storage.put(META_HOUSEKEEPING_AT, housekeeping);
1227
+ await this.ctx.storage.setAlarm(
1228
+ nextPingAt === null ? housekeeping : Math.min(housekeeping, nextPingAt),
1229
+ );
1230
+ }
1231
+
1232
+ /**
1233
+ * One VACUUM policy check. Returns the time of a follow-up check when a
1234
+ * rebuild is wanted but the object is not quiet yet, else null.
1235
+ */
1236
+ private async vacuumTick(
1237
+ db: DoSqliteDatabase,
1238
+ now: number,
1239
+ lastAccessAt: number,
1240
+ ): Promise<number | null> {
1241
+ let usage: { pageCount: number; freelistCount: number };
1242
+ try {
1243
+ usage = await db.pageUsage();
1244
+ } catch (err) {
1245
+ console.warn(
1246
+ `[user-do] page usage unavailable for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1247
+ );
1248
+ return null;
1249
+ }
1250
+ const input = {
1251
+ now,
1252
+ lastAccessAt,
1253
+ lastVacuumAt: await this.ctx.storage.get<number>(META_LAST_VACUUM),
1254
+ dirty: this.dirty,
1255
+ exporting: this.flushInFlight !== null,
1256
+ fileBytes: db.fileSize,
1257
+ ...usage,
1258
+ };
1259
+ const verdict = shouldVacuum(input);
1260
+ if (!verdict.vacuum) {
1261
+ return vacuumWanted(input) ? now + VACUUM_IDLE_MS : null;
1262
+ }
1263
+ try {
1264
+ const { beforeBytes, afterBytes } = await db.compact();
1265
+ await this.ctx.storage.put(META_LAST_VACUUM, now);
1266
+ console.log(
1267
+ `[user-do] vacuumed ${this.userDid}: ${beforeBytes} → ${afterBytes} bytes (free share was ${(verdict.freeShare * 100).toFixed(0)}%)`,
1268
+ );
1269
+ this.markDirty();
1270
+ } catch (err) {
1271
+ console.error(
1272
+ `[user-do] vacuum failed for ${this.userDid}: ${err instanceof Error ? err.message : String(err)}`,
1273
+ );
1274
+ await this.ctx.storage.put(META_LAST_VACUUM, now);
1275
+ }
1276
+ return null;
1277
+ }
1278
+
1279
+ // ── RPC surface (contracts.ts UserOracleObject) ───────────────────────
1280
+
1281
+ async createSession(
1282
+ identity: TurnIdentity,
1283
+ o: { roomId?: string; sessionId?: string } = {},
1284
+ ): Promise<SessionSummary> {
1285
+ await this.ready(identity);
1286
+ const sessions = this.sessions!;
1287
+ // Like the Node runtime: creating a session sends the MOST RECENT
1288
+ // previous session's transcript to the memory engine, in the
1289
+ // background — the new session never waits on it.
1290
+ const { sessions: recent } = await sessions.listSessions(undefined, 1, 0);
1291
+ const previous = recent[0];
1292
+ if (previous) this.scheduleHistoryIndexing(previous.sessionId);
1293
+ let sessionId = o.sessionId;
1294
+ let roomId = o.roomId;
1295
+ if (!sessionId) {
1296
+ // Mirror the Node runtime: a session id IS a Matrix event id when the
1297
+ // user has an oracle room (clients rely on that for deep links).
1298
+ // `null` is the definitive "this user has no oracle room" (local id
1299
+ // below). A failing gateway is NOT that: swallowing it minted local
1300
+ // ids for users who do have rooms, and the room replays then
1301
+ // threaded on an id that is not a Matrix event id — which the
1302
+ // crypto WASM panics on. Transient failures retry, then fail the
1303
+ // request; the client retries and nothing half-exists.
1304
+ const room = roomId
1305
+ ? { roomId }
1306
+ : await retryGateway(() =>
1307
+ this.gateway.resolveUserRoom(identity.userDid),
1308
+ );
1309
+ if (room) {
1310
+ roomId = room.roomId;
1311
+ // The marker is a waited, non-durable send: it must never be
1312
+ // replayed after a gateway restart (its event id IS the session id,
1313
+ // an orphan marker would be a ghost session). Instead it retries
1314
+ // the same transaction id across the restart window, which the
1315
+ // homeserver deduplicates. Node fails the request on send failure;
1316
+ // so do we — no silent local id when the user has an oracle room.
1317
+ const txnId = `marker-${crypto.randomUUID()}`;
1318
+ sessionId = await retryGateway(
1319
+ () =>
1320
+ this.gateway.sendText(room.roomId, NEW_CONVERSATION_TEXT, {
1321
+ priority: 'interactive',
1322
+ durable: false,
1323
+ txnId,
1324
+ }),
1325
+ {
1326
+ onRetry: (err, attempt, delayMs) =>
1327
+ console.warn(
1328
+ `[user-do] session marker send failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${
1329
+ err instanceof Error ? err.message : String(err)
1330
+ }`,
1331
+ ),
1332
+ },
1333
+ );
1334
+ }
1335
+ }
1336
+ sessionId ??= `s_${crypto.randomUUID()}`;
1337
+ const row = await sessions.createSession({
1338
+ sessionId,
1339
+ roomId,
1340
+ oracleName: this.core.identity.name,
1341
+ oracleDid: this.env.ORACLE_DID,
1342
+ oracleEntityDid: this.core.identity.entityDid,
1343
+ });
1344
+ this.markDirty();
1345
+ return toSummary(row);
1346
+ }
1347
+
1348
+ async listSessions(
1349
+ identity: TurnIdentity,
1350
+ o: { limit?: number; offset?: number } = {},
1351
+ ): Promise<{ sessions: SessionSummary[]; total: number }> {
1352
+ await this.ready(identity);
1353
+ // Task runs re-enter the agent as `task:<id>` sessions; like the Node
1354
+ // runtime (which lists only the user's main room) keep them out of
1355
+ // the user's session list.
1356
+ const { sessions, total } = await this.sessions!.listSessions(
1357
+ undefined,
1358
+ o.limit ?? 20,
1359
+ o.offset ?? 0,
1360
+ TASK_SESSION_PREFIX,
1361
+ );
1362
+ return { sessions: sessions.map(toSummary), total };
1363
+ }
1364
+
1365
+ async deleteSession(
1366
+ identity: TurnIdentity,
1367
+ sessionId: string,
1368
+ ): Promise<boolean> {
1369
+ await this.ready(identity);
1370
+ this.aborts.get(sessionId)?.abort();
1371
+ // Node indexes a session into the memory engine as it is deleted.
1372
+ // The rows are gone once this RPC returns, so capture the transcript
1373
+ // now and let the background indexer read the snapshot.
1374
+ const doomed = await this.sessions!.getSession(sessionId);
1375
+ if (doomed) {
1376
+ this.historySnapshots.set(sessionId, {
1377
+ session: doomed,
1378
+ messages: await this.historyMessages(sessionId),
1379
+ });
1380
+ this.scheduleHistoryIndexing(sessionId);
1381
+ }
1382
+ const deleted = await this.sessions!.deleteSession(sessionId);
1383
+ if (deleted) {
1384
+ await this.saver!.deleteThread(sessionId);
1385
+ this.markDirty();
1386
+ }
1387
+ return deleted;
1388
+ }
1389
+
1390
+ /** The session transcript as the indexer wants it (summarisation bookkeeping removed). */
1391
+ private async historyMessages(
1392
+ sessionId: string,
1393
+ ): Promise<HistoryMessage[]> {
1394
+ const messages = await this.saver!.listThreadMessages(sessionId);
1395
+ return messages
1396
+ .filter(
1397
+ (m) => !isSummarizationMessage(m) && !isAttachmentViewMessage(m),
1398
+ )
1399
+ .map((m) => ({ type: m.type, content: contentToText(m.content) }));
1400
+ }
1401
+
1402
+ /**
1403
+ * Fire-and-forget session-history indexing (`SessionHistoryIndexer`).
1404
+ * The watermark lives in this object's storage (`history:processed:<id>`),
1405
+ * seeded from the row's `lastProcessedCount` for files migrated from Node;
1406
+ * `touchSession` is not used because it bumps `last_updated_at` and
1407
+ * would reorder the session list.
1408
+ */
1409
+ private scheduleHistoryIndexing(sessionId: string): void {
1410
+ const ambient = this.ambient;
1411
+ const userDid = this.userDid;
1412
+ if (!ambient || !this.sessions || !userDid) return;
1413
+ // The store/saver are looked up per call, not captured: a working-copy
1414
+ // reset re-opens the database while a run is in flight.
1415
+ this.historyIndexer ??= new SessionHistoryIndexer({
1416
+ memoryEngineUrl: (() => {
1417
+ const v = this.core.validatedEnv['MEMORY_ENGINE_URL'];
1418
+ return typeof v === 'string' && v.length > 0 ? v : undefined;
1419
+ })(),
1420
+ getSession: async (id) => {
1421
+ const snapshot = this.historySnapshots.get(id);
1422
+ const row =
1423
+ snapshot?.session ?? (await this.sessions?.getSession(id));
1424
+ if (!row) return undefined;
1425
+ return {
1426
+ ...(row.title !== undefined ? { title: row.title } : {}),
1427
+ ...(row.roomId !== undefined ? { roomId: row.roomId } : {}),
1428
+ // The same `last_processed_count` column the Node runtime keeps,
1429
+ // so a migrated file continues where it left off either way.
1430
+ lastProcessedCount: row.lastProcessedCount ?? 0,
1431
+ };
1432
+ },
1433
+ listMessages: async (id) =>
1434
+ this.historySnapshots.get(id)?.messages ??
1435
+ (await this.historyMessages(id)),
1436
+ setProcessedCount: async (id, count) => {
1437
+ if (this.historySnapshots.has(id)) return; // deleted: nothing to advance
1438
+ await this.sessions?.setLastProcessedCount(id, count);
1439
+ this.markDirty();
1440
+ },
1441
+ resolveUserRoom: () =>
1442
+ this.gateway.resolveUserRoom(userDid).catch(() => null),
1443
+ ucan: {
1444
+ hasSigningKey: () => ambient.ucan.hasSigningKey(),
1445
+ resolveServiceDid: (url) => ambient.ucan.resolveServiceDid(url),
1446
+ mintInvocation: (target, opts) =>
1447
+ ambient.ucan.mintInvocation(userDid, target, opts),
1448
+ },
1449
+ speakerLabels: async (roomId) => {
1450
+ const prefs = await this.preferences
1451
+ ?.get(roomId)
1452
+ .catch(() => undefined);
1453
+ const userName =
1454
+ typeof prefs?.userName === 'string' ? prefs.userName.trim() : '';
1455
+ const agentName =
1456
+ typeof prefs?.agentName === 'string' ? prefs.agentName.trim() : '';
1457
+ return {
1458
+ user: userName || 'Me',
1459
+ oracle: agentName || this.core.identity.name.trim() || 'Oracle',
1460
+ };
1461
+ },
1462
+ logger: console,
1463
+ });
1464
+ const indexer = this.historyIndexer;
1465
+ this.ctx.waitUntil(
1466
+ indexer
1467
+ .process(sessionId)
1468
+ .catch((err: unknown) => {
1469
+ console.error(
1470
+ `[user-do] session-history indexing failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
1471
+ );
1472
+ })
1473
+ .finally(() => {
1474
+ if (this.historySnapshots.has(sessionId)) {
1475
+ this.historySnapshots.delete(sessionId);
1476
+ }
1477
+ }),
1478
+ );
1479
+ }
1480
+
1481
+ async listMessages(
1482
+ identity: TurnIdentity,
1483
+ sessionId: string,
1484
+ ): Promise<string> {
1485
+ await this.ready(identity);
1486
+ const messages = (await this.saver!.listThreadMessages(sessionId)).filter(
1487
+ (m) => !isSummarizationMessage(m),
1488
+ );
1489
+ const { messages: dtos } = await transformTranscript(messages);
1490
+ return JSON.stringify(dtos);
1491
+ }
1492
+
1493
+ /**
1494
+ * The shell deposited a (new) delegation for this user: adopt it now so
1495
+ * header-less turns mint from it immediately. Does not boot the object.
1496
+ */
1497
+ async setDelegation(
1498
+ userDid: string,
1499
+ raw: string,
1500
+ expiration?: number,
1501
+ ): Promise<void> {
1502
+ this.delegations.set(userDid, { raw });
1503
+ this.delegationMissUntil = 0;
1504
+ await this.ctx.storage.put(META_DELEGATION, {
1505
+ raw,
1506
+ at: Date.now(),
1507
+ ...(typeof expiration === 'number' ? { expiration } : {}),
1508
+ } satisfies StoredDelegation);
1509
+ }
1510
+
1511
+ /**
1512
+ * The user revoked their delegation: forget the cached copy so plugin
1513
+ * mints stop at once (Node's `revokeDelegationForUser` clears its cache
1514
+ * for the same reason). The next header-less turn re-reads the room
1515
+ * state, which the shell has already cleared.
1516
+ */
1517
+ async clearDelegation(userDid: string): Promise<void> {
1518
+ this.delegations.delete(userDid);
1519
+ this.delegationMissUntil = 0;
1520
+ await this.ctx.storage.delete(META_DELEGATION);
1521
+ }
1522
+
1523
+ async finishWorkStatus(
1524
+ requestId: string,
1525
+ phase: 'done' | 'superseded',
1526
+ ): Promise<void> {
1527
+ this.workStatus?.finish(requestId, phase);
1528
+ for (const [sessionId, inFlight] of this.matrixTurns)
1529
+ if (inFlight === requestId) this.matrixTurns.delete(sessionId);
1530
+ }
1531
+
1532
+ /**
1533
+ * Emit a single throttled `ixo.oracle.delegation_required` event into the
1534
+ * user's room when a Matrix turn finds no valid delegation; the web app
1535
+ * listens for it and opens the authorize modal in place. Throttled per
1536
+ * object (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws.
1537
+ */
1538
+ private async maybePromptReauth(
1539
+ userDid: string,
1540
+ roomId: string,
1541
+ ): Promise<void> {
1542
+ try {
1543
+ const raw = this.env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
1544
+ const throttleSeconds =
1545
+ typeof raw === 'string' &&
1546
+ Number.isFinite(Number(raw)) &&
1547
+ Number(raw) > 0
1548
+ ? Number(raw)
1549
+ : DEFAULT_REAUTH_THROTTLE_SECONDS;
1550
+ const last = await this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT);
1551
+ if (last !== undefined && Date.now() - last < throttleSeconds * 1000)
1552
+ return;
1553
+ await this.ctx.storage.put(META_REAUTH_PROMPT_AT, Date.now());
1554
+ await this.gateway.sendEvent(
1555
+ roomId,
1556
+ 'ixo.oracle.delegation_required',
1557
+ JSON.stringify({
1558
+ oracleEntityDid: this.core.identity.entityDid,
1559
+ oracleDid: this.env.ORACLE_DID,
1560
+ }),
1561
+ );
1562
+ console.log(
1563
+ `[user-do] sent ixo.oracle.delegation_required to ${roomId} for ${userDid}`,
1564
+ );
1565
+ } catch (err) {
1566
+ console.warn(
1567
+ `[user-do] delegation-required event failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
1568
+ );
1569
+ }
1570
+ }
1571
+
1572
+ /** Operator probe (`GET /debug/delegation`): what header-less turns would mint from. */
1573
+ async delegationStatus(userDid: string): Promise<{
1574
+ present: boolean;
1575
+ expiration?: number;
1576
+ source: 'memory' | 'storage' | 'none';
1577
+ }> {
1578
+ const stored =
1579
+ await this.ctx.storage.get<StoredDelegation>(META_DELEGATION);
1580
+ const inMemory = this.delegations.get(userDid);
1581
+ if (inMemory) {
1582
+ return {
1583
+ present: true,
1584
+ source: 'memory',
1585
+ ...(typeof stored?.expiration === 'number'
1586
+ ? { expiration: stored.expiration }
1587
+ : {}),
1588
+ };
1589
+ }
1590
+ if (stored?.raw) {
1591
+ return {
1592
+ present: true,
1593
+ source: 'storage',
1594
+ ...(typeof stored.expiration === 'number'
1595
+ ? { expiration: stored.expiration }
1596
+ : {}),
1597
+ };
1598
+ }
1599
+ return { present: false, source: 'none' };
1600
+ }
1601
+
1602
+ async abortTurn(sessionId: string): Promise<boolean> {
1603
+ const controller = this.aborts.get(sessionId);
1604
+ if (!controller) return false;
1605
+ controller.abort();
1606
+ this.aborts.delete(sessionId);
1607
+ return true;
1608
+ }
1609
+
1610
+ async runTurn(req: TurnRequest): Promise<TurnResult> {
1611
+ await this.ready(req.identity);
1612
+ // Matrix turns drive a `work_status` card in the user's thread; a newer
1613
+ // message on the same session supersedes the previous card (the
1614
+ // previous turn itself is aborted by prepareTurn).
1615
+ const card =
1616
+ req.client === 'matrix' && req.roomId && req.eventId && this.workStatus
1617
+ ? this.workStatus
1618
+ : null;
1619
+ if (card && req.roomId && req.eventId) {
1620
+ const previous = this.matrixTurns.get(req.sessionId);
1621
+ if (previous && previous !== req.requestId)
1622
+ card.finish(previous, 'superseded');
1623
+ this.matrixTurns.set(req.sessionId, req.requestId);
1624
+ card.beginTurn({
1625
+ requestId: req.requestId,
1626
+ roomId: req.roomId,
1627
+ threadId: req.threadId ?? req.eventId,
1628
+ sessionId: req.sessionId,
1629
+ forEventId: req.eventId,
1630
+ });
1631
+ card.emit(req.requestId, 'routing');
1632
+ }
1633
+ const { agent, stateInput, config, abortController, turnDisposables } =
1634
+ await this.prepareTurn(req, {
1635
+ message: req.message,
1636
+ timezone: req.identity.timezone,
1637
+ model: req.model,
1638
+ attachments: req.attachments,
1639
+ });
1640
+ try {
1641
+ const result = (await agent.invoke(stateInput, config)) as {
1642
+ messages?: BaseMessage[];
1643
+ };
1644
+ const messages = result.messages ?? [];
1645
+ const text = lastAiText(messages);
1646
+ await this.afterTurn(req.sessionId, messages);
1647
+ await this.runTurnDisposables(turnDisposables);
1648
+ card?.emit(req.requestId, 'delivering');
1649
+ const toolCalls = messages
1650
+ .filter((m): m is AIMessage => m.type === 'ai')
1651
+ .flatMap((m) =>
1652
+ (m.tool_calls ?? []).map((t) => ({
1653
+ name: t.name,
1654
+ status: 'done' as const,
1655
+ })),
1656
+ );
1657
+ return {
1658
+ sessionId: req.sessionId,
1659
+ requestId: req.requestId,
1660
+ text,
1661
+ ...(lastAiMessageId(messages) !== undefined
1662
+ ? { messageId: lastAiMessageId(messages) }
1663
+ : {}),
1664
+ toolCalls,
1665
+ };
1666
+ } finally {
1667
+ if (this.aborts.get(req.sessionId) === abortController)
1668
+ this.aborts.delete(req.sessionId);
1669
+ }
1670
+ }
1671
+
1672
+ /**
1673
+ * One bounded blob-compaction step. Returns true while work remains.
1674
+ * On completion: reclaim pages and export the (smaller) file.
1675
+ */
1676
+ private async compactionTick(db: DoSqliteDatabase): Promise<boolean> {
1677
+ this.compactDone ??=
1678
+ (await this.ctx.storage.get<boolean>(META_COMPACT_DONE)) ?? false;
1679
+ if (this.compactDone) return false;
1680
+ this.compactCursors ??=
1681
+ (await this.ctx.storage.get<CompactCursors>(META_COMPACT_CURSORS)) ??
1682
+ {};
1683
+ const step = await compactStep(db, COMPACT_BATCH, this.compactCursors);
1684
+ this.compactCursors = step.cursors;
1685
+ await this.ctx.storage.put(META_COMPACT_CURSORS, step.cursors);
1686
+ if (step.savedBytes > 0) {
1687
+ const prior =
1688
+ (await this.ctx.storage.get<number>(META_COMPACT_SAVED)) ?? 0;
1689
+ await this.ctx.storage.put(META_COMPACT_SAVED, prior + step.savedBytes);
1690
+ }
1691
+ if (!step.done) return true;
1692
+ const { vacuumed } = await finishCompaction(db, {
1693
+ fileSize: db.fileSize,
1694
+ });
1695
+ this.compactDone = true;
1696
+ await this.ctx.storage.put(META_COMPACT_DONE, true);
1697
+ const saved =
1698
+ (await this.ctx.storage.get<number>(META_COMPACT_SAVED)) ?? 0;
1699
+ if (saved > 0) {
1700
+ console.log(
1701
+ `[user-do] blob compaction complete for ${this.userDid}: ${saved} bytes saved (${vacuumed} vacuum), exporting the compacted file`,
1702
+ );
1703
+ // One export at the end, not per batch — flushing a multi-GB file
1704
+ // after every 300 rows would dwarf the savings.
1705
+ this.markDirty();
1706
+ }
1707
+ return false;
1708
+ }
1709
+
1710
+ /**
1711
+ * Slack watermark alert when the working copy crosses a new whole-GB
1712
+ * threshold toward the 10 GB DO cap. Fire-and-forget off the flush path.
1713
+ */
1714
+ private checkSizeAlert(fileBytes: number): void {
1715
+ const webhook = this.env.SLACK_ALERT_WEBHOOK_URL;
1716
+ const userDid = this.userDid;
1717
+ if (!webhook || !userDid) return;
1718
+ void (async () => {
1719
+ this.sizeAlertGb ??=
1720
+ (await this.ctx.storage.get<number>(META_SIZE_ALERT_GB)) ?? 0;
1721
+ const gb = crossedGbThreshold(fileBytes, this.sizeAlertGb);
1722
+ if (gb === null) return;
1723
+ this.sizeAlertGb = gb;
1724
+ await this.ctx.storage.put(META_SIZE_ALERT_GB, gb);
1725
+ await postSlackAlert(
1726
+ webhook,
1727
+ storageAlertText({
1728
+ oracleName: this.core.identity.name,
1729
+ oracleDid: this.env.ORACLE_DID,
1730
+ userDid,
1731
+ fileBytes,
1732
+ gb,
1733
+ }),
1734
+ );
1735
+ console.warn(
1736
+ `[user-do] storage watermark alert sent: ${userDid} crossed ${gb} GB`,
1737
+ );
1738
+ })().catch((err: unknown) => {
1739
+ console.warn(
1740
+ `[user-do] size alert failed: ${err instanceof Error ? err.message : String(err)}`,
1741
+ );
1742
+ });
1743
+ }
1744
+
1745
+ /**
1746
+ * Push the working copy to the user's owner store — without ever holding
1747
+ * the file in memory:
1748
+ *
1749
+ * 1. nothing written since the last upload (VFS write generation) →
1750
+ * done, no reads at all;
1751
+ * 2. otherwise pin a snapshot (SQLite keeps committing; the VFS shadows
1752
+ * the pages the snapshot needs) and hash it in one streamed pass —
1753
+ * same checksum as last time → done, no upload;
1754
+ * 3. otherwise the store streams the snapshot upstream (gzip → tus
1755
+ * parts → temp path → swap; see `IxoVfsOwnerStore.save`).
1756
+ *
1757
+ * A failed upload leaves the copy dirty for the next tick; after
1758
+ * `FLUSH_FALLBACK_AFTER_FAILURES` consecutive failures the legacy Matrix
1759
+ * media path takes the snapshot instead, loudly. Single-flight: a call
1760
+ * while one runs joins it.
1761
+ */
1762
+ async flushToOwnerStore(): Promise<FlushResult> {
1763
+ if (this.flushInFlight) return this.flushInFlight;
1764
+ const run = this.flushOnce().finally(() => {
1765
+ this.flushInFlight = null;
1766
+ });
1767
+ this.flushInFlight = run;
1768
+ return run;
1769
+ }
1770
+
1771
+ private async flushOnce(): Promise<FlushResult> {
1772
+ const db = this.db;
1773
+ const store = this.ownerStore;
1774
+ if (!db || !store) return { uploaded: false, bytes: 0 };
1775
+ this.checkSizeAlert(db.fileSize);
1776
+ const knownEtag = await this.ctx.storage.get<string>(META_OWNER_ETAG);
1777
+ const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
1778
+ if (uploadedGen !== undefined && uploadedGen === db.writeGeneration) {
1779
+ await this.clearDirty();
1780
+ return {
1781
+ uploaded: false,
1782
+ bytes: db.fileSize,
1783
+ etag: knownEtag,
1784
+ skipped: 'unchanged-generation',
1785
+ };
1786
+ }
1787
+
1788
+ const snapshot = await db.snapshot();
1789
+ try {
1790
+ const checksum = await sha256OfStream(snapshot.open());
1791
+ const last = await this.ctx.storage.get<string>(META_LAST_CHECKSUM);
1792
+ if (last === checksum) {
1793
+ await this.ctx.storage.put(META_UPLOADED_GEN, snapshot.generation);
1794
+ await this.clearDirty();
1795
+ return {
1796
+ uploaded: false,
1797
+ bytes: snapshot.size,
1798
+ etag: knownEtag,
1799
+ skipped: 'unchanged-checksum',
1800
+ };
1801
+ }
1802
+
1803
+ let saved: { etag: string; bytes: number };
1804
+ try {
1805
+ saved = await store.save(snapshot);
1806
+ } catch (err) {
1807
+ // Never diverted anywhere else: the working copy in Durable Object
1808
+ // storage is durable, stays dirty, and is retried. Loud from the
1809
+ // third consecutive failure so an outage (or a user without an
1810
+ // `ixo:filesystem` delegation) is visible in the logs.
1811
+ const failures =
1812
+ ((await this.ctx.storage.get<number>(META_FLUSH_FAILURES)) ?? 0) +
1813
+ 1;
1814
+ await this.ctx.storage.put(META_FLUSH_FAILURES, failures);
1815
+ const detail = err instanceof Error ? err.message : String(err);
1816
+ const line = `[user-do] owner-store flush failed (${failures} in a row) for ${this.userDid} — working copy kept dirty, retrying in ${FLUSH_RETRY_DELAY_MS / 60_000} min: ${detail}`;
1817
+ if (failures >= FLUSH_FAILURES_ERROR_THRESHOLD) console.error(line);
1818
+ else console.warn(line);
1819
+ throw err;
1820
+ }
1821
+ await this.ctx.storage.put(META_OWNER_ETAG, saved.etag);
1822
+ await this.ctx.storage.put(META_LAST_CHECKSUM, checksum);
1823
+ await this.ctx.storage.put(META_LAST_FLUSH, Date.now());
1824
+ await this.ctx.storage.put(META_UPLOADED_GEN, snapshot.generation);
1825
+ await this.ctx.storage.delete(META_FLUSH_FAILURES);
1826
+ await this.clearDirty();
1827
+ console.log(
1828
+ `[user-do] flushed ${snapshot.size} bytes (${saved.bytes} sent) to ${store.kind} for ${this.userDid} (${saved.etag})`,
1829
+ );
1830
+ const legacyRemoved = await this.removeLegacyCopyIfAny(saved.etag);
1831
+ return {
1832
+ uploaded: true,
1833
+ bytes: saved.bytes,
1834
+ etag: saved.etag,
1835
+ ...(legacyRemoved ? { legacyRemoved } : {}),
1836
+ };
1837
+ } finally {
1838
+ snapshot.close();
1839
+ }
1840
+ }
1841
+
1842
+ /**
1843
+ * Remove the user's legacy Matrix copy once VFS is confirmed to hold
1844
+ * `etag` (just uploaded, or just loaded from VFS). Runs at most until it
1845
+ * succeeds or finds no legacy copy — then `META_LEGACY_CLEARED` stops the
1846
+ * lookups for good. Errors are logged and retried on the next occasion.
1847
+ */
1848
+ private async removeLegacyCopyIfAny(etag: string): Promise<boolean> {
1849
+ const store = this.ownerStore;
1850
+ if (!store?.removeLegacyCopy) return false;
1851
+ if (await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED))
1852
+ return false;
1853
+ // Defence in depth: never drop the backstop for an empty working copy —
1854
+ // a zero-turn file is not worth confirming against, and redacting here
1855
+ // would strip a migrated user's only history if VFS held a stub.
1856
+ if (this.db && (await this.localTurnCount(this.db)) === 0) return false;
1857
+ try {
1858
+ const removed = await store.removeLegacyCopy(etag);
1859
+ await this.ctx.storage.put(META_LEGACY_CLEARED, true);
1860
+ if (removed) {
1861
+ console.log(
1862
+ `[user-do] legacy Matrix copy removed for ${this.userDid}; VFS is the only copy`,
1863
+ );
1864
+ }
1865
+ return removed;
1866
+ } catch (err) {
1867
+ console.warn(
1868
+ `[user-do] legacy Matrix copy check failed for ${this.userDid} (will retry): ${
1869
+ err instanceof Error ? err.message : String(err)
1870
+ }`,
1871
+ );
1872
+ return false;
1873
+ }
1874
+ }
1875
+
1876
+ /** Run (and forget) the cleanups a turn registered; each at most once. */
1877
+ private async runTurnDisposables(
1878
+ disposables: Set<() => void | Promise<void>>,
1879
+ ): Promise<void> {
1880
+ const pending = [...disposables];
1881
+ disposables.clear();
1882
+ if (pending.length > 0)
1883
+ console.log(
1884
+ `[user-do] turn cleanup: running ${pending.length} dispose(s)`,
1885
+ );
1886
+ for (const dispose of pending) {
1887
+ try {
1888
+ await dispose();
1889
+ } catch (err) {
1890
+ console.warn(
1891
+ `[user-do] turn cleanup failed: ${err instanceof Error ? err.message : String(err)}`,
1892
+ );
1893
+ }
1894
+ }
1895
+ }
1896
+
1897
+ private async clearDirty(): Promise<void> {
1898
+ // A write that landed while the snapshot was being uploaded already
1899
+ // re-armed the alarm through `markDirty`; only clear when the file's
1900
+ // generation still matches what was just recorded.
1901
+ const uploadedGen = await this.ctx.storage.get<number>(META_UPLOADED_GEN);
1902
+ if (
1903
+ this.db &&
1904
+ uploadedGen !== undefined &&
1905
+ uploadedGen !== this.db.writeGeneration
1906
+ )
1907
+ return;
1908
+ this.dirty = false;
1909
+ await this.ctx.storage.delete(META_DIRTY);
1910
+ }
1911
+
1912
+ async resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }> {
1913
+ const userDid =
1914
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
1915
+ // On a cold (evicted) object the in-memory dirty flag is gone — boot and
1916
+ // consult the persisted marker, or the wipe below discards changes the
1917
+ // owner store never received.
1918
+ if (!this.db && userDid) await this.ready({ userDid });
1919
+ if (
1920
+ this.dirty ||
1921
+ (await this.ctx.storage.get<boolean>(META_DIRTY)) === true
1922
+ ) {
1923
+ this.dirty = true;
1924
+ await this.flushToOwnerStore();
1925
+ }
1926
+ await this.wipeWorkingCopy(this.db);
1927
+ this.db = null;
1928
+ this.saver = null;
1929
+ this.sessions = null;
1930
+ this.ambient = null;
1931
+ this.secretsService = null;
1932
+ this.byo = null;
1933
+ this.initPromise = null;
1934
+ this.reloadedFromOwnerStore = false;
1935
+ if (userDid) await this.ready({ userDid });
1936
+ return { reloadedFromOwnerStore: this.reloadedFromOwnerStore };
1937
+ }
1938
+
1939
+ /**
1940
+ * `GET /debug/sessions/:id` — the raw session row (incl.
1941
+ * `lastProcessedCount`, which the public listing does not expose) so a
1942
+ * test can observe history indexing without going through the memory
1943
+ * engine.
1944
+ */
1945
+ async debugSession(
1946
+ userDid: string,
1947
+ sessionId: string,
1948
+ ): Promise<SessionRecord | null> {
1949
+ await this.ready({ userDid });
1950
+ return (await this.sessions!.getSession(sessionId)) ?? null;
1951
+ }
1952
+
1953
+ /**
1954
+ * `GET /debug/memory-schema` — fetch the memory engine's tool list with
1955
+ * this user's credentials and return `search_memory_engine`'s raw schema
1956
+ * plus whether it converts to Zod. Same headers the memory plugin sends.
1957
+ */
1958
+ async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
1959
+ await this.ready({ userDid });
1960
+ const ambient = this.ambient;
1961
+ const url = this.core.validatedEnv['MEMORY_MCP_URL'];
1962
+ if (!ambient || typeof url !== 'string' || url.length === 0) {
1963
+ return { error: 'MEMORY_MCP_URL is not configured' };
1964
+ }
1965
+ const memoryDid = await ambient.ucan.resolveServiceDid(url);
1966
+ if (!memoryDid)
1967
+ return { error: 'memory engine DID could not be resolved' };
1968
+ const invocation = await ambient.ucan.mintInvocation(
1969
+ userDid,
1970
+ { did: memoryDid, capability: 'ixo:memory' },
1971
+ { can: 'memory/*' },
1972
+ );
1973
+ if (!invocation)
1974
+ return { error: 'no UCAN invocation for the memory engine' };
1975
+ const room = await this.gateway
1976
+ .resolveUserRoom(userDid)
1977
+ .catch(() => null);
1978
+ const headers: Record<string, string> = {
1979
+ Authorization: `Bearer ${invocation}`,
1980
+ 'X-Auth-Type': 'ucan',
1981
+ 'User-Agent': 'LangChain-MCP-Client/1.0',
1982
+ ...(room?.roomId ? { 'x-room-id': room.roomId } : {}),
1983
+ };
1984
+ try {
1985
+ return (
1986
+ (await fetchMemorySchemaDump(url, headers)) ?? {
1987
+ error: 'search_memory_engine not in the tool list',
1988
+ }
1989
+ );
1990
+ } catch (err) {
1991
+ return { error: err instanceof Error ? err.message : String(err) };
1992
+ }
1993
+ }
1994
+
1995
+ async storageStatus(): Promise<StorageStatus> {
1996
+ const db = this.db;
1997
+ const fileBytes = db?.fileSize ?? 0;
1998
+ const vfs = db?.vfsStats();
1999
+ const usage = db
2000
+ ? await db.pageUsage().catch(() => undefined)
2001
+ : undefined;
2002
+ return {
2003
+ instanceId: this.instanceId,
2004
+ instanceUptimeMs: Date.now() - this.bootedAt,
2005
+ memorySchemaDump: getMemorySchemaDump(),
2006
+ fileBytes,
2007
+ pages: Math.ceil(fileBytes / 4096),
2008
+ ownerEtag: await this.ctx.storage.get<string>(META_OWNER_ETAG),
2009
+ lastFlushAt: await this.ctx.storage.get<number>(META_LAST_FLUSH),
2010
+ dirty: this.dirty,
2011
+ flushInFlight: this.flushInFlight !== null,
2012
+ writeGeneration: db?.writeGeneration,
2013
+ uploadedGeneration:
2014
+ await this.ctx.storage.get<number>(META_UPLOADED_GEN),
2015
+ lastChecksum: await this.ctx.storage.get<string>(META_LAST_CHECKSUM),
2016
+ flushFailures:
2017
+ (await this.ctx.storage.get<number>(META_FLUSH_FAILURES)) ?? 0,
2018
+ lastVacuumAt: await this.ctx.storage.get<number>(META_LAST_VACUUM),
2019
+ legacyCleared:
2020
+ (await this.ctx.storage.get<boolean>(META_LEGACY_CLEARED)) === true,
2021
+ lastAccessAt: await this.ctx.storage.get<number>(META_LAST_ACCESS),
2022
+ indexingInFlight: this.historyIndexer?.inFlightCount ?? 0,
2023
+ activeTurns: this.aborts.size,
2024
+ pendingTimers: timerTrackerInstalled()
2025
+ ? pendingTimers().length
2026
+ : undefined,
2027
+ alarmAt: await this.ctx.storage.getAlarm(),
2028
+ pageCount: usage?.pageCount,
2029
+ freelistCount: usage?.freelistCount,
2030
+ sqliteCacheSize: db
2031
+ ? await db.cacheSizePragma().catch(() => undefined)
2032
+ : undefined,
2033
+ chunkCache: vfs
2034
+ ? {
2035
+ budgetBytes: vfs.cacheBudgetBytes,
2036
+ capacityChunks: vfs.cacheCapacityChunks,
2037
+ cachedBytes: vfs.cachedPages * 4096,
2038
+ hits: vfs.cacheHits,
2039
+ misses: vfs.cacheMisses,
2040
+ storageReads: vfs.storageReads,
2041
+ rowsRead: vfs.rowsRead,
2042
+ storageWrites: vfs.storageWrites,
2043
+ rowsWritten: vfs.rowsWritten,
2044
+ }
2045
+ : undefined,
2046
+ };
2047
+ }
2048
+
2049
+ async tasksStatus(): Promise<{
2050
+ now: number;
2051
+ alarm: number | null;
2052
+ schedulerActive: boolean;
2053
+ tasks: Array<{
2054
+ id: string;
2055
+ title: string;
2056
+ status: string;
2057
+ nextRunAt: string | null;
2058
+ lastResult: { ok: boolean; summary: string; at: string } | null;
2059
+ consecutiveFailures: number;
2060
+ }>;
2061
+ }> {
2062
+ const records = this.taskScheduler
2063
+ ? await this.taskScheduler.surface.list()
2064
+ : [];
2065
+ return {
2066
+ now: Date.now(),
2067
+ alarm: await this.ctx.storage.getAlarm(),
2068
+ schedulerActive: this.taskScheduler !== null,
2069
+ tasks: records.map((t) => ({
2070
+ id: t.id,
2071
+ title: t.title,
2072
+ status: t.status,
2073
+ nextRunAt: t.nextRunAt ?? null,
2074
+ lastResult: t.lastResult ?? null,
2075
+ consecutiveFailures: t.consecutiveFailures,
2076
+ deliveryRoomId: t.deliveryRoomId ?? null,
2077
+ })),
2078
+ };
2079
+ }
2080
+
2081
+ // ── realtime (socket.io) channel ─────────────────────────────────────
2082
+
2083
+ /**
2084
+ * The socket.io endpoint of this object. Built lazily: the first socket
2085
+ * upgrade, hibernation callback or turn needing `ctx.frontend` creates
2086
+ * it, and its constructor re-adopts sockets that survived a restart.
2087
+ */
2088
+ private get realtime(): RealtimeEndpoint {
2089
+ this.realtimeEndpoint ??= new RealtimeEndpoint({
2090
+ ctx: this.ctx,
2091
+ authenticate: (auth) => {
2092
+ const headers = new Headers();
2093
+ if (auth.invocation) {
2094
+ headers.set('authorization', `Bearer ${auth.invocation}`);
2095
+ headers.set('x-auth-type', 'ucan');
2096
+ }
2097
+ if (auth.ucanDelegation)
2098
+ headers.set('x-ucan-delegation', auth.ucanDelegation);
2099
+ return authenticate(headers, {
2100
+ oracleDid: this.env.ORACLE_DID,
2101
+ blocksyncUri: this.env.BLOCKSYNC_GRAPHQL_URL,
2102
+ maxTtlSeconds: this.env.UCAN_AUTH_MAX_TTL_SECONDS
2103
+ ? Number(this.env.UCAN_AUTH_MAX_TTL_SECONDS)
2104
+ : undefined,
2105
+ });
2106
+ },
2107
+ sessionExists: async (userDid, sessionId) => {
2108
+ await this.ready({ userDid });
2109
+ return Boolean(await this.sessions!.getSession(sessionId));
2110
+ },
2111
+ router: this.events,
2112
+ logger: console,
2113
+ requestAlarm: (at) => this.requestAlarm(at),
2114
+ // Node indexes a session into the memory engine when its last socket
2115
+ // disconnects (`WsService.removeClientConnection`); same trigger here.
2116
+ onSessionDrained: (sessionId) =>
2117
+ this.scheduleHistoryIndexing(sessionId),
2118
+ });
2119
+ return this.realtimeEndpoint;
2120
+ }
2121
+
2122
+ async realtimeStatus(): Promise<RealtimeStatus> {
2123
+ return {
2124
+ ...this.realtime.status(),
2125
+ ...(timerTrackerInstalled() ? { pendingTimers: pendingTimers() } : {}),
2126
+ };
2127
+ }
2128
+
2129
+ /**
2130
+ * With `ORACLE_DEBUG_ROUTES=true`, record every live timer so
2131
+ * `GET /debug/realtime` can show what keeps the object resident (a
2132
+ * pending timer blocks WebSocket hibernation). Idempotent per isolate.
2133
+ */
2134
+ private installDebugTimerTracker(): void {
2135
+ if (this.env.ORACLE_DEBUG_ROUTES === 'true') installTimerTracker();
2136
+ }
2137
+
2138
+ override async webSocketMessage(
2139
+ ws: WebSocket,
2140
+ message: string | ArrayBuffer,
2141
+ ): Promise<void> {
2142
+ await this.realtime.onMessage(ws, message);
2143
+ }
2144
+
2145
+ override async webSocketClose(ws: WebSocket): Promise<void> {
2146
+ this.realtime.onClose(ws);
2147
+ }
2148
+
2149
+ override async webSocketError(
2150
+ ws: WebSocket,
2151
+ error: unknown,
2152
+ ): Promise<void> {
2153
+ this.realtime.onError(ws, error);
2154
+ }
2155
+
2156
+ // ── HTTP surface (used by the shell for streaming turns) ─────────────
2157
+
2158
+ override async fetch(request: Request): Promise<Response> {
2159
+ this.installDebugTimerTracker();
2160
+ const url = new URL(request.url);
2161
+ if (url.pathname.startsWith('/socket.io')) {
2162
+ return this.realtime.handleUpgrade(request, url);
2163
+ }
2164
+ if (url.pathname.startsWith('/byo-llm/')) {
2165
+ const byoIdentity = JSON.parse(
2166
+ request.headers.get('x-identity') ?? '{}',
2167
+ ) as TurnIdentity;
2168
+ if (!byoIdentity.userDid) {
2169
+ return Response.json(
2170
+ { statusCode: 400, message: 'x-identity is required' },
2171
+ { status: 400 },
2172
+ );
2173
+ }
2174
+ try {
2175
+ await this.ready(byoIdentity);
2176
+ } catch (err) {
2177
+ return readyFailureResponse(err);
2178
+ }
2179
+ return handleByoRequest(
2180
+ this.byo!,
2181
+ byoIdentity.userDid,
2182
+ request,
2183
+ console,
2184
+ );
2185
+ }
2186
+ const match = /^\/turn\/([^/]+)$/.exec(url.pathname);
2187
+ if (!match || request.method !== 'POST')
2188
+ return new Response('Not found', { status: 404 });
2189
+ const sessionId = decodeURIComponent(match[1]!);
2190
+ const identity = JSON.parse(
2191
+ request.headers.get('x-identity') ?? '{}',
2192
+ ) as TurnIdentity;
2193
+ const requestId =
2194
+ request.headers.get('x-request-id') ?? crypto.randomUUID();
2195
+ const parsed = parseTurnBody(await request.text());
2196
+ if (!parsed.ok) {
2197
+ return Response.json(
2198
+ { statusCode: parsed.status, message: parsed.message },
2199
+ { status: parsed.status },
2200
+ );
2201
+ }
2202
+ const body = parsed.body;
2203
+ try {
2204
+ await this.ready(identity);
2205
+ } catch (err) {
2206
+ return readyFailureResponse(err);
2207
+ }
2208
+ if (!(await this.sessions!.getSession(sessionId))) {
2209
+ return Response.json(
2210
+ { statusCode: 404, message: `Session ${sessionId} not found` },
2211
+ { status: 404 },
2212
+ );
2213
+ }
2214
+ // Validate attachments up front so a malformed payload is a 400 (the
2215
+ // Node DTO's behaviour), not a failed turn.
2216
+ let attachments: ReturnType<typeof parseAttachmentInputs>;
2217
+ try {
2218
+ attachments = parseAttachmentInputs(body.attachments);
2219
+ } catch (err) {
2220
+ return Response.json(
2221
+ {
2222
+ statusCode: 400,
2223
+ message: err instanceof Error ? err.message : String(err),
2224
+ },
2225
+ { status: 400 },
2226
+ );
2227
+ }
2228
+ const req: TurnRequest = {
2229
+ identity: { ...identity, timezone: body.timezone ?? identity.timezone },
2230
+ sessionId,
2231
+ message: body.message,
2232
+ client: 'portal',
2233
+ requestId,
2234
+ model: body.model,
2235
+ metadata: body.metadata ? JSON.stringify(body.metadata) : undefined,
2236
+ ...(attachments.length > 0 ? { attachments } : {}),
2237
+ };
2238
+ const stream = body.stream !== false;
2239
+ if (!stream) {
2240
+ this.replayToRoom(req, body.message, 'user');
2241
+ try {
2242
+ const result = await this.runTurn(req);
2243
+ this.replayToRoom(req, result.text, 'oracle');
2244
+ // Node's `SendMessageResponse.message` is `{ type, content, id }`.
2245
+ const payload: Record<string, unknown> = {
2246
+ message: {
2247
+ type: 'ai',
2248
+ content: result.text,
2249
+ id: result.messageId ?? requestId,
2250
+ },
2251
+ sessionId,
2252
+ requestId,
2253
+ };
2254
+ if (body.returnAllMessages)
2255
+ payload.messages = JSON.parse(
2256
+ await this.listMessages(identity, sessionId),
2257
+ );
2258
+ return Response.json(payload, {
2259
+ headers: { 'x-request-id': requestId },
2260
+ });
2261
+ } catch (err) {
2262
+ return Response.json(
2263
+ {
2264
+ statusCode: 500,
2265
+ message: err instanceof Error ? err.message : 'Turn failed',
2266
+ requestId,
2267
+ },
2268
+ { status: 500, headers: { 'x-request-id': requestId } },
2269
+ );
2270
+ }
2271
+ }
2272
+
2273
+ const {
2274
+ agent,
2275
+ stateInput,
2276
+ config,
2277
+ abortController,
2278
+ byoNotice,
2279
+ byoProvider,
2280
+ turnDisposables,
2281
+ } = await this.prepareTurn(req, body);
2282
+ this.replayToRoom(req, body.message, 'user');
2283
+ const agActionNames = new Set((body.agActions ?? []).map((a) => a.name));
2284
+ const sink = {
2285
+ emit: (eventName: string, payload: Record<string, unknown>) => {
2286
+ void payload;
2287
+ void eventName;
2288
+ },
2289
+ };
2290
+ let write: ((name: string, payload: unknown) => void) | null = null;
2291
+ sink.emit = (eventName, payload) => write?.(eventName, payload);
2292
+ this.events.register(sessionId, sink);
2293
+ const events = agent.streamEvents(stateInput, {
2294
+ ...config,
2295
+ version: 'v2',
2296
+ });
2297
+ const capture: BaseMessage[] = [];
2298
+ const sse = createSseTurnStream({
2299
+ events: tapMessages(events, capture),
2300
+ sessionId,
2301
+ requestId,
2302
+ abortController,
2303
+ mirror: (eventName, payload) =>
2304
+ this.events.emitToTaps(eventName, payload),
2305
+ agActionNames,
2306
+ byoProvider,
2307
+ onComplete: async () => {
2308
+ this.replayToRoom(req, lastAiText(capture), 'oracle');
2309
+ await this.afterTurn(sessionId, capture);
2310
+ await this.runTurnDisposables(turnDisposables);
2311
+ },
2312
+ onError: (err) => {
2313
+ console.error(`[user-do] turn ${requestId} failed:`, err);
2314
+ void this.runTurnDisposables(turnDisposables);
2315
+ },
2316
+ log: (m) => console.log(`[user-do] ${m}`),
2317
+ });
2318
+ // Let plugin-emitted events (ctx.emit.*) ride the same SSE stream.
2319
+ const [clientBranch, emitterBranch] = sse.tee();
2320
+ void emitterBranch.cancel();
2321
+ write = null;
2322
+ const merged = mergeWithEmitter(clientBranch, (register) => {
2323
+ write = register;
2324
+ });
2325
+ // A BYO turn that degraded to the platform model tells the user so —
2326
+ // the Node runtime emits the same notice on the SSE `error` channel.
2327
+ if (byoNotice) sink.emit('error', { ...byoNotice, sessionId, requestId });
2328
+ const cleanup = () => {
2329
+ this.events.unregister(sessionId, sink);
2330
+ if (this.aborts.get(sessionId) === abortController)
2331
+ this.aborts.delete(sessionId);
2332
+ };
2333
+ const finalStream = merged.pipeThrough(
2334
+ new TransformStream<Uint8Array, Uint8Array>({
2335
+ transform: (chunk, controller) => controller.enqueue(chunk),
2336
+ flush: cleanup,
2337
+ }),
2338
+ );
2339
+ return new Response(finalStream, {
2340
+ headers: { ...SSE_HEADERS, 'x-request-id': requestId },
2341
+ });
2342
+ }
2343
+
2344
+ // ── turn plumbing ──────────────────────────────────────────────────────
2345
+
2346
+ /** Matrix media access for the attachments pipeline, through the gateway. */
2347
+ private matrixMediaSource(): MatrixMediaSource {
2348
+ return {
2349
+ downloadMxc: (mxc) => this.gateway.downloadMxcMedia(mxc),
2350
+ downloadEvent: (roomId, eventId) =>
2351
+ this.gateway.downloadEventMedia(roomId, eventId),
2352
+ };
2353
+ }
2354
+
2355
+ /**
2356
+ * Session-scoped attachment access for the `view_attachment` tool: a
2357
+ * reference is resolved against the attachments recorded on this
2358
+ * session's own messages (never an arbitrary media id), then downloaded
2359
+ * and routed exactly like a fresh turn would for `model`.
2360
+ */
2361
+ private async attachmentViewSurface(input: {
2362
+ sessionId: string;
2363
+ roomId: string | undefined;
2364
+ model: string;
2365
+ ambientLlm: LlmAdapter;
2366
+ platform: OpenRouterLlmAdapter;
2367
+ signal: AbortSignal;
2368
+ }): Promise<AttachmentViewSurface> {
2369
+ const saver = this.saver!;
2370
+ const offloaded = await saver.hasThreadMessageMatching(input.sessionId, {
2371
+ messageType: 'human',
2372
+ contentContains: ATTACHMENT_PLACEHOLDER_PREFIX,
2373
+ });
2374
+ const findMeta = async (ref: string) => {
2375
+ // The placeholder quotes the ref, so a cheap pre-filter usually
2376
+ // finds the message; a still-inline attachment needs the full scan.
2377
+ const candidates = await saver.listThreadMessagesMatching(
2378
+ input.sessionId,
2379
+ { messageType: 'human', contentContains: ref },
2380
+ );
2381
+ const scan =
2382
+ candidates.length > 0
2383
+ ? candidates
2384
+ : await saver.listThreadMessages(input.sessionId);
2385
+ return scan
2386
+ .filter((m) => m.type === 'human')
2387
+ .flatMap((m) => attachmentMetas(m))
2388
+ .find((meta) => attachmentRef(meta) === ref);
2389
+ };
2390
+ return {
2391
+ offloaded,
2392
+ view: async (ref) => {
2393
+ const meta = await findMeta(ref);
2394
+ if (!meta) {
2395
+ throw new Error(
2396
+ `no attachment with ref "${ref}" in this conversation`,
2397
+ );
2398
+ }
2399
+ const view = await viewAttachment(meta, {
2400
+ source: this.matrixMediaSource(),
2401
+ extraction: this.extractionProvider(
2402
+ input.ambientLlm,
2403
+ input.platform,
2404
+ ),
2405
+ caps: getModelCapabilities(input.model),
2406
+ roomId: input.roomId,
2407
+ signal: input.signal,
2408
+ logger: console,
2409
+ });
2410
+ return { meta, view };
2411
+ },
2412
+ };
2413
+ }
2414
+
2415
+ /**
2416
+ * Attachment payload retention (`src/attachments/retention.ts`), run
2417
+ * once a turn has completed and its checkpoints are written: inline
2418
+ * payloads older than the newest `ATTACHMENT_PAYLOAD_TURNS` user turns
2419
+ * are rewritten in place in the messages table — the rows the next turn
2420
+ * loads, so the model sees the placeholder and the bytes leave the
2421
+ * database. Rows the graph state no longer references (summarised away)
2422
+ * are older than any retained turn and are rewritten too. Idempotent:
2423
+ * a rewritten row holds no inline block and is never selected again.
2424
+ */
2425
+ private async retainAttachmentPayloads(
2426
+ sessionId: string,
2427
+ stateMessages: BaseMessage[],
2428
+ ): Promise<void> {
2429
+ // Nothing captured (an interrupted stream): never offload blindly.
2430
+ if (stateMessages.length === 0) return;
2431
+ const saver = this.saver!;
2432
+ const { retainedIds, rewrites } = applyAttachmentRetention(
2433
+ stateMessages,
2434
+ ATTACHMENT_PAYLOAD_TURNS,
2435
+ );
2436
+ const pending = new Set(rewrites.map((m) => m.id));
2437
+ const stale = await saver.listThreadMessagesMatching(sessionId, {
2438
+ messageType: 'human',
2439
+ contentContains: INLINE_PAYLOAD_NEEDLE,
2440
+ });
2441
+ for (const row of stale) {
2442
+ if (!row.id || retainedIds.has(row.id) || pending.has(row.id)) continue;
2443
+ const rewritten = offloadInlinePayloads(row);
2444
+ if (rewritten) rewrites.push(rewritten);
2445
+ }
2446
+ let replaced = 0;
2447
+ for (const message of rewrites) {
2448
+ if (await saver.replaceThreadMessage(sessionId, message)) replaced += 1;
2449
+ }
2450
+ if (replaced > 0) {
2451
+ console.log(
2452
+ `[user-do] attachments: offloaded the inline payload of ${replaced} message(s) in ${sessionId}`,
2453
+ );
2454
+ }
2455
+ }
2456
+
2457
+ /**
2458
+ * The helper-model provider for attachment extraction: the PLATFORM
2459
+ * adapter's config + its `vision` role, never a BYO credential (Node wires
2460
+ * `FileProcessingService` the same way).
2461
+ */
2462
+ private extractionProvider(
2463
+ ambientLlm: LlmAdapter,
2464
+ platform: OpenRouterLlmAdapter,
2465
+ ): {
2466
+ baseURL: string;
2467
+ apiKey: string;
2468
+ headers: Record<string, string>;
2469
+ model: string;
2470
+ } | null {
2471
+ const adapter = isProviderAdapter(ambientLlm) ? ambientLlm : platform;
2472
+ if (!adapter.providerConfig.apiKey) return null;
2473
+ return {
2474
+ ...adapter.providerConfig,
2475
+ model: adapter.modelForRole('vision'),
2476
+ };
2477
+ }
2478
+
2479
+ /**
2480
+ * Sandbox archive target for this user's attachments — a UCAN minted from
2481
+ * their delegation for `SANDBOX_MCP_URL`, or null when either is missing
2482
+ * (originals are then not archived; the turn still runs).
2483
+ */
2484
+ private async sandboxArchiveConfig(
2485
+ userDid: string,
2486
+ ): Promise<SandboxUploadConfig | null> {
2487
+ const sandboxMcpUrl = this.env.SANDBOX_MCP_URL;
2488
+ if (typeof sandboxMcpUrl !== 'string' || sandboxMcpUrl.length === 0)
2489
+ return null;
2490
+ const delegation = this.delegations.get(userDid);
2491
+ if (!delegation?.raw || !this.ucan) return null;
2492
+ const minted = await this.ucan.createInvocationFromDelegation(
2493
+ delegation.raw,
2494
+ sandboxMcpUrl,
2495
+ { can: '*', with: 'ixo:sandbox' },
2496
+ );
2497
+ if ('error' in minted) {
2498
+ console.warn(
2499
+ `[user-do] attachments: cannot archive to the sandbox for ${userDid}: ${minted.error}`,
2500
+ );
2501
+ return null;
2502
+ }
2503
+ return {
2504
+ sandboxMcpUrl,
2505
+ authHeaders: {
2506
+ Authorization: `Bearer ${minted.invocation}`,
2507
+ 'X-Auth-Type': 'ucan',
2508
+ },
2509
+ };
2510
+ }
2511
+
2512
+ private async prepareTurn(
2513
+ req: TurnRequest,
2514
+ body: Pick<
2515
+ TurnBody,
2516
+ 'message' | 'timezone' | 'model' | 'tools' | 'agActions' | 'attachments'
2517
+ >,
2518
+ ) {
2519
+ const core = this.core;
2520
+ const baseAmbient = this.ambient!;
2521
+ const saver = this.saver!;
2522
+ const sessions = this.sessions!;
2523
+
2524
+ // Per-request model override, resolved BEFORE the agent build because
2525
+ // the BYO leg needs it. Platform ids are gated by the catalog
2526
+ // allow-list; `byo:` ids are validated (and bound to a connected
2527
+ // credential) inside `resolveForTurn`. An unknown id is dropped and
2528
+ // the turn falls back to the default model. (Mirrors the Node
2529
+ // runtime's AgentBuilder.)
2530
+ let requestedModel: string | undefined;
2531
+ let requestedByoModel: string | undefined;
2532
+ if (body.model) {
2533
+ if (isByoModelId(body.model)) {
2534
+ requestedByoModel = body.model;
2535
+ } else if (isAllowedModel(body.model)) {
2536
+ requestedModel = body.model;
2537
+ } else {
2538
+ console.warn(
2539
+ `[user-do] ignoring unknown model "${body.model}" — falling back to the default model.`,
2540
+ );
2541
+ }
2542
+ }
2543
+
2544
+ // BYO credential resolution. An explicit *platform* model choice keeps
2545
+ // the turn platform-paid, so the lookup is skipped entirely; a `byo:`
2546
+ // choice (or no choice at all — the Matrix ingress) resolves the
2547
+ // user's connected credential. No-ops to null when BYO_LLM_ENABLED is
2548
+ // off. Best-effort: a resolution failure degrades to a platform turn.
2549
+ let byoNotice: Record<string, unknown> | undefined;
2550
+ const noteFallback = (notice: Record<string, unknown>): void => {
2551
+ byoNotice = notice;
2552
+ };
2553
+ let byoTurn: ByoTurnState | null = null;
2554
+ if (requestedModel === undefined && this.byo) {
2555
+ byoTurn = await this.byo
2556
+ .resolveForTurn({
2557
+ userDid: req.identity.userDid,
2558
+ requestedModel: requestedByoModel,
2559
+ onNotice: (notice) => noteFallback({ ...notice }),
2560
+ })
2561
+ .catch((err: unknown): ByoTurnState | null => {
2562
+ console.warn(
2563
+ `[user-do] BYO credential resolution failed: ${err instanceof Error ? err.message : String(err)}`,
2564
+ );
2565
+ noteFallback({ ...buildByoFallbackNotice('error') });
2566
+ return null;
2567
+ });
2568
+ }
2569
+ if (byoTurn) {
2570
+ console.log(
2571
+ `[user-do] BYO turn — provider=${byoTurn.provider}, model=${byoTurn.mainModelId}, did=${req.identity.userDid}`,
2572
+ );
2573
+ }
2574
+ // On a BYO turn the swapped adapter serves every role the provider
2575
+ // covers, so the whole turn runs on the user's credential; the
2576
+ // credential lives only in the adapter's closure.
2577
+ const ambient: AmbientServices = byoTurn
2578
+ ? {
2579
+ ...baseAmbient,
2580
+ llm: createByoLlmAdapter(
2581
+ baseAmbient.llm,
2582
+ {
2583
+ credential: byoTurn.credential,
2584
+ mainModelId: byoTurn.mainModelId,
2585
+ chatGptBackend: byoTurn.chatGptBackend,
2586
+ },
2587
+ console,
2588
+ ),
2589
+ }
2590
+ : baseAmbient;
2591
+ const effectiveModel = byoTurn ? byoTurn.byoModelId : requestedModel;
2592
+ if (
2593
+ req.client === 'matrix' &&
2594
+ !(await sessions.getSession(req.sessionId))
2595
+ ) {
2596
+ await sessions.createSession({
2597
+ sessionId: req.sessionId,
2598
+ roomId: req.roomId,
2599
+ oracleName: core.identity.name,
2600
+ oracleDid: this.env.ORACLE_DID,
2601
+ oracleEntityDid: core.identity.entityDid,
2602
+ });
2603
+ }
2604
+ this.aborts.get(req.sessionId)?.abort();
2605
+ const abortController = new AbortController();
2606
+ this.aborts.set(req.sessionId, abortController);
2607
+
2608
+ // Every turn runs inside the user's oracle room, like the Node
2609
+ // runtime's request preparer: plugins key per-room state on it and the
2610
+ // memory engine rejects calls that carry no `x-room-id` (as a generic
2611
+ // "invalid token"). Matrix-ingress turns bring the room; HTTP turns
2612
+ // take it from the session row or resolve the user↔oracle room alias.
2613
+ const sessionRoomId =
2614
+ req.roomId ??
2615
+ (await sessions.getSession(req.sessionId))?.roomId ??
2616
+ (
2617
+ await this.gateway
2618
+ .resolveUserRoom(req.identity.userDid)
2619
+ .catch(() => null)
2620
+ )?.roomId;
2621
+ if (!sessionRoomId) {
2622
+ console.warn(
2623
+ `[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
2624
+ );
2625
+ }
2626
+
2627
+ // A Matrix turn with no usable delegation: ask the web app to open its
2628
+ // "authorize for Matrix" modal (throttled per user, best-effort) — the
2629
+ // Node AgentBuilder's `maybePromptReauth`.
2630
+ if (
2631
+ req.client === 'matrix' &&
2632
+ sessionRoomId &&
2633
+ !this.delegations.get(req.identity.userDid)?.raw
2634
+ ) {
2635
+ void this.maybePromptReauth(req.identity.userDid, sessionRoomId);
2636
+ }
2637
+
2638
+ // Per-room user preferences (tone, language, what to call whom) —
2639
+ // hydrated BEFORE the agent is built so the system prompt sees them
2640
+ // on turn 1, exactly like the Node AgentBuilder. Best-effort: a
2641
+ // preferences read failure never fails the turn.
2642
+ const userPreferences =
2643
+ sessionRoomId && this.preferences
2644
+ ? await this.preferences.get(sessionRoomId).catch((err: unknown) => {
2645
+ console.warn(
2646
+ `[user-do] could not load user preferences for ${sessionRoomId}: ${err instanceof Error ? err.message : String(err)}`,
2647
+ );
2648
+ return undefined;
2649
+ })
2650
+ : undefined;
2651
+
2652
+ // Attachments — the Node MessagesService contract: route by the model's
2653
+ // native capabilities, send images/files inline when it reads them,
2654
+ // otherwise the `vision` role turns them into text on the platform key;
2655
+ // originals are archived to the user's sandbox when one is reachable.
2656
+ const attachmentInputs = parseAttachmentInputs(
2657
+ body.attachments ?? req.attachments,
2658
+ );
2659
+ const attachmentModel =
2660
+ effectiveModel && isAllowedModel(effectiveModel)
2661
+ ? effectiveModel
2662
+ : getDefaultModelId(core.validatedEnv);
2663
+ const prepared =
2664
+ attachmentInputs.length > 0
2665
+ ? await prepareAttachments(
2666
+ {
2667
+ text: body.message,
2668
+ attachments: attachmentInputs,
2669
+ roomId: sessionRoomId,
2670
+ model: attachmentModel,
2671
+ caps: getModelCapabilities(attachmentModel),
2672
+ },
2673
+ {
2674
+ source: this.matrixMediaSource(),
2675
+ extraction: this.extractionProvider(baseAmbient.llm, core.llm),
2676
+ sandbox: await this.sandboxArchiveConfig(req.identity.userDid),
2677
+ signal: abortController.signal,
2678
+ logger: console,
2679
+ },
2680
+ )
2681
+ : null;
2682
+ if (prepared) {
2683
+ console.log(
2684
+ `[user-do] attachments: ${attachmentInputs.length} attached, ${prepared.extracted.length} extracted, ${Array.isArray(prepared.content) ? prepared.content.length - 1 : 0} native`,
2685
+ );
2686
+ }
2687
+
2688
+ // `view_attachment` for this session: payloads older than the newest
2689
+ // `ATTACHMENT_PAYLOAD_TURNS` user turns are placeholders by now (see
2690
+ // `retainAttachmentPayloads`); the tool fetches one again on demand.
2691
+ // Cleanups registered by tools for THIS turn (MCP clients …): run once
2692
+ // the turn ends, however it ends — see `RuntimeContext.onTurnEnd`.
2693
+ const turnDisposables = new Set<() => void | Promise<void>>();
2694
+ abortController.signal.addEventListener('abort', () => {
2695
+ void this.runTurnDisposables(turnDisposables);
2696
+ });
2697
+ const attachmentAccess = await this.attachmentViewSurface({
2698
+ sessionId: req.sessionId,
2699
+ roomId: sessionRoomId,
2700
+ model: attachmentModel,
2701
+ ambientLlm: baseAmbient.llm,
2702
+ platform: core.llm,
2703
+ signal: abortController.signal,
2704
+ });
2705
+
2706
+ const existing = await saver.getTupleWithoutMessages({
2707
+ configurable: { thread_id: req.sessionId },
2708
+ });
2709
+ const priorState = existing?.checkpoint.channel_values ?? {};
2710
+ // Request metadata (editor room, space, session run, entity) → state,
2711
+ // by the Node agent-builder's rules (see turn-metadata.ts).
2712
+ const meta = parseTurnMetadata(req.metadata);
2713
+ const priorMeta = priorMetadataState(priorState);
2714
+
2715
+ // Host page-context / safety-guardrail hooks, resolved against this
2716
+ // object's ambient services (see `OracleWorkerHooks`).
2717
+ const hostRoomTitle = opts.hooks?.getRoomTitle;
2718
+ const hostSafetyModel = opts.hooks?.safetyModel;
2719
+
2720
+ const { agent, context } = await createMainAgent({
2721
+ registries: core.registries,
2722
+ identity: core.identity,
2723
+ config: core.validatedEnv,
2724
+ availablePlugins: core.availablePlugins,
2725
+ byoProvider: byoTurn?.provider,
2726
+ ambient: {
2727
+ ...ambient,
2728
+ attachments: attachmentAccess,
2729
+ onTurnEnd: (dispose) => turnDisposables.add(dispose),
2730
+ },
2731
+ hooks: {
2732
+ // Drives the Matrix `work_status` card (one step per model/tool
2733
+ // call); a no-op for turns that registered no card (HTTP, tasks).
2734
+ middlewares: this.workStatus
2735
+ ? [createWorkStatusMiddleware({ producer: this.workStatus })]
2736
+ : [],
2737
+ ...(hostRoomTitle
2738
+ ? {
2739
+ getRoomTitle: (roomId: string) =>
2740
+ hostRoomTitle(roomId, ambient),
2741
+ }
2742
+ : {}),
2743
+ ...(hostSafetyModel ? { safetyModel: hostSafetyModel(ambient) } : {}),
2744
+ },
2745
+ requestCtx: {
2746
+ user: {
2747
+ did: req.identity.userDid,
2748
+ matrixUserId:
2749
+ req.identity.matrixUserId ??
2750
+ (await this.resolveMatrixUserId(req.identity.userDid)) ??
2751
+ '',
2752
+ ucanDelegation: { raw: req.identity.ucanDelegation ?? '' },
2753
+ timezone: req.identity.timezone,
2754
+ currentTime: new Date().toISOString(),
2755
+ },
2756
+ session: {
2757
+ id: req.sessionId,
2758
+ client: req.client,
2759
+ requestId: req.requestId,
2760
+ roomId: sessionRoomId,
2761
+ },
2762
+ model: effectiveModel,
2763
+ },
2764
+ state: {
2765
+ ...priorState,
2766
+ userPreferences,
2767
+ ...metadataBuildState(meta, priorMeta),
2768
+ // The client-declared surface of THIS request: the portal and
2769
+ // AG-UI plugins turn these into tools at build time
2770
+ // (`getRequestTools` reads `history.state`), so they must be here
2771
+ // and not only in the graph input.
2772
+ browserTools: body.tools ?? [],
2773
+ agActions: body.agActions ?? [],
2774
+ },
2775
+ checkpointer: saver,
2776
+ abortSignal: abortController.signal,
2777
+ });
2778
+
2779
+ const attachmentKwargs =
2780
+ prepared && prepared.metas.length > 0
2781
+ ? { attachment: prepared.metas[0], attachments: prepared.metas }
2782
+ : {};
2783
+ const stateInput = {
2784
+ messages: [
2785
+ new HumanMessage({
2786
+ content: prepared ? prepared.content : body.message,
2787
+ additional_kwargs: {
2788
+ timestamp: new Date().toISOString(),
2789
+ oracleName: core.identity.name,
2790
+ msgFromMatrixRoom: req.client === 'matrix',
2791
+ ...attachmentKwargs,
2792
+ },
2793
+ }),
2794
+ // Extraction-lane text rides as hidden context messages (the
2795
+ // transcript hides AI messages that carry an `attachment`), as on Node.
2796
+ ...(prepared?.extracted ?? []).map(
2797
+ (item) =>
2798
+ new AIMessage({
2799
+ content: item.text,
2800
+ additional_kwargs: {
2801
+ msgFromMatrixRoom: req.client === 'matrix',
2802
+ timestamp: new Date().toISOString(),
2803
+ attachment: item.meta,
2804
+ },
2805
+ }),
2806
+ ),
2807
+ ],
2808
+ config: { did: req.identity.userDid },
2809
+ client: req.client,
2810
+ ...metadataGraphInput(meta, priorMeta),
2811
+ browserTools: body.tools ?? [],
2812
+ agActions: body.agActions ?? [],
2813
+ };
2814
+ // LangSmith: metadata is attached unconditionally (inert without a
2815
+ // tracer); the explicit tracer only when this turn is traced (global
2816
+ // switch or per-DID allowlist — see `resolveLangsmithTracing`).
2817
+ const tracing = resolveLangsmithTracing({
2818
+ userDid: req.identity.userDid,
2819
+ client: req.client,
2820
+ env: langsmithEnvFromWorkerEnv(this.env),
2821
+ });
2822
+
2823
+ const config = {
2824
+ configurable: { thread_id: req.sessionId },
2825
+ context,
2826
+ signal: abortController.signal,
2827
+ recursionLimit: 60,
2828
+ metadata: tracing.metadata,
2829
+ ...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
2830
+ };
2831
+ return {
2832
+ agent,
2833
+ stateInput,
2834
+ config,
2835
+ abortController,
2836
+ byoNotice,
2837
+ byoProvider: byoTurn?.provider,
2838
+ turnDisposables,
2839
+ };
2840
+ }
2841
+
2842
+ /**
2843
+ * Node parity (`MessagesService.sendMessage`): every HTTP/SSE turn is
2844
+ * replayed into the user's oracle room as a thread under the session's
2845
+ * root event — the user's message as `**You:**`, the reply as the
2846
+ * oracle. Fire-and-forget, exactly as on Node: the turn never waits on
2847
+ * Matrix, a failure is logged. Room-originated turns are answered in the
2848
+ * room by the gateway, and synthetic task sessions have no root event.
2849
+ */
2850
+ private replayToRoom(
2851
+ req: TurnRequest,
2852
+ text: string,
2853
+ who: 'user' | 'oracle',
2854
+ ): void {
2855
+ if (req.client === 'matrix') return;
2856
+ if (req.sessionId.startsWith(SYNTHETIC_SESSION_PREFIX)) return;
2857
+ // A local session id has no marker event to thread on: the user had
2858
+ // no oracle room when the session was created, so there is no room
2859
+ // transcript to mirror either.
2860
+ if (!req.sessionId.startsWith('$')) return;
2861
+ if (!text.trim()) return;
2862
+ const label = who === 'user' ? 'user message' : 'AI response';
2863
+ // `waitUntil`: the non-streaming handler returns its Response right
2864
+ // after the turn, and un-awaited I/O started inside a request is torn
2865
+ // down with it ("Network connection lost") unless the object is told
2866
+ // to keep the work alive.
2867
+ this.ctx.waitUntil(
2868
+ (async () => {
2869
+ const roomId =
2870
+ req.roomId ??
2871
+ (await this.sessions?.getSession(req.sessionId))?.roomId ??
2872
+ (await this.gateway.resolveUserRoom(req.identity.userDid))?.roomId;
2873
+ if (!roomId) throw new Error('no oracle room for this user');
2874
+ const { body, formattedBody } = formatReplay({
2875
+ message: text,
2876
+ isOracle: who === 'oracle',
2877
+ oracleName: this.core.identity.name,
2878
+ });
2879
+ const eventId = await this.gateway.sendText(roomId, body, {
2880
+ threadId: req.sessionId,
2881
+ formattedBody,
2882
+ priority: 'background',
2883
+ });
2884
+ console.log(
2885
+ `[user-do] Matrix replay (${label}) → ${eventId} in thread ${req.sessionId}`,
2886
+ );
2887
+ })().catch((err: unknown) => {
2888
+ console.error(
2889
+ `[user-do] Matrix replay (${label}) failed — session=${req.sessionId}: ${
2890
+ err instanceof Error ? err.message : String(err)
2891
+ }`,
2892
+ );
2893
+ }),
2894
+ );
2895
+ }
2896
+
2897
+ private async afterTurn(
2898
+ sessionId: string,
2899
+ messages: BaseMessage[],
2900
+ ): Promise<void> {
2901
+ const sessions = this.sessions!;
2902
+ await sessions.touchSession(sessionId);
2903
+ await this.retainAttachmentPayloads(sessionId, messages).catch(
2904
+ (err: unknown) => {
2905
+ console.warn(
2906
+ `[user-do] attachment payload retention failed for ${sessionId}: ${err instanceof Error ? err.message : String(err)}`,
2907
+ );
2908
+ },
2909
+ );
2910
+ const row = await sessions.getSession(sessionId);
2911
+ if (row && (!row.title || row.title === UNTITLED_SESSION)) {
2912
+ const title = await this.generateTitle(messages).catch(() => null);
2913
+ if (title)
2914
+ await sessions.setTitle(sessionId, title, { onlyIfUntitled: true });
2915
+ }
2916
+ this.markDirty();
2917
+ }
2918
+
2919
+ private async generateTitle(
2920
+ messages: BaseMessage[],
2921
+ ): Promise<string | null> {
2922
+ const human = messages.find((m) => m.type === 'human');
2923
+ const ai = [...messages].reverse().find((m) => m.type === 'ai');
2924
+ if (!human) return null;
2925
+ // The ambient adapter honours LLM_PROVIDER; fall back to the core's
2926
+ // OpenRouter adapter if boot has not built the ambient bag yet.
2927
+ const model = (this.ambient?.llm ?? this.core.llm).get('session-title');
2928
+ const prompt =
2929
+ 'Write a 3–6 word title for this conversation. Reply with the title only, no quotes.\n\n' +
2930
+ `User: ${contentToText(human.content).slice(0, 500)}\n` +
2931
+ (ai ? `Assistant: ${contentToText(ai.content).slice(0, 500)}` : '');
2932
+ const res = await model.invoke(prompt);
2933
+ const title = contentToText(res.content)
2934
+ .trim()
2935
+ .replace(/^["']|["']$/g, '')
2936
+ .slice(0, 80);
2937
+ return title || null;
2938
+ }
2939
+ };
2940
+ }
2941
+
2942
+ /**
2943
+ * Durable KV for the BYO service over this object's storage: device-auth
2944
+ * bindings and unpersisted rotated ChatGPT tokens must survive object
2945
+ * eviction. TTLs are stored inline and enforced on read.
2946
+ */
2947
+ function createByoStateStore(storage: DurableObjectStorage): ByoStateStore {
2948
+ const keyOf = (key: string): string => `byo:${key}`;
2949
+ return {
2950
+ async get(key) {
2951
+ const hit = await storage.get<{ value: string; expiresAt?: number }>(
2952
+ keyOf(key),
2953
+ );
2954
+ if (!hit) return undefined;
2955
+ if (hit.expiresAt !== undefined && hit.expiresAt <= Date.now()) {
2956
+ await storage.delete(keyOf(key));
2957
+ return undefined;
2958
+ }
2959
+ return hit.value;
2960
+ },
2961
+ async put(key, value, ttlMs) {
2962
+ await storage.put(keyOf(key), {
2963
+ value,
2964
+ ...(ttlMs !== undefined ? { expiresAt: Date.now() + ttlMs } : {}),
2965
+ });
2966
+ },
2967
+ async delete(key) {
2968
+ await storage.delete(keyOf(key));
2969
+ },
2970
+ };
2971
+ }
2972
+
2973
+ /** Pass LangGraph events through untouched while recording the final message list. */
2974
+ async function* tapMessages(
2975
+ events: AsyncIterable<unknown>,
2976
+ capture: BaseMessage[],
2977
+ ): AsyncIterable<unknown> {
2978
+ for await (const evt of events) {
2979
+ const e = evt as {
2980
+ event?: string;
2981
+ data?: { output?: { messages?: BaseMessage[] } };
2982
+ };
2983
+ if (e.event === 'on_chain_end' && Array.isArray(e.data?.output?.messages)) {
2984
+ capture.splice(0, capture.length, ...e.data.output.messages);
2985
+ }
2986
+ yield evt;
2987
+ }
2988
+ }
2989
+
2990
+ /**
2991
+ * Merge the turn's SSE stream with plugin-emitted events. Plugin events are
2992
+ * written straight into the same byte stream as they happen.
2993
+ */
2994
+ function mergeWithEmitter(
2995
+ base: ReadableStream<Uint8Array>,
2996
+ onRegister: (write: (eventName: string, payload: unknown) => void) => void,
2997
+ ): ReadableStream<Uint8Array> {
2998
+ const encoder = new TextEncoder();
2999
+ let closed = false;
3000
+ return new ReadableStream<Uint8Array>({
3001
+ start(controller) {
3002
+ onRegister((eventName, payload) => {
3003
+ if (closed) return;
3004
+ try {
3005
+ controller.enqueue(encoder.encode(formatSSE(eventName, payload)));
3006
+ } catch {
3007
+ closed = true;
3008
+ }
3009
+ });
3010
+ const reader = base.getReader();
3011
+ void (async () => {
3012
+ try {
3013
+ for (;;) {
3014
+ const { done, value } = await reader.read();
3015
+ if (done) break;
3016
+ if (!closed) controller.enqueue(value);
3017
+ }
3018
+ } catch (err) {
3019
+ if (!closed) controller.error(err);
3020
+ } finally {
3021
+ closed = true;
3022
+ try {
3023
+ controller.close();
3024
+ } catch {
3025
+ /* already closed */
3026
+ }
3027
+ }
3028
+ })();
3029
+ },
3030
+ cancel() {
3031
+ closed = true;
3032
+ void base.cancel();
3033
+ },
3034
+ });
3035
+ }