@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
@@ -4,7 +4,15 @@ export interface ParsedCommand {
4
4
  args: string[];
5
5
  }
6
6
 
7
- export function stripBotMentions(text: string, botMentionRegex: RegExp | null): string {
7
+ export type DiscordGuildMessageMode = 'off' | 'mention' | 'free';
8
+
9
+ const GREETING_ONLY_RE =
10
+ /^(hi|hey|hello|yo|sup|thanks|thank you|thx|ok|okay|got it|roger|cool)[!. ]*$/i;
11
+
12
+ export function stripBotMentions(
13
+ text: string,
14
+ botMentionRegex: RegExp | null,
15
+ ): string {
8
16
  if (!botMentionRegex) return text;
9
17
  return text.replace(botMentionRegex, '').trim();
10
18
  }
@@ -30,7 +38,11 @@ export function hasPrefixInvocation(
30
38
  return text.startsWith(prefix);
31
39
  }
32
40
 
33
- export function buildSessionIdFromContext(guildId: string | null, channelId: string, userId: string): string {
41
+ export function buildSessionIdFromContext(
42
+ guildId: string | null,
43
+ channelId: string,
44
+ userId: string,
45
+ ): string {
34
46
  return guildId ? `${guildId}:${channelId}` : `dm:${userId}`;
35
47
  }
36
48
 
@@ -45,28 +57,75 @@ export function parseCommand(
45
57
  }
46
58
 
47
59
  const parts = text.split(/\s+/);
48
- const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'clear', 'help'];
60
+ const subcommands = [
61
+ 'bot',
62
+ 'rag',
63
+ 'model',
64
+ 'usage',
65
+ 'export',
66
+ 'sessions',
67
+ 'audit',
68
+ 'schedule',
69
+ 'channel',
70
+ 'clear',
71
+ 'help',
72
+ ];
49
73
  if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
50
- return { isCommand: true, command: parts[0].toLowerCase(), args: parts.slice(1) };
74
+ return {
75
+ isCommand: true,
76
+ command: parts[0].toLowerCase(),
77
+ args: parts.slice(1),
78
+ };
51
79
  }
52
80
 
53
81
  return { isCommand: false, command: '', args: [] };
54
82
  }
55
83
 
84
+ export function shouldSuppressAutoReply(
85
+ content: string,
86
+ suppressPatterns?: string[],
87
+ ): boolean {
88
+ const normalized = content.trim().toLowerCase();
89
+ if (!normalized) return false;
90
+ if (GREETING_ONLY_RE.test(normalized)) return true;
91
+ if (!suppressPatterns || suppressPatterns.length === 0) return false;
92
+ return suppressPatterns.some((pattern) => {
93
+ const needle = pattern.trim().toLowerCase();
94
+ if (!needle) return false;
95
+ return normalized.includes(needle);
96
+ });
97
+ }
98
+
56
99
  export function isTrigger(params: {
57
100
  content: string;
58
101
  isDm: boolean;
59
102
  commandsOnly: boolean;
60
103
  respondToAllMessages: boolean;
104
+ guildMessageMode: DiscordGuildMessageMode;
61
105
  prefix: string;
62
106
  botMentionRegex: RegExp | null;
63
107
  hasBotMention: boolean;
108
+ suppressPatterns?: string[];
64
109
  }): boolean {
110
+ const stripped = stripBotMentions(params.content, params.botMentionRegex);
111
+
65
112
  if (params.commandsOnly) {
66
- return hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix);
113
+ return hasPrefixInvocation(
114
+ params.content,
115
+ params.botMentionRegex,
116
+ params.prefix,
117
+ );
67
118
  }
119
+ if (
120
+ hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix)
121
+ )
122
+ return true;
123
+ if (shouldSuppressAutoReply(stripped, params.suppressPatterns)) return false;
68
124
  if (params.isDm) return true;
69
- if (params.respondToAllMessages) return true;
125
+ if (params.guildMessageMode === 'off') return false;
126
+ if (params.guildMessageMode === 'free') return true;
127
+ // Keep `respondToAllMessages` consumed for compatibility; mode resolution decides guild behavior.
128
+ void params.respondToAllMessages;
70
129
  if (params.hasBotMention) return true;
