@ixo/oracle-runtime-workers 0.4.1 → 0.9.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.
@@ -12,6 +12,7 @@ import {
12
12
  isAttachmentPlaceholderText,
13
13
  isAttachmentViewMessage,
14
14
  } from '../attachments/retention';
15
+ import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
15
16
 
16
17
  export interface ToolCallDto {
17
18
  name: string;
@@ -103,11 +104,23 @@ export function contentToText(content: BaseMessage['content']): string {
103
104
  return String(content);
104
105
  }
105
106
 
106
- /** The summarization middleware's bookkeeping message — never shown to users. */
107
+ /**
108
+ * The summarization middleware's bookkeeping message — never shown to users.
109
+ * LangChain 1.4 writes it as a HUMAN message tagged
110
+ * `additional_kwargs.lc_source: 'summarization'` whose text starts with the
111
+ * summary prefix; older versions used a system message and other tags. All
112
+ * of them match here, so a condensed thread lists as the kept tail only.
113
+ */
107
114
  export function isSummarizationMessage(message: BaseMessage): boolean {
108
115
  const kw = message.additional_kwargs as Record<string, unknown> | undefined;
109
- if (kw?.lc_summary_message === true || kw?.summary === true) return true;
116
+ if (
117
+ kw?.lc_source === 'summarization' ||
118
+ kw?.lc_summary_message === true ||
119
+ kw?.summary === true
120
+ )
121
+ return true;
110
122
  const text = contentToText(message.content);
123
+ if (text.startsWith(SUMMARY_PREFIX)) return true;
111
124
  return message.type === 'system' && /summary of the conversation/i.test(text);
112
125
  }
113
126
 
@@ -0,0 +1,24 @@
1
+ import { baseEnvSchema, TURN_RECURSION_LIMIT_DEFAULT } from '../core/env';
2
+ import type { OracleWorkerEnv } from './contracts';
3
+
4
+ /**
5
+ * The LangGraph `recursionLimit` every turn runs with (HTTP/SSE turns, room
6
+ * turns and the task runner's background turns all build their config in
7
+ * one place). `TURN_RECURSION_LIMIT` is validated by the base env schema at
8
+ * boot; this re-parses the raw Worker var through the same field so the
9
+ * turn never sees anything but a positive integer, and falls back to the
10
+ * default (with a warning) if the var is somehow malformed.
11
+ */
12
+ export function turnRecursionLimit(
13
+ env: Pick<OracleWorkerEnv, 'TURN_RECURSION_LIMIT'>,
14
+ log: Pick<Console, 'warn'> = console,
15
+ ): number {
16
+ const parsed = baseEnvSchema.shape.TURN_RECURSION_LIMIT.safeParse(
17
+ env.TURN_RECURSION_LIMIT,
18
+ );
19
+ if (parsed.success) return parsed.data;
20
+ log.warn(
21
+ `[turn] TURN_RECURSION_LIMIT=${JSON.stringify(env.TURN_RECURSION_LIMIT)} is not a positive integer; using ${TURN_RECURSION_LIMIT_DEFAULT}`,
22
+ );
23
+ return TURN_RECURSION_LIMIT_DEFAULT;
24
+ }
@@ -37,6 +37,8 @@ import {
37
37
  type AttachmentViewSurface,
38
38
  type MatrixMediaSource,
39
39
  type SandboxUploadConfig,
40
+ MAX_FILE_SIZE,
41
+ readBytesCapped,
40
42
  } from '../attachments';
41
43
  import type { RuntimeCore } from '../core';
42
44
  import {
@@ -103,6 +105,7 @@ import {
103
105
  UNTITLED_SESSION,
104
106
  type SessionRecord,
105
107
  } from '../sqlite/sessions-store';
108
+ import { createAttachmentDecryptor } from '@ixo/matrix-bot-workers-sdk';
106
109
  import { MatrixMediaOwnerStore } from '../owner-store/matrix-media-store';
107
110
  import { MigratingOwnerStore } from '../owner-store/migrating-store';
108
111
  import type { OwnerCopy } from '../owner-store/types';
@@ -135,7 +138,13 @@ import {
135
138
  type TurnRequest,
136
139
  type TurnResult,
137
140
  type MemorySchemaDebug,
141
+ TURN_INTERRUPTED_MARKER,
138
142
  } from './contracts';
143
+ import { turnRecursionLimit } from './turn-config';
144
+ import { decideMatrixTurn, MatrixTurnLedger } from './matrix-turn-ledger';
145
+ import { decideBootDirty } from './boot-dirty';
146
+ import { mirrorTxnId, RoomMirror } from './room-mirror';
147
+ import { ReauthPrompter } from './reauth-prompt';
139
148
  import {
140
149
  createTaskScheduler,
141
150
  TASK_SESSION_PREFIX,
@@ -239,6 +248,16 @@ const DELEGATION_MISS_TTL_MS = 60_000;
239
248
  const META_REAUTH_PROMPT_AT = 'meta:reauthPromptAt';
240
249
  /** Node's `AgentBuilder.DEFAULT_REAUTH_THROTTLE_SECONDS`. */
241
250
  const DEFAULT_REAUTH_THROTTLE_SECONDS = 6 * 60 * 60;
251
+
252
+ /** `UCAN_REAUTH_PROMPT_THROTTLE_SECONDS` as a positive number, else the default. */
253
+ function reauthThrottleSeconds(env: OracleWorkerEnv): number {
254
+ const raw = env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
255
+ return typeof raw === 'string' &&
256
+ Number.isFinite(Number(raw)) &&
257
+ Number(raw) > 0
258
+ ? Number(raw)
259
+ : DEFAULT_REAUTH_THROTTLE_SECONDS;
260
+ }
242
261
  /** Highest whole-GB size watermark already alerted to Slack (0 = none). */
243
262
  const META_SIZE_ALERT_GB = 'meta:sizeAlertGb';
244
263
  /** Blob re-compression bookkeeping (see sqlite/blob-compactor.ts). */
@@ -433,6 +452,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
433
452
 
434
453
  /** In-flight Matrix turn per session (sessionId → requestId), for supersede. */
435
454
  private readonly matrixTurns = new Map<string, string>();
455
+ /** What this object remembers about each room message it answered (see matrix-turn-ledger.ts). */
456
+ private matrixLedger: MatrixTurnLedger | null = null;
457
+ /** Room mirrors of HTTP turns, serialised per session and retried across gateway restarts (see room-mirror.ts). */
458
+ private mirror: RoomMirror | null = null;
459
+ /** The oracle room of each session this instance resolved: spares the mirrors a row read per send. */
460
+ private readonly sessionRooms = new Map<string, string>();
461
+ /** The throttled `delegation_required` prompt (see reauth-prompt.ts). */
462
+ private reauthPrompter: ReauthPrompter | null = null;
463
+ /** Room turns running right now, by Matrix event id: a gateway that asks again attaches instead of re-running. */
464
+ private readonly matrixTurnRuns = new Map<string, Promise<TurnResult>>();
436
465
 
437
466
  /** Session-history → memory-engine indexing (built lazily, see `scheduleHistoryIndexing`). */
438
467
  private historyIndexer: SessionHistoryIndexer | null = null;
@@ -447,6 +476,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
447
476
  >();
448
477
  private initPromise: Promise<void> | null = null;
449
478
  private reloadedFromOwnerStore = false;
479
+ /** Set by `adoptOwnerCopy` for a legacy copy: `ready()` flushes it to the system of record right after. */
480
+ private pendingLegacyMigration: { bytes: number } | null = null;
450
481
 
451
482
  // ── lifecycle ───────────────────────────────────────────────────────────
452
483
 
@@ -792,7 +823,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
792
823
  const imported = await this.adoptOwnerCopy(db, loaded);
793
824
  this.reloadedFromOwnerStore = true;
794
825
  console.log(
795
- `[user-do] imported ${imported} bytes from ${this.ownerStore.kind} for ${userDid}`,
826
+ `[user-do] imported ${imported} bytes from ${loaded.fromLegacy ? 'legacy Matrix media' : this.ownerStore.kind} for ${userDid}`,
796
827
  );
797
828
  }
798
829
  } else {
@@ -926,6 +957,31 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
926
957
 
927
958
  this.db ??= db;
928
959
  const liveDb = this.db;
960
+ if (this.pendingLegacyMigration) {
961
+ const { bytes } = this.pendingLegacyMigration;
962
+ this.pendingLegacyMigration = null;
963
+ // Second half of the one-time migration: the legacy copy just
964
+ // imported goes to the system of record from the working copy —
965
+ // streamed from a snapshot like every flush, so a Node-era history of
966
+ // any size costs a few chunks of memory. A failure (no
967
+ // `ixo:filesystem` delegation yet, a VFS outage) keeps the copy dirty
968
+ // and retried every 10 min; the object serves the legacy history
969
+ // meanwhile, and the Matrix copy is only redacted once VFS holds it.
970
+ try {
971
+ const flushed = await this.flushToOwnerStore();
972
+ console.log(
973
+ `[user-do] migrated ${bytes} bytes from legacy Matrix media to ${this.ownerStore.kind} for ${userDid} (${flushed.etag ?? '-'})`,
974
+ );
975
+ } catch (err) {
976
+ console.warn(
977
+ `[user-do] VFS migration write failed for ${userDid} — serving the legacy Matrix copy for now: ${err instanceof Error ? err.message : String(err)}`,
978
+ );
979
+ // Sooner than the 24 h debounce `markDirty` armed: the system of
980
+ // record is still empty for this user.
981
+ this.requestAlarm(Date.now() + FLUSH_RETRY_DELAY_MS);
982
+ }
983
+ }
984
+ await this.reconcileDirtyOnBoot(liveDb, userDid);
929
985
  {
930
986
  // One line per boot so an operator can tell what a user's object
931
987
  // actually holds without a debug route: which store, how big, how
@@ -959,6 +1015,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
959
1015
  await this.saver.setup();
960
1016
  this.sessions = new SessionsStore(liveDb);
961
1017
  await this.sessions.setup();
1018
+ this.matrixLedger = new MatrixTurnLedger(liveDb);
1019
+ await this.matrixLedger.setup();
962
1020
 
963
1021
  const core = this.core;
964
1022
  // Boot-time plugin hooks + collision checks, once per object (memoised).
@@ -1003,8 +1061,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1003
1061
  logger: console,
1004
1062
  });
1005
1063
  this.workStatus = new WorkStatusProducer({
1006
- postEvent: (roomId, type, content) =>
1007
- this.gateway.sendEvent(roomId, type, JSON.stringify(content)),
1064
+ postEvent: (roomId, type, content, opts) =>
1065
+ this.gateway.sendEvent(
1066
+ roomId,
1067
+ type,
1068
+ JSON.stringify(content),
1069
+ opts ?? {},
1070
+ ),
1008
1071
  logger: console,
1009
1072
  });
1010
1073
  this.ambient = createAmbientServices({
@@ -1022,6 +1085,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1022
1085
  delegationFor: (did) => this.delegations.get(did),
1023
1086
  events: this.events,
1024
1087
  secrets: createSecretsAdapter(secretsService),
1088
+ background: (work) => this.ctx.waitUntil(work),
1025
1089
  });
1026
1090
  }
