@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,17 +1,29 @@
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';
4
+ import {
5
+ DISCORD_MAX_LINES_PER_MESSAGE,
6
+ DISCORD_TEXT_CHUNK_LIMIT,
7
+ } from '../../config.js';
7
8
  import { logger } from '../../logger.js';
9
+ import {
10
+ getHumanDelayMs,
11
+ type HumanDelayConfig,
12
+ sleep,
13
+ } from './human-delay.js';
8
14
 
9
15
  interface DiscordSendChannel {
10
- send: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
16
+ send: (payload: {
17
+ content: string;
18
+ files?: AttachmentBuilder[];
19
+ }) => Promise<DiscordMessage>;
11
20
  }
12
21
 
13
22
  interface DiscordEditMessage {
14
- edit: (payload: { content: string; files?: AttachmentBuilder[] }) => Promise<DiscordMessage>;
23
+ edit: (payload: {
24
+ content: string;
25
+ files?: AttachmentBuilder[];
26
+ }) => Promise<DiscordMessage>;
15
27
  delete: () => Promise<unknown>;
16
28
  }
17
29
 
@@ -29,10 +41,9 @@ export interface DiscordStreamOptions {
29
41
  maxLines?: number;
30
42
  editIntervalMs?: number;
31
43
  onFirstMessage?: () => void;
44
+ humanDelay?: HumanDelayConfig;
32
45
  }
33
46
 
34
- const DEFAULT_MAX_CHARS = 1_800;
35
- const DEFAULT_MAX_LINES = 20;
36
47
  const DEFAULT_EDIT_INTERVAL_MS = 1_200;
37
48
  const RETRY_MAX_ATTEMPTS = 3;
38
49
  const RETRY_BASE_DELAY_MS = 500;
@@ -40,20 +51,30 @@ const RETRY_BASE_DELAY_MS = 500;
40
51
  function isRetryableDiscordError(error: unknown): boolean {
41
52
  const maybe = error as DiscordErrorLike;
42
53
  const status = maybe.status ?? maybe.httpStatus;
43
- return status === 429 || (typeof status === 'number' && status >= 500 && status <= 599);
54
+ return (
55
+ status === 429 ||
56
+ (typeof status === 'number' && status >= 500 && status <= 599)
57
+ );
44
58
  }
45
59
 
