@kodelyth/matrix 2026.5.42 → 2026.6.2

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 (205) hide show
  1. package/klaw.plugin.json +891 -3
  2. package/package.json +18 -6
  3. package/CHANGELOG.md +0 -321
  4. package/SPEC-SUPPORT.md +0 -116
  5. package/api.ts +0 -38
  6. package/auth-presence.ts +0 -56
  7. package/channel-plugin-api.ts +0 -3
  8. package/cli-metadata.ts +0 -11
  9. package/contract-api.ts +0 -17
  10. package/doctor-contract-api.ts +0 -1
  11. package/helper-api.ts +0 -3
  12. package/index.ts +0 -55
  13. package/plugin-entry.handlers.runtime.ts +0 -1
  14. package/runtime-api.ts +0 -72
  15. package/runtime-heavy-api.ts +0 -1
  16. package/runtime-setter-api.ts +0 -3
  17. package/secret-contract-api.ts +0 -5
  18. package/setup-entry.ts +0 -17
  19. package/setup-plugin-api.ts +0 -3
  20. package/src/account-selection.ts +0 -223
  21. package/src/actions.ts +0 -346
  22. package/src/approval-auth.ts +0 -25
  23. package/src/approval-handler.runtime.ts +0 -592
  24. package/src/approval-ids.ts +0 -6
  25. package/src/approval-native.ts +0 -345
  26. package/src/approval-reaction-auth.ts +0 -45
  27. package/src/approval-reactions.ts +0 -313
  28. package/src/auth-precedence.ts +0 -61
  29. package/src/channel-account-paths.ts +0 -97
  30. package/src/channel.runtime.ts +0 -17
  31. package/src/channel.setup.ts +0 -48
  32. package/src/channel.ts +0 -667
  33. package/src/cli-metadata.ts +0 -19
  34. package/src/cli.ts +0 -2298
  35. package/src/config-adapter.ts +0 -41
  36. package/src/config-schema.ts +0 -159
  37. package/src/config-ui-hints.ts +0 -56
  38. package/src/directory-live.ts +0 -238
  39. package/src/doctor-contract.ts +0 -287
  40. package/src/doctor.ts +0 -262
  41. package/src/env-vars.ts +0 -92
  42. package/src/exec-approval-resolver.ts +0 -23
  43. package/src/exec-approvals.ts +0 -287
  44. package/src/group-mentions.ts +0 -41
  45. package/src/legacy-crypto-inspector-availability.ts +0 -60
  46. package/src/legacy-crypto.ts +0 -531
  47. package/src/legacy-state.ts +0 -156
  48. package/src/matrix/account-config.ts +0 -175
  49. package/src/matrix/accounts.ts +0 -194
  50. package/src/matrix/actions/client.ts +0 -31
  51. package/src/matrix/actions/devices.ts +0 -34
  52. package/src/matrix/actions/limits.ts +0 -6
  53. package/src/matrix/actions/messages.ts +0 -129
  54. package/src/matrix/actions/pins.ts +0 -63
  55. package/src/matrix/actions/polls.ts +0 -109
  56. package/src/matrix/actions/profile.ts +0 -37
  57. package/src/matrix/actions/reactions.ts +0 -59
  58. package/src/matrix/actions/room.ts +0 -71
  59. package/src/matrix/actions/summary.ts +0 -88
  60. package/src/matrix/actions/types.ts +0 -63
  61. package/src/matrix/actions/verification.ts +0 -589
  62. package/src/matrix/actions.ts +0 -37
  63. package/src/matrix/active-client.ts +0 -26
  64. package/src/matrix/async-lock.ts +0 -18
  65. package/src/matrix/backup-health.ts +0 -124
  66. package/src/matrix/client/config-runtime-api.ts +0 -9
  67. package/src/matrix/client/config-secret-input.runtime.ts +0 -1
  68. package/src/matrix/client/config.ts +0 -853
  69. package/src/matrix/client/create-client.ts +0 -105
  70. package/src/matrix/client/env-auth.ts +0 -95
  71. package/src/matrix/client/file-sync-store.ts +0 -289
  72. package/src/matrix/client/logging.ts +0 -140
  73. package/src/matrix/client/migration-snapshot.runtime.ts +0 -1
  74. package/src/matrix/client/private-network-host.ts +0 -1
  75. package/src/matrix/client/runtime.ts +0 -4
  76. package/src/matrix/client/shared.ts +0 -316
  77. package/src/matrix/client/storage.ts +0 -543
  78. package/src/matrix/client/types.ts +0 -50
  79. package/src/matrix/client/url-validation.ts +0 -73
  80. package/src/matrix/client-bootstrap.ts +0 -173
  81. package/src/matrix/client.ts +0 -23
  82. package/src/matrix/config-paths.ts +0 -31
  83. package/src/matrix/config-update.ts +0 -292
  84. package/src/matrix/credentials-read.ts +0 -208
  85. package/src/matrix/credentials-write.runtime.ts +0 -35
  86. package/src/matrix/credentials.ts +0 -95
  87. package/src/matrix/deps.ts +0 -309
  88. package/src/matrix/device-health.ts +0 -29
  89. package/src/matrix/direct-management.ts +0 -349
  90. package/src/matrix/direct-room.ts +0 -128
  91. package/src/matrix/draft-stream.ts +0 -225
  92. package/src/matrix/encryption-guidance.ts +0 -24
  93. package/src/matrix/errors.ts +0 -21
  94. package/src/matrix/format.ts +0 -426
  95. package/src/matrix/legacy-crypto-inspector.ts +0 -95
  96. package/src/matrix/media-errors.ts +0 -20
  97. package/src/matrix/media-text.ts +0 -162
  98. package/src/matrix/monitor/access-state.ts +0 -145
  99. package/src/matrix/monitor/ack-config.ts +0 -27
  100. package/src/matrix/monitor/allowlist.ts +0 -89
  101. package/src/matrix/monitor/auto-join.ts +0 -86
  102. package/src/matrix/monitor/config.ts +0 -569
  103. package/src/matrix/monitor/context-summary.ts +0 -43
  104. package/src/matrix/monitor/direct.ts +0 -296
  105. package/src/matrix/monitor/events.ts +0 -397
  106. package/src/matrix/monitor/handler.ts +0 -2266
  107. package/src/matrix/monitor/inbound-dedupe.ts +0 -267
  108. package/src/matrix/monitor/index.ts +0 -540
  109. package/src/matrix/monitor/legacy-crypto-restore.ts +0 -139
  110. package/src/matrix/monitor/location.ts +0 -108
  111. package/src/matrix/monitor/media.ts +0 -119
  112. package/src/matrix/monitor/mentions.ts +0 -256
  113. package/src/matrix/monitor/reaction-events.ts +0 -197
  114. package/src/matrix/monitor/recent-invite.ts +0 -30
  115. package/src/matrix/monitor/replies.ts +0 -136
  116. package/src/matrix/monitor/reply-context.ts +0 -92
  117. package/src/matrix/monitor/room-history.ts +0 -301
  118. package/src/matrix/monitor/room-info.ts +0 -126
  119. package/src/matrix/monitor/rooms.ts +0 -52
  120. package/src/matrix/monitor/route.ts +0 -179
  121. package/src/matrix/monitor/runtime-api.ts +0 -28
  122. package/src/matrix/monitor/startup-verification.ts +0 -237
  123. package/src/matrix/monitor/startup.ts +0 -218
  124. package/src/matrix/monitor/status.ts +0 -120
  125. package/src/matrix/monitor/sync-lifecycle.ts +0 -91
  126. package/src/matrix/monitor/task-runner.ts +0 -38
  127. package/src/matrix/monitor/test-events.ts +0 -21
  128. package/src/matrix/monitor/thread-context.ts +0 -108
  129. package/src/matrix/monitor/threads.ts +0 -85
  130. package/src/matrix/monitor/types.ts +0 -30
  131. package/src/matrix/monitor/verification-events.ts +0 -643
  132. package/src/matrix/monitor/verification-utils.ts +0 -46
  133. package/src/matrix/outbound-media-runtime.ts +0 -1
  134. package/src/matrix/poll-summary.ts +0 -110
  135. package/src/matrix/poll-types.ts +0 -429
  136. package/src/matrix/probe.runtime.ts +0 -4
  137. package/src/matrix/probe.ts +0 -97
  138. package/src/matrix/profile.ts +0 -184
  139. package/src/matrix/reaction-common.ts +0 -147
  140. package/src/matrix/sdk/crypto-bootstrap.ts +0 -438
  141. package/src/matrix/sdk/crypto-facade.ts +0 -242
  142. package/src/matrix/sdk/crypto-node.runtime.ts +0 -17
  143. package/src/matrix/sdk/crypto-runtime.ts +0 -14
  144. package/src/matrix/sdk/decrypt-bridge.ts +0 -410
  145. package/src/matrix/sdk/event-helpers.ts +0 -83
  146. package/src/matrix/sdk/http-client.ts +0 -87
  147. package/src/matrix/sdk/idb-persistence-lock.ts +0 -51
  148. package/src/matrix/sdk/idb-persistence.ts +0 -288
  149. package/src/matrix/sdk/logger.ts +0 -108
  150. package/src/matrix/sdk/read-response-with-limit.ts +0 -19
  151. package/src/matrix/sdk/recovery-key-store.ts +0 -453
  152. package/src/matrix/sdk/timeout-abort-signal.ts +0 -1
  153. package/src/matrix/sdk/transport-runtime-api.ts +0 -18
  154. package/src/matrix/sdk/transport.ts +0 -352
  155. package/src/matrix/sdk/types.ts +0 -245
  156. package/src/matrix/sdk/verification-manager.ts +0 -795
  157. package/src/matrix/sdk/verification-status.ts +0 -23
  158. package/src/matrix/sdk.ts +0 -2152
  159. package/src/matrix/send/client.ts +0 -93
  160. package/src/matrix/send/formatting.ts +0 -189
  161. package/src/matrix/send/media.ts +0 -244
  162. package/src/matrix/send/targets.ts +0 -104
  163. package/src/matrix/send/types.ts +0 -131
  164. package/src/matrix/send.ts +0 -660
  165. package/src/matrix/session-store-metadata.ts +0 -108
  166. package/src/matrix/startup-abort.ts +0 -44
  167. package/src/matrix/subagent-hooks.ts +0 -308
  168. package/src/matrix/sync-state.ts +0 -27
  169. package/src/matrix/target-ids.ts +0 -79
  170. package/src/matrix/thread-bindings-shared.ts +0 -206
  171. package/src/matrix/thread-bindings.ts +0 -580
  172. package/src/matrix-migration.runtime.ts +0 -9
  173. package/src/migration-config.ts +0 -243
  174. package/src/migration-snapshot-backup.ts +0 -116
  175. package/src/migration-snapshot.ts +0 -53
  176. package/src/onboarding.ts +0 -775
  177. package/src/outbound.ts +0 -248
  178. package/src/plugin-entry.runtime.js +0 -115
  179. package/src/plugin-entry.runtime.ts +0 -70
  180. package/src/profile-update.ts +0 -71
  181. package/src/record-shared.ts +0 -3
  182. package/src/resolve-targets.ts +0 -175
  183. package/src/resolver.runtime.ts +0 -5
  184. package/src/resolver.ts +0 -21
  185. package/src/runtime-api.ts +0 -106
  186. package/src/runtime.ts +0 -13
  187. package/src/secret-contract.ts +0 -174
  188. package/src/session-route.ts +0 -126
  189. package/src/setup-bootstrap.ts +0 -102
  190. package/src/setup-config.ts +0 -222
  191. package/src/setup-contract.ts +0 -90
  192. package/src/setup-core.ts +0 -146
  193. package/src/setup-dm-policy.ts +0 -15
  194. package/src/setup-surface.ts +0 -4
  195. package/src/startup-maintenance.ts +0 -114
  196. package/src/storage-paths.ts +0 -92
  197. package/src/thread-binding-api.ts +0 -23
  198. package/src/tool-actions.runtime.ts +0 -1
  199. package/src/tool-actions.ts +0 -498
  200. package/src/types.ts +0 -257
  201. package/subagent-hooks-api.ts +0 -31
  202. package/test-api.ts +0 -21
  203. package/thread-binding-api.ts +0 -4
  204. package/thread-bindings-runtime.ts +0 -4
  205. package/tsconfig.json +0 -16
