mercury-agent 0.5.16 → 0.5.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mercury-agent",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
4
4
  "description": "Personal AI assistant for chat platforms (WhatsApp, Slack, Discord, Telegram)",
5
5
  "license": "MIT",
6
6
  "author": "Avishai Tsabari",
@@ -86,6 +86,7 @@ export function resolveConversation(
86
86
 
87
87
  seedSpaceConfigIfAbsent(db, spaceId, "trigger.match", "always");
88
88
  seedSpaceConfigIfAbsent(db, spaceId, "context.mode", "context");
89
+ seedSpaceConfigIfAbsent(db, spaceId, "debounce.idle_timeout_ms", "2000");
89
90
  if (autoSpace.defaultMemberPermissions) {
90
91
  seedSpaceConfigIfAbsent(
91
92
  db,
@@ -0,0 +1,135 @@
1
+ import { logger } from "../logger.js";
2
+ import type { IngressMessage } from "../types.js";
3
+
4
+ interface DebounceEntry {
5
+ ingresses: IngressMessage[];
6
+ timer: ReturnType<typeof setTimeout>;
7
+ }
8
+
9
+ interface ProcessingEntry {
10
+ pending: IngressMessage[] | null;
11
+ }
12
+
13
+ type FlushCallback = (merged: IngressMessage) => Promise<void>;
14
+
15
+ const DEBOUNCE_PLATFORMS: Record<string, number> = {
16
+ whatsapp: 2000,
17
+ telegram: 2000,
18
+ };
19
+
20
+ export function getDefaultDebounceMs(platform: string): number {
21
+ return DEBOUNCE_PLATFORMS[platform] ?? 0;
22
+ }
23
+
24
+ export function mergeIngresses(batch: IngressMessage[]): IngressMessage {
25
+ if (batch.length === 1) return batch[0];
26
+
27
+ const first = batch[0];
28
+ const last = batch[batch.length - 1];
29
+
30
+ return {
31
+ platform: first.platform,
32
+ spaceId: first.spaceId,
33
+ conversationExternalId: first.conversationExternalId,
34
+ callerId: first.callerId,
35
+ authorName: first.authorName,
36
+ text: batch.map((m) => m.text).join("\n"),
37
+ isDM: first.isDM,
38
+ isReplyToBot: first.isReplyToBot,
39
+ attachments: batch.flatMap((m) => m.attachments),
40
+ hadIncomingAttachments: batch.some((m) => m.hadIncomingAttachments),
41
+ replyToPlatformMessageId: first.replyToPlatformMessageId,
42
+ platformMessageId: last.platformMessageId,
43
+ };
44
+ }
45
+
46
+ export class MessageDebouncer {
47
+ private batches = new Map<string, DebounceEntry>();
48
+ private processing = new Map<string, ProcessingEntry>();
49
+
50
+ submit(
51
+ key: string,
52
+ ingress: IngressMessage,
53
+ timeoutMs: number,
54
+ onFlush: FlushCallback,
55
+ ): void {
56
+ const proc = this.processing.get(key);
57
+ if (proc) {
58
+ if (!proc.pending) {
59
+ proc.pending = [ingress];
60
+ } else {
61
+ proc.pending.push(ingress);
62
+ }
63
+ return;
64
+ }
65
+
66
+ const existing = this.batches.get(key);
67
+ if (existing) {
68
+ clearTimeout(existing.timer);
69
+ existing.ingresses.push(ingress);
70
+ existing.timer = setTimeout(() => {
71
+ this.flush(key, onFlush);
72
+ }, timeoutMs);
73
+ } else {
74
+ const timer = setTimeout(() => {
75
+ this.flush(key, onFlush);
76
+ }, timeoutMs);
77
+ this.batches.set(key, { ingresses: [ingress], timer });
78
+ }
79
+ }
80
+
81
+ flushKey(key: string, onFlush: FlushCallback): void {
82
+ const entry = this.batches.get(key);
83
+ if (!entry) return;
84
+ clearTimeout(entry.timer);
85
+ this.batches.delete(key);
86
+ this.executeFlush(key, entry.ingresses, onFlush);
87
+ }
88
+
89
+ isProcessing(key: string): boolean {
90
+ return this.processing.has(key);
91
+ }
92
+
93
+ flushAll(onFlush: FlushCallback): void {
94
+ for (const [key, entry] of this.batches) {
95
+ clearTimeout(entry.timer);
96
+ this.executeFlush(key, entry.ingresses, onFlush);
97
+ }
98
+ this.batches.clear();
99
+ }
100
+
101
+ private flush(key: string, onFlush: FlushCallback): void {
102
+ const entry = this.batches.get(key);
103
+ if (!entry) return;
104
+ this.batches.delete(key);
105
+ this.executeFlush(key, entry.ingresses, onFlush);
106
+ }
107
+
108
+ private executeFlush(
109
+ key: string,
110
+ ingresses: IngressMessage[],
111
+ onFlush: FlushCallback,
112
+ ): void {
113
+ const merged = mergeIngresses(ingresses);
114
+ this.processing.set(key, { pending: null });
115
+
116
+ onFlush(merged)
117
+ .catch((err) => {
118
+ logger.error("Debounce flush error", {
119
+ key,
120
+ error: err instanceof Error ? err.message : String(err),
121
+ });
122
+ })
123
+ .finally(() => {
124
+ const proc = this.processing.get(key);
125
+ if (proc?.pending) {
126
+ const batch = proc.pending;
127
+ proc.pending = null;
128
+ this.processing.delete(key);
129
+ this.executeFlush(key, batch, onFlush);
130
+ } else {
131
+ this.processing.delete(key);
132
+ }
133
+ });
134
+ }
135
+ }
@@ -2,12 +2,17 @@ import type { Adapter, Message } from "chat";
2
2
  import { telegramInboundLooksLikeMedia } from "../bridges/telegram.js";
3
3
  import type { AppConfig } from "../config.js";
4
4
  import { logger } from "../logger.js";
5
- import type { NormalizeContext, PlatformBridge } from "../types.js";
5
+ import type {
6
+ IngressMessage,
7
+ NormalizeContext,
8
+ PlatformBridge,
9
+ } from "../types.js";
6
10
  import {
7
11
  type AutoSpaceConfig,
8
12
  inferConversationKind,
9
13
  resolveConversation,
10
14
  } from "./conversation.js";
15
+ import { getDefaultDebounceMs, MessageDebouncer } from "./debounce.js";
11
16
  import type { MercuryCoreRuntime } from "./runtime.js";
12
17
  import { loadTriggerConfig, matchTrigger } from "./trigger.js";
13
18
 
@@ -38,6 +43,105 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
38
43
  }
39
44
  : undefined;
40
45
 
46
+ const debouncer = new MessageDebouncer();
47
+
48
+ async function processIngress(
49
+ ingress: IngressMessage,
50
+ threadId: string,
51
+ ): Promise<void> {
52
+ const startTime = Date.now();
53
+ let lastStatusMessageId: string | undefined;
54
+ let hadStatusMessage = false;
55
+
56
+ const heartbeat = setInterval(() => {
57
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
58
+ const statusText = `⏳ Processing… (${elapsed}s)`;
59
+ const currentId = lastStatusMessageId;
60
+
61
+ if (currentId && bridge.editMessage) {
62
+ bridge
63
+ .editMessage(threadId, currentId, statusText)
64
+ .then((ok) => {
65
+ if (!ok) {
66
+ bridge
67
+ .sendReply(threadId, statusText)
68
+ .then(async (id) => {
69
+ await bridge
70
+ .deleteMessages?.(threadId, [currentId])
71
+ .catch(() => {});
72
+ if (id) lastStatusMessageId = id;
73
+ })
74
+ .catch(() => {});
75
+ }
76
+ })
77
+ .catch(() => {});
78
+ } else {
79
+ const prevId = lastStatusMessageId;
80
+ bridge
81
+ .sendReply(threadId, statusText)
82
+ .then(async (id) => {
83
+ if (prevId) {
84
+ await bridge.deleteMessages?.(threadId, [prevId]).catch(() => {});
85
+ }
86
+ if (id) {
87
+ lastStatusMessageId = id;
88
+ hadStatusMessage = true;
89
+ }
90
+ })
91
+ .catch(() => {});
92
+ }
93
+ }, 30_000);
94
+
95
+ let result: Awaited<ReturnType<typeof core.handleRawInput>>;
96
+ try {
97
+ result = await core.handleRawInput(ingress, "chat-sdk");
98
+ } finally {
99
+ clearInterval(heartbeat);
100
+ }
101
+
102
+ if (lastStatusMessageId) {
103
+ await bridge
104
+ .deleteMessages?.(threadId, [lastStatusMessageId])
105
+ .catch(() => {});
106
+ }
107
+
108
+ if (result.type === "ignore") return;
109
+
110
+ if (result.type === "denied") {
111
+ await bridge.sendReply(threadId, result.reason);
112
+ return;
113
+ }
114
+
115
+ if (result.result) {
116
+ const { reply, files, assistantMessageId } = result.result;
117
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
118
+ const finalReply =
119
+ hadStatusMessage && reply
120
+ ? `${reply}\n\n_(responded in ${elapsed}s)_`
121
+ : reply;
122
+ if (finalReply || files.length > 0) {
123
+ const sentPlatformId = await bridge.sendReply(
124
+ threadId,
125
+ finalReply,
126
+ files.length > 0 ? files : undefined,
127
+ );
128
+
129
+ if (
130
+ sentPlatformId &&
131
+ assistantMessageId &&
132
+ ingress.conversationExternalId
133
+ ) {
134
+ core.recordOutboundPlatformId(
135
+ assistantMessageId,
136
+ bridge.platform,
137
+ ingress.conversationExternalId,
138
+ sentPlatformId,
139
+ );
140
+ }
141
+ }
142
+ }
143
+ }
144
+
41
145
  return async (
42
146
  adapter: Adapter,
43
147
  threadId: string,
@@ -120,101 +224,38 @@ export function createMessageHandler(opts: MessageHandlerOptions) {
120
224
  }
121
225
  }
122
226
 
123
- const startTime = Date.now();
124
- let lastStatusMessageId: string | undefined;
125
- let hadStatusMessage = false;
126
-
127
- const heartbeat = setInterval(() => {
128
- const elapsed = Math.round((Date.now() - startTime) / 1000);
129
- const statusText = `⏳ Processing… (${elapsed}s)`;
130
- const currentId = lastStatusMessageId;
131
-
132
- if (currentId && bridge.editMessage) {
133
- bridge
134
- .editMessage(threadId, currentId, statusText)
135
- .then((ok) => {
136
- if (!ok) {
137
- // Edit failed — fall back to delete+send
138
- bridge
139
- .sendReply(threadId, statusText)
140
- .then(async (id) => {
141
- await bridge
142
- .deleteMessages?.(threadId, [currentId])
143
- .catch(() => {});
144
- if (id) lastStatusMessageId = id;
145
- })
146
- .catch(() => {});
147
- }
148
- })
149
- .catch(() => {});
150
- } else {
151
- const prevId = lastStatusMessageId;
152
- bridge
153
- .sendReply(threadId, statusText)
154
- .then(async (id) => {
155
- if (prevId) {
156
- await bridge
157
- .deleteMessages?.(threadId, [prevId])
158
- .catch(() => {});
159
- }
160
- if (id) {
161
- lastStatusMessageId = id;
162
- hadStatusMessage = true;
163
- }
164
- })
165
- .catch(() => {});
166
- }
167
- }, 30_000);
168
-
169
- let result: Awaited<ReturnType<typeof core.handleRawInput>>;
170
- try {
171
- result = await core.handleRawInput(ingress, "chat-sdk");
172
- } finally {
173
- clearInterval(heartbeat);
174
- }
227
+ const isCommand = text.startsWith("/");
228
+ const shouldProcess =
229
+ triggerResult.matched || (ingress.isReplyToBot && !isDM);
175
230
 
176
- if (lastStatusMessageId) {
177
- await bridge
178
- .deleteMessages?.(threadId, [lastStatusMessageId])
179
- .catch(() => {});
180
- }
231
+ if (!shouldProcess && !isCommand) return;
181
232
 
182
- if (result.type === "ignore") return;
233
+ const debounceKey = `${spaceId}:${ingress.callerId}`;
234
+ const onFlush = (merged: IngressMessage) =>
235
+ processIngress(merged, threadId);
183
236
 
184
- if (result.type === "denied") {
185
- await bridge.sendReply(threadId, result.reason);
237
+ if (isCommand) {
238
+ debouncer.flushKey(debounceKey, onFlush);
239
+ await processIngress(ingress, threadId);
186
240
  return;
187
241
  }
188
242
 
189
- if (result.result) {
190
- const { reply, files, assistantMessageId } = result.result;
191
- const elapsed = Math.round((Date.now() - startTime) / 1000);
192
- const finalReply =
193
- hadStatusMessage && reply
194
- ? `${reply}\n\n_(responded in ${elapsed}s)_`
195
- : reply;
196
- if (finalReply || files.length > 0) {
197
- const sentPlatformId = await bridge.sendReply(
198
- threadId,
199
- finalReply,
200
- files.length > 0 ? files : undefined,
201
- );
243
+ const configuredMs = core.db.getSpaceConfig(
244
+ spaceId,
245
+ "debounce.idle_timeout_ms",
246
+ );
247
+ const parsed =
248
+ configuredMs !== null ? Number.parseInt(configuredMs, 10) : Number.NaN;
249
+ const timeoutMs = Number.isNaN(parsed)
250
+ ? getDefaultDebounceMs(bridge.platform)
251
+ : parsed;
202
252
 
203
- // Record the platform message ID mapping for the bot's outbound message
204
- if (
205
- sentPlatformId &&
206
- assistantMessageId &&
207
- ingress.conversationExternalId
208
- ) {
209
- core.recordOutboundPlatformId(
210
- assistantMessageId,
211
- bridge.platform,
212
- ingress.conversationExternalId,
213
- sentPlatformId,
214
- );
215
- }
216
- }
253
+ if (timeoutMs <= 0) {
254
+ await processIngress(ingress, threadId);
255
+ return;
217
256
  }
257
+
258
+ debouncer.submit(debounceKey, ingress, timeoutMs, onFlush);
218
259
  } catch (err) {
219
260
  logger.error("Message handler error", {
220
261
  platform: bridge.platform,
@@ -13,6 +13,7 @@ export const BUILTIN_CONFIG_KEYS = new Set([
13
13
  "rate_limit",
14
14
  "rate_limit.member",
15
15
  "rate_limit.admin",
16
+ "debounce.idle_timeout_ms",
16
17
  ]);
17
18
 
18
19
  /**
@@ -46,6 +47,8 @@ export const BUILTIN_CONFIG_DESCRIPTIONS: Record<string, string> = {
46
47
  "Daily message cap for members in this space. Overrides global rate_limit_daily_member. Integer ≥ 1, or 0 for unlimited.",
47
48
  "rate_limit.admin":
48
49
  "Daily message cap for admins in this space. Overrides global rate_limit_daily_admin. Integer ≥ 1, or 0 for unlimited.",
50
+ "debounce.idle_timeout_ms":
51
+ "Milliseconds to wait for additional messages before processing a batch. 0 disables debounce. Platform default: 2000 for WhatsApp/Telegram, 0 for others.",
49
52
  };
50
53
 
51
54
  const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
@@ -103,6 +106,12 @@ const BUILTIN_VALIDATORS: Record<string, (v: string) => string | null> = {
103
106
  ? null
104
107
  : "Invalid rate_limit.admin value. Must be an integer between 0 and 10000";
105
108
  },
109
+ "debounce.idle_timeout_ms": (v) => {
110
+ const n = Number.parseInt(v, 10);
111
+ return Number.isInteger(n) && n >= 0 && n <= 10000
112
+ ? null
113
+ : "Invalid debounce.idle_timeout_ms value. Must be an integer between 0 and 10000";
114
+ },
106
115
  };
107
116
 
108
117
  export function isBuiltinConfigKey(key: string): boolean {