discoclaw 0.1.0

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 (393) hide show
  1. package/.context/README.md +42 -0
  2. package/.context/architecture.md +58 -0
  3. package/.context/bot-setup.md +24 -0
  4. package/.context/dev.md +230 -0
  5. package/.context/discord.md +144 -0
  6. package/.context/memory.md +257 -0
  7. package/.context/ops.md +59 -0
  8. package/.context/pa-safety.md +47 -0
  9. package/.context/pa.md +118 -0
  10. package/.context/project.md +43 -0
  11. package/.context/runtime.md +253 -0
  12. package/.context/tasks.md +71 -0
  13. package/.context/tools.md +75 -0
  14. package/.env.example +88 -0
  15. package/.env.example.full +378 -0
  16. package/LICENSE +21 -0
  17. package/README.md +220 -0
  18. package/dist/beads/auto-tag.js +2 -0
  19. package/dist/beads/auto-tag.test.js +62 -0
  20. package/dist/beads/bd-cli.js +9 -0
  21. package/dist/beads/bd-cli.test.js +495 -0
  22. package/dist/beads/bead-hooks-cli.js +149 -0
  23. package/dist/beads/bead-sync-cli.js +5 -0
  24. package/dist/beads/bead-sync-cli.test.js +72 -0
  25. package/dist/beads/bead-sync-coordinator.js +4 -0
  26. package/dist/beads/bead-sync-coordinator.test.js +239 -0
  27. package/dist/beads/bead-sync-watcher.js +2 -0
  28. package/dist/beads/bead-sync-watcher.test.js +96 -0
  29. package/dist/beads/bead-sync.js +7 -0
  30. package/dist/beads/bead-sync.test.js +876 -0
  31. package/dist/beads/bead-thread-cache.js +8 -0
  32. package/dist/beads/bead-thread-cache.test.js +91 -0
  33. package/dist/beads/discord-sync.js +18 -0
  34. package/dist/beads/discord-sync.test.js +782 -0
  35. package/dist/beads/find-bead-by-thread.test.js +36 -0
  36. package/dist/beads/forum-guard.js +2 -0
  37. package/dist/beads/forum-guard.test.js +204 -0
  38. package/dist/beads/initialize.js +3 -0
  39. package/dist/beads/initialize.test.js +304 -0
  40. package/dist/beads/types.js +10 -0
  41. package/dist/cli/daemon-installer.js +225 -0
  42. package/dist/cli/daemon-installer.test.js +289 -0
  43. package/dist/cli/index.js +42 -0
  44. package/dist/cli/init-wizard.js +374 -0
  45. package/dist/cli/init-wizard.test.js +191 -0
  46. package/dist/config.js +385 -0
  47. package/dist/config.test.js +589 -0
  48. package/dist/cron/auto-tag.js +100 -0
  49. package/dist/cron/auto-tag.test.js +91 -0
  50. package/dist/cron/cadence.js +74 -0
  51. package/dist/cron/cadence.test.js +53 -0
  52. package/dist/cron/cron-sync-coordinator.js +66 -0
  53. package/dist/cron/cron-sync-coordinator.test.js +118 -0
  54. package/dist/cron/cron-sync.js +165 -0
  55. package/dist/cron/cron-sync.test.js +228 -0
  56. package/dist/cron/cron-tag-map-watcher.js +128 -0
  57. package/dist/cron/cron-tag-map-watcher.test.js +155 -0
  58. package/dist/cron/default-timezone.js +23 -0
  59. package/dist/cron/default-timezone.test.js +30 -0
  60. package/dist/cron/discord-sync.js +205 -0
  61. package/dist/cron/discord-sync.test.js +353 -0
  62. package/dist/cron/executor.js +303 -0
  63. package/dist/cron/executor.test.js +614 -0
  64. package/dist/cron/forum-sync.js +347 -0
  65. package/dist/cron/forum-sync.test.js +539 -0
  66. package/dist/cron/job-lock.js +164 -0
  67. package/dist/cron/job-lock.test.js +178 -0
  68. package/dist/cron/parser.js +68 -0
  69. package/dist/cron/parser.test.js +115 -0
  70. package/dist/cron/run-control.js +24 -0
  71. package/dist/cron/run-control.test.js +27 -0
  72. package/dist/cron/run-stats.js +265 -0
  73. package/dist/cron/run-stats.test.js +160 -0
  74. package/dist/cron/scheduler.js +97 -0
  75. package/dist/cron/scheduler.test.js +112 -0
  76. package/dist/cron/tag-map.js +47 -0
  77. package/dist/cron/tag-map.test.js +64 -0
  78. package/dist/cron/types.js +1 -0
  79. package/dist/discoclaw-plan-format.test.js +137 -0
  80. package/dist/discoclaw-recipe-format.test.js +137 -0
  81. package/dist/discord/abort-registry.js +70 -0
  82. package/dist/discord/action-categories.js +36 -0
  83. package/dist/discord/action-types.js +1 -0
  84. package/dist/discord/action-utils.js +58 -0
  85. package/dist/discord/action-utils.test.js +58 -0
  86. package/dist/discord/actions-beads.js +1 -0
  87. package/dist/discord/actions-beads.test.js +372 -0
  88. package/dist/discord/actions-bot-profile.js +107 -0
  89. package/dist/discord/actions-bot-profile.test.js +138 -0
  90. package/dist/discord/actions-channels.js +427 -0
  91. package/dist/discord/actions-channels.test.js +697 -0
  92. package/dist/discord/actions-config.js +173 -0
  93. package/dist/discord/actions-config.test.js +322 -0
  94. package/dist/discord/actions-crons.js +586 -0
  95. package/dist/discord/actions-crons.test.js +499 -0
  96. package/dist/discord/actions-defer.js +60 -0
  97. package/dist/discord/actions-defer.test.js +134 -0
  98. package/dist/discord/actions-forge.js +134 -0
  99. package/dist/discord/actions-forge.test.js +206 -0
  100. package/dist/discord/actions-guild.js +301 -0
  101. package/dist/discord/actions-guild.test.js +386 -0
  102. package/dist/discord/actions-memory.js +106 -0
  103. package/dist/discord/actions-memory.test.js +248 -0
  104. package/dist/discord/actions-messaging.js +401 -0
  105. package/dist/discord/actions-messaging.test.js +738 -0
  106. package/dist/discord/actions-moderation.js +65 -0
  107. package/dist/discord/actions-moderation.test.js +88 -0
  108. package/dist/discord/actions-plan.js +445 -0
  109. package/dist/discord/actions-plan.test.js +610 -0
  110. package/dist/discord/actions-poll.js +38 -0
  111. package/dist/discord/actions-poll.test.js +93 -0
  112. package/dist/discord/actions-tasks.js +3 -0
  113. package/dist/discord/actions-tasks.test.js +418 -0
  114. package/dist/discord/actions.js +600 -0
  115. package/dist/discord/actions.test.js +522 -0
  116. package/dist/discord/allowed-mentions.js +3 -0
  117. package/dist/discord/allowed-mentions.test.js +17 -0
  118. package/dist/discord/allowlist.js +29 -0
  119. package/dist/discord/allowlist.test.js +24 -0
  120. package/dist/discord/audit-handler.js +191 -0
  121. package/dist/discord/audit-handler.test.js +361 -0
  122. package/dist/discord/bot.js +141 -0
  123. package/dist/discord/channel-context.js +181 -0
  124. package/dist/discord/defer-scheduler.js +45 -0
  125. package/dist/discord/destructive-confirmation.js +128 -0
  126. package/dist/discord/destructive-confirmation.test.js +49 -0
  127. package/dist/discord/discord-plan-auto-implement.test.js +18 -0
  128. package/dist/discord/durable-memory.js +145 -0
  129. package/dist/discord/durable-memory.test.js +281 -0
  130. package/dist/discord/durable-write-queue.js +4 -0
  131. package/dist/discord/file-download.js +308 -0
  132. package/dist/discord/file-download.test.js +303 -0
  133. package/dist/discord/forge-audit-verdict.js +140 -0
  134. package/dist/discord/forge-auto-implement.js +80 -0
  135. package/dist/discord/forge-auto-implement.test.js +110 -0
  136. package/dist/discord/forge-commands.js +698 -0
  137. package/dist/discord/forge-commands.test.js +1606 -0
  138. package/dist/discord/forge-plan-registry.js +68 -0
  139. package/dist/discord/forge-plan-registry.test.js +127 -0
  140. package/dist/discord/forum-count-sync.js +130 -0
  141. package/dist/discord/forum-count-sync.test.js +200 -0
  142. package/dist/discord/health-command.js +98 -0
  143. package/dist/discord/health-command.test.js +195 -0
  144. package/dist/discord/help-command.js +22 -0
  145. package/dist/discord/help-command.test.js +49 -0
  146. package/dist/discord/image-download.js +201 -0
  147. package/dist/discord/image-download.test.js +499 -0
  148. package/dist/discord/inflight-replies.js +228 -0
  149. package/dist/discord/inflight-replies.test.js +295 -0
  150. package/dist/discord/json-extract.js +110 -0
  151. package/dist/discord/keyed-queue.js +22 -0
  152. package/dist/discord/memory-commands.js +85 -0
  153. package/dist/discord/memory-commands.test.js +159 -0
  154. package/dist/discord/memory-timing.integration.test.js +159 -0
  155. package/dist/discord/message-coordinator.js +2347 -0
  156. package/dist/discord/message-coordinator.onboarding.test.js +183 -0
  157. package/dist/discord/message-coordinator.plan-run.test.js +264 -0
  158. package/dist/discord/message-history.js +53 -0
  159. package/dist/discord/message-history.test.js +95 -0
  160. package/dist/discord/models-command.js +59 -0
  161. package/dist/discord/models-command.test.js +150 -0
  162. package/dist/discord/nickname.test.js +76 -0
  163. package/dist/discord/onboarding-completion.js +55 -0
  164. package/dist/discord/onboarding-completion.test.js +176 -0
  165. package/dist/discord/output-common.js +178 -0
  166. package/dist/discord/output-common.test.js +198 -0
  167. package/dist/discord/output-utils.js +156 -0
  168. package/dist/discord/parse-identity-name.test.js +129 -0
  169. package/dist/discord/plan-commands.js +612 -0
  170. package/dist/discord/plan-commands.test.js +1622 -0
  171. package/dist/discord/plan-manager.js +1491 -0
  172. package/dist/discord/plan-manager.test.js +2380 -0
  173. package/dist/discord/plan-parser.js +110 -0
  174. package/dist/discord/plan-parser.test.js +63 -0
  175. package/dist/discord/plan-run-phase-start.js +20 -0
  176. package/dist/discord/plan-run-phase-start.test.js +29 -0
  177. package/dist/discord/platform-message.js +45 -0
  178. package/dist/discord/platform-message.test.js +110 -0
  179. package/dist/discord/prompt-common.js +240 -0
  180. package/dist/discord/prompt-common.test.js +423 -0
  181. package/dist/discord/reaction-handler.js +691 -0
  182. package/dist/discord/reaction-handler.test.js +1574 -0
  183. package/dist/discord/reaction-prompts.js +118 -0
  184. package/dist/discord/reaction-prompts.test.js +253 -0
  185. package/dist/discord/reply-reference.js +66 -0
  186. package/dist/discord/reply-reference.test.js +125 -0
  187. package/dist/discord/restart-command.js +143 -0
  188. package/dist/discord/restart-command.test.js +196 -0
  189. package/dist/discord/runtime-utils.js +43 -0
  190. package/dist/discord/runtime-utils.test.js +112 -0
  191. package/dist/discord/session-key.js +7 -0
  192. package/dist/discord/session-key.test.js +13 -0
  193. package/dist/discord/shortterm-memory.js +166 -0
  194. package/dist/discord/shortterm-memory.test.js +345 -0
  195. package/dist/discord/shutdown-context.js +122 -0
  196. package/dist/discord/shutdown-context.test.js +279 -0
  197. package/dist/discord/startup-profile.test.js +214 -0
  198. package/dist/discord/status-channel.js +190 -0
  199. package/dist/discord/status-channel.test.js +282 -0
  200. package/dist/discord/status-command.js +206 -0
  201. package/dist/discord/status-command.test.js +341 -0
  202. package/dist/discord/streaming-progress.js +107 -0
  203. package/dist/discord/streaming-progress.test.js +93 -0
  204. package/dist/discord/summarizer.js +89 -0
  205. package/dist/discord/summarizer.test.js +245 -0
  206. package/dist/discord/system-bootstrap.js +396 -0
  207. package/dist/discord/system-bootstrap.test.js +724 -0
  208. package/dist/discord/thread-context.js +169 -0
  209. package/dist/discord/thread-context.test.js +386 -0
  210. package/dist/discord/tool-aware-queue.js +116 -0
  211. package/dist/discord/tool-aware-queue.test.js +180 -0
  212. package/dist/discord/update-command.js +127 -0
  213. package/dist/discord/update-command.test.js +275 -0
  214. package/dist/discord/user-errors.js +40 -0
  215. package/dist/discord/user-errors.test.js +31 -0
  216. package/dist/discord/user-turn-to-durable.js +111 -0
  217. package/dist/discord/user-turn-to-durable.test.js +273 -0
  218. package/dist/discord-followup.test.js +677 -0
  219. package/dist/discord.channel-context.test.js +95 -0
  220. package/dist/discord.fail-closed.test.js +199 -0
  221. package/dist/discord.health-command.integration.test.js +140 -0
  222. package/dist/discord.js +190 -0
  223. package/dist/discord.prompt-context.test.js +1431 -0
  224. package/dist/discord.render.test.js +621 -0
  225. package/dist/discord.status-wiring.test.js +187 -0
  226. package/dist/engine/claudeCli.js +137 -0
  227. package/dist/engine/types.js +1 -0
  228. package/dist/group-queue.js +25 -0
  229. package/dist/health/credential-check.js +175 -0
  230. package/dist/health/credential-check.test.js +401 -0
  231. package/dist/health/startup-healing.js +139 -0
  232. package/dist/health/startup-healing.test.js +298 -0
  233. package/dist/identity.js +36 -0
  234. package/dist/index.js +1378 -0
  235. package/dist/logging/logger-like.js +1 -0
  236. package/dist/observability/memory-sampler.js +51 -0
  237. package/dist/observability/memory-sampler.test.js +93 -0
  238. package/dist/observability/metrics.js +88 -0
  239. package/dist/observability/metrics.test.js +42 -0
  240. package/dist/onboarding/onboarding-flow.js +246 -0
  241. package/dist/onboarding/onboarding-flow.test.js +238 -0
  242. package/dist/onboarding/onboarding-writer.js +102 -0
  243. package/dist/onboarding/onboarding-writer.test.js +143 -0
  244. package/dist/pidlock.js +187 -0
  245. package/dist/pidlock.test.js +128 -0
  246. package/dist/pipeline/engine.js +206 -0
  247. package/dist/pipeline/engine.test.js +771 -0
  248. package/dist/root-policy.js +21 -0
  249. package/dist/root-policy.test.js +55 -0
  250. package/dist/runtime/claude-code-cli.js +35 -0
  251. package/dist/runtime/claude-code-cli.test.js +1199 -0
  252. package/dist/runtime/cli-adapter.js +584 -0
  253. package/dist/runtime/cli-output-parsers.js +108 -0
  254. package/dist/runtime/cli-shared.js +96 -0
  255. package/dist/runtime/cli-shared.test.js +104 -0
  256. package/dist/runtime/cli-strategy.js +6 -0
  257. package/dist/runtime/codex-cli.js +16 -0
  258. package/dist/runtime/codex-cli.test.js +862 -0
  259. package/dist/runtime/concurrency-limit.js +80 -0
  260. package/dist/runtime/concurrency-limit.test.js +137 -0
  261. package/dist/runtime/gemini-cli.js +16 -0
  262. package/dist/runtime/gemini-cli.test.js +413 -0
  263. package/dist/runtime/long-running-process.js +415 -0
  264. package/dist/runtime/long-running-process.test.js +318 -0
  265. package/dist/runtime/model-smoke-helpers.js +160 -0
  266. package/dist/runtime/model-smoke.test.js +194 -0
  267. package/dist/runtime/model-tiers.js +33 -0
  268. package/dist/runtime/model-tiers.test.js +65 -0
  269. package/dist/runtime/openai-auth.js +151 -0
  270. package/dist/runtime/openai-auth.test.js +361 -0
  271. package/dist/runtime/openai-compat.js +178 -0
  272. package/dist/runtime/openai-compat.test.js +449 -0
  273. package/dist/runtime/process-pool.js +93 -0
  274. package/dist/runtime/process-pool.test.js +148 -0
  275. package/dist/runtime/registry.js +15 -0
  276. package/dist/runtime/registry.test.js +47 -0
  277. package/dist/runtime/session-scanner.js +186 -0
  278. package/dist/runtime/session-scanner.test.js +257 -0
  279. package/dist/runtime/strategies/claude-strategy.js +193 -0
  280. package/dist/runtime/strategies/codex-strategy.js +161 -0
  281. package/dist/runtime/strategies/gemini-strategy.js +64 -0
  282. package/dist/runtime/strategies/template-strategy.js +85 -0
  283. package/dist/runtime/tool-capabilities.js +27 -0
  284. package/dist/runtime/tool-capabilities.test.js +24 -0
  285. package/dist/runtime/tool-labels.js +48 -0
  286. package/dist/runtime/types.js +2 -0
  287. package/dist/sessionManager.js +47 -0
  288. package/dist/sessions.js +18 -0
  289. package/dist/tasks/architecture-contract.js +33 -0
  290. package/dist/tasks/architecture-contract.test.js +90 -0
  291. package/dist/tasks/auto-tag.js +50 -0
  292. package/dist/tasks/auto-tag.test.js +64 -0
  293. package/dist/tasks/bd-cli.js +164 -0
  294. package/dist/tasks/bd-cli.test.js +359 -0
  295. package/dist/tasks/bead-sync.js +1 -0
  296. package/dist/tasks/context-summary.js +27 -0
  297. package/dist/tasks/discord-sync.js +3 -0
  298. package/dist/tasks/discord-sync.test.js +685 -0
  299. package/dist/tasks/discord-types.js +4 -0
  300. package/dist/tasks/find-task-by-thread.test.js +36 -0
  301. package/dist/tasks/forum-guard.js +81 -0
  302. package/dist/tasks/forum-guard.test.js +192 -0
  303. package/dist/tasks/initialize.js +77 -0
  304. package/dist/tasks/initialize.test.js +263 -0
  305. package/dist/tasks/logger-types.js +1 -0
  306. package/dist/tasks/metrics-types.js +3 -0
  307. package/dist/tasks/migrate.js +33 -0
  308. package/dist/tasks/migrate.test.js +156 -0
  309. package/dist/tasks/path-defaults.js +67 -0
  310. package/dist/tasks/path-defaults.test.js +73 -0
  311. package/dist/tasks/runtime-types.js +1 -0
  312. package/dist/tasks/service.js +33 -0
  313. package/dist/tasks/service.test.js +51 -0
  314. package/dist/tasks/store.js +238 -0
  315. package/dist/tasks/store.test.js +417 -0
  316. package/dist/tasks/sync-context.js +1 -0
  317. package/dist/tasks/sync-contract.js +24 -0
  318. package/dist/tasks/sync-contract.test.js +25 -0
  319. package/dist/tasks/sync-coordinator-metrics.js +41 -0
  320. package/dist/tasks/sync-coordinator-retries.js +71 -0
  321. package/dist/tasks/sync-coordinator.js +96 -0
  322. package/dist/tasks/sync-coordinator.test.js +501 -0
  323. package/dist/tasks/sync-types.js +1 -0
  324. package/dist/tasks/sync-watcher.js +27 -0
  325. package/dist/tasks/sync-watcher.test.js +92 -0
  326. package/dist/tasks/tag-map.js +36 -0
  327. package/dist/tasks/tag-map.test.js +54 -0
  328. package/dist/tasks/task-action-contract.js +16 -0
  329. package/dist/tasks/task-action-contract.test.js +16 -0
  330. package/dist/tasks/task-action-executor.js +18 -0
  331. package/dist/tasks/task-action-executor.test.js +420 -0
  332. package/dist/tasks/task-action-mutation-helpers.js +17 -0
  333. package/dist/tasks/task-action-mutations.js +151 -0
  334. package/dist/tasks/task-action-prompt.js +62 -0
  335. package/dist/tasks/task-action-read-ops.js +73 -0
  336. package/dist/tasks/task-action-runner-types.js +1 -0
  337. package/dist/tasks/task-action-thread-sync.js +82 -0
  338. package/dist/tasks/task-actions.js +3 -0
  339. package/dist/tasks/task-cli.js +227 -0
  340. package/dist/tasks/task-context.js +1 -0
  341. package/dist/tasks/task-lifecycle.js +46 -0
  342. package/dist/tasks/task-lifecycle.test.js +35 -0
  343. package/dist/tasks/task-sync-apply-plan.js +95 -0
  344. package/dist/tasks/task-sync-apply-types.js +12 -0
  345. package/dist/tasks/task-sync-apply.js +319 -0
  346. package/dist/tasks/task-sync-cli.js +89 -0
  347. package/dist/tasks/task-sync-cli.test.js +70 -0
  348. package/dist/tasks/task-sync-engine.js +88 -0
  349. package/dist/tasks/task-sync-engine.test.js +934 -0
  350. package/dist/tasks/task-sync-phase-apply.js +171 -0
  351. package/dist/tasks/task-sync-pipeline.js +2 -0
  352. package/dist/tasks/task-sync-pipeline.test.js +265 -0
  353. package/dist/tasks/task-sync-reconcile-plan.js +182 -0
  354. package/dist/tasks/task-sync-reconcile.js +144 -0
  355. package/dist/tasks/task-sync.js +56 -0
  356. package/dist/tasks/task-sync.test.js +86 -0
  357. package/dist/tasks/thread-cache.js +42 -0
  358. package/dist/tasks/thread-cache.test.js +89 -0
  359. package/dist/tasks/thread-contracts.test.js +711 -0
  360. package/dist/tasks/thread-forum-ops.js +68 -0
  361. package/dist/tasks/thread-helpers.js +86 -0
  362. package/dist/tasks/thread-helpers.test.js +33 -0
  363. package/dist/tasks/thread-lifecycle-ops.js +144 -0
  364. package/dist/tasks/thread-ops-shared.js +21 -0
  365. package/dist/tasks/thread-ops.js +2 -0
  366. package/dist/tasks/types.js +20 -0
  367. package/dist/tasks/types.test.js +60 -0
  368. package/dist/test-setup.js +11 -0
  369. package/dist/test-setup.test.js +42 -0
  370. package/dist/transport/types.js +1 -0
  371. package/dist/validate.js +41 -0
  372. package/dist/validate.test.js +94 -0
  373. package/dist/version.js +15 -0
  374. package/dist/version.test.js +31 -0
  375. package/dist/webhook/server.js +199 -0
  376. package/dist/webhook/server.test.js +460 -0
  377. package/dist/workspace-bootstrap.js +135 -0
  378. package/dist/workspace-bootstrap.test.js +514 -0
  379. package/dist/workspace-permissions.js +134 -0
  380. package/dist/workspace-permissions.test.js +181 -0
  381. package/package.json +74 -0
  382. package/scripts/cron/cron-tag-map.json +9 -0
  383. package/scripts/tasks/tag-map.json +10 -0
  384. package/systemd/discoclaw.service +19 -0
  385. package/templates/recipes/integration.discoclaw-recipe.md +171 -0
  386. package/templates/workspace/AGENTS.md +217 -0
  387. package/templates/workspace/BOOTSTRAP.md +1 -0
  388. package/templates/workspace/HEARTBEAT.md +10 -0
  389. package/templates/workspace/IDENTITY.md +16 -0
  390. package/templates/workspace/MEMORY.md +24 -0
  391. package/templates/workspace/SOUL.md +52 -0
  392. package/templates/workspace/TOOLS.md +304 -0
  393. package/templates/workspace/USER.md +37 -0