1027
1091
 
@@ -1094,10 +1158,25 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1094
1158
  */
1095
1159
  private async adoptOwnerCopy(
1096
1160
  db: DoSqliteDatabase,
1097
- copy: { stream: ReadableStream<Uint8Array>; etag: string },
1161
+ copy: OwnerCopy,
1098
1162
  ): Promise<number> {
1099
1163
  const bytes = await db.importFromStream(copy.stream);
1100
1164
  await this.ctx.storage.put(META_OWNER_ETAG, copy.etag);
1165
+ if (copy.fromLegacy) {
1166
+ // Streamed in from the legacy Matrix media, not in the system of
1167
+ // record yet: forget any earlier upload markers so the flush that
1168
+ // `ready()` runs next (`pendingLegacyMigration`) actually sends it,
1169
+ // and forget "no legacy copy, stop looking" — a copy evidently
1170
+ // exists now, and the flush must be able to redact it.
1171
+ await this.ctx.storage.delete([
1172
+ META_LAST_CHECKSUM,
1173
+ META_UPLOADED_GEN,
1174
+ META_LEGACY_CLEARED,
1175
+ ]);
1176
+ this.markDirty();
1177
+ this.pendingLegacyMigration = { bytes };
1178
+ return bytes;
1179
+ }
1101
1180
  await this.ctx.storage.put(META_LAST_CHECKSUM, await db.checksum());
1102
1181
  await this.ctx.storage.put(META_UPLOADED_GEN, db.writeGeneration);
1103
1182
  return bytes;
@@ -1226,8 +1305,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1226
1305
  }