46
60
  function extractRetryDelayMs(error: unknown, fallbackMs: number): number {
47
61
  const maybe = error as DiscordErrorLike;
48
62
  const retryAfterSeconds = maybe.retryAfter ?? maybe.data?.retry_after;
49
- if (typeof retryAfterSeconds === 'number' && Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0) {
63
+ if (
64
+ typeof retryAfterSeconds === 'number' &&
65
+ Number.isFinite(retryAfterSeconds) &&
66
+ retryAfterSeconds > 0
67
+ ) {
50
68
  return Math.max(50, Math.ceil(retryAfterSeconds * 1_000));
51
69
  }
52
70
  const jitter = Math.floor(Math.random() * 250);
53
71
  return fallbackMs + jitter;
54
72
  }
55
73
 
56
- async function withDiscordRetry<T>(label: string, run: () => Promise<T>): Promise<T> {
74
+ async function withDiscordRetry<T>(
75
+ label: string,
76
+ run: () => Promise<T>,
77
+ ): Promise<T> {
57
78
  let attempt = 0;
58
79
  let delayMs = RETRY_BASE_DELAY_MS;
59
80
  while (true) {
@@ -65,7 +86,10 @@ async function withDiscordRetry<T>(label: string, run: () => Promise<T>): Promis
65
86
  throw error;
66
87
  }
67
88
  const waitMs = extractRetryDelayMs(error, delayMs);
68
- logger.warn({ label, attempt, waitMs, error }, 'Discord request failed; retrying');
89
+ logger.warn(
90
+ { label, attempt, waitMs, error },
91
+ 'Discord request failed; retrying',
92
+ );
69
93
  await new Promise((resolve) => setTimeout(resolve, waitMs));
70
94
  delayMs = Math.min(delayMs * 2, 4_000);
71
95
  }
@@ -79,6 +103,7 @@ export class DiscordStreamManager {
79
103
  private readonly maxLines: number;
80
104
  private readonly editIntervalMs: number;
81
105
  private readonly onFirstMessage?: () => void;
106
+ private readonly humanDelay?: HumanDelayConfig;
82
107
 
83
108
  private readonly messages: DiscordEditMessage[] = [];
84
109
  private sentChunks: string[] = [];
@@ -91,10 +116,20 @@ export class DiscordStreamManager {
91
116
  constructor(sourceMessage: DiscordMessage, options?: DiscordStreamOptions) {
92
117
  this.sourceMessage = sourceMessage;
93
118
  this.channel = sourceMessage.channel as unknown as DiscordSendChannel;
94
- this.maxChars = Math.max(200, options?.maxChars ?? DEFAULT_MAX_CHARS);
95
- this.maxLines = Math.max(4, options?.maxLines ?? DEFAULT_MAX_LINES);
96
- this.editIntervalMs = Math.max(250, options?.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS);
119
+ this.maxChars = Math.max(
120
+ 200,
121
+ Math.min(2_000, options?.maxChars ?? DISCORD_TEXT_CHUNK_LIMIT),
122
+ );
123
+ this.maxLines = Math.max(
124
+ 4,
125
+ Math.min(200, options?.maxLines ?? DISCORD_MAX_LINES_PER_MESSAGE),
126
+ );
127
+ this.editIntervalMs = Math.max(
128
+ 250,
129
+ options?.editIntervalMs ?? DEFAULT_EDIT_INTERVAL_MS,
130
+ );
97
131
  this.onFirstMessage = options?.onFirstMessage;
132
+ this.humanDelay = options?.humanDelay;
98
133
  }
99
134
 
100
135
  hasSentMessages(): boolean {
@@ -125,9 +160,7 @@ export class DiscordStreamManager {
125
160
 
126
161
  fail(errorText: string): Promise<void> {
127
162
  if (this.closed) return Promise.resolve();
128
- this.content = this.content
129
- ? `${this.content}\n\n${errorText}`
130
- : errorText;
163
+ this.content = this.content ? `${this.content}\n\n${errorText}` : errorText;
131
164
  if (this.flushTimer) {
132
165
  clearTimeout(this.flushTimer);
133
166
  this.flushTimer = null;
@@ -159,17 +192,18 @@ export class DiscordStreamManager {
159
192
  }
160
193
 
161
194
  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
- });
195
+ this.opQueue = this.opQueue.then(task).catch((error) => {
196
+ logger.warn({ error }, 'Discord stream operation failed');
197
+ });
167
198
  return this.opQueue;
168
199
  }
169
200
 
170
201
  private scheduleFlush(): void {
171
202
  if (this.flushTimer || this.closed) return;
172
- const waitMs = Math.max(0, this.editIntervalMs - (Date.now() - this.lastEditAt));
203
+ const waitMs = Math.max(
204
+ 0,
205
+ this.editIntervalMs - (Date.now() - this.lastEditAt),
206
+ );
173
207
  this.flushTimer = setTimeout(() => {
174
208
  this.flushTimer = null;
175
209
  void this.enqueue(async () => {
@@ -178,7 +212,10 @@ export class DiscordStreamManager {
178
212
  }, waitMs);
179
213
  }
180
214
 
181
- private async sync(forceLastEdit: boolean, files?: AttachmentBuilder[]): Promise<void> {
215
+ private async sync(
216
+ forceLastEdit: boolean,
217
+ files?: AttachmentBuilder[],
218
+ ): Promise<void> {
182
219
  const chunks = chunkMessage(this.content, {
183
220
  maxChars: this.maxChars,
184
221
  maxLines: this.maxLines,
@@ -187,7 +224,9 @@ export class DiscordStreamManager {
187
224
  if (chunks.length === 0) {
188
225
  if (files && files.length > 0) {
189
226
  const fallback = 'Attached files:';
190
- const sent = await withDiscordRetry('reply', () => this.sourceMessage.reply({ content: fallback, files }));
227
+ const sent = await withDiscordRetry('reply', () =>
228
+ this.sourceMessage.reply({ content: fallback, files }),
229
+ );
191
230
  this.messages.push(sent as unknown as DiscordEditMessage);
192
231
  this.sentChunks.push(fallback);
193
232
  this.onFirstMessage?.();
@@ -200,9 +239,20 @@ export class DiscordStreamManager {
200
239
  const isLast = i === chunks.length - 1;
201
240
 
202
241
  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 }));
242
+ if (i > 0) {
243
+ const delayMs = getHumanDelayMs(this.humanDelay);
244
+ if (delayMs > 0) {
245
+ await sleep(delayMs);
246
+ }
247
+ }
248
+ const sent =
249
+ i === 0
250
+ ? await withDiscordRetry('reply', () =>
251
+ this.sourceMessage.reply({ content: chunk }),
252
+ )
253
+ : await withDiscordRetry('send', () =>
254
+ this.channel.send({ content: chunk }),
255
+ );
206
256
  this.messages.push(sent as unknown as DiscordEditMessage);
207
257
  this.sentChunks.push(chunk);
208
258
  this.onFirstMessage?.();
@@ -217,7 +267,9 @@ export class DiscordStreamManager {
217
267
  continue;
218
268
  }
219
269
 
220
- await withDiscordRetry('edit', () => this.messages[i].edit({ content: chunk }));
270
+ await withDiscordRetry('edit', () =>
271
+ this.messages[i].edit({ content: chunk }),
272
+ );
221
273
  this.sentChunks[i] = chunk;
222
274
  this.lastEditAt = Date.now();
223
275
  }
@@ -232,7 +284,9 @@ export class DiscordStreamManager {
232
284
 
233
285
  if (files && files.length > 0) {
234
286
  const lastIndex = chunks.length - 1;
235
- await withDiscordRetry('edit', () => this.messages[lastIndex].edit({ content: chunks[lastIndex], files }));
287
+ await withDiscordRetry('edit', () =>
288
+ this.messages[lastIndex].edit({ content: chunks[lastIndex], files }),
289
+ );
236
290
  this.sentChunks[lastIndex] = chunks[lastIndex];
237
291
  this.lastEditAt = Date.now();
238
292
  }
@@ -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) {