@hybridaione/hybridclaw 0.2.2 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (277) hide show
  1. package/.github/workflows/ci.yml +70 -0
  2. package/.husky/pre-commit +1 -0
  3. package/CHANGELOG.md +85 -0
  4. package/CONTRIBUTING.md +33 -0
  5. package/README.md +41 -16
  6. package/SECURITY.md +17 -0
  7. package/biome.json +35 -0
  8. package/config.example.json +71 -8
  9. package/container/package-lock.json +2 -2
  10. package/container/package.json +1 -1
  11. package/container/src/approval-policy.ts +1303 -0
  12. package/container/src/browser-tools.ts +431 -136
  13. package/container/src/extensions.ts +36 -12
  14. package/container/src/hybridai-client.ts +34 -13
  15. package/container/src/index.ts +451 -109
  16. package/container/src/ipc.ts +5 -3
  17. package/container/src/token-usage.ts +20 -10
  18. package/container/src/tools.ts +599 -225
  19. package/container/src/types.ts +32 -2
  20. package/container/src/web-fetch.ts +89 -32
  21. package/dist/agent.d.ts.map +1 -1
  22. package/dist/agent.js +10 -2
  23. package/dist/agent.js.map +1 -1
  24. package/dist/audit-cli.d.ts.map +1 -1
  25. package/dist/audit-cli.js +4 -2
  26. package/dist/audit-cli.js.map +1 -1
  27. package/dist/audit-events.d.ts.map +1 -1
  28. package/dist/audit-events.js +53 -3
  29. package/dist/audit-events.js.map +1 -1
  30. package/dist/audit-trail.d.ts.map +1 -1
  31. package/dist/audit-trail.js +17 -8
  32. package/dist/audit-trail.js.map +1 -1
  33. package/dist/channels/discord/attachments.d.ts.map +1 -1
  34. package/dist/channels/discord/attachments.js +14 -7
  35. package/dist/channels/discord/attachments.js.map +1 -1
  36. package/dist/channels/discord/debounce.d.ts +9 -0
  37. package/dist/channels/discord/debounce.d.ts.map +1 -0
  38. package/dist/channels/discord/debounce.js +20 -0
  39. package/dist/channels/discord/debounce.js.map +1 -0
  40. package/dist/channels/discord/delivery.d.ts +4 -1
  41. package/dist/channels/discord/delivery.d.ts.map +1 -1
  42. package/dist/channels/discord/delivery.js +19 -3
  43. package/dist/channels/discord/delivery.js.map +1 -1
  44. package/dist/channels/discord/human-delay.d.ts +16 -0
  45. package/dist/channels/discord/human-delay.d.ts.map +1 -0
  46. package/dist/channels/discord/human-delay.js +29 -0
  47. package/dist/channels/discord/human-delay.js.map +1 -0
  48. package/dist/channels/discord/inbound.d.ts +4 -0
  49. package/dist/channels/discord/inbound.d.ts.map +1 -1
  50. package/dist/channels/discord/inbound.js +45 -4
  51. package/dist/channels/discord/inbound.js.map +1 -1
  52. package/dist/channels/discord/mentions.d.ts.map +1 -1
  53. package/dist/channels/discord/mentions.js +16 -4
  54. package/dist/channels/discord/mentions.js.map +1 -1
  55. package/dist/channels/discord/presence.d.ts +33 -0
  56. package/dist/channels/discord/presence.d.ts.map +1 -0
  57. package/dist/channels/discord/presence.js +111 -0
  58. package/dist/channels/discord/presence.js.map +1 -0
  59. package/dist/channels/discord/rate-limiter.d.ts +14 -0
  60. package/dist/channels/discord/rate-limiter.d.ts.map +1 -0
  61. package/dist/channels/discord/rate-limiter.js +49 -0
  62. package/dist/channels/discord/rate-limiter.js.map +1 -0
  63. package/dist/channels/discord/reactions.d.ts +38 -0
  64. package/dist/channels/discord/reactions.d.ts.map +1 -0
  65. package/dist/channels/discord/reactions.js +151 -0
  66. package/dist/channels/discord/reactions.js.map +1 -0
  67. package/dist/channels/discord/runtime.d.ts +6 -3
  68. package/dist/channels/discord/runtime.d.ts.map +1 -1
  69. package/dist/channels/discord/runtime.js +621 -125
  70. package/dist/channels/discord/runtime.js.map +1 -1
  71. package/dist/channels/discord/stream.d.ts +4 -1
  72. package/dist/channels/discord/stream.d.ts.map +1 -1
  73. package/dist/channels/discord/stream.js +16 -8
  74. package/dist/channels/discord/stream.js.map +1 -1
  75. package/dist/channels/discord/tool-actions.d.ts.map +1 -1
  76. package/dist/channels/discord/tool-actions.js +24 -12
  77. package/dist/channels/discord/tool-actions.js.map +1 -1
  78. package/dist/channels/discord/typing.d.ts +15 -0
  79. package/dist/channels/discord/typing.d.ts.map +1 -0
  80. package/dist/channels/discord/typing.js +106 -0
  81. package/dist/channels/discord/typing.js.map +1 -0
  82. package/dist/chunk.d.ts.map +1 -1
  83. package/dist/chunk.js +4 -2
  84. package/dist/chunk.js.map +1 -1
  85. package/dist/cli.js +47 -22
  86. package/dist/cli.js.map +1 -1
  87. package/dist/config.d.ts +19 -0
  88. package/dist/config.d.ts.map +1 -1
  89. package/dist/config.js +103 -18
  90. package/dist/config.js.map +1 -1
  91. package/dist/container-runner.d.ts.map +1 -1
  92. package/dist/container-runner.js +58 -26
  93. package/dist/container-runner.js.map +1 -1
  94. package/dist/container-setup.d.ts.map +1 -1
  95. package/dist/container-setup.js +10 -9
  96. package/dist/container-setup.js.map +1 -1
  97. package/dist/conversation.d.ts +2 -2
  98. package/dist/conversation.d.ts.map +1 -1
  99. package/dist/conversation.js +1 -1
  100. package/dist/conversation.js.map +1 -1
  101. package/dist/db.d.ts +118 -2
  102. package/dist/db.d.ts.map +1 -1
  103. package/dist/db.js +1568 -50
  104. package/dist/db.js.map +1 -1
  105. package/dist/delegation-manager.d.ts.map +1 -1
  106. package/dist/delegation-manager.js +3 -2
  107. package/dist/delegation-manager.js.map +1 -1
  108. package/dist/gateway-client.d.ts +2 -2
  109. package/dist/gateway-client.d.ts.map +1 -1
  110. package/dist/gateway-client.js +10 -4
  111. package/dist/gateway-client.js.map +1 -1
  112. package/dist/gateway-service.d.ts +3 -3
  113. package/dist/gateway-service.d.ts.map +1 -1
  114. package/dist/gateway-service.js +563 -73
  115. package/dist/gateway-service.js.map +1 -1
  116. package/dist/gateway-types.d.ts +24 -0
  117. package/dist/gateway-types.d.ts.map +1 -1
  118. package/dist/gateway-types.js.map +1 -1
  119. package/dist/gateway.js +179 -24
  120. package/dist/gateway.js.map +1 -1
  121. package/dist/health.d.ts.map +1 -1
  122. package/dist/health.js +20 -10
  123. package/dist/health.js.map +1 -1
  124. package/dist/heartbeat.d.ts +4 -0
  125. package/dist/heartbeat.d.ts.map +1 -1
  126. package/dist/heartbeat.js +48 -20
  127. package/dist/heartbeat.js.map +1 -1
  128. package/dist/hybridai-bots.d.ts.map +1 -1
  129. package/dist/hybridai-bots.js +4 -2
  130. package/dist/hybridai-bots.js.map +1 -1
  131. package/dist/instruction-approval-audit.d.ts.map +1 -1
  132. package/dist/instruction-approval-audit.js.map +1 -1
  133. package/dist/instruction-integrity.d.ts.map +1 -1
  134. package/dist/instruction-integrity.js +8 -2
  135. package/dist/instruction-integrity.js.map +1 -1
  136. package/dist/ipc.d.ts.map +1 -1
  137. package/dist/ipc.js +6 -1
  138. package/dist/ipc.js.map +1 -1
  139. package/dist/logger.js.map +1 -1
  140. package/dist/memory-consolidation.d.ts +17 -0
  141. package/dist/memory-consolidation.d.ts.map +1 -0
  142. package/dist/memory-consolidation.js +25 -0
  143. package/dist/memory-consolidation.js.map +1 -0
  144. package/dist/memory-service.d.ts +200 -0
  145. package/dist/memory-service.d.ts.map +1 -0
  146. package/dist/memory-service.js +294 -0
  147. package/dist/memory-service.js.map +1 -0
  148. package/dist/mount-security.d.ts.map +1 -1
  149. package/dist/mount-security.js +31 -7
  150. package/dist/mount-security.js.map +1 -1
  151. package/dist/observability-ingest.d.ts.map +1 -1
  152. package/dist/observability-ingest.js +32 -11
  153. package/dist/observability-ingest.js.map +1 -1
  154. package/dist/onboarding.d.ts.map +1 -1
  155. package/dist/onboarding.js +32 -9
  156. package/dist/onboarding.js.map +1 -1
  157. package/dist/proactive-policy.d.ts.map +1 -1
  158. package/dist/proactive-policy.js +2 -1
  159. package/dist/proactive-policy.js.map +1 -1
  160. package/dist/prompt-hooks.d.ts.map +1 -1
  161. package/dist/prompt-hooks.js +9 -7
  162. package/dist/prompt-hooks.js.map +1 -1
  163. package/dist/runtime-config.d.ts +98 -1
  164. package/dist/runtime-config.d.ts.map +1 -1
  165. package/dist/runtime-config.js +477 -23
  166. package/dist/runtime-config.js.map +1 -1
  167. package/dist/scheduled-task-runner.d.ts +1 -0
  168. package/dist/scheduled-task-runner.d.ts.map +1 -1
  169. package/dist/scheduled-task-runner.js +29 -10
  170. package/dist/scheduled-task-runner.js.map +1 -1
  171. package/dist/scheduler.d.ts +43 -4
  172. package/dist/scheduler.d.ts.map +1 -1
  173. package/dist/scheduler.js +530 -56
  174. package/dist/scheduler.js.map +1 -1
  175. package/dist/session-export.d.ts +26 -0
  176. package/dist/session-export.d.ts.map +1 -0
  177. package/dist/session-export.js +149 -0
  178. package/dist/session-export.js.map +1 -0
  179. package/dist/session-maintenance.d.ts.map +1 -1
  180. package/dist/session-maintenance.js +75 -13
  181. package/dist/session-maintenance.js.map +1 -1
  182. package/dist/session-transcripts.d.ts.map +1 -1
  183. package/dist/session-transcripts.js.map +1 -1
  184. package/dist/side-effects.d.ts.map +1 -1
  185. package/dist/side-effects.js +14 -2
  186. package/dist/side-effects.js.map +1 -1
  187. package/dist/skills-guard.d.ts.map +1 -1
  188. package/dist/skills-guard.js +893 -130
  189. package/dist/skills-guard.js.map +1 -1
  190. package/dist/skills.d.ts +5 -0
  191. package/dist/skills.d.ts.map +1 -1
  192. package/dist/skills.js +29 -15
  193. package/dist/skills.js.map +1 -1
  194. package/dist/token-efficiency.d.ts.map +1 -1
  195. package/dist/token-efficiency.js.map +1 -1
  196. package/dist/tui.js +92 -11
  197. package/dist/tui.js.map +1 -1
  198. package/dist/types.d.ts +146 -0
  199. package/dist/types.d.ts.map +1 -1
  200. package/dist/types.js +24 -1
  201. package/dist/types.js.map +1 -1
  202. package/dist/update.d.ts.map +1 -1
  203. package/dist/update.js +42 -14
  204. package/dist/update.js.map +1 -1
  205. package/dist/workspace.d.ts.map +1 -1
  206. package/dist/workspace.js +49 -9
  207. package/dist/workspace.js.map +1 -1
  208. package/docs/chat.html +9 -3
  209. package/docs/index.html +37 -13
  210. package/package.json +8 -2
  211. package/src/agent.ts +16 -3
  212. package/src/audit-cli.ts +44 -16
  213. package/src/audit-events.ts +69 -5
  214. package/src/audit-trail.ts +41 -15
  215. package/src/channels/discord/attachments.ts +81 -27
  216. package/src/channels/discord/debounce.ts +25 -0
  217. package/src/channels/discord/delivery.ts +57 -13
  218. package/src/channels/discord/human-delay.ts +48 -0
  219. package/src/channels/discord/inbound.ts +66 -7
  220. package/src/channels/discord/mentions.ts +42 -18
  221. package/src/channels/discord/presence.ts +148 -0
  222. package/src/channels/discord/rate-limiter.ts +58 -0
  223. package/src/channels/discord/reactions.ts +211 -0
  224. package/src/channels/discord/runtime.ts +1048 -182
  225. package/src/channels/discord/stream.ts +73 -27
  226. package/src/channels/discord/tool-actions.ts +78 -37
  227. package/src/channels/discord/typing.ts +140 -0
  228. package/src/chunk.ts +12 -4
  229. package/src/cli.ts +141 -56
  230. package/src/config.ts +192 -34
  231. package/src/container-runner.ts +132 -42
  232. package/src/container-setup.ts +57 -22
  233. package/src/conversation.ts +9 -7
  234. package/src/db.ts +2217 -84
  235. package/src/delegation-manager.ts +6 -2
  236. package/src/gateway-client.ts +41 -17
  237. package/src/gateway-service.ts +1019 -201
  238. package/src/gateway-types.ts +33 -0
  239. package/src/gateway.ts +321 -48
  240. package/src/health.ts +66 -26
  241. package/src/heartbeat.ts +84 -22
  242. package/src/hybridai-bots.ts +14 -5
  243. package/src/instruction-approval-audit.ts +4 -1
  244. package/src/instruction-integrity.ts +30 -9
  245. package/src/ipc.ts +23 -5
  246. package/src/logger.ts +4 -1
  247. package/src/memory-consolidation.ts +41 -0
  248. package/src/memory-service.ts +606 -0
  249. package/src/mount-security.ts +58 -13
  250. package/src/observability-ingest.ts +134 -35
  251. package/src/onboarding.ts +126 -35
  252. package/src/proactive-policy.ts +3 -1
  253. package/src/prompt-hooks.ts +40 -17
  254. package/src/runtime-config.ts +1114 -99
  255. package/src/scheduled-task-runner.ts +63 -11
  256. package/src/scheduler.ts +683 -60
  257. package/src/session-export.ts +196 -0
  258. package/src/session-maintenance.ts +125 -22
  259. package/src/session-transcripts.ts +12 -3
  260. package/src/side-effects.ts +28 -5
  261. package/src/skills-guard.ts +1067 -219
  262. package/src/skills.ts +163 -65
  263. package/src/token-efficiency.ts +31 -9
  264. package/src/tui.ts +166 -25
  265. package/src/types.ts +195 -2
  266. package/src/update.ts +79 -23
  267. package/src/workspace.ts +63 -11
  268. package/tests/approval-policy.test.ts +224 -0
  269. package/tests/discord.basic.test.ts +82 -2
  270. package/tests/discord.human-presence.test.ts +85 -0
  271. package/tests/gateway-service.media-routing.test.ts +8 -2
  272. package/tests/memory-service.test.ts +1114 -0
  273. package/tests/token-efficiency.basic.test.ts +8 -2
  274. package/vitest.e2e.config.ts +3 -1
  275. package/vitest.integration.config.ts +3 -1
  276. package/vitest.live.config.ts +3 -1
  277. package/vitest.unit.config.ts +9 -0
