@hybridaione/hybridclaw 0.2.2 → 0.2.6

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 (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,18 +1,22 @@
1
- import { ActivityType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
- import { DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_PRESENCE_INTENT, DISCORD_PREFIX, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_TOKEN, } from '../../config.js';
1
+ import { ApplicationCommandOptionType, Client, GatewayIntentBits, Partials, } from 'discord.js';
2
+ import { DISCORD_ACK_REACTION, DISCORD_ACK_REACTION_SCOPE, DISCORD_COMMAND_USER_ID, DISCORD_COMMANDS_ONLY, DISCORD_DEBOUNCE_MS, DISCORD_FREE_RESPONSE_CHANNELS, DISCORD_GROUP_POLICY, DISCORD_GUILD_MEMBERS_INTENT, DISCORD_GUILDS, DISCORD_HUMAN_DELAY, DISCORD_LIFECYCLE_REACTIONS, DISCORD_MAX_CONCURRENT_PER_CHANNEL, DISCORD_PREFIX, DISCORD_PRESENCE_INTENT, DISCORD_RATE_LIMIT_EXEMPT_ROLES, DISCORD_RATE_LIMIT_PER_USER, DISCORD_REMOVE_ACK_AFTER_REPLY, DISCORD_RESPOND_TO_ALL_MESSAGES, DISCORD_SELF_PRESENCE, DISCORD_SUPPRESS_PATTERNS, DISCORD_TOKEN, DISCORD_TYPING_MODE, } from '../../config.js';
3
+ import { logger } from '../../logger.js';
3
4
  import { buildAttachmentContext } from './attachments.js';
5
+ import { DEFAULT_DEBOUNCE_MAX_BUFFER, resolveInboundDebounceMs, shouldDebounceInbound, } from './debounce.js';
6
+ import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
4
7
  import { buildSessionIdFromContext as buildSessionIdFromContextInbound, cleanIncomingContent as cleanIncomingContentInbound, hasPrefixInvocation as hasPrefixInvocationInbound, isTrigger as isTriggerInbound, parseCommand as parseCommandInbound, } from './inbound.js';
5
8
  import { addMentionAlias, extractMentionAliasHints, normalizeMentionAlias, } from './mentions.js';
6
- import { formatError, prepareChunkedPayloads, sendChunkedDirectReply as sendChunkedDirectReplyFromDelivery, sendChunkedInteractionReply as sendChunkedInteractionReplyFromDelivery, sendChunkedReply as sendChunkedReplyFromDelivery, } from './delivery.js';
7
- import { createDiscordToolActionRunner, } from './tool-actions.js';
9
+ import { DiscordAutoPresenceController, } from './presence.js';
10
+ import { SlidingWindowRateLimiter } from './rate-limiter.js';
11
+ import { addAckReaction, LifecycleReactionController, } from './reactions.js';
8
12
  import { DiscordStreamManager } from './stream.js';
9
- import { logger } from '../../logger.js';
13
+ import { createDiscordToolActionRunner, } from './tool-actions.js';
14
+ import { createTypingController } from './typing.js';
10
15
  let client;
11
16
  let messageHandler;
12
17
  let commandHandler;
13
18
  let activeConversationRuns = 0;
14
19
  let botMentionRegex = null;
15
- const MESSAGE_DEBOUNCE_MS = 2_500;
16
20
  const DISCORD_RETRY_MAX_ATTEMPTS = 3;
17
21
  const DISCORD_RETRY_BASE_DELAY_MS = 500;
18
22
  const GUILD_INBOUND_HISTORY_LIMIT = 20;
@@ -22,7 +26,32 @@ const PARTICIPANT_MEMORY_MAX_CHANNELS = 200;
22
26
  const PARTICIPANT_MEMORY_MAX_USERS_PER_CHANNEL = 200;
23
27
  const PARTICIPANT_MEMORY_MAX_ALIASES_PER_USER = 8;
24
28
  const MAX_PRESENCE_CACHE_USERS = 5_000;
29
+ const RATE_LIMIT_NOTIFY_COOLDOWN_MS = 12_000;
30
+ const CONCURRENCY_RETRY_DELAY_MS = 250;
31
+ const PRESENCE_WINDOW_MS = 5 * 60_000;
32
+ const PRESENCE_DEGRADED_DURATION_MS = 45_000;
33
+ const PRESENCE_EXHAUSTED_ERROR_RE = /(api down|unavailable|rate limit|too many active containers|quota|token limit|timeout)/i;
34
+ const FRIENDLY_RATE_LIMIT_MESSAGE = "You're sending messages too fast — give me a moment to catch up!";
35
+ const READ_WITHOUT_REPLY_RE = /^(thanks|thank you|thx|ty|got it|ok|okay|cool|perfect|awesome|sounds good|roger)[!. ]*$/i;
36
+ const READ_WITHOUT_REPLY_PROBABILITY = 0.6;
37
+ const STARTUP_STAGGER_WINDOW_MS = 120_000;
38
+ const STARTUP_STAGGER_MIN_DELAY_MS = 500;
39
+ const STARTUP_STAGGER_MAX_DELAY_MS = 3_500;
40
+ const SELECTIVE_SILENCE_BASE_PROBABILITY = 0.25;
41
+ const SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY = 0.5;
42
+ const SELECTIVE_SILENCE_RECENT_WINDOW_MS = 60_000;
43
+ const NIGHT_HOURS_START = 22;
44
+ const NIGHT_HOURS_END = 7;
45
+ const CONVERSATION_COOLDOWN_RESET_MS = 20 * 60_000;
46
+ const CONVERSATION_COOLDOWN_THRESHOLD = 5;
47
+ const CONVERSATION_COOLDOWN_MAX_FACTOR = 2.5;
25
48
  const discordPresenceCache = new Map();
49
+ const userRateLimiter = new SlidingWindowRateLimiter(60_000);
50
+ const recentConversationMetrics = [];
51
+ let consecutiveConversationFailures = 0;
52
+ let presenceController = null;
53
+ let startupConnectedAtMs = 0;
54
+ const conversationExchangeByKey = new Map();
26
55
  function setDiscordPresence(userId, data) {
27
56
  discordPresenceCache.set(userId, data);
28
57
  if (discordPresenceCache.size > MAX_PRESENCE_CACHE_USERS) {
@@ -90,7 +119,8 @@ function buildPendingHistoryContext(entries) {
90
119
  const line = summarizePendingHistoryEntry(entries[i]);
91
120
  if (!line)
92
121
  continue;
93
- if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS && selected.length > 0)
122
+ if (totalChars + line.length > GUILD_INBOUND_HISTORY_MAX_CHARS &&
123
+ selected.length > 0)
94
124
  break;
95
125
  selected.push(line);
96
126
  totalChars += line.length + 1;
@@ -110,7 +140,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
110
140
  if (!msg.guild || !('messages' in msg.channel))
111
141
  return { entries: [], context: '' };
112
142
  try {
113
- const recentMessages = await msg.channel.messages.fetch({ limit: GUILD_INBOUND_HISTORY_LIMIT });
143
+ const recentMessages = await msg.channel.messages.fetch({
144
+ limit: GUILD_INBOUND_HISTORY_LIMIT,
145
+ });
114
146
  const entries = [];
115
147
  let hiddenTextCount = 0;
116
148
  let hiddenBotTextCount = 0;
@@ -119,7 +151,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
119
151
  if (plainText)
120
152
  return plainText;
121
153
  const embedChunks = recent.embeds
122
- .map((embed) => [embed.title?.trim(), embed.description?.trim()].filter(Boolean).join(' — '))
154
+ .map((embed) => [embed.title?.trim(), embed.description?.trim()]
155
+ .filter(Boolean)
156
+ .join(' — '))
123
157
  .map((part) => part.trim())
124
158
  .filter(Boolean)
125
159
  .slice(0, 3);
@@ -133,7 +167,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
133
167
  if (attachmentNames.length > 0) {
134
168
  return `[attachments] ${attachmentNames.join(', ')}`;
135
169
  }
136
- const systemContent = recent.system ? (recent.cleanContent || '').trim() : '';
170
+ const systemContent = recent.system
171
+ ? (recent.cleanContent || '').trim()
172
+ : '';
137
173
  if (systemContent)
138
174
  return `[system] ${systemContent}`;
139
175
  hiddenTextCount += 1;
@@ -157,7 +193,9 @@ async function buildInboundHistorySnapshot(msg, excludeMessageIds) {
157
193
  username: recent.author.username || 'user',
158
194
  displayName: recent.member?.displayName || null,
159
195
  isBot: Boolean(recent.author.bot),
160
- timestampMs: Number.isFinite(recent.createdTimestamp) ? recent.createdTimestamp : 0,
196
+ timestampMs: Number.isFinite(recent.createdTimestamp)
197
+ ? recent.createdTimestamp
198
+ : 0,
161
199
  content,
162
200
  });
163
201
  }
@@ -314,29 +352,101 @@ function isAuthorizedCommandUserId(userId) {
314
352
  function buildSessionIdFromContext(guildId, channelId, userId) {
315
353
  return buildSessionIdFromContextInbound(guildId, channelId, userId);
316
354
  }
317
- function isTrigger(msg) {
355
+ function resolveGuildMessageMode(msg) {
356
+ if (!msg.guild)
357
+ return 'free';
358
+ if (DISCORD_GROUP_POLICY === 'disabled')
359
+ return 'off';
360
+ const guildConfig = DISCORD_GUILDS[msg.guild.id];
361
+ const explicitMode = guildConfig?.channels[msg.channelId]?.mode;
362
+ if (DISCORD_GROUP_POLICY === 'allowlist') {
363
+ return explicitMode ?? 'off';
364
+ }
365
+ if (explicitMode)
366
+ return explicitMode;
367
+ if (DISCORD_FREE_RESPONSE_CHANNELS.includes(msg.channelId))
368
+ return 'free';
369
+ if (guildConfig)
370
+ return guildConfig.defaultMode;
371
+ if (DISCORD_RESPOND_TO_ALL_MESSAGES)
372
+ return 'free';
373
+ return 'mention';
374
+ }
375
+ function resolveChannelBehavior(msg) {
376
+ const guildConfig = msg.guild ? DISCORD_GUILDS[msg.guild.id] : undefined;
377
+ const channelConfig = guildConfig?.channels[msg.channelId];
378
+ return {
379
+ guildMessageMode: resolveGuildMessageMode(msg),
380
+ typingMode: channelConfig?.typingMode ?? DISCORD_TYPING_MODE,
381
+ debounceMs: resolveInboundDebounceMs(DISCORD_DEBOUNCE_MS, channelConfig?.debounceMs),
382
+ ackReaction: (channelConfig?.ackReaction ?? DISCORD_ACK_REACTION).trim() ||
383
+ DISCORD_ACK_REACTION,
384
+ ackReactionScope: channelConfig?.ackReactionScope ?? DISCORD_ACK_REACTION_SCOPE,
385
+ removeAckAfterReply: channelConfig?.removeAckAfterReply ?? DISCORD_REMOVE_ACK_AFTER_REPLY,
386
+ humanDelay: channelConfig?.humanDelay ?? DISCORD_HUMAN_DELAY,
387
+ rateLimitPerUser: Math.max(0, channelConfig?.rateLimitPerUser ?? DISCORD_RATE_LIMIT_PER_USER),
388
+ suppressPatterns: channelConfig?.suppressPatterns ?? DISCORD_SUPPRESS_PATTERNS,
389
+ maxConcurrentPerChannel: Math.max(1, channelConfig?.maxConcurrentPerChannel ??
390
+ DISCORD_MAX_CONCURRENT_PER_CHANNEL),
391
+ };
392
+ }
393
+ function isTrigger(msg, behavior) {
318
394
  return isTriggerInbound({
319
395
  content: msg.content,
320
396
  isDm: !msg.guild,
321
397
  commandsOnly: DISCORD_COMMANDS_ONLY,
322
398
  respondToAllMessages: DISCORD_RESPOND_TO_ALL_MESSAGES,
399
+ guildMessageMode: behavior.guildMessageMode,
323
400
  prefix: DISCORD_PREFIX,
324
401
  botMentionRegex,
325
402
  hasBotMention: Boolean(client.user && msg.mentions.has(client.user)),
403
+ suppressPatterns: behavior.suppressPatterns,
326
404
  });
327
405
  }
406
+ function shouldApplyAckReaction(msg, behavior) {
407
+ const scope = behavior.ackReactionScope;
408
+ if (scope === 'off')
409
+ return false;
410
+ if (scope === 'all')
411
+ return true;
412
+ if (scope === 'direct')
413
+ return !msg.guild;
414
+ if (!msg.guild || !client.user)
415
+ return false;
416
+ return msg.mentions.has(client.user);
417
+ }
418
+ function isRateLimitExempt(msg) {
419
+ if (msg.author.id === DISCORD_COMMAND_USER_ID.trim())
420
+ return true;
421
+ if (!msg.guild)
422
+ return false;
423
+ if (!msg.member || DISCORD_RATE_LIMIT_EXEMPT_ROLES.length === 0)
424
+ return false;
425
+ const exemptByName = new Set(DISCORD_RATE_LIMIT_EXEMPT_ROLES.map((role) => role.trim().toLowerCase()).filter(Boolean));
426
+ if (exemptByName.size === 0)
427
+ return false;
428
+ for (const role of msg.member.roles.cache.values()) {
429
+ const normalized = role.name.trim().toLowerCase();
430
+ if (exemptByName.has(normalized))
431
+ return true;
432
+ }
433
+ return false;
434
+ }
328
435
  function parseCommand(content) {
329
436
  return parseCommandInbound(content, botMentionRegex, DISCORD_PREFIX);
330
437
  }
331
438
  function isRetryableDiscordError(error) {
332
439
  const maybe = error;
333
440
  const status = maybe.status ?? maybe.httpStatus;
334
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
441
+ return (status === 429 ||
442
+ (typeof status === 'number' && status >= 500 && status <= 599));
335
443
  }
336
444
  function retryDelayMs(error, fallbackMs) {
337
445
  const maybe = error;
338
446
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
339
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
447
+ if (typeof retryAfterSeconds === 'number' &&
448
+ Number.isFinite(retryAfterSeconds) &&
449
+ retryAfterSeconds > 0) {
340
450
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
341
451
  }
342
452
  return fallbackMs + Math.floor(Math.random() * 250);
@@ -350,7 +460,8 @@ async function withDiscordRetry(label, fn) {
350
460
  return await fn();
351
461
  }
352
462
  catch (error) {
353
- if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS || !isRetryableDiscordError(error)) {
463
+ if (attempt >= DISCORD_RETRY_MAX_ATTEMPTS ||
464
+ !isRetryableDiscordError(error)) {
354
465
  throw error;
355
466
  }
356
467
  const waitMs = retryDelayMs(error, delayMs);
@@ -386,7 +497,9 @@ function buildChannelInfoContext(msg) {
386
497
  if (channelTopic) {
387
498
  lines.push(`- channel_topic: ${channelTopic}`);
388
499
  }
389
- const parentName = typeof namedChannel.parent?.name === 'string' ? namedChannel.parent.name.trim() : '';
500
+ const parentName = typeof namedChannel.parent?.name === 'string'
501
+ ? namedChannel.parent.name.trim()
502
+ : '';
390
503
  if (parentName) {
391
504
  lines.push(`- parent_channel: ${parentName}`);
392
505
  }
@@ -395,7 +508,9 @@ function buildChannelInfoContext(msg) {
395
508
  }
396
509
  async function buildReplyContext(msg) {
397
510
  const blocks = [];
398
- if ('isThread' in msg.channel && typeof msg.channel.isThread === 'function' && msg.channel.isThread()) {
511
+ if ('isThread' in msg.channel &&
512
+ typeof msg.channel.isThread === 'function' &&
513
+ msg.channel.isThread()) {
399
514
  try {
400
515
  const starter = await msg.channel.fetchStarterMessage();
401
516
  if (starter) {
@@ -427,70 +542,22 @@ async function buildReplyContext(msg) {
427
542
  return '';
428
543
  return `${blocks.join('\n\n')}\n\n`;
429
544
  }
430
- async function addProcessingReaction(msg) {
431
- if (!client.user)
432
- return async () => { };
433
- const botUserId = client.user.id;
434
- try {
435
- await withDiscordRetry('react', () => msg.react('👀'));
436
- }
437
- catch (error) {
438
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to add processing reaction');
439
- return async () => { };
440
- }
441
- return async () => {
442
- try {
443
- const reaction = msg.reactions.resolve('👀');
444
- if (!reaction)
445
- return;
446
- await withDiscordRetry('reaction-remove', () => reaction.users.remove(botUserId));
447
- }
448
- catch (error) {
449
- logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed to remove processing reaction');
450
- }
451
- };
452
- }
453
- function startTypingLoop(msg) {
454
- let stopped = false;
455
- const sendTyping = async () => {
456
- if (stopped)
457
- return;
458
- if (!('sendTyping' in msg.channel))
459
- return;
460
- try {
461
- await msg.channel.sendTyping();
462
- }
463
- catch (error) {
464
- logger.debug({ error, channelId: msg.channelId }, 'Failed to send typing indicator');
465
- }
466
- };
467
- void sendTyping();
468
- const timer = setInterval(() => {
469
- void sendTyping();
470
- }, 8_000);
471
- return {
472
- stop: () => {
473
- if (stopped)
474
- return;
475
- stopped = true;
476
- clearInterval(timer);
477
- },
478
- };
479
- }
480
- async function sendChunkedReply(msg, text, files, mentionLookup) {
545
+ async function sendChunkedReply(msg, text, files, mentionLookup, humanDelay) {
481
546
  await sendChunkedReplyFromDelivery({
482
547
  msg,
483
548
  text,
484
549
  withRetry: withDiscordRetry,
550
+ ...(humanDelay ? { humanDelay } : {}),
485
551
  ...(files ? { files } : {}),
486
552
  ...(mentionLookup ? { mentionLookup } : {}),
487
553
  });
488
554
  }
489
- async function sendChunkedDirectReply(msg, text, files, mentionLookup) {
555
+ async function sendChunkedDirectReply(msg, text, files, mentionLookup, humanDelay) {
490
556
  await sendChunkedDirectReplyFromDelivery({
491
557
  msg,
492
558
  text,
493
559
  withRetry: withDiscordRetry,
560
+ ...(humanDelay ? { humanDelay } : {}),
494
561
  ...(files ? { files } : {}),
495
562
  ...(mentionLookup ? { mentionLookup } : {}),
496
563
  });
@@ -503,52 +570,177 @@ async function sendChunkedInteractionReply(interaction, text, files) {
503
570
  ...(files ? { files } : {}),
504
571
  });
505
572
  }
506
- async function ensureSlashStatusCommand() {
507
- const definition = {
508
- name: 'status',
509
- description: 'Show HybridClaw runtime status (only visible to you)',
510
- };
573
+ async function ensureSlashCommands() {
574
+ const definitions = [
575
+ {
576
+ name: 'status',
577
+ description: 'Show HybridClaw runtime status (only visible to you)',
578
+ },
579
+ {
580
+ name: 'channel-mode',
581
+ description: 'Set this channel to off, mention-only, or free-response',
582
+ options: [
583
+ {
584
+ type: ApplicationCommandOptionType.String,
585
+ name: 'mode',
586
+ description: 'Response mode for this channel',
587
+ required: true,
588
+ choices: [
589
+ { name: 'off', value: 'off' },
590
+ { name: 'mention', value: 'mention' },
591
+ { name: 'free', value: 'free' },
592
+ ],
593
+ },
594
+ ],
595
+ },
596
+ {
597
+ name: 'channel-policy',
598
+ description: 'Set guild channel policy to open, allowlist, or disabled',
599
+ options: [
600
+ {
601
+ type: ApplicationCommandOptionType.String,
602
+ name: 'policy',
603
+ description: 'Guild channel policy',
604
+ required: true,
605
+ choices: [
606
+ { name: 'open', value: 'open' },
607
+ { name: 'allowlist', value: 'allowlist' },
608
+ { name: 'disabled', value: 'disabled' },
609
+ ],
610
+ },
611
+ ],
612
+ },
613
+ ];
511
614
  if (!client.application)
512
615
  return;
513
616
  await Promise.allSettled([...client.guilds.cache.values()].map(async (guild) => {
514
617
  try {
515
618
  const existing = await guild.commands.fetch();
516
- const current = existing.find((command) => command.name === definition.name);
517
- if (!current) {
518
- await guild.commands.create(definition);
519
- logger.info({ guildId: guild.id }, 'Registered slash command /status');
520
- return;
521
- }
522
- if (current.description !== definition.description) {
619
+ for (const definition of definitions) {
620
+ const current = existing.find((command) => command.name === definition.name);
621
+ if (!current) {
622
+ await guild.commands.create(definition);
623
+ logger.info({ guildId: guild.id, command: definition.name }, 'Registered slash command');
624
+ continue;
625
+ }
523
626
  await guild.commands.edit(current.id, definition);
524
- logger.info({ guildId: guild.id }, 'Updated slash command /status');
627
+ logger.info({ guildId: guild.id, command: definition.name }, 'Updated slash command');
525
628
  }
526
629
  }
527
630
  catch (error) {
528
- logger.warn({ error, guildId: guild.id }, 'Failed to register slash command /status');
631
+ logger.warn({ error, guildId: guild.id }, 'Failed to register Discord slash commands');
529
632
  }
530
633
  }));
531
634
  }
532
- function updatePresence() {
533
- if (!client.user)
534
- return;
535
- if (activeConversationRuns > 0) {
536
- client.user.setPresence({
537
- activities: [{ name: 'Thinking...', type: ActivityType.Playing }],
538
- status: 'online',
539
- });
635
+ function trimRecentConversationMetrics(nowMs = Date.now()) {
636
+ const cutoff = nowMs - PRESENCE_WINDOW_MS;
637
+ while (recentConversationMetrics.length > 0 &&
638
+ recentConversationMetrics[0].atMs < cutoff) {
639
+ recentConversationMetrics.shift();
640
+ }
641
+ }
642
+ function recordConversationMetric(params) {
643
+ const nowMs = Date.now();
644
+ const errorText = params.error instanceof Error
645
+ ? params.error.message
646
+ : String(params.error || '');
647
+ const exhaustedHint = !params.ok && PRESENCE_EXHAUSTED_ERROR_RE.test(errorText);
648
+ if (params.ok) {
649
+ consecutiveConversationFailures = 0;
650
+ }
651
+ else {
652
+ consecutiveConversationFailures += 1;
653
+ }
654
+ recentConversationMetrics.push({
655
+ atMs: nowMs,
656
+ durationMs: Math.max(0, Math.floor(params.durationMs)),
657
+ ok: params.ok,
658
+ exhaustedHint,
659
+ });
660
+ trimRecentConversationMetrics(nowMs);
661
+ }
662
+ function resolvePresenceHealthState() {
663
+ trimRecentConversationMetrics();
664
+ if (activeConversationRuns >= 4)
665
+ return 'degraded';
666
+ if (consecutiveConversationFailures >= 3)
667
+ return 'exhausted';
668
+ if (recentConversationMetrics.some((entry) => entry.exhaustedHint))
669
+ return 'exhausted';
670
+ if (recentConversationMetrics.length === 0)
671
+ return 'healthy';
672
+ const totalDuration = recentConversationMetrics.reduce((sum, entry) => sum + entry.durationMs, 0);
673
+ const avgDuration = totalDuration / recentConversationMetrics.length;
674
+ const failureCount = recentConversationMetrics.filter((entry) => !entry.ok).length;
675
+ const errorRate = failureCount / recentConversationMetrics.length;
676
+ if (errorRate >= 0.25 || avgDuration >= PRESENCE_DEGRADED_DURATION_MS)
677
+ return 'degraded';
678
+ return 'healthy';
679
+ }
680
+ function randomIntInRange(minMs, maxMs) {
681
+ const lo = Math.floor(Math.max(0, minMs));
682
+ const hi = Math.floor(Math.max(lo, maxMs));
683
+ if (hi <= lo)
684
+ return lo;
685
+ return lo + Math.floor(Math.random() * (hi - lo + 1));
686
+ }
687
+ function isNightOrWeekend(now) {
688
+ const day = now.getDay();
689
+ const hour = now.getHours();
690
+ const weekend = day === 0 || day === 6;
691
+ const night = hour >= NIGHT_HOURS_START || hour < NIGHT_HOURS_END;
692
+ return weekend || night;
693
+ }
694
+ function buildConversationCooldownKey(channelId, userId) {
695
+ return `${channelId}:${userId}`;
696
+ }
697
+ function resolveHumanDelayWithBehavior(base, cooldownKey) {
698
+ if (base.mode === 'off')
699
+ return base;
700
+ const now = new Date();
701
+ let factor = isNightOrWeekend(now) ? 1.5 : 1;
702
+ const record = conversationExchangeByKey.get(cooldownKey);
703
+ if (record) {
704
+ const elapsedMs = Date.now() - record.lastAtMs;
705
+ if (elapsedMs > CONVERSATION_COOLDOWN_RESET_MS) {
706
+ conversationExchangeByKey.delete(cooldownKey);
707
+ }
708
+ else if (record.count > CONVERSATION_COOLDOWN_THRESHOLD) {
709
+ const extra = Math.min(CONVERSATION_COOLDOWN_MAX_FACTOR - 1, (record.count - CONVERSATION_COOLDOWN_THRESHOLD) * 0.12);
710
+ factor += Math.max(0, extra);
711
+ }
712
+ }
713
+ if (factor <= 1)
714
+ return base;
715
+ const minMs = Math.round((base.minMs ?? 800) * factor);
716
+ const maxMs = Math.round((base.maxMs ?? 2_500) * factor);
717
+ return {
718
+ mode: 'custom',
719
+ minMs,
720
+ maxMs: Math.max(minMs, maxMs),
721
+ };
722
+ }
723
+ function noteConversationExchange(cooldownKey) {
724
+ const nowMs = Date.now();
725
+ const existing = conversationExchangeByKey.get(cooldownKey);
726
+ if (!existing || nowMs - existing.lastAtMs > CONVERSATION_COOLDOWN_RESET_MS) {
727
+ conversationExchangeByKey.set(cooldownKey, { count: 1, lastAtMs: nowMs });
540
728
  return;
541
729
  }
542
- client.user.setPresence({
543
- activities: [{ name: `in ${client.guilds.cache.size} servers`, type: ActivityType.Listening }],
544
- status: 'online',
730
+ conversationExchangeByKey.set(cooldownKey, {
731
+ count: existing.count + 1,
732
+ lastAtMs: nowMs,
545
733
  });
546
734
  }
735
+ function pickReadWithoutReplyEmoji() {
736
+ return Math.random() < 0.7 ? '👍' : '✅';
737
+ }
547
738
  export function initDiscord(onMessage, onCommand) {
548
739
  messageHandler = onMessage;
549
740
  commandHandler = onCommand;
550
741
  const pendingBatches = new Map();
551
742
  const inFlightByMessageId = new Map();
743
+ const channelConcurrencyById = new Map();
552
744
  const negativeFeedbackByChannel = new Map();
553
745
  const participantMemoryByChannel = new Map();
554
746
  const touchParticipantMemoryChannel = (channelId) => {
@@ -623,6 +815,99 @@ export function initDiscord(onMessage, onCommand) {
623
815
  rememberParticipantAliasForChannel(msg.channelId, hint.userId, hint.alias);
624
816
  }
625
817
  };
818
+ const waitForChannelConcurrencySlot = async (channelId, maxConcurrent, abortSignal) => {
819
+ const boundedMax = Math.max(1, Math.floor(maxConcurrent));
820
+ while ((channelConcurrencyById.get(channelId) ?? 0) >= boundedMax) {
821
+ if (abortSignal?.aborted) {
822
+ throw new Error('Conversation aborted while waiting for channel concurrency slot.');
823
+ }
824
+ await new Promise((resolve) => setTimeout(resolve, CONCURRENCY_RETRY_DELAY_MS));
825
+ }
826
+ if (abortSignal?.aborted) {
827
+ throw new Error('Conversation aborted before acquiring channel concurrency slot.');
828
+ }
829
+ channelConcurrencyById.set(channelId, (channelConcurrencyById.get(channelId) ?? 0) + 1);
830
+ return () => {
831
+ const current = channelConcurrencyById.get(channelId) ?? 0;
832
+ const next = Math.max(0, current - 1);
833
+ if (next === 0) {
834
+ channelConcurrencyById.delete(channelId);
835
+ }
836
+ else {
837
+ channelConcurrencyById.set(channelId, next);
838
+ }
839
+ };
840
+ };
841
+ const enforcePerUserRateLimit = async (msg, behavior) => {
842
+ const limit = Math.max(0, Math.floor(behavior.rateLimitPerUser));
843
+ if (limit === 0)
844
+ return true;
845
+ if (isRateLimitExempt(msg))
846
+ return true;
847
+ const key = `${msg.channelId}:${msg.author.id}`;
848
+ const decision = userRateLimiter.check(key, limit);
849
+ if (decision.allowed)
850
+ return true;
851
+ if (userRateLimiter.shouldNotify(key, RATE_LIMIT_NOTIFY_COOLDOWN_MS)) {
852
+ try {
853
+ await withDiscordRetry('rate-limit-reply', () => msg.reply({ content: FRIENDLY_RATE_LIMIT_MESSAGE }));
854
+ }
855
+ catch (error) {
856
+ logger.debug({ error, channelId: msg.channelId, userId: msg.author.id }, 'Failed to send rate-limit warning');
857
+ }
858
+ }
859
+ return false;
860
+ };
861
+ const maybeHandleReadWithoutReply = async (msg, content) => {
862
+ if (!content.trim())
863
+ return false;
864
+ if (msg.attachments.size > 0)
865
+ return false;
866
+ if (hasPrefixInvocation(msg.content || ''))
867
+ return false;
868
+ if (client.user && msg.mentions.has(client.user))
869
+ return false;
870
+ if (content.trim().length > 80)
871
+ return false;
872
+ if (!READ_WITHOUT_REPLY_RE.test(content.trim()))
873
+ return false;
874
+ if (Math.random() > READ_WITHOUT_REPLY_PROBABILITY)
875
+ return false;
876
+ try {
877
+ await withDiscordRetry('reaction-read-without-reply', () => msg.react(pickReadWithoutReplyEmoji()));
878
+ return true;
879
+ }
880
+ catch (error) {
881
+ logger.debug({ error, channelId: msg.channelId, messageId: msg.id }, 'Failed read-without-reply reaction');
882
+ return false;
883
+ }
884
+ };
885
+ const shouldSelectivelySilence = (params) => {
886
+ if (!params.sourceItem.msg.guild)
887
+ return false;
888
+ if (params.behavior.guildMessageMode !== 'free')
889
+ return false;
890
+ if (params.sourceItem.wasExplicitlyAddressed)
891
+ return false;
892
+ const nowMs = Date.now();
893
+ const peerMessages = params.inboundHistory
894
+ .filter((entry) => !entry.isBot &&
895
+ entry.userId !== params.sourceItem.msg.author.id &&
896
+ nowMs - entry.timestampMs <= SELECTIVE_SILENCE_RECENT_WINDOW_MS)
897
+ .sort((a, b) => a.timestampMs - b.timestampMs);
898
+ if (peerMessages.length === 0)
899
+ return false;
900
+ const sourceText = params.sourceItem.content.trim();
901
+ const asksQuestion = sourceText.includes('?');
902
+ const latestPeer = peerMessages[peerMessages.length - 1]?.content?.trim().toLowerCase() ||
903
+ '';
904
+ const peerLooksLikeAnswer = latestPeer.length >= 24 ||
905
+ /\\b(you can|try|use|it is|it's|because|should|here|answer|fix)\\b/.test(latestPeer);
906
+ const probability = asksQuestion && peerLooksLikeAnswer
907
+ ? SELECTIVE_SILENCE_ACTIVE_CHAT_PROBABILITY
908
+ : SELECTIVE_SILENCE_BASE_PROBABILITY;
909
+ return Math.random() < probability;
910
+ };
626
911
  const intents = [
627
912
  GatewayIntentBits.Guilds,
628
913
  GatewayIntentBits.GuildMessages,
@@ -636,7 +921,12 @@ export function initDiscord(onMessage, onCommand) {
636
921
  intents.push(GatewayIntentBits.GuildPresences);
637
922
  client = new Client({
638
923
  intents,
639
- partials: [Partials.Channel, Partials.Message, Partials.Reaction, Partials.User],
924
+ partials: [
925
+ Partials.Channel,
926
+ Partials.Message,
927
+ Partials.Reaction,
928
+ Partials.User,
929
+ ],
640
930
  });
641
931
  client.on('presenceUpdate', (_oldPresence, nextPresence) => {
642
932
  const userId = nextPresence.userId || nextPresence.user?.id;
@@ -654,17 +944,27 @@ export function initDiscord(onMessage, onCommand) {
654
944
  });
655
945
  client.on('clientReady', () => {
656
946
  logger.info({ user: client.user?.tag }, 'Discord bot connected');
947
+ startupConnectedAtMs = Date.now();
657
948
  if (client.user) {
658
949
  botMentionRegex = new RegExp(`<@!?${client.user.id}>`, 'g');
659
950
  }
660
- updatePresence();
661
- void ensureSlashStatusCommand();
951
+ presenceController?.stop();
952
+ presenceController = new DiscordAutoPresenceController({
953
+ client,
954
+ getConfig: () => DISCORD_SELF_PRESENCE,
955
+ resolveState: resolvePresenceHealthState,
956
+ });
957
+ presenceController.start();
958
+ void ensureSlashCommands();
662
959
  });
663
960
  client.on('interactionCreate', async (interaction) => {
664
961
  if (!interaction.isChatInputCommand())
665
962
  return;
666
- if (interaction.commandName !== 'status')
963
+ if (interaction.commandName !== 'status' &&
964
+ interaction.commandName !== 'channel-mode' &&
965
+ interaction.commandName !== 'channel-policy') {
667
966
  return;
967
+ }
668
968
  if (!isAuthorizedCommandUserId(interaction.user.id)) {
669
969
  await sendChunkedInteractionReply(interaction, 'You are not authorized to run commands for this bot.');
670
970
  return;
@@ -672,12 +972,45 @@ export function initDiscord(onMessage, onCommand) {
672
972
  const guildId = interaction.guildId ?? null;
673
973
  const channelId = interaction.channelId;
674
974
  const sessionId = buildSessionIdFromContext(guildId, channelId, interaction.user.id);
975
+ const args = interaction.commandName === 'status'
976
+ ? ['status']
977
+ : interaction.commandName === 'channel-mode'
978
+ ? (() => {
979
+ if (!interaction.guildId)
980
+ return null;
981
+ const selectedMode = interaction.options
982
+ .getString('mode', true)
983
+ .trim()
984
+ .toLowerCase();
985
+ if (selectedMode !== 'off' &&
986
+ selectedMode !== 'mention' &&
987
+ selectedMode !== 'free')
988
+ return null;
989
+ return ['channel', 'mode', selectedMode];
990
+ })()
991
+ : (() => {
992
+ if (!interaction.guildId)
993
+ return null;
994
+ const selectedPolicy = interaction.options
995
+ .getString('policy', true)
996
+ .trim()
997
+ .toLowerCase();
998
+ if (selectedPolicy !== 'open' &&
999
+ selectedPolicy !== 'allowlist' &&
1000
+ selectedPolicy !== 'disabled')
1001
+ return null;
1002
+ return ['channel', 'policy', selectedPolicy];
1003
+ })();
1004
+ if (!args) {
1005
+ await sendChunkedInteractionReply(interaction, 'This command can only be used in a server channel with a valid mode/policy option.');
1006
+ return;
1007
+ }
675
1008
  try {
676
- await commandHandler(sessionId, guildId, channelId, ['status'], async (text, files) => sendChunkedInteractionReply(interaction, text, files));
1009
+ await commandHandler(sessionId, guildId, channelId, args, async (text, files) => sendChunkedInteractionReply(interaction, text, files));
677
1010
  }
678
1011
  catch (error) {
679
1012
  const detail = error instanceof Error ? error.message : String(error);
680
- logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash /status command failed');
1013
+ logger.error({ error, guildId, channelId, userId: interaction.user.id }, 'Discord slash command failed');
681
1014
  await sendChunkedInteractionReply(interaction, formatError('Gateway Error', detail));
682
1015
  }
683
1016
  });
@@ -696,8 +1029,13 @@ export function initDiscord(onMessage, onCommand) {
696
1029
  const channelId = msg.channelId;
697
1030
  const userId = msg.author.id;
698
1031
  const username = msg.author.username;
1032
+ const behavior = sourceItem.behavior;
1033
+ const startedAt = Date.now();
1034
+ let releaseChannelSlot = null;
699
1035
  const batchedContent = items.length > 1
700
- ? items.map((item, index) => `Message ${index + 1}:\n${item.content}`).join('\n\n')
1036
+ ? items
1037
+ .map((item, index) => `Message ${index + 1}:\n${item.content}`)
1038
+ .join('\n\n')
701
1039
  : sourceItem.content;
702
1040
  const channelInfoContext = buildChannelInfoContext(msg);
703
1041
  const replyContext = await buildReplyContext(msg);
@@ -712,78 +1050,205 @@ export function initDiscord(onMessage, onCommand) {
712
1050
  const participantContext = buildParticipantContext(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
713
1051
  const mentionLookup = buildMentionLookup(items.map((item) => item.msg), inboundHistory.entries, rememberedParticipants);
714
1052
  const combinedContent = `${feedbackNote ? `[Reaction feedback]\n${feedbackNote}\n\n` : ''}${channelInfoContext}${replyContext}${inboundHistory.context}${attachmentContext.context}${participantContext}${batchedContent}`;
1053
+ const selectiveSilence = shouldSelectivelySilence({
1054
+ sourceItem,
1055
+ inboundHistory: inboundHistory.entries,
1056
+ behavior,
1057
+ });
715
1058
  const abortController = new AbortController();
716
- const typingLoop = startTypingLoop(msg);
1059
+ const typingController = pending.typingController;
1060
+ const lifecycleController = pending.lifecycleController;
1061
+ const emitLifecyclePhase = (phase) => {
1062
+ if (phase === 'queued') {
1063
+ typingController.setPhase('received');
1064
+ }
1065
+ else if (phase === 'thinking') {
1066
+ typingController.setPhase('thinking');
1067
+ }
1068
+ else if (phase === 'toolUse') {
1069
+ typingController.setPhase('toolUse');
1070
+ }
1071
+ else if (phase === 'streaming') {
1072
+ typingController.setPhase('streaming');
1073
+ }
1074
+ else {
1075
+ typingController.setPhase('done');
1076
+ }
1077
+ lifecycleController?.setPhase(phase);
1078
+ };
717
1079
  const stream = new DiscordStreamManager(msg, {
718
- onFirstMessage: () => typingLoop.stop(),
1080
+ onFirstMessage: () => emitLifecyclePhase('streaming'),
1081
+ humanDelay: behavior.humanDelay,
719
1082
  });
720
1083
  const inFlight = {
721
1084
  abortController,
722
1085
  stream,
723
1086
  messageIds: new Set(items.map((item) => item.msg.id)),
724
1087
  aborted: false,
1088
+ emitLifecyclePhase,
725
1089
  };
726
1090
  for (const messageId of inFlight.messageIds) {
727
1091
  inFlightByMessageId.set(messageId, inFlight);
728
1092
  }
729
1093
  try {
1094
+ if (selectiveSilence) {
1095
+ emitLifecyclePhase('done');
1096
+ if (Math.random() < 0.5) {
1097
+ await withDiscordRetry('reaction-selective-silence', () => msg.react(pickReadWithoutReplyEmoji())).catch(() => { });
1098
+ }
1099
+ recordConversationMetric({
1100
+ durationMs: Date.now() - startedAt,
1101
+ ok: true,
1102
+ });
1103
+ return;
1104
+ }
1105
+ releaseChannelSlot = await waitForChannelConcurrencySlot(channelId, behavior.maxConcurrentPerChannel, abortController.signal);
1106
+ if (abortController.signal.aborted) {
1107
+ return;
1108
+ }
730
1109
  activeConversationRuns += 1;
731
- updatePresence();
1110
+ emitLifecyclePhase('thinking');
732
1111
  await messageHandler(sessionId, guildId, channelId, userId, username, combinedContent, attachmentContext.media, async (text, files) => {
733
- typingLoop.stop();
734
- await sendChunkedReply(msg, text, files, mentionLookup);
1112
+ emitLifecyclePhase('streaming');
1113
+ await sendChunkedReply(msg, text, files, mentionLookup, behavior.humanDelay);
735
1114
  }, {
736
1115
  sourceMessage: msg,
737
1116
  batchedMessages: items.map((item) => item.msg),
738
1117
  abortSignal: abortController.signal,
739
1118
  stream,
740
1119
  mentionLookup,
1120
+ emitLifecyclePhase,
741
1121
  });
1122
+ emitLifecyclePhase('done');
1123
+ recordConversationMetric({
1124
+ durationMs: Date.now() - startedAt,
1125
+ ok: true,
1126
+ });
1127
+ noteConversationExchange(sourceItem.cooldownKey);
742
1128
  }
743
1129
  catch (error) {
1130
+ if (abortController.signal.aborted || inFlight.aborted) {
1131
+ logger.debug({ channelId, sessionId }, 'Conversation batch aborted before completion');
1132
+ return;
1133
+ }
1134
+ emitLifecyclePhase('error');
1135
+ recordConversationMetric({
1136
+ durationMs: Date.now() - startedAt,
1137
+ ok: false,
1138
+ error,
1139
+ });
744
1140
  logger.error({ error, channelId, sessionId }, 'Conversation batch handling failed');
745
1141
  const detail = error instanceof Error ? error.message : String(error);
746
1142
  if (stream.hasSentMessages()) {
747
1143
  await stream.fail(formatError('Gateway Error', detail));
748
1144
  }
749
1145
  else {
750
- await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup);
1146
+ await sendChunkedReply(msg, formatError('Gateway Error', detail), undefined, mentionLookup, behavior.humanDelay);
751
1147
  }
752
1148
  }
753
1149
  finally {
754
1150
  activeConversationRuns = Math.max(0, activeConversationRuns - 1);
755
- updatePresence();
1151
+ if (releaseChannelSlot) {
1152
+ releaseChannelSlot();
1153
+ }
756
1154
  for (const messageId of inFlight.messageIds) {
757
1155
  if (inFlightByMessageId.get(messageId) === inFlight) {
758
1156
  inFlightByMessageId.delete(messageId);
759
1157
  }
760
1158
  }
761
- typingLoop.stop();
1159
+ typingController.stop();
762
1160
  await Promise.all(items.map(async (item) => {
763
- await item.clearReaction();
1161
+ await item.clearAckReaction();
764
1162
  }));
765
1163
  }
766
1164
  };
767
- const queueConversationMessage = async (msg, content) => {
1165
+ const queueConversationMessage = async (msg, content, behavior) => {
768
1166
  const key = `${msg.channelId}:${msg.author.id}`;
769
- const clearReaction = await addProcessingReaction(msg);
770
- const queued = { msg, content, clearReaction };
1167
+ const cooldownKey = buildConversationCooldownKey(msg.channelId, msg.author.id);
1168
+ const adjustedHumanDelay = resolveHumanDelayWithBehavior(behavior.humanDelay, cooldownKey);
1169
+ const queuedBehavior = {
1170
+ ...behavior,
1171
+ humanDelay: adjustedHumanDelay,
1172
+ };
1173
+ const wasExplicitlyAddressed = !msg.guild ||
1174
+ hasPrefixInvocation(msg.content || '') ||
1175
+ Boolean(client.user && msg.mentions.has(client.user));
1176
+ let clearAckReaction = async () => { };
1177
+ if (client.user && shouldApplyAckReaction(msg, behavior)) {
1178
+ const clearReaction = await addAckReaction({
1179
+ message: msg,
1180
+ emoji: behavior.ackReaction,
1181
+ withRetry: withDiscordRetry,
1182
+ botUserId: client.user.id,
1183
+ });
1184
+ clearAckReaction = behavior.removeAckAfterReply
1185
+ ? clearReaction
1186
+ : async () => { };
1187
+ }
1188
+ const queued = {
1189
+ msg,
1190
+ content,
1191
+ behavior: queuedBehavior,
1192
+ clearAckReaction,
1193
+ wasExplicitlyAddressed,
1194
+ cooldownKey,
1195
+ };
771
1196
  const existing = pendingBatches.get(key);
1197
+ const shouldDebounceMessage = shouldDebounceInbound({
1198
+ content: msg.content || '',
1199
+ hasAttachments: msg.attachments.size > 0,
1200
+ isPrefixedCommand: hasPrefixInvocation(msg.content || ''),
1201
+ });
772
1202
  if (!existing) {
1203
+ const typingController = createTypingController(msg, behavior.typingMode);
1204
+ typingController.setPhase('received');
1205
+ const lifecycleController = client.user && DISCORD_LIFECYCLE_REACTIONS.enabled
1206
+ ? new LifecycleReactionController({
1207
+ message: msg,
1208
+ withRetry: withDiscordRetry,
1209
+ botUserId: client.user.id,
1210
+ config: {
1211
+ enabled: DISCORD_LIFECYCLE_REACTIONS.enabled,
1212
+ removeOnComplete: DISCORD_LIFECYCLE_REACTIONS.removeOnComplete,
1213
+ phases: DISCORD_LIFECYCLE_REACTIONS.phases,
1214
+ },
1215
+ })
1216
+ : null;
1217
+ lifecycleController?.setPhase('queued');
1218
+ const baseDelayMs = shouldDebounceMessage ? behavior.debounceMs : 0;
1219
+ const startupStaggerMs = startupConnectedAtMs > 0 &&
1220
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1221
+ !wasExplicitlyAddressed
1222
+ ? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
1223
+ : 0;
1224
+ const delayMs = baseDelayMs + startupStaggerMs;
773
1225
  const timer = setTimeout(() => {
774
1226
  void dispatchConversationBatch(key);
775
- }, MESSAGE_DEBOUNCE_MS);
1227
+ }, delayMs);
776
1228
  pendingBatches.set(key, {
777
1229
  items: [queued],
778
1230
  timer,
1231
+ typingController,
1232
+ lifecycleController,
779
1233
  });
780
1234
  return;
781
1235
  }
1236
+ existing.typingController.setPhase('received');
1237
+ existing.lifecycleController?.setPhase('queued');
782
1238
  clearTimeout(existing.timer);
783
1239
  existing.items.push(queued);
1240
+ const shouldFlushImmediately = !shouldDebounceMessage ||
1241
+ existing.items.length >= DEFAULT_DEBOUNCE_MAX_BUFFER;
1242
+ const baseDelayMs = shouldFlushImmediately ? 0 : behavior.debounceMs;
1243
+ const startupStaggerMs = startupConnectedAtMs > 0 &&
1244
+ Date.now() - startupConnectedAtMs < STARTUP_STAGGER_WINDOW_MS &&
1245
+ !wasExplicitlyAddressed
1246
+ ? randomIntInRange(STARTUP_STAGGER_MIN_DELAY_MS, STARTUP_STAGGER_MAX_DELAY_MS)
1247
+ : 0;
1248
+ const delayMs = baseDelayMs + startupStaggerMs;
784
1249
  existing.timer = setTimeout(() => {
785
1250
  void dispatchConversationBatch(key);
786
- }, MESSAGE_DEBOUNCE_MS);
1251
+ }, delayMs);
787
1252
  };
788
1253
  const dropPendingMessage = async (messageId) => {
789
1254
  for (const [key, pending] of pendingBatches) {
@@ -791,29 +1256,39 @@ export function initDiscord(onMessage, onCommand) {
791
1256
  if (index === -1)
792
1257
  continue;
793
1258
  const [removed] = pending.items.splice(index, 1);
794
- await removed.clearReaction();
1259
+ await removed.clearAckReaction();
795
1260
  if (pending.items.length === 0) {
796
1261
  clearTimeout(pending.timer);
1262
+ pending.typingController.stop();
1263
+ await pending.lifecycleController?.clear();
797
1264
  pendingBatches.delete(key);
798
1265
  }
799
1266
  return;
800
1267
  }
801
1268
  };
802
- const updatePendingMessage = async (messageId, nextMsg, nextContent) => {
1269
+ const updatePendingMessage = async (messageId, nextMsg, nextContent, nextBehavior) => {
803
1270
  for (const [key, pending] of pendingBatches) {
804
1271
  const index = pending.items.findIndex((item) => item.msg.id === messageId);
805
1272
  if (index === -1)
806
1273
  continue;
807
1274
  if (!nextContent) {
808
1275
  const [removed] = pending.items.splice(index, 1);
809
- await removed.clearReaction();
1276
+ await removed.clearAckReaction();
810
1277
  }
811
1278
  else {
812
1279
  pending.items[index].msg = nextMsg;
813
1280
  pending.items[index].content = nextContent;
1281
+ pending.items[index].behavior = nextBehavior;
1282
+ pending.items[index].wasExplicitlyAddressed =
1283
+ !nextMsg.guild ||
1284
+ hasPrefixInvocation(nextMsg.content || '') ||
1285
+ Boolean(client.user && nextMsg.mentions.has(client.user));
1286
+ pending.items[index].cooldownKey = buildConversationCooldownKey(nextMsg.channelId, nextMsg.author.id);
814
1287
  }
815
1288
  if (pending.items.length === 0) {
816
1289
  clearTimeout(pending.timer);
1290
+ pending.typingController.stop();
1291
+ await pending.lifecycleController?.clear();
817
1292
  pendingBatches.delete(key);
818
1293
  }
819
1294
  return true;
@@ -826,15 +1301,16 @@ export function initDiscord(onMessage, onCommand) {
826
1301
  const sessionId = getSessionId(msg);
827
1302
  const guildId = msg.guild?.id || null;
828
1303
  const channelId = msg.channelId;
1304
+ const behavior = resolveChannelBehavior(msg);
829
1305
  const content = cleanIncomingContent(msg.content);
830
1306
  observeMessageParticipants(msg, content);
831
1307
  const immediateMentionLookup = buildMentionLookup([msg], [], msg.guild ? participantMemoryByChannel.get(msg.channelId) : undefined);
832
1308
  const reply = async (text, files) => {
833
- await sendChunkedReply(msg, text, files, immediateMentionLookup);
1309
+ await sendChunkedReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
834
1310
  };
835
1311
  const commandReply = async (text, files) => {
836
1312
  try {
837
- await sendChunkedDirectReply(msg, text, files, immediateMentionLookup);
1313
+ await sendChunkedDirectReply(msg, text, files, immediateMentionLookup, behavior.humanDelay);
838
1314
  }
839
1315
  catch (error) {
840
1316
  logger.warn({ error, userId: msg.author.id, channelId: msg.channelId }, 'Failed to send command reply via DM; command response dropped');
@@ -867,7 +1343,7 @@ export function initDiscord(onMessage, onCommand) {
867
1343
  await commandHandler(sessionId, guildId, channelId, [parsed.command, ...parsed.args], commandReply);
868
1344
  return;
869
1345
  }
870
- if (!isTrigger(msg))
1346
+ if (!isTrigger(msg, behavior))
871
1347
  return;
872
1348
  if (ignorePrefixCommand) {
873
1349
  return;
@@ -885,7 +1361,14 @@ export function initDiscord(onMessage, onCommand) {
885
1361
  await reply('How can I help? Send me a message or try `!claw help`.');
886
1362
  return;
887
1363
  }
888
- await queueConversationMessage(msg, content);
1364
+ const readWithoutReplyHandled = await maybeHandleReadWithoutReply(msg, content);
1365
+ if (readWithoutReplyHandled) {
1366
+ return;
1367
+ }
1368
+ const rateLimitAllowed = await enforcePerUserRateLimit(msg, behavior);
1369
+ if (!rateLimitAllowed)
1370
+ return;
1371
+ await queueConversationMessage(msg, content, behavior);
889
1372
  });
890
1373
  client.on('messageUpdate', async (_oldMsg, nextMsg) => {
891
1374
  if (DISCORD_COMMANDS_ONLY)
@@ -898,15 +1381,19 @@ export function initDiscord(onMessage, onCommand) {
898
1381
  if (fetched.author?.bot)
899
1382
  return;
900
1383
  const updatedContent = cleanIncomingContent(fetched.content || '');
1384
+ const behavior = resolveChannelBehavior(fetched);
901
1385
  observeMessageParticipants(fetched, updatedContent);
902
- if (!isTrigger(fetched))
1386
+ if (!isTrigger(fetched, behavior)) {
1387
+ await updatePendingMessage(fetched.id, fetched, '', behavior);
903
1388
  return;
904
- await updatePendingMessage(fetched.id, fetched, updatedContent);
1389
+ }
1390
+ await updatePendingMessage(fetched.id, fetched, updatedContent, behavior);
905
1391
  const inFlight = inFlightByMessageId.get(fetched.id);
906
1392
  if (!inFlight || inFlight.aborted)
907
1393
  return;
908
1394
  inFlight.aborted = true;
909
1395
  inFlight.abortController.abort();
1396
+ inFlight.emitLifecyclePhase('error');
910
1397
  for (const messageId of inFlight.messageIds) {
911
1398
  if (inFlightByMessageId.get(messageId) === inFlight) {
912
1399
  inFlightByMessageId.delete(messageId);
@@ -914,7 +1401,10 @@ export function initDiscord(onMessage, onCommand) {
914
1401
  }
915
1402
  await inFlight.stream.discard();
916
1403
  if (updatedContent) {
917
- await queueConversationMessage(fetched, updatedContent);
1404
+ const rateLimitAllowed = await enforcePerUserRateLimit(fetched, behavior);
1405
+ if (!rateLimitAllowed)
1406
+ return;
1407
+ await queueConversationMessage(fetched, updatedContent, behavior);
918
1408
  }
919
1409
  });
920
1410
  client.on('messageDelete', async (msg) => {
@@ -924,6 +1414,7 @@ export function initDiscord(onMessage, onCommand) {
924
1414
  return;
925
1415
  inFlight.aborted = true;
926
1416
  inFlight.abortController.abort();
1417
+ inFlight.emitLifecyclePhase('error');
927
1418
  for (const messageId of inFlight.messageIds) {
928
1419
  if (inFlightByMessageId.get(messageId) === inFlight) {
929
1420
  inFlightByMessageId.delete(messageId);
@@ -956,6 +1447,11 @@ export function initDiscord(onMessage, onCommand) {
956
1447
  client.login(DISCORD_TOKEN);
957
1448
  return client;
958
1449
  }
1450
+ export async function setDiscordMaintenancePresence() {
1451
+ if (!presenceController)
1452
+ return;
1453
+ await presenceController.setMaintenance();
1454
+ }
959
1455
  /**
960
1456
  * Send a message to a channel by ID (used by scheduler).
961
1457
  */