@hybridaione/hybridclaw 0.2.3 → 0.2.7

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 (285) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +95 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +57 -19
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +72 -7
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1405 -0
  12. package/container/src/browser-tools.ts +672 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +65 -16
  15. package/container/src/index.ts +463 -110
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +107 -18
  18. package/container/src/tools.ts +600 -225
  19. package/container/src/types.ts +49 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +15 -4
  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 +24 -4
  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 +31 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +156 -6
  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 +7 -4
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +755 -150
  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 +19 -12
  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 +77 -23
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +24 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +113 -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 +59 -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 +624 -87
  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 +303 -26
  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 +62 -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/hybridai-models.d.ts +8 -0
  132. package/dist/hybridai-models.d.ts.map +1 -0
  133. package/dist/hybridai-models.js +94 -0
  134. package/dist/hybridai-models.js.map +1 -0
  135. package/dist/instruction-approval-audit.d.ts.map +1 -1
  136. package/dist/instruction-approval-audit.js.map +1 -1
  137. package/dist/instruction-integrity.d.ts.map +1 -1
  138. package/dist/instruction-integrity.js +8 -2
  139. package/dist/instruction-integrity.js.map +1 -1
  140. package/dist/ipc.d.ts.map +1 -1
  141. package/dist/ipc.js +6 -1
  142. package/dist/ipc.js.map +1 -1
  143. package/dist/logger.js.map +1 -1
  144. package/dist/memory-consolidation.d.ts +17 -0
  145. package/dist/memory-consolidation.d.ts.map +1 -0
  146. package/dist/memory-consolidation.js +25 -0
  147. package/dist/memory-consolidation.js.map +1 -0
  148. package/dist/memory-service.d.ts +200 -0
  149. package/dist/memory-service.d.ts.map +1 -0
  150. package/dist/memory-service.js +294 -0
  151. package/dist/memory-service.js.map +1 -0
  152. package/dist/mount-security.d.ts.map +1 -1
  153. package/dist/mount-security.js +31 -7
  154. package/dist/mount-security.js.map +1 -1
  155. package/dist/observability-ingest.d.ts.map +1 -1
  156. package/dist/observability-ingest.js +32 -11
  157. package/dist/observability-ingest.js.map +1 -1
  158. package/dist/onboarding.d.ts.map +1 -1
  159. package/dist/onboarding.js +32 -9
  160. package/dist/onboarding.js.map +1 -1
  161. package/dist/proactive-policy.d.ts.map +1 -1
  162. package/dist/proactive-policy.js +2 -1
  163. package/dist/proactive-policy.js.map +1 -1
  164. package/dist/prompt-hooks.d.ts.map +1 -1
  165. package/dist/prompt-hooks.js +11 -7
  166. package/dist/prompt-hooks.js.map +1 -1
  167. package/dist/runtime-config.d.ts +104 -1
  168. package/dist/runtime-config.d.ts.map +1 -1
  169. package/dist/runtime-config.js +503 -24
  170. package/dist/runtime-config.js.map +1 -1
  171. package/dist/scheduled-task-runner.d.ts +1 -0
  172. package/dist/scheduled-task-runner.d.ts.map +1 -1
  173. package/dist/scheduled-task-runner.js +43 -10
  174. package/dist/scheduled-task-runner.js.map +1 -1
  175. package/dist/scheduler.d.ts +43 -4
  176. package/dist/scheduler.d.ts.map +1 -1
  177. package/dist/scheduler.js +530 -56
  178. package/dist/scheduler.js.map +1 -1
  179. package/dist/session-export.d.ts +26 -0
  180. package/dist/session-export.d.ts.map +1 -0
  181. package/dist/session-export.js +149 -0
  182. package/dist/session-export.js.map +1 -0
  183. package/dist/session-maintenance.d.ts.map +1 -1
  184. package/dist/session-maintenance.js +75 -13
  185. package/dist/session-maintenance.js.map +1 -1
  186. package/dist/session-transcripts.d.ts.map +1 -1
  187. package/dist/session-transcripts.js.map +1 -1
  188. package/dist/side-effects.d.ts.map +1 -1
  189. package/dist/side-effects.js +14 -2
  190. package/dist/side-effects.js.map +1 -1
  191. package/dist/skills-guard.d.ts.map +1 -1
  192. package/dist/skills-guard.js +893 -130
  193. package/dist/skills-guard.js.map +1 -1
  194. package/dist/skills.d.ts +5 -0
  195. package/dist/skills.d.ts.map +1 -1
  196. package/dist/skills.js +29 -15
  197. package/dist/skills.js.map +1 -1
  198. package/dist/token-efficiency.d.ts.map +1 -1
  199. package/dist/token-efficiency.js.map +1 -1
  200. package/dist/tui.js +92 -11
  201. package/dist/tui.js.map +1 -1
  202. package/dist/types.d.ts +150 -0
  203. package/dist/types.d.ts.map +1 -1
  204. package/dist/types.js +24 -1
  205. package/dist/types.js.map +1 -1
  206. package/dist/update.d.ts.map +1 -1
  207. package/dist/update.js +42 -14
  208. package/dist/update.js.map +1 -1
  209. package/dist/workspace.d.ts.map +1 -1
  210. package/dist/workspace.js +49 -9
  211. package/dist/workspace.js.map +1 -1
  212. package/docs/chat.html +9 -3
  213. package/docs/index.html +72 -13
  214. package/package.json +8 -2
  215. package/src/agent.ts +31 -4
  216. package/src/audit-cli.ts +44 -16
  217. package/src/audit-events.ts +69 -5
  218. package/src/audit-trail.ts +41 -15
  219. package/src/channels/discord/attachments.ts +81 -27
  220. package/src/channels/discord/debounce.ts +25 -0
  221. package/src/channels/discord/delivery.ts +65 -14
  222. package/src/channels/discord/human-delay.ts +48 -0
  223. package/src/channels/discord/inbound.ts +190 -8
  224. package/src/channels/discord/mentions.ts +42 -18
  225. package/src/channels/discord/presence.ts +148 -0
  226. package/src/channels/discord/rate-limiter.ts +58 -0
  227. package/src/channels/discord/reactions.ts +211 -0
  228. package/src/channels/discord/runtime.ts +1161 -205
  229. package/src/channels/discord/stream.ts +85 -31
  230. package/src/channels/discord/tool-actions.ts +78 -37
  231. package/src/channels/discord/typing.ts +140 -0
  232. package/src/chunk.ts +12 -4
  233. package/src/cli.ts +174 -57
  234. package/src/config.ts +208 -36
  235. package/src/container-runner.ts +134 -42
  236. package/src/container-setup.ts +57 -22
  237. package/src/conversation.ts +9 -7
  238. package/src/db.ts +2217 -84
  239. package/src/delegation-manager.ts +6 -2
  240. package/src/gateway-client.ts +41 -17
  241. package/src/gateway-service.ts +996 -220
  242. package/src/gateway-types.ts +33 -0
  243. package/src/gateway.ts +487 -52
  244. package/src/health.ts +66 -26
  245. package/src/heartbeat.ts +99 -22
  246. package/src/hybridai-bots.ts +14 -5
  247. package/src/hybridai-models.ts +158 -0
  248. package/src/instruction-approval-audit.ts +4 -1
  249. package/src/instruction-integrity.ts +30 -9
  250. package/src/ipc.ts +23 -5
  251. package/src/logger.ts +4 -1
  252. package/src/memory-consolidation.ts +41 -0
  253. package/src/memory-service.ts +606 -0
  254. package/src/mount-security.ts +58 -13
  255. package/src/observability-ingest.ts +134 -35
  256. package/src/onboarding.ts +126 -35
  257. package/src/proactive-policy.ts +3 -1
  258. package/src/prompt-hooks.ts +42 -17
  259. package/src/runtime-config.ts +1112 -122
  260. package/src/scheduled-task-runner.ts +77 -11
  261. package/src/scheduler.ts +683 -60
  262. package/src/session-export.ts +196 -0
  263. package/src/session-maintenance.ts +125 -22
  264. package/src/session-transcripts.ts +12 -3
  265. package/src/side-effects.ts +28 -5
  266. package/src/skills-guard.ts +1067 -219
  267. package/src/skills.ts +163 -65
  268. package/src/token-efficiency.ts +31 -9
  269. package/src/tui.ts +166 -25
  270. package/src/types.ts +199 -2
  271. package/src/update.ts +79 -23
  272. package/src/workspace.ts +63 -11
  273. package/tests/approval-policy.test.ts +335 -0
  274. package/tests/discord.basic.test.ts +213 -4
  275. package/tests/discord.human-presence.test.ts +85 -0
  276. package/tests/gateway-service.media-routing.test.ts +8 -2
  277. package/tests/hybridai-client.test.ts +112 -0
  278. package/tests/hybridai-models.test.ts +46 -0
  279. package/tests/memory-service.test.ts +1114 -0
  280. package/tests/token-efficiency.basic.test.ts +8 -2
  281. package/tests/token-usage.cache.test.ts +128 -0
  282. package/vitest.e2e.config.ts +3 -1
  283. package/vitest.integration.config.ts +3 -1
  284. package/vitest.live.config.ts +3 -1
  285. package/vitest.unit.config.ts +9 -0