71
- return params.content.startsWith(params.prefix);
130
+ return false;
72
131
  }
@@ -21,7 +21,11 @@ export function normalizeMentionAlias(raw: string | null | undefined): string {
21
21
  return lowered;
22
22
  }
23
23
 
24
- export function addMentionAlias(lookup: MentionLookup, rawAlias: string | null | undefined, userId: string): void {
24
+ export function addMentionAlias(
25
+ lookup: MentionLookup,
26
+ rawAlias: string | null | undefined,
27
+ userId: string,
28
+ ): void {
25
29
  const alias = normalizeMentionAlias(rawAlias);
26
30
  if (!alias) return;
27
31
  let ids = lookup.byAlias.get(alias);
@@ -36,7 +40,10 @@ export function extractMentionAliasHints(text: string): MentionAliasHint[] {
36
40
  if (!text) return [];
37
41
 
38
42
  const hints = new Map<string, MentionAliasHint>();
39
- const collect = (rawAlias: string | null | undefined, rawUserId: string | null | undefined): void => {
43
+ const collect = (
44
+ rawAlias: string | null | undefined,
45
+ rawUserId: string | null | undefined,
46
+ ): void => {
40
47
  const userId = (rawUserId || '').trim();
41
48
  if (!/^\d{16,22}$/.test(userId)) return;
42
49
  const alias = normalizeMentionAlias(rawAlias);
@@ -45,32 +52,44 @@ export function extractMentionAliasHints(text: string): MentionAliasHint[] {
45
52
  if (!hints.has(key)) hints.set(key, { alias, userId });
46
53
  };
47
54
 
48
- const aliasToId = /(^|[\s,;:.!?])@?([\p{L}\p{N}._-]{2,32})\s*(?:ist|is|=|->|=>|means|heißt)\s*(?:<@!?(\d{16,22})>|(\d{16,22}))/giu;
55
+ const aliasToId =
56
+ /(^|[\s,;:.!?])@?([\p{L}\p{N}._-]{2,32})\s*(?:ist|is|=|->|=>|means|heißt)\s*(?:<@!?(\d{16,22})>|(\d{16,22}))/giu;
49
57
  let match: RegExpExecArray | null;
50
- while ((match = aliasToId.exec(text)) !== null) {
58
+ while (true) {
59
+ match = aliasToId.exec(text);
60
+ if (match === null) break;
51
61
  collect(match[2], match[3] || match[4]);
52
62
  }
53
63
 
54
- const idToAlias = /(?:<@!?(\d{16,22})>|(\d{16,22}))\s*(?:ist|is|=|->|=>|means|heißt)\s*@?([\p{L}\p{N}._-]{2,32})/giu;
55
- while ((match = idToAlias.exec(text)) !== null) {
64
+ const idToAlias =
65
+ /(?:<@!?(\d{16,22})>|(\d{16,22}))\s*(?:ist|is|=|->|=>|means|heißt)\s*@?([\p{L}\p{N}._-]{2,32})/giu;
66
+ while (true) {
67
+ match = idToAlias.exec(text);
68
+ if (match === null) break;
56
69
  collect(match[3], match[1] || match[2]);
57
70
  }
58
71
 
59
72
  return Array.from(hints.values());
60
73
  }
61
74
 
62
- export function rewriteUserMentions(text: string, lookup: MentionLookup): string {
75
+ export function rewriteUserMentions(
76
+ text: string,
77
+ lookup: MentionLookup,
78
+ ): string {
63
79
  if (!text) return text;
64
80
  if (!lookup.byAlias.size) return text;
65
- return text.replace(/(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu, (full, prefix: string, rawAlias: string) => {
66
- const alias = normalizeMentionAlias(rawAlias);
67
- if (!alias) return full;
68
- const ids = lookup.byAlias.get(alias);
69
- if (!ids || ids.size !== 1) return full;
70
- const [id] = Array.from(ids);
71
- if (!id) return full;
72
- return `${prefix}<@${id}>`;
73
- });
81
+ return text.replace(
82
+ /(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu,
83
+ (full, prefix: string, rawAlias: string) => {
84
+ const alias = normalizeMentionAlias(rawAlias);
85
+ if (!alias) return full;
86
+ const ids = lookup.byAlias.get(alias);
87
+ if (!ids || ids.size !== 1) return full;
88
+ const [id] = Array.from(ids);
89
+ if (!id) return full;
90
+ return `${prefix}<@${id}>`;
91
+ },
92
+ );
74
93
  }
75
94
 
76
95
  function extractMentionAliases(text: string): string[] {
@@ -78,7 +97,9 @@ function extractMentionAliases(text: string): string[] {
78
97
  const aliases = new Set<string>();
79
98
  const re = /(^|[\s([{:>])@([\p{L}\p{N}._-]{2,32})\b/gu;
80
99
  let match: RegExpExecArray | null;
81
- while ((match = re.exec(text)) !== null) {
100
+ while (true) {
101
+ match = re.exec(text);
102
+ if (match === null) break;
82
103
  const alias = normalizeMentionAlias(match[2]);
83
104
  if (!alias) continue;
84
105
  aliases.add(alias);
@@ -97,7 +118,10 @@ async function enrichMentionLookupFromGuild(
97
118
  for (const alias of aliases) {
98
119
  if (lookup.byAlias.has(alias)) continue;
99
120
  try {
100
- const members = await msg.guild.members.search({ query: alias, limit: 5 });
121
+ const members = await msg.guild.members.search({
122
+ query: alias,
123
+ limit: 5,
124
+ });
101
125
  const exactMatches = Array.from(members.values()).filter((member) => {
102
126
  const username = normalizeMentionAlias(member.user?.username || '');
103
127
  const displayName = normalizeMentionAlias(member.displayName || '');
@@ -0,0 +1,148 @@
1
+ import { ActivityType, type Client, type PresenceStatusData } from 'discord.js';
2
+
3
+ import { logger } from '../../logger.js';
4
+
5
+ export type PresenceHealthState =
6
+ | 'healthy'
7
+ | 'degraded'
8
+ | 'exhausted'
9
+ | 'maintenance';
10
+ export type DiscordPresenceActivityType =
11
+ | 'playing'
12
+ | 'watching'
13
+ | 'listening'
14
+ | 'competing'
15
+ | 'custom';
16
+
17
+ export interface DiscordAutoPresenceConfig {
18
+ enabled: boolean;
19
+ intervalMs: number;
20
+ healthyText: string;
21
+ degradedText: string;
22
+ exhaustedText: string;
23
+ activityType: DiscordPresenceActivityType;
24
+ }
25
+
26
+ type PresenceConfigResolver = () => DiscordAutoPresenceConfig;
27
+ type PresenceStateResolver = () => PresenceHealthState;
28
+
29
+ function toDiscordActivityType(
30
+ type: DiscordPresenceActivityType,
31
+ ): ActivityType {
32
+ switch (type) {
33
+ case 'playing':
34
+ return ActivityType.Playing;
35
+ case 'listening':
36
+ return ActivityType.Listening;
37
+ case 'competing':
38
+ return ActivityType.Competing;
39
+ case 'custom':
40
+ return ActivityType.Custom;
41
+ case 'watching':
42
+ default:
43
+ return ActivityType.Watching;
44
+ }
45
+ }
46
+
47
+ function presenceTextForState(
48
+ config: DiscordAutoPresenceConfig,
49
+ state: PresenceHealthState,
50
+ ): string {
51
+ if (state === 'degraded') return config.degradedText;
52
+ if (state === 'exhausted') return config.exhaustedText;
53
+ return config.healthyText;
54
+ }
55
+
56
+ function presenceStatusForState(
57
+ state: PresenceHealthState,
58
+ ): PresenceStatusData {
59
+ if (state === 'maintenance') return 'invisible';
60
+ if (state === 'exhausted') return 'dnd';
61
+ if (state === 'degraded') return 'idle';
62
+ return 'online';
63
+ }
64
+
65
+ export class DiscordAutoPresenceController {
66
+ private readonly client: Client;
67
+ private readonly getConfig: PresenceConfigResolver;
68
+ private readonly resolveState: PresenceStateResolver;
69
+ private timer: ReturnType<typeof setTimeout> | null = null;
70
+ private running = false;
71
+ private maintenance = false;
72
+ private lastFingerprint = '';
73
+
74
+ constructor(params: {
75
+ client: Client;
76
+ getConfig: PresenceConfigResolver;
77
+ resolveState: PresenceStateResolver;
78
+ }) {
79
+ this.client = params.client;
80
+ this.getConfig = params.getConfig;
81
+ this.resolveState = params.resolveState;
82
+ }
83
+
84
+ start(): void {
85
+ this.stop();
86
+ this.running = true;
87
+ const tick = async (): Promise<void> => {
88
+ if (!this.running) return;
89
+ await this.evaluateNow();
90
+ if (!this.running) return;
91
+ const intervalMs = Math.max(
92
+ 5_000,
93
+ Math.floor(this.getConfig().intervalMs),
94
+ );
95
+ this.timer = setTimeout(() => {
96
+ void tick();
97
+ }, intervalMs);
98
+ };
99
+ this.timer = setTimeout(() => {
100
+ void tick();
101
+ }, 0);
102
+ }
103
+
104
+ stop(): void {
105
+ this.running = false;
106
+ if (!this.timer) return;
107
+ clearTimeout(this.timer);
108
+ this.timer = null;
109
+ }
110
+
111
+ async setMaintenance(): Promise<void> {
112
+ this.maintenance = true;
113
+ await this.evaluateNow();
114
+ }
115
+
116
+ private async evaluateNow(): Promise<void> {
117
+ const config = this.getConfig();
118
+ if (!this.client.user) return;
119
+ if (!config.enabled && !this.maintenance) return;
120
+
121
+ const state = this.maintenance ? 'maintenance' : this.resolveState();
122
+ const status = presenceStatusForState(state);
123
+ const activityText =
124
+ state === 'maintenance' ? '' : presenceTextForState(config, state);
125
+ const activityType = toDiscordActivityType(config.activityType);
126
+ const fingerprint = `${state}:${status}:${activityType}:${activityText}`;
127
+ if (fingerprint === this.lastFingerprint) return;
128
+
129
+ try {
130
+ if (state === 'maintenance') {
131
+ await this.client.user.setPresence({
132
+ status,
133
+ activities: [],
134
+ });
135
+ } else {
136
+ await this.client.user.setPresence({
137
+ status,
138
+ activities: activityText
139
+ ? [{ name: activityText, type: activityType }]
140
+ : [],
141
+ });
142
+ }
143
+ this.lastFingerprint = fingerprint;
144
+ } catch (error) {
145
+ logger.debug({ error, state }, 'Failed to update Discord presence');
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,58 @@
1
+ export interface RateLimitDecision {
2
+ allowed: boolean;
3
+ remaining: number;
4
+ retryAfterMs: number;
5
+ }
6
+
7
+ export class SlidingWindowRateLimiter {
8
+ private readonly windowMs: number;
9
+ private readonly buckets = new Map<string, number[]>();
10
+ private readonly notifyAt = new Map<string, number>();
11
+
12
+ constructor(windowMs = 60_000) {
13
+ this.windowMs = Math.max(1_000, Math.floor(windowMs));
14
+ }
15
+
16
+ check(key: string, limit: number, nowMs = Date.now()): RateLimitDecision {
17
+ const boundedLimit = Math.max(0, Math.floor(limit));
18
+ if (!key || boundedLimit === 0) {
19
+ return {
20
+ allowed: true,
21
+ remaining: Number.POSITIVE_INFINITY,
22
+ retryAfterMs: 0,
23
+ };
24
+ }
25
+
26
+ const cutoff = nowMs - this.windowMs;
27
+ const timestamps = this.buckets.get(key) ?? [];
28
+ const active = timestamps.filter((ts) => ts > cutoff);
29
+
30
+ if (active.length >= boundedLimit) {
31
+ this.buckets.set(key, active);
32
+ const oldest = active[0];
33
+ const retryAfterMs = Math.max(0, oldest + this.windowMs - nowMs);
34
+ return {
35
+ allowed: false,
36
+ remaining: 0,
37
+ retryAfterMs,
38
+ };
39
+ }
40
+
41
+ active.push(nowMs);
42
+ this.buckets.set(key, active);
43
+ return {
44
+ allowed: true,
45
+ remaining: Math.max(0, boundedLimit - active.length),
46
+ retryAfterMs: 0,
47
+ };
48
+ }
49
+
50
+ shouldNotify(key: string, cooldownMs = 10_000, nowMs = Date.now()): boolean {
51
+ if (!key) return true;
52
+ const boundedCooldown = Math.max(1_000, Math.floor(cooldownMs));
53
+ const nextAllowedAt = this.notifyAt.get(key) ?? 0;
54
+ if (nowMs < nextAllowedAt) return false;
55
+ this.notifyAt.set(key, nowMs + boundedCooldown);
56
+ return true;
57
+ }
58
+ }
@@ -0,0 +1,211 @@
1
+ import type { Message as DiscordMessage } from 'discord.js';
2
+
3
+ import { logger } from '../../logger.js';
4
+
5
+ export type LifecyclePhase =
6
+ | 'queued'
7
+ | 'thinking'
8
+ | 'toolUse'
9
+ | 'streaming'
10
+ | 'done'
11
+ | 'error';
12
+ export type DiscordRetryFn = <T>(
13
+ label: string,
14
+ fn: () => Promise<T>,
15
+ ) => Promise<T>;
16
+
17
+ const MIN_REACTION_GAP_MS = 350;
18
+ const DONE_REACTION_VISIBILITY_MS = 1_000;
19
+
20
+ function sleep(ms: number): Promise<void> {
21
+ return new Promise((resolve) => setTimeout(resolve, ms));
22
+ }
23
+
24
+ function findReactionByEmoji(
25
+ message: DiscordMessage,
26
+ emoji: string,
27
+ ): { users: { remove: (userId: string) => Promise<unknown> } } | null {
28
+ const direct = message.reactions.resolve(emoji);
29
+ if (direct) return direct;
30
+ const trimmed = emoji.trim();
31
+ if (!trimmed) return null;
32
+ const fallback = message.reactions.cache.find(
33
+ (reaction) =>
34
+ reaction.emoji.toString() === trimmed || reaction.emoji.name === trimmed,
35
+ );
36
+ return fallback ?? null;
37
+ }
38
+
39
+ export async function addAckReaction(params: {
40
+ message: DiscordMessage;
41
+ emoji: string;
42
+ withRetry: DiscordRetryFn;
43
+ botUserId: string;
44
+ }): Promise<() => Promise<void>> {
45
+ const reactionEmoji = params.emoji.trim();
46
+ if (!reactionEmoji) {
47
+ return async () => {};
48
+ }
49
+
50
+ try {
51
+ await params.withRetry('reaction-ack-add', () =>
52
+ params.message.react(reactionEmoji),
53
+ );
54
+ } catch (error) {
55
+ logger.debug(
56
+ {
57
+ error,
58
+ channelId: params.message.channelId,
59
+ messageId: params.message.id,
60
+ reactionEmoji,
61
+ },
62
+ 'Failed to add ack reaction',
63
+ );
64
+ return async () => {};
65
+ }
66
+
67
+ return async () => {
68
+ try {
69
+ const reaction = findReactionByEmoji(params.message, reactionEmoji);
70
+ if (!reaction) return;
71
+ await params.withRetry('reaction-ack-remove', () =>
72
+ reaction.users.remove(params.botUserId),
73
+ );
74
+ } catch (error) {
75
+ logger.debug(
76
+ {
77
+ error,
78
+ channelId: params.message.channelId,
79
+ messageId: params.message.id,
80
+ reactionEmoji,
81
+ },
82
+ 'Failed to remove ack reaction',
83
+ );
84
+ }
85
+ };
86
+ }
87
+
88
+ interface LifecycleReactionConfig {
89
+ enabled: boolean;
90
+ removeOnComplete: boolean;
91
+ phases: Record<LifecyclePhase, string>;
92
+ }
93
+
94
+ export class LifecycleReactionController {
95
+ private readonly message: DiscordMessage;
96
+ private readonly withRetry: DiscordRetryFn;
97
+ private readonly botUserId: string;
98
+ private readonly config: LifecycleReactionConfig;
99
+ private currentEmoji: string | null = null;
100
+ private currentPhase: LifecyclePhase | null = null;
101
+ private queue = Promise.resolve();
102
+ private lastReactionAt = 0;
103
+
104
+ constructor(params: {
105
+ message: DiscordMessage;
106
+ withRetry: DiscordRetryFn;
107
+ botUserId: string;
108
+ config: LifecycleReactionConfig;
109
+ }) {
110
+ this.message = params.message;
111
+ this.withRetry = params.withRetry;
112
+ this.botUserId = params.botUserId;
113
+ this.config = params.config;
114
+ }
115
+
116
+ setPhase(phase: LifecyclePhase): void {
117
+ if (!this.config.enabled) return;
118
+ if (this.currentPhase === phase) return;
119
+ this.currentPhase = phase;
120
+ this.queue = this.queue
121
+ .then(async () => {
122
+ await this.transitionToPhase(phase);
123
+ })
124
+ .catch((error) => {
125
+ logger.debug(
126
+ {
127
+ error,
128
+ channelId: this.message.channelId,
129
+ messageId: this.message.id,
130
+ phase,
131
+ },
132
+ 'Lifecycle reaction transition failed',
133
+ );
134
+ });
135
+ }
136
+
137
+ async clear(): Promise<void> {
138
+ if (!this.config.enabled) return;
139
+ await this.queue;
140
+ if (!this.currentEmoji) return;
141
+ await this.removeReaction(this.currentEmoji);
142
+ this.currentEmoji = null;
143
+ this.currentPhase = null;
144
+ }
145
+
146
+ private async transitionToPhase(phase: LifecyclePhase): Promise<void> {
147
+ const nextEmoji = (this.config.phases[phase] || '').trim();
148
+ if (!nextEmoji) return;
149
+ if (this.currentEmoji && this.currentEmoji !== nextEmoji) {
150
+ await this.removeReaction(this.currentEmoji);
151
+ this.currentEmoji = null;
152
+ }
153
+
154
+ await this.addReaction(nextEmoji);
155
+ this.currentEmoji = nextEmoji;
156
+
157
+ if (phase === 'done' && this.config.removeOnComplete) {
158
+ await sleep(DONE_REACTION_VISIBILITY_MS);
159
+ await this.removeReaction(nextEmoji);
160
+ this.currentEmoji = null;
161
+ }
162
+ }
163
+
164
+ private async waitForReactionWindow(): Promise<void> {
165
+ const elapsed = Date.now() - this.lastReactionAt;
166
+ if (elapsed >= MIN_REACTION_GAP_MS) return;
167
+ await sleep(MIN_REACTION_GAP_MS - elapsed);
168
+ }
169
+
170
+ private async addReaction(emoji: string): Promise<void> {
171
+ await this.waitForReactionWindow();
172
+ try {
173
+ await this.withRetry('reaction-lifecycle-add', () =>
174
+ this.message.react(emoji),
175
+ );
176
+ this.lastReactionAt = Date.now();
177
+ } catch (error) {
178
+ logger.debug(
179
+ {
180
+ error,
181
+ channelId: this.message.channelId,
182
+ messageId: this.message.id,
183
+ emoji,
184
+ },
185
+ 'Failed to add lifecycle reaction',
186
+ );
187
+ }
188
+ }
189
+
190
+ private async removeReaction(emoji: string): Promise<void> {
191
+ await this.waitForReactionWindow();
192
+ try {
193
+ const reaction = findReactionByEmoji(this.message, emoji);
194
+ if (!reaction) return;
195
+ await this.withRetry('reaction-lifecycle-remove', () =>
196
+ reaction.users.remove(this.botUserId),
197
+ );
198
+ this.lastReactionAt = Date.now();
199
+ } catch (error) {
200
+ logger.debug(
201
+ {
202
+ error,
203
+ channelId: this.message.channelId,
204
+ messageId: this.message.id,
205
+ emoji,
206
+ },
207
+ 'Failed to remove lifecycle reaction',
208
+ );
209
+ }
210
+ }
211
+ }