@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.
- package/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +38 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +249 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +1019 -169
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/owner-store/ixo-vfs-store.ts +12 -5
- package/src/owner-store/matrix-media-store.ts +12 -7
- package/src/owner-store/measure.ts +39 -0
- package/src/owner-store/migrating-store.ts +9 -3
- package/src/owner-store/types.ts +14 -4
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +78 -0
- package/src/sqlite/cache-config.ts +27 -6
- package/src/sqlite/chunk-layout.ts +14 -0
- package/src/sqlite/database.ts +188 -19
- package/src/sqlite/do-vfs.ts +963 -99
- package/src/sqlite/index.ts +13 -0
- package/src/sqlite/page-tier.ts +668 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
package/src/matrix/gateway-do.ts
CHANGED
|
@@ -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(
|
|
347
|
-
|
|
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
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|