@@ -1,17 +1,25 @@
1
- import {
2
- AttachmentBuilder,
3
- type Message as DiscordMessage,
4
- } from 'discord.js';
1
+ import type { AttachmentBuilder, Message as DiscordMessage } from 'discord.js';
5
2
 
6
3
  import { chunkMessage } from '../../chunk.js';
7
4
  import { logger } from '../../logger.js';
5
+ import {
6
+ getHumanDelayMs,
7
+ type HumanDelayConfig,
8
+ sleep,
9
+ } from './human-delay.js';
8
10
 
9
11
  interface DiscordSendChannel {
10
- send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
12
+ send: (payload: {
13
+ content: string;
14
+ files?: AttachmentBuilder[];
15
+ }) => Promise<DiscordMessage>;
11
16
  }
12
17
 
13
18
  interface DiscordEditMessage {
14
- edit: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
19
+ edit: (payload: {
20
+ content: string;
21
+ files?: AttachmentBuilder[];
22
+ }) => Promise<DiscordMessage>;
15
23
  delete: () => Promise<unknown>;
16
24
  }
17
25
 
@@ -29,6 +37,7 @@ export interface DiscordStreamOptions {
29
37
  maxLines?: number;
30
38
  editIntervalMs?: number;
31
39
  onFirstMessage?: () => void;
40
+ humanDelay?: HumanDelayConfig;
32
41
  }
