@ixo/oracle-runtime-workers 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +38 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +249 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +1019 -169
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/owner-store/ixo-vfs-store.ts +12 -5
  39. package/src/owner-store/matrix-media-store.ts +12 -7
  40. package/src/owner-store/measure.ts +39 -0
  41. package/src/owner-store/migrating-store.ts +9 -3
  42. package/src/owner-store/types.ts +14 -4
  43. package/src/plugin-api/tool-helper.ts +9 -1
  44. package/src/plugin-api/types.ts +86 -0
  45. package/src/plugins/index.ts +3 -0
  46. package/src/plugins/matrix-group-chats/README.md +70 -0
  47. package/src/plugins/matrix-group-chats/index.ts +132 -0
  48. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  49. package/src/plugins/tasks/tasks-tools.ts +4 -0
  50. package/src/shell/app.ts +78 -0
  51. package/src/sqlite/cache-config.ts +27 -6
  52. package/src/sqlite/chunk-layout.ts +14 -0
  53. package/src/sqlite/database.ts +188 -19
  54. package/src/sqlite/do-vfs.ts +963 -99
  55. package/src/sqlite/index.ts +13 -0
  56. package/src/sqlite/page-tier.ts +668 -0
  57. package/src/sqlite/sqlite-saver.ts +81 -0
  58. package/src/tasks/scheduler.ts +134 -3
  59. package/src/tasks/store.ts +22 -0
@@ -1,6 +1,8 @@
1
+ import type { TranscriptPageOptions } from './transcript';
1
2
  import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
2
3
  import type { AttachmentInput } from '../attachments/types';
3
4
  import type { RealtimeStatus } from '../realtime/realtime-endpoint';
5
+ import type { TierFlushResult, TierStatus } from '../sqlite/do-vfs';
4
6
  /**
5
7
  * Cross-object contracts for the Workers runtime.
6
8
  *
@@ -60,6 +62,11 @@ export interface OracleWorkerEnv {
60
62
  MATRIX_SEND_CONCURRENCY?: string;
61
63
  /** Room turns in flight at once in the gateway (each ends in an encrypted reply; default 4). */
62
64
  MATRIX_TURN_CONCURRENCY?: string;
65
+ /** Group rooms (gateway): `silent` (default) never speaks there, `gate` runs the Node lane, `answer` replies to everything. */
66
+ MATRIX_GROUP_ROOMS?: string;
67
+ GROUP_CHAT_ACTIVE_THREAD_TTL_MS?: string;
68
+ GROUP_CHAT_REQUIRE_POWER_LEVEL?: string;
69
+ GROUP_CHAT_ROOM_INFO_TTL_MS?: string;
63
70
  /** Idle recycle of the gateway object after this many sends (default 300; 0 disables). */
64
71
  MATRIX_RECYCLE_AFTER_SENDS?: string;
65
72
  /** Rooms kept fully built in memory (SDK default 64; idle rooms are released). */
