@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,8 +1,19 @@
1
1
  import { expect, test } from 'vitest';
2
2
 
3
3
  import { buildResponseText } from '../src/channels/discord/delivery.js';
4
- import { isTrigger, parseCommand } from '../src/channels/discord/inbound.js';
5
- import { rewriteUserMentions, type MentionLookup } from '../src/channels/discord/mentions.js';
4
+ import {
5
+ hasLooseBotMention,
6
+ isAddressedToChannel,
7
+ isAuthorizedCommandUser,
8
+ isTrigger,
9
+ parseCommand,
10
+ shouldReplyInFreeMode,
11
+ shouldSkipFreeReplyBecauseOtherUsersMentioned,
12
+ } from '../src/channels/discord/inbound.js';
13
+ import {
14
+ type MentionLookup,
15
+ rewriteUserMentions,
16
+ } from '../src/channels/discord/mentions.js';
6
17
 
7
18
  function createLookup(entries: Record<string, string[]>): MentionLookup {
8
19
  const byAlias = new Map<string, Set<string>>();
@@ -19,7 +30,9 @@ test('rewriteUserMentions rewrites a uniquely-resolved @alias', () => {
19
30
  });
20
31
 
21
32
  test('rewriteUserMentions does not rewrite ambiguous aliases', () => {
22
- const lookup = createLookup({ bob: ['111111111111111111', '222222222222222222'] });
33
+ const lookup = createLookup({
34
+ bob: ['111111111111111111', '222222222222222222'],
35
+ });
23
36
  const output = rewriteUserMentions('hi @bob', lookup);
24
37
  expect(output).toBe('hi @bob');
25
38
  });
@@ -71,9 +84,23 @@ test('isTrigger still allows prefixed commands when channel mode is off', () =>
71
84
  expect(shouldTrigger).toBe(true);
72
85
  });
73
86
 
