@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.
- package/README.md +110 -0
- package/package.json +77 -0
- package/src/attachments/classify.ts +114 -0
- package/src/attachments/content-blocks.ts +51 -0
- package/src/attachments/download.ts +235 -0
- package/src/attachments/extract.ts +241 -0
- package/src/attachments/index.ts +84 -0
- package/src/attachments/magic.ts +133 -0
- package/src/attachments/pipeline.ts +409 -0
- package/src/attachments/retention.ts +205 -0
- package/src/attachments/route.ts +41 -0
- package/src/attachments/sandbox-archive.ts +90 -0
- package/src/attachments/sanitize.ts +35 -0
- package/src/attachments/types.ts +86 -0
- package/src/attachments/view.ts +107 -0
- package/src/core/env.ts +200 -0
- package/src/core/index.ts +513 -0
- package/src/core/llm.ts +842 -0
- package/src/core/main-agent-types.ts +123 -0
- package/src/core/main-agent.ts +399 -0
- package/src/core/manifest.ts +365 -0
- package/src/core/meta-tools.ts +232 -0
- package/src/core/middlewares/byo-history-sanitizer.ts +165 -0
- package/src/core/middlewares/capability-gate.ts +76 -0
- package/src/core/middlewares/index.ts +18 -0
- package/src/core/middlewares/page-context.ts +98 -0
- package/src/core/middlewares/safety-guardrail.ts +117 -0
- package/src/core/middlewares/summarization.ts +99 -0
- package/src/core/middlewares/tool-repetition-guard.ts +125 -0
- package/src/core/middlewares/tool-validation.ts +83 -0
- package/src/core/middlewares/work-status.ts +57 -0
- package/src/core/openrouter-pricing.ts +124 -0
- package/src/core/plugin-loader.ts +192 -0
- package/src/core/plugins/index.ts +6 -0
- package/src/core/plugins/skills/index.ts +8 -0
- package/src/core/plugins/skills/skills-tools.ts +356 -0
- package/src/core/plugins/skills/skills.plugin.ts +113 -0
- package/src/core/plugins/weather/index.ts +8 -0
- package/src/core/plugins/weather/weather-client.ts +153 -0
- package/src/core/plugins/weather/weather-middleware.ts +27 -0
- package/src/core/plugins/weather/weather-sub-agent.ts +65 -0
- package/src/core/plugins/weather/weather-tools.ts +103 -0
- package/src/core/plugins/weather/weather.plugin.ts +227 -0
- package/src/core/prompt-composer.ts +469 -0
- package/src/core/registries.ts +642 -0
- package/src/core/runtime-context.ts +619 -0
- package/src/core/state.ts +108 -0
- package/src/core/sub-agent-fallback.ts +187 -0
- package/src/core/subagent-as-tool.ts +322 -0
- package/src/core/template.ts +245 -0
- package/src/core/utils.ts +246 -0
- package/src/core/wrap-plugin-tool.ts +100 -0
- package/src/do/ambient.ts +266 -0
- package/src/do/contracts.ts +562 -0
- package/src/do/debug-timers.ts +120 -0
- package/src/do/fresh-stub.ts +32 -0
- package/src/do/gateway-retry.ts +73 -0
- package/src/do/secrets-adapter.ts +32 -0
- package/src/do/size-alerts.ts +76 -0
- package/src/do/sse-stream.ts +472 -0
- package/src/do/transcript.ts +192 -0
- package/src/do/turn-body.ts +84 -0
- package/src/do/turn-metadata.ts +146 -0
- package/src/do/ucan-service.ts +339 -0
- package/src/do/user-oracle-do.ts +3035 -0
- package/src/gateway-worker.ts +82 -0
- package/src/index.ts +237 -0
- package/src/llm/byo-adapter.ts +59 -0
- package/src/llm/byo-catalog.ts +520 -0
- package/src/llm/byo-client.ts +280 -0
- package/src/llm/byo-routes.ts +255 -0
- package/src/llm/byo-service.ts +836 -0
- package/src/llm/chatgpt-oauth.ts +430 -0
- package/src/llm/provider-error.ts +283 -0
- package/src/matrix/gateway-do.ts +918 -0
- package/src/matrix/ingest.ts +209 -0
- package/src/matrix/replay-format.ts +34 -0
- package/src/matrix/reply-chain.ts +109 -0
- package/src/matrix/room-state-codec.ts +115 -0
- package/src/matrix/user-homeserver.ts +106 -0
- package/src/matrix/work-status.ts +294 -0
- package/src/memory/session-history-indexer.ts +328 -0
- package/src/owner-store/ixo-vfs-store.ts +570 -0
- package/src/owner-store/matrix-media-store.ts +136 -0
- package/src/owner-store/migrating-store.ts +185 -0
- package/src/owner-store/owner-copy-errors.ts +175 -0
- package/src/owner-store/retry.ts +45 -0
- package/src/owner-store/tus-upload.ts +333 -0
- package/src/owner-store/types.ts +0 -0
- package/src/plugin-api/define-plugin.ts +45 -0
- package/src/plugin-api/index.ts +56 -0
- package/src/plugin-api/oracle-plugin.ts +145 -0
- package/src/plugin-api/tool-helper.ts +82 -0
- package/src/plugin-api/types.ts +816 -0
- package/src/plugin-api/ucan-errors.ts +16 -0
- package/src/plugins/agui/agui-agent.ts +115 -0
- package/src/plugins/agui/agui.plugin.ts +148 -0
- package/src/plugins/agui/index.ts +2 -0
- package/src/plugins/attachments/attachments.plugin.ts +54 -0
- package/src/plugins/attachments/index.ts +6 -0
- package/src/plugins/attachments/view-attachment-tool.ts +91 -0
- package/src/plugins/base64.ts +26 -0
- package/src/plugins/composio/composio-tools.ts +365 -0
- package/src/plugins/composio/composio-ucan.ts +43 -0
- package/src/plugins/composio/composio.plugin.ts +214 -0
- package/src/plugins/composio/index.ts +15 -0
- package/src/plugins/domain-indexer/domain-indexer-agent.ts +57 -0
- package/src/plugins/domain-indexer/domain-indexer-tools.ts +253 -0
- package/src/plugins/domain-indexer/domain-indexer.plugin.ts +100 -0
- package/src/plugins/domain-indexer/index.ts +3 -0
- package/src/plugins/editor/blocknote-bridge.ts +120 -0
- package/src/plugins/editor/content-session.ts +331 -0
- package/src/plugins/editor/content-tools.ts +686 -0
- package/src/plugins/editor/document-model.ts +0 -0
- package/src/plugins/editor/editor-access-denied-tool.ts +58 -0
- package/src/plugins/editor/editor-agent.ts +34 -0
- package/src/plugins/editor/editor-config.ts +64 -0
- package/src/plugins/editor/editor-mx.ts +128 -0
- package/src/plugins/editor/editor.plugin.ts +143 -0
- package/src/plugins/editor/failures.ts +154 -0
- package/src/plugins/editor/index.ts +31 -0
- package/src/plugins/editor/jsdom-shim.ts +55 -0
- package/src/plugins/editor/prompts.ts +59 -0
- package/src/plugins/editor/prop-policy.ts +147 -0
- package/src/plugins/editor/provider.ts +386 -0
- package/src/plugins/editor/room-membership.ts +95 -0
- package/src/plugins/editor/standalone-editor-tool.ts +200 -0
- package/src/plugins/firecrawl/firecrawl-agent.ts +93 -0
- package/src/plugins/firecrawl/firecrawl-tools.ts +220 -0
- package/src/plugins/firecrawl/firecrawl.plugin.ts +105 -0
- package/src/plugins/firecrawl/index.ts +15 -0
- package/src/plugins/flows/action-metadata.ts +3505 -0
- package/src/plugins/flows/actions.ts +190 -0
- package/src/plugins/flows/edit.ts +314 -0
- package/src/plugins/flows/errors.ts +38 -0
- package/src/plugins/flows/explain.ts +43 -0
- package/src/plugins/flows/flow-doc.ts +111 -0
- package/src/plugins/flows/flows.plugin.ts +129 -0
- package/src/plugins/flows/forms.ts +330 -0
- package/src/plugins/flows/index.ts +2 -0
- package/src/plugins/flows/linkage.ts +147 -0
- package/src/plugins/flows/port-types.ts +53 -0
- package/src/plugins/flows/prompts.ts +65 -0
- package/src/plugins/flows/read.ts +392 -0
- package/src/plugins/flows/references.ts +66 -0
- package/src/plugins/flows/test-support.ts +165 -0
- package/src/plugins/flows/tools/authoring.ts +516 -0
- package/src/plugins/flows/tools/discovery.ts +119 -0
- package/src/plugins/flows/tools/forms.ts +142 -0
- package/src/plugins/flows/tools/inspect.ts +165 -0
- package/src/plugins/flows/tools/linkage.ts +117 -0
- package/src/plugins/flows/tools/settings.ts +184 -0
- package/src/plugins/flows/translator.ts +328 -0
- package/src/plugins/flows/types.ts +210 -0
- package/src/plugins/flows/ydoc-helpers.ts +543 -0
- package/src/plugins/index.ts +70 -0
- package/src/plugins/mcp-call-timeout.ts +38 -0
- package/src/plugins/mcp-tool-adapter.ts +87 -0
- package/src/plugins/memory/index.ts +17 -0
- package/src/plugins/memory/memory-tools.ts +463 -0
- package/src/plugins/memory/memory-ucan.ts +51 -0
- package/src/plugins/memory/memory.plugin.ts +122 -0
- package/src/plugins/memory/types.ts +147 -0
- package/src/plugins/portal/action-log.ts +34 -0
- package/src/plugins/portal/index.ts +2 -0
- package/src/plugins/portal/portal.plugin.ts +203 -0
- package/src/plugins/sandbox/index.ts +34 -0
- package/src/plugins/sandbox/sandbox-bridge.ts +295 -0
- package/src/plugins/sandbox/sandbox-mcp.ts +127 -0
- package/src/plugins/sandbox/sandbox-write-blob.ts +103 -0
- package/src/plugins/sandbox/sandbox.plugin.ts +599 -0
- package/src/plugins/tasks/index.ts +17 -0
- package/src/plugins/tasks/manifest.ts +81 -0
- package/src/plugins/tasks/middleware.ts +219 -0
- package/src/plugins/tasks/tasks-tools.ts +414 -0
- package/src/plugins/tasks/tasks.plugin.ts +74 -0
- package/src/plugins/ucan-failure.ts +133 -0
- package/src/plugins/user-preferences/index.ts +22 -0
- package/src/plugins/user-preferences/schema.ts +31 -0
- package/src/plugins/user-preferences/user-preferences-store.ts +172 -0
- package/src/plugins/user-preferences/user-preferences-tool.ts +131 -0
- package/src/plugins/user-preferences/user-preferences.plugin.ts +137 -0
- package/src/plugins/vfs/index.ts +40 -0
- package/src/plugins/vfs/vfs-auth.ts +56 -0
- package/src/plugins/vfs/vfs-client.ts +655 -0
- package/src/plugins/vfs/vfs-content.ts +181 -0
- package/src/plugins/vfs/vfs-errors.ts +163 -0
- package/src/plugins/vfs/vfs-sandbox-tools.ts +272 -0
- package/src/plugins/vfs/vfs-tools.ts +635 -0
- package/src/plugins/vfs/vfs.plugin.ts +201 -0
- package/src/realtime/frontend-call-registry.ts +163 -0
- package/src/realtime/realtime-endpoint.ts +619 -0
- package/src/realtime/session-socket-hub.ts +249 -0
- package/src/realtime/socket-io-codec.ts +187 -0
- package/src/secrets/jwe.ts +74 -0
- package/src/secrets/pin-cipher.ts +43 -0
- package/src/secrets/secrets-service.ts +319 -0
- package/src/shell/app.ts +564 -0
- package/src/shell/auth.ts +290 -0
- package/src/sqlite/blob-codec.ts +100 -0
- package/src/sqlite/blob-compactor.ts +146 -0
- package/src/sqlite/cache-config.ts +45 -0
- package/src/sqlite/database.ts +643 -0
- package/src/sqlite/do-vfs.ts +1485 -0
- package/src/sqlite/index.ts +61 -0
- package/src/sqlite/serialization.ts +370 -0
- package/src/sqlite/sessions-store.ts +311 -0
- package/src/sqlite/sqlite-saver.ts +919 -0
- package/src/sqlite/vacuum-policy.ts +77 -0
- package/src/sqlite/wa-sqlite-loader.ts +123 -0
- package/src/sqlite/wasm.d.ts +10 -0
- package/src/tasks/schedule.ts +174 -0
- package/src/tasks/scheduler.ts +753 -0
- package/src/tasks/spec.ts +125 -0
- 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
|
+
}
|