@ixo/oracle-runtime-workers 0.10.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 (48) 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 +227 -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 +875 -146
  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/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -0,0 +1,1163 @@
1
+ /**
2
+ * Matrix group rooms — the port of the Node runtime's `matrix-group-chats`
3
+ * plugin: the gate that decides whether the oracle answers a message in a
4
+ * room with more than two members, and the channel memory every such room
5
+ * gets (compacted summaries, pinned facts, the member roster).
6
+ *
7
+ * Node ran the gate as an agent middleware inside each speaker's turn; the
8
+ * room-level state (active threads, the per-room memory database) lived in
9
+ * the process. On Workers every speaker has their own user object, so the
10
+ * room-level state belongs to the one object every message passes through:
11
+ * the gateway. It runs the gate BEFORE a turn is dispatched (an ignored
12
+ * message never wakes a user object) and keeps the memory in its own SQLite
13
+ * (`group_*` tables; FTS5 for search, the LIKE fallback Node has when FTS5 is
14
+ * missing). The decision, the room kind and the speaker's display name ride
15
+ * on the turn request so the user object applies Node's `[DisplayName]: `
16
+ * prefix and offers the channel-memory tools (`plugins/matrix-group-chats`).
17
+ *
18
+ * The rules, in Node's order (`guard.ts`):
19
+ * 1. a direct room (the `m.room.create` `is_direct` flag, or ≤ 2 joined
20
+ * members — and, here, a room whose canonical alias is a user↔oracle
21
+ * alias of this oracle, whatever its member count: that room IS the
22
+ * user's conversation with the oracle, and the service bots that
23
+ * homeservers seat in it must not turn it into a group)
24
+ * → always answer, no gate, no prefix, no tools;
25
+ * 2. the bot is in `m.mentions.user_ids` → answer;
26
+ * 3. the message quote-replies a message the bot sent → answer;
27
+ * 4. the thread is one the bot answered in within the TTL → answer
28
+ * (an in-memory map, re-warmed from the durable `group_bot_threads`
29
+ * table after a restart — Node re-warmed from a room history scan);
30
+ * 5. otherwise stay silent — the message is still captured into channel
31
+ * memory so summaries stay accurate.
32
+ * An answer is skipped when the bot's power level is below the room's
33
+ * `m.room.message` threshold (plus `GROUP_CHAT_REQUIRE_POWER_LEVEL`).
34
+ *
35
+ * What happens in a group room is a deployment choice, `MATRIX_GROUP_ROOMS`
36
+ * on the gateway: `silent` (the default) answers nothing there and captures
37
+ * nothing — the bot only ever speaks in direct rooms (the user↔oracle room,
38
+ * the task rooms it creates); `gate` runs the Node lane described here;
39
+ * `answer` replies to everything as in a direct room (Node without the
40
+ * plugin). Direct rooms behave the same under every policy.
41
+ *
42
+ * Compaction: every observed message goes to a per-room buffer; at 20 the
43
+ * buffer is summarized (`group-chat-summarizer.ts`, run in the speaker's user
44
+ * object) into a chunk, and just before the bot answers a buffer of ≥ 5 is
45
+ * compacted with a 3 s cap so the reply is not held up. Node also compacted
46
+ * after 5 idle minutes; here the buffer is durable and the next engagement
47
+ * compacts it, so nothing is lost while a room is quiet.
48
+ */
49
+ import type { JsonString } from '../do/contracts';
50
+ import type { ObservedMessage } from './group-chat-summarizer';
51
+
52
+ export interface RoomTypeInfo {
53
+ isDirect: boolean;
54
+ memberCount: number;
55
+ joinedMemberIds: string[];
56
+ }
57
+
58
+ export interface ChannelMemoryChunk {
59
+ id: string;
60
+ roomId: string;
61
+ summary: string;
62
+ fromEventId: string;
63
+ toEventId: string;
64
+ fromTimestamp: number;
65
+ toTimestamp: number;
66
+ messageCount: number;
67
+ participants: string[];
68
+ threadIds: string[];
69
+ tier: number;
70
+ createdAt: number;
71
+ }
72
+
73
+ export interface PinnedFact {
74
+ id: string;
75
+ roomId: string;
76
+ fact: string;
77
+ pinnedByDid: string;
78
+ sourceEventId?: string;
79
+ createdAt: number;
80
+ }
81
+
82
+ export interface ChannelMember {
83
+ matrixUserId: string;
84
+ displayName: string;
85
+ did?: string;
86
+ }
87
+
88
+ export const DEFAULT_ACTIVE_THREAD_TTL_MS = 30 * 60 * 1000;
89
+ export const DEFAULT_ROOM_INFO_TTL_MS = 30 * 60 * 1000;
90
+ export const DEFAULT_REQUIRE_POWER_LEVEL = 0;
91
+ export const COMPACT_BUFFER_THRESHOLD = 20;
92
+ export const COMPACT_JIT_MIN = 5;
93
+ export const COMPACT_JIT_TIMEOUT_MS = 3000;
94
+ export const RECALL_DEFAULT_CHUNKS = 10;
95
+ export const RECALL_MAX_CHUNKS = 30;
96
+ export const PINNED_FACT_MAX_CHARS = 500;
97
+
98
+ /** What the bot does in a room with more than two members (`MATRIX_GROUP_ROOMS`). */
99
+ export type GroupRoomPolicy = 'silent' | 'gate' | 'answer';
100
+ export const DEFAULT_GROUP_ROOM_POLICY: GroupRoomPolicy = 'silent';
101
+
102
+ export interface GroupChatOptions {
103
+ groupRooms: GroupRoomPolicy;
104
+ activeThreadTtlMs: number;
105
+ requirePowerLevel: number;
106
+ roomInfoTtlMs: number;
107
+ }
108
+
109
+ export interface GroupChatEnv {
110
+ MATRIX_GROUP_ROOMS?: string;
111
+ GROUP_CHAT_ACTIVE_THREAD_TTL_MS?: string;
112
+ GROUP_CHAT_REQUIRE_POWER_LEVEL?: string;
113
+ GROUP_CHAT_ROOM_INFO_TTL_MS?: string;
114
+ }
115
+
116
+ function intFromEnv(raw: string | undefined, fallback: number, min: number) {
117
+ const n = Number(raw);
118
+ return raw !== undefined && Number.isInteger(n) && n >= min ? n : fallback;
119
+ }
120
+
121
+ /** The Node plugin's config knobs, read from the gateway's env. */
122
+ export function groupChatOptionsFromEnv(env: GroupChatEnv): GroupChatOptions {
123
+ return {
124
+ groupRooms:
125
+ env.MATRIX_GROUP_ROOMS === 'gate' || env.MATRIX_GROUP_ROOMS === 'answer'
126
+ ? env.MATRIX_GROUP_ROOMS
127
+ : DEFAULT_GROUP_ROOM_POLICY,
128
+ activeThreadTtlMs: intFromEnv(
129
+ env.GROUP_CHAT_ACTIVE_THREAD_TTL_MS,
130
+ DEFAULT_ACTIVE_THREAD_TTL_MS,
131
+ 60_000,
132
+ ),
133
+ requirePowerLevel: intFromEnv(
134
+ env.GROUP_CHAT_REQUIRE_POWER_LEVEL,
135
+ DEFAULT_REQUIRE_POWER_LEVEL,
136
+ 0,
137
+ ),
138
+ roomInfoTtlMs: intFromEnv(
139
+ env.GROUP_CHAT_ROOM_INFO_TTL_MS,
140
+ DEFAULT_ROOM_INFO_TTL_MS,
141
+ 60_000,
142
+ ),
143
+ };
144
+ }
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Pure helpers (the Node guard's, unchanged in semantics)
148
+ // ---------------------------------------------------------------------------
149
+
150
+ /** True when the event's `m.mentions.user_ids` names the bot. Older clients omit `m.mentions`: false. */
151
+ export function isBotMentioned(
152
+ content: Record<string, unknown> | undefined,
153
+ botUserId: string,
154
+ ): boolean {
155
+ if (!content) return false;
156
+ const mentions = content['m.mentions'] as { user_ids?: unknown } | undefined;
157
+ const userIds = mentions?.user_ids;
158
+ if (!Array.isArray(userIds)) return false;
159
+ return userIds.some((id) => typeof id === 'string' && id === botUserId);
160
+ }
161
+
162
+ export interface BotPowerLevel {
163
+ /** The bot's effective power level in the room. */
164
+ pl: number;
165
+ /** Power level required to send `m.room.message` events. */
166
+ sendThreshold: number;
167
+ /** True when the bot has at least `max(sendThreshold, requiredMin)`. */
168
+ allowed: (requiredMin?: number) => boolean;
169
+ }
170
+
171
+ /**
172
+ * The bot's send permission from an `m.room.power_levels` content (a
173
+ * missing event is "everyone allowed", the Matrix default).
174
+ */
175
+ export function botPowerLevelOf(
176
+ content: unknown,
177
+ botUserId: string,
178
+ ): BotPowerLevel {
179
+ const pls =
180
+ typeof content === 'object' && content !== null
181
+ ? (content as {
182
+ users?: Record<string, number>;
183
+ users_default?: number;
184
+ events?: Record<string, number>;
185
+ events_default?: number;
186
+ })
187
+ : {};
188
+ const usersDefault = pls.users_default ?? 0;
189
+ const eventsDefault = pls.events_default ?? 0;
190
+ const pl = pls.users?.[botUserId] ?? usersDefault;
191
+ const sendThreshold = pls.events?.['m.room.message'] ?? eventsDefault;
192
+ return {
193
+ pl,
194
+ sendThreshold,
195
+ allowed: (requiredMin = 0) => pl >= Math.max(sendThreshold, requiredMin),
196
+ };
197
+ }
198
+
199
+ const activeKey = (roomId: string, threadId: string) => `${roomId}:${threadId}`;
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // Store — the Node `ChannelMemoryRepo` schema on the gateway's SQLite
203
+ // ---------------------------------------------------------------------------
204
+
205
+ type ChunkRow = {
206
+ id: string;
207
+ room_id: string;
208
+ summary: string;
209
+ from_event_id: string;
210
+ to_event_id: string;
211
+ from_ts: number;
212
+ to_ts: number;
213
+ message_count: number;
214
+ participants_json: string;
215
+ thread_ids_json: string;
216
+ tier: number;
217
+ created_at: number;
218
+ } & Record<string, SqlStorageValue>;
219
+
220
+ type FactRow = {
221
+ id: string;
222
+ room_id: string;
223
+ fact: string;
224
+ pinned_by_did: string;
225
+ source_event_id: string | null;
226
+ created_at: number;
227
+ } & Record<string, SqlStorageValue>;
228
+
229
+ type BufferRow = {
230
+ event_id: string;
231
+ room_id: string;
232
+ thread_id: string;
233
+ sender_did: string;
234
+ sender_user_id: string;
235
+ display_name: string;
236
+ body: string;
237
+ ts: number;
238
+ } & Record<string, SqlStorageValue>;
239
+
240
+ function jsonArray(raw: unknown): string[] {
241
+ if (typeof raw !== 'string' || !raw.length) return [];
242
+ try {
243
+ const parsed: unknown = JSON.parse(raw);
244
+ return Array.isArray(parsed)
245
+ ? parsed.filter((s): s is string => typeof s === 'string')
246
+ : [];
247
+ } catch {
248
+ return [];
249
+ }
250
+ }
251
+
252
+ const rowToChunk = (row: ChunkRow): ChannelMemoryChunk => ({
253
+ id: row.id,
254
+ roomId: row.room_id,
255
+ summary: row.summary,
256
+ fromEventId: row.from_event_id,
257
+ toEventId: row.to_event_id,
258
+ fromTimestamp: Number(row.from_ts),
259
+ toTimestamp: Number(row.to_ts),
260
+ messageCount: Number(row.message_count),
261
+ participants: jsonArray(row.participants_json),
262
+ threadIds: jsonArray(row.thread_ids_json),
263
+ tier: Number(row.tier ?? 1),
264
+ createdAt: Number(row.created_at),
265
+ });
266
+
267
+ const rowToFact = (row: FactRow): PinnedFact => ({
268
+ id: row.id,
269
+ roomId: row.room_id,
270
+ fact: row.fact,
271
+ pinnedByDid: row.pinned_by_did,
272
+ ...(row.source_event_id ? { sourceEventId: row.source_event_id } : {}),
273
+ createdAt: Number(row.created_at),
274
+ });
275
+
276
+ const rowToObserved = (row: BufferRow): ObservedMessage => ({
277
+ eventId: row.event_id,
278
+ threadId: row.thread_id,
279
+ senderDid: row.sender_did,
280
+ senderMatrixUserId: row.sender_user_id,
281
+ senderDisplayName: row.display_name,
282
+ body: row.body,
283
+ timestamp: Number(row.ts),
284
+ });
285
+
286
+ export class GroupChatStore {
287
+ /** Whether the FTS5 index exists (search falls back to LIKE otherwise, as on Node). */
288
+ readonly fts: boolean;
289
+
290
+ constructor(
291
+ private readonly sql: SqlStorage,
292
+ log: (level: 'warn', msg: string) => void = () => undefined,
293
+ ) {
294
+ sql.exec(`CREATE TABLE IF NOT EXISTS group_memory_chunks (
295
+ id TEXT PRIMARY KEY,
296
+ room_id TEXT NOT NULL,
297
+ summary TEXT NOT NULL,
298
+ from_event_id TEXT NOT NULL,
299
+ to_event_id TEXT NOT NULL,
300
+ from_ts INTEGER NOT NULL,
301
+ to_ts INTEGER NOT NULL,
302
+ message_count INTEGER NOT NULL,
303
+ participants_json TEXT NOT NULL,
304
+ thread_ids_json TEXT NOT NULL,
305
+ tier INTEGER NOT NULL DEFAULT 1,
306
+ created_at INTEGER NOT NULL
307
+ )`);
308
+ sql.exec(
309
+ `CREATE INDEX IF NOT EXISTS idx_group_chunks_room_ts ON group_memory_chunks(room_id, to_ts DESC)`,
310
+ );
311
+ sql.exec(`CREATE TABLE IF NOT EXISTS group_pinned_facts (
312
+ id TEXT PRIMARY KEY,
313
+ room_id TEXT NOT NULL,
314
+ fact TEXT NOT NULL,
315
+ pinned_by_did TEXT NOT NULL,
316
+ source_event_id TEXT,
317
+ created_at INTEGER NOT NULL
318
+ )`);
319
+ sql.exec(
320
+ `CREATE INDEX IF NOT EXISTS idx_group_facts_room ON group_pinned_facts(room_id, created_at DESC)`,
321
+ );
322
+ sql.exec(`CREATE TABLE IF NOT EXISTS group_room_meta (
323
+ room_id TEXT PRIMARY KEY,
324
+ members_json TEXT NOT NULL DEFAULT '[]',
325
+ updated_at INTEGER NOT NULL
326
+ )`);
327
+ sql.exec(`CREATE TABLE IF NOT EXISTS group_message_buffer (
328
+ event_id TEXT PRIMARY KEY,
329
+ room_id TEXT NOT NULL,
330
+ thread_id TEXT NOT NULL,
331
+ sender_did TEXT NOT NULL,
332
+ sender_user_id TEXT NOT NULL,
333
+ display_name TEXT NOT NULL,
334
+ body TEXT NOT NULL,
335
+ ts INTEGER NOT NULL
336
+ )`);
337
+ sql.exec(
338
+ `CREATE INDEX IF NOT EXISTS idx_group_buffer_room ON group_message_buffer(room_id, ts)`,
339
+ );
340
+ sql.exec(`CREATE TABLE IF NOT EXISTS group_bot_threads (
341
+ room_id TEXT NOT NULL,
342
+ thread_id TEXT NOT NULL,
343
+ expires_at INTEGER NOT NULL,
344
+ PRIMARY KEY (room_id, thread_id)
345
+ )`);
346
+ let fts = false;
347
+ try {
348
+ sql.exec(`CREATE VIRTUAL TABLE IF NOT EXISTS group_memory_chunks_fts USING fts5(
349
+ summary,
350
+ room_id UNINDEXED,
351
+ content='group_memory_chunks',
352
+ content_rowid='rowid',
353
+ tokenize='porter unicode61'
354
+ )`);
355
+ sql.exec(`CREATE TRIGGER IF NOT EXISTS group_chunks_ai AFTER INSERT ON group_memory_chunks
356
+ BEGIN
357
+ INSERT INTO group_memory_chunks_fts(rowid, summary, room_id)
358
+ VALUES (new.rowid, new.summary, new.room_id);
359
+ END`);
360
+ sql.exec(`CREATE TRIGGER IF NOT EXISTS group_chunks_ad AFTER DELETE ON group_memory_chunks
361
+ BEGIN
362
+ INSERT INTO group_memory_chunks_fts(group_memory_chunks_fts, rowid, summary, room_id)
363
+ VALUES ('delete', old.rowid, old.summary, old.room_id);
364
+ END`);
365
+ fts = true;
366
+ } catch (err) {
367
+ log(
368
+ 'warn',
369
+ `[group-chat] FTS5 unavailable; channel-memory search uses LIKE: ${err instanceof Error ? err.message : String(err)}`,
370
+ );
371
+ }
372
+ this.fts = fts;
373
+ }
374
+
375
+ insertChunk(chunk: ChannelMemoryChunk): void {
376
+ this.sql.exec(
377
+ `INSERT INTO group_memory_chunks (
378
+ id, room_id, summary, from_event_id, to_event_id, from_ts, to_ts,
379
+ message_count, participants_json, thread_ids_json, tier, created_at
380
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
381
+ chunk.id,
382
+ chunk.roomId,
383
+ chunk.summary,
384
+ chunk.fromEventId,
385
+ chunk.toEventId,
386
+ chunk.fromTimestamp,
387
+ chunk.toTimestamp,
388
+ chunk.messageCount,
389
+ JSON.stringify(chunk.participants),
390
+ JSON.stringify(chunk.threadIds),
391
+ chunk.tier,
392
+ chunk.createdAt,
393
+ );
394
+ }
395
+
396
+ recentChunks(roomId: string, limit: number, tier = 1): ChannelMemoryChunk[] {
397
+ return this.sql
398
+ .exec<ChunkRow>(
399
+ `SELECT * FROM group_memory_chunks WHERE room_id = ? AND tier = ?
400
+ ORDER BY to_ts DESC LIMIT ?`,
401
+ roomId,
402
+ tier,
403
+ limit,
404
+ )
405
+ .toArray()
406
+ .map(rowToChunk);
407
+ }
408
+
409
+ oldestChunks(roomId: string, limit: number): ChannelMemoryChunk[] {
410
+ return this.sql
411
+ .exec<ChunkRow>(
412
+ `SELECT * FROM group_memory_chunks WHERE room_id = ? ORDER BY to_ts ASC LIMIT ?`,
413
+ roomId,
414
+ limit,
415
+ )
416
+ .toArray()
417
+ .map(rowToChunk);
418
+ }
419
+
420
+ countChunks(roomId?: string): number {
421
+ const rows = roomId
422
+ ? this.sql
423
+ .exec<{
424
+ n: number;
425
+ }>(
426
+ `SELECT COUNT(*) AS n FROM group_memory_chunks WHERE room_id = ?`,
427
+ roomId,
428
+ )
429
+ .toArray()
430
+ : this.sql
431
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM group_memory_chunks`)
432
+ .toArray();
433
+ return Number(rows[0]?.n ?? 0);
434
+ }
435
+
436
+ /**
437
+ * Keyword search over the chunks: FTS5 (`OR` of the words, ranked) when
438
+ * the index exists and the query parses, else the Node LIKE fallback
439
+ * (every word, newest first).
440
+ */
441
+ searchChunks(
442
+ roomId: string,
443
+ query: string,
444
+ limit: number,
445
+ ): ChannelMemoryChunk[] {
446
+ const trimmed = query.trim();
447
+ if (!trimmed) return this.recentChunks(roomId, limit);
448
+ const words = trimmed.split(/\s+/).filter(Boolean);
449
+ if (this.fts) {
450
+ const ftsQuery = words.length > 1 ? words.join(' OR ') : trimmed;
451
+ try {
452
+ return this.sql
453
+ .exec<ChunkRow>(
454
+ `SELECT c.* FROM group_memory_chunks c
455
+ JOIN group_memory_chunks_fts f ON f.rowid = c.rowid
456
+ WHERE c.room_id = ? AND group_memory_chunks_fts MATCH ?
457
+ ORDER BY rank LIMIT ?`,
458
+ roomId,
459
+ ftsQuery,
460
+ limit,
461
+ )
462
+ .toArray()
463
+ .map(rowToChunk);
464
+ } catch {
465
+ // A query the FTS parser rejects (punctuation, operators): LIKE.
466
+ }
467
+ }
468
+ const conditions = words
469
+ .map(() => `summary LIKE ? ESCAPE '\\'`)
470
+ .join(' AND ');
471
+ const values = words.map(
472
+ (w) => `%${w.replace(/[%_\\]/g, (c) => `\\${c}`)}%`,
473
+ );
474
+ return this.sql
475
+ .exec<ChunkRow>(
476
+ `SELECT * FROM group_memory_chunks WHERE room_id = ? AND ${conditions}
477
+ ORDER BY to_ts DESC LIMIT ?`,
478
+ roomId,
479
+ ...values,
480
+ limit,
481
+ )
482
+ .toArray()
483
+ .map(rowToChunk);
484
+ }
485
+
486
+ insertPinnedFact(fact: PinnedFact): void {
487
+ this.sql.exec(
488
+ `INSERT INTO group_pinned_facts (id, room_id, fact, pinned_by_did, source_event_id, created_at)
489
+ VALUES (?, ?, ?, ?, ?, ?)`,
490
+ fact.id,
491
+ fact.roomId,
492
+ fact.fact,
493
+ fact.pinnedByDid,
494
+ fact.sourceEventId ?? null,
495
+ fact.createdAt,
496
+ );
497
+ }
498
+
499
+ listPinnedFacts(roomId: string, limit = 100): PinnedFact[] {
500
+ return this.sql
501
+ .exec<FactRow>(
502
+ `SELECT * FROM group_pinned_facts WHERE room_id = ? ORDER BY created_at DESC LIMIT ?`,
503
+ roomId,
504
+ limit,
505
+ )
506
+ .toArray()
507
+ .map(rowToFact);
508
+ }
509
+
510
+ deletePinnedFact(roomId: string, factId: string): boolean {
511
+ const before = this.countFacts(roomId);
512
+ this.sql.exec(
513
+ `DELETE FROM group_pinned_facts WHERE room_id = ? AND id = ?`,
514
+ roomId,
515
+ factId,
516
+ );
517
+ return this.countFacts(roomId) < before;
518
+ }
519
+
520
+ countFacts(roomId?: string): number {
521
+ const rows = roomId
522
+ ? this.sql
523
+ .exec<{
524
+ n: number;
525
+ }>(
526
+ `SELECT COUNT(*) AS n FROM group_pinned_facts WHERE room_id = ?`,
527
+ roomId,
528
+ )
529
+ .toArray()
530
+ : this.sql
531
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM group_pinned_facts`)
532
+ .toArray();
533
+ return Number(rows[0]?.n ?? 0);
534
+ }
535
+
536
+ upsertMembers(roomId: string, members: ChannelMember[], now: number): void {
537
+ this.sql.exec(
538
+ `INSERT INTO group_room_meta (room_id, members_json, updated_at) VALUES (?, ?, ?)
539
+ ON CONFLICT(room_id) DO UPDATE SET members_json = excluded.members_json, updated_at = excluded.updated_at`,
540
+ roomId,
541
+ JSON.stringify(members),
542
+ now,
543
+ );
544
+ }
545
+
546
+ getMembers(roomId: string): ChannelMember[] {
547
+ const rows = this.sql
548
+ .exec<{
549
+ members_json: string;
550
+ }>(`SELECT members_json FROM group_room_meta WHERE room_id = ?`, roomId)
551
+ .toArray();
552
+ const raw = rows[0]?.members_json;
553
+ if (!raw) return [];
554
+ try {
555
+ const parsed: unknown = JSON.parse(raw);
556
+ if (!Array.isArray(parsed)) return [];
557
+ return parsed.filter(
558
+ (m): m is ChannelMember =>
559
+ typeof m === 'object' &&
560
+ m !== null &&
561
+ typeof (m as { matrixUserId?: unknown }).matrixUserId === 'string' &&
562
+ typeof (m as { displayName?: unknown }).displayName === 'string',
563
+ );
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
568
+
569
+ /** Buffer a message for compaction; false when the event was already buffered (a replay). */
570
+ bufferAppend(roomId: string, msg: ObservedMessage): boolean {
571
+ const before = this.bufferCount(roomId);
572
+ this.sql.exec(
573
+ `INSERT OR IGNORE INTO group_message_buffer
574
+ (event_id, room_id, thread_id, sender_did, sender_user_id, display_name, body, ts)
575
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
576
+ msg.eventId,
577
+ roomId,
578
+ msg.threadId,
579
+ msg.senderDid,
580
+ msg.senderMatrixUserId,
581
+ msg.senderDisplayName,
582
+ msg.body,
583
+ msg.timestamp,
584
+ );
585
+ return this.bufferCount(roomId) > before;
586
+ }
587
+
588
+ bufferCount(roomId?: string): number {
589
+ const rows = roomId
590
+ ? this.sql
591
+ .exec<{
592
+ n: number;
593
+ }>(
594
+ `SELECT COUNT(*) AS n FROM group_message_buffer WHERE room_id = ?`,
595
+ roomId,
596
+ )
597
+ .toArray()
598
+ : this.sql
599
+ .exec<{ n: number }>(`SELECT COUNT(*) AS n FROM group_message_buffer`)
600
+ .toArray();
601
+ return Number(rows[0]?.n ?? 0);
602
+ }
603
+
604
+ /** The room's buffered messages, oldest first. */
605
+ bufferRows(roomId: string): ObservedMessage[] {
606
+ return this.sql
607
+ .exec<BufferRow>(
608
+ `SELECT * FROM group_message_buffer WHERE room_id = ? ORDER BY ts ASC, rowid ASC`,
609
+ roomId,
610
+ )
611
+ .toArray()
612
+ .map(rowToObserved);
613
+ }
614
+
615
+ bufferDelete(eventIds: string[]): void {
616
+ for (const id of eventIds)
617
+ this.sql.exec(`DELETE FROM group_message_buffer WHERE event_id = ?`, id);
618
+ }
619
+
620
+ markBotThread(roomId: string, threadId: string, expiresAt: number): void {
621
+ this.sql.exec(
622
+ `INSERT INTO group_bot_threads (room_id, thread_id, expires_at) VALUES (?, ?, ?)
623
+ ON CONFLICT(room_id, thread_id) DO UPDATE SET expires_at = excluded.expires_at`,
624
+ roomId,
625
+ threadId,
626
+ expiresAt,
627
+ );
628
+ }
629
+
630
+ /** Whether the bot answered in the thread within the TTL (the durable half of the active-thread map). */
631
+ botThreadExpiry(roomId: string, threadId: string): number | undefined {
632
+ const rows = this.sql
633
+ .exec<{
634
+ expires_at: number;
635
+ }>(
636
+ `SELECT expires_at FROM group_bot_threads WHERE room_id = ? AND thread_id = ?`,
637
+ roomId,
638
+ threadId,
639
+ )
640
+ .toArray();
641
+ return rows[0] ? Number(rows[0].expires_at) : undefined;
642
+ }
643
+
644
+ sweepBotThreads(now: number): void {
645
+ this.sql.exec(`DELETE FROM group_bot_threads WHERE expires_at <= ?`, now);
646
+ }
647
+
648
+ stats(): { buffered: number; chunks: number; facts: number } {
649
+ return {
650
+ buffered: this.bufferCount(),
651
+ chunks: this.countChunks(),
652
+ facts: this.countFacts(),
653
+ };
654
+ }
655
+ }
656
+
657
+ // ---------------------------------------------------------------------------
658
+ // Service — room info, display names, the gate, compaction, the read APIs
659
+ // ---------------------------------------------------------------------------
660
+
661
+ export interface GroupChatDeps {
662
+ botUserId: string;
663
+ /** Whether the room's canonical alias names it as a user↔oracle room of this oracle. */
664
+ isOracleRoom(roomId: string): Promise<boolean>;
665
+ getRoomStateEvent(
666
+ roomId: string,
667
+ type: string,
668
+ stateKey?: string,
669
+ ): Promise<JsonString | null>;
670
+ getJoinedRoomMembers(roomId: string): Promise<string[]>;
671
+ getEvent(roomId: string, eventId: string): Promise<JsonString | null>;
672
+ getUserProfile(userId: string): Promise<{ displayname?: string } | null>;
673
+ /** Compact a batch into a summary (`null` keeps the batch buffered). */
674
+ summarize(messages: ObservedMessage[]): Promise<string | null>;
675
+ /** Keeps background work alive past the request that started it (`ctx.waitUntil`). */
676
+ keepAlive(work: Promise<unknown>): void;
677
+ log(
678
+ level: 'debug' | 'info' | 'warn' | 'error',
679
+ msg: string,
680
+ extra?: unknown,
681
+ ): void;
682
+ now?(): number;
683
+ /** The JIT compaction cap (tests shorten it). */
684
+ jitTimeoutMs?: number;
685
+ }
686
+
687
+ export type GateReason =
688
+ | 'dm'
689
+ | 'mentioned'
690
+ | 'reply-to-bot'
691
+ | 'active-thread'
692
+ | 'ignored'
693
+ | 'power-level'
694
+ /** `MATRIX_GROUP_ROOMS=silent`: a group room, nothing is answered or captured. */
695
+ | 'group-rooms-off'
696
+ /** `MATRIX_GROUP_ROOMS=answer`: every room is treated as direct. */
697
+ | 'answer-all';
698
+
699
+ export interface GateInput {
700
+ roomId: string;
701
+ threadId: string;
702
+ /** The message the decision is about (the latest text message of a burst). */
703
+ eventId: string;
704
+ sender: string;
705
+ senderDid: string;
706
+ body: string;
707
+ ts: number;
708
+ mentionsBot: boolean;
709
+ inReplyTo?: string;
710
+ }
711
+
712
+ export interface GateDecision {
713
+ respond: boolean;
714
+ reason: GateReason;
715
+ roomKind: 'direct' | 'group';
716
+ memberCount: number;
717
+ /** The speaker's display name in the room (their user id when unknown). */
718
+ displayName: string;
719
+ }
720
+
721
+ export interface ChannelMemoryRecall {
722
+ chunks: ChannelMemoryChunk[];
723
+ pinnedFacts: PinnedFact[];
724
+ members: ChannelMember[];
725
+ }
726
+
727
+ function parseJson(json: JsonString | null): unknown {
728
+ if (!json) return undefined;
729
+ try {
730
+ return JSON.parse(json) as unknown;
731
+ } catch {
732
+ return undefined;
733
+ }
734
+ }
735
+
736
+ function contentOf(
737
+ json: JsonString | null,
738
+ ): Record<string, unknown> | undefined {
739
+ const parsed = parseJson(json);
740
+ if (typeof parsed !== 'object' || parsed === null) return undefined;
741
+ // `getRoomStateEvent` answers with the content itself; `getEvent` with the
742
+ // whole event. Accept both shapes.
743
+ const maybe = parsed as { content?: unknown; type?: unknown };
744
+ if (typeof maybe.type === 'string' && typeof maybe.content === 'object')
745
+ return maybe.content as Record<string, unknown>;
746
+ return parsed as Record<string, unknown>;
747
+ }
748
+
749
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
750
+
751
+ export class GroupChatService {
752
+ readonly store: GroupChatStore;
753
+ private readonly rooms = new Map<
754
+ string,
755
+ { info: RoomTypeInfo; expiresAt: number }
756
+ >();
757
+ private readonly names = new Map<
758
+ string,
759
+ { name: string; expiresAt: number }
760
+ >();
761
+ private readonly activeThreads = new Map<string, number>();
762
+ private readonly compacting = new Map<string, Promise<void>>();
763
+ private readonly now: () => number;
764
+
765
+ constructor(
766
+ sql: SqlStorage,
767
+ private readonly deps: GroupChatDeps,
768
+ readonly options: GroupChatOptions,
769
+ ) {
770
+ this.now = deps.now ?? (() => Date.now());
771
+ this.store = new GroupChatStore(sql, (level, msg) => deps.log(level, msg));
772
+ }
773
+
774
+ // ── room info + display names ───────────────────────────────────────────
775
+
776
+ /**
777
+ * Node's `getRoomInfo`: the create event's `is_direct`, else ≤ 2 joined
778
+ * members; a user↔oracle room (by alias) is direct regardless. Cached.
779
+ */
780
+ async roomInfo(roomId: string): Promise<RoomTypeInfo> {
781
+ const cached = this.rooms.get(roomId);
782
+ if (cached && cached.expiresAt > this.now()) return cached.info;
783
+ let isDirect = await this.deps.isOracleRoom(roomId).catch(() => false);
784
+ const create = contentOf(
785
+ await this.deps
786
+ .getRoomStateEvent(roomId, 'm.room.create')
787
+ .catch(() => null),
788
+ );
789
+ if (create?.is_direct === true) isDirect = true;
790
+ const joinedMemberIds = await this.deps.getJoinedRoomMembers(roomId);
791
+ const memberCount = joinedMemberIds.length;
792
+ if (!isDirect && memberCount > 0 && memberCount <= 2) isDirect = true;
793
+ const info: RoomTypeInfo = { isDirect, memberCount, joinedMemberIds };
794
+ this.sweepMaps();
795
+ this.rooms.set(roomId, {
796
+ info,
797
+ expiresAt: this.now() + this.options.roomInfoTtlMs,
798
+ });
799
+ return info;
800
+ }
801
+
802
+ /** Forget a room's cached info (a membership change arrived). */
803
+ invalidateRoom(roomId: string): void {
804
+ this.rooms.delete(roomId);
805
+ for (const key of this.names.keys())
806
+ if (key.startsWith(`${roomId}|`)) this.names.delete(key);
807
+ }
808
+
809
+ /** A member's display name in the room: the member event, the profile, else the user id. Cached. */
810
+ async displayName(roomId: string, userId: string): Promise<string> {
811
+ const key = `${roomId}|${userId}`;
812
+ const cached = this.names.get(key);
813
+ if (cached && cached.expiresAt > this.now()) return cached.name;
814
+ let name: string | undefined;
815
+ const member = contentOf(
816
+ await this.deps
817
+ .getRoomStateEvent(roomId, 'm.room.member', userId)
818
+ .catch(() => null),
819
+ );
820
+ if (typeof member?.displayname === 'string' && member.displayname)
821
+ name = member.displayname;
822
+ if (!name) {
823
+ const profile = await this.deps.getUserProfile(userId).catch(() => null);
824
+ if (profile?.displayname) name = profile.displayname;
825
+ }
826
+ name ??= userId;
827
+ this.names.set(key, {
828
+ name,
829
+ expiresAt: this.now() + this.options.roomInfoTtlMs,
830
+ });
831
+ return name;
832
+ }
833
+
834
+ private sweepMaps(): void {
835
+ const now = this.now();
836
+ for (const [key, entry] of this.rooms)
837
+ if (entry.expiresAt <= now) this.rooms.delete(key);
838
+ for (const [key, entry] of this.names)
839
+ if (entry.expiresAt <= now) this.names.delete(key);
840
+ }
841
+
842
+ // ── the gate ────────────────────────────────────────────────────────────
843
+
844
+ async gate(input: GateInput): Promise<GateDecision> {
845
+ const { roomId, threadId } = input;
846
+ if (this.options.groupRooms === 'answer')
847
+ return {
848
+ respond: true,
849
+ reason: 'answer-all',
850
+ roomKind: 'direct',
851
+ memberCount: 0,
852
+ displayName: input.sender,
853
+ };
854
+ let info: RoomTypeInfo;
855
+ try {
856
+ info = await this.roomInfo(roomId);
857
+ } catch (err) {
858
+ // Node's middleware passes through when the room cannot be read.
859
+ this.deps.log(
860
+ 'warn',
861
+ `[group-chat] room info failed for ${roomId}; answering as a direct room`,
862
+ err,
863
+ );
864
+ return {
865
+ respond: true,
866
+ reason: 'dm',
867
+ roomKind: 'direct',
868
+ memberCount: 0,
869
+ displayName: input.sender,
870
+ };
871
+ }
872
+ const direct = info.isDirect || info.memberCount <= 2;
873
+ // The default: the bot has no business in a group room — not a reply,
874
+ // not a typing indicator, not a captured message.
875
+ if (!direct && this.options.groupRooms === 'silent')
876
+ return {
877
+ respond: false,
878
+ reason: 'group-rooms-off',
879
+ roomKind: 'group',
880
+ memberCount: info.memberCount,
881
+ displayName: input.sender,
882
+ };
883
+ const displayName = await this.displayName(roomId, input.sender);
884
+ if (direct)
885
+ return {
886
+ respond: true,
887
+ reason: 'dm',
888
+ roomKind: 'direct',
889
+ memberCount: info.memberCount,
890
+ displayName,
891
+ };
892
+
893
+ const group = {
894
+ roomKind: 'group' as const,
895
+ memberCount: info.memberCount,
896
+ displayName,
897
+ };
898
+ // Captured whether or not the bot answers, so summaries stay accurate.
899
+ this.observe(roomId, {
900
+ eventId: input.eventId,
901
+ threadId,
902
+ senderDid: input.senderDid,
903
+ senderMatrixUserId: input.sender,
904
+ senderDisplayName: displayName,
905
+ body: input.body,
906
+ timestamp: input.ts,
907
+ });
908
+ this.sweepActiveThreads();
909
+
910
+ const reason = await this.respondReason(input);
911
+ if (reason === 'ignored') {
912
+ this.deps.log(
913
+ 'info',
914
+ `[group-chat] room=${roomId} thread=${threadId.slice(0, 10)} ignored`,
915
+ );
916
+ return { ...group, respond: false, reason };
917
+ }
918
+
919
+ const pl = botPowerLevelOf(
920
+ contentOf(
921
+ await this.deps
922
+ .getRoomStateEvent(roomId, 'm.room.power_levels')
923
+ .catch((err: unknown) => {
924
+ this.deps.log(
925
+ 'warn',
926
+ `[group-chat] power_levels read failed for ${roomId}`,
927
+ err,
928
+ );
929
+ return null;
930
+ }),
931
+ ),
932
+ this.deps.botUserId,
933
+ );
934
+ if (!pl.allowed(this.options.requirePowerLevel)) {
935
+ this.deps.log(
936
+ 'warn',
937
+ `[group-chat] room=${roomId} bot PL ${pl.pl} < required ${Math.max(pl.sendThreshold, this.options.requirePowerLevel)} — skipping`,
938
+ );
939
+ return { ...group, respond: false, reason: 'power-level' };
940
+ }
941
+
942
+ this.markActive(roomId, threadId);
943
+ this.deps.keepAlive(this.refreshMembers(roomId).catch(() => undefined));
944
+ await this.compactJustInTime(roomId);
945
+ return { ...group, respond: true, reason };
946
+ }
947
+
948
+ private async respondReason(
949
+ input: GateInput,
950
+ ): Promise<
951
+ Exclude<GateReason, 'dm' | 'power-level' | 'group-rooms-off' | 'answer-all'>
952
+ > {
953
+ const { roomId, threadId } = input;
954
+ if (input.mentionsBot) return 'mentioned';
955
+ if (input.inReplyTo && (await this.isBotEvent(roomId, input.inReplyTo)))
956
+ return 'reply-to-bot';
957
+ if (this.isActiveThread(roomId, threadId)) return 'active-thread';
958
+ // A bare message roots its own thread: nothing to look up for it.
959
+ if (threadId !== input.eventId) {
960
+ const expiry = this.store.botThreadExpiry(roomId, threadId);
961
+ if (expiry !== undefined && expiry > this.now()) {
962
+ this.activeThreads.set(activeKey(roomId, threadId), expiry);
963
+ return 'active-thread';
964
+ }
965
+ }
966
+ return 'ignored';
967
+ }
968
+
969
+ private async isBotEvent(roomId: string, eventId: string): Promise<boolean> {
970
+ try {
971
+ const parsed = parseJson(await this.deps.getEvent(roomId, eventId));
972
+ return (
973
+ typeof parsed === 'object' &&
974
+ parsed !== null &&
975
+ (parsed as { sender?: unknown }).sender === this.deps.botUserId
976
+ );
977
+ } catch (err) {
978
+ this.deps.log(
979
+ 'warn',
980
+ `[group-chat] could not fetch reply target ${eventId} in ${roomId}`,
981
+ err,
982
+ );
983
+ return false;
984
+ }
985
+ }
986
+
987
+ private isActiveThread(roomId: string, threadId: string): boolean {
988
+ const key = activeKey(roomId, threadId);
989
+ const expiresAt = this.activeThreads.get(key);
990
+ if (expiresAt === undefined) return false;
991
+ if (expiresAt <= this.now()) {
992
+ this.activeThreads.delete(key);
993
+ return false;
994
+ }
995
+ return true;
996
+ }
997
+
998
+ /** Mark a thread as one the bot is engaged in (memory + durable). */
999
+ markActive(roomId: string, threadId: string): void {
1000
+ const expiresAt = this.now() + this.options.activeThreadTtlMs;
1001
+ this.activeThreads.set(activeKey(roomId, threadId), expiresAt);
1002
+ this.store.markBotThread(roomId, threadId, expiresAt);
1003
+ }
1004
+
1005
+ private sweepActiveThreads(): void {
1006
+ const now = this.now();
1007
+ for (const [key, expiresAt] of this.activeThreads)
1008
+ if (expiresAt <= now) this.activeThreads.delete(key);
1009
+ this.store.sweepBotThreads(now);
1010
+ }
1011
+
1012
+ // ── capture + compaction ────────────────────────────────────────────────
1013
+
1014
+ observe(roomId: string, message: ObservedMessage): void {
1015
+ if (!this.store.bufferAppend(roomId, message)) return;
1016
+ if (this.store.bufferCount(roomId) >= COMPACT_BUFFER_THRESHOLD)
1017
+ this.deps.keepAlive(
1018
+ this.compact(roomId).catch((err: unknown) =>
1019
+ this.deps.log(
1020
+ 'warn',
1021
+ `[group-chat] threshold compaction failed for ${roomId}`,
1022
+ err,
1023
+ ),
1024
+ ),
1025
+ );
1026
+ }
1027
+
1028
+ /** Compact a buffer of ≥ 5 before the bot answers, bounded so the reply is not held up. */
1029
+ async compactJustInTime(roomId: string): Promise<void> {
1030
+ if (this.store.bufferCount(roomId) < COMPACT_JIT_MIN) return;
1031
+ const work = this.compact(roomId).catch((err: unknown) =>
1032
+ this.deps.log(
1033
+ 'warn',
1034
+ `[group-chat] JIT compaction failed for ${roomId}`,
1035
+ err,
1036
+ ),
1037
+ );
1038
+ this.deps.keepAlive(work);
1039
+ await Promise.race([
1040
+ work,
1041
+ sleep(this.deps.jitTimeoutMs ?? COMPACT_JIT_TIMEOUT_MS),
1042
+ ]);
1043
+ }
1044
+
1045
+ /** Summarize the room's buffer into one chunk (single-flight per room). */
1046
+ compact(roomId: string): Promise<void> {
1047
+ const inFlight = this.compacting.get(roomId);
1048
+ if (inFlight) return inFlight;
1049
+ const work = this.compactInner(roomId).finally(() => {
1050
+ this.compacting.delete(roomId);
1051
+ });
1052
+ this.compacting.set(roomId, work);
1053
+ return work;
1054
+ }
1055
+
1056
+ private async compactInner(roomId: string): Promise<void> {
1057
+ const drained = this.store.bufferRows(roomId);
1058
+ const first = drained[0];
1059
+ const last = drained[drained.length - 1];
1060
+ if (!first || !last) return;
1061
+ const summary = await this.deps.summarize(drained);
1062
+ if (!summary) {
1063
+ this.deps.log(
1064
+ 'warn',
1065
+ `[group-chat] summary unavailable; ${drained.length} messages stay buffered for ${roomId}`,
1066
+ );
1067
+ return;
1068
+ }
1069
+ const chunk: ChannelMemoryChunk = {
1070
+ id: crypto.randomUUID(),
1071
+ roomId,
1072
+ summary,
1073
+ fromEventId: first.eventId,
1074
+ toEventId: last.eventId,
1075
+ fromTimestamp: first.timestamp,
1076
+ toTimestamp: last.timestamp,
1077
+ messageCount: drained.length,
1078
+ participants: Array.from(new Set(drained.map((m) => m.senderDid))),
1079
+ threadIds: Array.from(new Set(drained.map((m) => m.threadId))),
1080
+ tier: 1,
1081
+ createdAt: this.now(),
1082
+ };
1083
+ this.store.insertChunk(chunk);
1084
+ this.store.bufferDelete(drained.map((m) => m.eventId));
1085
+ this.deps.log(
1086
+ 'info',
1087
+ `[group-chat] room=${roomId} chunk=${chunk.id} msgs=${chunk.messageCount} totalChunks=${this.store.countChunks(roomId)}`,
1088
+ );
1089
+ }
1090
+
1091
+ // ── read APIs (the four tools) ──────────────────────────────────────────
1092
+
1093
+ /** The joined members (bot excluded) with display names, stored for the roster. */
1094
+ async refreshMembers(roomId: string): Promise<ChannelMember[]> {
1095
+ try {
1096
+ const info = await this.roomInfo(roomId);
1097
+ const members: ChannelMember[] = [];
1098
+ for (const userId of info.joinedMemberIds) {
1099
+ if (userId === this.deps.botUserId) continue;
1100
+ members.push({
1101
+ matrixUserId: userId,
1102
+ displayName: await this.displayName(roomId, userId),
1103
+ });
1104
+ }
1105
+ this.store.upsertMembers(roomId, members, this.now());
1106
+ return members;
1107
+ } catch (err) {
1108
+ this.deps.log(
1109
+ 'warn',
1110
+ `[group-chat] refreshMembers failed for ${roomId}`,
1111
+ err,
1112
+ );
1113
+ return this.store.getMembers(roomId);
1114
+ }
1115
+ }
1116
+
1117
+ recall(roomId: string, limit = RECALL_DEFAULT_CHUNKS): ChannelMemoryRecall {
1118
+ const cap = Math.max(1, Math.min(RECALL_MAX_CHUNKS, limit));
1119
+ return {
1120
+ chunks: this.store.recentChunks(roomId, cap),
1121
+ pinnedFacts: this.store.listPinnedFacts(roomId),
1122
+ members: this.store.getMembers(roomId),
1123
+ };
1124
+ }
1125
+
1126
+ search(
1127
+ roomId: string,
1128
+ query: string,
1129
+ limit = RECALL_DEFAULT_CHUNKS,
1130
+ ): ChannelMemoryChunk[] {
1131
+ return this.store.searchChunks(
1132
+ roomId,
1133
+ query,
1134
+ Math.max(1, Math.min(RECALL_MAX_CHUNKS, limit)),
1135
+ );
1136
+ }
1137
+
1138
+ pinFact(args: {
1139
+ roomId: string;
1140
+ fact: string;
1141
+ pinnedByDid: string;
1142
+ sourceEventId?: string;
1143
+ }): PinnedFact {
1144
+ const fact: PinnedFact = {
1145
+ id: crypto.randomUUID(),
1146
+ roomId: args.roomId,
1147
+ fact: args.fact.trim().slice(0, PINNED_FACT_MAX_CHARS),
1148
+ pinnedByDid: args.pinnedByDid,
1149
+ ...(args.sourceEventId ? { sourceEventId: args.sourceEventId } : {}),
1150
+ createdAt: this.now(),
1151
+ };
1152
+ this.store.insertPinnedFact(fact);
1153
+ return fact;
1154
+ }
1155
+
1156
+ unpinFact(roomId: string, factId: string): boolean {
1157
+ return this.store.deletePinnedFact(roomId, factId);
1158
+ }
1159
+
1160
+ stats() {
1161
+ return this.store.stats();
1162
+ }
1163
+ }