@xmanrui/dsh-im 4.38.2 → 4.39.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.
@@ -22,6 +22,8 @@ import {
22
22
  normalizeMatrixDeliveryTarget,
23
23
  normalizeMatrixTimelineEvent,
24
24
  resolveBangMatrixCommand,
25
+ threadIdOf,
26
+ replyEventIdOf,
25
27
  } from './matrix-normalize.mjs';
26
28
  import {
27
29
  DEFAULT_ROOM_HISTORY_LIMIT,
@@ -36,6 +38,11 @@ import {
36
38
  extractOutboundMentions,
37
39
  hasRoomMention,
38
40
  } from './matrix-rich-text.mjs';
41
+ import { RootContextRegistry } from '../shared/conversation/root-context.mjs';
42
+ import { WindowAmbientProvider } from '../shared/conversation/ambient-provider.mjs';
43
+ import { createEventLru, resolveReplyChain } from '../shared/conversation/reply-context.mjs';
44
+ import { renderContextEntries } from '../shared/conversation/context-builder.mjs';
45
+ import { TRIGGER_REPLY_MODES } from '../shared/conversation/trigger-policy.mjs';
39
46
  import { chunkMarkdownParts } from '../shared/markdown-chunks.mjs';
40
47
 
41
48
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
@@ -45,6 +52,8 @@ const INVITE_JOIN_TIMEOUT_MS = 45_000;
45
52
  const DEFAULT_MAX_MESSAGE_LENGTH = 16_000;
46
53
  const DEFAULT_MAX_MEDIA_BYTES = 104_857_600;
47
54
  const EDIT_STREAM_INTERVAL_MS = 350;
55
+ const MATRIX_THREADS_MODES = new Set(['inherit', 'isolate', 'auto']);
56
+ const MAX_BOT_EVENT_BINDINGS = 5_000;
48
57
 
49
58
  function safeInt(value, min, max, fallback) {
50
59
  if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
@@ -264,12 +273,45 @@ export class MatrixRuntime {
264
273
  && typeof roomHistory.consumePending === 'function'
265
274
  && typeof roomHistory.pending === 'function'
266
275
  && typeof roomHistory.search === 'function'
276
+ && typeof roomHistory.recent === 'function'
267
277
  ? roomHistory
268
278
  : null;
269
279
  this.#roomContextLimit = safeInt(config.roomContextLimit, 1, 500, 50);
270
280
  this.#roomContextMaxChars = safeInt(config.roomContextMaxChars, 200, 200_000, 8_000);
271
281
  this.#roomContextTzOffsetMinutes = safeInt(config.roomContextTzOffsetMinutes, -840, 840, 0);
272
282
  this.#roomContextEnabled = config.roomContextEnabled !== false;
283
+ this.#threadsMode = MATRIX_THREADS_MODES.has(config.threadsMode) ? config.threadsMode : 'inherit';
284
+ this.#triggerOnReply = TRIGGER_REPLY_MODES.includes(config.triggerOnReply) ? config.triggerOnReply : 'never';
285
+ this.#rootOnceEnabled = config.rootContextOnceEnabled === true;
286
+ this.#replyContextEnabled = config.replyContextEnabled === true;
287
+ this.#ambientContextEnabled = config.ambientContextEnabled === true;
288
+ // The room-visible window is shipped on: what members can read in the room
289
+ // timeline reaches the model as collected context, bounded only by the
290
+ // window and budget knobs below.
291
+ this.#visibleContextEnabled = config.visibleContextEnabled !== false;
292
+ this.#visibleContextWindowMs = safeInt(config.visibleContextWindowMs, 10_000, 86_400_000, 600_000);
293
+ this.#visibleContextLimit = safeInt(config.visibleContextLimit, 1, 50, 8);
294
+ // Follow-up lanes for rooms run on rapid consecutive posts: a same sender's
295
+ // burst folds into one dispatch, and that sender's mid-turn follow-ups steer
296
+ // the in-flight turn rather than queueing behind the answer they correct.
297
+ // Other members' messages still queue one voice at a time.
298
+ this.#autoSteerSameSender = config.autoSteerSameSender !== false;
299
+ this.#autoSteerLimit = safeInt(config.autoSteerLimit, 0, 20, 5);
300
+ this.#coalesceWindowMs = safeInt(config.coalesceWindowMs, 0, 3_000, 450);
301
+ this.#visibleContextChars = safeInt(config.visibleContextChars, 200, 200_000, 4_000);
302
+ this.#contextMaxChars = safeInt(config.contextMaxChars, 200, 200_000, 8_000);
303
+ this.#replyMaxDepth = safeInt(config.replyMaxDepth, 1, 10, 5);
304
+ this.#rootRegistry = new RootContextRegistry({ state });
305
+ this.#ambientProvider = this.#roomHistory
306
+ ? new WindowAmbientProvider({
307
+ store: this.#roomHistory,
308
+ burstGapMs: safeInt(config.ambientBurstGapMs, 5_000, 3_600_000, 90_000),
309
+ recentWindowMs: safeInt(config.ambientRecentWindowMs, 10_000, 86_400_000, 180_000),
310
+ recentCount: safeInt(config.ambientRecentCount, 1, 200, 20),
311
+ maxContextChars: this.#contextMaxChars,
312
+ })
313
+ : null;
314
+ this.#contextEvents = createEventLru(256);
273
315
  this.#replyTimeoutMs = replyTimeoutMs;
274
316
  this.#patterns = compileIgnorePatterns(config.ignoreUserPatterns);
275
317
  for (const roomId of toRoomSet(config.freeResponseRooms)) this.#freeRooms.add(roomId);
@@ -283,6 +325,50 @@ export class MatrixRuntime {
283
325
  #roomContextMaxChars = 8_000;
284
326
  #roomContextTzOffsetMinutes = 0;
285
327
  #roomContextEnabled = true;
328
+ #threadsMode = 'inherit';
329
+ #triggerOnReply = 'never';
330
+ /**
331
+ * Within one conversation an event enters the model as collected context at most
332
+ * once. The in-memory map rides on the room record's durable per-conversation
333
+ * ledger (`contextInjected`/`markContextInjected`), so an entry that was already
334
+ * offered stays retired across restarts; only a cleared session releases it.
335
+ */
336
+ #contextLedger = new Map();
337
+ /**
338
+ * Per-conversation session generation, bumped when /new clears the session. A
339
+ * recorded bubble's epoch retires it from its own conversation's window only
340
+ * while it matches this counter, so a fresh session re-reads the previous
341
+ * session's bubbles once instead of inheriting their retirement.
342
+ */
343
+ #turnEpochs = new Map();
344
+ #ledgerRouteCap = 400;
345
+ #ledgerEntryCap = 512;
346
+ /**
347
+ * Group senders' room-state display names. Reads are synchronous against this
348
+ * cache so the timeline handler never blocks on the homeserver; a miss
349
+ * schedules the state read and the same sender reads as its MXID localpart
350
+ * until the name arrives. Entries expire after the TTL and the Map evicts in
351
+ * insertion order beyond the cap.
352
+ */
353
+ #memberNames = new Map();
354
+ #memberNameCap = 512;
355
+ #memberNameTtlMs = 600_000;
356
+ #rootOnceEnabled = false;
357
+ #replyContextEnabled = false;
358
+ #ambientContextEnabled = false;
359
+ #visibleContextEnabled = true;
360
+ #visibleContextWindowMs = 600_000;
361
+ #visibleContextLimit = 8;
362
+ #autoSteerSameSender = true;
363
+ #autoSteerLimit = 5;
364
+ #coalesceWindowMs = 450;
365
+ #visibleContextChars = 4_000;
366
+ #contextMaxChars = 8_000;
367
+ #replyMaxDepth = 5;
368
+ #rootRegistry = null;
369
+ #ambientProvider = null;
370
+ #contextEvents = null;
371
+ #botEventBindings = new Map();
286
372
 
287
373
  get status() {
288
374
  return this.#status;
@@ -346,6 +432,15 @@ export class MatrixRuntime {
346
432
  status: this.#status,
347
433
  logger: this.#logger,
348
434
  replyTimeoutMs: this.#replyTimeoutMs,
435
+ autoSteerSameSender: this.#autoSteerSameSender,
436
+ autoSteerLimit: this.#autoSteerLimit,
437
+ coalesceWindowMs: this.#coalesceWindowMs,
438
+ // The binding table and the /new anchor reset serve the trigger policy and the
439
+ // `auto` thread router, not only the context collectors, so those modes wire the
440
+ // hooks even when every structured-context collector stays off.
441
+ contextHooks: this.#structuredContextActive() || this.#triggerOnReply === 'bot-only' || this.#threadsMode === 'auto'
442
+ ? this.#createContextHooks()
443
+ : null,
349
444
  });
350
445
  await this.#initialSync();
351
446
  if (generation !== this.#generation) return;
@@ -625,21 +720,40 @@ export class MatrixRuntime {
625
720
  processNotices: this.#config.processNotices,
626
721
  freeResponseRooms: this.#freeRooms,
627
722
  allowedRooms: this.#allowedRooms.size > 0 ? this.#allowedRooms : null,
723
+ threadsMode: this.#threadsMode,
724
+ triggerOnReply: this.#triggerOnReply,
725
+ botId: this.#config.botId ?? null,
628
726
  },
629
727
  patterns: this.#patterns,
630
728
  ring: this.#ring,
631
729
  clock: this.#clock,
632
- deps: { createMediaSource: (mediaContent, msgtype) => this.#createMediaSource(mediaContent, msgtype) },
730
+ deps: {
731
+ createMediaSource: (mediaContent, msgtype) => this.#createMediaSource(mediaContent, msgtype),
732
+ classifyReplyTarget: (eventId) => this.#classifyReplyTarget(eventId),
733
+ routeOverrideForReply: (eventId) => this.#routeOverrideForReply(eventId),
734
+ isBotRoomEvent: (roomId, eventId) => (this.#roomHistory?.kindOf({ roomId, eventId }) ?? null) === 'bot',
735
+ },
633
736
  });
634
737
  if (outcome.drop) {
635
738
  if (outcome.warnSkew && !this.#status.lastClockSkewAt) {
636
739
  this.#status.lastClockSkewAt = new Date().toISOString();
637
740
  this.#logger.warn?.(t('Matrix 收到的时间戳持续远落后于本机时间,检测到本机时钟超前,请校准系统时间后重启机器人。'));
638
741
  }
639
- if (outcome.drop === 'mention-required') this.#recordRoomAmbient(roomId, event);
742
+ if (outcome.drop === 'mention-required'
743
+ || outcome.drop === 'reply-to-human'
744
+ || outcome.drop === 'reply-target-unknown') this.#recordRoomAmbient(roomId, event);
745
+ else if (outcome.drop === 'self') this.#recordRoomBotReply(roomId, event);
640
746
  return;
641
747
  }
642
748
  const message = { ...outcome.message };
749
+ if (message.kind === 'group' && typeof message.senderId === 'string' && message.senderId) {
750
+ const speakerName = this.#resolveSenderDisplayName(roomId, message.senderId);
751
+ if (speakerName) {
752
+ message.speakerName = speakerName;
753
+ const baseSource = typeof message.contextSource === 'function' ? message.contextSource() : {};
754
+ message.contextSource = () => ({ ...baseSource, senderName });
755
+ }
756
+ }
643
757
  if (message.addressed && typeof message.content === 'string' && message.content.startsWith('!')) {
644
758
  const resolved = resolveBangMatrixCommand(message.content, (name) => this.#isKnownCommand(name));
645
759
  if (resolved !== message.content) {
@@ -652,7 +766,7 @@ export class MatrixRuntime {
652
766
  const commandLike = isMatrixCommandLike(message.content);
653
767
  const directAddress = message.mentioned === true || Boolean(message.replyToEventId);
654
768
  this.#recordRoomHuman(message, { kind: commandLike ? 'command' : 'human', injected: true });
655
- if (!commandLike && directAddress) {
769
+ if (!commandLike && directAddress && !this.#structuredContextActive()) {
656
770
  const block = this.#consumeRoomContext(message.roomId);
657
771
  if (block) {
658
772
  const label = t('【下面这条才是对你的提问,请只回答它】');
@@ -671,6 +785,357 @@ export class MatrixRuntime {
671
785
  }
672
786
  }
673
787
 
788
+ #structuredContextActive() {
789
+ return this.#rootOnceEnabled === true
790
+ || this.#replyContextEnabled === true
791
+ || this.#ambientContextEnabled === true
792
+ || this.#visibleContextEnabled === true;
793
+ }
794
+
795
+ /** Sync classifier fed to the trigger table: a bound event id proves bot authorship. */
796
+ #classifyReplyTarget(eventId) {
797
+ return typeof eventId === 'string' && this.#botEventBindings.has(eventId) ? 'bot' : null;
798
+ }
799
+
800
+ /** The `auto` thread mode's routing hint: the conversation a reply target belongs to. */
801
+ #routeOverrideForReply(eventId) {
802
+ if (typeof eventId !== 'string') return null;
803
+ return this.#botEventBindings.get(eventId)?.conversationKey ?? null;
804
+ }
805
+
806
+ /**
807
+ * The conversation's current session generation. A live clear updates the in-memory
808
+ * counter; after a restart the durable counter in the room record keeps retired
809
+ * answers retired without a read-back.
810
+ */
811
+ #contextEpoch(conversationKey) {
812
+ if (typeof conversationKey !== 'string' || !conversationKey) return 0;
813
+ const live = this.#turnEpochs.get(conversationKey);
814
+ if (Number.isSafeInteger(live) && live >= 0) return live;
815
+ return this.#roomHistory?.contextEpoch({ conversationKey }) ?? 0;
816
+ }
817
+
818
+ #recordBotEventDelivery({ conversationKey, sessionId, providerMessageIds }) {
819
+ if (!Array.isArray(providerMessageIds) || providerMessageIds.length === 0) return;
820
+ for (const eventId of providerMessageIds) {
821
+ if (typeof eventId !== 'string' || !eventId) continue;
822
+ const boundKey = typeof conversationKey === 'string' && conversationKey ? conversationKey : null;
823
+ this.#botEventBindings.set(eventId, {
824
+ conversationKey: boundKey,
825
+ sessionId: typeof sessionId === 'string' && sessionId ? sessionId : null,
826
+ // The session generation live at delivery, the same stamp the room-visible
827
+ // record carries at the send exit: after /new bumps the epoch, the former
828
+ // session's bubbles are no longer this conversation's own words.
829
+ turnEpoch: boundKey ? this.#contextEpoch(boundKey) : null,
830
+ at: Date.now(),
831
+ });
832
+ }
833
+ while (this.#botEventBindings.size > MAX_BOT_EVENT_BINDINGS) {
834
+ const oldest = this.#botEventBindings.keys().next().value;
835
+ this.#botEventBindings.delete(oldest);
836
+ }
837
+ }
838
+
839
+ /**
840
+ * Whether an event is this conversation's own answer of the current session
841
+ * generation. Its text is transcript content already, so no collection lane may
842
+ * re-serve it as room speech, however the trigger reached it. The provenance
843
+ * rides the delivery binding (`#recordBotEventDelivery`); once `/new` bumps the
844
+ * generation, the successor conversation no longer claims the bubble and reads
845
+ * it back through the windows once.
846
+ */
847
+ #isOwnCurrentAnswer(conversationKey, eventId) {
848
+ if (typeof conversationKey !== 'string' || !conversationKey) return false;
849
+ if (typeof eventId !== 'string' || !eventId) return false;
850
+ const binding = this.#botEventBindings.get(eventId);
851
+ if (!binding || binding.conversationKey !== conversationKey) return false;
852
+ return Number.isSafeInteger(binding.turnEpoch) && binding.turnEpoch >= 0
853
+ && binding.turnEpoch === this.#contextEpoch(conversationKey);
854
+ }
855
+
856
+ /**
857
+ * Resolve one event for the context collectors through the CS API single-event
858
+ * endpoint, decrypting when the room is end-to-end encrypted. Resolved lookups
859
+ * (including misses) are cached per room so a burst of replies into one chain
860
+ * does not refetch shared ancestors.
861
+ */
862
+ async #fetchContextEvent(roomId, eventId) {
863
+ if (!this.#api || typeof this.#api.getEvent !== 'function') return null;
864
+ if (typeof eventId !== 'string' || !eventId) return null;
865
+ const cacheKey = `${roomId}|${eventId}`;
866
+ const cached = this.#contextEvents.get(cacheKey);
867
+ if (cached !== undefined) return cached;
868
+ const event = await this.#api.getEvent(roomId, eventId).catch(() => null);
869
+ let resolved = null;
870
+ if (event && typeof event.content === 'object' && event.content !== null) {
871
+ let content = event.content;
872
+ if (event.type === 'm.room.encrypted' && this.#crypto) {
873
+ content = (await this.#crypto.decryptRoomEvent(roomId, event).catch(() => null))?.content ?? null;
874
+ }
875
+ const body = typeof content?.body === 'string' ? content.body : '';
876
+ if (body) {
877
+ // Chain walking follows genuine reply relations only; a falling-back
878
+ // relation is the thread-membership declaration, not a reply target.
879
+ const replyTo = replyEventIdOf(content);
880
+ resolved = {
881
+ sender: typeof event.sender === 'string' ? event.sender : '',
882
+ name: '',
883
+ text: body,
884
+ ts: Number.isSafeInteger(event.origin_server_ts) && event.origin_server_ts > 0 ? event.origin_server_ts : null,
885
+ replyTo,
886
+ };
887
+ }
888
+ }
889
+ this.#contextEvents.set(cacheKey, resolved);
890
+ return resolved;
891
+ }
892
+
893
+ #contextAlreadyInjected(conversationKey, eventId) {
894
+ if (typeof conversationKey !== 'string' || !conversationKey || typeof eventId !== 'string' || !eventId) return false;
895
+ if (this.#contextLedger.get(conversationKey)?.has(eventId) === true) return true;
896
+ return this.#roomHistory?.contextInjected({ conversationKey, eventId }) === true;
897
+ }
898
+
899
+ #markContextInjected(conversationKey, eventIds) {
900
+ if (typeof conversationKey !== 'string' || !conversationKey || eventIds.length === 0) return;
901
+ let seen = this.#contextLedger.get(conversationKey);
902
+ if (seen) {
903
+ // Recency follows the last injection, so the route cap evicts the stalest conversation.
904
+ this.#contextLedger.delete(conversationKey);
905
+ this.#contextLedger.set(conversationKey, seen);
906
+ } else {
907
+ seen = new Set();
908
+ this.#contextLedger.set(conversationKey, seen);
909
+ while (this.#contextLedger.size > this.#ledgerRouteCap) {
910
+ const oldest = this.#contextLedger.keys().next().value;
911
+ if (oldest === conversationKey && this.#contextLedger.size === 1) break;
912
+ this.#contextLedger.delete(oldest);
913
+ }
914
+ }
915
+ for (const eventId of eventIds) {
916
+ seen.add(eventId);
917
+ while (seen.size > this.#ledgerEntryCap) {
918
+ seen.delete(seen.values().next().value);
919
+ }
920
+ }
921
+ this.#roomHistory?.markContextInjected({ conversationKey, eventIds });
922
+ }
923
+
924
+ /**
925
+ * The bridge-facing context pipeline. Collection runs after command handling
926
+ * so commands never pay for a lookup; consumption markers are applied only when
927
+ * the turn completed successfully, and delivered event ids feed the binding
928
+ * table that classifies reply targets and routes `auto` thread replies.
929
+ */
930
+ #createContextHooks() {
931
+ return {
932
+ enabled: () => this.#structuredContextActive(),
933
+ collect: async ({ message, conversationKey, sessionIdHint }) => {
934
+ const entries = [];
935
+ let used = 0;
936
+ const placeholder = t('正在处理…');
937
+ // The coalescer folded this turn out of several bubbles: the extra
938
+ // members are already spoken here as the turn's own words, so the
939
+ // windows retire them with the turn instead of re-serving that text a
940
+ // turn later.
941
+ if (Array.isArray(message.coalescedEventIds)) {
942
+ this.#markContextInjected(conversationKey, message.coalescedEventIds.filter(
943
+ (eventId) => typeof eventId === 'string' && eventId,
944
+ ));
945
+ }
946
+ const offer = (entry) => {
947
+ if (entry.eventId && entries.some((existing) => existing.eventId === entry.eventId)) return false;
948
+ // An event whose fetched body is still the pending-placeholder
949
+ // template carries no content members can read: homeservers hand
950
+ // back the pre-edit body, so the receipt never enters the context.
951
+ if (typeof entry.text === 'string' && entry.text.trim() === placeholder) return false;
952
+ const cost = (typeof entry.text === 'string' ? entry.text.length : 0) + 48;
953
+ if (used + cost > this.#contextMaxChars) return false;
954
+ used += cost;
955
+ entries.push(entry);
956
+ return true;
957
+ };
958
+ const roomId = typeof message.roomId === 'string' ? message.roomId : '';
959
+ if (this.#rootOnceEnabled && message.kind === 'group'
960
+ && typeof message.threadId === 'string' && message.threadId) {
961
+ const plan = this.#rootRegistry.plan({
962
+ routeKey: conversationKey,
963
+ sessionId: sessionIdHint,
964
+ rootId: message.threadId,
965
+ });
966
+ if (plan.inject && !this.#contextAlreadyInjected(conversationKey, message.threadId)) {
967
+ const rootId = message.threadId;
968
+ const root = await this.#fetchContextEvent(roomId, rootId);
969
+ // A pending placeholder that opened the thread reads as its receipt
970
+ // line in the thread header, not as question content; what members can
971
+ // read of that answer is the final body, which reaches the model
972
+ // through the same-list room-visible record instead, so a
973
+ // placeholder root contributes no entry of its own.
974
+ if (root && root.text !== placeholder && !this.#contextAlreadyInjected(conversationKey, rootId)) {
975
+ offer({
976
+ collector: 'thread-root',
977
+ roomId,
978
+ eventId: rootId,
979
+ sender: root.sender,
980
+ name: root.name,
981
+ ts: root.ts,
982
+ text: root.text,
983
+ });
984
+ }
985
+ }
986
+ }
987
+ if (this.#replyContextEnabled
988
+ && typeof message.replyToEventId === 'string' && message.replyToEventId) {
989
+ const chain = await resolveReplyChain({
990
+ fetch: (eventId) => this.#fetchContextEvent(roomId, eventId),
991
+ startReplyTo: message.replyToEventId,
992
+ maxDepth: this.#replyMaxDepth,
993
+ });
994
+ // A machine receipt is not a human target: the chain answers only up
995
+ // to the pending-placeholder body (oldest ancestor first, so entries
996
+ // nearer the trigger follow it). The walk ends at the receipt and the
997
+ // question behind a placeholder never crosses into the panel above it.
998
+ const receiptAt = chain.findIndex(
999
+ (node) => typeof node.text === 'string' && node.text.trim() === placeholder,
1000
+ );
1001
+ const walkable = receiptAt >= 0 ? chain.slice(receiptAt + 1) : chain;
1002
+ for (const node of walkable) {
1003
+ if (this.#contextAlreadyInjected(conversationKey, node.eventId)) continue;
1004
+ // The quote is a platform fact, yet the bubble behind it can be this
1005
+ // conversation's own answer of the current generation: transcript
1006
+ // content already, held out of the context as the room window holds it.
1007
+ if (this.#isOwnCurrentAnswer(conversationKey, node.eventId)) continue;
1008
+ if (!offer({
1009
+ collector: 'reply',
1010
+ roomId,
1011
+ eventId: node.eventId,
1012
+ sender: node.sender,
1013
+ name: node.name,
1014
+ ts: node.ts,
1015
+ text: node.text,
1016
+ })) break;
1017
+ }
1018
+ }
1019
+ // The room-visible window backfills the surrounding readback an
1020
+ // elliptical follow-up leans on — earlier answered questions and the
1021
+ // bot's own results included — whatever the other collectors did not
1022
+ // already carry and this conversation has not seen yet. It leads the
1023
+ // ambient queries so the chronological room window keeps the earlier
1024
+ // slots; the offer guard then keeps later collectors from repeating it.
1025
+ if (this.#visibleContextEnabled && message.kind === 'group' && this.#roomHistory) {
1026
+ const seenEventIds = new Set(entries.map((entry) => entry.eventId).filter(Boolean));
1027
+ if (typeof message.messageId === 'string' && message.messageId) seenEventIds.add(message.messageId);
1028
+ const window = this.#roomHistory.recent({
1029
+ roomId,
1030
+ threadId: typeof message.threadId === 'string' && message.threadId ? message.threadId : null,
1031
+ exclude: seenEventIds,
1032
+ limit: this.#visibleContextLimit,
1033
+ maxChars: this.#visibleContextChars,
1034
+ sinceTs: Date.now() - this.#visibleContextWindowMs,
1035
+ });
1036
+ for (const record of window) {
1037
+ if (this.#contextAlreadyInjected(conversationKey, record.eventId)) continue;
1038
+ // What this conversation's own current session produced already sits in
1039
+ // its transcript; re-serving it as room speech duplicates the model's
1040
+ // own words. Other conversations and a since-cleared session (newer
1041
+ // epoch) keep reading the bubble — its root-bubble panel role survives.
1042
+ if (record.turnKey === conversationKey
1043
+ && record.turnEpoch === this.#contextEpoch(conversationKey)) continue;
1044
+ if (!offer({
1045
+ collector: 'room-visible',
1046
+ roomId,
1047
+ eventId: record.eventId,
1048
+ sender: record.sender,
1049
+ name: record.name,
1050
+ ts: record.ts,
1051
+ text: record.text,
1052
+ })) break;
1053
+ }
1054
+ }
1055
+ if (this.#ambientContextEnabled && message.kind === 'group' && this.#ambientProvider) {
1056
+ const { burst, recent } = this.#ambientProvider.query({
1057
+ roomId,
1058
+ senderId: typeof message.senderId === 'string' ? message.senderId : '',
1059
+ threadId: typeof message.threadId === 'string' && message.threadId ? message.threadId : null,
1060
+ });
1061
+ for (const record of burst) {
1062
+ if (this.#contextAlreadyInjected(conversationKey, record.eventId)) continue;
1063
+ if (!offer({
1064
+ collector: 'ambient-burst',
1065
+ roomId,
1066
+ eventId: record.eventId,
1067
+ sender: record.sender,
1068
+ name: record.name,
1069
+ ts: record.ts,
1070
+ text: record.text,
1071
+ })) break;
1072
+ }
1073
+ for (const record of recent) {
1074
+ if (this.#contextAlreadyInjected(conversationKey, record.eventId)) continue;
1075
+ if (!offer({
1076
+ collector: 'ambient-recent',
1077
+ roomId,
1078
+ eventId: record.eventId,
1079
+ sender: record.sender,
1080
+ name: record.name,
1081
+ ts: record.ts,
1082
+ text: record.text,
1083
+ })) break;
1084
+ }
1085
+ }
1086
+ // The trigger body itself now rides this conversation's model input; the
1087
+ // ledger records that so a later turn's room-visible window never
1088
+ // re-serves the question the session already answered verbatim.
1089
+ if (typeof message.messageId === 'string' && message.messageId) {
1090
+ this.#markContextInjected(conversationKey, [message.messageId]);
1091
+ }
1092
+ return entries;
1093
+ },
1094
+ render: (entries) => renderContextEntries(entries),
1095
+ onConsumed: async ({ conversationKey, sessionId, entries }) => {
1096
+ const ambient = entries.filter((entry) => entry.collector === 'ambient-burst' || entry.collector === 'ambient-recent');
1097
+ if (ambient.length > 0 && this.#ambientProvider) {
1098
+ await this.#ambientProvider.markConsumed({ roomId: ambient[0]?.roomId, entries: ambient });
1099
+ }
1100
+ const root = entries.find((entry) => entry.collector === 'thread-root');
1101
+ if (root && typeof sessionId === 'string' && sessionId) {
1102
+ await this.#rootRegistry.markInjected({ routeKey: conversationKey, sessionId, rootId: root.eventId });
1103
+ }
1104
+ const collected = entries.filter((entry) => entry.collector === 'reply' || entry.collector === 'thread-root' || entry.collector === 'room-visible');
1105
+ if (collected.length > 0) {
1106
+ this.#markContextInjected(conversationKey, collected.map((entry) => entry.eventId).filter(Boolean));
1107
+ }
1108
+ },
1109
+ /**
1110
+ * The steered body reached the Session inside the in-flight turn, exactly
1111
+ * like a folded coalescer member: retire its bubble with the turn so the
1112
+ * window never re-serves text the same session has already taken in.
1113
+ */
1114
+ onSteered: async ({ conversationKey, messageId }) => {
1115
+ if (typeof messageId === 'string' && messageId) {
1116
+ this.#markContextInjected(conversationKey, [messageId]);
1117
+ }
1118
+ },
1119
+ onSessionCleared: async (conversationKey) => {
1120
+ await this.#rootRegistry.clearRoute(conversationKey);
1121
+ this.#contextLedger.delete(conversationKey);
1122
+ // The successor session never saw the previous one's answers; bumping the
1123
+ // generation lifts their retirement so they read back into this window once.
1124
+ // The durable ledger and the durable generation carry the same reset across a
1125
+ // restart, so the read-back stays bounded to once per conversation instead of
1126
+ // recurring on every reboot.
1127
+ const durable = this.#roomHistory ? this.#roomHistory.bumpContextEpoch({ conversationKey }) : null;
1128
+ this.#turnEpochs.set(conversationKey, Number.isSafeInteger(durable) && durable > 0
1129
+ ? durable
1130
+ : (this.#turnEpochs.get(conversationKey) ?? 0) + 1);
1131
+ this.#roomHistory?.clearContextInjected({ conversationKey });
1132
+ },
1133
+ onDelivered: async ({ conversationKey, sessionId, providerMessageIds }) => {
1134
+ this.#recordBotEventDelivery({ conversationKey, sessionId, providerMessageIds });
1135
+ },
1136
+ };
1137
+ }
1138
+
674
1139
  #recordRoomAmbient(roomId, event) {
675
1140
  if (!this.#roomHistory || !this.#roomContextEnabled) return;
676
1141
  if (this.#dmRooms.has(roomId)) return;
@@ -686,11 +1151,58 @@ export class MatrixRuntime {
686
1151
  sender,
687
1152
  text: body,
688
1153
  ts: Number.isSafeInteger(event?.origin_server_ts) ? event.origin_server_ts : Date.now(),
1154
+ threadId: threadIdOf(event?.content),
689
1155
  kind: 'human',
690
1156
  injected: false,
691
1157
  });
692
1158
  }
693
1159
 
1160
+ // One recorded bot message, from any exit that reaches the room: a plain
1161
+ // send, an edit frame of a streamed answer, or an echoed timeline event.
1162
+ // The pending placeholder carries no content and stays out of the record.
1163
+ #recordBotRoomMessage({ roomId, eventId, text, threadId = null, edit = false, turnKey = null }) {
1164
+ if (!this.#roomHistory || !this.#roomContextEnabled || !this.#visibleContextEnabled) return;
1165
+ if (this.#dmRooms.has(roomId)) return;
1166
+ const body = typeof text === 'string' ? text.trim() : '';
1167
+ if (!eventId) return;
1168
+ const turnEpoch = turnKey === null ? null : this.#contextEpoch(turnKey);
1169
+ const self = this.#botUserId ?? '';
1170
+ const name = self ? this.#resolveSenderDisplayName(roomId, self) : null;
1171
+ const ts = Date.now();
1172
+ // A pending placeholder earns a content-free bot receipt: the window never
1173
+ // renders an empty entry, while the fork policy and the final edit frames
1174
+ // (which revise the same entry to the read body) both key off this id.
1175
+ if (!body || body === t('正在处理…')) {
1176
+ this.#roomHistory.append({ roomId, eventId, sender: self, name, text: '', ts, threadId, kind: 'bot', turnKey, turnEpoch });
1177
+ return;
1178
+ }
1179
+ if (edit) {
1180
+ this.#roomHistory.revise({ roomId, eventId, sender: self, name, text: body, ts, threadId, turnKey, turnEpoch });
1181
+ return;
1182
+ }
1183
+ this.#roomHistory.append({ roomId, eventId, sender: self, name, text: body, ts, threadId, kind: 'bot', turnKey, turnEpoch });
1184
+ }
1185
+
1186
+ // The timeline face of bot authorship: kept for homeservers that echo bot
1187
+ // events back, coalescing by event id with the send-face records. Edit
1188
+ // frames are owned by the send-face revising writer and skip here so one
1189
+ // streamed answer never lands twice under relation ids.
1190
+ #recordRoomBotReply(roomId, event) {
1191
+ if (!this.#roomHistory || !this.#roomContextEnabled || !this.#visibleContextEnabled) return;
1192
+ const content = event?.content;
1193
+ if (content?.['m.new_content'] || content?.['m.rel_type'] === 'm.replace') return;
1194
+ const eventId = typeof event?.event_id === 'string' ? event.event_id : '';
1195
+ const body = typeof content?.body === 'string' ? content.body : '';
1196
+ const msgtype = content?.msgtype;
1197
+ if (msgtype && msgtype !== 'm.text') return;
1198
+ this.#recordBotRoomMessage({
1199
+ roomId,
1200
+ eventId,
1201
+ text: body,
1202
+ threadId: threadIdOf(content),
1203
+ });
1204
+ }
1205
+
694
1206
  #recordRoomHuman(message, { kind = 'human', injected = false } = {}) {
695
1207
  if (!this.#roomHistory || !this.#roomContextEnabled) return;
696
1208
  const text = typeof message.content === 'string' ? message.content.trim() : '';
@@ -703,6 +1215,7 @@ export class MatrixRuntime {
703
1215
  sender: message.senderId,
704
1216
  text,
705
1217
  ts: Date.now(),
1218
+ threadId: typeof message.threadId === 'string' && message.threadId ? message.threadId : null,
706
1219
  kind,
707
1220
  injected,
708
1221
  });
@@ -842,6 +1355,33 @@ export class MatrixRuntime {
842
1355
  return await api.sendEvent(roomId, eventType, content, options);
843
1356
  }
844
1357
 
1358
+ // Resolves a group sender's display name without blocking the synchronous
1359
+ // timeline handler: a fresh entry answers at once, a stale entry still beats
1360
+ // the localpart while the refresh runs, and a miss labels with the MXID
1361
+ // localpart while the room state read fills the cache for later turns.
1362
+ #resolveSenderDisplayName(roomId, senderId) {
1363
+ const key = `${roomId}|${senderId.toLowerCase()}`;
1364
+ const hit = this.#memberNames.get(key);
1365
+ if (hit && hit.at + this.#memberNameTtlMs > Date.now()) return hit.name;
1366
+ const api = this.#api;
1367
+ if (typeof api?.getRoomMemberDisplayName === 'function') {
1368
+ Promise.resolve(api.getRoomMemberDisplayName(roomId, senderId))
1369
+ .then((name) => {
1370
+ if (!name) return;
1371
+ this.#memberNames.delete(key);
1372
+ this.#memberNames.set(key, { name, at: Date.now() });
1373
+ while (this.#memberNames.size > this.#memberNameCap) {
1374
+ this.#memberNames.delete(this.#memberNames.keys().next().value);
1375
+ }
1376
+ })
1377
+ .catch(() => undefined);
1378
+ }
1379
+ if (hit) return hit.name;
1380
+ const local = senderId.startsWith('@') ? senderId.slice(1) : senderId;
1381
+ const colon = local.indexOf(':');
1382
+ return (colon > 0 ? local.slice(0, colon) : local) || null;
1383
+ }
1384
+
845
1385
  #createMediaSource(content, msgtype) {
846
1386
  const encrypted = content?.file && typeof content.file === 'object' ? content.file : null;
847
1387
  const contentUri = cleanString(content?.url ?? encrypted?.url);
@@ -909,6 +1449,11 @@ export class MatrixRuntime {
909
1449
  };
910
1450
  }
911
1451
 
1452
+ /** The conversation key a turn-bound delivery target was stamped with, or null for a local send. */
1453
+ #targetTurnKey(target) {
1454
+ return typeof target?.dshTurnKey === 'string' && target.dshTurnKey ? target.dshTurnKey : null;
1455
+ }
1456
+
912
1457
  async #sendRoomText(target, text, options = {}) {
