@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
@@ -27,6 +27,7 @@ import {
27
27
  Semaphore,
28
28
  withRateLimitRetry,
29
29
  type BotMessage,
30
+ type BotTimelineEvent,
30
31
  type MatrixBotOptions,
31
32
  type StartResult,
32
33
  } from '@ixo/matrix-bot-workers-sdk';
@@ -37,6 +38,7 @@ import {
37
38
  type BotCredentials,
38
39
  type CreateRoomOptions,
39
40
  type GatewayStatus,
41
+ type GroupRoomInfo,
40
42
  type JsonString,
41
43
  type MatrixGatewayObject,
42
44
  type OracleWorkerEnv,
@@ -56,6 +58,7 @@ import {
56
58
  } from '../secrets/signing-mnemonic';
57
59
  import {
58
60
  IngestPipeline,
61
+ userDidFromRoomAlias,
59
62
  type InboundAttachment,
60
63
  type IngestTurn,
61
64
  type InboundMessage,
@@ -66,6 +69,7 @@ import {
66
69
  deleteInboxRows,
67
70
  ensureInboxTable,
68
71
  inboundOfRow,
72
+ type InboxRow,
69
73
  insertInboxRow,
70
74
  listInboxRows,
71
75
  MAX_TURN_REPLAYS,
@@ -74,6 +78,14 @@ import {
74
78
  updateInboxThread,
75
79
  } from './inbox-store';
76
80
  import {
81
+ type GateDecision,
82
+ GroupChatService,
83
+ groupChatOptionsFromEnv,
84
+ isBotMentioned,
85
+ } from './group-chat';
86
+ import {
87
+ inReplyToOf,
88
+ type MatrixRelatesTo,
77
89
  readRelatesTo,
78
90
  resolveReplyChainRoot,
79
91
  ThreadRootCache,
@@ -194,6 +206,14 @@ function readMediaInfo(content: Record<string, unknown>): {
194
206
  }
195
207
 
196
208
  /** The `eventId` a `m.ixo.media_state` event points at, when set. */
209
+ /** `content` of a room event fetched as JSON (undefined when the shape is off). */
210
+ function eventContentOf(json: JsonString): unknown {
211
+ const parsed: unknown = JSON.parse(json);
212
+ return typeof parsed === 'object' && parsed !== null && 'content' in parsed
213
+ ? parsed.content
214
+ : undefined;
215
+ }
216
+
197
217
  function stateEventId(json: JsonString | null): string | undefined {
198
218
  if (!json) return undefined;
199
219
  const parsed: unknown = JSON.parse(json);
@@ -235,6 +255,8 @@ export class MatrixGatewayDO
235
255
  /** Event ids of turns running in this instance (a graceful restart must not replay them). */
236
256
  private readonly inFlightEvents = new Set<string>();
237
257
  private inboxReplayRunning = false;
258
+ /** Group rooms: the gate and the per-room channel memory (see group-chat.ts). */
259
+ private groupChatService: GroupChatService | null = null;
238
260
 
239
261
  // -------------------------------------------------------------------------
240
262
  // Configuration
@@ -307,6 +329,7 @@ export class MatrixGatewayDO
307
329
  const cfg = this.cfg();
308
330
  this.ingest = new IngestPipeline({
309
331
  oracleDid: cfg.oracleRoomDid,
332
+ botUserId: cfg.userId,
310
333
  canonicalAlias: (roomId) => this.roomAliases.get(roomId) ?? null,
311
334
  dispatch: (turn) => this.dispatchTurn(turn),
312
335
  onError: (err, context) =>
@@ -334,6 +357,13 @@ export class MatrixGatewayDO
334
357
  ...(isFile
335
358
  ? { attachment: attachmentOf(content, eventId, message.body) }
336
359
  : {}),
360
+ ...(isBotMentioned(content, this.cfg().userId)
361
+ ? { mentionsBot: true }
362
+ : {}),
363
+ ...(() => {
364
+ const target = inReplyToOf(readRelatesTo(content));
365
+ return target ? { inReplyTo: target } : {};
366
+ })(),
337
367
  };
338
368
  // Durable before anything waits on the network: the SDK has already
339
369
  // marked the event processed, so from here on only this row brings the
@@ -343,8 +373,13 @@ export class MatrixGatewayDO
343
373
  // Resolved before the offer so the pipeline's synchronous alias lookup
344
374
  // (room alias → user DID) can answer from the memo.
345
375
  await this.canonicalAliasOf(message.roomId);
346
- const threadRootId = await this.threadRootFor(message, eventId);
347
- if (threadRootId && threadRootId !== inbound.threadRootId) {
376
+ const threadRootId = await this.threadRootFor(
377
+ eventId,
378
+ message.roomId,
379
+ readRelatesTo(message.content),
380
+ message.threadRootId,
381
+ );
382
+ if (threadRootId !== inbound.threadRootId) {
348
383
  inbound.threadRootId = threadRootId;
349
384
  updateInboxThread(sql, eventId, threadRootId);
350
385
  }
@@ -363,34 +398,104 @@ export class MatrixGatewayDO
363
398
  }
364
399
  }
365
400
 
401
+ /** A membership change invalidates what the group-chat gate knows about the room. */
402
+ protected override async onEvent(event: BotTimelineEvent): Promise<void> {
403
+ await super.onEvent(event);
404
+ if (event.type === 'm.room.member')
405
+ this.groupChat().invalidateRoom(event.roomId);
406
+ }
407
+
366
408
  /**
367
- * Thread for an inbound message: the SDK's `threadRootId` (an explicit
368
- * `m.thread` relation) or, for a quote-reply, the thread of the message
369
- * chain it replies to (see `reply-chain.ts`).
409
+ * The group-room gate and channel memory, built once per instance on the
410
+ * gateway's own SQLite. Summaries are produced by the speaker's user
411
+ * object (the platform model's keys live there, not in this script).
412
+ */
413
+ private groupChat(): GroupChatService {
414
+ if (this.groupChatService) return this.groupChatService;
415
+ const cfg = this.cfg();
416
+ this.groupChatService = new GroupChatService(
417
+ this.ctx.storage.sql,
418
+ {
419
+ botUserId: cfg.userId,
420
+ isOracleRoom: async (roomId) => {
421
+ const alias = await this.canonicalAliasOf(roomId);
422
+ return (
423
+ alias !== null &&
424
+ userDidFromRoomAlias(alias, cfg.oracleRoomDid) !== null
425
+ );
426
+ },
427
+ getRoomStateEvent: (roomId, type, stateKey) =>
428
+ this.getRoomStateEvent(roomId, type, stateKey),
429
+ getJoinedRoomMembers: (roomId) => this.getJoinedRoomMembers(roomId),
430
+ getEvent: (roomId, eventId) => this.getEvent(roomId, eventId),
431
+ getUserProfile: (userId) => this.getUserProfile(userId),
432
+ summarize: (messages) => {
433
+ const last = messages[messages.length - 1];
434
+ if (!last) return Promise.resolve(null);
435
+ return this.userStub(last.senderDid).summarizeGroupMessages(
436
+ JSON.stringify(messages),
437
+ );
438
+ },
439
+ keepAlive: (work) => this.ctx.waitUntil(work),
440
+ log: (level, msg, extra) => this.log(level, msg, extra),
441
+ },
442
+ groupChatOptionsFromEnv(this.env),
443
+ );
444
+ return this.groupChatService;
445
+ }
446
+
447
+ /**
448
+ * The thread of an inbound message — its session (`reply-chain.ts`): the
449
+ * SDK's `threadRootId` (an explicit `m.thread` relation), the root of the
450
+ * chain a quote-reply answers, or the message itself when it is bare.
370
451
  */
371
452
  private async threadRootFor(
372
- message: BotMessage,
373
453
  eventId: string,
374
- ): Promise<string | undefined> {
375
- if (message.threadRootId) return message.threadRootId;
376
- const relatesTo = readRelatesTo(message.content);
377
- if (!relatesTo) return undefined;
378
- const root = await resolveReplyChainRoot({
454
+ roomId: string,
455
+ relatesTo: MatrixRelatesTo | undefined,
456
+ sdkThreadRootId?: string,
457
+ ): Promise<string> {
458
+ if (sdkThreadRootId) return sdkThreadRootId;
459
+ return resolveReplyChainRoot({
379
460
  eventId,
380
461
  relatesTo,
381
462
  cache: this.threadRoots,
382
- fetchRelatesTo: async (id) => {
383
- const json = await this.getEvent(message.roomId, id);
384
- if (!json) return null;
385
- const parsed: unknown = JSON.parse(json);
386
- const content =
387
- typeof parsed === 'object' && parsed !== null && 'content' in parsed
388
- ? parsed.content
389
- : undefined;
390
- return readRelatesTo(content) ?? {};
391
- },
463
+ fetchRelatesTo: async (id) => this.relatesToOf(roomId, id),
392
464
  });
393
- return root ?? undefined;
465
+ }
466
+
467
+ /** `m.relates_to` of a room event from the homeserver; null when the event is unavailable. */
468
+ private async relatesToOf(
469
+ roomId: string,
470
+ eventId: string,
471
+ ): Promise<MatrixRelatesTo | null> {
472
+ const json = await this.getEvent(roomId, eventId);
473
+ if (!json) return null;
474
+ return readRelatesTo(eventContentOf(json)) ?? {};
475
+ }
476
+
477
+ /**
478
+ * The thread of an inbox row on replay. A row whose turn was resolved
479
+ * before the reset carries it; one interrupted between the insert and the
480
+ * resolution has to read its own event back (relations travel in the
481
+ * clear, so an encrypted event answers too). Unreadable → the message is
482
+ * treated as bare, which is right for every message without a relation.
483
+ */
484
+ private async threadRootForRow(row: InboxRow): Promise<string> {
485
+ if (row.threadRootId) return row.threadRootId;
486
+ let relatesTo: MatrixRelatesTo | undefined;
487
+ try {
488
+ relatesTo =
489
+ (await this.relatesToOf(row.roomId, row.eventId)) ?? undefined;
490
+ } catch (err) {
491
+ this.log(
492
+ 'warn',
493
+ `inbox: could not read ${row.eventId} back to resolve its thread; treating it as a bare message`,
494
+ err,
495
+ );
496
+ relatesTo = undefined;
497
+ }
498
+ return this.threadRootFor(row.eventId, row.roomId, relatesTo);
394
499
  }
395
500
 
396
501
  /** `m.room.canonical_alias` of a room, memoised per instance (null = none). */
@@ -417,9 +522,46 @@ export class MatrixGatewayDO
417
522
 
418
523
  private async dispatchTurnNow(turn: IngestTurn): Promise<void> {
419
524
  const requestId = crypto.randomUUID();
525
+ // Group rooms: the Node gate — answer a mention, a reply to the bot, or
526
+ // a thread the bot is in; stay silent otherwise (the message is still
527
+ // captured into channel memory). Direct rooms always pass.
528
+ const decision = await this.groupChat()
529
+ .gate({
530
+ roomId: turn.roomId,
531
+ threadId: turn.threadId,
532
+ eventId: turn.sourceEventId,
533
+ sender: turn.matrixUserId,
534
+ senderDid: turn.userDid,
535
+ body: turn.message,
536
+ ts: turn.ts,
537
+ mentionsBot: turn.mentionsBot,
538
+ ...(turn.inReplyTo ? { inReplyTo: turn.inReplyTo } : {}),
539
+ })
540
+ .catch((err: unknown): GateDecision => {
541
+ this.log(
542
+ 'warn',
543
+ `turn ${requestId}: group-chat gate failed; answering`,
544
+ err,
545
+ );
546
+ return {
547
+ respond: true,
548
+ reason: 'dm',
549
+ roomKind: 'direct',
550
+ memberCount: 0,
551
+ displayName: turn.matrixUserId,
552
+ };
553
+ });
554
+ if (!decision.respond) {
555
+ this.log(
556
+ 'info',
557
+ `turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}) not answered (${decision.reason})`,
558
+ );
559
+ deleteInboxRows(this.inboxSql(), turn.eventIds);
560
+ return;
561
+ }
420
562
  this.log(
421
563
  'info',
422
- `turn ${requestId}: ${turn.userDid} in ${turn.roomId}${turn.threadId ? ` (thread ${turn.threadId})` : ''}`,
564
+ `turn ${requestId}: ${turn.userDid} in ${turn.roomId} (thread ${turn.threadId}${decision.roomKind === 'group' ? `, group room: ${decision.reason}` : ''})`,
423
565
  );
424
566
  for (const id of turn.eventIds) this.inFlightEvents.add(id);
425
567
  let typing: ReturnType<typeof setInterval> | null = null;
@@ -430,7 +572,7 @@ export class MatrixGatewayDO
430
572
  () => undefined,
431
573
  );
432
574
  }, TYPING_REFRESH_MS);
433
- const result = await this.runTurn(turn, requestId);
575
+ const result = await this.runTurn(turn, requestId, decision);
434
576
  if (result.replayed)
435
577
  this.log(
436
578
  'info',
@@ -440,8 +582,10 @@ export class MatrixGatewayDO
440
582
  // The transaction id is derived from the event id, so this reply is
441
583
  // deduplicated by the homeserver if a previous incarnation already
442
584
  // sent it before it died (see inbox-store.ts).
585
+ // Always inside the thread: the reply to a bare message opens the
586
+ // thread on it (Node's listener bridge does the same).
443
587
  await this.sendText(turn.roomId, result.text, {
444
- ...(turn.threadId ? { threadId: turn.threadId } : {}),
588
+ threadId: turn.threadId,
445
589
  ...(turn.eventIds[0] ? { txnId: replyTxnId(turn.eventIds[0]) } : {}),
446
590
  });
447
591
  } else {
@@ -466,7 +610,7 @@ export class MatrixGatewayDO
466
610
  else this.log('error', `turn ${requestId} failed`, err);
467
611
  try {
468
612
  await this.sendNotice(turn.roomId, TURN_FAILED_NOTICE, {
469
- ...(turn.threadId ? { threadId: turn.threadId } : {}),
613
+ threadId: turn.threadId,
470
614
  ...(turn.eventIds[0]
471
615
  ? { txnId: `${replyTxnId(turn.eventIds[0])}-notice` }
472
616
  : {}),
@@ -510,6 +654,7 @@ export class MatrixGatewayDO
510
654
  protected async runTurn(
511
655
  turn: IngestTurn,
512
656
  requestId: string,
657
+ decision: GateDecision,
513
658
  ): Promise<TurnResult> {
514
659
  return this.userStub(turn.userDid).runTurn({
515
660
  identity: { userDid: turn.userDid, matrixUserId: turn.matrixUserId },
@@ -517,7 +662,9 @@ export class MatrixGatewayDO
517
662
  message: turn.message,
518
663
  client: 'matrix',
519
664
  roomId: turn.roomId,
520
- ...(turn.threadId ? { threadId: turn.threadId } : {}),
665
+ threadId: turn.threadId,
666
+ roomKind: decision.roomKind,
667
+ senderDisplayName: decision.displayName,
521
668
  ...(turn.eventIds[0] ? { eventId: turn.eventIds[0] } : {}),
522
669
  ...(turn.attachments?.length ? { attachments: turn.attachments } : {}),
523
670
  requestId,
@@ -580,7 +727,7 @@ export class MatrixGatewayDO
580
727
  );
581
728
  try {
582
729
  await this.sendNotice(row.roomId, TURN_FAILED_NOTICE, {
583
- ...(row.threadRootId ? { threadId: row.threadRootId } : {}),
730
+ threadId: row.threadRootId ?? row.eventId,
584
731
  txnId: `${replyTxnId(row.eventId)}-notice`,
585
732
  });
586
733
  } catch (err) {
@@ -603,7 +750,13 @@ export class MatrixGatewayDO
603
750
  } catch (err) {
604
751
  this.log('warn', `inbox: alias lookup failed for ${row.roomId}`, err);
605
752
  }
606
- this.offerInbound(inboundOfRow(row));
753
+ const inbound = inboundOfRow(row);
754
+ const threadRootId = await this.threadRootForRow(row);
755
+ if (threadRootId !== inbound.threadRootId) {
756
+ inbound.threadRootId = threadRootId;
757
+ updateInboxThread(sql, row.eventId, threadRootId);
758
+ }
759
+ this.offerInbound(inbound);
607
760
  }
608
761
  this.log(
609
762
  'info',
@@ -1158,10 +1311,58 @@ export class MatrixGatewayDO
1158
1311
  }));
1159
1312
  }
1160
1313
 
1314
+ // -------------------------------------------------------------------------
1315
+ // Group rooms: room kind + channel memory for the user objects' tools
1316
+ // -------------------------------------------------------------------------
1317
+
1318
+ async groupChatRoomInfo(roomId: string): Promise<GroupRoomInfo> {
1319
+ const service = this.groupChat();
1320
+ return {
1321
+ ...(await service.roomInfo(roomId)),
1322
+ groupLane: service.options.groupRooms === 'gate',
1323
+ };
1324
+ }
1325
+
1326
+ async channelMemoryRecall(
1327
+ roomId: string,
1328
+ limit?: number,
1329
+ ): Promise<JsonString> {
1330
+ return JSON.stringify(this.groupChat().recall(roomId, limit));
1331
+ }
1332
+
1333
+ async channelMemorySearch(
1334
+ roomId: string,
1335
+ query: string,
1336
+ limit?: number,
1337
+ ): Promise<JsonString> {
1338
+ return JSON.stringify(this.groupChat().search(roomId, query, limit));
1339
+ }
1340
+
1341
+ async channelMemoryPin(
1342
+ roomId: string,
1343
+ fact: string,
1344
+ pinnedByDid: string,
1345
+ sourceEventId?: string,
1346
+ ): Promise<JsonString> {
1347
+ return JSON.stringify(
1348
+ this.groupChat().pinFact({
1349
+ roomId,
1350
+ fact,
1351
+ pinnedByDid,
1352
+ ...(sourceEventId ? { sourceEventId } : {}),
1353
+ }),
1354
+ );
1355
+ }
1356
+
1357
+ async channelMemoryUnpin(roomId: string, factId: string): Promise<boolean> {
1358
+ return this.groupChat().unpinFact(roomId, factId);
1359
+ }
1360
+
1161
1361
  override async status(): Promise<GatewayStatus> {
1162
1362
  return {
1163
1363
  ...(await super.status()),
1164
1364
  turns: { inFlight: this.turnGate.inUse, waiting: this.turnGate.queued },
1365
+ groupChat: this.groupChat().stats(),
1165
1366
  ingestPending: this.ingest?.pendingCount ?? 0,
1166
1367
  inbox: countInboxRows(this.inboxSql()),
1167
1368
  };
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Channel-memory compaction — the port of the Node runtime's
3
+ * `ChannelMemorySummarizer` (`plugins/matrix-group-chats`). A batch of
4
+ * observed group-room messages becomes one dense summary chunk; the prompts
5
+ * are the Node ones verbatim so both runtimes distil the same memory.
6
+ *
7
+ * Runs in a user object (that is where the platform model's keys live); the
8
+ * gateway, which owns the per-room buffer, asks the speaker's object for the
9
+ * summary (`summarizeGroupMessages`).
10
+ */
11
+ import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
12
+ import { HumanMessage, SystemMessage } from '@langchain/core/messages';
13
+
14
+ /** A group-room message as channel memory sees it (the Node `ObservedMessage`). */
15
+ export interface ObservedMessage {
16
+ eventId: string;
17
+ threadId: string;
18
+ senderDid: string;
19
+ senderMatrixUserId: string;
20
+ senderDisplayName: string;
21
+ body: string;
22
+ timestamp: number;
23
+ }
24
+
25
+ export const GROUP_SUMMARY_SYSTEM_PROMPT = `You compact a chunk of group-chat messages into a durable, dense summary used as long-term memory for an AI assistant.
26
+
27
+ KEEP:
28
+ - Decisions, agreements, commitments
29
+ - Concrete facts (names, dates, numbers, URLs)
30
+ - Topics discussed and any unresolved questions
31
+ - Member dynamics and stated preferences
32
+ - Action items and ownership
33
+
34
+ DROP:
35
+ - Pleasantries, fillers, redundancy
36
+ - Transient state (typing, reactions)
37
+ - Information that has been explicitly superseded
38
+
39
+ OUTPUT RULES:
40
+ - Output ONLY the summary text. No preamble, no headings, no markdown lists unless they aid clarity.
41
+ - Aim for 200-400 tokens.
42
+ - Refer to people by their display name.
43
+ - Be concrete. Quote short fragments verbatim when wording matters.
44
+ - If the messages are mostly chitchat, produce a one-sentence summary noting that.`;
45
+
46
+ /** Node caps a summary at this many characters. */
47
+ export const GROUP_SUMMARY_MAX_CHARS = 6000;
48
+
49
+ export function formatObservedForPrompt(messages: ObservedMessage[]): string {
50
+ return messages
51
+ .map((m) => {
52
+ const ts = new Date(m.timestamp).toISOString();
53
+ const thread = m.threadId ? ` thread=${m.threadId.slice(0, 10)}` : '';
54
+ return `[${m.senderDisplayName} @ ${ts}${thread}]: ${m.body}`;
55
+ })
56
+ .join('\n');
57
+ }
58
+
59
+ export function extractText(content: unknown): string {
60
+ if (typeof content === 'string') return content;
61
+ if (Array.isArray(content)) {
62
+ return content
63
+ .map((part) => {
64
+ if (typeof part === 'string') return part;
65
+ if (part && typeof part === 'object' && 'text' in part) {
66
+ const t = (part as { text?: unknown }).text;
67
+ return typeof t === 'string' ? t : '';
68
+ }
69
+ return '';
70
+ })
71
+ .join('');
72
+ }
73
+ return '';
74
+ }
75
+
76
+ /**
77
+ * Compact a batch of observed messages into a summary. `null` when the model
78
+ * call fails or answers nothing — the caller keeps the batch buffered.
79
+ */
80
+ export async function summarizeObservedMessages(
81
+ model: BaseChatModel,
82
+ messages: ObservedMessage[],
83
+ log: Pick<Console, 'warn'> = console,
84
+ ): Promise<string | null> {
85
+ if (messages.length === 0) return null;
86
+ try {
87
+ const response = await model.invoke([
88
+ new SystemMessage(GROUP_SUMMARY_SYSTEM_PROMPT),
89
+ new HumanMessage(formatObservedForPrompt(messages)),
90
+ ]);
91
+ const text = extractText(response.content).trim();
92
+ if (!text) {
93
+ log.warn(
94
+ `[group-chat] empty summary returned for ${messages.length} messages`,
95
+ );
96
+ return null;
97
+ }
98
+ return text.length > GROUP_SUMMARY_MAX_CHARS
99
+ ? text.slice(0, GROUP_SUMMARY_MAX_CHARS)
100
+ : text;
101
+ } catch (err) {
102
+ log.warn(
103
+ `[group-chat] compaction failed: ${err instanceof Error ? err.message : String(err)}`,
104
+ );
105
+ return null;
106
+ }
107
+ }