@ixo/oracle-runtime-workers 0.10.0 → 0.11.1

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 (49) 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 +29 -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 +229 -0
  19. package/src/do/flush-schedule.ts +40 -0
  20. package/src/do/result-store-test-do.ts +88 -0
  21. package/src/do/result-store.ts +339 -0
  22. package/src/do/run-buffer.ts +204 -0
  23. package/src/do/run-coordinator.ts +572 -0
  24. package/src/do/run-request.ts +52 -0
  25. package/src/do/run-store-test-do.ts +130 -0
  26. package/src/do/run-store.ts +604 -0
  27. package/src/do/session-id-migration.ts +85 -0
  28. package/src/do/speaker-prefix.ts +37 -0
  29. package/src/do/sse-stream.ts +567 -400
  30. package/src/do/transcript.ts +238 -0
  31. package/src/do/turn-body.ts +10 -0
  32. package/src/do/user-oracle-do.ts +948 -162
  33. package/src/matrix/gateway-do.ts +230 -29
  34. package/src/matrix/group-chat-summarizer.ts +107 -0
  35. package/src/matrix/group-chat.ts +1163 -0
  36. package/src/matrix/inbox-store.ts +23 -3
  37. package/src/matrix/ingest.ts +54 -11
  38. package/src/matrix/reply-chain.ts +34 -25
  39. package/src/plugin-api/tool-helper.ts +9 -1
  40. package/src/plugin-api/types.ts +86 -0
  41. package/src/plugins/index.ts +3 -0
  42. package/src/plugins/matrix-group-chats/README.md +70 -0
  43. package/src/plugins/matrix-group-chats/index.ts +132 -0
  44. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  45. package/src/plugins/tasks/tasks-tools.ts +4 -0
  46. package/src/shell/app.ts +66 -0
  47. package/src/sqlite/sqlite-saver.ts +81 -0
  48. package/src/tasks/scheduler.ts +134 -3
  49. package/src/tasks/store.ts +22 -0
@@ -13,6 +13,10 @@ import {
13
13
  isAttachmentViewMessage,
14
14
  } from '../attachments/retention';
15
15
  import { SUMMARY_PREFIX } from '../core/middlewares/summarization';
16
+ import type {
17
+ ThreadMessageAnchor,
18
+ ThreadMessageRow,
19
+ } from '../sqlite/sqlite-saver';
16
20
 
17
21
  export interface ToolCallDto {
18
22
  name: string;
@@ -203,3 +207,237 @@ export async function transformTranscript(
203
207
  }
204
208
  return { messages: acc };
205
209
  }