@@ -1,9 +1,25 @@
1
- import { AttachmentBuilder, type ChatInputCommandInteraction, type Message as DiscordMessage } from 'discord.js';
1
+ import type {
2
+ AttachmentBuilder,
3
+ ChatInputCommandInteraction,
4
+ Message as DiscordMessage,
5
+ } from 'discord.js';
2
6
 
3
7
  import { chunkMessage } from '../../chunk.js';
4
- import { rewriteUserMentions, type MentionLookup } from './mentions.js';
8
+ import {
9
+ DISCORD_MAX_LINES_PER_MESSAGE,
10
+ DISCORD_TEXT_CHUNK_LIMIT,
11
+ } from '../../config.js';
12
+ import {
13
+ getHumanDelayMs,
14
+ type HumanDelayConfig,
15
+ sleep,
16
+ } from './human-delay.js';
17
+ import { type MentionLookup, rewriteUserMentions } from './mentions.js';
5
18
 
6
- export type DiscordRetryFn = <T>(label: string, fn: () => Promise<T>) => Promise<T>;
19
+ export type DiscordRetryFn = <T>(
20
+ label: string,
21
+ fn: () => Promise<T>,
22
+ ) => Promise<T>;
7
23
 
8
24
  export function buildResponseText(text: string, toolsUsed?: string[]): string {
9
25
  let body = text;
@@ -27,12 +43,19 @@ export function prepareChunkedPayloads(
27
43
  files?: AttachmentBuilder[],
28
44
  mentionLookup?: MentionLookup,
29
45
  ): { content: string; files?: AttachmentBuilder[] }[] {
30
- const prepared = mentionLookup ? rewriteUserMentions(text, mentionLookup) : text;
31
- const chunks = chunkMessage(prepared, { maxChars: 1_900, maxLines: 20 });
46
+ const prepared = mentionLookup
47
+ ? rewriteUserMentions(text, mentionLookup)
48
+ : text;
49
+ const chunks = chunkMessage(prepared, {
50
+ maxChars: Math.max(200, Math.min(2_000, DISCORD_TEXT_CHUNK_LIMIT)),
51
+ maxLines: Math.max(4, Math.min(200, DISCORD_MAX_LINES_PER_MESSAGE)),
52
+ });
32
53
  const safeChunks = chunks.length > 0 ? chunks : ['(no content)'];
33
54
  return safeChunks.map((content, i) => ({
34
55
  content,
35
- ...(i === safeChunks.length - 1 && files && files.length > 0 ? { files } : {}),
56
+ ...(i === safeChunks.length - 1 && files && files.length > 0
57
+ ? { files }
58
+ : {}),
36
59
  }));
37
60
  }
38
61
 
@@ -42,6 +65,7 @@ export async function sendChunkedReply(params: {
42
65
  withRetry: DiscordRetryFn;
43
66
  files?: AttachmentBuilder[];
44
67
  mentionLookup?: MentionLookup;
68
+ humanDelay?: HumanDelayConfig;
45
69
  }): Promise<void> {
46
70
  const payloads = prepareChunkedPayloads(
47
71
  params.text,
@@ -52,9 +76,20 @@ export async function sendChunkedReply(params: {
52
76
  if (i === 0) {
53
77
  await params.withRetry('reply', () => params.msg.reply(payloads[i]));
54
78
  } else {
55
- await params.withRetry('send', () => (params.msg.channel as unknown as {
56
- send: (next: { content: string; files?: AttachmentBuilder[] }) => Promise<void>;
57
- }).send(payloads[i]));
79
+ const delayMs = getHumanDelayMs(params.humanDelay);
80
+ if (delayMs > 0) {
81
+ await sleep(delayMs);
82
+ }
83
+ await params.withRetry('send', () =>
84
+ (
85
+ params.msg.channel as unknown as {
86
+ send: (next: {
87
+ content: string;
88
+ files?: AttachmentBuilder[];
89
+ }) => Promise<void>;
90
+ }
91
+ ).send(payloads[i]),
92
+ );
58
93
  }
59
94
  }
60
95
  }
@@ -65,14 +100,24 @@ export async function sendChunkedDirectReply(params: {
65
100
  withRetry: DiscordRetryFn;
66
101
  files?: AttachmentBuilder[];
67
102
  mentionLookup?: MentionLookup;
103
+ humanDelay?: HumanDelayConfig;
68
104
  }): Promise<void> {
69
105
  const payloads = prepareChunkedPayloads(
70
106
  params.text,
71
107
  params.files,
72
108
  params.mentionLookup,
73
109
  );
74
- const dm = await params.withRetry('dm-open', () => params.msg.author.createDM());
75
- for (const payload of payloads) {
110
+ const dm = await params.withRetry('dm-open', () =>
111
+ params.msg.author.createDM(),
112
+ );
113
+ for (let i = 0; i < payloads.length; i += 1) {
114
+ if (i > 0) {
115
+ const delayMs = getHumanDelayMs(params.humanDelay);
116
+ if (delayMs > 0) {
117
+ await sleep(delayMs);
118
+ }
119
+ }
120
+ const payload = payloads[i];
76
121
  await params.withRetry('dm-send', () => dm.send(payload));
77
122
  }
78
123
  }
@@ -88,12 +133,18 @@ export async function sendChunkedInteractionReply(params: {
88
133
  const payload = { ...payloads[i], ephemeral: true };
89
134
  if (i === 0) {
90
135
  if (params.interaction.replied || params.interaction.deferred) {
91
- await params.withRetry('interaction-followup', () => params.interaction.followUp(payload));
136
+ await params.withRetry('interaction-followup', () =>
137
+ params.interaction.followUp(payload),
138
+ );
92
139
  } else {
93
- await params.withRetry('interaction-reply', () => params.interaction.reply(payload));
140
+ await params.withRetry('interaction-reply', () =>
141
+ params.interaction.reply(payload),
142
+ );
94
143
  }
95
144
  continue;
96
145
  }
97
- await params.withRetry('interaction-followup', () => params.interaction.followUp(payload));
146
+ await params.withRetry('interaction-followup', () =>
147
+ params.interaction.followUp(payload),
148
+ );
98
149
  }
99
150
  }
@@ -0,0 +1,48 @@
1
+ export interface HumanDelayConfig {
2
+ mode: 'off' | 'natural' | 'custom';
3
+ minMs?: number;
4
+ maxMs?: number;
5
+ }
6
+
7
+ export interface ResolvedHumanDelayConfig {
8
+ mode: 'off' | 'natural' | 'custom';
9
+ minMs: number;
10
+ maxMs: number;
11
+ }
12
+
13
+ export const DEFAULT_HUMAN_DELAY_MIN_MS = 800;
14
+ export const DEFAULT_HUMAN_DELAY_MAX_MS = 2_500;
15
+
16
+ export function resolveHumanDelayConfig(
17
+ config?: HumanDelayConfig,
18
+ ): ResolvedHumanDelayConfig {
19
+ const mode = config?.mode ?? 'natural';
20
+ const minMs = Math.max(
21
+ 0,
22
+ Math.floor(config?.minMs ?? DEFAULT_HUMAN_DELAY_MIN_MS),
23
+ );
24
+ const rawMax = Math.max(
25
+ 0,
26
+ Math.floor(config?.maxMs ?? DEFAULT_HUMAN_DELAY_MAX_MS),
27
+ );
28
+ const maxMs = Math.max(minMs, rawMax);
29
+ return {
30
+ mode,
31
+ minMs,
32
+ maxMs,
33
+ };
34
+ }
35
+
36
+ export function getHumanDelayMs(config?: HumanDelayConfig): number {
37
+ const resolved = resolveHumanDelayConfig(config);
38
+ if (resolved.mode === 'off') return 0;
39
+ if (resolved.maxMs <= resolved.minMs) return resolved.minMs;
40
+ const span = resolved.maxMs - resolved.minMs + 1;
41
+ return resolved.minMs + Math.floor(Math.random() * span);
42
+ }
43
+
44
+ export async function sleep(ms: number): Promise<void> {
45
+ const waitMs = Math.max(0, Math.floor(ms));
46
+ if (waitMs <= 0) return;
47
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
48
+ }
@@ -5,8 +5,79 @@ export interface ParsedCommand {
5
5
  }
6
6
 
7
7
  export type DiscordGuildMessageMode = 'off' | 'mention' | 'free';
8
+ export type DiscordCommandAccessMode = 'public' | 'restricted';
9
+ const KNOWN_SUBCOMMANDS = new Set([
10
+ 'bot',
11
+ 'rag',
12
+ 'model',
13
+ 'status',
14
+ 'approve',
15
+ 'usage',
16
+ 'export',
17
+ 'sessions',
18
+ 'audit',
19
+ 'schedule',
20
+ 'channel',
21
+ 'ralph',
22
+ 'clear',
23
+ 'help',
24
+ ]);
8
25
 
9
- export function stripBotMentions(text: string, botMentionRegex: RegExp | null): string {
26
+ const GREETING_ONLY_RE =
27
+ /^(hi|hey|hello|yo|sup|thanks|thank you|thx|ok|okay|got it|roger|cool)[!. ]*$/i;
28
+ const FREE_MODE_ACK_ONLY_RE =
29
+ /^(ok|okay|thanks|thank you|thx|ty|cool|nice|got it|roger|sounds good|sgtm|lol|lmao)[!. ]*$/i;
30
+ const FREE_MODE_QUESTION_START_RE =
31
+ /^(?:hey|hi|hello|yo|hmm|hm|well)?[\s,:-]*(?:can|could|would|will|should|do|does|did|is|are|am|how|what|why|when|where|who|which)\b/i;
32
+ const FREE_MODE_REQUEST_VERB_RE =
33
+ /\b(?:please|pls|help|explain|review|check|debug|fix|summari[sz]e|create|write|show|tell|give|generate|compare|analy[sz]e)\b/i;
34
+ const FREE_MODE_PROBLEM_SIGNAL_RE =
35
+ /\b(?:error|failed|failure|exception|stack trace|broken|issue|not working|doesn't work|cannot|can't|wont|won't)\b/i;
36
+ const URL_ONLY_RE = /^https?:\/\/\S+$/i;
37
+ const FREE_MODE_CHANNEL_ADDRESS_RE =
38
+ /\b(?:all|everyone|anyone|team|folks|guys|channel|chat|alle|zusammen|jemand)\b/i;
39
+
40
+ function escapeRegex(value: string): string {
41
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ }
43
+
44
+ function normalizeAlias(raw: string): string {
45
+ return raw.trim().replace(/^@+/, '').toLowerCase();
46
+ }
47
+
48
+ export function hasLooseBotMention(
49
+ content: string,
50
+ botAliases: string[],
51
+ ): boolean {
52
+ const normalized = content.trim().toLowerCase();
53
+ if (!normalized) return false;
54
+
55
+ for (const rawAlias of botAliases) {
56
+ const alias = normalizeAlias(rawAlias);
57
+ if (!alias || alias.length < 2) continue;
58
+ if (!/^[\p{L}\p{N}._-]+$/u.test(alias)) continue;
59
+
60
+ const escapedAlias = escapeRegex(alias);
61
+ const mentionRe = new RegExp(
62
+ `(?:^|[^\\p{L}\\p{N}_-])${escapedAlias}(?=$|[^\\p{L}\\p{N}_-])`,
63
+ 'iu',
64
+ );
65
+ if (mentionRe.test(normalized)) return true;
66
+ }
67
+
68
+ return false;
69
+ }
70
+
71
+ export function isAddressedToChannel(content: string): boolean {
72
+ const normalized = content.trim();
73
+ if (!normalized) return false;
74
+ return FREE_MODE_CHANNEL_ADDRESS_RE.test(normalized);
75
+ }
76
+
77
+ export function stripBotMentions(
78
+ text: string,
79
+ botMentionRegex: RegExp | null,
80
+ ): string {
10
81
  if (!botMentionRegex) return text;
11
82
  return text.replace(botMentionRegex, '').trim();
12
83
  }
@@ -32,7 +103,22 @@ export function hasPrefixInvocation(
32
103
  return text.startsWith(prefix);
33
104
  }
34
105
 
35
- export function buildSessionIdFromContext(guildId: string | null, channelId: string, userId: string): string {
106
+ export function hasSlashCommandInvocation(
107
+ content: string,
108
+ botMentionRegex: RegExp | null,
109
+ ): boolean {
110
+ const text = stripBotMentions(content, botMentionRegex).trim();
111
+ if (!text.startsWith('/')) return false;
112
+ const token = text.slice(1).split(/\s+/)[0]?.toLowerCase() || '';
113
+ if (!token) return false;
114
+ return KNOWN_SUBCOMMANDS.has(token);
115
+ }
116
+
117
+ export function buildSessionIdFromContext(
118
+ guildId: string | null,
119
+ channelId: string,
120
+ userId: string,
121
+ ): string {
36
122
  return guildId ? `${guildId}:${channelId}` : `dm:${userId}`;
37
123
  }
38
124
 
@@ -44,17 +130,99 @@ export function parseCommand(
44
130
  let text = stripBotMentions(content, botMentionRegex);
45
131
  if (text.startsWith(prefix)) {
46
132
  text = text.slice(prefix.length).trim();
133
+ } else if (text.startsWith('/')) {
134
+ text = text.slice(1).trim();
47
135
  }
48
136
 
49
137
  const parts = text.split(/\s+/);
50
- const subcommands = ['bot', 'rag', 'model', 'sessions', 'audit', 'schedule', 'channel', 'clear', 'help'];
51
- if (parts.length > 0 && subcommands.includes(parts[0].toLowerCase())) {
52
- return { isCommand: true, command: parts[0].toLowerCase(), args: parts.slice(1) };
138
+ if (parts.length > 0 && KNOWN_SUBCOMMANDS.has(parts[0].toLowerCase())) {
139
+ return {
140
+ isCommand: true,
141
+ command: parts[0].toLowerCase(),
142
+ args: parts.slice(1),
143
+ };
53
144
  }
54
145
 
55
146
  return { isCommand: false, command: '', args: [] };
56
147
  }
57
148
 
149
+ export function shouldSuppressAutoReply(
150
+ content: string,
151
+ suppressPatterns?: string[],
152
+ ): boolean {
153
+ const normalized = content.trim().toLowerCase();
154
+ if (!normalized) return false;
155
+ if (GREETING_ONLY_RE.test(normalized)) return true;
156
+ if (!suppressPatterns || suppressPatterns.length === 0) return false;
157
+ return suppressPatterns.some((pattern) => {
158
+ const needle = pattern.trim().toLowerCase();
159
+ if (!needle) return false;
160
+ return normalized.includes(needle);
161
+ });
162
+ }
163
+
164
+ export function shouldSkipFreeReplyBecauseOtherUsersMentioned(params: {
165
+ guildMessageMode: DiscordGuildMessageMode;
166
+ hasBotMention: boolean;
167
+ hasPrefixInvocation: boolean;
168
+ botUserId: string | null;
169
+ mentionedUserIds: string[];
170
+ }): boolean {
171
+ if (params.guildMessageMode !== 'free') return false;
172
+ if (params.hasBotMention) return false;
173
+ if (params.hasPrefixInvocation) return false;
174
+ if (params.mentionedUserIds.length === 0) return false;
175
+
176
+ const botId = params.botUserId?.trim() || '';
177
+ return params.mentionedUserIds.some((userId) => userId !== botId);
178
+ }
179
+
180
+ export function shouldReplyInFreeMode(params: {
181
+ guildMessageMode: DiscordGuildMessageMode;
182
+ content: string;
183
+ hasBotMention: boolean;
184
+ hasLooseBotMention?: boolean;
185
+ isAddressedToChannel?: boolean;
186
+ hasPrefixInvocation: boolean;
187
+ isReplyToBot: boolean;
188
+ hasAttachments: boolean;
189
+ }): boolean {
190
+ if (params.guildMessageMode !== 'free') return true;
191
+ if (params.hasPrefixInvocation) return true;
192
+ if (params.hasBotMention) return true;
193
+ if (params.hasLooseBotMention) return true;
194
+ if (params.isReplyToBot) return true;
195
+ if (params.hasAttachments) return true;
196
+
197
+ const normalized = params.content.trim();
198
+ if (!normalized) return false;
199
+ if (FREE_MODE_ACK_ONLY_RE.test(normalized)) return false;
200
+ if (URL_ONLY_RE.test(normalized)) return false;
201
+ if (params.isAddressedToChannel) return true;
202
+ if (normalized.includes('?')) return true;
203
+ if (FREE_MODE_QUESTION_START_RE.test(normalized)) return true;
204
+ if (FREE_MODE_REQUEST_VERB_RE.test(normalized)) return true;
205
+ if (FREE_MODE_PROBLEM_SIGNAL_RE.test(normalized)) return true;
206
+ if (normalized.includes('```') || /`[^`]+`/.test(normalized)) return true;
207
+ return false;
208
+ }
209
+
210
+ export function isAuthorizedCommandUser(params: {
211
+ mode: DiscordCommandAccessMode;
212
+ userId: string;
213
+ allowedUserIds: string[];
214
+ legacyCommandUserId?: string;
215
+ }): boolean {
216
+ if (params.mode === 'public') return true;
217
+ const allowed = new Set(
218
+ params.allowedUserIds.map((entry) => entry.trim()).filter(Boolean),
219
+ );
220
+ const legacy = (params.legacyCommandUserId || '').trim();
221
+ if (legacy) allowed.add(legacy);
222
+ if (allowed.size === 0) return false;
223
+ return allowed.has(params.userId);
224
+ }
225
+
58
226
  export function isTrigger(params: {
59
227
  content: string;
60
228
  isDm: boolean;
@@ -64,15 +232,29 @@ export function isTrigger(params: {
64
232
  prefix: string;
65
233
  botMentionRegex: RegExp | null;
66
234
  hasBotMention: boolean;
235
+ suppressPatterns?: string[];
67
236
  }): boolean {
237
+ const stripped = stripBotMentions(params.content, params.botMentionRegex);
238
+ const hasPrefixed = hasPrefixInvocation(
239
+ params.content,
240
+ params.botMentionRegex,
241
+ params.prefix,
242
+ );
243
+ const hasSlash = hasSlashCommandInvocation(
244
+ params.content,
245
+ params.botMentionRegex,
246
+ );
247
+
68
248
  if (params.commandsOnly) {
69
- return hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix);
249
+ return hasPrefixed || hasSlash;
70
250
  }
251
+ if (hasPrefixed || hasSlash) return true;
252
+ if (shouldSuppressAutoReply(stripped, params.suppressPatterns)) return false;
71
253
  if (params.isDm) return true;
72
- if (hasPrefixInvocation(params.content, params.botMentionRegex, params.prefix)) return true;
73
254
  if (params.guildMessageMode === 'off') return false;
74
255
  if (params.guildMessageMode === 'free') return true;
75
- if (params.respondToAllMessages) return true;
256
+ // Keep `respondToAllMessages` consumed for compatibility; mode resolution decides guild behavior.
257
+ void params.respondToAllMessages;
76
258
  if (params.hasBotMention) return true;
77
259
  return false;
78
260
  }
@@ -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
+ }