87
+ test('isTrigger allows slash-text commands when channel mode is off', () => {
88
+ const shouldTrigger = isTrigger({
89
+ content: '/status',
90
+ isDm: false,
91
+ commandsOnly: false,
92
+ respondToAllMessages: false,
93
+ guildMessageMode: 'off',
94
+ prefix: '!claw',
95
+ botMentionRegex: null,
96
+ hasBotMention: false,
97
+ });
98
+ expect(shouldTrigger).toBe(true);
99
+ });
100
+
74
101
  test('isTrigger allows free-response mode in guild channels', () => {
75
102
  const shouldTrigger = isTrigger({
76
- content: 'hello',
103
+ content: 'Can you review this patch?',
77
104
  isDm: false,
78
105
  commandsOnly: false,
79
106
  respondToAllMessages: false,
@@ -85,6 +112,20 @@ test('isTrigger allows free-response mode in guild channels', () => {
85
112
  expect(shouldTrigger).toBe(true);
86
113
  });
87
114
 
115
+ test('isTrigger keeps mention mode even when respondToAllMessages is enabled', () => {
116
+ const shouldTrigger = isTrigger({
117
+ content: 'hello',
118
+ isDm: false,
119
+ commandsOnly: false,
120
+ respondToAllMessages: true,
121
+ guildMessageMode: 'mention',
122
+ prefix: '!claw',
123
+ botMentionRegex: null,
124
+ hasBotMention: false,
125
+ });
126
+ expect(shouldTrigger).toBe(false);
127
+ });
128
+
88
129
  test('parseCommand recognizes channel command namespace', () => {
89
130
  const parsed = parseCommand('!claw channel mode free', null, '!claw');
90
131
  expect(parsed).toEqual({
@@ -93,3 +134,171 @@ test('parseCommand recognizes channel command namespace', () => {
93
134
  args: ['mode', 'free'],
94
135
  });
95
136
  });
137
+
138
+ test('parseCommand recognizes usage command namespace', () => {
139
+ const parsed = parseCommand('!claw usage monthly', null, '!claw');
140
+ expect(parsed).toEqual({
141
+ isCommand: true,
142
+ command: 'usage',
143
+ args: ['monthly'],
144
+ });
145
+ });
146
+
147
+ test('parseCommand recognizes status command namespace', () => {
148
+ const parsed = parseCommand('!claw status', null, '!claw');
149
+ expect(parsed).toEqual({
150
+ isCommand: true,
151
+ command: 'status',
152
+ args: [],
153
+ });
154
+ });
155
+
156
+ test('parseCommand recognizes slash-text status command namespace', () => {
157
+ const parsed = parseCommand('/status', null, '!claw');
158
+ expect(parsed).toEqual({
159
+ isCommand: true,
160
+ command: 'status',
161
+ args: [],
162
+ });
163
+ });
164
+
165
+ test('isTrigger commands-only allows slash-text commands', () => {
166
+ const shouldTrigger = isTrigger({
167
+ content: '/status',
168
+ isDm: false,
169
+ commandsOnly: true,
170
+ respondToAllMessages: false,
171
+ guildMessageMode: 'off',
172
+ prefix: '!claw',
173
+ botMentionRegex: null,
174
+ hasBotMention: false,
175
+ });
176
+ expect(shouldTrigger).toBe(true);
177
+ });
178
+
179
+ test('free-mode skips non-bot user mentions', () => {
180
+ const shouldSkip = shouldSkipFreeReplyBecauseOtherUsersMentioned({
181
+ guildMessageMode: 'free',
182
+ hasBotMention: false,
183
+ hasPrefixInvocation: false,
184
+ botUserId: '111',
185
+ mentionedUserIds: ['222'],
186
+ });
187
+ expect(shouldSkip).toBe(true);
188
+ });
189
+
190
+ test('free-mode does not skip when bot is mentioned', () => {
191
+ const shouldSkip = shouldSkipFreeReplyBecauseOtherUsersMentioned({
192
+ guildMessageMode: 'free',
193
+ hasBotMention: true,
194
+ hasPrefixInvocation: false,
195
+ botUserId: '111',
196
+ mentionedUserIds: ['111', '222'],
197
+ });
198
+ expect(shouldSkip).toBe(false);
199
+ });
200
+
201
+ test('free-mode replies to actionable questions', () => {
202
+ const shouldReply = shouldReplyInFreeMode({
203
+ guildMessageMode: 'free',
204
+ content: 'Can you review this patch?',
205
+ hasBotMention: false,
206
+ hasPrefixInvocation: false,
207
+ isReplyToBot: false,
208
+ hasAttachments: false,
209
+ });
210
+ expect(shouldReply).toBe(true);
211
+ });
212
+
213
+ test('free-mode replies to plain-text bot-name mentions', () => {
214
+ const hasLooseMention = hasLooseBotMention('hybridclaw, thoughts on this?', [
215
+ 'HybridClaw',
216
+ 'claw',
217
+ ]);
218
+ expect(hasLooseMention).toBe(true);
219
+
220
+ const shouldReply = shouldReplyInFreeMode({
221
+ guildMessageMode: 'free',
222
+ content: 'hybridclaw please take a look',
223
+ hasBotMention: false,
224
+ hasLooseBotMention: true,
225
+ hasPrefixInvocation: false,
226
+ isReplyToBot: false,
227
+ hasAttachments: false,
228
+ });
229
+ expect(shouldReply).toBe(true);
230
+ });
231
+
232
+ test('free-mode replies to channel-addressed messages', () => {
233
+ expect(isAddressedToChannel('Hey everyone, quick status update')).toBe(true);
234
+
235
+ const shouldReply = shouldReplyInFreeMode({
236
+ guildMessageMode: 'free',
237
+ content: 'Hey everyone, quick status update',
238
+ hasBotMention: false,
239
+ hasPrefixInvocation: false,
240
+ isAddressedToChannel: true,
241
+ isReplyToBot: false,
242
+ hasAttachments: false,
243
+ });
244
+ expect(shouldReply).toBe(true);
245
+ });
246
+
247
+ test('free-mode ignores low-signal acknowledgements', () => {
248
+ const shouldReply = shouldReplyInFreeMode({
249
+ guildMessageMode: 'free',
250
+ content: 'ok',
251
+ hasBotMention: false,
252
+ hasPrefixInvocation: false,
253
+ isReplyToBot: false,
254
+ hasAttachments: false,
255
+ });
256
+ expect(shouldReply).toBe(false);
257
+ });
258
+
259
+ test('free-mode allows reply-to-bot followups', () => {
260
+ const shouldReply = shouldReplyInFreeMode({
261
+ guildMessageMode: 'free',
262
+ content: 'sure',
263
+ hasBotMention: false,
264
+ hasPrefixInvocation: false,
265
+ isReplyToBot: true,
266
+ hasAttachments: false,
267
+ });
268
+ expect(shouldReply).toBe(true);
269
+ });
270
+
271
+ test('command access allows all users in public mode', () => {
272
+ const authorized = isAuthorizedCommandUser({
273
+ mode: 'public',
274
+ userId: '222',
275
+ allowedUserIds: ['111'],
276
+ legacyCommandUserId: '333',
277
+ });
278
+ expect(authorized).toBe(true);
279
+ });
280
+
281
+ test('command access enforces allowlist in restricted mode', () => {
282
+ const authorized = isAuthorizedCommandUser({
283
+ mode: 'restricted',
284
+ userId: '222',
285
+ allowedUserIds: ['111', '222'],
286
+ });
287
+ expect(authorized).toBe(true);
288
+ const denied = isAuthorizedCommandUser({
289
+ mode: 'restricted',
290
+ userId: '333',
291
+ allowedUserIds: ['111', '222'],
292
+ });
293
+ expect(denied).toBe(false);
294
+ });
295
+
296
+ test('command access uses legacy commandUserId in restricted mode', () => {
297
+ const authorized = isAuthorizedCommandUser({
298
+ mode: 'restricted',
299
+ userId: '777',
300
+ allowedUserIds: [],
301
+ legacyCommandUserId: '777',
302
+ });
303
+ expect(authorized).toBe(true);
304
+ });
@@ -0,0 +1,85 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import {
4
+ resolveInboundDebounceMs,
5
+ shouldDebounceInbound,
6
+ } from '../src/channels/discord/debounce.js';
7
+ import {
8
+ getHumanDelayMs,
9
+ resolveHumanDelayConfig,
10
+ } from '../src/channels/discord/human-delay.js';
11
+ import { shouldSuppressAutoReply } from '../src/channels/discord/inbound.js';
12
+ import { SlidingWindowRateLimiter } from '../src/channels/discord/rate-limiter.js';
13
+
14
+ test('resolveHumanDelayConfig normalizes custom bounds', () => {
15
+ const resolved = resolveHumanDelayConfig({
16
+ mode: 'custom',
17
+ minMs: 2_500,
18
+ maxMs: 800,
19
+ });
20
+ expect(resolved).toEqual({
21
+ mode: 'custom',
22
+ minMs: 2_500,
23
+ maxMs: 2_500,
24
+ });
25
+ });
26
+
27
+ test('getHumanDelayMs returns 0 for off mode', () => {
28
+ expect(getHumanDelayMs({ mode: 'off' })).toBe(0);
29
+ });
30
+
31
+ test('getHumanDelayMs respects configured range', () => {
32
+ for (let i = 0; i < 20; i += 1) {
33
+ const value = getHumanDelayMs({ mode: 'custom', minMs: 10, maxMs: 20 });
34
+ expect(value).toBeGreaterThanOrEqual(10);
35
+ expect(value).toBeLessThanOrEqual(20);
36
+ }
37
+ });
38
+
39
+ test('shouldDebounceInbound skips media and control commands', () => {
40
+ expect(
41
+ shouldDebounceInbound({
42
+ content: 'hello',
43
+ hasAttachments: false,
44
+ isPrefixedCommand: false,
45
+ }),
46
+ ).toBe(true);
47
+ expect(
48
+ shouldDebounceInbound({
49
+ content: 'hello',
50
+ hasAttachments: true,
51
+ isPrefixedCommand: false,
52
+ }),
53
+ ).toBe(false);
54
+ expect(
55
+ shouldDebounceInbound({
56
+ content: '/stop now',
57
+ hasAttachments: false,
58
+ isPrefixedCommand: false,
59
+ }),
60
+ ).toBe(false);
61
+ });
62
+
63
+ test('resolveInboundDebounceMs uses channel override when provided', () => {
64
+ expect(resolveInboundDebounceMs(2500, undefined)).toBe(2500);
65
+ expect(resolveInboundDebounceMs(2500, 5000)).toBe(5000);
66
+ });
67
+
68
+ test('shouldSuppressAutoReply matches patterns and greeting-only text', () => {
69
+ expect(shouldSuppressAutoReply('brb', ['/stop', 'brb'])).toBe(true);
70
+ expect(shouldSuppressAutoReply('hello', ['/stop'])).toBe(true);
71
+ expect(shouldSuppressAutoReply('please help me debug this', ['/stop'])).toBe(
72
+ false,
73
+ );
74
+ });
75
+
76
+ test('SlidingWindowRateLimiter enforces per-minute limits', () => {
77
+ const limiter = new SlidingWindowRateLimiter(60_000);
78
+ const key = 'channel:user';
79
+ expect(limiter.check(key, 2, 1_000).allowed).toBe(true);
80
+ expect(limiter.check(key, 2, 2_000).allowed).toBe(true);
81
+ const blocked = limiter.check(key, 2, 3_000);
82
+ expect(blocked.allowed).toBe(false);
83
+ expect(blocked.retryAfterMs).toBeGreaterThan(0);
84
+ expect(limiter.check(key, 2, 62_000).allowed).toBe(true);
85
+ });
@@ -15,13 +15,19 @@ const SAMPLE_MEDIA: MediaContextItem[] = [
15
15
  ];
16
16
 
17
17
  test('Discord image question blocks browser_vision and prioritizes vision_analyze', () => {
18
- const policy = resolveMediaToolPolicy('Was steht auf dem Bild?', SAMPLE_MEDIA);
18
+ const policy = resolveMediaToolPolicy(
19
+ 'Was steht auf dem Bild?',
20
+ SAMPLE_MEDIA,
21
+ );
19
22
  expect(policy.prioritizeVisionTool).toBe(true);
20
23
  expect(policy.blockedTools).toEqual(['browser_vision']);
21
24
  });
22
25
 
23
26
  test('Explicit browser-tab question does not block browser_vision', () => {
24
- const policy = resolveMediaToolPolicy('What is on the current browser tab?', SAMPLE_MEDIA);
27
+ const policy = resolveMediaToolPolicy(
28
+ 'What is on the current browser tab?',
29
+ SAMPLE_MEDIA,
30
+ );
25
31
  expect(policy.prioritizeVisionTool).toBe(false);
26
32
  expect(policy.blockedTools).toBeUndefined();
27
33
  });
@@ -0,0 +1,112 @@
1
+ import { afterEach, expect, test, vi } from 'vitest';
2
+
3
+ import {
4
+ callHybridAI,
5
+ callHybridAIStream,
6
+ } from '../container/src/hybridai-client.js';
7
+
8
+ const okResponse = {
9
+ id: 'resp_1',
10
+ model: 'gpt-5-nano',
11
+ choices: [
12
+ {
13
+ message: {
14
+ role: 'assistant',
15
+ content: 'ok',
16
+ },
17
+ finish_reason: 'stop',
18
+ },
19
+ ],
20
+ };
21
+
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ });
25
+
26
+ test('callHybridAI forwards max_tokens when provided', async () => {
27
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
28
+ const body = JSON.parse(String(init?.body || '{}')) as Record<
29
+ string,
30
+ unknown
31
+ >;
32
+ expect(body.max_tokens).toBe(4096);
33
+ return new Response(JSON.stringify(okResponse), {
34
+ status: 200,
35
+ headers: { 'Content-Type': 'application/json' },
36
+ });
37
+ });
38
+ vi.stubGlobal('fetch', fetchMock);
39
+
40
+ const result = await callHybridAI(
41
+ 'https://hybridai.one',
42
+ 'test-key',
43
+ 'gpt-5-nano',
44
+ 'bot_1',
45
+ true,
46
+ [{ role: 'user', content: 'hello' }],
47
+ [],
48
+ 4096,
49
+ );
50
+
51
+ expect(result.choices[0]?.message.content).toBe('ok');
52
+ expect(fetchMock).toHaveBeenCalledTimes(1);
53
+ });
54
+
55
+ test('callHybridAI omits max_tokens when not provided', async () => {
56
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
57
+ const body = JSON.parse(String(init?.body || '{}')) as Record<
58
+ string,
59
+ unknown
60
+ >;
61
+ expect(body.max_tokens).toBeUndefined();
62
+ return new Response(JSON.stringify(okResponse), {
63
+ status: 200,
64
+ headers: { 'Content-Type': 'application/json' },
65
+ });
66
+ });
67
+ vi.stubGlobal('fetch', fetchMock);
68
+
69
+ await callHybridAI(
70
+ 'https://hybridai.one',
71
+ 'test-key',
72
+ 'gpt-5-nano',
73
+ 'bot_1',
74
+ true,
75
+ [{ role: 'user', content: 'hello' }],
76
+ [],
77
+ );
78
+
79
+ expect(fetchMock).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ test('callHybridAIStream forwards stream and max_tokens', async () => {
83
+ const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
84
+ const body = JSON.parse(String(init?.body || '{}')) as Record<
85
+ string,
86
+ unknown
87
+ >;
88
+ expect(body.stream).toBe(true);
89
+ expect(body.stream_options).toEqual({ include_usage: true });
90
+ expect(body.max_tokens).toBe(1024);
91
+ return new Response(JSON.stringify(okResponse), {
92
+ status: 200,
93
+ headers: { 'Content-Type': 'application/json' },
94
+ });
95
+ });
96
+ vi.stubGlobal('fetch', fetchMock);
97
+
98
+ const result = await callHybridAIStream(
99
+ 'https://hybridai.one',
100
+ 'test-key',
101
+ 'gpt-5-nano',
102
+ 'bot_1',
103
+ true,
104
+ [{ role: 'user', content: 'hello' }],
105
+ [],
106
+ () => {},
107
+ 1024,
108
+ );
109
+
110
+ expect(result.choices[0]?.message.content).toBe('ok');
111
+ expect(fetchMock).toHaveBeenCalledTimes(1);
112
+ });
@@ -0,0 +1,46 @@
1
+ import { expect, test } from 'vitest';
2
+
3
+ import {
4
+ resolveModelContextWindowFallback,
5
+ resolveModelContextWindowFromList,
6
+ } from '../src/hybridai-models.js';
7
+
8
+ test('resolveModelContextWindowFromList matches exact model id', () => {
9
+ const models = [
10
+ { id: 'gpt-5-mini', contextWindowTokens: 272_000 },
11
+ { id: 'gpt-5-nano', contextWindowTokens: 128_000 },
12
+ ];
13
+
14
+ expect(resolveModelContextWindowFromList(models, 'gpt-5-mini')).toBe(272_000);
15
+ });
16
+
17
+ test('resolveModelContextWindowFromList matches provider-prefixed tail', () => {
18
+ const models = [{ id: 'openai/gpt-5', contextWindowTokens: 400_000 }];
19
+
20
+ expect(resolveModelContextWindowFromList(models, 'gpt-5')).toBe(400_000);
21
+ });
22
+
23
+ test('resolveModelContextWindowFromList returns null when unresolved', () => {
24
+ const models = [{ id: 'openai/gpt-5', contextWindowTokens: null }];
25
+
26
+ expect(resolveModelContextWindowFromList(models, 'gpt-5')).toBeNull();
27
+ });
28
+
29
+ test('resolveModelContextWindowFallback resolves known defaults', () => {
30
+ expect(resolveModelContextWindowFallback('gpt-5-mini')).toBe(400_000);
31
+ expect(resolveModelContextWindowFallback('openai/gpt-5-nano')).toBe(400_000);
32
+ expect(resolveModelContextWindowFallback('gpt-5.1')).toBe(400_000);
33
+ expect(resolveModelContextWindowFallback('gpt-5.3')).toBe(400_000);
34
+ expect(resolveModelContextWindowFallback('anthropic/claude-opus-4-6')).toBe(
35
+ 200_000,
36
+ );
37
+ expect(resolveModelContextWindowFallback('claude-sonnet-4.6')).toBe(200_000);
38
+ expect(resolveModelContextWindowFallback('google/gemini-3.1-pro')).toBe(
39
+ 1_048_576,
40
+ );
41
+ expect(resolveModelContextWindowFallback('openai:gpt-5')).toBe(400_000);
42
+ });
43
+
44
+ test('resolveModelContextWindowFallback returns null for unknown models', () => {
45
+ expect(resolveModelContextWindowFallback('unknown-model')).toBeNull();
46
+ });