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,198 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import { buildAttachments, imageMediaTypeToExtension, editThenSendChunks, replyThenSendChunks, sendChunks, shouldSuppressFollowUp, buildUnavailableActionTypesNotice, appendUnavailableActionTypesNotice, } from './output-common.js';
3
+ describe('imageMediaTypeToExtension', () => {
4
+ it('maps known types', () => {
5
+ expect(imageMediaTypeToExtension('image/png')).toBe('png');
6
+ expect(imageMediaTypeToExtension('image/jpeg')).toBe('jpeg');
7
+ expect(imageMediaTypeToExtension('image/webp')).toBe('webp');
8
+ expect(imageMediaTypeToExtension('image/gif')).toBe('gif');
9
+ });
10
+ it('defaults to png for unknown', () => {
11
+ expect(imageMediaTypeToExtension('image/bmp')).toBe('png');
12
+ });
13
+ });
14
+ describe('buildAttachments', () => {
15
+ it('creates correct filenames and buffers', () => {
16
+ const images = [
17
+ { base64: Buffer.from('png-data').toString('base64'), mediaType: 'image/png' },
18
+ { base64: Buffer.from('jpeg-data').toString('base64'), mediaType: 'image/jpeg' },
19
+ ];
20
+ const attachments = buildAttachments(images);
21
+ expect(attachments).toHaveLength(2);
22
+ expect(attachments[0].name).toBe('image-1.png');
23
+ expect(attachments[1].name).toBe('image-2.jpeg');
24
+ });
25
+ it('returns empty array for empty input', () => {
26
+ expect(buildAttachments([])).toHaveLength(0);
27
+ });
28
+ });
29
+ describe('editThenSendChunks with images', () => {
30
+ function mockReply() {
31
+ return { edit: vi.fn().mockResolvedValue(undefined) };
32
+ }
33
+ function mockChannel() {
34
+ return { send: vi.fn().mockResolvedValue(undefined) };
35
+ }
36
+ it('sends text-only with no images', async () => {
37
+ const reply = mockReply();
38
+ const channel = mockChannel();
39
+ await editThenSendChunks(reply, channel, 'hello');
40
+ expect(reply.edit).toHaveBeenCalledOnce();
41
+ expect(reply.edit.mock.calls[0][0].content).toBe('hello');
42
+ expect(reply.edit.mock.calls[0][0].files).toBeUndefined();
43
+ });
44
+ it('sends image-only when text is empty', async () => {
45
+ const reply = mockReply();
46
+ const channel = mockChannel();
47
+ const images = [
48
+ { base64: Buffer.from('test').toString('base64'), mediaType: 'image/png' },
49
+ ];
50
+ await editThenSendChunks(reply, channel, '', images);
51
+ expect(reply.edit).toHaveBeenCalledOnce();
52
+ expect(reply.edit.mock.calls[0][0].content).toBe('');
53
+ expect(reply.edit.mock.calls[0][0].files).toHaveLength(1);
54
+ });
55
+ it('attaches images to single text chunk in one edit', async () => {
56
+ const reply = mockReply();
57
+ const channel = mockChannel();
58
+ const images = [
59
+ { base64: Buffer.from('test').toString('base64'), mediaType: 'image/png' },
60
+ ];
61
+ await editThenSendChunks(reply, channel, 'Response text', images);
62
+ // Should only call edit once (no double-edit)
63
+ expect(reply.edit).toHaveBeenCalledOnce();
64
+ expect(reply.edit.mock.calls[0][0].content).toBe('Response text');
65
+ expect(reply.edit.mock.calls[0][0].files).toHaveLength(1);
66
+ });
67
+ it('attaches images to last chunk in multi-chunk text', async () => {
68
+ const reply = mockReply();
69
+ const channel = mockChannel();
70
+ const images = [
71
+ { base64: Buffer.from('img').toString('base64'), mediaType: 'image/png' },
72
+ ];
73
+ // Generate text long enough to split into multiple chunks (>2000 chars)
74
+ const longText = 'A'.repeat(2100);
75
+ await editThenSendChunks(reply, channel, longText, images);
76
+ // First chunk via edit (no files), remaining via channel.send
77
+ expect(reply.edit).toHaveBeenCalledOnce();
78
+ expect(reply.edit.mock.calls[0][0].files).toBeUndefined();
79
+ // Last send should have files
80
+ const sendCalls = channel.send.mock.calls;
81
+ expect(sendCalls.length).toBeGreaterThanOrEqual(1);
82
+ const lastSend = sendCalls[sendCalls.length - 1][0];
83
+ expect(lastSend.files).toHaveLength(1);
84
+ });
85
+ it('shows (no output) when empty text and no images', async () => {
86
+ const reply = mockReply();
87
+ const channel = mockChannel();
88
+ await editThenSendChunks(reply, channel, '');
89
+ expect(reply.edit).toHaveBeenCalledOnce();
90
+ expect(reply.edit.mock.calls[0][0].content).toBe('(no output)');
91
+ });
92
+ });
93
+ describe('replyThenSendChunks with images', () => {
94
+ function mockMessage() {
95
+ return {
96
+ reply: vi.fn().mockResolvedValue(undefined),
97
+ channel: { send: vi.fn().mockResolvedValue(undefined) },
98
+ };
99
+ }
100
+ it('sends image-only with empty text', async () => {
101
+ const message = mockMessage();
102
+ const images = [
103
+ { base64: Buffer.from('test').toString('base64'), mediaType: 'image/png' },
104
+ ];
105
+ await replyThenSendChunks(message, '', images);
106
+ expect(message.reply).toHaveBeenCalledOnce();
107
+ expect(message.reply.mock.calls[0][0].content).toBe('');
108
+ expect(message.reply.mock.calls[0][0].files).toHaveLength(1);
109
+ });
110
+ it('shows (no output) when no text and no images', async () => {
111
+ const message = mockMessage();
112
+ await replyThenSendChunks(message, '');
113
+ expect(message.reply).toHaveBeenCalledOnce();
114
+ expect(message.reply.mock.calls[0][0].content).toBe('(no output)');
115
+ });
116
+ });
117
+ describe('shouldSuppressFollowUp', () => {
118
+ it('suppresses when text is short and all counts are zero', () => {
119
+ expect(shouldSuppressFollowUp('hi', 0, 0, 0)).toBe(true);
120
+ });
121
+ it('suppresses when text is empty and all counts are zero', () => {
122
+ expect(shouldSuppressFollowUp('', 0, 0, 0)).toBe(true);
123
+ });
124
+ it('suppresses when whitespace-collapsed text is under 50 chars', () => {
125
+ const text = ' a b c '; // collapses to "a b c" (5 chars)
126
+ expect(shouldSuppressFollowUp(text, 0, 0, 0)).toBe(true);
127
+ });
128
+ it('does not suppress when text is 50 chars or more', () => {
129
+ const text = 'A'.repeat(50);
130
+ expect(shouldSuppressFollowUp(text, 0, 0, 0)).toBe(false);
131
+ });
132
+ it('does not suppress when actionsCount > 0', () => {
133
+ expect(shouldSuppressFollowUp('hi', 1, 0, 0)).toBe(false);
134
+ });
135
+ it('does not suppress when imagesCount > 0', () => {
136
+ expect(shouldSuppressFollowUp('hi', 0, 1, 0)).toBe(false);
137
+ });
138
+ it('does not suppress when strippedUnrecognizedCount > 0, even with short text', () => {
139
+ expect(shouldSuppressFollowUp('', 0, 0, 1)).toBe(false);
140
+ });
141
+ it('does not suppress when strippedUnrecognizedCount > 0, even with zero actions and images', () => {
142
+ expect(shouldSuppressFollowUp('short', 0, 0, 3)).toBe(false);
143
+ });
144
+ });
145
+ describe('buildUnavailableActionTypesNotice', () => {
146
+ it('returns empty string when no types were stripped', () => {
147
+ expect(buildUnavailableActionTypesNotice([])).toBe('');
148
+ });
149
+ it('renders singular notice for one unavailable type', () => {
150
+ const out = buildUnavailableActionTypesNotice(['channelCreate']);
151
+ expect(out).toContain('Ignored unavailable action type:');
152
+ expect(out).toContain('`channelCreate`');
153
+ });
154
+ it('deduplicates and renders plural notice for multiple unavailable types', () => {
155
+ const out = buildUnavailableActionTypesNotice(['taskSync', 'taskSync', 'planRun']);
156
+ expect(out).toContain('Ignored unavailable action types:');
157
+ expect(out).toContain('`taskSync`');
158
+ expect(out).toContain('`planRun`');
159
+ });
160
+ });
161
+ describe('appendUnavailableActionTypesNotice', () => {
162
+ it('appends the notice under existing text', () => {
163
+ const out = appendUnavailableActionTypesNotice('hello', ['channelCreate']);
164
+ expect(out).toContain('hello');
165
+ expect(out).toContain('Ignored unavailable action type');
166
+ });
167
+ it('returns notice alone when base text is empty', () => {
168
+ const out = appendUnavailableActionTypesNotice('', ['channelCreate']);
169
+ expect(out.startsWith('Ignored unavailable action type')).toBe(true);
170
+ });
171
+ it('returns original text when no stripped types are provided', () => {
172
+ expect(appendUnavailableActionTypesNotice('hello', [])).toBe('hello');
173
+ });
174
+ });
175
+ describe('sendChunks with images', () => {
176
+ function mockChannel() {
177
+ return { send: vi.fn().mockResolvedValue(undefined) };
178
+ }
179
+ it('sends image-only with empty text', async () => {
180
+ const channel = mockChannel();
181
+ const images = [
182
+ { base64: Buffer.from('test').toString('base64'), mediaType: 'image/png' },
183
+ ];
184
+ await sendChunks(channel, '', images);
185
+ expect(channel.send).toHaveBeenCalledOnce();
186
+ expect(channel.send.mock.calls[0][0].content).toBe('');
187
+ expect(channel.send.mock.calls[0][0].files).toHaveLength(1);
188
+ });
189
+ it('attaches images to last text chunk', async () => {
190
+ const channel = mockChannel();
191
+ const images = [
192
+ { base64: Buffer.from('test').toString('base64'), mediaType: 'image/png' },
193
+ ];
194
+ await sendChunks(channel, 'Hello world', images);
195
+ expect(channel.send).toHaveBeenCalledOnce();
196
+ expect(channel.send.mock.calls[0][0].files).toHaveLength(1);
197
+ });
198
+ });
@@ -0,0 +1,156 @@
1
+ export function splitDiscord(text, limit = 2000) {
2
+ // Minimal fence-safe markdown chunking.
3
+ const normalized = text.replace(/\r\n?/g, '\n');
4
+ if (normalized.length <= limit)
5
+ return [normalized];
6
+ const rawLines = normalized.split('\n');
7
+ const chunks = [];
8
+ let cur = '';
9
+ let inFence = false;
10
+ let fenceHeader = '```';
11
+ const effectiveCurLen = () => {
12
+ if (cur.length > 0)
13
+ return cur.length;
14
+ return inFence ? fenceHeader.length : 0;
15
+ };
16
+ const remainingRoom = () => {
17
+ const base = effectiveCurLen();
18
+ const sep = base > 0 ? 1 : 0;
19
+ return Math.max(0, limit - base - sep);
20
+ };
21
+ const ensureFenceOpen = () => {
22
+ if (cur)
23
+ return;
24
+ if (inFence)
25
+ cur = `${fenceHeader}`;
26
+ };
27
+ const flush = () => {
28
+ if (!cur)
29
+ return;
30
+ if (inFence && !cur.trimEnd().endsWith('```')) {
31
+ const close = '\n```';
32
+ if (cur.length + close.length <= limit) {
33
+ cur += close;
34
+ }
35
+ }
36
+ chunks.push(cur);
37
+ cur = '';
38
+ };
39
+ const appendLine = (line) => {
40
+ ensureFenceOpen();
41
+ const sep = cur.length > 0 ? '\n' : '';
42
+ cur += sep + line;
43
+ };
44
+ for (const line of rawLines) {
45
+ const curLen = effectiveCurLen();
46
+ const nextLen = (curLen ? curLen + 1 : 0) + line.length;
47
+ if (nextLen > limit && cur) {
48
+ flush();
49
+ }
50
+ if (line.length > remainingRoom()) {
51
+ let rest = line;
52
+ while (rest.length > 0) {
53
+ const room = Math.max(1, remainingRoom());
54
+ const take = rest.slice(0, room);
55
+ appendLine(take);
56
+ rest = rest.slice(room);
57
+ if (rest.length > 0) {
58
+ flush();
59
+ }
60
+ }
61
+ }
62
+ else {
63
+ appendLine(line);
64
+ }
65
+ const trimmed = line.trimStart();
66
+ if (trimmed.startsWith('```')) {
67
+ if (!inFence) {
68
+ inFence = true;
69
+ fenceHeader = trimmed.trimEnd();
70
+ }
71
+ else {
72
+ inFence = false;
73
+ fenceHeader = '```';
74
+ }
75
+ }
76
+ if (inFence && cur.length >= limit - 8) {
77
+ flush();
78
+ }
79
+ }
80
+ flush();
81
+ return chunks.filter((c) => c.trim().length > 0);
82
+ }
83
+ export function truncateCodeBlocks(text, maxLines = 20) {
84
+ // Truncate fenced code blocks that exceed maxLines, keeping first/last lines.
85
+ return text.replace(/^([ \t]*```[^\n]*\n)([\s\S]*?)(^[ \t]*```[ \t]*$)/gm, (_match, open, body, close) => {
86
+ const lines = body.split('\n');
87
+ const trimmedLines = lines.length > 0 && lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
88
+ if (trimmedLines.length <= maxLines)
89
+ return open + body + close;
90
+ const keepTop = Math.ceil(maxLines / 2);
91
+ const keepBottom = Math.floor(maxLines / 2);
92
+ const omitted = trimmedLines.length - keepTop - keepBottom;
93
+ const top = trimmedLines.slice(0, keepTop);
94
+ const bottom = trimmedLines.slice(trimmedLines.length - keepBottom);
95
+ return (open +
96
+ top.join('\n') + '\n' +
97
+ `... (${omitted} lines omitted)\n` +
98
+ bottom.join('\n') + '\n' +
99
+ close);
100
+ });
101
+ }
102
+ export function renderDiscordTail(text, maxLines = 8, maxWidth = 72) {
103
+ const normalized = String(text ?? '').replace(/\r\n?/g, '\n');
104
+ const lines = normalized.split('\n').filter((l) => l.length > 0);
105
+ const tail = lines.slice(-maxLines).map((l) => l.length > maxWidth ? l.slice(0, maxWidth - 1) + '\u2026' : l);
106
+ while (tail.length < maxLines)
107
+ tail.unshift('\u200b');
108
+ const safe = tail.join('\n').replace(/```/g, '``\\`');
109
+ return `\`\`\`text\n${safe}\n\`\`\``;
110
+ }
111
+ export function formatBoldLabel(label, maxWidth = 72) {
112
+ const singleLine = label.split('\n').find((l) => l.length > 0) ?? '';
113
+ const truncated = singleLine.length > maxWidth
114
+ ? singleLine.slice(0, maxWidth - 1) + '\u2026'
115
+ : singleLine;
116
+ const safe = truncated.replace(/([*_~|`\\[\]])/g, '\\$1');
117
+ return `**${safe}**`;
118
+ }
119
+ export function renderActivityTail(label, maxLines = 8, maxWidth = 72) {
120
+ const lines = [];
121
+ for (let i = 0; i < maxLines; i++)
122
+ lines.push('\u200b');
123
+ const safe = lines.join('\n').replace(/```/g, '``\\`');
124
+ return `${formatBoldLabel(label, maxWidth)}\n\`\`\`text\n${safe}\n\`\`\``;
125
+ }
126
+ export function thinkingLabel(tick) {
127
+ const dotCounts = [1, 2, 3, 0];
128
+ return 'Thinking' + '.'.repeat(dotCounts[tick % 4]);
129
+ }
130
+ export function formatElapsed(ms) {
131
+ const totalSeconds = Math.floor(ms / 1000);
132
+ if (totalSeconds < 60)
133
+ return `(${totalSeconds}s)`;
134
+ const minutes = Math.floor(totalSeconds / 60);
135
+ const seconds = totalSeconds % 60;
136
+ return `(${minutes}m${seconds}s)`;
137
+ }
138
+ export function selectStreamingOutput(opts) {
139
+ const preview = opts.showPreview ?? true;
140
+ const prefix = opts.elapsedMs !== undefined ? formatElapsed(opts.elapsedMs) + ' ' : '';
141
+ // finalText always bypasses the gate — completion/error output renders immediately.
142
+ if (!preview && !opts.finalText) {
143
+ if (opts.activityLabel)
144
+ return formatBoldLabel(prefix + opts.activityLabel);
145
+ return formatBoldLabel(prefix + thinkingLabel(opts.statusTick));
146
+ }
147
+ if (opts.deltaText) {
148
+ const label = prefix + thinkingLabel(opts.statusTick);
149
+ return `**${label}**\n${renderDiscordTail(opts.deltaText)}`;
150
+ }
151
+ if (opts.activityLabel)
152
+ return renderActivityTail(prefix + opts.activityLabel);
153
+ if (opts.finalText)
154
+ return renderDiscordTail(opts.finalText);
155
+ return renderActivityTail(prefix + thinkingLabel(opts.statusTick));
156
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import fs from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { parseIdentityName, resolveDisplayName } from '../identity.js';
6
+ describe('parseIdentityName', () => {
7
+ it('parses "- **Name:** Weston" format', async () => {
8
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
9
+ try {
10
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '# Identity\n\n- **Name:** Weston\n- **Vibe:** chill', 'utf8');
11
+ const result = await parseIdentityName(dir);
12
+ expect(result).toBe('Weston');
13
+ }
14
+ finally {
15
+ await fs.rm(dir, { recursive: true });
16
+ }
17
+ });
18
+ it('parses "**Name**: Weston" format (no leading dash)', async () => {
19
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
20
+ try {
21
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '# Identity\n\n**Name**: Weston\n', 'utf8');
22
+ const result = await parseIdentityName(dir);
23
+ expect(result).toBe('Weston');
24
+ }
25
+ finally {
26
+ await fs.rm(dir, { recursive: true });
27
+ }
28
+ });
29
+ it('parses "Name: Weston" format (no bold)', async () => {
30
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
31
+ try {
32
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '# Identity\n\nName: Weston\n', 'utf8');
33
+ const result = await parseIdentityName(dir);
34
+ expect(result).toBe('Weston');
35
+ }
36
+ finally {
37
+ await fs.rm(dir, { recursive: true });
38
+ }
39
+ });
40
+ it('returns undefined for empty/missing name value', async () => {
41
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
42
+ try {
43
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '# Identity\n\nNo name here\n', 'utf8');
44
+ const result = await parseIdentityName(dir);
45
+ expect(result).toBeUndefined();
46
+ }
47
+ finally {
48
+ await fs.rm(dir, { recursive: true });
49
+ }
50
+ });
51
+ it('returns undefined for missing file (ENOENT)', async () => {
52
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
53
+ try {
54
+ const result = await parseIdentityName(dir);
55
+ expect(result).toBeUndefined();
56
+ }
57
+ finally {
58
+ await fs.rm(dir, { recursive: true });
59
+ }
60
+ });
61
+ it('trims whitespace from the name', async () => {
62
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
63
+ try {
64
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '- **Name:** Weston \n', 'utf8');
65
+ const result = await parseIdentityName(dir);
66
+ expect(result).toBe('Weston');
67
+ }
68
+ finally {
69
+ await fs.rm(dir, { recursive: true });
70
+ }
71
+ });
72
+ });
73
+ describe('resolveDisplayName', () => {
74
+ it('uses configName when provided', async () => {
75
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
76
+ try {
77
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '- **Name:** Ignored\n', 'utf8');
78
+ const result = await resolveDisplayName({ configName: 'FromConfig', workspaceCwd: dir });
79
+ expect(result).toBe('FromConfig');
80
+ }
81
+ finally {
82
+ await fs.rm(dir, { recursive: true });
83
+ }
84
+ });
85
+ it('falls back to IDENTITY.md name when configName is undefined', async () => {
86
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
87
+ try {
88
+ await fs.writeFile(path.join(dir, 'IDENTITY.md'), '- **Name:** Weston\n', 'utf8');
89
+ const result = await resolveDisplayName({ workspaceCwd: dir });
90
+ expect(result).toBe('Weston');
91
+ }
92
+ finally {
93
+ await fs.rm(dir, { recursive: true });
94
+ }
95
+ });
96
+ it('falls back to Discoclaw when no name source exists', async () => {
97
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
98
+ try {
99
+ const result = await resolveDisplayName({ workspaceCwd: dir });
100
+ expect(result).toBe('Discoclaw');
101
+ }
102
+ finally {
103
+ await fs.rm(dir, { recursive: true });
104
+ }
105
+ });
106
+ it('truncates names exceeding 32 characters', async () => {
107
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
108
+ const log = { warn: vi.fn() };
109
+ try {
110
+ const longName = 'A'.repeat(40);
111
+ const result = await resolveDisplayName({ configName: longName, workspaceCwd: dir, log });
112
+ expect(result).toBe('A'.repeat(32));
113
+ expect(log.warn).toHaveBeenCalledWith(expect.objectContaining({ original: longName, truncated: 'A'.repeat(32) }), expect.stringContaining('truncating'));
114
+ }
115
+ finally {
116
+ await fs.rm(dir, { recursive: true });
117
+ }
118
+ });
119
+ it('falls back to Discoclaw for whitespace-only configName', async () => {
120
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'identity-'));
121
+ try {
122
+ const result = await resolveDisplayName({ configName: ' ', workspaceCwd: dir });
123
+ expect(result).toBe('Discoclaw');
124
+ }
125
+ finally {
126
+ await fs.rm(dir, { recursive: true });
127
+ }
128
+ });
129
+ });