@@ -1,2266 +0,0 @@
1
- import {
2
- createPreviewMessageReceipt,
3
- defineFinalizableLivePreviewAdapter,
4
- deliverWithFinalizableLivePreviewAdapter,
5
- type MessageReceipt,
6
- } from "klaw/plugin-sdk/channel-message";
7
- import {
8
- buildChannelProgressDraftLineForEntry,
9
- createChannelProgressDraftGate,
10
- type ChannelProgressDraftLine,
11
- formatChannelProgressDraftLine,
12
- formatChannelProgressDraftLineForEntry,
13
- formatChannelProgressDraftText,
14
- isChannelProgressDraftWorkToolName,
15
- mergeChannelProgressDraftLine,
16
- normalizeChannelProgressDraftLineIdentity,
17
- resolveChannelProgressDraftMaxLines,
18
- } from "klaw/plugin-sdk/channel-streaming";
19
- import {
20
- evaluateSupplementalContextVisibility,
21
- resolveChannelContextVisibilityMode,
22
- } from "klaw/plugin-sdk/context-visibility-runtime";
23
- import { isDangerousNameMatchingEnabled } from "klaw/plugin-sdk/dangerous-name-runtime";
24
- import { hasFinalInboundReplyDispatch } from "klaw/plugin-sdk/inbound-reply-dispatch";
25
- import type { ChannelBotLoopProtectionFacts } from "klaw/plugin-sdk/inbound-reply-dispatch";
26
- import { mergePairLoopGuardConfig } from "klaw/plugin-sdk/pair-loop-guard-runtime";
27
- import { buildInboundHistoryFromEntries } from "klaw/plugin-sdk/reply-history";
28
- import {
29
- buildTtsSupplementMediaPayload,
30
- getReplyPayloadTtsSupplement,
31
- } from "klaw/plugin-sdk/reply-payload";
32
- import type { GetReplyOptions } from "klaw/plugin-sdk/reply-runtime";
33
- import { resolveInboundLastRouteSessionKey } from "klaw/plugin-sdk/routing";
34
- import { resolvePinnedMainDmOwnerFromAllowlist } from "klaw/plugin-sdk/security-runtime";
35
- import { loadSessionStore, resolveSessionStoreEntry } from "klaw/plugin-sdk/session-store-runtime";
36
- import { normalizeOptionalString } from "klaw/plugin-sdk/string-coerce-runtime";
37
- import type {
38
- CoreConfig,
39
- MatrixConfig,
40
- MatrixRoomConfig,
41
- MatrixStreamingMode,
42
- ReplyToMode,
43
- } from "../../types.js";
44
- import {
45
- resolveMatrixAccountAllowlistConfig,
46
- resolveMatrixAccountConfig,
47
- } from "../account-config.js";
48
- import { formatMatrixErrorMessage } from "../errors.js";
49
- import { isMatrixMediaSizeLimitError } from "../media-errors.js";
50
- import {
51
- formatMatrixMediaTooLargeText,
52
- formatMatrixMediaUnavailableText,
53
- formatMatrixMessageText,
54
- resolveMatrixMessageAttachment,
55
- resolveMatrixMessageBody,
56
- } from "../media-text.js";
57
- import { fetchMatrixPollSnapshot, type MatrixPollSnapshot } from "../poll-summary.js";
58
- import {
59
- formatPollAsText,
60
- isPollEventType,
61
- isPollStartType,
62
- parsePollStartContent,
63
- } from "../poll-types.js";
64
- import type { LocationMessageEventContent, MatrixClient } from "../sdk.js";
65
- import { MATRIX_KLAW_FINALIZED_PREVIEW_KEY } from "../send/types.js";
66
- import { resolveMatrixStoredSessionMeta } from "../session-store-metadata.js";
67
- import {
68
- resolveMatrixMonitorAccessState,
69
- resolveMatrixMonitorCommandAccess,
70
- } from "./access-state.js";
71
- import { resolveMatrixAckReactionConfig } from "./ack-config.js";
72
- import { normalizeMatrixUserId, resolveMatrixAllowListMatch } from "./allowlist.js";
73
- import {
74
- resolveMatrixMonitorLiveUserAllowlist,
75
- type MatrixResolvedAllowlistEntry,
76
- } from "./config.js";
77
- import type { MatrixInboundEventDeduper } from "./inbound-dedupe.js";
78
- import { resolveMatrixLocation, type MatrixLocationPayload } from "./location.js";
79
- import { downloadMatrixMedia } from "./media.js";
80
- import { resolveMentions, stripMatrixMentionPrefix } from "./mentions.js";
81
- import { deliverMatrixReplies } from "./replies.js";
82
- import { createMatrixReplyContextResolver } from "./reply-context.js";
83
- import { createRoomHistoryTracker } from "./room-history.js";
84
- import type { HistoryEntry } from "./room-history.js";
85
- import { resolveMatrixRoomConfig } from "./rooms.js";
86
- import { resolveMatrixInboundRoute } from "./route.js";
87
- import {
88
- createReplyPrefixOptions,
89
- createTypingCallbacks,
90
- getAgentScopedMediaLocalRoots,
91
- logInboundDrop,
92
- logTypingFailure,
93
- type BlockReplyContext,
94
- type PluginRuntime,
95
- type ReplyPayload,
96
- type RuntimeEnv,
97
- type RuntimeLogger,
98
- } from "./runtime-api.js";
99
- import { createMatrixThreadContextResolver } from "./thread-context.js";
100
- import {
101
- resolveMatrixReplyToEventId,
102
- resolveMatrixThreadRootId,
103
- resolveMatrixThreadRouting,
104
- } from "./threads.js";
105
- import type { MatrixRawEvent, RoomMessageEventContent } from "./types.js";
106
- import { EventType, RelationType } from "./types.js";
107
- import { isMatrixVerificationRoomMessage } from "./verification-utils.js";
108
-
109
- const ALLOW_FROM_STORE_CACHE_TTL_MS = 30_000;
110
- const PAIRING_REPLY_COOLDOWN_MS = 5 * 60_000;
111
- const MATRIX_TOOL_PROGRESS_MAX_CHARS = 300;
112
- let matrixSendModulePromise: Promise<typeof import("../send.js")> | undefined;
113
- let acpBindingRuntimePromise:
114
- | Promise<typeof import("klaw/plugin-sdk/acp-binding-runtime")>
115
- | undefined;
116
- let sessionBindingRuntimePromise:
117
- | Promise<typeof import("klaw/plugin-sdk/session-binding-runtime")>
118
- | undefined;
119
- let matrixReactionEventsPromise: Promise<typeof import("./reaction-events.js")> | undefined;
120
- let matrixDraftStreamPromise: Promise<typeof import("../draft-stream.js")> | undefined;
121
-
122
- function loadMatrixSendModule(): Promise<typeof import("../send.js")> {
123
- matrixSendModulePromise ??= import("../send.js");
124
- return matrixSendModulePromise;
125
- }
126
-
127
- function loadAcpBindingRuntime(): Promise<typeof import("klaw/plugin-sdk/acp-binding-runtime")> {
128
- acpBindingRuntimePromise ??= import("klaw/plugin-sdk/acp-binding-runtime");
129
- return acpBindingRuntimePromise;
130
- }
131
-
132
- function loadSessionBindingRuntime(): Promise<
133
- typeof import("klaw/plugin-sdk/session-binding-runtime")
134
- > {
135
- sessionBindingRuntimePromise ??= import("klaw/plugin-sdk/session-binding-runtime");
136
- return sessionBindingRuntimePromise;
137
- }
138
-
139
- function loadMatrixReactionEvents(): Promise<typeof import("./reaction-events.js")> {
140
- matrixReactionEventsPromise ??= import("./reaction-events.js");
141
- return matrixReactionEventsPromise;
142
- }
143
-
144
- function loadMatrixDraftStream(): Promise<typeof import("../draft-stream.js")> {
145
- matrixDraftStreamPromise ??= import("../draft-stream.js");
146
- return matrixDraftStreamPromise;
147
- }
148
-
149
- const MAX_TRACKED_PAIRING_REPLY_SENDERS = 512;
150
- const MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES = 512;
151
- type MatrixAllowBotsMode = "off" | "mentions" | "all";
152
- type MatrixDraftStreamHandle = {
153
- update: (text: string) => void;
154
- stop: () => Promise<string | undefined>;
155
- discardPending: () => Promise<void>;
156
- eventId: () => string | undefined;
157
- mustDeliverFinalNormally: () => boolean;
158
- matchesPreparedText: (text: string) => boolean;
159
- finalizeLive: () => Promise<boolean>;
160
- reset: () => void;
161
- };
162
-
163
- export class MatrixRetryableInboundError extends Error {
164
- constructor(message: string, options?: ErrorOptions) {
165
- super(message, options);
166
- this.name = "MatrixRetryableInboundError";
167
- }
168
- }
169
-
170
- async function redactMatrixDraftEvent(
171
- client: MatrixClient,
172
- roomId: string,
173
- draftEventId: string,
174
- ): Promise<void> {
175
- await client.redactEvent(roomId, draftEventId).catch(() => {});
176
- }
177
-
178
- function buildMatrixFinalizedPreviewContent(): Record<string, unknown> {
179
- return { [MATRIX_KLAW_FINALIZED_PREVIEW_KEY]: true };
180
- }
181
-
182
- export type MatrixMonitorHandlerParams = {
183
- client: MatrixClient;
184
- core: PluginRuntime;
185
- cfg: CoreConfig;
186
- accountId: string;
187
- accountConfig?: MatrixConfig;
188
- runtime: RuntimeEnv;
189
- logger: RuntimeLogger;
190
- logVerboseMessage: (message: string) => void;
191
- allowFrom: string[];
192
- allowFromResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
193
- groupAllowFrom?: string[];
194
- groupAllowFromResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
195
- roomsConfig?: Record<string, MatrixRoomConfig>;
196
- accountAllowBots?: boolean | "mentions";
197
- configuredBotUserIds?: ReadonlySet<string>;
198
- groupPolicy: "open" | "allowlist" | "disabled";
199
- replyToMode: ReplyToMode;
200
- threadReplies: "off" | "inbound" | "always";
201
- /** DM-specific threadReplies override. Falls back to threadReplies when absent. */
202
- dmThreadReplies?: "off" | "inbound" | "always";
203
- /** DM session grouping behavior. */
204
- dmSessionScope?: "per-user" | "per-room";
205
- streaming: MatrixStreamingMode;
206
- previewToolProgressEnabled: boolean;
207
- blockStreamingEnabled: boolean;
208
- dmEnabled: boolean;
209
- dmPolicy: "open" | "pairing" | "allowlist" | "disabled";
210
- textLimit: number;
211
- mediaMaxBytes: number;
212
- historyLimit: number;
213
- startupMs: number;
214
- startupGraceMs: number;
215
- dropPreStartupMessages: boolean;
216
- inboundDeduper?: Pick<MatrixInboundEventDeduper, "claimEvent" | "commitEvent" | "releaseEvent">;
217
- directTracker: {
218
- isDirectMessage: (params: {
219
- roomId: string;
220
- senderId: string;
221
- selfUserId: string;
222
- }) => Promise<boolean>;
223
- };
224
- getRoomInfo: (
225
- roomId: string,
226
- opts?: { includeAliases?: boolean },
227
- ) => Promise<{ name?: string; canonicalAlias?: string; altAliases: string[] }>;
228
- getMemberDisplayName: (roomId: string, userId: string) => Promise<string>;
229
- needsRoomAliasesForConfig: boolean;
230
- resolveLiveUserAllowlist?: typeof resolveMatrixMonitorLiveUserAllowlist;
231
- };
232
-
233
- function resolveMatrixMentionPrecheckText(params: {
234
- eventType: string;
235
- content: RoomMessageEventContent;
236
- locationText?: string | null;
237
- }): string {
238
- if (params.locationText?.trim()) {
239
- return params.locationText.trim();
240
- }
241
- if (typeof params.content.body === "string" && params.content.body.trim()) {
242
- return params.content.body.trim();
243
- }
244
- if (isPollStartType(params.eventType)) {
245
- const parsed = parsePollStartContent(params.content as never);
246
- if (parsed) {
247
- return formatPollAsText(parsed);
248
- }
249
- }
250
- return "";
251
- }
252
-
253
- function hasBundledMatrixReplacementRelation(event: MatrixRawEvent) {
254
- const relations = event.unsigned?.["m.relations"];
255
- if (!relations || typeof relations !== "object") {
256
- return false;
257
- }
258
- return relations[RelationType.Replace] !== undefined;
259
- }
260
-
261
- function resolveMatrixInboundBodyText(params: {
262
- rawBody: string;
263
- filename?: string;
264
- mediaPlaceholder?: string;
265
- msgtype?: string;
266
- hadMediaUrl: boolean;
267
- mediaDownloadFailed: boolean;
268
- mediaSizeLimitExceeded?: boolean;
269
- }): string {
270
- if (params.mediaPlaceholder) {
271
- return params.rawBody || params.mediaPlaceholder;
272
- }
273
- if (!params.mediaDownloadFailed || !params.hadMediaUrl) {
274
- return params.rawBody;
275
- }
276
- if (params.mediaSizeLimitExceeded) {
277
- return formatMatrixMediaTooLargeText({
278
- body: params.rawBody,
279
- filename: params.filename,
280
- msgtype: params.msgtype,
281
- });
282
- }
283
- return formatMatrixMediaUnavailableText({
284
- body: params.rawBody,
285
- filename: params.filename,
286
- msgtype: params.msgtype,
287
- });
288
- }
289
-
290
- function markTrackedRoomIfFirst(set: Set<string>, roomId: string): boolean {
291
- if (set.has(roomId)) {
292
- return false;
293
- }
294
- set.add(roomId);
295
- if (set.size > MAX_TRACKED_SHARED_DM_CONTEXT_NOTICES) {
296
- const oldest = set.keys().next().value;
297
- if (typeof oldest === "string") {
298
- set.delete(oldest);
299
- }
300
- }
301
- return true;
302
- }
303
-
304
- function resolveMatrixSharedDmContextNotice(params: {
305
- storePath: string;
306
- sessionKey: string;
307
- roomId: string;
308
- accountId: string;
309
- dmSessionScope?: "per-user" | "per-room";
310
- sentRooms: Set<string>;
311
- logVerboseMessage: (message: string) => void;
312
- }): string | null {
313
- if ((params.dmSessionScope ?? "per-user") === "per-room") {
314
- return null;
315
- }
316
- if (params.sentRooms.has(params.roomId)) {
317
- return null;
318
- }
319
-
320
- try {
321
- const store = loadSessionStore(params.storePath);
322
- const currentSession = resolveMatrixStoredSessionMeta(
323
- resolveSessionStoreEntry({
324
- store,
325
- sessionKey: params.sessionKey,
326
- }).existing,
327
- );
328
- if (!currentSession) {
329
- return null;
330
- }
331
- if (currentSession.channel && currentSession.channel !== "matrix") {
332
- return null;
333
- }
334
- if (currentSession.accountId && currentSession.accountId !== params.accountId) {
335
- return null;
336
- }
337
- if (!currentSession.directUserId) {
338
- return null;
339
- }
340
- if (!currentSession.roomId || currentSession.roomId === params.roomId) {
341
- return null;
342
- }
343
-
344
- return [
345
- "This Matrix DM is sharing a session with another Matrix DM room.",
346
- "Use /focus here for a one-off isolated thread session when thread bindings are enabled, or set",
347
- "channels.matrix.dm.sessionScope to per-room to isolate each Matrix DM room.",
348
- ].join(" ");
349
- } catch (err) {
350
- params.logVerboseMessage(
351
- `matrix: failed checking shared DM session notice room=${params.roomId} (${String(err)})`,
352
- );
353
- return null;
354
- }
355
- }
356
-
357
- function resolveMatrixPendingHistoryText(params: {
358
- mentionPrecheckText: string;
359
- content: RoomMessageEventContent;
360
- mediaUrl?: string;
361
- }): string {
362
- if (params.mentionPrecheckText) {
363
- return params.mentionPrecheckText;
364
- }
365
- if (!params.mediaUrl) {
366
- return "";
367
- }
368
- const body = typeof params.content.body === "string" ? params.content.body.trim() : undefined;
369
- const filename =
370
- typeof params.content.filename === "string" ? params.content.filename.trim() : undefined;
371
- const msgtype = typeof params.content.msgtype === "string" ? params.content.msgtype : undefined;
372
- return (
373
- formatMatrixMessageText({
374
- body: resolveMatrixMessageBody({ body, filename, msgtype }),
375
- attachment: resolveMatrixMessageAttachment({ body, filename, msgtype }),
376
- }) ?? ""
377
- );
378
- }
379
-
380
- function resolveMatrixAllowBotsMode(value?: boolean | "mentions"): MatrixAllowBotsMode {
381
- if (value === true) {
382
- return "all";
383
- }
384
- if (value === "mentions") {
385
- return "mentions";
386
- }
387
- return "off";
388
- }
389
-
390
- function formatMatrixToolProgressMarkdownCode(text: string): string {
391
- const clipped =
392
- text.length <= MATRIX_TOOL_PROGRESS_MAX_CHARS
393
- ? text
394
- : `${text.slice(0, MATRIX_TOOL_PROGRESS_MAX_CHARS - 1).trimEnd()}...`;
395
- const safe = clipped.replaceAll("`", "'");
396
- return `\`${safe}\``;
397
- }
398
-
399
- export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParams) {
400
- const {
401
- client,
402
- core,
403
- cfg,
404
- accountId,
405
- accountConfig,
406
- runtime,
407
- logger,
408
- logVerboseMessage,
409
- allowFromResolvedEntries = [],
410
- groupAllowFromResolvedEntries = [],
411
- roomsConfig,
412
- accountAllowBots,
413
- configuredBotUserIds = new Set<string>(),
414
- groupPolicy,
415
- replyToMode,
416
- threadReplies,
417
- dmThreadReplies,
418
- dmSessionScope,
419
- streaming,
420
- previewToolProgressEnabled,
421
- blockStreamingEnabled,
422
- dmEnabled,
423
- dmPolicy,
424
- textLimit,
425
- mediaMaxBytes,
426
- historyLimit,
427
- startupMs,
428
- startupGraceMs,
429
- dropPreStartupMessages,
430
- inboundDeduper,
431
- directTracker,
432
- getRoomInfo,
433
- getMemberDisplayName,
434
- needsRoomAliasesForConfig,
435
- resolveLiveUserAllowlist = resolveMatrixMonitorLiveUserAllowlist,
436
- } = params;
437
- const contextVisibilityMode = resolveChannelContextVisibilityMode({
438
- cfg,
439
- channel: "matrix",
440
- accountId,
441
- });
442
- let cachedStoreAllowFrom: {
443
- value: string[];
444
- expiresAtMs: number;
445
- } | null = null;
446
- type LiveAllowlistCacheEntry = { signature: string; entries: string[] };
447
- let liveDmAllowlistCache: LiveAllowlistCacheEntry | null = null;
448
- let liveGroupAllowlistCache: LiveAllowlistCacheEntry | null = null;
449
- const resolveCachedLiveAllowlist = async (params: {
450
- cfg: CoreConfig;
451
- entries?: ReadonlyArray<string | number>;
452
- failClosedOnUnresolved?: boolean;
453
- startupResolvedEntries?: readonly MatrixResolvedAllowlistEntry[];
454
- cache: LiveAllowlistCacheEntry | null;
455
- updateCache: (next: LiveAllowlistCacheEntry) => void;
456
- }): Promise<string[]> => {
457
- const accountConfig = resolveMatrixAccountConfig({ cfg: params.cfg, accountId });
458
- const signature = JSON.stringify({
459
- entries: (params.entries ?? []).map((entry) => String(entry).trim()),
460
- failClosedOnUnresolved: params.failClosedOnUnresolved === true,
461
- dangerouslyAllowNameMatching: isDangerousNameMatchingEnabled(accountConfig),
462
- });
463
- if (params.cache?.signature === signature) {
464
- return params.cache.entries;
465
- }
466
- const entries = await resolveLiveUserAllowlist({
467
- cfg: params.cfg,
468
- accountId,
469
- entries: params.entries,
470
- failClosedOnUnresolved: params.failClosedOnUnresolved,
471
- startupResolvedEntries: params.startupResolvedEntries,
472
- runtime,
473
- });
474
- const next = { signature, entries };
475
- params.updateCache(next);
476
- return entries;
477
- };
478
- const pairingReplySentAtMsBySender = new Map<string, number>();
479
- const resolveThreadContext = createMatrixThreadContextResolver({
480
- client,
481
- getMemberDisplayName,
482
- logVerboseMessage,
483
- });
484
- const resolveReplyContext = createMatrixReplyContextResolver({
485
- client,
486
- getMemberDisplayName,
487
- logVerboseMessage,
488
- });
489
- const roomHistoryTracker = createRoomHistoryTracker();
490
- const roomIngressTails = new Map<string, Promise<void>>();
491
- const sharedDmContextNoticeRooms = new Set<string>();
492
-
493
- const readStoreAllowFrom = async (): Promise<string[]> => {
494
- const now = Date.now();
495
- if (cachedStoreAllowFrom && now < cachedStoreAllowFrom.expiresAtMs) {
496
- return cachedStoreAllowFrom.value;
497
- }
498
- const value = await core.channel.pairing
499
- .readAllowFromStore({
500
- channel: "matrix",
501
- env: process.env,
502
- accountId,
503
- })
504
- .catch(() => []);
505
- cachedStoreAllowFrom = {
506
- value,
507
- expiresAtMs: now + ALLOW_FROM_STORE_CACHE_TTL_MS,
508
- };
509
- return value;
510
- };
511
-
512
- const shouldSendPairingReply = (senderId: string, created: boolean): boolean => {
513
- const now = Date.now();
514
- if (created) {
515
- pairingReplySentAtMsBySender.set(senderId, now);
516
- return true;
517
- }
518
- const lastSentAtMs = pairingReplySentAtMsBySender.get(senderId);
519
- if (typeof lastSentAtMs === "number" && now - lastSentAtMs < PAIRING_REPLY_COOLDOWN_MS) {
520
- return false;
521
- }
522
- pairingReplySentAtMsBySender.set(senderId, now);
523
- if (pairingReplySentAtMsBySender.size > MAX_TRACKED_PAIRING_REPLY_SENDERS) {
524
- const oldestSender = pairingReplySentAtMsBySender.keys().next().value;
525
- if (typeof oldestSender === "string") {
526
- pairingReplySentAtMsBySender.delete(oldestSender);
527
- }
528
- }
529
- return true;
530
- };
531
-
532
- const runRoomIngress = async <T>(roomId: string, task: () => Promise<T>): Promise<T> => {
533
- const previous = roomIngressTails.get(roomId) ?? Promise.resolve();
534
- let releaseCurrent!: () => void;
535
- const current = new Promise<void>((resolve) => {
536
- releaseCurrent = resolve;
537
- });
538
- const chain = previous.catch(() => {}).then(() => current);
539
- roomIngressTails.set(roomId, chain);
540
- await previous.catch(() => {});
541
- try {
542
- return await task();
543
- } finally {
544
- releaseCurrent();
545
- if (roomIngressTails.get(roomId) === chain) {
546
- roomIngressTails.delete(roomId);
547
- }
548
- }
549
- };
550
-
551
- return async (roomId: string, event: MatrixRawEvent) => {
552
- const eventId = typeof event.event_id === "string" ? event.event_id.trim() : "";
553
- let claimedInboundEvent = false;
554
- let draftStreamRef: MatrixDraftStreamHandle | undefined;
555
- let draftConsumed = false;
556
- try {
557
- const eventType = event.type;
558
- if (eventType === EventType.RoomMessageEncrypted) {
559
- // Encrypted payloads are emitted separately after decryption.
560
- return;
561
- }
562
-
563
- const isPollEvent = isPollEventType(eventType);
564
- const isReactionEvent = eventType === EventType.Reaction;
565
- const locationContent = event.content as LocationMessageEventContent;
566
- const isLocationEvent =
567
- eventType === EventType.Location ||
568
- (eventType === EventType.RoomMessage && locationContent.msgtype === EventType.Location);
569
- if (
570
- eventType !== EventType.RoomMessage &&
571
- !isPollEvent &&
572
- !isLocationEvent &&
573
- !isReactionEvent
574
- ) {
575
- return;
576
- }
577
- logVerboseMessage(
578
- `matrix: inbound event room=${roomId} type=${eventType} id=${event.event_id ?? "unknown"}`,
579
- );
580
- if (event.unsigned?.redacted_because) {
581
- return;
582
- }
583
- const senderId = event.sender;
584
- if (!senderId) {
585
- return;
586
- }
587
- const eventTs = event.origin_server_ts;
588
- const eventAge = event.unsigned?.age;
589
- const commitInboundEventIfClaimed = async () => {
590
- if (!claimedInboundEvent || !inboundDeduper || !eventId) {
591
- return;
592
- }
593
- await inboundDeduper.commitEvent({ roomId, eventId });
594
- claimedInboundEvent = false;
595
- };
596
- const readIngressPrefix = async () => {
597
- const selfUserId = await client.getUserId();
598
- if (senderId === selfUserId) {
599
- return undefined;
600
- }
601
- if (dropPreStartupMessages) {
602
- if (typeof eventTs === "number" && eventTs < startupMs - startupGraceMs) {
603
- return undefined;
604
- }
605
- if (
606
- typeof eventTs !== "number" &&
607
- typeof eventAge === "number" &&
608
- eventAge > startupGraceMs
609
- ) {
610
- return undefined;
611
- }
612
- }
613
-
614
- let content = event.content as RoomMessageEventContent;
615
-
616
- if (
617
- eventType === EventType.RoomMessage &&
618
- isMatrixVerificationRoomMessage({
619
- msgtype: (content as { msgtype?: unknown }).msgtype,
620
- body: content.body,
621
- })
622
- ) {
623
- logVerboseMessage(`matrix: skip verification/system room message room=${roomId}`);
624
- return undefined;
625
- }
626
-
627
- const locationPayload: MatrixLocationPayload | null = resolveMatrixLocation({
628
- eventType,
629
- content: content as LocationMessageEventContent,
630
- });
631
-
632
- const relates = content["m.relates_to"];
633
- if (relates && "rel_type" in relates && relates.rel_type === RelationType.Replace) {
634
- return undefined;
635
- }
636
- if (hasBundledMatrixReplacementRelation(event)) {
637
- return undefined;
638
- }
639
- if (eventId && inboundDeduper) {
640
- claimedInboundEvent = inboundDeduper.claimEvent({ roomId, eventId });
641
- if (!claimedInboundEvent) {
642
- logVerboseMessage(`matrix: skip duplicate inbound event room=${roomId} id=${eventId}`);
643
- return undefined;
644
- }
645
- }
646
-
647
- const isDirectMessage = await directTracker.isDirectMessage({
648
- roomId,
649
- senderId,
650
- selfUserId,
651
- });
652
- return { content, isDirectMessage, locationPayload, selfUserId };
653
- };
654
- const continueIngress = async (params: {
655
- content: RoomMessageEventContent;
656
- isDirectMessage: boolean;
657
- locationPayload: MatrixLocationPayload | null;
658
- selfUserId: string;
659
- }) => {
660
- let content = params.content;
661
- const isDirectMessage = params.isDirectMessage;
662
- const isRoom = !isDirectMessage;
663
- const { locationPayload, selfUserId } = params;
664
- if (isRoom && groupPolicy === "disabled") {
665
- await commitInboundEventIfClaimed();
666
- return undefined;
667
- }
668
-
669
- const roomInfoForConfig =
670
- isRoom && needsRoomAliasesForConfig
671
- ? await getRoomInfo(roomId, { includeAliases: true })
672
- : undefined;
673
- const roomAliasesForConfig = roomInfoForConfig
674
- ? [roomInfoForConfig.canonicalAlias ?? "", ...roomInfoForConfig.altAliases].filter(
675
- Boolean,
676
- )
677
- : [];
678
- const roomConfigInfo = isRoom
679
- ? resolveMatrixRoomConfig({
680
- rooms: roomsConfig,
681
- roomId,
682
- aliases: roomAliasesForConfig,
683
- })
684
- : undefined;
685
- const roomConfig = roomConfigInfo?.config;
686
- const allowBotsMode = resolveMatrixAllowBotsMode(roomConfig?.allowBots ?? accountAllowBots);
687
- const isConfiguredBotSender = configuredBotUserIds.has(senderId);
688
- const roomMatchMeta = roomConfigInfo
689
- ? `matchKey=${roomConfigInfo.matchKey ?? "none"} matchSource=${
690
- roomConfigInfo.matchSource ?? "none"
691
- }`
692
- : "matchKey=none matchSource=none";
693
-
694
- if (isConfiguredBotSender && allowBotsMode === "off") {
695
- logVerboseMessage(
696
- `matrix: drop configured bot sender=${senderId} (allowBots=false${isDirectMessage ? "" : `, ${roomMatchMeta}`})`,
697
- );
698
- await commitInboundEventIfClaimed();
699
- return undefined;
700
- }
701
- const botLoopProtection: ChannelBotLoopProtectionFacts | undefined =
702
- isConfiguredBotSender && senderId !== selfUserId
703
- ? {
704
- scopeId: accountId,
705
- conversationId: roomId,
706
- senderId,
707
- receiverId: selfUserId,
708
- config: mergePairLoopGuardConfig(
709
- accountConfig?.botLoopProtection,
710
- roomConfig?.botLoopProtection,
711
- ),
712
- defaultsConfig: cfg.channels?.defaults?.botLoopProtection,
713
- defaultEnabled: true,
714
- nowMs: eventTs ?? undefined,
715
- }
716
- : undefined;
717
-
718
- if (isRoom && roomConfig && !roomConfigInfo?.allowed) {
719
- logVerboseMessage(`matrix: room disabled room=${roomId} (${roomMatchMeta})`);
720
- await commitInboundEventIfClaimed();
721
- return undefined;
722
- }
723
- if (isRoom && groupPolicy === "allowlist") {
724
- if (!roomConfigInfo?.allowlistConfigured) {
725
- logVerboseMessage(`matrix: drop room message (no allowlist, ${roomMatchMeta})`);
726
- await commitInboundEventIfClaimed();
727
- return undefined;
728
- }
729
- if (!roomConfig) {
730
- logVerboseMessage(`matrix: drop room message (not in allowlist, ${roomMatchMeta})`);
731
- await commitInboundEventIfClaimed();
732
- return undefined;
733
- }
734
- }
735
-
736
- let senderNamePromise: Promise<string> | null = null;
737
- const getSenderName = async (): Promise<string> => {
738
- senderNamePromise ??= getMemberDisplayName(roomId, senderId).catch(() => senderId);
739
- return await senderNamePromise;
740
- };
741
- const storeAllowFrom =
742
- isDirectMessage && dmPolicy !== "allowlist" && dmPolicy !== "open"
743
- ? await readStoreAllowFrom()
744
- : [];
745
- const roomUsers = roomConfig?.users ?? [];
746
- const liveCfg = core.config.current() as CoreConfig;
747
- const liveAccountAllowlists = resolveMatrixAccountAllowlistConfig({
748
- cfg: liveCfg,
749
- accountId,
750
- });
751
- const liveDmAllowFrom = await resolveCachedLiveAllowlist({
752
- cfg: liveCfg,
753
- entries: liveAccountAllowlists.dmAllowFrom,
754
- startupResolvedEntries: allowFromResolvedEntries,
755
- cache: liveDmAllowlistCache,
756
- updateCache: (next) => {
757
- liveDmAllowlistCache = next;
758
- },
759
- });
760
- const liveGroupAllowFrom = await resolveCachedLiveAllowlist({
761
- cfg: liveCfg,
762
- entries: liveAccountAllowlists.groupAllowFrom,
763
- failClosedOnUnresolved: true,
764
- startupResolvedEntries: groupAllowFromResolvedEntries,
765
- cache: liveGroupAllowlistCache,
766
- updateCache: (next) => {
767
- liveGroupAllowlistCache = next;
768
- },
769
- });
770
- const accessState = await resolveMatrixMonitorAccessState({
771
- allowFrom: liveDmAllowFrom,
772
- storeAllowFrom,
773
- dmPolicy,
774
- groupPolicy,
775
- groupAllowFrom: liveGroupAllowFrom,
776
- roomUsers,
777
- senderId,
778
- isRoom,
779
- accountId,
780
- eventKind: isReactionEvent ? "reaction" : "message",
781
- });
782
- const { effectiveGroupAllowFrom, effectiveRoomUsers, messageIngress } = accessState;
783
- const ingressDecision = messageIngress.ingress;
784
-
785
- if (isDirectMessage) {
786
- if (!dmEnabled || dmPolicy === "disabled") {
787
- await commitInboundEventIfClaimed();
788
- return undefined;
789
- }
790
- const senderReason = messageIngress.senderAccess.reasonCode;
791
- if (ingressDecision.decision !== "allow") {
792
- if (ingressDecision.admission === "pairing-required") {
793
- const senderName = await getSenderName();
794
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
795
- channel: "matrix",
796
- id: senderId,
797
- accountId,
798
- meta: { name: senderName },
799
- });
800
- if (shouldSendPairingReply(senderId, created)) {
801
- const pairingReply = core.channel.pairing.buildPairingReply({
802
- channel: "matrix",
803
- idLine: `Your Matrix user id: ${senderId}`,
804
- code,
805
- });
806
- logVerboseMessage(
807
- created
808
- ? `matrix pairing request sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})`
809
- : `matrix pairing reminder sender=${senderId} name=${senderName ?? "unknown"} (reason=${senderReason})`,
810
- );
811
- try {
812
- const { sendMessageMatrix } = await loadMatrixSendModule();
813
- await sendMessageMatrix(
814
- `room:${roomId}`,
815
- created
816
- ? pairingReply
817
- : `${pairingReply}\n\nPairing request is still pending approval. Reusing existing code.`,
818
- {
819
- client,
820
- cfg,
821
- accountId,
822
- },
823
- );
824
- await commitInboundEventIfClaimed();
825
- } catch (err) {
826
- logVerboseMessage(`matrix pairing reply failed for ${senderId}: ${String(err)}`);
827
- return undefined;
828
- }
829
- } else {
830
- logVerboseMessage(
831
- `matrix pairing reminder suppressed sender=${senderId} (cooldown)`,
832
- );
833
- await commitInboundEventIfClaimed();
834
- }
835
- }
836
- if (isReactionEvent || dmPolicy !== "pairing") {
837
- logVerboseMessage(
838
- `matrix: blocked ${isReactionEvent ? "reaction" : "dm"} sender ${senderId} (dmPolicy=${dmPolicy}, reason=${senderReason})`,
839
- );
840
- await commitInboundEventIfClaimed();
841
- }
842
- return undefined;
843
- }
844
- }
845
-
846
- if (isRoom && ingressDecision.decision !== "allow") {
847
- logVerboseMessage(
848
- `matrix: blocked sender ${senderId} (ingress=${ingressDecision.reasonCode}, ${roomMatchMeta})`,
849
- );
850
- await commitInboundEventIfClaimed();
851
- return undefined;
852
- }
853
- if (isRoom) {
854
- logVerboseMessage(`matrix: allow room ${roomId} (${roomMatchMeta})`);
855
- }
856
-
857
- if (isReactionEvent) {
858
- const senderName = await getSenderName();
859
- const { handleInboundMatrixReaction } = await loadMatrixReactionEvents();
860
- await handleInboundMatrixReaction({
861
- client,
862
- core,
863
- cfg,
864
- accountId,
865
- roomId,
866
- event,
867
- senderId,
868
- senderLabel: senderName,
869
- selfUserId,
870
- isDirectMessage,
871
- logVerboseMessage,
872
- });
873
- await commitInboundEventIfClaimed();
874
- return undefined;
875
- }
876
-
877
- let pollSnapshotPromise: Promise<MatrixPollSnapshot | null> | null = null;
878
- const getPollSnapshot = async (): Promise<MatrixPollSnapshot | null> => {
879
- if (!isPollEvent) {
880
- return null;
881
- }
882
- pollSnapshotPromise ??= fetchMatrixPollSnapshot(client, roomId, event).catch((err) => {
883
- logVerboseMessage(
884
- `matrix: failed resolving poll snapshot room=${roomId} id=${event.event_id ?? "unknown"}: ${String(err)}`,
885
- );
886
- return null;
887
- });
888
- return await pollSnapshotPromise;
889
- };
890
-
891
- const mentionPrecheckText = resolveMatrixMentionPrecheckText({
892
- eventType,
893
- content,
894
- locationText: locationPayload?.text,
895
- });
896
- const contentUrl =
897
- "url" in content && typeof content.url === "string" ? content.url : undefined;
898
- const contentFile =
899
- "file" in content && content.file && typeof content.file === "object"
900
- ? content.file
901
- : undefined;
902
- const mediaUrl = contentUrl ?? contentFile?.url;
903
- const pendingHistoryText = resolveMatrixPendingHistoryText({
904
- mentionPrecheckText,
905
- content,
906
- mediaUrl,
907
- });
908
- const pendingHistoryPollText =
909
- !pendingHistoryText && isPollEvent && historyLimit > 0
910
- ? (await getPollSnapshot())?.text
911
- : "";
912
- if (!mentionPrecheckText && !mediaUrl && !isPollEvent) {
913
- await commitInboundEventIfClaimed();
914
- return undefined;
915
- }
916
-
917
- const messageId = event.event_id ?? "";
918
- const threadRootId = resolveMatrixThreadRootId({ event, content });
919
- const thread = resolveMatrixThreadRouting({
920
- isDirectMessage,
921
- threadReplies,
922
- dmThreadReplies,
923
- messageId,
924
- threadRootId,
925
- });
926
- const {
927
- route: _route,
928
- configuredBinding: _configuredBinding,
929
- runtimeBindingId: _runtimeBindingId,
930
- } = resolveMatrixInboundRoute({
931
- cfg,
932
- accountId,
933
- roomId,
934
- senderId,
935
- isDirectMessage,
936
- dmSessionScope,
937
- threadId: thread.threadId,
938
- eventTs: eventTs ?? undefined,
939
- resolveAgentRoute: core.channel.routing.resolveAgentRoute,
940
- });
941
- const hasExplicitSessionBinding = _configuredBinding !== null || _runtimeBindingId !== null;
942
- const agentMentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, _route.agentId);
943
- const selfDisplayName = content.formatted_body
944
- ? await getMemberDisplayName(roomId, selfUserId).catch(() => undefined)
945
- : undefined;
946
- const { wasMentioned, hasExplicitMention } = resolveMentions({
947
- content,
948
- userId: selfUserId,
949
- displayName: selfDisplayName,
950
- text: mentionPrecheckText,
951
- mentionRegexes: agentMentionRegexes,
952
- });
953
- if (
954
- isConfiguredBotSender &&
955
- allowBotsMode === "mentions" &&
956
- !isDirectMessage &&
957
- !wasMentioned
958
- ) {
959
- logVerboseMessage(
960
- `matrix: drop configured bot sender=${senderId} (allowBots=mentions, missing mention, ${roomMatchMeta})`,
961
- );
962
- await commitInboundEventIfClaimed();
963
- return undefined;
964
- }
965
- const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
966
- cfg,
967
- surface: "matrix",
968
- });
969
- const useAccessGroups = cfg.commands?.useAccessGroups !== false;
970
- // Keep mention stripping on the command-only path so history and agent
971
- // prompt text continue to see the original Matrix message.
972
- const commandCheckText = stripMatrixMentionPrefix({
973
- text: mentionPrecheckText,
974
- userId: selfUserId,
975
- displayName: selfDisplayName,
976
- mentionRegexes: agentMentionRegexes,
977
- });
978
- const hasControlCommandInMessage = core.channel.text.hasControlCommand(
979
- commandCheckText,
980
- cfg,
981
- );
982
- const commandAccess = await resolveMatrixMonitorCommandAccess(accessState, {
983
- useAccessGroups,
984
- allowTextCommands,
985
- hasControlCommand: hasControlCommandInMessage,
986
- });
987
- const commandAuthorized = commandAccess.authorized;
988
- if (isRoom && commandAccess.shouldBlockControlCommand) {
989
- logInboundDrop({
990
- log: logVerboseMessage,
991
- channel: "matrix",
992
- reason: "control command (unauthorized)",
993
- target: senderId,
994
- });
995
- await commitInboundEventIfClaimed();
996
- return undefined;
997
- }
998
- const shouldRequireMention = isRoom
999
- ? roomConfig?.autoReply === true
1000
- ? false
1001
- : roomConfig?.autoReply === false
1002
- ? true
1003
- : typeof roomConfig?.requireMention === "boolean"
1004
- ? roomConfig?.requireMention
1005
- : true
1006
- : false;
1007
- const shouldBypassMention =
1008
- allowTextCommands &&
1009
- isRoom &&
1010
- shouldRequireMention &&
1011
- !wasMentioned &&
1012
- !hasExplicitMention &&
1013
- commandAuthorized &&
1014
- hasControlCommandInMessage;
1015
- const canDetectMention = agentMentionRegexes.length > 0 || hasExplicitMention;
1016
- if (isRoom && shouldRequireMention && !wasMentioned && !shouldBypassMention) {
1017
- const pendingHistoryBody = pendingHistoryText || pendingHistoryPollText;
1018
- if (historyLimit > 0 && pendingHistoryBody) {
1019
- const pendingEntry: HistoryEntry = {
1020
- sender: senderId,
1021
- body: pendingHistoryBody,
1022
- timestamp: eventTs ?? undefined,
1023
- messageId,
1024
- };
1025
- roomHistoryTracker.recordPending(roomId, pendingEntry);
1026
- }
1027
- logger.info("skipping room message", { roomId, reason: "no-mention" });
1028
- await commitInboundEventIfClaimed();
1029
- return undefined;
1030
- }
1031
-
1032
- if (isPollEvent) {
1033
- const pollSnapshot = await getPollSnapshot();
1034
- if (!pollSnapshot) {
1035
- return undefined;
1036
- }
1037
- content = {
1038
- msgtype: "m.text",
1039
- body: pollSnapshot.text,
1040
- } as unknown as RoomMessageEventContent;
1041
- }
1042
-
1043
- let media: {
1044
- path: string;
1045
- contentType?: string;
1046
- placeholder: string;
1047
- } | null = null;
1048
- let mediaDownloadFailed = false;
1049
- let mediaSizeLimitExceeded = false;
1050
- const finalContentUrl =
1051
- "url" in content && typeof content.url === "string" ? content.url : undefined;
1052
- const finalContentFile =
1053
- "file" in content && content.file && typeof content.file === "object"
1054
- ? content.file
1055
- : undefined;
1056
- const finalMediaUrl = finalContentUrl ?? finalContentFile?.url;
1057
- const contentBody = typeof content.body === "string" ? content.body.trim() : "";
1058
- const contentFilename = typeof content.filename === "string" ? content.filename.trim() : "";
1059
- const originalFilename = contentFilename || contentBody || undefined;
1060
- const contentInfo =
1061
- "info" in content && content.info && typeof content.info === "object"
1062
- ? (content.info as { mimetype?: string; size?: number })
1063
- : undefined;
1064
- const contentType = contentInfo?.mimetype;
1065
- const contentSize = typeof contentInfo?.size === "number" ? contentInfo.size : undefined;
1066
- if (finalMediaUrl?.startsWith("mxc://")) {
1067
- try {
1068
- media = await downloadMatrixMedia({
1069
- client,
1070
- mxcUrl: finalMediaUrl,
1071
- contentType,
1072
- sizeBytes: contentSize,
1073
- maxBytes: mediaMaxBytes,
1074
- file: finalContentFile,
1075
- originalFilename,
1076
- });
1077
- } catch (err) {
1078
- mediaDownloadFailed = true;
1079
- if (isMatrixMediaSizeLimitError(err)) {
1080
- mediaSizeLimitExceeded = true;
1081
- }
1082
- const errorText = formatMatrixErrorMessage(err);
1083
- logVerboseMessage(
1084
- `matrix: media download failed room=${roomId} id=${event.event_id ?? "unknown"} type=${content.msgtype} error=${errorText}`,
1085
- );
1086
- logger.warn("matrix media download failed", {
1087
- roomId,
1088
- eventId: event.event_id,
1089
- msgtype: content.msgtype,
1090
- encrypted: Boolean(finalContentFile),
1091
- error: errorText,
1092
- });
1093
- }
1094
- }
1095
-
1096
- const rawBody = locationPayload?.text ?? contentBody;
1097
- const bodyText = resolveMatrixInboundBodyText({
1098
- rawBody,
1099
- filename: typeof content.filename === "string" ? content.filename : undefined,
1100
- mediaPlaceholder: media?.placeholder,
1101
- msgtype: content.msgtype,
1102
- hadMediaUrl: Boolean(finalMediaUrl),
1103
- mediaDownloadFailed,
1104
- mediaSizeLimitExceeded,
1105
- });
1106
- if (!bodyText) {
1107
- await commitInboundEventIfClaimed();
1108
- return undefined;
1109
- }
1110
- const commandBodyText = hasControlCommandInMessage ? commandCheckText : bodyText;
1111
- const senderName = await getSenderName();
1112
- if (_configuredBinding) {
1113
- const { ensureConfiguredAcpBindingReady } = await loadAcpBindingRuntime();
1114
- const ensured = await ensureConfiguredAcpBindingReady({
1115
- cfg,
1116
- configuredBinding: _configuredBinding,
1117
- });
1118
- if (!ensured.ok) {
1119
- logInboundDrop({
1120
- log: logVerboseMessage,
1121
- channel: "matrix",
1122
- reason: "configured ACP binding unavailable",
1123
- target: _configuredBinding.spec.conversationId,
1124
- });
1125
- return undefined;
1126
- }
1127
- }
1128
- if (_runtimeBindingId) {
1129
- const { getSessionBindingService } = await loadSessionBindingRuntime();
1130
- getSessionBindingService().touch(_runtimeBindingId, eventTs ?? undefined);
1131
- }
1132
- const preparedTrigger =
1133
- isRoom && historyLimit > 0
1134
- ? roomHistoryTracker.prepareTrigger(_route.agentId, roomId, historyLimit, {
1135
- sender: senderName,
1136
- body: bodyText,
1137
- timestamp: eventTs ?? undefined,
1138
- messageId,
1139
- })
1140
- : undefined;
1141
- const inboundHistory = preparedTrigger
1142
- ? buildInboundHistoryFromEntries({
1143
- entries: preparedTrigger.history,
1144
- limit: historyLimit,
1145
- })
1146
- : undefined;
1147
- const triggerSnapshot = preparedTrigger;
1148
-
1149
- return {
1150
- route: _route,
1151
- hasExplicitSessionBinding,
1152
- roomConfig,
1153
- isDirectMessage,
1154
- isRoom,
1155
- shouldRequireMention,
1156
- wasMentioned,
1157
- shouldBypassMention,
1158
- canDetectMention,
1159
- commandAuthorized,
1160
- inboundHistory,
1161
- senderName,
1162
- bodyText,
1163
- commandBodyText,
1164
- media,
1165
- locationPayload,
1166
- messageId,
1167
- triggerSnapshot,
1168
- threadRootId,
1169
- thread,
1170
- botLoopProtection,
1171
- effectiveGroupAllowFrom,
1172
- effectiveRoomUsers,
1173
- };
1174
- };
1175
- const ingressResult =
1176
- historyLimit > 0
1177
- ? await runRoomIngress(roomId, async () => {
1178
- const prefix = await readIngressPrefix();
1179
- if (!prefix) {
1180
- return undefined;
1181
- }
1182
- if (prefix.isDirectMessage) {
1183
- return { deferredPrefix: prefix } as const;
1184
- }
1185
- return { ingressResult: await continueIngress(prefix) } as const;
1186
- })
1187
- : undefined;
1188
- const resolvedIngressResult =
1189
- historyLimit > 0
1190
- ? ingressResult?.deferredPrefix
1191
- ? await continueIngress(ingressResult.deferredPrefix)
1192
- : ingressResult?.ingressResult
1193
- : await (async () => {
1194
- const prefix = await readIngressPrefix();
1195
- if (!prefix) {
1196
- return undefined;
1197
- }
1198
- return await continueIngress(prefix);
1199
- })();
1200
- if (!resolvedIngressResult) {
1201
- return;
1202
- }
1203
-
1204
- const {
1205
- route: _route,
1206
- hasExplicitSessionBinding,
1207
- roomConfig,
1208
- isDirectMessage,
1209
- isRoom,
1210
- shouldRequireMention,
1211
- wasMentioned,
1212
- shouldBypassMention,
1213
- canDetectMention,
1214
- commandAuthorized,
1215
- inboundHistory,
1216
- senderName,
1217
- bodyText,
1218
- commandBodyText,
1219
- media,
1220
- locationPayload,
1221
- messageId,
1222
- triggerSnapshot,
1223
- threadRootId,
1224
- thread,
1225
- botLoopProtection,
1226
- effectiveGroupAllowFrom,
1227
- effectiveRoomUsers,
1228
- } = resolvedIngressResult;
1229
-
1230
- // Keep the per-room ingress gate focused on ordering-sensitive state updates.
1231
- // Prompt/session enrichment below can run concurrently after the history snapshot is fixed.
1232
- const replyToEventId = resolveMatrixReplyToEventId(event.content as RoomMessageEventContent);
1233
- const threadTarget = thread.threadId;
1234
- const isRoomContextSenderAllowed = (contextSenderId?: string): boolean => {
1235
- if (!isRoom || !contextSenderId) {
1236
- return true;
1237
- }
1238
- if (effectiveRoomUsers.length > 0) {
1239
- return resolveMatrixAllowListMatch({
1240
- allowList: effectiveRoomUsers,
1241
- userId: contextSenderId,
1242
- }).allowed;
1243
- }
1244
- if (groupPolicy === "allowlist" && effectiveGroupAllowFrom.length > 0) {
1245
- return resolveMatrixAllowListMatch({
1246
- allowList: effectiveGroupAllowFrom,
1247
- userId: contextSenderId,
1248
- }).allowed;
1249
- }
1250
- return true;
1251
- };
1252
- const shouldIncludeRoomContextSender = (
1253
- kind: "thread" | "quote" | "history",
1254
- contextSenderId?: string,
1255
- ): boolean =>
1256
- evaluateSupplementalContextVisibility({
1257
- mode: contextVisibilityMode,
1258
- kind,
1259
- senderAllowed: isRoomContextSenderAllowed(contextSenderId),
1260
- }).include;
1261
- let threadContext = threadRootId
1262
- ? await resolveThreadContext({ roomId, threadRootId })
1263
- : undefined;
1264
- let threadContextBlockedByPolicy = false;
1265
- if (
1266
- threadContext?.senderId &&
1267
- !shouldIncludeRoomContextSender("thread", threadContext.senderId)
1268
- ) {
1269
- logVerboseMessage(`matrix: drop thread root context (mode=${contextVisibilityMode})`);
1270
- threadContextBlockedByPolicy = true;
1271
- threadContext = undefined;
1272
- }
1273
- let replyContext: Awaited<ReturnType<typeof resolveReplyContext>> | undefined;
1274
- if (replyToEventId && replyToEventId === threadRootId && threadContext?.summary) {
1275
- replyContext = {
1276
- replyToBody: threadContext.summary,
1277
- replyToSender: threadContext.senderLabel,
1278
- replyToSenderId: threadContext.senderId,
1279
- };
1280
- } else if (
1281
- replyToEventId &&
1282
- replyToEventId === threadRootId &&
1283
- threadContextBlockedByPolicy
1284
- ) {
1285
- replyContext = await resolveReplyContext({ roomId, eventId: replyToEventId });
1286
- } else {
1287
- replyContext = replyToEventId
1288
- ? await resolveReplyContext({ roomId, eventId: replyToEventId })
1289
- : undefined;
1290
- }
1291
- if (
1292
- replyContext?.replyToSenderId &&
1293
- !shouldIncludeRoomContextSender("quote", replyContext.replyToSenderId)
1294
- ) {
1295
- logVerboseMessage(`matrix: drop reply context (mode=${contextVisibilityMode})`);
1296
- replyContext = undefined;
1297
- }
1298
- const roomInfo = isRoom ? await getRoomInfo(roomId) : undefined;
1299
- const roomName = roomInfo?.name;
1300
- const envelopeFrom = isDirectMessage ? senderName : (roomName ?? roomId);
1301
- const textWithId = `${bodyText}\n[matrix event id: ${messageId} room: ${roomId}]`;
1302
- const storePath = core.channel.session.resolveStorePath(cfg.session?.store, {
1303
- agentId: _route.agentId,
1304
- });
1305
- const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(cfg);
1306
- const previousTimestamp = core.channel.session.readSessionUpdatedAt({
1307
- storePath,
1308
- sessionKey: _route.sessionKey,
1309
- });
1310
- const sharedDmNoticeSessionKey = threadTarget
1311
- ? _route.mainSessionKey || _route.sessionKey
1312
- : _route.sessionKey;
1313
- const sharedDmContextNotice = isDirectMessage
1314
- ? hasExplicitSessionBinding
1315
- ? null
1316
- : resolveMatrixSharedDmContextNotice({
1317
- storePath,
1318
- sessionKey: sharedDmNoticeSessionKey,
1319
- roomId,
1320
- accountId: _route.accountId,
1321
- dmSessionScope,
1322
- sentRooms: sharedDmContextNoticeRooms,
1323
- logVerboseMessage,
1324
- })
1325
- : null;
1326
- const body = core.channel.reply.formatAgentEnvelope({
1327
- channel: "Matrix",
1328
- from: envelopeFrom,
1329
- timestamp: eventTs ?? undefined,
1330
- previousTimestamp,
1331
- envelope: envelopeOptions,
1332
- body: textWithId,
1333
- });
1334
- const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
1335
- const ctxPayload = core.channel.reply.finalizeInboundContext({
1336
- Body: body,
1337
- RawBody: bodyText,
1338
- CommandBody: commandBodyText,
1339
- BodyForAgent: bodyText,
1340
- BodyForCommands: commandBodyText,
1341
- InboundHistory: inboundHistory && inboundHistory.length > 0 ? inboundHistory : undefined,
1342
- From: isDirectMessage ? `matrix:${senderId}` : `matrix:channel:${roomId}`,
1343
- To: `room:${roomId}`,
1344
- SessionKey: _route.sessionKey,
1345
- AccountId: _route.accountId,
1346
- ChatType: isDirectMessage ? "direct" : "channel",
1347
- ConversationLabel: envelopeFrom,
1348
- SenderName: senderName,
1349
- SenderId: senderId,
1350
- SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
1351
- GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
1352
- GroupId: isRoom ? roomId : undefined,
1353
- GroupChannel: isRoom ? roomId : undefined,
1354
- GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
1355
- Provider: "matrix" as const,
1356
- Surface: "matrix" as const,
1357
- WasMentioned: isRoom ? wasMentioned : undefined,
1358
- MessageSid: messageId,
1359
- ReplyToId: threadTarget ? undefined : (replyToEventId ?? undefined),
1360
- ReplyToBody: replyContext?.replyToBody,
1361
- ReplyToSender: replyContext?.replyToSender,
1362
- MessageThreadId: threadTarget,
1363
- ThreadStarterBody: threadContext?.threadStarterBody,
1364
- Timestamp: eventTs ?? undefined,
1365
- MediaPath: media?.path,
1366
- MediaType: media?.contentType,
1367
- MediaUrl: media?.path,
1368
- ...locationPayload?.context,
1369
- CommandAuthorized: commandAuthorized,
1370
- CommandSource: "text" as const,
1371
- NativeChannelId: roomId,
1372
- NativeDirectUserId: isDirectMessage ? senderId : undefined,
1373
- OriginatingChannel: "matrix" as const,
1374
- OriginatingTo: `room:${roomId}`,
1375
- });
1376
-
1377
- const preview = bodyText.slice(0, 200).replace(/\n/g, "\\n");
1378
- logVerboseMessage(`matrix inbound: room=${roomId} from=${senderId} preview="${preview}"`);
1379
-
1380
- const replyTarget = ctxPayload.To;
1381
- if (!replyTarget) {
1382
- runtime.error?.("matrix: missing reply target");
1383
- return;
1384
- }
1385
-
1386
- const { ackReaction, ackReactionScope: ackScope } = resolveMatrixAckReactionConfig({
1387
- cfg,
1388
- agentId: _route.agentId,
1389
- accountId,
1390
- });
1391
- const shouldAckReaction = () =>
1392
- Boolean(
1393
- ackReaction &&
1394
- core.channel.reactions.shouldAckReaction({
1395
- scope: ackScope,
1396
- isDirect: isDirectMessage,
1397
- isGroup: isRoom,
1398
- isMentionableGroup: isRoom,
1399
- requireMention: shouldRequireMention,
1400
- canDetectMention,
1401
- effectiveWasMentioned: wasMentioned || shouldBypassMention,
1402
- shouldBypassMention,
1403
- }),
1404
- );
1405
- if (shouldAckReaction() && messageId) {
1406
- loadMatrixSendModule()
1407
- .then(({ reactMatrixMessage }) =>
1408
- reactMatrixMessage(roomId, messageId, ackReaction, client),
1409
- )
1410
- .catch((err) => {
1411
- logVerboseMessage(`matrix react failed for room ${roomId}: ${String(err)}`);
1412
- });
1413
- }
1414
-
1415
- if (messageId) {
1416
- loadMatrixSendModule()
1417
- .then(({ sendReadReceiptMatrix }) => sendReadReceiptMatrix(roomId, messageId, client))
1418
- .catch((err) => {
1419
- logVerboseMessage(
1420
- `matrix: read receipt failed room=${roomId} id=${messageId}: ${String(err)}`,
1421
- );
1422
- });
1423
- }
1424
-
1425
- const tableMode = core.channel.text.resolveMarkdownTableMode({
1426
- cfg,
1427
- channel: "matrix",
1428
- accountId: _route.accountId,
1429
- });
1430
- const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, _route.agentId);
1431
- let finalReplyDeliveryFailed = false;
1432
- let nonFinalReplyDeliveryFailed = false;
1433
- let retryableReplyDeliveryFailed = false;
1434
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
1435
- cfg,
1436
- agentId: _route.agentId,
1437
- channel: "matrix",
1438
- accountId: _route.accountId,
1439
- });
1440
- const typingCallbacks = createTypingCallbacks({
1441
- start: async () => {
1442
- const { sendTypingMatrix } = await loadMatrixSendModule();
1443
- await sendTypingMatrix(roomId, true, undefined, client);
1444
- },
1445
- stop: async () => {
1446
- const { sendTypingMatrix } = await loadMatrixSendModule();
1447
- await sendTypingMatrix(roomId, false, undefined, client);
1448
- },
1449
- onStartError: (err) => {
1450
- logTypingFailure({
1451
- log: logVerboseMessage,
1452
- channel: "matrix",
1453
- action: "start",
1454
- target: roomId,
1455
- error: err,
1456
- });
1457
- },
1458
- onStopError: (err) => {
1459
- logTypingFailure({
1460
- log: logVerboseMessage,
1461
- channel: "matrix",
1462
- action: "stop",
1463
- target: roomId,
1464
- error: err,
1465
- });
1466
- },
1467
- });
1468
- const draftStreamingEnabled = streaming !== "off";
1469
- const quietDraftStreaming = streaming === "quiet" || streaming === "progress";
1470
- const progressDraftStreaming = streaming === "progress";
1471
- const draftReplyToId = replyToMode !== "off" && !threadTarget ? messageId : undefined;
1472
- const draftStream: MatrixDraftStreamHandle | undefined = draftStreamingEnabled
1473
- ? await loadMatrixDraftStream().then(({ createMatrixDraftStream }) =>
1474
- createMatrixDraftStream({
1475
- roomId,
1476
- client,
1477
- cfg,
1478
- mode: quietDraftStreaming ? "quiet" : "partial",
1479
- threadId: threadTarget,
1480
- replyToId: draftReplyToId,
1481
- preserveReplyId: replyToMode === "all",
1482
- accountId: _route.accountId,
1483
- log: logVerboseMessage,
1484
- }),
1485
- )
1486
- : undefined;
1487
- draftStreamRef = draftStream;
1488
- const shouldStreamPreviewToolProgress = Boolean(draftStream) && previewToolProgressEnabled;
1489
- const shouldSuppressDefaultToolProgressMessages =
1490
- Boolean(draftStream) &&
1491
- (shouldStreamPreviewToolProgress || params.streaming === "progress");
1492
- type PendingDraftBoundary = {
1493
- messageGeneration: number;
1494
- endOffset: number;
1495
- };
1496
- // Track the current draft block start plus any queued block-end offsets
1497
- // inside the model's cumulative partial text so multiple block
1498
- // boundaries can drain in order even when Matrix delivery lags behind.
1499
- let currentDraftMessageGeneration = 0;
1500
- let currentDraftBlockOffset = 0;
1501
- let latestDraftFullText = "";
1502
- const pendingDraftBoundaries: PendingDraftBoundary[] = [];
1503
- const latestQueuedDraftBoundaryOffsets = new Map<number, number>();
1504
- let currentDraftReplyToId = draftReplyToId;
1505
- let previewToolProgressSuppressed = false;
1506
- let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
1507
- const progressConfigEntry = params.accountConfig ?? cfg.channels?.matrix;
1508
- const progressSeed = `${_route.accountId}:${roomId}`;
1509
- // Set after the first final payload consumes or discards the draft event
1510
- // so subsequent finals go through normal delivery.
1511
-
1512
- const renderProgressDraft = () => {
1513
- if (!draftStream || !progressDraftStreaming) {
1514
- return;
1515
- }
1516
- const previewText = formatChannelProgressDraftText({
1517
- entry: progressConfigEntry,
1518
- lines: previewToolProgressLines,
1519
- seed: progressSeed,
1520
- formatLine: formatMatrixToolProgressMarkdownCode,
1521
- bullet: "-",
1522
- });
1523
- if (!previewText) {
1524
- return;
1525
- }
1526
- draftStream.update(previewText);
1527
- };
1528
- const progressDraftGate = createChannelProgressDraftGate({
1529
- onStart: renderProgressDraft,
1530
- });
1531
-
1532
- const pushPreviewToolProgress = async (
1533
- line?: string | ChannelProgressDraftLine,
1534
- options?: { toolName?: string },
1535
- ) => {
1536
- if (!draftStream) {
1537
- return;
1538
- }
1539
- if (
1540
- options?.toolName !== undefined &&
1541
- !isChannelProgressDraftWorkToolName(options.toolName)
1542
- ) {
1543
- return;
1544
- }
1545
- const normalized = normalizeChannelProgressDraftLineIdentity(line);
1546
- const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
1547
- if (!progressDraftStreaming) {
1548
- if (!shouldStreamPreviewToolProgress || previewToolProgressSuppressed || !normalized) {
1549
- return;
1550
- }
1551
- const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
1552
- maxLines: resolveChannelProgressDraftMaxLines(progressConfigEntry),
1553
- });
1554
- if (nextLines === previewToolProgressLines) {
1555
- return;
1556
- }
1557
- previewToolProgressLines = nextLines;
1558
- draftStream.update(
1559
- formatChannelProgressDraftText({
1560
- entry: progressConfigEntry,
1561
- lines: previewToolProgressLines,
1562
- seed: progressSeed,
1563
- formatLine: formatMatrixToolProgressMarkdownCode,
1564
- bullet: "-",
1565
- }),
1566
- );
1567
- return;
1568
- }
1569
- if (shouldStreamPreviewToolProgress && !previewToolProgressSuppressed && normalized) {
1570
- previewToolProgressLines = mergeChannelProgressDraftLine(
1571
- previewToolProgressLines,
1572
- progressLine,
1573
- {
1574
- maxLines: resolveChannelProgressDraftMaxLines(progressConfigEntry),
1575
- },
1576
- );
1577
- }
1578
- const alreadyStarted = progressDraftGate.hasStarted;
1579
- await progressDraftGate.noteWork();
1580
- if (alreadyStarted && progressDraftGate.hasStarted) {
1581
- renderProgressDraft();
1582
- }
1583
- };
1584
-
1585
- const suppressPreviewToolProgressForAnswerText = (text: string | undefined) => {
1586
- if (!text?.trim()) {
1587
- return;
1588
- }
1589
- previewToolProgressSuppressed = true;
1590
- previewToolProgressLines = [];
1591
- };
1592
-
1593
- const resetPreviewToolProgress = () => {
1594
- previewToolProgressSuppressed = false;
1595
- previewToolProgressLines = [];
1596
- };
1597
-
1598
- const buildPreviewToolProgressReplyOptions = (): Partial<GetReplyOptions> => {
1599
- if (!shouldSuppressDefaultToolProgressMessages) {
1600
- return {};
1601
- }
1602
- const options: Partial<GetReplyOptions> = {
1603
- suppressDefaultToolProgressMessages: true,
1604
- };
1605
- if (!shouldStreamPreviewToolProgress) {
1606
- return options;
1607
- }
1608
- return {
1609
- ...options,
1610
- onToolStart: async (payload) => {
1611
- const toolName = payload.name?.trim();
1612
- await pushPreviewToolProgress(
1613
- formatChannelProgressDraftLineForEntry(
1614
- progressConfigEntry,
1615
- {
1616
- event: "tool",
1617
- name: toolName,
1618
- phase: payload.phase,
1619
- args: payload.args,
1620
- },
1621
- payload.detailMode ? { detailMode: payload.detailMode } : undefined,
1622
- ),
1623
- { toolName },
1624
- );
1625
- },
1626
- onItemEvent: async (payload) => {
1627
- await pushPreviewToolProgress(
1628
- buildChannelProgressDraftLineForEntry(progressConfigEntry, {
1629
- event: "item",
1630
- itemId: payload.itemId,
1631
- itemKind: payload.kind,
1632
- title: payload.title,
1633
- name: payload.name,
1634
- phase: payload.phase,
1635
- status: payload.status,
1636
- summary: payload.summary,
1637
- progressText: payload.progressText,
1638
- meta: payload.meta,
1639
- }),
1640
- );
1641
- },
1642
- onPlanUpdate: async (payload) => {
1643
- if (payload.phase !== "update") {
1644
- return;
1645
- }
1646
- await pushPreviewToolProgress(
1647
- formatChannelProgressDraftLine({
1648
- event: "plan",
1649
- phase: payload.phase,
1650
- title: payload.title,
1651
- explanation: payload.explanation,
1652
- steps: payload.steps,
1653
- }),
1654
- );
1655
- },
1656
- onApprovalEvent: async (payload) => {
1657
- if (payload.phase !== "requested") {
1658
- return;
1659
- }
1660
- await pushPreviewToolProgress(
1661
- formatChannelProgressDraftLine({
1662
- event: "approval",
1663
- phase: payload.phase,
1664
- title: payload.title,
1665
- command: payload.command,
1666
- reason: payload.reason,
1667
- message: payload.message,
1668
- }),
1669
- );
1670
- },
1671
- onCommandOutput: async (payload) => {
1672
- if (payload.phase !== "end") {
1673
- return;
1674
- }
1675
- await pushPreviewToolProgress(
1676
- formatChannelProgressDraftLine({
1677
- event: "command-output",
1678
- phase: payload.phase,
1679
- title: payload.title,
1680
- name: payload.name,
1681
- status: payload.status,
1682
- exitCode: payload.exitCode,
1683
- }),
1684
- );
1685
- },
1686
- onPatchSummary: async (payload) => {
1687
- if (payload.phase !== "end") {
1688
- return;
1689
- }
1690
- await pushPreviewToolProgress(
1691
- formatChannelProgressDraftLine({
1692
- event: "patch",
1693
- phase: payload.phase,
1694
- title: payload.title,
1695
- name: payload.name,
1696
- added: payload.added,
1697
- modified: payload.modified,
1698
- deleted: payload.deleted,
1699
- summary: payload.summary,
1700
- }),
1701
- );
1702
- },
1703
- };
1704
- };
1705
-
1706
- const getDisplayableDraftText = () => {
1707
- const nextDraftBoundaryOffset = pendingDraftBoundaries.find(
1708
- (boundary) => boundary.messageGeneration === currentDraftMessageGeneration,
1709
- )?.endOffset;
1710
- if (nextDraftBoundaryOffset === undefined) {
1711
- return latestDraftFullText.slice(currentDraftBlockOffset);
1712
- }
1713
- return latestDraftFullText.slice(currentDraftBlockOffset, nextDraftBoundaryOffset);
1714
- };
1715
-
1716
- const updateDraftFromLatestFullText = () => {
1717
- const blockText = getDisplayableDraftText();
1718
- if (blockText) {
1719
- draftStream?.update(blockText);
1720
- }
1721
- };
1722
-
1723
- const queueDraftBlockBoundary = (payload: ReplyPayload, context?: BlockReplyContext) => {
1724
- const payloadTextLength = payload.text?.length ?? 0;
1725
- const messageGeneration = context?.assistantMessageIndex ?? currentDraftMessageGeneration;
1726
- const lastQueuedDraftBoundaryOffset =
1727
- latestQueuedDraftBoundaryOffsets.get(messageGeneration) ?? 0;
1728
- // Logical block boundaries must follow emitted block text, not whichever
1729
- // later partial preview has already arrived by the time the async
1730
- // boundary callback drains.
1731
- const nextDraftBoundaryOffset = lastQueuedDraftBoundaryOffset + payloadTextLength;
1732
- latestQueuedDraftBoundaryOffsets.set(messageGeneration, nextDraftBoundaryOffset);
1733
- pendingDraftBoundaries.push({
1734
- messageGeneration,
1735
- endOffset: nextDraftBoundaryOffset,
1736
- });
1737
- };
1738
-
1739
- const advanceDraftBlockBoundary = (options?: { fallbackToLatestEnd?: boolean }) => {
1740
- const completedBoundary = pendingDraftBoundaries.shift();
1741
- if (completedBoundary) {
1742
- if (
1743
- !pendingDraftBoundaries.some(
1744
- (entry) => entry.messageGeneration === completedBoundary.messageGeneration,
1745
- )
1746
- ) {
1747
- latestQueuedDraftBoundaryOffsets.delete(completedBoundary.messageGeneration);
1748
- }
1749
- if (completedBoundary.messageGeneration === currentDraftMessageGeneration) {
1750
- currentDraftBlockOffset = completedBoundary.endOffset;
1751
- }
1752
- return;
1753
- }
1754
- if (options?.fallbackToLatestEnd) {
1755
- currentDraftBlockOffset = latestDraftFullText.length;
1756
- }
1757
- };
1758
-
1759
- const resetDraftBlockOffsets = () => {
1760
- currentDraftMessageGeneration += 1;
1761
- currentDraftBlockOffset = 0;
1762
- latestDraftFullText = "";
1763
- };
1764
-
1765
- const { dispatcher, replyOptions, markDispatchIdle, markRunComplete } =
1766
- core.channel.reply.createReplyDispatcherWithTyping({
1767
- ...prefixOptions,
1768
- humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, _route.agentId),
1769
- deliver: async (payload: ReplyPayload, info: { kind: string }) => {
1770
- if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
1771
- const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
1772
- const ttsSupplement = getReplyPayloadTtsSupplement(payload);
1773
- const fallbackPayload =
1774
- ttsSupplement &&
1775
- ttsSupplement.visibleTextAlreadyDelivered !== true &&
1776
- !payload.text?.trim()
1777
- ? { ...payload, text: ttsSupplement.spokenText }
1778
- : payload;
1779
-
1780
- if (draftConsumed) {
1781
- await draftStream.discardPending();
1782
- await deliverMatrixReplies({
1783
- cfg,
1784
- replies: [fallbackPayload],
1785
- roomId,
1786
- client,
1787
- runtime,
1788
- textLimit,
1789
- replyToMode,
1790
- threadId: threadTarget,
1791
- accountId: _route.accountId,
1792
- mediaLocalRoots,
1793
- tableMode,
1794
- });
1795
- return;
1796
- }
1797
-
1798
- const payloadReplyToId = normalizeOptionalString(payload.replyToId);
1799
- const payloadReplyMismatch =
1800
- replyToMode !== "off" &&
1801
- !threadTarget &&
1802
- payloadReplyToId !== currentDraftReplyToId;
1803
- let mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
1804
- const canPotentiallyFinalizeDraft =
1805
- Boolean(payload.text?.trim()) &&
1806
- !payload.isError &&
1807
- !payloadReplyMismatch &&
1808
- !mustDeliverFinalNormally;
1809
-
1810
- if (canPotentiallyFinalizeDraft) {
1811
- await draftStream.stop();
1812
- mustDeliverFinalNormally = draftStream.mustDeliverFinalNormally();
1813
- } else {
1814
- await draftStream.discardPending();
1815
- }
1816
- const draftEventId = draftStream.eventId();
1817
-
1818
- if (
1819
- draftEventId &&
1820
- payload.text &&
1821
- !payload.isError &&
1822
- !hasMedia &&
1823
- !payloadReplyMismatch &&
1824
- !mustDeliverFinalNormally
1825
- ) {
1826
- const finalPreviewText = payload.text;
1827
- await deliverWithFinalizableLivePreviewAdapter<
1828
- ReplyPayload,
1829
- string,
1830
- {
1831
- text: string;
1832
- finalizeLive: boolean;
1833
- extraContent?: Record<string, unknown>;
1834
- }
1835
- >({
1836
- kind: "final",
1837
- payload,
1838
- adapter: defineFinalizableLivePreviewAdapter({
1839
- draft: {
1840
- flush: async () => {},
1841
- clear: async () => {},
1842
- discardPending: async () => {},
1843
- id: () => draftEventId,
1844
- },
1845
- buildFinalEdit: () => ({
1846
- text: finalPreviewText,
1847
- finalizeLive: !(
1848
- quietDraftStreaming || !draftStream.matchesPreparedText(finalPreviewText)
1849
- ),
1850
- ...(quietDraftStreaming
1851
- ? { extraContent: buildMatrixFinalizedPreviewContent() }
1852
- : {}),
1853
- }),
1854
- editFinal: async (_draftEventId, edit) => {
1855
- if (edit.finalizeLive) {
1856
- if (!(await draftStream.finalizeLive())) {
1857
- throw new Error("Matrix draft live finalize failed");
1858
- }
1859
- return;
1860
- }
1861
- const { editMessageMatrix } = await loadMatrixSendModule();
1862
- await editMessageMatrix(roomId, _draftEventId, edit.text, {
1863
- client,
1864
- cfg,
1865
- threadId: threadTarget,
1866
- accountId: _route.accountId,
1867
- extraContent: edit.extraContent,
1868
- });
1869
- },
1870
- createPreviewReceipt: (id): MessageReceipt =>
1871
- createPreviewMessageReceipt({
1872
- id,
1873
- ...(threadTarget ? { threadId: threadTarget } : {}),
1874
- ...(currentDraftReplyToId ? { replyToId: currentDraftReplyToId } : {}),
1875
- }),
1876
- logPreviewEditFailure: (err) => {
1877
- logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
1878
- },
1879
- }),
1880
- deliverNormally: async () => {
1881
- await redactMatrixDraftEvent(client, roomId, draftEventId);
1882
- await deliverMatrixReplies({
1883
- cfg,
1884
- replies: [fallbackPayload],
1885
- roomId,
1886
- client,
1887
- runtime,
1888
- textLimit,
1889
- replyToMode,
1890
- threadId: threadTarget,
1891
- accountId: _route.accountId,
1892
- mediaLocalRoots,
1893
- tableMode,
1894
- });
1895
- },
1896
- });
1897
- draftConsumed = true;
1898
- } else if (draftEventId && hasMedia && !payloadReplyMismatch) {
1899
- let textEditOk = !mustDeliverFinalNormally;
1900
- const payloadText = payload.text ?? ttsSupplement?.spokenText;
1901
- const payloadTextMatchesDraft =
1902
- typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
1903
- const reusesDraftTextUnchanged =
1904
- typeof payloadText === "string" &&
1905
- Boolean(payloadText.trim()) &&
1906
- payloadTextMatchesDraft;
1907
- const requiresFinalTextEdit =
1908
- quietDraftStreaming ||
1909
- (typeof payloadText === "string" && !payloadTextMatchesDraft);
1910
- if (textEditOk && payloadText && requiresFinalTextEdit) {
1911
- const { editMessageMatrix } = await loadMatrixSendModule();
1912
- textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
1913
- client,
1914
- cfg,
1915
- threadId: threadTarget,
1916
- accountId: _route.accountId,
1917
- extraContent: quietDraftStreaming
1918
- ? buildMatrixFinalizedPreviewContent()
1919
- : undefined,
1920
- }).then(
1921
- () => true,
1922
- () => false,
1923
- );
1924
- } else if (textEditOk && reusesDraftTextUnchanged) {
1925
- textEditOk = await draftStream.finalizeLive();
1926
- }
1927
- const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
1928
- if (!reusesDraftAsFinalText) {
1929
- await redactMatrixDraftEvent(client, roomId, draftEventId);
1930
- }
1931
- const mediaPayload =
1932
- ttsSupplement && reusesDraftAsFinalText
1933
- ? buildTtsSupplementMediaPayload(payload)
1934
- : {
1935
- ...payload,
1936
- text: reusesDraftAsFinalText
1937
- ? undefined
1938
- : (payload.text ??
1939
- (ttsSupplement?.visibleTextAlreadyDelivered === true
1940
- ? undefined
1941
- : ttsSupplement?.spokenText)),
1942
- };
1943
- await deliverMatrixReplies({
1944
- cfg,
1945
- replies: [mediaPayload],
1946
- roomId,
1947
- client,
1948
- runtime,
1949
- textLimit,
1950
- replyToMode,
1951
- threadId: threadTarget,
1952
- accountId: _route.accountId,
1953
- mediaLocalRoots,
1954
- tableMode,
1955
- });
1956
- draftConsumed = true;
1957
- } else {
1958
- const draftRedacted =
1959
- Boolean(draftEventId) &&
1960
- (payload.isError || payloadReplyMismatch || mustDeliverFinalNormally);
1961
- if (draftRedacted && draftEventId) {
1962
- await redactMatrixDraftEvent(client, roomId, draftEventId);
1963
- }
1964
- const deliveredFallback = await deliverMatrixReplies({
1965
- cfg,
1966
- replies: [fallbackPayload],
1967
- roomId,
1968
- client,
1969
- runtime,
1970
- textLimit,
1971
- replyToMode,
1972
- threadId: threadTarget,
1973
- accountId: _route.accountId,
1974
- mediaLocalRoots,
1975
- tableMode,
1976
- });
1977
- if (draftRedacted || deliveredFallback) {
1978
- draftConsumed = true;
1979
- }
1980
- }
1981
-
1982
- if (info.kind === "block") {
1983
- draftConsumed = false;
1984
- advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
1985
- draftStream.reset();
1986
- currentDraftReplyToId = replyToMode === "all" ? draftReplyToId : undefined;
1987
- updateDraftFromLatestFullText();
1988
-
1989
- // Re-assert typing so the user still sees the indicator while
1990
- // the next block generates.
1991
- const { sendTypingMatrix } = await loadMatrixSendModule();
1992
- await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
1993
- }
1994
- } else {
1995
- await deliverMatrixReplies({
1996
- cfg,
1997
- replies: [payload],
1998
- roomId,
1999
- client,
2000
- runtime,
2001
- textLimit,
2002
- replyToMode,
2003
- threadId: threadTarget,
2004
- accountId: _route.accountId,
2005
- mediaLocalRoots,
2006
- tableMode,
2007
- });
2008
- }
2009
- },
2010
- onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
2011
- if (err instanceof MatrixRetryableInboundError) {
2012
- retryableReplyDeliveryFailed = true;
2013
- }
2014
- if (info.kind === "final") {
2015
- finalReplyDeliveryFailed = true;
2016
- } else {
2017
- nonFinalReplyDeliveryFailed = true;
2018
- }
2019
- if (info.kind === "block") {
2020
- advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
2021
- }
2022
- runtime.error?.(`matrix ${info.kind} reply failed: ${String(err)}`);
2023
- },
2024
- onReplyStart: typingCallbacks.onReplyStart,
2025
- onIdle: typingCallbacks.onIdle,
2026
- });
2027
- const pinnedMainDmOwner = isDirectMessage
2028
- ? await (async () => {
2029
- const livePinnedCfg = core.config.current() as CoreConfig;
2030
- const livePinnedAllowlists = resolveMatrixAccountAllowlistConfig({
2031
- cfg: livePinnedCfg,
2032
- accountId,
2033
- });
2034
- const livePinnedDmAllowFrom = await resolveCachedLiveAllowlist({
2035
- cfg: livePinnedCfg,
2036
- entries: livePinnedAllowlists.dmAllowFrom,
2037
- startupResolvedEntries: allowFromResolvedEntries,
2038
- cache: liveDmAllowlistCache,
2039
- updateCache: (next) => {
2040
- liveDmAllowlistCache = next;
2041
- },
2042
- });
2043
- return resolvePinnedMainDmOwnerFromAllowlist({
2044
- dmScope: livePinnedCfg.session?.dmScope,
2045
- allowFrom: livePinnedDmAllowFrom,
2046
- normalizeEntry: normalizeMatrixUserId,
2047
- });
2048
- })()
2049
- : null;
2050
-
2051
- const inboundLastRouteSessionKey = resolveInboundLastRouteSessionKey({
2052
- route: _route,
2053
- sessionKey: _route.sessionKey,
2054
- });
2055
-
2056
- const turnResult = await core.channel.turn.run({
2057
- channel: "matrix",
2058
- accountId: _route.accountId,
2059
- raw: event,
2060
- adapter: {
2061
- ingest: () => ({
2062
- id: messageId,
2063
- rawText: bodyText,
2064
- textForAgent: ctxPayload.BodyForAgent,
2065
- textForCommands: ctxPayload.CommandBody,
2066
- raw: event,
2067
- }),
2068
- resolveTurn: () => ({
2069
- channel: "matrix",
2070
- accountId: _route.accountId,
2071
- routeSessionKey: _route.sessionKey,
2072
- storePath,
2073
- ctxPayload,
2074
- recordInboundSession: core.channel.session.recordInboundSession,
2075
- botLoopProtection,
2076
- record: {
2077
- updateLastRoute: isDirectMessage
2078
- ? {
2079
- sessionKey: inboundLastRouteSessionKey,
2080
- channel: "matrix",
2081
- to: `room:${roomId}`,
2082
- accountId: _route.accountId,
2083
- mainDmOwnerPin:
2084
- inboundLastRouteSessionKey === _route.mainSessionKey && pinnedMainDmOwner
2085
- ? {
2086
- ownerRecipient: pinnedMainDmOwner,
2087
- senderRecipient: normalizeMatrixUserId(senderId),
2088
- onSkip: ({
2089
- ownerRecipient,
2090
- senderRecipient,
2091
- }: {
2092
- ownerRecipient: string;
2093
- senderRecipient: string;
2094
- }) => {
2095
- logVerboseMessage(
2096
- `matrix: skip main-session last route for ${senderRecipient} (pinned owner ${ownerRecipient})`,
2097
- );
2098
- },
2099
- }
2100
- : undefined,
2101
- }
2102
- : undefined,
2103
- onRecordError: (err) => {
2104
- logger.warn("failed updating session meta", {
2105
- error: String(err),
2106
- storePath,
2107
- sessionKey: ctxPayload.SessionKey ?? _route.sessionKey,
2108
- });
2109
- },
2110
- },
2111
- onPreDispatchFailure: () =>
2112
- core.channel.reply.settleReplyDispatcher({
2113
- dispatcher,
2114
- onSettled: () => {
2115
- markRunComplete();
2116
- markDispatchIdle();
2117
- },
2118
- }),
2119
- runDispatch: async () => {
2120
- if (
2121
- sharedDmContextNotice &&
2122
- markTrackedRoomIfFirst(sharedDmContextNoticeRooms, roomId)
2123
- ) {
2124
- client
2125
- .sendMessage(roomId, {
2126
- msgtype: "m.notice",
2127
- body: sharedDmContextNotice,
2128
- })
2129
- .catch((err) => {
2130
- logVerboseMessage(
2131
- `matrix: failed sending shared DM session notice room=${roomId}: ${String(err)}`,
2132
- );
2133
- });
2134
- }
2135
-
2136
- return await core.channel.reply.withReplyDispatcher({
2137
- dispatcher,
2138
- onSettled: () => {
2139
- markDispatchIdle();
2140
- },
2141
- run: async () => {
2142
- try {
2143
- return await core.channel.reply.dispatchReplyFromConfig({
2144
- ctx: ctxPayload,
2145
- cfg,
2146
- dispatcher,
2147
- replyOptions: {
2148
- ...replyOptions,
2149
- skillFilter: roomConfig?.skills,
2150
- // Keep block streaming enabled when explicitly requested, even
2151
- // with draft previews on. The draft remains the live preview
2152
- // for the current assistant block, while block deliveries
2153
- // finalize completed blocks into their own preserved events.
2154
- disableBlockStreaming: !blockStreamingEnabled,
2155
- onPartialReply: draftStream
2156
- ? (payload) => {
2157
- if (progressDraftStreaming) {
2158
- return;
2159
- }
2160
- latestDraftFullText = payload.text ?? "";
2161
- suppressPreviewToolProgressForAnswerText(latestDraftFullText);
2162
- updateDraftFromLatestFullText();
2163
- }
2164
- : undefined,
2165
- onBlockReplyQueued: draftStream
2166
- ? (payload, context) => {
2167
- if (payload.isCompactionNotice === true) {
2168
- return;
2169
- }
2170
- queueDraftBlockBoundary(payload, context);
2171
- }
2172
- : undefined,
2173
- // Reset draft boundary bookkeeping on assistant message
2174
- // boundaries so post-tool blocks stream from a fresh
2175
- // cumulative payload (payload.text resets upstream).
2176
- onAssistantMessageStart: draftStream
2177
- ? () => {
2178
- resetDraftBlockOffsets();
2179
- resetPreviewToolProgress();
2180
- }
2181
- : undefined,
2182
- ...buildPreviewToolProgressReplyOptions(),
2183
- onModelSelected,
2184
- },
2185
- });
2186
- } finally {
2187
- progressDraftGate.cancel();
2188
- markRunComplete();
2189
- }
2190
- },
2191
- });
2192
- },
2193
- }),
2194
- },
2195
- });
2196
- if (!turnResult.dispatched) {
2197
- if (
2198
- turnResult.admission.kind === "drop" &&
2199
- turnResult.admission.reason === "bot-loop-protection"
2200
- ) {
2201
- await commitInboundEventIfClaimed();
2202
- }
2203
- return;
2204
- }
2205
- const { dispatchResult } = turnResult;
2206
- const { queuedFinal, counts } = dispatchResult;
2207
- if (finalReplyDeliveryFailed) {
2208
- if (retryableReplyDeliveryFailed) {
2209
- logVerboseMessage(
2210
- `matrix: final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
2211
- );
2212
- // Explicit retryable failures reopen replay so the same history can be retried.
2213
- return;
2214
- }
2215
- logVerboseMessage(
2216
- `matrix: final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`,
2217
- );
2218
- await commitInboundEventIfClaimed();
2219
- return;
2220
- }
2221
- if (!queuedFinal && nonFinalReplyDeliveryFailed) {
2222
- if (retryableReplyDeliveryFailed) {
2223
- logVerboseMessage(
2224
- `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; leaving event uncommitted`,
2225
- );
2226
- // Explicit retryable failures reopen replay.
2227
- return;
2228
- }
2229
- logVerboseMessage(
2230
- `matrix: non-final reply delivery failed room=${roomId} id=${messageId}; keeping replay committed`,
2231
- );
2232
- await commitInboundEventIfClaimed();
2233
- return;
2234
- }
2235
- // Advance the per-agent watermark now that the reply succeeded (or no reply was needed).
2236
- // Only advance to the snapshot position — messages added during async processing remain
2237
- // visible for the next trigger.
2238
- if (isRoom && triggerSnapshot) {
2239
- roomHistoryTracker.consumeHistory(_route.agentId, roomId, triggerSnapshot, messageId);
2240
- }
2241
- if (!hasFinalInboundReplyDispatch({ queuedFinal, counts })) {
2242
- await commitInboundEventIfClaimed();
2243
- return;
2244
- }
2245
- const finalCount = counts.final;
2246
- logVerboseMessage(
2247
- `matrix: delivered ${finalCount} reply${finalCount === 1 ? "" : "ies"} to ${replyTarget}`,
2248
- );
2249
- await commitInboundEventIfClaimed();
2250
- } catch (err) {
2251
- runtime.error?.(`matrix handler failed: ${String(err)}`);
2252
- } finally {
2253
- // Stop the draft stream timer so partial drafts don't leak if the
2254
- // model run throws or times out mid-stream.
2255
- if (draftStreamRef) {
2256
- const draftEventId = await draftStreamRef.stop().catch(() => undefined);
2257
- if (draftEventId && !draftConsumed) {
2258
- await redactMatrixDraftEvent(client, roomId, draftEventId);
2259
- }
2260
- }
2261
- if (claimedInboundEvent && inboundDeduper && eventId) {
2262
- inboundDeduper.releaseEvent({ roomId, eventId });
2263
- }
2264
- }
2265
- };
2266
- }