913
1458
  const api = this.#api;
914
1459
  if (!api) throw new Error('Matrix bot is not connected');
@@ -924,22 +1469,32 @@ export class MatrixRuntime {
924
1469
  const providerMessageIds = [];
925
1470
  for (const chunk of chunks) {
926
1471
  let content;
1472
+ let sentBody;
927
1473
  try {
928
1474
  content = buildMatrixTextContent({
929
1475
  text: chunk.markdown,
930
1476
  mentionUserIds: extractOutboundMentions(chunk.markdown),
931
1477
  roomMention: this.#config.allowRoomMentions && hasRoomMention(chunk.markdown),
932
1478
  });
1479
+ sentBody = typeof content.body === 'string' && content.body ? content.body : chunk.plain;
933
1480
  } catch {
934
1481
  this.#logger.warn?.('[dsh-im:matrix] Rich text could not be prepared; sending plain text');
935
1482
  if (!chunk.plain) continue;
936
1483
  content = { msgtype: 'm.text', body: chunk.plain };
1484
+ sentBody = chunk.plain;
937
1485
  }
938
1486
  applyMatrixRelations(content, { threadId, replyToEventId });
939
1487
  const result = await this.#sendRoomEvent(roomId, 'm.room.message', content, {
940
1488
  ...(options.signal ? { signal: options.signal } : {}),
941
1489
  });