@@ -0,0 +1,195 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { MetricsRegistry } from '../observability/metrics.js';
3
+ import { parseHealthCommand, renderHealthReport, renderHealthToolsReport } from './health-command.js';
4
+ describe('parseHealthCommand', () => {
5
+ it('parses supported command forms', () => {
6
+ expect(parseHealthCommand('!health')).toBe('basic');
7
+ expect(parseHealthCommand(' !health verbose ')).toBe('verbose');
8
+ expect(parseHealthCommand('!health tools')).toBe('tools');
9
+ expect(parseHealthCommand('!memory show')).toBeNull();
10
+ });
11
+ });
12
+ describe('renderHealthReport', () => {
13
+ it('renders basic and verbose reports without secrets', () => {
14
+ const metrics = new MetricsRegistry();
15
+ metrics.increment('discord.message.received');
16
+ metrics.recordInvokeStart('message');
17
+ metrics.recordInvokeResult('message', 120, false, 'timed out');
18
+ const baseConfig = {
19
+ runtimeModel: 'opus',
20
+ runtimeTimeoutMs: 60000,
21
+ runtimeTools: ['Read', 'Edit'],
22
+ useRuntimeSessions: true,
23
+ toolAwareStreaming: true,
24
+ maxConcurrentInvocations: 3,
25
+ discordActionsEnabled: true,
26
+ summaryEnabled: true,
27
+ durableMemoryEnabled: true,
28
+ messageHistoryBudget: 3000,
29
+ reactionHandlerEnabled: false,
30
+ reactionRemoveHandlerEnabled: false,
31
+ cronEnabled: true,
32
+ tasksEnabled: false,
33
+ tasksActive: false,
34
+ tasksSyncFailureRetryEnabled: true,
35
+ tasksSyncFailureRetryDelayMs: 30000,
36
+ tasksSyncDeferredRetryDelayMs: 30000,
37
+ requireChannelContext: true,
38
+ autoIndexChannelContext: true,
39
+ };
40
+ const base = {
41
+ metrics,
42
+ queueDepth: 2,
43
+ config: baseConfig,
44
+ };
45
+ const basic = renderHealthReport({ ...base, mode: 'basic' });
46
+ expect(basic).toContain('Discoclaw Health');
47
+ expect(basic).toContain('Queue depth: 2');
48
+ expect(basic).not.toContain('Config (safe)');
49
+ const verbose = renderHealthReport({ ...base, mode: 'verbose' });
50
+ expect(verbose).toContain('Config (safe)');
51
+ expect(verbose).toContain('runtimeModel=opus');
52
+ expect(verbose).toContain('Error classes:');
53
+ });
54
+ it('shows tasks=active when tasksActive is true', () => {
55
+ const metrics = new MetricsRegistry();
56
+ const verbose = renderHealthReport({
57
+ metrics,
58
+ queueDepth: 0,
59
+ config: {
60
+ runtimeModel: 'opus', runtimeTimeoutMs: 60000, runtimeTools: ['Read'],
61
+ useRuntimeSessions: true, toolAwareStreaming: false, maxConcurrentInvocations: 0,
62
+ discordActionsEnabled: false, summaryEnabled: true, durableMemoryEnabled: true,
63
+ messageHistoryBudget: 3000, reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: false,
64
+ cronEnabled: true, tasksEnabled: true, tasksActive: true,
65
+ tasksSyncFailureRetryEnabled: true, tasksSyncFailureRetryDelayMs: 30000, tasksSyncDeferredRetryDelayMs: 30000,
66
+ requireChannelContext: true, autoIndexChannelContext: true,
67
+ },
68
+ mode: 'verbose',
69
+ });
70
+ expect(verbose).toContain('tasks=active');
71
+ });
72
+ it('shows tasks=degraded when enabled but not active', () => {
73
+ const metrics = new MetricsRegistry();
74
+ const verbose = renderHealthReport({
75
+ metrics,
76
+ queueDepth: 0,
77
+ config: {
78
+ runtimeModel: 'opus', runtimeTimeoutMs: 60000, runtimeTools: ['Read'],
79
+ useRuntimeSessions: true, toolAwareStreaming: false, maxConcurrentInvocations: 0,
80
+ discordActionsEnabled: false, summaryEnabled: true, durableMemoryEnabled: true,
81
+ messageHistoryBudget: 3000, reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: false,
82
+ cronEnabled: true, tasksEnabled: true, tasksActive: false,
83
+ tasksSyncFailureRetryEnabled: true, tasksSyncFailureRetryDelayMs: 30000, tasksSyncDeferredRetryDelayMs: 30000,
84
+ requireChannelContext: true, autoIndexChannelContext: true,
85
+ },
86
+ mode: 'verbose',
87
+ });
88
+ expect(verbose).toContain('tasks=degraded');
89
+ });
90
+ it('shows tasks=off when explicitly disabled', () => {
91
+ const metrics = new MetricsRegistry();
92
+ const verbose = renderHealthReport({
93
+ metrics,
94
+ queueDepth: 0,
95
+ config: {
96
+ runtimeModel: 'opus', runtimeTimeoutMs: 60000, runtimeTools: ['Read'],
97
+ useRuntimeSessions: true, toolAwareStreaming: false, maxConcurrentInvocations: 0,
98
+ discordActionsEnabled: false, summaryEnabled: true, durableMemoryEnabled: true,
99
+ messageHistoryBudget: 3000, reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: false,
100
+ cronEnabled: true, tasksEnabled: false, tasksActive: false,
101
+ tasksSyncFailureRetryEnabled: true, tasksSyncFailureRetryDelayMs: 30000, tasksSyncDeferredRetryDelayMs: 30000,
102
+ requireChannelContext: true, autoIndexChannelContext: true,
103
+ },
104
+ mode: 'verbose',
105
+ });
106
+ expect(verbose).toContain('tasks=off');
107
+ });
108
+ it('shows task sync no-runs message in verbose mode before first sync', () => {
109
+ const metrics = new MetricsRegistry();
110
+ const verbose = renderHealthReport({
111
+ metrics,
112
+ queueDepth: 0,
113
+ config: {
114
+ runtimeModel: 'opus', runtimeTimeoutMs: 60000, runtimeTools: ['Read'],
115
+ useRuntimeSessions: true, toolAwareStreaming: false, maxConcurrentInvocations: 0,
116
+ discordActionsEnabled: false, summaryEnabled: true, durableMemoryEnabled: true,
117
+ messageHistoryBudget: 3000, reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: false,
118
+ cronEnabled: true, tasksEnabled: true, tasksActive: true,
119
+ tasksSyncFailureRetryEnabled: true, tasksSyncFailureRetryDelayMs: 30000, tasksSyncDeferredRetryDelayMs: 30000,
120
+ requireChannelContext: true, autoIndexChannelContext: true,
121
+ },
122
+ mode: 'verbose',
123
+ });
124
+ expect(verbose).toContain('Task sync: no runs yet');
125
+ });
126
+ it('shows task sync lifecycle, transition, and retry metrics in verbose mode', () => {
127
+ const metrics = new MetricsRegistry();
128
+ metrics.increment('tasks.sync.started', 4);
129
+ metrics.increment('tasks.sync.succeeded', 3);
130
+ metrics.increment('tasks.sync.failed', 1);
131
+ metrics.increment('tasks.sync.coalesced', 2);
132
+ metrics.increment('tasks.sync.duration_ms.total', 1100);
133
+ metrics.increment('tasks.sync.duration_ms.samples', 4);
134
+ metrics.increment('tasks.sync.transition.threads_created', 5);
135
+ metrics.increment('tasks.sync.transition.thread_names_updated', 11);
136
+ metrics.increment('tasks.sync.transition.starter_messages_updated', 12);
137
+ metrics.increment('tasks.sync.transition.statuses_updated', 13);
138
+ metrics.increment('tasks.sync.transition.tags_updated', 14);
139
+ metrics.increment('tasks.sync.transition.threads_archived', 6);
140
+ metrics.increment('tasks.sync.transition.threads_reconciled', 7);
141
+ metrics.increment('tasks.sync.transition.orphan_threads_found', 8);
142
+ metrics.increment('tasks.sync.transition.closes_deferred', 9);
143
+ metrics.increment('tasks.sync.transition.warnings', 10);
144
+ metrics.increment('tasks.sync.follow_up.scheduled', 2);
145
+ metrics.increment('tasks.sync.follow_up.triggered', 2);
146
+ metrics.increment('tasks.sync.follow_up.succeeded', 1);
147
+ metrics.increment('tasks.sync.follow_up.failed', 1);
148
+ metrics.increment('tasks.sync.follow_up.error_class.other', 1);
149
+ metrics.increment('tasks.sync.retry.scheduled', 3);
150
+ metrics.increment('tasks.sync.retry.triggered', 2);
151
+ metrics.increment('tasks.sync.retry.failed', 1);
152
+ metrics.increment('tasks.sync.retry.coalesced', 5);
153
+ metrics.increment('tasks.sync.retry.canceled', 2);
154
+ metrics.increment('tasks.sync.failure_retry.scheduled', 4);
155
+ metrics.increment('tasks.sync.failure_retry.triggered', 3);
156
+ metrics.increment('tasks.sync.failure_retry.failed', 2);
157
+ metrics.increment('tasks.sync.failure_retry.coalesced', 6);
158
+ metrics.increment('tasks.sync.failure_retry.canceled', 1);
159
+ metrics.increment('tasks.sync.failure_retry.disabled', 7);
160
+ metrics.increment('tasks.sync.tag_map_reload.attempted', 9);
161
+ metrics.increment('tasks.sync.tag_map_reload.succeeded', 8);
162
+ metrics.increment('tasks.sync.tag_map_reload.failed', 1);
163
+ const verbose = renderHealthReport({
164
+ metrics,
165
+ queueDepth: 0,
166
+ config: {
167
+ runtimeModel: 'opus', runtimeTimeoutMs: 60000, runtimeTools: ['Read'],
168
+ useRuntimeSessions: true, toolAwareStreaming: false, maxConcurrentInvocations: 0,
169
+ discordActionsEnabled: false, summaryEnabled: true, durableMemoryEnabled: true,
170
+ messageHistoryBudget: 3000, reactionHandlerEnabled: false, reactionRemoveHandlerEnabled: false,
171
+ cronEnabled: true, tasksEnabled: true, tasksActive: true,
172
+ tasksSyncFailureRetryEnabled: false, tasksSyncFailureRetryDelayMs: 12000, tasksSyncDeferredRetryDelayMs: 18000,
173
+ requireChannelContext: true, autoIndexChannelContext: true,
174
+ },
175
+ mode: 'verbose',
176
+ });
177
+ expect(verbose).toContain('Task sync: started=4 ok=3 failed=1 coalesced=2 avgMs=275');
178
+ expect(verbose).toContain('Task sync transitions: created=5 renamed=11 starter=12 statuses=13 tags=14 archived=6 reconciled=7 orphans=8 deferred=9 warnings=10');
179
+ expect(verbose).toContain('taskSyncPolicy: failureRetry=off failureDelayMs=12000 deferredDelayMs=18000');
180
+ expect(verbose).toContain('Task sync follow-up/retry: followUp=2/2/1/1 retry=3/2/1 (coalesced=5 canceled=2) failureRetry=4/3/2 (coalesced=6 canceled=1 disabled=7)');
181
+ expect(verbose).toContain('Task sync tag-map reload: attempts=9 ok=8 failed=1');
182
+ expect(verbose).toContain('- tasks.sync.follow_up.error_class.other=1');
183
+ });
184
+ it('renders tools report', () => {
185
+ const out = renderHealthToolsReport({
186
+ permissionTier: 'standard',
187
+ effectiveTools: ['Read', 'Edit'],
188
+ configuredRuntimeTools: ['Read', 'Edit', 'WebSearch'],
189
+ });
190
+ expect(out).toContain('Discoclaw Tools');
191
+ expect(out).toContain('Permission tier: standard');
192
+ expect(out).toContain('Effective tools: Read, Edit');
193
+ expect(out).toContain('Configured runtime tools: Read, Edit, WebSearch');
194
+ });
195
+ });
@@ -0,0 +1,22 @@
1
+ export function parseHelpCommand(content) {
2
+ const normalized = String(content ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
3
+ if (normalized !== '!help')
4
+ return null;
5
+ return true;
6
+ }
7
+ export function handleHelpCommand() {
8
+ return [
9
+ '**Discoclaw commands:**',
10
+ '',
11
+ '- `!forge <task>` — start a forge run (AI-drafted + audited plan); `!forge help` for details',
12
+ '- `!plan <task>` — create and manage phased plans; `!plan help` for details',
13
+ '- `!memory` — view or edit durable memory; `!memory help` for details',
14
+ '- `!models` — show or change model assignments; `!models help` for details',
15
+ '- `!health` — show bot health and metrics; `!health verbose` for full config',
16
+ '- `!status` — show live runtime status (uptime, crons, memory, API connectivity)',
17
+ '- `!update` — check for or apply code updates; `!update help` for details',
18
+ '- `!restart` — restart the discoclaw service; `!restart help` for details',
19
+ '- `!stop` — abort all active AI streams and cancel any running forge',
20
+ '- `!help` — this message',
21
+ ].join('\n');
22
+ }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseHelpCommand, handleHelpCommand } from './help-command.js';
3
+ describe('parseHelpCommand', () => {
4
+ it('matches !help exactly', () => {
5
+ expect(parseHelpCommand('!help')).toBe(true);
6
+ });
7
+ it('is case-insensitive', () => {
8
+ expect(parseHelpCommand('!HELP')).toBe(true);
9
+ expect(parseHelpCommand('!Help')).toBe(true);
10
+ });
11
+ it('handles surrounding whitespace', () => {
12
+ expect(parseHelpCommand(' !help ')).toBe(true);
13
+ });
14
+ it('rejects commands that start with !help but have extra content', () => {
15
+ expect(parseHelpCommand('!helping')).toBeNull();
16
+ expect(parseHelpCommand('!helper')).toBeNull();
17
+ expect(parseHelpCommand('!help me')).toBeNull();
18
+ expect(parseHelpCommand('!help foo')).toBeNull();
19
+ });
20
+ it('returns null for unrelated messages', () => {
21
+ expect(parseHelpCommand('hello')).toBeNull();
22
+ expect(parseHelpCommand('!restart')).toBeNull();
23
+ expect(parseHelpCommand('!health')).toBeNull();
24
+ expect(parseHelpCommand('')).toBeNull();
25
+ });
26
+ });
27
+ describe('handleHelpCommand', () => {
28
+ it('returns a string listing all commands', () => {
29
+ const result = handleHelpCommand();
30
+ expect(typeof result).toBe('string');
31
+ expect(result).toContain('!forge');
32
+ expect(result).toContain('!plan');
33
+ expect(result).toContain('!memory');
34
+ expect(result).toContain('!models');
35
+ expect(result).toContain('!health');
36
+ expect(result).toContain('!update');
37
+ expect(result).toContain('!restart');
38
+ expect(result).toContain('!stop');
39
+ expect(result).toContain('!help');
40
+ });
41
+ it('mentions help subcommands for commands that have them', () => {
42
+ const result = handleHelpCommand();
43
+ expect(result).toContain('!forge help');
44
+ expect(result).toContain('!plan help');
45
+ expect(result).toContain('!models help');
46
+ expect(result).toContain('!update help');
47
+ expect(result).toContain('!restart help');
48
+ });
49
+ });
@@ -0,0 +1,201 @@
1
+ import { MAX_IMAGES_PER_INVOCATION } from '../runtime/types.js';
2
+ /** Allowed Discord CDN hosts (SSRF protection). */
3
+ const ALLOWED_HOSTS = new Set(['cdn.discordapp.com', 'media.discordapp.net']);
4
+ /** Max bytes per individual image (20 MB). */
5
+ const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
6
+ /** Max total bytes across all images in one message (50 MB). */
7
+ const MAX_TOTAL_BYTES = 50 * 1024 * 1024;
8
+ /** Per-image download timeout (10 seconds). */
9
+ const DOWNLOAD_TIMEOUT_MS = 10_000;
10
+ /** Supported image MIME types. */
11
+ const SUPPORTED_MEDIA_TYPES = new Set([
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/webp',
15
+ 'image/gif',
16
+ ]);
17
+ /** Extension-to-MIME fallback map. */
18
+ const EXT_TO_MIME = {
19
+ png: 'image/png',
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ webp: 'image/webp',
23
+ gif: 'image/gif',
24
+ };
25
+ // Per-format minimum payload sizes (structural floors for each format).
26
+ export const MIN_PNG_BYTES = 45; // sig(8) + IHDR(25) + IEND(12)
27
+ export const MIN_JPEG_BYTES = 20; // SOI(2) + marker(4) + frame(11) + EOI(2) ≈ 19, rounded up
28
+ export const MIN_GIF_BYTES = 26; // header(6) + LSD(7) + image desc(10) + LZW(2) + trailer(1)
29
+ export const MIN_WEBP_BYTES = 26; // RIFF(12) + VP8 chunk header(8) + minimal frame(6)
30
+ const MIN_BYTES_FOR_TYPE = {
31
+ 'image/png': MIN_PNG_BYTES,
32
+ 'image/jpeg': MIN_JPEG_BYTES,
33
+ 'image/gif': MIN_GIF_BYTES,
34
+ 'image/webp': MIN_WEBP_BYTES,
35
+ };
36
+ /**
37
+ * Sniff the image format from magic bytes.
38
+ * Returns the MIME type string or null if unrecognized.
39
+ */
40
+ export function sniffMediaType(buffer) {
41
+ // PNG: 8-byte signature 89 50 4E 47 0D 0A 1A 0A
42
+ if (buffer.length >= 8 &&
43
+ buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47 &&
44
+ buffer[4] === 0x0D && buffer[5] === 0x0A && buffer[6] === 0x1A && buffer[7] === 0x0A) {
45
+ return 'image/png';
46
+ }
47
+ // JPEG: 3-byte SOI + marker FF D8 FF
48
+ if (buffer.length >= 3 &&
49
+ buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) {
50
+ return 'image/jpeg';
51
+ }
52
+ // GIF: 6-byte version string GIF87a or GIF89a
53
+ if (buffer.length >= 6 &&
54
+ buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x38 &&
55
+ (buffer[4] === 0x37 || buffer[4] === 0x39) && buffer[5] === 0x61) {
56
+ return 'image/gif';
57
+ }
58
+ // WebP: RIFF....WEBP (bytes 0-3 = RIFF, bytes 8-11 = WEBP)
59
+ if (buffer.length >= 12 &&
60
+ buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
61
+ buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) {
62
+ return 'image/webp';
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * Resolve a Discord attachment's MIME type from its contentType or filename extension.
68
+ * Returns null if the attachment is not a supported image format.
69
+ */
70
+ export function resolveMediaType(attachment) {
71
+ // Prefer Discord's reported contentType.
72
+ if (attachment.contentType) {
73
+ const mime = attachment.contentType.split(';')[0].trim().toLowerCase();
74
+ if (SUPPORTED_MEDIA_TYPES.has(mime))
75
+ return mime;
76
+ }
77
+ // Fall back to file extension.
78
+ const name = attachment.name ?? '';
79
+ const dotIdx = name.lastIndexOf('.');
80
+ if (dotIdx >= 0) {
81
+ const ext = name.slice(dotIdx + 1).toLowerCase();
82
+ const mime = EXT_TO_MIME[ext];
83
+ if (mime)
84
+ return mime;
85
+ }
86
+ return null;
87
+ }
88
+ /** Sanitize an attachment filename for error messages (no URLs or query params). */
89
+ function safeName(attachment) {
90
+ const raw = attachment.name ?? 'unknown';
91
+ return raw.replace(/[\x00-\x1f]/g, '').slice(0, 100).trim() || 'unknown';
92
+ }
93
+ /**
94
+ * Download a single Discord image attachment.
95
+ * Returns the ImageData on success, or an error string on failure.
96
+ */
97
+ export async function downloadAttachment(attachment, mediaType) {
98
+ const name = safeName(attachment);
99
+ // SSRF protection: validate host.
100
+ let parsedUrl;
101
+ try {
102
+ parsedUrl = new URL(attachment.url);
103
+ }
104
+ catch {
105
+ return { ok: false, error: `${name}: invalid URL` };
106
+ }
107
+ if (parsedUrl.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsedUrl.hostname)) {
108
+ return { ok: false, error: `${name}: blocked (non-Discord CDN host)` };
109
+ }
110
+ // Pre-check size from Discord metadata.
111
+ if (attachment.size != null && attachment.size > MAX_IMAGE_BYTES) {
112
+ const sizeMB = (attachment.size / (1024 * 1024)).toFixed(1);
113
+ return { ok: false, error: `${name}: too large (${sizeMB} MB, max 20 MB)` };
114
+ }
115
+ try {
116
+ const response = await fetch(attachment.url, {
117
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
118
+ redirect: 'error',
119
+ });
120
+ if (!response.ok) {
121
+ return { ok: false, error: `${name}: HTTP ${response.status}` };
122
+ }
123
+ const buffer = Buffer.from(await response.arrayBuffer());
124
+ // Post-download size check.
125
+ if (buffer.length > MAX_IMAGE_BYTES) {
126
+ const sizeMB = (buffer.length / (1024 * 1024)).toFixed(1);
127
+ return { ok: false, error: `${name}: too large (${sizeMB} MB, max 20 MB)` };
128
+ }
129
+ // Sniff actual format from magic bytes — override declared MIME.
130
+ const sniffed = sniffMediaType(buffer);
131
+ if (!sniffed) {
132
+ return { ok: false, error: `${name}: unsupported image format (magic bytes don't match PNG, JPEG, GIF, or WebP)` };
133
+ }
134
+ // Reject truncated payloads below format structural minimums.
135
+ const minBytes = MIN_BYTES_FOR_TYPE[sniffed];
136
+ if (minBytes && buffer.length < minBytes) {
137
+ return { ok: false, error: `${name}: image too small to be valid ${sniffed} (${buffer.length} bytes, minimum ${minBytes})` };
138
+ }
139
+ return {
140
+ ok: true,
141
+ image: {
142
+ base64: buffer.toString('base64'),
143
+ mediaType: sniffed,
144
+ },
145
+ };
146
+ }
147
+ catch (err) {
148
+ const errObj = err instanceof Error ? err : null;
149
+ if (errObj?.name === 'TimeoutError' || errObj?.name === 'AbortError') {
150
+ return { ok: false, error: `${name}: download timed out` };
151
+ }
152
+ if (errObj?.name === 'TypeError' && String(errObj.message).includes('redirect')) {
153
+ return { ok: false, error: `${name}: blocked (unexpected redirect)` };
154
+ }
155
+ return { ok: false, error: `${name}: download failed` };
156
+ }
157
+ }
158
+ /**
159
+ * Download image attachments from a Discord message.
160
+ *
161
+ * Filters for supported image types, respects MAX_IMAGES_PER_INVOCATION,
162
+ * and enforces a total byte cap across all images.
163
+ */
164
+ export async function downloadMessageImages(attachments, maxImages = MAX_IMAGES_PER_INVOCATION) {
165
+ // Filter to supported image attachments with resolved MIME types.
166
+ const candidates = [];
167
+ for (const att of attachments) {
168
+ const mediaType = resolveMediaType(att);
169
+ if (mediaType)
170
+ candidates.push({ attachment: att, mediaType });
171
+ }
172
+ // Cap at maxImages.
173
+ const toDownload = candidates.slice(0, maxImages);
174
+ if (toDownload.length === 0)
175
+ return { images: [], errors: [] };
176
+ // Pre-check total byte budget from Discord metadata.
177
+ let estimatedTotal = 0;
178
+ const withinBudget = [];
179
+ const errors = [];
180
+ for (const item of toDownload) {
181
+ const size = item.attachment.size ?? 0;
182
+ if (estimatedTotal + size > MAX_TOTAL_BYTES) {
183
+ errors.push(`${safeName(item.attachment)}: skipped (total size limit exceeded)`);
184
+ continue;
185
+ }
186
+ estimatedTotal += size;
187
+ withinBudget.push(item);
188
+ }
189
+ // Download all in parallel.
190
+ const results = await Promise.all(withinBudget.map(({ attachment, mediaType }) => downloadAttachment(attachment, mediaType)));
191
+ const images = [];
192
+ for (const result of results) {
193
+ if (result.ok) {
194
+ images.push(result.image);
195
+ }
196
+ else {
197
+ errors.push(result.error);
198
+ }
199
+ }
200
+ return { images, errors };
201
+ }