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,150 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { parseModelsCommand, handleModelsCommand } from './models-command.js';
3
+ import * as actionsConfig from './actions-config.js';
4
+ // ---------------------------------------------------------------------------
5
+ // Parser
6
+ // ---------------------------------------------------------------------------
7
+ describe('parseModelsCommand', () => {
8
+ it('parses bare !models as show', () => {
9
+ expect(parseModelsCommand('!models')).toEqual({ action: 'show' });
10
+ });
11
+ it('parses !models show', () => {
12
+ expect(parseModelsCommand('!models show')).toEqual({ action: 'show' });
13
+ });
14
+ it('parses !models help', () => {
15
+ expect(parseModelsCommand('!models help')).toEqual({ action: 'help' });
16
+ });
17
+ it('parses !models set with valid role and model', () => {
18
+ expect(parseModelsCommand('!models set chat sonnet')).toEqual({
19
+ action: 'set',
20
+ role: 'chat',
21
+ model: 'sonnet',
22
+ });
23
+ expect(parseModelsCommand('!models set forge-drafter opus')).toEqual({
24
+ action: 'set',
25
+ role: 'forge-drafter',
26
+ model: 'opus',
27
+ });
28
+ });
29
+ it('rejects !models set with invalid role', () => {
30
+ expect(parseModelsCommand('!models set bogus sonnet')).toBeNull();
31
+ });
32
+ it('rejects !models set with missing model', () => {
33
+ expect(parseModelsCommand('!models set chat')).toBeNull();
34
+ });
35
+ it('returns null for non-models messages', () => {
36
+ expect(parseModelsCommand('hello')).toBeNull();
37
+ expect(parseModelsCommand('!health')).toBeNull();
38
+ expect(parseModelsCommand('!restart')).toBeNull();
39
+ expect(parseModelsCommand('!modelsxyz')).toBeNull();
40
+ });
41
+ it('is case-insensitive for command and role', () => {
42
+ expect(parseModelsCommand('!MODELS')).toEqual({ action: 'show' });
43
+ expect(parseModelsCommand('!Models Show')).toEqual({ action: 'show' });
44
+ expect(parseModelsCommand('!MODELS SET CHAT sonnet')).toEqual({
45
+ action: 'set',
46
+ role: 'chat',
47
+ model: 'sonnet',
48
+ });
49
+ });
50
+ it('preserves original case for model token', () => {
51
+ expect(parseModelsCommand('!models set chat Claude-3-Opus')).toEqual({
52
+ action: 'set',
53
+ role: 'chat',
54
+ model: 'Claude-3-Opus',
55
+ });
56
+ expect(parseModelsCommand('!MODELS SET CHAT Sonnet')).toEqual({
57
+ action: 'set',
58
+ role: 'chat',
59
+ model: 'Sonnet',
60
+ });
61
+ });
62
+ it('handles extra whitespace', () => {
63
+ expect(parseModelsCommand(' !models ')).toEqual({ action: 'show' });
64
+ expect(parseModelsCommand(' !models show ')).toEqual({ action: 'show' });
65
+ expect(parseModelsCommand(' !models set chat sonnet ')).toEqual({
66
+ action: 'set',
67
+ role: 'chat',
68
+ model: 'sonnet',
69
+ });
70
+ });
71
+ it('returns null for unknown subcommands', () => {
72
+ expect(parseModelsCommand('!models bogus')).toBeNull();
73
+ expect(parseModelsCommand('!models set')).toBeNull();
74
+ expect(parseModelsCommand('!models show now')).toBeNull();
75
+ expect(parseModelsCommand('!models set chat sonnet extra')).toBeNull();
76
+ });
77
+ it('parses all valid roles', () => {
78
+ for (const role of ['chat', 'fast', 'forge-drafter', 'forge-auditor', 'summary', 'cron', 'cron-exec']) {
79
+ expect(parseModelsCommand(`!models set ${role} haiku`)).toEqual({
80
+ action: 'set',
81
+ role,
82
+ model: 'haiku',
83
+ });
84
+ }
85
+ });
86
+ });
87
+ // ---------------------------------------------------------------------------
88
+ // Handler
89
+ // ---------------------------------------------------------------------------
90
+ describe('handleModelsCommand', () => {
91
+ const mockConfigCtx = {
92
+ botParams: {
93
+ runtimeModel: 'sonnet',
94
+ summaryModel: 'haiku',
95
+ forgeDrafterModel: 'opus',
96
+ forgeAuditorModel: 'sonnet',
97
+ },
98
+ runtime: {
99
+ id: 'claude-code',
100
+ defaultModel: 'sonnet',
101
+ },
102
+ };
103
+ const enabled = { configCtx: mockConfigCtx, configEnabled: true };
104
+ it('returns startup message when configCtx is undefined but feature is enabled', () => {
105
+ const result = handleModelsCommand({ action: 'show' }, { configCtx: undefined, configEnabled: true });
106
+ expect(result).toContain('not yet available');
107
+ expect(result).toContain('starting up');
108
+ });
109
+ it('returns disabled message when configCtx is undefined and feature is disabled', () => {
110
+ const result = handleModelsCommand({ action: 'show' }, { configCtx: undefined, configEnabled: false });
111
+ expect(result).toContain('disabled');
112
+ expect(result).not.toContain('starting up');
113
+ });
114
+ it('show delegates to executeConfigAction modelShow', () => {
115
+ const spy = vi.spyOn(actionsConfig, 'executeConfigAction').mockReturnValue({
116
+ ok: true,
117
+ summary: '**chat**: `sonnet`\n**summary**: `haiku`',
118
+ });
119
+ const result = handleModelsCommand({ action: 'show' }, enabled);
120
+ expect(spy).toHaveBeenCalledWith({ type: 'modelShow' }, mockConfigCtx);
121
+ expect(result).toContain('sonnet');
122
+ spy.mockRestore();
123
+ });
124
+ it('set delegates to executeConfigAction modelSet', () => {
125
+ const spy = vi.spyOn(actionsConfig, 'executeConfigAction').mockReturnValue({
126
+ ok: true,
127
+ summary: 'Model updated: chat → opus',
128
+ });
129
+ const result = handleModelsCommand({ action: 'set', role: 'chat', model: 'opus' }, enabled);
130
+ expect(spy).toHaveBeenCalledWith({ type: 'modelSet', role: 'chat', model: 'opus' }, mockConfigCtx);
131
+ expect(result).toContain('Model updated');
132
+ spy.mockRestore();
133
+ });
134
+ it('set returns error string on failure', () => {
135
+ const spy = vi.spyOn(actionsConfig, 'executeConfigAction').mockReturnValue({
136
+ ok: false,
137
+ error: 'Cron subsystem not configured',
138
+ });
139
+ const result = handleModelsCommand({ action: 'set', role: 'cron', model: 'haiku' }, enabled);
140
+ expect(result).toContain('Error: Cron subsystem not configured');
141
+ spy.mockRestore();
142
+ });
143
+ it('help returns usage text with roles and examples', () => {
144
+ const result = handleModelsCommand({ action: 'help' }, enabled);
145
+ expect(result).toContain('!models commands');
146
+ expect(result).toContain('chat');
147
+ expect(result).toContain('forge-drafter');
148
+ expect(result).toContain('!models set chat sonnet');
149
+ });
150
+ });
@@ -0,0 +1,76 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { setBotNickname } from '../discord.js';
3
+ function mockGuild() {
4
+ const me = {
5
+ nickname: null,
6
+ user: { username: 'Discoclaw' },
7
+ setNickname: vi.fn().mockResolvedValue(undefined),
8
+ };
9
+ return {
10
+ id: 'guild-1',
11
+ members: {
12
+ me,
13
+ fetchMe: vi.fn().mockResolvedValue(me),
14
+ },
15
+ };
16
+ }
17
+ function mockLog() {
18
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
19
+ }
20
+ describe('setBotNickname', () => {
21
+ it('sets nickname when different from current', async () => {
22
+ const guild = mockGuild();
23
+ const log = mockLog();
24
+ await setBotNickname(guild, 'Weston', log);
25
+ expect(guild.members.me.setNickname).toHaveBeenCalledWith('Weston', 'Automatic nickname from bot identity');
26
+ expect(log.info).toHaveBeenCalledWith(expect.objectContaining({ guildId: 'guild-1', nickname: 'Weston' }), 'discord:nickname set');
27
+ });
28
+ it('skips when nickname already matches', async () => {
29
+ const me = { nickname: 'Weston', user: { username: 'Discoclaw' }, setNickname: vi.fn() };
30
+ const guild = { id: 'guild-1', members: { me, fetchMe: vi.fn() } };
31
+ const log = mockLog();
32
+ await setBotNickname(guild, 'Weston', log);
33
+ expect(me.setNickname).not.toHaveBeenCalled();
34
+ });
35
+ it('skips when username matches and no nickname set', async () => {
36
+ const me = { nickname: null, user: { username: 'Weston' }, setNickname: vi.fn() };
37
+ const guild = { id: 'guild-1', members: { me, fetchMe: vi.fn() } };
38
+ const log = mockLog();
39
+ await setBotNickname(guild, 'Weston', log);
40
+ expect(me.setNickname).not.toHaveBeenCalled();
41
+ });
42
+ it('handles missing permissions gracefully (error code 50013)', async () => {
43
+ const err = Object.assign(new Error('Missing Permissions'), { code: 50013 });
44
+ const guild = mockGuild();
45
+ guild.members.me.setNickname = vi.fn().mockRejectedValue(err);
46
+ const log = mockLog();
47
+ await setBotNickname(guild, 'Weston', log);
48
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ guildId: 'guild-1' }), 'discord:nickname Missing Permissions — cannot set nickname');
49
+ });
50
+ it('handles null guild.members.me by calling fetchMe()', async () => {
51
+ const me = {
52
+ nickname: null,
53
+ user: { username: 'Discoclaw' },
54
+ setNickname: vi.fn().mockResolvedValue(undefined),
55
+ };
56
+ const guild = {
57
+ id: 'guild-1',
58
+ members: {
59
+ me: null,
60
+ fetchMe: vi.fn().mockResolvedValue(me),
61
+ },
62
+ };
63
+ const log = mockLog();
64
+ await setBotNickname(guild, 'Weston', log);
65
+ expect(guild.members.fetchMe).toHaveBeenCalledOnce();
66
+ expect(me.setNickname).toHaveBeenCalledWith('Weston', 'Automatic nickname from bot identity');
67
+ });
68
+ it('handles other errors gracefully', async () => {
69
+ const err = new Error('Unknown error');
70
+ const guild = mockGuild();
71
+ guild.members.me.setNickname = vi.fn().mockRejectedValue(err);
72
+ const log = mockLog();
73
+ await setBotNickname(guild, 'Weston', log);
74
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ err, guildId: 'guild-1' }), 'discord:nickname failed to set');
75
+ });
76
+ });
@@ -0,0 +1,55 @@
1
+ /**
2
+ * completeOnboarding — single owner of post-onboarding logic.
3
+ *
4
+ * Writes workspace files, optionally dispatches a morning check-in cron job,
5
+ * and sends the outcome message to the user.
6
+ */
7
+ import { writeWorkspaceFiles } from '../onboarding/onboarding-writer.js';
8
+ import { executeCronAction } from './actions-crons.js';
9
+ import { NO_MENTIONS } from './allowed-mentions.js';
10
+ // ---------------------------------------------------------------------------
11
+ // Implementation
12
+ // ---------------------------------------------------------------------------
13
+ /**
14
+ * Complete the onboarding flow: write workspace files, optionally create the
15
+ * morning check-in cron, and send a summary message to the user.
16
+ *
17
+ * Cron dispatch failure is logged but does not fail onboarding.
18
+ */
19
+ export async function completeOnboarding(values, workspaceCwd, sendTarget, cronDispatch) {
20
+ const writeResult = await writeWorkspaceFiles(values, workspaceCwd);
21
+ if (writeResult.errors.length > 0) {
22
+ const errorSummary = writeResult.errors.join('; ');
23
+ await sendTarget.send({
24
+ content: `Something went wrong writing your files: ${errorSummary}\n` +
25
+ `Type **retry** to try again, pick a number to edit a field, or \`!cancel\` to give up.`,
26
+ allowedMentions: NO_MENTIONS,
27
+ });
28
+ return { writeResult };
29
+ }
30
+ let cronResult;
31
+ if (values.morningCheckin && cronDispatch) {
32
+ try {
33
+ cronResult = await executeCronAction({
34
+ type: 'cronCreate',
35
+ name: 'Morning Check-in',
36
+ schedule: '0 8 * * *',
37
+ timezone: values.timezone,
38
+ channel: cronDispatch.actionCtx.channelId,
39
+ prompt: 'Good morning! Time for your daily check-in.',
40
+ }, cronDispatch.actionCtx, cronDispatch.cronCtx);
41
+ }
42
+ catch (err) {
43
+ cronDispatch.log?.warn({ err }, 'onboarding:cron-dispatch failed');
44
+ cronResult = { ok: false, error: String(err) };
45
+ }
46
+ }
47
+ const warnings = writeResult.warnings.length > 0
48
+ ? `\n\n${writeResult.warnings.join('\n')}`
49
+ : '';
50
+ await sendTarget.send({
51
+ content: `All set! I've written your **IDENTITY.md** and **USER.md**. I'm ready to go.${warnings}`,
52
+ allowedMentions: NO_MENTIONS,
53
+ });
54
+ return { writeResult, cronResult };
55
+ }
@@ -0,0 +1,176 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { completeOnboarding } from './onboarding-completion.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks
5
+ // ---------------------------------------------------------------------------
6
+ vi.mock('../onboarding/onboarding-writer.js', () => ({
7
+ writeWorkspaceFiles: vi.fn(),
8
+ }));
9
+ vi.mock('./actions-crons.js', () => ({
10
+ executeCronAction: vi.fn(),
11
+ }));
12
+ // ---------------------------------------------------------------------------
13
+ // Helpers
14
+ // ---------------------------------------------------------------------------
15
+ const baseValues = {
16
+ userName: 'David',
17
+ timezone: 'America/New_York',
18
+ morningCheckin: false,
19
+ };
20
+ const checkinValues = {
21
+ ...baseValues,
22
+ morningCheckin: true,
23
+ };
24
+ function makeWriteResult(overrides) {
25
+ return {
26
+ written: ['IDENTITY.md', 'USER.md'],
27
+ errors: [],
28
+ warnings: [],
29
+ ...overrides,
30
+ };
31
+ }
32
+ function makeSendTarget() {
33
+ const send = vi.fn(async () => { });
34
+ return { send, asSendTarget: { send } };
35
+ }
36
+ function makeActionCtx() {
37
+ return {
38
+ guild: { id: 'guild-1' },
39
+ client: {},
40
+ channelId: 'ch-1',
41
+ messageId: 'msg-1',
42
+ };
43
+ }
44
+ function makeCronDispatch() {
45
+ const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
46
+ return {
47
+ cronCtx: {},
48
+ actionCtx: makeActionCtx(),
49
+ log,
50
+ };
51
+ }
52
+ async function getWriteWorkspaceFiles() {
53
+ const mod = await import('../onboarding/onboarding-writer.js');
54
+ return vi.mocked(mod.writeWorkspaceFiles);
55
+ }
56
+ async function getExecuteCronAction() {
57
+ const mod = await import('./actions-crons.js');
58
+ return vi.mocked(mod.executeCronAction);
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // Tests
62
+ // ---------------------------------------------------------------------------
63
+ describe('completeOnboarding', () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+ it('writes workspace files', async () => {
68
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
69
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
70
+ const { asSendTarget } = makeSendTarget();
71
+ await completeOnboarding(baseValues, '/workspace', asSendTarget);
72
+ expect(writeWorkspaceFiles).toHaveBeenCalledWith(baseValues, '/workspace');
73
+ });
74
+ it('sends success message on write success', async () => {
75
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
76
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
77
+ const { send, asSendTarget } = makeSendTarget();
78
+ await completeOnboarding(baseValues, '/workspace', asSendTarget);
79
+ expect(send).toHaveBeenCalledOnce();
80
+ expect(send.mock.calls[0][0].content).toContain('All set');
81
+ expect(send.mock.calls[0][0].content).toContain('IDENTITY.md');
82
+ });
83
+ it('includes warnings in success message', async () => {
84
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
85
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult({ warnings: ['USER.md has unresolved placeholders: {{name}}'] }));
86
+ const { send, asSendTarget } = makeSendTarget();
87
+ await completeOnboarding(baseValues, '/workspace', asSendTarget);
88
+ expect(send.mock.calls[0][0].content).toContain('unresolved placeholders');
89
+ });
90
+ it('returns writeResult in result object', async () => {
91
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
92
+ const wr = makeWriteResult();
93
+ writeWorkspaceFiles.mockResolvedValue(wr);
94
+ const { asSendTarget } = makeSendTarget();
95
+ const result = await completeOnboarding(baseValues, '/workspace', asSendTarget);
96
+ expect(result.writeResult).toBe(wr);
97
+ });
98
+ it('sends error message and returns early on write errors', async () => {
99
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
100
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult({ written: [], errors: ['Failed to write IDENTITY.md: EACCES'] }));
101
+ const executeCronAction = await getExecuteCronAction();
102
+ const { send, asSendTarget } = makeSendTarget();
103
+ const cronDispatch = makeCronDispatch();
104
+ const result = await completeOnboarding(checkinValues, '/workspace', asSendTarget, cronDispatch);
105
+ expect(send).toHaveBeenCalledOnce();
106
+ expect(send.mock.calls[0][0].content).toContain('went wrong');
107
+ expect(send.mock.calls[0][0].content).toContain('EACCES');
108
+ expect(result.cronResult).toBeUndefined();
109
+ expect(executeCronAction).not.toHaveBeenCalled();
110
+ });
111
+ it('does not dispatch cron when morningCheckin is false', async () => {
112
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
113
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
114
+ const executeCronAction = await getExecuteCronAction();
115
+ const { asSendTarget } = makeSendTarget();
116
+ const cronDispatch = makeCronDispatch();
117
+ await completeOnboarding(baseValues, '/workspace', asSendTarget, cronDispatch);
118
+ expect(executeCronAction).not.toHaveBeenCalled();
119
+ });
120
+ it('does not dispatch cron when cronDispatch is not provided', async () => {
121
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
122
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
123
+ const executeCronAction = await getExecuteCronAction();
124
+ const { asSendTarget } = makeSendTarget();
125
+ await completeOnboarding(checkinValues, '/workspace', asSendTarget);
126
+ expect(executeCronAction).not.toHaveBeenCalled();
127
+ });
128
+ it('dispatches morning cron when morningCheckin is true and cronDispatch is provided', async () => {
129
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
130
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
131
+ const executeCronAction = await getExecuteCronAction();
132
+ executeCronAction.mockResolvedValue({ ok: true, summary: 'Cron created' });
133
+ const { asSendTarget } = makeSendTarget();
134
+ const cronDispatch = makeCronDispatch();
135
+ const result = await completeOnboarding(checkinValues, '/workspace', asSendTarget, cronDispatch);
136
+ expect(executeCronAction).toHaveBeenCalledOnce();
137
+ expect(executeCronAction).toHaveBeenCalledWith(expect.objectContaining({
138
+ type: 'cronCreate',
139
+ name: 'Morning Check-in',
140
+ schedule: '0 8 * * *',
141
+ timezone: 'America/New_York',
142
+ channel: 'ch-1',
143
+ }), cronDispatch.actionCtx, cronDispatch.cronCtx);
144
+ expect(result.cronResult).toEqual({ ok: true, summary: 'Cron created' });
145
+ });
146
+ it('cron dispatch failure is logged and does not fail onboarding', async () => {
147
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
148
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
149
+ const executeCronAction = await getExecuteCronAction();
150
+ executeCronAction.mockRejectedValue(new Error('Forum not found'));
151
+ const { send, asSendTarget } = makeSendTarget();
152
+ const cronDispatch = makeCronDispatch();
153
+ const result = await completeOnboarding(checkinValues, '/workspace', asSendTarget, cronDispatch);
154
+ // Onboarding still succeeds — success message sent
155
+ expect(send).toHaveBeenCalledOnce();
156
+ expect(send.mock.calls[0][0].content).toContain('All set');
157
+ // Error is logged
158
+ expect(cronDispatch.log.warn).toHaveBeenCalled();
159
+ // cronResult reflects the failure
160
+ expect(result.cronResult).toEqual(expect.objectContaining({ ok: false }));
161
+ });
162
+ it('success message suppresses all mentions', async () => {
163
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
164
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult());
165
+ const { send, asSendTarget } = makeSendTarget();
166
+ await completeOnboarding(baseValues, '/workspace', asSendTarget);
167
+ expect(send.mock.calls[0][0].allowedMentions).toEqual({ parse: [] });
168
+ });
169
+ it('error message suppresses all mentions', async () => {
170
+ const writeWorkspaceFiles = await getWriteWorkspaceFiles();
171
+ writeWorkspaceFiles.mockResolvedValue(makeWriteResult({ written: [], errors: ['boom'] }));
172
+ const { send, asSendTarget } = makeSendTarget();
173
+ await completeOnboarding(baseValues, '/workspace', asSendTarget);
174
+ expect(send.mock.calls[0][0].allowedMentions).toEqual({ parse: [] });
175
+ });
176
+ });
@@ -0,0 +1,178 @@
1
+ import { AttachmentBuilder } from 'discord.js';
2
+ import { splitDiscord, truncateCodeBlocks } from './output-utils.js';
3
+ import { NO_MENTIONS } from './allowed-mentions.js';
4
+ export function prepareDiscordOutput(text) {
5
+ const outText = truncateCodeBlocks(text);
6
+ return splitDiscord(outText);
7
+ }
8
+ export function imageMediaTypeToExtension(mediaType) {
9
+ switch (mediaType) {
10
+ case 'image/png': return 'png';
11
+ case 'image/jpeg': return 'jpeg';
12
+ case 'image/webp': return 'webp';
13
+ case 'image/gif': return 'gif';
14
+ default: return 'png';
15
+ }
16
+ }
17
+ export function buildAttachments(images) {
18
+ return images.map((img, i) => {
19
+ const ext = imageMediaTypeToExtension(img.mediaType);
20
+ const buf = Buffer.from(img.base64, 'base64');
21
+ return new AttachmentBuilder(buf, { name: `image-${i + 1}.${ext}` });
22
+ });
23
+ }
24
+ // Discord allows max 10 attachments per message.
25
+ const MAX_ATTACHMENTS_PER_MESSAGE = 10;
26
+ export async function editThenSendChunks(reply, channel, text, images) {
27
+ const attachments = images && images.length > 0 ? buildAttachments(images) : [];
28
+ const chunks = prepareDiscordOutput(text);
29
+ const hasContent = chunks.length > 0 && chunks.some((c) => c.trim().length > 0);
30
+ const hasImages = attachments.length > 0;
31
+ if (!hasContent && !hasImages) {
32
+ await reply.edit({ content: '(no output)', allowedMentions: NO_MENTIONS });
33
+ return;
34
+ }
35
+ if (!hasContent && hasImages) {
36
+ // Image-only: send with empty content string.
37
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
38
+ await reply.edit({ content: '', allowedMentions: NO_MENTIONS, files: firstBatch });
39
+ // Overflow images in extra messages.
40
+ for (let i = MAX_ATTACHMENTS_PER_MESSAGE; i < attachments.length; i += MAX_ATTACHMENTS_PER_MESSAGE) {
41
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(i, i + MAX_ATTACHMENTS_PER_MESSAGE) });
42
+ }
43
+ return;
44
+ }
45
+ // Text + optional images: attach images to the last chunk.
46
+ const lastIdx = chunks.length - 1;
47
+ if (lastIdx === 0 && attachments.length > 0) {
48
+ // Single chunk with images: one edit with files attached.
49
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
50
+ await reply.edit({ content: chunks[0] ?? '(no output)', allowedMentions: NO_MENTIONS, files: firstBatch });
51
+ for (let j = MAX_ATTACHMENTS_PER_MESSAGE; j < attachments.length; j += MAX_ATTACHMENTS_PER_MESSAGE) {
52
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(j, j + MAX_ATTACHMENTS_PER_MESSAGE) });
53
+ }
54
+ return;
55
+ }
56
+ // Multi-chunk: first chunk via edit, rest via send, images on last chunk.
57
+ await reply.edit({ content: chunks[0] ?? '(no output)', allowedMentions: NO_MENTIONS });
58
+ for (let i = 1; i < chunks.length; i++) {
59
+ if (i === lastIdx && attachments.length > 0) {
60
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
61
+ await channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS, files: firstBatch });
62
+ for (let j = MAX_ATTACHMENTS_PER_MESSAGE; j < attachments.length; j += MAX_ATTACHMENTS_PER_MESSAGE) {
63
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(j, j + MAX_ATTACHMENTS_PER_MESSAGE) });
64
+ }
65
+ }
66
+ else {
67
+ await channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS });
68
+ }
69
+ }
70
+ }
71
+ export async function replyThenSendChunks(message, text, images) {
72
+ const attachments = images && images.length > 0 ? buildAttachments(images) : [];
73
+ const chunks = prepareDiscordOutput(text);
74
+ const hasContent = chunks.length > 0 && chunks.some((c) => c.trim().length > 0);
75
+ const hasImages = attachments.length > 0;
76
+ if (!hasContent && !hasImages) {
77
+ await message.reply({ content: '(no output)', allowedMentions: NO_MENTIONS });
78
+ return;
79
+ }
80
+ if (!hasContent && hasImages) {
81
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
82
+ await message.reply({ content: '', allowedMentions: NO_MENTIONS, files: firstBatch });
83
+ for (let i = MAX_ATTACHMENTS_PER_MESSAGE; i < attachments.length; i += MAX_ATTACHMENTS_PER_MESSAGE) {
84
+ await message.channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(i, i + MAX_ATTACHMENTS_PER_MESSAGE) });
85
+ }
86
+ return;
87
+ }
88
+ const lastIdx = chunks.length - 1;
89
+ if (lastIdx === 0 && attachments.length > 0) {
90
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
91
+ await message.reply({ content: chunks[0] ?? '(no output)', allowedMentions: NO_MENTIONS, files: firstBatch });
92
+ for (let j = MAX_ATTACHMENTS_PER_MESSAGE; j < attachments.length; j += MAX_ATTACHMENTS_PER_MESSAGE) {
93
+ await message.channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(j, j + MAX_ATTACHMENTS_PER_MESSAGE) });
94
+ }
95
+ return;
96
+ }
97
+ await message.reply({ content: chunks[0] ?? '(no output)', allowedMentions: NO_MENTIONS });
98
+ for (let i = 1; i < chunks.length; i++) {
99
+ if (i === lastIdx && attachments.length > 0) {
100
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
101
+ await message.channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS, files: firstBatch });
102
+ for (let j = MAX_ATTACHMENTS_PER_MESSAGE; j < attachments.length; j += MAX_ATTACHMENTS_PER_MESSAGE) {
103
+ await message.channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(j, j + MAX_ATTACHMENTS_PER_MESSAGE) });
104
+ }
105
+ }
106
+ else {
107
+ await message.channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS });
108
+ }
109
+ }
110
+ }
111
+ /**
112
+ * Decides whether a follow-up placeholder message should be suppressed.
113
+ *
114
+ * Suppress when there is effectively no output: no actions, no images, no
115
+ * stripped-unrecognized blocks, and the cleaned text is under 50 chars.
116
+ *
117
+ * Never suppress when strippedUnrecognizedCount > 0 — the AI tried to act
118
+ * but the action type was unknown/disabled, so the user must see "(no output)"
119
+ * rather than a silent delete.
120
+ */
121
+ export function shouldSuppressFollowUp(processedText, actionsCount, imagesCount, strippedUnrecognizedCount) {
122
+ if (strippedUnrecognizedCount > 0)
123
+ return false;
124
+ if (actionsCount > 0 || imagesCount > 0)
125
+ return false;
126
+ const chars = processedText.replace(/\s+/g, ' ').trim().length;
127
+ return chars < 50;
128
+ }
129
+ /**
130
+ * Build a user-facing note for action types that were stripped because they
131
+ * were unknown or disabled by the current action category flags.
132
+ */
133
+ export function buildUnavailableActionTypesNotice(strippedTypes) {
134
+ const uniqueTypes = Array.from(new Set(strippedTypes.map((t) => t.trim()).filter(Boolean)));
135
+ if (uniqueTypes.length === 0)
136
+ return '';
137
+ const renderedTypes = uniqueTypes.map((t) => `\`${t}\``).join(', ');
138
+ if (uniqueTypes.length === 1) {
139
+ return `Ignored unavailable action type: ${renderedTypes} (unknown type or category disabled).`;
140
+ }
141
+ return `Ignored unavailable action types: ${renderedTypes} (unknown type or category disabled).`;
142
+ }
143
+ export function appendUnavailableActionTypesNotice(text, strippedTypes) {
144
+ const notice = buildUnavailableActionTypesNotice(strippedTypes);
145
+ if (!notice)
146
+ return text;
147
+ const base = String(text ?? '').trimEnd();
148
+ return base ? `${base}\n\n${notice}` : notice;
149
+ }
150
+ export async function sendChunks(channel, text, images) {
151
+ const attachments = images && images.length > 0 ? buildAttachments(images) : [];
152
+ const chunks = prepareDiscordOutput(text);
153
+ const hasContent = chunks.length > 0 && chunks.some((c) => c.trim().length > 0);
154
+ const hasImages = attachments.length > 0;
155
+ if (!hasContent && hasImages) {
156
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
157
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: firstBatch });
158
+ for (let i = MAX_ATTACHMENTS_PER_MESSAGE; i < attachments.length; i += MAX_ATTACHMENTS_PER_MESSAGE) {
159
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(i, i + MAX_ATTACHMENTS_PER_MESSAGE) });
160
+ }
161
+ return;
162
+ }
163
+ const lastIdx = chunks.length - 1;
164
+ for (let i = 0; i < chunks.length; i++) {
165
+ if (!chunks[i].trim())
166
+ continue;
167
+ if (i === lastIdx && attachments.length > 0) {
168
+ const firstBatch = attachments.slice(0, MAX_ATTACHMENTS_PER_MESSAGE);
169
+ await channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS, files: firstBatch });
170
+ for (let j = MAX_ATTACHMENTS_PER_MESSAGE; j < attachments.length; j += MAX_ATTACHMENTS_PER_MESSAGE) {
171
+ await channel.send({ content: '', allowedMentions: NO_MENTIONS, files: attachments.slice(j, j + MAX_ATTACHMENTS_PER_MESSAGE) });
172
+ }
173
+ }
174
+ else {
175
+ await channel.send({ content: chunks[i], allowedMentions: NO_MENTIONS });
176
+ }
177
+ }
178
+ }