@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.
- package/lib/client.js +4 -2
- package/lib/index.js +311 -306
- package/package.json +1 -1
- package/plugin-src/host/injected-context.mjs +13 -1
- package/src/channels/matrix/matrix-api.mjs +34 -0
- package/src/channels/matrix/matrix-config-store.mjs +50 -2
- package/src/channels/matrix/matrix-normalize.mjs +106 -14
- package/src/channels/matrix/matrix-room-history.mjs +240 -8
- package/src/channels/matrix/matrix-runtime.mjs +563 -3
- package/src/channels/shared/context-enhancement.mjs +2 -0
- package/src/channels/shared/conversation/ambient-provider.mjs +115 -0
- package/src/channels/shared/conversation/context-builder.mjs +148 -0
- package/src/channels/shared/conversation/reply-context.mjs +95 -0
- package/src/channels/shared/conversation/root-context.mjs +101 -0
- package/src/channels/shared/conversation/route.mjs +71 -0
- package/src/channels/shared/conversation/semantic-message.mjs +75 -0
- package/src/channels/shared/conversation/trigger-policy.mjs +80 -0
- package/src/channels/shared/conversation-state-store.mjs +1 -1
- package/src/channels/shared/harness-approval.mjs +19 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
- package/src/channels/shared/injected-context.mjs +64 -2
- package/src/channels/shared/speaker-label.mjs +46 -0
- package/src/channels/shared/text-harness-bridge.mjs +348 -32
- package/src/channels/wecom/wecom-cards.mjs +1 -1
|
@@ -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: {
|
|
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'
|
|
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,
|