1227
1306
 
1228
1307
  // Legacy-blob compaction: one bounded batch per tick until the whole
1229
- // file is codec-compressed (see sqlite/blob-compactor.ts).
1230
- if (this.db) {
1308
+ // file is codec-compressed (see sqlite/blob-compactor.ts). A flush in
1309
+ // flight holds a snapshot of the file, which the compactor's swap-in
1310
+ // cannot replace — a large migration flush (streamed, seconds long)
1311
+ // routinely overlaps the first tick, so wait for it instead of erroring.
1312
+ if (this.db && this.flushInFlight) {
1313
+ deadlines.push(now + 5000);
1314
+ } else if (this.db) {
1231
1315
  try {
1232
1316
  if (await this.compactionTick(this.db)) deadlines.push(now + 2000);
1233
1317
  } catch (err) {
@@ -1579,42 +1663,70 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1579
1663
  }
1580
1664
 
1581
1665
  /**
1582
- * Emit a single throttled `ixo.oracle.delegation_required` event into the
1583
- * user's room when a Matrix turn finds no valid delegation; the web app
1584
- * listens for it and opens the authorize modal in place. Throttled per
1585
- * object (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws.
1666
+ * Ask the web app to open its "authorize for Matrix" modal in place when a
1667
+ * Matrix turn finds no valid delegation — the Node AgentBuilder's
1668
+ * `maybePromptReauth`, throttled per object
1669
+ * (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`, default 6 h). Never throws; the
1670
+ * prompt outlives the turn under `waitUntil` while it is retried across a
1671
+ * gateway restart (see reauth-prompt.ts for the ordering that matters).
1586
1672
  */
1587
- private async maybePromptReauth(
1673
+ private promptReauth(userDid: string, roomId: string): void {
1674
+ this.reauthPrompter ??= new ReauthPrompter({
1675
+ throttleMs: reauthThrottleSeconds(this.env) * 1000,
1676
+ getStamp: () => this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT),
1677
+ setStamp: (at) => this.ctx.storage.put(META_REAUTH_PROMPT_AT, at),
1678
+ send: (room, txnId) =>
1679
+ this.gateway.sendEvent(
1680
+ room,
1681
+ 'ixo.oracle.delegation_required',
1682
+ JSON.stringify({
1683
+ oracleEntityDid: this.core.identity.entityDid,
1684
+ oracleDid: this.env.ORACLE_DID,
1685
+ }),
1686
+ { txnId },
1687
+ ),
1688
+ keepAlive: (work) => this.ctx.waitUntil(work),
1689
+ log: (message) => console.log(message),
1690
+ warn: (message) => console.warn(message),
1691
+ });
1692
+ void this.reauthPrompter.prompt(userDid, roomId);
1693
+ }
1694
+
1695
+ /** Debug route: forget when the last re-authorise prompt was posted, so a drill can trigger the next one. */
1696
+ async debugResetReauthThrottle(): Promise<void> {
1697
+ await this.ctx.storage.delete(META_REAUTH_PROMPT_AT);
1698
+ }
1699
+
1700
+ /**
1701
+ * A turn that died mid-way leaves committed checkpoint steps with no dirty
1702
+ * mark (the mark is set at the end of a turn). Compare the file's write
1703
+ * generation with the last upload's on boot and mark the copy dirty when
1704
+ * it moved on; one batched storage read per boot (see boot-dirty.ts).
1705
+ */
1706
+ private async reconcileDirtyOnBoot(
1707
+ db: DoSqliteDatabase,
1588
1708
  userDid: string,
1589
- roomId: string,
1590
1709
  ): Promise<void> {
1591
- try {
1592
- const raw = this.env.UCAN_REAUTH_PROMPT_THROTTLE_SECONDS;
1593
- const throttleSeconds =
1594
- typeof raw === 'string' &&
1595
- Number.isFinite(Number(raw)) &&
1596
- Number(raw) > 0
1597
- ? Number(raw)
1598
- : DEFAULT_REAUTH_THROTTLE_SECONDS;
1599
- const last = await this.ctx.storage.get<number>(META_REAUTH_PROMPT_AT);
1600
- if (last !== undefined && Date.now() - last < throttleSeconds * 1000)
1601
- return;
1602
- await this.ctx.storage.put(META_REAUTH_PROMPT_AT, Date.now());
1603
- await this.gateway.sendEvent(
1604
- roomId,
1605
- 'ixo.oracle.delegation_required',
1606
- JSON.stringify({
1607
- oracleEntityDid: this.core.identity.entityDid,
1608
- oracleDid: this.env.ORACLE_DID,
1609
- }),
1610
- );
1710
+ if (this.dirty) return;
1711
+ const got = await this.ctx.storage.get<unknown>([
1712
+ META_DIRTY,
1713
+ META_UPLOADED_GEN,
1714
+ ]);
1715
+ const decision = decideBootDirty({
1716
+ dirtyInMemory: this.dirty,
1717
+ dirtyFlag: got.get(META_DIRTY),
1718
+ uploadedGen: got.get(META_UPLOADED_GEN),
1719
+ writeGeneration: db.writeGeneration,
1720
+ });
1721
+ if (decision === 'flagged') {
1722
+ // The mark is on disk already (its flush alarm too); just adopt it.
1723
+ this.dirty = true;
1724
+ this.requestAlarm(Date.now() + FLUSH_DEBOUNCE_MS);
1725
+ } else if (decision === 'behind-upload') {
1611
1726
  console.log(
1612
- `[user-do] sent ixo.oracle.delegation_required to ${roomId} for ${userDid}`,
1613
- );
1614
- } catch (err) {
1615
- console.warn(
1616
- `[user-do] delegation-required event failed for ${userDid}: ${err instanceof Error ? err.message : String(err)}`,
1727
+ `[user-do] working copy of ${userDid} is at generation ${db.writeGeneration}, last upload at ${String(got.get(META_UPLOADED_GEN))} — a turn ended without marking it; flushing`,
1617
1728
  );
1729
+ this.markDirty();
1618
1730
  }
1619
1731
  }
1620
1732
 
@@ -1656,8 +1768,68 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1656
1768
  return true;
1657
1769
  }
1658
1770
 
1771
+ /**
1772
+ * A room turn runs at most once per Matrix event, however many times the
1773
+ * gateway asks (it asks again after a reset, see the inbox in
1774
+ * `src/matrix/gateway-do.ts`). The ledger and the in-flight map decide:
1775
+ * answered → stored reply; running → attach; started but lost in a reset
1776
+ * of this object → refused (tools may have run), the user is told to
1777
+ * retry; unknown → run once. HTTP and task turns carry no event id and
1778
+ * always run.
1779
+ */
1659
1780
  async runTurn(req: TurnRequest): Promise<TurnResult> {
1660
1781
  await this.ready(req.identity);
1782
+ const eventId = req.client === 'matrix' ? req.eventId : undefined;
1783
+ const ledger = this.matrixLedger;
1784
+ if (!eventId || !ledger) return this.runTurnOnce(req);
1785
+
1786
+ const running = this.matrixTurnRuns.get(eventId);
1787
+ if (running) {
1788
+ console.log(
1789
+ `[user-do] turn for ${eventId} is still running; the gateway asked again and attaches to it`,
1790
+ );
1791
+ return running;
1792
+ }
1793
+ const existing = await ledger.get(eventId);
1794
+ switch (decideMatrixTurn(existing)) {
1795
+ case 'answered':
1796
+ console.log(
1797
+ `[user-do] turn for ${eventId} already answered; returning the stored reply`,
1798
+ );
1799
+ return {
1800
+ sessionId: req.sessionId,
1801
+ requestId: req.requestId,
1802
+ text: existing?.replyText ?? '',
1803
+ toolCalls: [],
1804
+ replayed: true,
1805
+ };
1806
+ case 'interrupted':
1807
+ throw new Error(`${TURN_INTERRUPTED_MARKER} (${eventId})`);
1808
+ case 'run':
1809
+ break;
1810
+ }
1811
+ await ledger.start(eventId, req.sessionId, req.requestId);
1812
+ const run = this.runTurnOnce(req)
1813
+ .then(async (result) => {
1814
+ await ledger.answer(eventId, result.text);
1815
+ return result;
1816
+ })
1817
+ .finally(() => {
1818
+ this.matrixTurnRuns.delete(eventId);
1819
+ });
1820
+ this.matrixTurnRuns.set(eventId, run);
1821
+ // The turn's I/O belongs to this RPC's request context. If the gateway
1822
+ // that made the call is reset, the context would be cancelled with it:
1823
+ // fetches started afterwards (the next model call, the next tool) never
1824
+ // settle, and the turn hangs forever — with a replay attached to it.
1825
+ // `waitUntil` keeps the context alive until the turn has ended, so the
1826
+ // turn finishes, the ledger records the reply, and the replayed request
1827
+ // returns it.
1828
+ this.ctx.waitUntil(run.catch(() => undefined));
1829
+ return run;
1830
+ }
1831
+
1832
+ private async runTurnOnce(req: TurnRequest): Promise<TurnResult> {
1661
1833
  // Matrix turns drive a `work_status` card in the user's thread; a newer
1662
1834
  // message on the same session supersedes the previous card (the
1663
1835
  // previous turn itself is aborted by prepareTurn).
@@ -1809,6 +1981,13 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1809
1981
  * while one runs joins it.
1810
1982
  */
1811
1983
  async flushToOwnerStore(): Promise<FlushResult> {
1984
+ if (!this.db) {
1985
+ // A cold (evicted) object: boot from the persisted identity first, so
1986
+ // an operator's `POST /debug/storage/flush` is never a silent no-op.
1987
+ const userDid =
1988
+ this.userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
1989
+ if (userDid) await this.ready({ userDid });
1990
+ }
1812
1991
  if (this.flushInFlight) return this.flushInFlight;
1813
1992
  const run = this.flushOnce().finally(() => {
1814
1993
  this.flushInFlight = null;
@@ -1976,6 +2155,8 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1976
2155
  this.db = null;
1977
2156
  this.saver = null;
1978
2157
  this.sessions = null;
2158
+ this.matrixLedger = null;
2159
+ this.sessionRooms.clear();
1979
2160
  this.ambient = null;
1980
2161
  this.secretsService = null;
1981
2162
  this.byo = null;
@@ -1994,9 +2175,21 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
1994
2175
  async debugSession(
1995
2176
  userDid: string,
1996
2177
  sessionId: string,
1997
- ): Promise<SessionRecord | null> {
2178
+ ): Promise<Record<string, unknown> | null> {
1998
2179
  await this.ready({ userDid });
1999
- return (await this.sessions!.getSession(sessionId)) ?? null;
2180
+ const session = await this.sessions!.getSession(sessionId);
2181
+ if (!session) return null;
2182
+ // Whether the thread's agent context was condensed: the summarization
2183
+ // middleware's bookkeeping message is stored with the history but
2184
+ // never listed, so `GET /messages/:id` alone cannot tell.
2185
+ const all = await this.saver!.listThreadMessages(sessionId);
2186
+ return {
2187
+ ...session,
2188
+ threadMessages: all.length,
2189
+ summaryMessages: all.filter(isSummarizationMessage).length,
2190
+ // Folded into their assistant messages by the listing, not shown alone.
2191
+ toolMessages: all.filter((m) => m.type === 'tool').length,
2192
+ };
2000
2193
  }
2001
2194
 
2002
2195
  /**
@@ -2004,6 +2197,16 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2004
2197
  * this user's credentials and return `search_memory_engine`'s raw schema
2005
2198
  * plus whether it converts to Zod. Same headers the memory plugin sends.
2006
2199
  */
2200
+ /**
2201
+ * Operator / testing aid behind `ORACLE_DEBUG_ROUTES`: reset this object
2202
+ * the way an unplanned platform reset does — in-memory state and every
2203
+ * in-flight turn are gone, committed storage survives, the next request
2204
+ * boots a fresh instance. The RPC rejects by design.
2205
+ */
2206
+ async debugAbortObject(): Promise<void> {
2207
+ this.ctx.abort('debug reset requested');
2208
+ }
2209
+
2007
2210
  async debugMemorySchema(userDid: string): Promise<MemorySchemaDebug> {
2008
2211
  await this.ready({ userDid });
2009
2212
  const ambient = this.ambient;
@@ -2095,7 +2298,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2095
2298
  };
2096
2299
  }
2097
2300
 
2098
- async tasksStatus(): Promise<{
2301
+ async tasksStatus(userDid?: string): Promise<{
2099
2302
  now: number;
2100
2303
  alarm: number | null;
2101
2304
  schedulerActive: boolean;
@@ -2107,14 +2310,41 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2107
2310
  lastResult: { ok: boolean; summary: string; at: string } | null;
2108
2311
  consecutiveFailures: number;
2109
2312
  }>;
2313
+ openRuns: Array<{
2314
+ runId: string;
2315
+ taskId: string;
2316
+ startedAt: string;
2317
+ state: 'running' | 'delivering';
2318
+ attempts: number;
2319
+ retryAt: number | null;
2320
+ }>;
2110
2321
  }> {
2322
+ // A cold (evicted or reset) object has no scheduler until it boots;
2323
+ // reporting "no tasks" for it would be wrong, so boot like the alarm
2324
+ // does when the caller — or the stored identity — says whose object.
2325
+ if (!this.taskScheduler) {
2326
+ const did =
2327
+ userDid ?? (await this.ctx.storage.get<string>(META_USER_DID));
2328
+ if (did) await this.ready({ userDid: did });
2329
+ }
2111
2330
  const records = this.taskScheduler
2112
2331
  ? await this.taskScheduler.surface.list()
2113
2332
  : [];
2333
+ const openRuns = this.taskScheduler
2334
+ ? await this.taskScheduler.openRuns()
2335
+ : [];
2114
2336
  return {
2115
2337
  now: Date.now(),
2116
2338
  alarm: await this.ctx.storage.getAlarm(),
2117
2339
  schedulerActive: this.taskScheduler !== null,
2340
+ openRuns: openRuns.map((r) => ({
2341
+ runId: r.runId,
2342
+ taskId: r.taskId,
2343
+ startedAt: r.startedAt,
2344
+ state: r.state,
2345
+ attempts: r.attempts,
2346
+ retryAt: r.retryAt ?? null,
2347
+ })),
2118
2348
  tasks: records.map((t) => ({
2119
2349
  id: t.id,
2120
2350
  title: t.title,
@@ -2392,12 +2622,36 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2392
2622
 
2393
2623
  // ── turn plumbing ──────────────────────────────────────────────────────
2394
2624
 
2395
- /** Matrix media access for the attachments pipeline, through the gateway. */
2625
+ /**
2626
+ * Matrix media access for the attachments pipeline, through the gateway.
2627
+ * Both transfers stream across the object boundary and are cut off at
2628
+ * the attachment cap; an encrypted attachment is decrypted HERE, not in
2629
+ * the gateway (its whole-buffer download grew the crypto WASM heap for
2630
+ * good, and a hash mismatch on the far side of the RPC would only reach
2631
+ * this object as a disconnect).
2632
+ */
2396
2633
  private matrixMediaSource(): MatrixMediaSource {
2397
2634
  return {
2398
- downloadMxc: (mxc) => this.gateway.downloadMxcMedia(mxc),
2399
- downloadEvent: (roomId, eventId) =>
2400
- this.gateway.downloadEventMedia(roomId, eventId),
2635
+ downloadMxc: async (mxc, maxBytes = MAX_FILE_SIZE) =>
2636
+ readBytesCapped(
2637
+ await this.gateway.downloadMxcMediaStream(mxc),
2638
+ maxBytes,
2639
+ ),
2640
+ downloadEvent: async (roomId, eventId, maxBytes = MAX_FILE_SIZE) => {
2641
+ const media = await this.gateway.downloadEventMediaStream(
2642
+ roomId,
2643
+ eventId,
2644
+ );
2645
+ if (!media) return null;
2646
+ const plain = media.file
2647
+ ? media.stream.pipeThrough(createAttachmentDecryptor(media.file))
2648
+ : media.stream;
2649
+ return {
2650
+ bytes: await readBytesCapped(plain, maxBytes),
2651
+ ...(media.mimetype ? { mimetype: media.mimetype } : {}),
2652
+ ...(media.filename ? { filename: media.filename } : {}),
2653
+ };
2654
+ },
2401
2655
  };
2402
2656
  }
2403
2657
 
@@ -2672,6 +2926,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2672
2926
  `[user-do] no oracle room resolved for ${req.identity.userDid}; room-scoped plugins (memory) will be unavailable this turn`,
2673
2927
  );
2674
2928
  }
2929
+ if (sessionRoomId) this.sessionRooms.set(req.sessionId, sessionRoomId);
2675
2930
 
2676
2931
  // A Matrix turn with no usable delegation: ask the web app to open its
2677
2932
  // "authorize for Matrix" modal (throttled per user, best-effort) — the
@@ -2681,7 +2936,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2681
2936
  sessionRoomId &&
2682
2937
  !this.delegations.get(req.identity.userDid)?.raw
2683
2938
  ) {
2684
- void this.maybePromptReauth(req.identity.userDid, sessionRoomId);
2939
+ this.promptReauth(req.identity.userDid, sessionRoomId);
2685
2940
  }
2686
2941
 
2687
2942
  // Per-room user preferences (tone, language, what to call whom) —
@@ -2873,7 +3128,7 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2873
3128
  configurable: { thread_id: req.sessionId },
2874
3129
  context,
2875
3130
  signal: abortController.signal,
2876
- recursionLimit: 60,
3131
+ recursionLimit: turnRecursionLimit(this.env),
2877
3132
  metadata: tracing.metadata,
2878
3133
  ...(tracing.callbacks ? { callbacks: tracing.callbacks } : {}),
2879
3134
  };
@@ -2909,40 +3164,57 @@ export function createUserOracleDO(opts: UserOracleDOOptions) {
2909
3164
  if (!req.sessionId.startsWith('$')) return;
2910
3165
  if (!text.trim()) return;
2911
3166
  const label = who === 'user' ? 'user message' : 'AI response';
2912
- // `waitUntil`: the non-streaming handler returns its Response right
2913
- // after the turn, and un-awaited I/O started inside a request is torn
2914
- // down with it ("Network connection lost") unless the object is told
2915
- // to keep the work alive.
2916
- this.ctx.waitUntil(
2917
- (async () => {
3167
+ // Serialised per session, retried across a gateway restart with a fixed
3168
+ // transaction id, kept alive past the request (room-mirror.ts).
3169
+ void this.roomMirror().enqueue(
3170
+ req.sessionId,
3171
+ async () => {
2918
3172
  const roomId =
3173
+ this.sessionRooms.get(req.sessionId) ??
2919
3174
  req.roomId ??
2920
3175
  (await this.sessions?.getSession(req.sessionId))?.roomId ??
2921
- (await this.gateway.resolveUserRoom(req.identity.userDid))?.roomId;
3176
+ (
3177
+ await retryGateway(() =>
3178
+ this.gateway.resolveUserRoom(req.identity.userDid),
3179
+ )
3180
+ )?.roomId;
2922
3181
  if (!roomId) throw new Error('no oracle room for this user');
3182
+ this.sessionRooms.set(req.sessionId, roomId);
2923
3183
  const { body, formattedBody } = formatReplay({
2924
3184
  message: text,
2925
3185
  isOracle: who === 'oracle',
2926
3186
  oracleName: this.core.identity.name,
2927
3187
  });
2928
- const eventId = await this.gateway.sendText(roomId, body, {
3188
+ return {
3189
+ roomId,
3190
+ body,
3191
+ ...(formattedBody ? { formattedBody } : {}),
2929
3192
  threadId: req.sessionId,
2930
- formattedBody,
2931
- priority: 'background',
2932
- });
2933
- console.log(
2934
- `[user-do] Matrix replay (${label}) → ${eventId} in thread ${req.sessionId}`,
2935
- );
2936
- })().catch((err: unknown) => {
2937
- console.error(
2938
- `[user-do] Matrix replay (${label}) failed — session=${req.sessionId}: ${
2939
- err instanceof Error ? err.message : String(err)
2940
- }`,
2941
- );
2942
- }),
3193
+ txnId: mirrorTxnId(req.sessionId, req.requestId, who),
3194
+ };
3195
+ },
3196
+ label,
2943
3197
  );
2944
3198
  }
2945
3199
 
3200
+ private roomMirror(): RoomMirror {
3201
+ this.mirror ??= new RoomMirror({
3202
+ sendText: (send) =>
3203
+ this.gateway.sendText(send.roomId, send.body, {
3204
+ threadId: send.threadId,
3205
+ ...(send.formattedBody
3206
+ ? { formattedBody: send.formattedBody }
3207
+ : {}),
3208
+ priority: 'background',
3209
+ txnId: send.txnId,
3210
+ }),
3211
+ keepAlive: (work) => this.ctx.waitUntil(work),
3212
+ log: (message) => console.log(message),
3213
+ warn: (message) => console.warn(message),
3214
+ });
3215
+ return this.mirror;
3216
+ }
3217
+
2946
3218
  private async afterTurn(
2947
3219
  sessionId: string,
2948
3220
  messages: BaseMessage[],