942
1490
  if (typeof result?.event_id === 'string') providerMessageIds.push(result.event_id);
1491
+ // The send exits are the trusted record of what the room received: this
1492
+ // deployment's homeserver verifiably does not echo bot-authored events
1493
+ // back onto the timeline, so the room-visible store is fed here too and
1494
+ // coalesces with any echo a different server does deliver.
1495
+ if (options.recordRoom !== false) {
1496
+ this.#recordBotRoomMessage({ roomId, eventId: result?.event_id, text: sentBody, threadId, turnKey: this.#targetTurnKey(target) });
1497
+ }
943
1498
  }
944
1499
  return { providerMessageIds };
945
1500
  }
@@ -972,6 +1527,11 @@ export class MatrixRuntime {
972
1527
  };
973
1528
  }
974
1529
  await this.#sendRoomEvent(roomId, 'm.room.message', content);
1530
+ // Every edit frame revises the same entry, so the store ends holding the
1531
+ // final body members read in the room regardless of frame count. The turn
1532
+ // stamp rides the same target the stream opened under, so the placeholder
1533
+ // receipt and its final body share one provenance.
1534
+ this.#recordBotRoomMessage({ roomId, eventId: streamMessageId, text, threadId, edit: true, turnKey: this.#targetTurnKey(target) });
975
1535
  },
976
1536
  sendRemainder: async (chunk) => await this.#sendRoomText(target, chunk),
977
1537
  messageIdForResult: (result) => result?.providerMessageIds?.at(-1) ?? null,