@@ -82,6 +89,20 @@ export interface OracleWorkerEnv {
82
89
  * for the hit/miss counters that show what a budget buys.
83
90
  */
84
91
  CHUNK_CACHE_BYTES?: string;
92
+ /**
93
+ * R2 page tier for the users' working copies (see `sqlite/page-tier.ts`):
94
+ * chunks no turn touched for `TIER_EVICT_AFTER_PERIODS` periods move
95
+ * from DO SQLite ($0.20/GB-month, 10 GB cap per object) into 1 MiB R2
96
+ * segment objects ($0.015/GB-month, no cap) under a per-object prefix.
97
+ * Absent = every chunk stays in DO storage (the pre-tier behaviour).
98
+ */
99
+ TIER_BUCKET?: R2Bucket;
100
+ /** Soft target for hot bytes per user object (bytes or `16m`; default 16 MiB). */
101
+ TIER_HOT_BUDGET_BYTES?: string;
102
+ /** Periods (of `TIER_PERIOD_MS`) a chunk must go untouched before eviction (default 2). */
103
+ TIER_EVICT_AFTER_PERIODS?: string;
104
+ /** Length of one access-tracking period in ms (default one day; tests shorten it). */
105
+ TIER_PERIOD_MS?: string;
85
106
  /** IXO VFS worker base URL when `OWNER_STORE=vfs` (defaults per NETWORK). */
86
107
  VFS_BASE_URL?: string;
87
108
  /** UCAN store worker base URL when `OWNER_STORE=vfs` (defaults per NETWORK). */
@@ -109,6 +130,26 @@ export interface OracleWorkerEnv {
109
130
  MAIN_REASONING_EFFORT?: string;
110
131
  /** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
111
132
  TURN_RECURSION_LIMIT?: string;
133
+ /** Durable-run knobs (docs/plans/durable-runs.md); see `runDurabilityConfig`. */
134
+ RUN_KEEPALIVE_MS?: string;
135
+ RUN_SEGMENT_FLUSH_MS?: string;
136
+ RUN_SEGMENT_BYTES?: string;
137
+ RUN_RECOVERY_ATTEMPTS?: string;
138
+ RUN_RECOVERY_DELAYS_MS?: string;
139
+ TURN_MULTITASK_DEFAULT?: string;
140
+ /** Context-budget knobs (docs/plans/context-budgets.md). */
141
+ MODEL_CONTEXT_TOKENS?: string;
142
+ MODEL_CONTEXT_OVERRIDES?: string;
143
+ CONTEXT_SUMMARIZE_FRACTION?: string;
144
+ CONTEXT_PRUNE_FRACTION?: string;
145
+ CONTEXT_RESULT_CAP_FRACTION?: string;
146
+ CONTEXT_RESULT_CAP_MAX_CHARS?: string;
147
+ CONTEXT_REQUEST_FRACTION?: string;
148
+ CONTEXT_OUTPUT_RESERVE_TOKENS?: string;
149
+ CONTEXT_SUMMARIZE_MESSAGES?: string;
150
+ CONTEXT_KEEP_MESSAGES?: string;
151
+ TOOL_RESULT_TTL_HOURS?: string;
152
+ TOOL_RESULT_R2_MIN_BYTES?: string;
112
153
  /** Platform model provider. Default `openrouter`; `nebius` for self-hosted. */
113
154
  LLM_PROVIDER?: 'openrouter' | 'nebius';
114
155
  /** Nebius Token Factory API key — required when `LLM_PROVIDER=nebius`. */
@@ -188,6 +229,14 @@ export interface TurnRequest {
188
229
  threadId?: string;
189
230
  /** The user's message event this turn answers (Matrix turns): anchors the `work_status` card. */
190
231
  eventId?: string;
232
+ /**
233
+ * Matrix turns: whether the room is a direct room or a group room (more
234
+ * than two members) as the gateway's group-chat gate saw it. A group turn's
235
+ * message is prefixed `[DisplayName]: ` and gets the channel-memory tools.
236
+ */
237
+ roomKind?: 'direct' | 'group';
238
+ /** The speaker's display name in the room (Matrix turns). */
239
+ senderDisplayName?: string;
191
240
  requestId: string;
192
241
  /** Per-request model override (validated against the catalog). */
193
242
  model?: string;
@@ -195,6 +244,134 @@ export interface TurnRequest {
195
244
  metadata?: JsonString;
196
245
  /** Files attached to the message (validated `AttachmentDto` shape). */
197
246
  attachments?: AttachmentInput[];
247
+ /**
248
+ * What this message does to a run already active on the same session:
249
+ * `interrupt` (default; the running turn is aborted, Node parity) or
250
+ * `enqueue` (it waits its turn). See docs/plans/durable-runs.md.
251
+ */
252
+ multitask?: 'interrupt' | 'enqueue';
253
+ /** The scheduler's run id when this turn executes a scheduled task. */
254
+ taskRunId?: string;
255
+ }
256
+
257
+ /** A run as the shell reports it (`GET /sessions/:id/run`, `GET /debug/runs`). */
258
+ /** `GET /debug/context` — the context budget a model gets on this object. */
259
+ /**
260
+ * Per-session context counters (`GET /debug/context?session=`): what the
261
+ * guard did across the session's turns plus the thread as stored, so a test
262
+ * or an operator can see pruning and summarization without the logs.
263
+ */
264
+ export interface SessionContextStatus {
265
+ id: string;
266
+ /** Every row of the stored transcript (never condensed; what the user sees). */
267
+ threadMessages: number;
268
+ /**
269
+ * The working context: the messages the latest checkpoint carries into
270
+ * the next request — after a summary, one summary message plus the kept
271
+ * tail, however long the transcript is.
272
+ */
273
+ contextMessages: number;
274
+ /** Summary messages in the working context (1 once the history was condensed). */
275
+ contextSummaries: number;
276
+ contextToolMessages: number;
277
+ /** chars/4 estimate of the working context. */
278
+ contextTokens: number;
279
+ /** Requests the guard pruned before sending (soft or hard). */
280
+ prunes: number;
281
+ hardPrunes: number;
282
+ /** Tool results demoted or de-duplicated in total. */
283
+ prunedResults: number;
284
+ overflowRetries: number;
285
+ refusals: number;
286
+ lastEventAt?: string;
287
+ }
288
+
289
+ export interface ContextStatus {
290
+ model: string;
291
+ byoProvider?: string;
292
+ window: { tokens: number; origin: string; catalogId?: string };
293
+ budget: {
294
+ summarizeAtTokens: number;
295
+ pruneAtTokens: number;
296
+ resultCapChars: number;
297
+ requestCapTokens: number;
298
+ outputReserveTokens: number;
299
+ keepMessages: number;
300
+ summarizeTriggerMessages?: number;
301
+ };
302
+ results: {
303
+ rows: number;
304
+ sqliteBytes: number;
305
+ r2Rows: number;
306
+ r2Bytes: number;
307
+ tier: 'sqlite+r2' | 'sqlite';
308
+ };
309
+ /** Present when a session was asked for; null when that session is unknown. */
310
+ session?: SessionContextStatus | null;
311
+ }
312
+
313
+ export interface RunSummary {
314
+ runId: string;
315
+ sessionId: string;
316
+ requestId: string;
317
+ client: 'portal' | 'matrix';
318
+ status:
319
+ | 'queued'
320
+ | 'running'
321
+ | 'recovering'
322
+ | 'finished'
323
+ | 'aborted'
324
+ | 'interrupted'
325
+ | 'failed';
326
+ startedAt: string;
327
+ updatedAt: string;
328
+ /** Highest frame sequence a re-join can ask `after`. */
329
+ lastSeq: number;
330
+ messageId?: string;
331
+ /** The reply so far, for a run that ended without a committed reply. */
332
+ partialText?: string;
333
+ }
334
+
335
+ /** `GET /debug/runs`: the durable-run configuration, live runs and recent rows. */
336
+ export interface RunsStatus {
337
+ config: {
338
+ keepAliveMs: number;
339
+ segmentFlushMs: number;
340
+ segmentBytes: number;
341
+ recoveryAttempts: number;
342
+ recoveryDelaysMs: readonly number[];
343
+ multitaskDefault: 'interrupt' | 'enqueue';
344
+ };
345
+ live: Array<{
346
+ runId: string;
347
+ sessionId: string;
348
+ status: string;
349
+ attemptInFlight: boolean;
350
+ generation: number;
351
+ lastSeq: number;
352
+ /** Highest frame sequence packed into a segment so far. */
353
+ packedSeq: number;
354
+ subscribers: number;
355
+ nextAttemptAt: number | null;
356
+ }>;
357
+ runs: Array<
358
+ RunSummary & {
359
+ marks: Array<{
360
+ toolCallId: string;
361
+ toolName: string;
362
+ effect: 'read' | 'write';
363
+ startedAt: string;
364
+ doneAt: string | null;
365
+ outcome: string | null;
366
+ attempts: number;
367
+ }>;
368
+ segments: number;
369
+ attempts: number;
370
+ nextAttemptAt: number | null;
371
+ error: string | null;
372
+ taskRunId: string | null;
373
+ }
374
+ >;
198
375
  }
199
376
 
200
377
  /** Non-streaming turn result. */
@@ -284,6 +461,8 @@ export interface StorageStatus {
284
461
  freelistCount?: number;
285
462
  /** SQLite's own page cache (`PRAGMA cache_size`; negative = KiB). */
286
463
  sqliteCacheSize?: number;
464
+ /** The R2 page tier of this object's working copy (see sqlite/page-tier.ts). */
465
+ tier?: TierStatus & { lastPassAt?: number };
287
466
  /** The DO VFS clean-chunk cache and its storage counters (isolate lifetime). */
288
467
  chunkCache?: {
289
468
  budgetBytes: number;
@@ -306,6 +485,13 @@ export interface StorageStatus {
306
485
  export interface UserOracleObject extends Rpc.DurableObjectBranded {
307
486
  /** Run a turn and return the final text (used by the Matrix gateway). */
308
487
  runTurn(req: TurnRequest): Promise<TurnResult>;
488
+ /**
489
+ * Compact a batch of group-room messages (JSON `ObservedMessage[]`) into
490
+ * one channel-memory summary with the platform model, for the gateway's
491
+ * per-room memory (`src/matrix/group-chat.ts`). `null` when the model
492
+ * answered nothing — the gateway keeps the batch buffered.
493
+ */
494
+ summarizeGroupMessages(messages: JsonString): Promise<string | null>;
309
495
  createSession(
310
496
  identity: TurnIdentity,
311
497
  opts?: { roomId?: string; sessionId?: string },
@@ -317,7 +503,33 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
317
503
  deleteSession(identity: TurnIdentity, sessionId: string): Promise<boolean>;
318
504
  /** JSON-encoded `MessageDto[]` for `GET /messages/:sessionId`. */
319
505
  listMessages(identity: TurnIdentity, sessionId: string): Promise<JsonString>;
506
+ /**
507
+ * `GET /sessions/:id/messages` — one turn-aligned page of the transcript
508
+ * (docs/plans/transcript-paging.md); the JSON of a `TranscriptPage`, or
509
+ * the 400 to answer with when the cursor names no row.
510
+ */
511
+ listMessagesPage(
512
+ identity: TurnIdentity,
513
+ sessionId: string,
514
+ options: TranscriptPageOptions,
515
+ ): Promise<
516
+ { ok: true; json: JsonString } | { ok: false; status: 400; message: string }
517
+ >;
320
518
  abortTurn(sessionId: string): Promise<boolean>;
519
+ /** The session's active (queued/running/recovering) run, if any. */
520
+ sessionRun(
521
+ identity: TurnIdentity,
522
+ sessionId: string,
523
+ ): Promise<RunSummary | null>;
524
+ /** Diagnostics: recent runs with their tool marks and live state. */
525
+ runsStatus(): Promise<RunsStatus>;
526
+ /** `GET /debug/context` — the context budget a model gets on this object. */
527
+ contextStatus(
528
+ identity: TurnIdentity,
529
+ model?: string,
530
+ byoProvider?: string,
531
+ sessionId?: string,
532
+ ): Promise<ContextStatus>;
321
533
  /** Adopt a freshly deposited delegation for header-less turns (no boot). */
322
534
  setDelegation(
323
535
  userDid: string,
@@ -356,6 +568,11 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
356
568
  * the "user deleted their file upstream" path.
357
569
  */
358
570
  resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }>;
571
+ /** Run one R2 page-tier eviction pass now (debug routes; `force` ignores recency). */
572
+ tierFlush(opts?: {
573
+ force?: boolean;
574
+ maxSegments?: number;
575
+ }): Promise<TierFlushResult>;
359
576
  /** Raw upstream schema of the memory search tool as fetched right now. */
360
577
  debugMemorySchema(userDid: string): Promise<MemorySchemaDebug>;
361
578
  /** Forcibly reset the object like a platform host drain (debug routes only). The call itself rejects. */
@@ -544,6 +761,28 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
544
761
  getRoomState(roomId: string): Promise<JsonString>;
545
762
  /** One timeline event (decrypted when E2EE) as JSON `{ event_id, type, content, sender, origin_server_ts }`, or null. */
546
763
  getEvent(roomId: string, eventId: string): Promise<JsonString | null>;
764
+ /**
765
+ * Group rooms (`src/matrix/group-chat.ts`): whether a room is direct or a
766
+ * group room, and the room's channel memory — what the user object's
767
+ * `matrix-group-chats` tools read and write. Payloads are JSON strings.
768
+ */
769
+ groupChatRoomInfo(roomId: string): Promise<GroupRoomInfo>;
770
+ /** `{ chunks, pinnedFacts, members }` — the newest `limit` summary chunks (1..30, default 10). */
771
+ channelMemoryRecall(roomId: string, limit?: number): Promise<JsonString>;
772
+ /** Matching summary chunks for the keywords. */
773
+ channelMemorySearch(
774
+ roomId: string,
775
+ query: string,
776
+ limit?: number,
777
+ ): Promise<JsonString>;
778
+ /** The pinned fact as stored. */
779
+ channelMemoryPin(
780
+ roomId: string,
781
+ fact: string,
782
+ pinnedByDid: string,
783
+ sourceEventId?: string,
784
+ ): Promise<JsonString>;
785
+ channelMemoryUnpin(roomId: string, factId: string): Promise<boolean>;
547
786
  /**
548
787
  * The oracle's P-256 secrets key, JSON-encoded private JWK. Read from the
549
788
  * oracle's Matrix *account* room (`MATRIX_ACCOUNT_ROOM_ID`) — the published
@@ -604,8 +843,18 @@ export interface SnapshotStream extends MediaStream {
604
843
  eventId: string;
605
844
  }
606
845
 
846
+ export interface GroupRoomInfo {
847
+ isDirect: boolean;
848
+ memberCount: number;
849
+ joinedMemberIds: string[];
850
+ /** Whether this deployment runs the group-chat lane (`MATRIX_GROUP_ROOMS=gate`); the channel-memory tools exist only then. */
851
+ groupLane: boolean;
852
+ }
853
+
607
854
  export interface GatewayStatus extends BotStatus {
608
855
  turns: { inFlight: number; waiting: number };
856
+ /** Channel memory of group rooms: messages awaiting compaction, summary chunks, pinned facts. */
857
+ groupChat: { buffered: number; chunks: number; facts: number };
609
858
  /** Debounce buffers waiting to become turns. */
610
859
  ingestPending: number;
611
860
  /** Room messages whose turn has not ended yet (durable; replayed after a reset). */
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Test-only Durable Object driving `ResultStore` over real DO SQLite and the
3
+ * test R2 bucket inside workerd. Bound as `RESULT_STORE_TEST` by
4
+ * `test/wrangler.test.jsonc`. Not part of the runtime.
5
+ */
6
+ import { DurableObject } from 'cloudflare:workers';
7
+ import { DoSqliteDatabase } from '../sqlite/database';
8
+ import {
9
+ ResultStore,
10
+ type ReadResultOutcome,
11
+ type StoredResultRef,
12
+ } from './result-store';
13
+
14
+ export class ResultStoreTestDO extends DurableObject<{ TIER_TEST: R2Bucket }> {
15
+ private db: DoSqliteDatabase | undefined;
16
+
17
+ private store: ResultStore | undefined;
18
+
19
+ private nowMs = Date.parse('2026-09-14T10:00:00.000Z');
20
+
21
+ private r2MinBytes = 1_000_000;
22
+
23
+ private withBucket = true;
24
+
25
+ private async resultStore(): Promise<ResultStore> {
26
+ if (!this.db || !this.db.isOpen) {
27
+ this.db = await DoSqliteDatabase.open(this.ctx, 'results-test.db');
28
+ this.store = undefined;
29
+ }
30
+ this.store ??= new ResultStore(this.db, {
31
+ ...(this.withBucket ? { bucket: this.env.TIER_TEST } : {}),
32
+ prefix: `test-${this.ctx.id.toString()}`,
33
+ ttlMs: 60 * 60 * 1000,
34
+ r2MinBytes: this.r2MinBytes,
35
+ now: () => this.nowMs,
36
+ });
37
+ return this.store;
38
+ }
39
+
40
+ async configure(opts: {
41
+ r2MinBytes?: number;
42
+ withBucket?: boolean;
43
+ }): Promise<void> {
44
+ if (opts.r2MinBytes !== undefined) this.r2MinBytes = opts.r2MinBytes;
45
+ if (opts.withBucket !== undefined) this.withBucket = opts.withBucket;
46
+ this.store = undefined;
47
+ }
48
+
49
+ async setNow(ms: number): Promise<void> {
50
+ this.nowMs = ms;
51
+ }
52
+
53
+ async put(input: {
54
+ sessionId: string;
55
+ toolName: string;
56
+ content: string;
57
+ }): Promise<StoredResultRef | undefined> {
58
+ return (await this.resultStore()).put(input);
59
+ }
60
+
61
+ async read(
62
+ id: string,
63
+ offset?: number,
64
+ length?: number,
65
+ ): Promise<ReadResultOutcome> {
66
+ return (await this.resultStore()).read(id, offset, length);
67
+ }
68
+
69
+ async sweep(): Promise<number> {
70
+ return (await this.resultStore()).sweep();
71
+ }
72
+
73
+ async deleteForSession(sessionId: string): Promise<number> {
74
+ return (await this.resultStore()).deleteForSession(sessionId);
75
+ }
76
+
77
+ async stats(): Promise<Awaited<ReturnType<ResultStore['stats']>>> {
78
+ return (await this.resultStore()).stats();
79
+ }
80
+
81
+ async r2Has(id: string): Promise<boolean> {
82
+ return (
83
+ (await this.env.TIER_TEST.head(
84
+ `test-${this.ctx.id.toString()}/results/${id}`,
85
+ )) !== null
86
+ );
87
+ }
88
+ }