210
+
211
+ // ── Transcript paging (docs/plans/transcript-paging.md) ─────────────────────
212
+
213
+ export const TRANSCRIPT_PAGE_DEFAULT = 20;
214
+ export const TRANSCRIPT_PAGE_MAX = 100;
215
+ /** Rows read per round while a page collects its turns (a turn is one row or dozens). */
216
+ const TRANSCRIPT_ROW_BATCH = 80;
217
+
218
+ export interface TranscriptPageOptions {
219
+ /** Turns per page: a user message with everything the agent did until the next one. */
220
+ limit: number;
221
+ /** The page of turns older than this cursor (a message id from an earlier page). */
222
+ before?: string;
223
+ /**
224
+ * What follows this cursor, oldest first: the turn the cursor sits in,
225
+ * re-sent whole from its start when the cursor split it, then up to
226
+ * `limit` newer turns. A client folds it in by message id.
227
+ */
228
+ after?: string;
229
+ }
230
+
231
+ export interface TranscriptPage extends ListMessagesResponse {
232
+ /** Continue older from here (`before=`); null once the first message is in the page. */
233
+ prevCursor: string | null;
234
+ /** Continue newer from here (`after=`); null while the thread has no rows. */
235
+ nextCursor: string | null;
236
+ hasOlder: boolean;
237
+ hasNewer: boolean;
238
+ }
239
+
240
+ /** The rows a pager reads — what `SqliteSaver` implements. */
241
+ export interface TranscriptRowSource {
242
+ findThreadMessageAnchor(
243
+ threadId: string,
244
+ messageId: string,
245
+ ): Promise<ThreadMessageAnchor | null>;
246
+ listThreadMessageRows(
247
+ threadId: string,
248
+ opts: {
249
+ direction: 'older' | 'newer';
250
+ anchor?: ThreadMessageAnchor | null;
251
+ inclusive?: boolean;
252
+ limit: number;
253
+ },
254
+ ): Promise<ThreadMessageRow[]>;
255
+ }
256
+
257
+ /** A cursor naming no row of the thread (a client kept one from another session). */
258
+ export class TranscriptCursorError extends Error {
259
+ constructor(readonly cursor: string) {
260
+ super(`unknown transcript cursor: ${cursor}`);
261
+ this.name = 'TranscriptCursorError';
262
+ }
263
+ }
264
+
265
+ /** A turn starts at a user message; the summarizer's bookkeeping row is not one. */
266
+ function startsTurn(row: ThreadMessageRow): boolean {
267
+ return row.message.type === 'human' && !isSummarizationMessage(row.message);
268
+ }
269
+
270
+ /** `GET /sessions/:id/messages` query → options, or the 400 to answer with. */
271
+ export function parseTranscriptPageQuery(
272
+ query: Record<string, string | undefined>,
273
+ ):
274
+ | { ok: true; options: TranscriptPageOptions }
275
+ | { ok: false; message: string } {
276
+ const rawLimit = query.limit?.trim();
277
+ let limit = TRANSCRIPT_PAGE_DEFAULT;
278
+ if (rawLimit) {
279
+ const n = Number(rawLimit);
280
+ if (!Number.isInteger(n) || n < 1)
281
+ return { ok: false, message: 'limit must be a positive integer' };
282
+ limit = Math.min(n, TRANSCRIPT_PAGE_MAX);
283
+ }
284
+ const before = query.before?.trim() || undefined;
285
+ const after = query.after?.trim() || undefined;
286
+ if (before && after)
287
+ return { ok: false, message: 'before and after are mutually exclusive' };
288
+ return {
289
+ ok: true,
290
+ options: {
291
+ limit,
292
+ ...(before !== undefined && { before }),
293
+ ...(after !== undefined && { after }),
294
+ },
295
+ };
296
+ }
297
+
298
+ async function toPage(rows: ThreadMessageRow[]): Promise<MessageDto[]> {
299
+ const { messages } = await transformTranscript(
300
+ rows.map((r) => r.message).filter((m) => !isSummarizationMessage(m)),
301
+ );
302
+ return messages;
303
+ }
304
+
305
+ /**
306
+ * One page of a session's transcript, aligned to turns so a tool result is
307
+ * never separated from the reply that called it. Without a cursor: the
308
+ * newest `limit` turns. The legacy `GET /messages/:id` listing is this with
309
+ * no limit.
310
+ */
311
+ export async function pageThreadTranscript(
312
+ source: TranscriptRowSource,
313
+ threadId: string,
314
+ options: TranscriptPageOptions,
315
+ ): Promise<TranscriptPage> {
316
+ const limit = Math.max(1, Math.min(options.limit, TRANSCRIPT_PAGE_MAX));
317
+ if (options.after !== undefined)
318
+ return pageNewer(source, threadId, options.after, limit);
319
+ return pageOlder(source, threadId, options.before, limit);
320
+ }
321
+
322
+ async function pageOlder(
323
+ source: TranscriptRowSource,
324
+ threadId: string,
325
+ before: string | undefined,
326
+ limit: number,
327
+ ): Promise<TranscriptPage> {
328
+ let anchor: ThreadMessageAnchor | null = null;
329
+ if (before !== undefined) {
330
+ anchor = await source.findThreadMessageAnchor(threadId, before);
331
+ if (!anchor) throw new TranscriptCursorError(before);
332
+ }
333
+ const collected: ThreadMessageRow[] = []; // newest first
334
+ let turns = 0;
335
+ let hasOlder = false;
336
+ let cursor = anchor;
337
+ outer: for (;;) {
338
+ const batch = await source.listThreadMessageRows(threadId, {
339
+ direction: 'older',
340
+ anchor: cursor,
341
+ limit: TRANSCRIPT_ROW_BATCH,
342
+ });
343
+ for (let i = 0; i < batch.length; i += 1) {
344
+ const row = batch[i]!;
345
+ collected.push(row);
346
+ if (!startsTurn(row)) continue;
347
+ turns += 1;
348
+ if (turns < limit) continue;
349
+ hasOlder =
350
+ i + 1 < batch.length ||
351
+ (
352
+ await source.listThreadMessageRows(threadId, {
353
+ direction: 'older',
354
+ anchor: row.anchor,
355
+ limit: 1,
356
+ })
357
+ ).length > 0;
358
+ break outer;
359
+ }
360
+ if (batch.length < TRANSCRIPT_ROW_BATCH) break; // the thread's first row is in the page
361
+ cursor = batch[batch.length - 1]!.anchor;
362
+ }
363
+ const rows = collected.reverse();
364
+ return {
365
+ messages: await toPage(rows),
366
+ prevCursor: hasOlder ? rows[0]!.messageId : null,
367
+ nextCursor: rows.length ? rows[rows.length - 1]!.messageId : null,
368
+ hasOlder,
369
+ hasNewer: before !== undefined,
370
+ };
371
+ }
372
+
373
+ async function pageNewer(
374
+ source: TranscriptRowSource,
375
+ threadId: string,
376
+ after: string,
377
+ limit: number,
378
+ ): Promise<TranscriptPage> {
379
+ const anchor = await source.findThreadMessageAnchor(threadId, after);
380
+ if (!anchor) throw new TranscriptCursorError(after);
381
+ const fresh: ThreadMessageRow[] = []; // oldest first
382
+ let turns = 0;
383
+ let hasNewer = false;
384
+ let cursor: ThreadMessageAnchor = anchor;
385
+ outer: for (;;) {
386
+ const batch = await source.listThreadMessageRows(threadId, {
387
+ direction: 'newer',
388
+ anchor: cursor,
389
+ limit: TRANSCRIPT_ROW_BATCH,
390
+ });
391
+ for (const row of batch) {
392
+ if (startsTurn(row)) {
393
+ turns += 1;
394
+ if (turns > limit) {
395
+ hasNewer = true;
396
+ break outer;
397
+ }
398
+ }
399
+ fresh.push(row);
400
+ }
401
+ if (batch.length < TRANSCRIPT_ROW_BATCH) break;
402
+ cursor = batch[batch.length - 1]!.anchor;
403
+ }
404
+ if (fresh.length === 0)
405
+ return {
406
+ messages: [],
407
+ prevCursor: null,
408
+ nextCursor: after,
409
+ hasOlder: false,
410
+ hasNewer: false,
411
+ };
412
+ // The cursor split a turn (a reply and its tool results land row by row):
413
+ // re-send that turn from its start so the tool results fold into their
414
+ // reply again on the client.
415
+ const head: ThreadMessageRow[] = []; // newest first, the cursor row included
416
+ if (!startsTurn(fresh[0]!)) {
417
+ let back: ThreadMessageAnchor | null = anchor;
418
+ let inclusive = true;
419
+ outer2: for (;;) {
420
+ const batch = await source.listThreadMessageRows(threadId, {
421
+ direction: 'older',
422
+ anchor: back,
423
+ inclusive,
424
+ limit: TRANSCRIPT_ROW_BATCH,
425
+ });
426
+ for (const row of batch) {
427
+ head.push(row);
428
+ if (startsTurn(row)) break outer2;
429
+ }
430
+ if (batch.length < TRANSCRIPT_ROW_BATCH) break;
431
+ back = batch[batch.length - 1]!.anchor;
432
+ inclusive = false;
433
+ }
434
+ }
435
+ const rows = [...head.reverse(), ...fresh];
436
+ return {
437
+ messages: await toPage(rows),
438
+ prevCursor: null,
439
+ nextCursor: rows[rows.length - 1]!.messageId,
440
+ hasOlder: false,
441
+ hasNewer,
442
+ };
443
+ }
@@ -19,11 +19,14 @@ export const TURN_BODY_KEYS: ReadonlySet<string> = new Set([
19
19
  'mcpInvocations',
20
20
  'attachments',
21
21
  'requestId',
22
+ 'multitask',
22
23
  ]);
23
24
 
24
25
  export interface TurnBody {
25
26
  message: string;
26
27
  stream?: boolean;
28
+ /** `interrupt` (default) or `enqueue` — see `TurnRequest.multitask`. */
29
+ multitask?: 'interrupt' | 'enqueue';
27
30
  returnAllMessages?: boolean;
28
31
  timezone?: string;
29
32
  model?: string;
@@ -80,5 +83,12 @@ export function parseTurnBody(raw: string): ParsedTurnBody {
80
83
  if (typeof record.message !== 'string' || record.message.length === 0) {
81
84
  return reject('message is required');
82
85
  }
86
+ if (
87
+ record.multitask !== undefined &&
88
+ record.multitask !== 'interrupt' &&
89
+ record.multitask !== 'enqueue'
90
+ ) {
91
+ return reject('multitask must be one of: interrupt, enqueue');
92
+ }
83
93
  return { ok: true, body: record as unknown as TurnBody };
84
94
  }