33
42
 
34
43
  const DEFAULT_MAX_CHARS = 1_800;
@@ -40,20 +49,30 @@ const RETRY_BASE_DELAY_MS = 500;
40
49
  function isRetryableDiscordError(error: unknown): boolean {
41
50
  const maybe = error as DiscordErrorLike;
42
51
  const status = maybe.status ?? maybe.httpStatus;
43
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
52
+ return (
53
+ status === 429 ||
54
+ (typeof status === 'number' && status >= 500 && status <= 599)
55
+ );
44
56
  }
45
57
 
46
58
  function extractRetryDelayMs(error: unknown, fallbackMs: number): number {
47
59
  const maybe = error as DiscordErrorLike;
48
60
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
49
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
61
+ if (
62
+ typeof retryAfterSeconds === 'number' &&
63
+ Number.isFinite(retryAfterSeconds) &&
64
+ retryAfterSeconds > 0
65
+ ) {
50
66
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
51
67
  }
52
68
  const jitter = Math.floor(Math.random() * 250);
53
69
  return fallbackMs + jitter;
54
70
  }
55
71
 
56
- async function withDiscordRetry<T>(label: string, run: () => Promise<T>): Promise<T> {
72
+ async function withDiscordRetry<T>(
73
+ label: string,
74
+ run: () => Promise<T>,
75
+ ): Promise<T> {
57
76
  let attempt = 0;
58
77
  let delayMs = RETRY_BASE_DELAY_MS;
59
78
  while (true) {
@@ -65,7 +84,10 @@ async function withDiscordRetry<T>(label: string, run: () => Promise<T>): Promis
65
84
  throw error;
66
85
  }
67
86
  const waitMs = extractRetryDelayMs(error, delayMs);
68
- logger.warn({ label, attempt, waitMs, error }, 'Discord request failed; retrying');
87
+ logger.warn(
88
+ { label, attempt, waitMs, error },
89
+ 'Discord request failed; retrying',
90
+ );
69
91
  await new Promise((resolve) => setTimeout(resolve, waitMs));
70
92
  delayMs = Math.min(delayMs * 2, 4_000);
71
93
  }
@@ -79,6 +101,7 @@ export class DiscordStreamManager {
79
101
  private readonly maxLines: number;
80
102
  private readonly editIntervalMs: number;
81
103
  private readonly onFirstMessage?: () => void;
104
+ private readonly humanDelay?: HumanDelayConfig;
82
105
 
83
106
  private readonly messages: DiscordEditMessage[] = [];
84
107
  private sentChunks: string[] = [];
@@ -93,8 +116,12 @@ export class DiscordStreamManager {
93
116
  this.channel = sourceMessage.channel as unknown as DiscordSendChannel;
94
117
  this.maxChars = Math.max(200, options?.maxChars ?? DEFAULT_MAX_CHARS);
95
118
  this.maxLines = Math.max(4, options?.maxLines ?? DEFAULT_MAX_LINES);
96
- this.editIntervalMs = Math.max(250, options?.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS);
119
+ this.editIntervalMs = Math.max(
120
+ 250,
121
+ options?.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS,
122
+ );
97
123
  this.onFirstMessage = options?.onFirstMessage;
124
+ this.humanDelay = options?.humanDelay;
98
125
  }
99
126
 
100
127
  hasSentMessages(): boolean {
@@ -125,9 +152,7 @@ export class DiscordStreamManager {
125
152
 
126
153
  fail(errorText: string): Promise<void> {
127
154
  if (this.closed) return Promise.resolve();
128
- this.content = this.content
129
- ? `${this.content}\n\n${errorText}`
130
- : errorText;
155
+ this.content = this.content ? `${this.content}\n\n${errorText}` : errorText;
131
156
  if (this.flushTimer) {
132
157
  clearTimeout(this.flushTimer);
133
158
  this.flushTimer = null;
@@ -159,17 +184,18 @@ export class DiscordStreamManager {
159
184
  }
160
185
 
161
186
  private enqueue(task: () => Promise<void>): Promise<void> {
162
- this.opQueue = this.opQueue
163
- .then(task)
164
- .catch((error) => {
165
- logger.warn({ error }, 'Discord stream operation failed');
166
- });
187
+ this.opQueue = this.opQueue.then(task).catch((error) => {
188
+ logger.warn({ error }, 'Discord stream operation failed');
189
+ });
167
190
  return this.opQueue;
168
191
  }
169
192
 
170
193
  private scheduleFlush(): void {
171
194
  if (this.flushTimer || this.closed) return;
172
- const waitMs = Math.max(0, this.editIntervalMs - (Date.now() - this.lastEditAt));
195
+ const waitMs = Math.max(
196
+ 0,
197
+ this.editIntervalMs - (Date.now() - this.lastEditAt),
198
+ );
173
199
  this.flushTimer = setTimeout(() => {
174
200
  this.flushTimer = null;
175
201
  void this.enqueue(async () => {
@@ -178,7 +204,10 @@ export class DiscordStreamManager {
178
204
  }, waitMs);
179
205
  }
180
206
 
181
- private async sync(forceLastEdit: boolean, files?: AttachmentBuilder[]): Promise<void> {
207
+ private async sync(
208
+ forceLastEdit: boolean,
209
+ files?: AttachmentBuilder[],
210
+ ): Promise<void> {
182
211
  const chunks = chunkMessage(this.content, {
183
212
  maxChars: this.maxChars,
184
213
  maxLines: this.maxLines,
@@ -187,7 +216,9 @@ export class DiscordStreamManager {
187
216
  if (chunks.length === 0) {
188
217
  if (files && files.length > 0) {
189
218
  const fallback = 'Attached files:';
190
- const sent = await withDiscordRetry('reply', () => this.sourceMessage.reply({ content: fallback, files }));
219
+ const sent = await withDiscordRetry('reply', () =>
220
+ this.sourceMessage.reply({ content: fallback, files }),
221
+ );
191
222
  this.messages.push(sent as unknown as DiscordEditMessage);
192
223
  this.sentChunks.push(fallback);
193
224
  this.onFirstMessage?.();
@@ -200,9 +231,20 @@ export class DiscordStreamManager {
200
231
  const isLast = i === chunks.length - 1;
201
232
 
202
233
  if (i >= this.messages.length) {
203
- const sent = i === 0
204
- ? await withDiscordRetry('reply', () => this.sourceMessage.reply({ content: chunk }))
205
- : await withDiscordRetry('send', () => this.channel.send({ content: chunk }));
234
+ if (i > 0) {
235
+ const delayMs = getHumanDelayMs(this.humanDelay);
236
+ if (delayMs > 0) {
237
+ await sleep(delayMs);
238
+ }
239
+ }
240
+ const sent =
241
+ i === 0
242
+ ? await withDiscordRetry('reply', () =>
243
+ this.sourceMessage.reply({ content: chunk }),
244
+ )
245
+ : await withDiscordRetry('send', () =>
246
+ this.channel.send({ content: chunk }),
247
+ );
206
248
  this.messages.push(sent as unknown as DiscordEditMessage);
207
249
  this.sentChunks.push(chunk);
208
250
  this.onFirstMessage?.();
@@ -217,7 +259,9 @@ export class DiscordStreamManager {
217
259
  continue;
218
260
  }
219
261
 
220
- await withDiscordRetry('edit', () => this.messages[i].edit({ content: chunk }));
262
+ await withDiscordRetry('edit', () =>
263
+ this.messages[i].edit({ content: chunk }),
264
+ );
221
265
  this.sentChunks[i] = chunk;
222
266
  this.lastEditAt = Date.now();
223
267
  }
@@ -232,7 +276,9 @@ export class DiscordStreamManager {
232
276
 
233
277
  if (files && files.length > 0) {
234
278
  const lastIndex = chunks.length - 1;
235
- await withDiscordRetry('edit', () => this.messages[lastIndex].edit({ content: chunks[lastIndex], files }));
279
+ await withDiscordRetry('edit', () =>
280
+ this.messages[lastIndex].edit({ content: chunks[lastIndex], files }),
281
+ );
236
282
  this.sentChunks[lastIndex] = chunks[lastIndex];
237
283
  this.lastEditAt = Date.now();
238
284
  }
@@ -33,7 +33,10 @@ export interface DiscordToolActionDependencies {
33
33
  getDiscordPresence: (userId: string) => CachedDiscordPresence | undefined;
34
34
  }
35
35
 
36
- function sanitizeDiscordId(rawValue: string | undefined, label: string): string {
36
+ function sanitizeDiscordId(
37
+ rawValue: string | undefined,
38
+ label: string,
39
+ ): string {
37
40
  const value = (rawValue || '').trim();
38
41
  if (!/^\d{16,22}$/.test(value)) {
39
42
  throw new Error(`${label} must be a Discord snowflake id.`);
@@ -59,7 +62,9 @@ function scoreGuildMemberForLookup(member: GuildMember, query: string): number {
59
62
  const globalName = member.user.globalName?.toLowerCase() || '';
60
63
  const nickname = member.nickname?.toLowerCase() || '';
61
64
  const displayName = member.displayName?.toLowerCase() || '';
62
- const candidates = [username, globalName, nickname, displayName].filter(Boolean);
65
+ const candidates = [username, globalName, nickname, displayName].filter(
66
+ Boolean,
67
+ );
63
68
 
64
69
  let score = 0;
65
70
  if (candidates.some((value) => value === q)) score += 3;
@@ -93,7 +98,10 @@ async function resolveGuildMemberIdFromLookup(params: {
93
98
  try {
94
99
  members = await guild.members.search({ query: searchQuery, limit: 25 });
95
100
  } catch {
96
- const fetched = await guild.members.fetch({ query: searchQuery, limit: 25 });
101
+ const fetched = await guild.members.fetch({
102
+ query: searchQuery,
103
+ limit: 25,
104
+ });
97
105
  members = fetched;
98
106
  }
99
107
  let best: GuildMember | null = null;
@@ -146,14 +154,22 @@ async function runDiscordReadAction(
146
154
  const after = request.after?.trim();
147
155
  const around = request.around?.trim();
148
156
 
149
- const query: { limit: number; before?: string; after?: string; around?: string } = { limit };
157
+ const query: {
158
+ limit: number;
159
+ before?: string;
160
+ after?: string;
161
+ around?: string;
162
+ } = { limit };
150
163
  if (before) query.before = before;
151
164
  if (after) query.after = after;
152
165
  if (around) query.around = around;
153
166
 
154
167
  const fetched = await channel.messages.fetch(query);
155
168
  const messages = Array.from(fetched.values())
156
- .sort((a, b) => a.createdTimestamp - b.createdTimestamp || a.id.localeCompare(b.id))
169
+ .sort(
170
+ (a, b) =>
171
+ a.createdTimestamp - b.createdTimestamp || a.id.localeCompare(b.id),
172
+ )
157
173
  .map((message) => ({
158
174
  id: message.id,
159
175
  channelId: message.channelId,
@@ -175,13 +191,15 @@ async function runDiscordReadAction(
175
191
  displayName: message.member.displayName || null,
176
192
  }
177
193
  : null,
178
- attachments: Array.from(message.attachments.values()).map((attachment) => ({
179
- id: attachment.id,
180
- name: attachment.name || null,
181
- url: attachment.url,
182
- contentType: attachment.contentType || null,
183
- size: attachment.size,
184
- })),
194
+ attachments: Array.from(message.attachments.values()).map(
195
+ (attachment) => ({
196
+ id: attachment.id,
197
+ name: attachment.name || null,
198
+ url: attachment.url,
199
+ contentType: attachment.contentType || null,
200
+ size: attachment.size,
201
+ }),
202
+ ),
185
203
  mentions: {
186
204
  users: Array.from(message.mentions.users.values()).map((user) => ({
187
205
  id: user.id,
@@ -192,12 +210,16 @@ async function runDiscordReadAction(
192
210
  id: role.id,
193
211
  name: role.name,
194
212
  })),
195
- channels: Array.from(message.mentions.channels.values()).map((mentionedChannel) => ({
196
- id: mentionedChannel.id,
197
- name: 'name' in mentionedChannel && typeof mentionedChannel.name === 'string'
198
- ? mentionedChannel.name
199
- : null,
200
- })),
213
+ channels: Array.from(message.mentions.channels.values()).map(
214
+ (mentionedChannel) => ({
215
+ id: mentionedChannel.id,
216
+ name:
217
+ 'name' in mentionedChannel &&
218
+ typeof mentionedChannel.name === 'string'
219
+ ? mentionedChannel.name
220
+ : null,
221
+ }),
222
+ ),
201
223
  },
202
224
  }));
203
225
 
@@ -217,10 +239,7 @@ async function runDiscordMemberInfoAction(
217
239
  const activeClient = deps.requireDiscordClientReady();
218
240
  const guildId = sanitizeDiscordId(request.guildId, 'guildId');
219
241
  const userLookupRaw =
220
- request.userId
221
- || request.memberId
222
- || request.user
223
- || request.username;
242
+ request.userId || request.memberId || request.user || request.username;
224
243
  const resolvedUser = await resolveGuildMemberIdFromLookup({
225
244
  requireDiscordClientReady: deps.requireDiscordClientReady,
226
245
  guildId,
@@ -258,7 +277,9 @@ async function runDiscordMemberInfoAction(
258
277
  nickname: member.nickname || null,
259
278
  joinedAt: normalizeDate(member.joinedAt),
260
279
  premiumSince: normalizeDate(member.premiumSince),
261
- communicationDisabledUntil: normalizeDate(member.communicationDisabledUntil),
280
+ communicationDisabledUntil: normalizeDate(
281
+ member.communicationDisabledUntil,
282
+ ),
262
283
  roles,
263
284
  },
264
285
  ...(presence
@@ -285,25 +306,43 @@ async function runDiscordChannelInfoAction(
285
306
  id: channel.id,
286
307
  type: channel.type,
287
308
  guildId: 'guildId' in channel ? channel.guildId || null : null,
288
- name: 'name' in channel && typeof channel.name === 'string' ? channel.name : null,
309
+ name:
310
+ 'name' in channel && typeof channel.name === 'string'
311
+ ? channel.name
312
+ : null,
289
313
  parentId: 'parentId' in channel ? channel.parentId || null : null,
290
- topic: 'topic' in channel && typeof channel.topic === 'string' ? channel.topic : null,
291
- nsfw: 'nsfw' in channel && typeof channel.nsfw === 'boolean' ? channel.nsfw : null,
314
+ topic:
315
+ 'topic' in channel && typeof channel.topic === 'string'
316
+ ? channel.topic
317
+ : null,
318
+ nsfw:
319
+ 'nsfw' in channel && typeof channel.nsfw === 'boolean'
320
+ ? channel.nsfw
321
+ : null,
292
322
  rateLimitPerUser:
293
- 'rateLimitPerUser' in channel && typeof channel.rateLimitPerUser === 'number'
323
+ 'rateLimitPerUser' in channel &&
324
+ typeof channel.rateLimitPerUser === 'number'
294
325
  ? channel.rateLimitPerUser
295
326
  : null,
296
- isTextBased: typeof channel.isTextBased === 'function' ? channel.isTextBased() : false,
297
- isDMBased: typeof channel.isDMBased === 'function' ? channel.isDMBased() : false,
298
- isThread: typeof channel.isThread === 'function' ? channel.isThread() : false,
299
- lastMessageId: 'lastMessageId' in channel ? channel.lastMessageId || null : null,
327
+ isTextBased:
328
+ typeof channel.isTextBased === 'function' ? channel.isTextBased() : false,
329
+ isDMBased:
330
+ typeof channel.isDMBased === 'function' ? channel.isDMBased() : false,
331
+ isThread:
332
+ typeof channel.isThread === 'function' ? channel.isThread() : false,
333
+ lastMessageId:
334
+ 'lastMessageId' in channel ? channel.lastMessageId || null : null,
300
335
  };
301
336
 
302
337
  if (typeof channel.isThread === 'function' && channel.isThread()) {
303
338
  channelData.archived =
304
- 'archived' in channel && typeof channel.archived === 'boolean' ? channel.archived : null;
339
+ 'archived' in channel && typeof channel.archived === 'boolean'
340
+ ? channel.archived
341
+ : null;
305
342
  channelData.locked =
306
- 'locked' in channel && typeof channel.locked === 'boolean' ? channel.locked : null;
343
+ 'locked' in channel && typeof channel.locked === 'boolean'
344
+ ? channel.locked
345
+ : null;
307
346
  channelData.ownerId = 'ownerId' in channel ? channel.ownerId || null : null;
308
347
  }
309
348
 
@@ -314,9 +353,9 @@ async function runDiscordChannelInfoAction(
314
353
  };
315
354
  }
316
355
 
317
- export function createDiscordToolActionRunner(deps: DiscordToolActionDependencies): (
318
- request: DiscordToolActionRequest,
319
- ) => Promise<Record<string, unknown>> {
356
+ export function createDiscordToolActionRunner(
357
+ deps: DiscordToolActionDependencies,
358
+ ): (request: DiscordToolActionRequest) => Promise<Record<string, unknown>> {
320
359
  return async (request: DiscordToolActionRequest) => {
321
360
  switch (request.action) {
322
361
  case 'read':
@@ -326,7 +365,9 @@ export function createDiscordToolActionRunner(deps: DiscordToolActionDependencie
326
365
  case 'channel-info':
327
366
  return await runDiscordChannelInfoAction(request, deps);
328
367
  default:
329
- throw new Error(`Unsupported Discord action: ${request.action as string}`);
368
+ throw new Error(
369
+ `Unsupported Discord action: ${request.action as string}`,
370
+ );
330
371
  }
331
372
  };
332
373
  }
@@ -0,0 +1,140 @@
1
+ import type { Message as DiscordMessage } from 'discord.js';
2
+
3
+ import { logger } from '../../logger.js';
4
+
5
+ export type DiscordTypingPhase =
6
+ | 'received'
7
+ | 'thinking'
8
+ | 'toolUse'
9
+ | 'streaming'
10
+ | 'done';
11
+ export type DiscordTypingMode = 'instant' | 'thinking' | 'streaming' | 'never';
12
+
13
+ export interface TypingController {
14
+ setPhase: (phase: DiscordTypingPhase) => void;
15
+ stop: () => void;
16
+ }
17
+
18
+ interface CreateTypingControllerOptions {
19
+ keepaliveMs?: number;
20
+ ttlMs?: number;
21
+ stopGraceMs?: number;
22
+ }
23
+
24
+ const DEFAULT_KEEPALIVE_MS = 8_000;
25
+ const DEFAULT_TTL_MS = 60_000;
26
+ const DEFAULT_STOP_GRACE_MS = 500;
27
+
28
+ function isTypingActiveForPhase(
29
+ mode: DiscordTypingMode,
30
+ phase: DiscordTypingPhase,
31
+ ): boolean {
32
+ if (mode === 'never') return false;
33
+ if (mode === 'instant') return phase !== 'done';
34
+ if (mode === 'thinking') return phase === 'thinking' || phase === 'toolUse';
35
+ return phase === 'streaming';
36
+ }
37
+
38
+ export function createTypingController(
39
+ message: DiscordMessage,
40
+ mode: DiscordTypingMode,
41
+ options?: CreateTypingControllerOptions,
42
+ ): TypingController {
43
+ if (mode === 'never') {
44
+ return {
45
+ setPhase: () => {},
46
+ stop: () => {},
47
+ };
48
+ }
49
+
50
+ const keepaliveMs = Math.max(
51
+ 2_000,
52
+ Math.floor(options?.keepaliveMs ?? DEFAULT_KEEPALIVE_MS),
53
+ );
54
+ const ttlMs = Math.max(5_000, Math.floor(options?.ttlMs ?? DEFAULT_TTL_MS));
55
+ const stopGraceMs = Math.max(
56
+ 0,
57
+ Math.floor(options?.stopGraceMs ?? DEFAULT_STOP_GRACE_MS),
58
+ );
59
+
60
+ let active = false;
61
+ let stopped = false;
62
+ let keepaliveTimer: ReturnType<typeof setInterval> | null = null;
63
+ let ttlTimer: ReturnType<typeof setTimeout> | null = null;
64
+ let stopTimer: ReturnType<typeof setTimeout> | null = null;
65
+
66
+ const sendTyping = async (): Promise<void> => {
67
+ if (stopped || !active) return;
68
+ if (!('sendTyping' in message.channel)) return;
69
+ try {
70
+ await message.channel.sendTyping();
71
+ } catch (error) {
72
+ logger.debug(
73
+ { error, channelId: message.channelId },
74
+ 'Failed to send typing indicator',
75
+ );
76
+ }
77
+ };
78
+
79
+ const clearTimers = (): void => {
80
+ if (keepaliveTimer) {
81
+ clearInterval(keepaliveTimer);
82
+ keepaliveTimer = null;
83
+ }
84
+ if (ttlTimer) {
85
+ clearTimeout(ttlTimer);
86
+ ttlTimer = null;
87
+ }
88
+ if (stopTimer) {
89
+ clearTimeout(stopTimer);
90
+ stopTimer = null;
91
+ }
92
+ };
93
+
94
+ const stopNow = (): void => {
95
+ active = false;
96
+ clearTimers();
97
+ };
98
+
99
+ const scheduleStop = (): void => {
100
+ if (stopped || !active) return;
101
+ if (stopTimer) return;
102
+ stopTimer = setTimeout(() => {
103
+ stopTimer = null;
104
+ stopNow();
105
+ }, stopGraceMs);
106
+ };
107
+
108
+ const ensureRunning = (): void => {
109
+ if (stopped) return;
110
+ if (stopTimer) {
111
+ clearTimeout(stopTimer);
112
+ stopTimer = null;
113
+ }
114
+ if (active) return;
115
+ active = true;
116
+ void sendTyping();
117
+ keepaliveTimer = setInterval(() => {
118
+ void sendTyping();
119
+ }, keepaliveMs);
120
+ ttlTimer = setTimeout(() => {
121
+ stopNow();
122
+ }, ttlMs);
123
+ };
124
+
125
+ return {
126
+ setPhase: (phase) => {
127
+ if (stopped) return;
128
+ if (isTypingActiveForPhase(mode, phase)) {
129
+ ensureRunning();
130
+ } else {
131
+ scheduleStop();
132
+ }
133
+ },
134
+ stop: () => {
135
+ if (stopped) return;
136
+ stopped = true;
137
+ stopNow();
138
+ },
139
+ };
140
+ }
package/src/chunk.ts CHANGED
@@ -82,7 +82,10 @@ function splitLongLine(line: string, maxChars: number): string[] {
82
82
  return pieces;
83
83
  }
84
84
 
85
- export function chunkMessage(text: string, opts?: ChunkMessageOptions): string[] {
85
+ export function chunkMessage(
86
+ text: string,
87
+ opts?: ChunkMessageOptions,
88
+ ): string[] {
86
89
  const maxChars = Math.max(200, opts?.maxChars ?? DEFAULT_MAX_CHARS);
87
90
  const maxLines = Math.max(4, opts?.maxLines ?? DEFAULT_MAX_LINES);
88
91
  const normalized = (text || '').replace(/\r\n?/g, '\n');
@@ -120,15 +123,20 @@ export function chunkMessage(text: string, opts?: ChunkMessageOptions): string[]
120
123
  };
121
124
 
122
125
  const appendLine = (line: string): void => {
123
- const addedChars = currentLines.length === 0 ? line.length : line.length + 1;
126
+ const addedChars =
127
+ currentLines.length === 0 ? line.length : line.length + 1;
124
128
  const nextChars = currentChars + addedChars;
125
129
  const nextLines = currentLines.length + 1;
126
- if (currentLines.length > 0 && (nextChars > maxChars || nextLines > maxLines)) {
130
+ if (
131
+ currentLines.length > 0 &&
132
+ (nextChars > maxChars || nextLines > maxLines)
133
+ ) {
127
134
  flush(false);
128
135
  }
129
136
 
130
137
  currentLines.push(line);
131
- currentChars = currentLines.length === 1 ? line.length : currentChars + line.length + 1;
138
+ currentChars =
139
+ currentLines.length === 1 ? line.length : currentChars + line.length + 1;
132
140
 
133
141
  if (isFenceLine(line)) {
134
142
  if (!openFence) {