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,691 @@
1
+ import { ensureGroupDir } from '../discord.js';
2
+ import { isAllowlisted } from './allowlist.js';
3
+ import { discordSessionKey } from './session-key.js';
4
+ import { ensureIndexedDiscordChannelContext, resolveDiscordChannelContext } from './channel-context.js';
5
+ import { parseDiscordActions, executeDiscordActions, discordActionsPromptSection, buildDisplayResultLines, buildAllResultLines } from './actions.js';
6
+ import { hasQueryAction, QUERY_ACTION_TYPES } from './action-categories.js';
7
+ import { tryResolveReactionPrompt } from './reaction-prompts.js';
8
+ import { tryAbortAll } from './abort-registry.js';
9
+ import { getActiveOrchestrator } from './forge-plan-registry.js';
10
+ import { buildContextFiles, inlineContextFiles, buildDurableMemorySection, buildTaskThreadSection, loadWorkspacePaFiles, resolveEffectiveTools, buildPromptPreamble } from './prompt-common.js';
11
+ import { editThenSendChunks, appendUnavailableActionTypesNotice } from './output-common.js';
12
+ import { formatBoldLabel, thinkingLabel, selectStreamingOutput } from './output-utils.js';
13
+ import { NO_MENTIONS } from './allowed-mentions.js';
14
+ import { registerInFlightReply, isShuttingDown } from './inflight-replies.js';
15
+ import { downloadMessageImages, resolveMediaType } from './image-download.js';
16
+ import { downloadTextAttachments } from './file-download.js';
17
+ import { mapRuntimeErrorToUserMessage } from './user-errors.js';
18
+ import { globalMetrics } from '../observability/metrics.js';
19
+ import { resolveModel } from '../runtime/model-tiers.js';
20
+ export function reactionPromptText(mode) {
21
+ if (mode === 'add') {
22
+ return {
23
+ eventLine: (reactingUser, userId, emoji, channelLabel) => `${reactingUser} (ID: ${userId}) reacted with ${emoji} to a message in ${channelLabel}.`,
24
+ guidanceLine: 'Respond based on your identity and context. The reaction signals the user wants you to engage with this message. Your response will be posted as a reply.',
25
+ };
26
+ }
27
+ return {
28
+ eventLine: (reactingUser, userId, emoji, channelLabel) => `${reactingUser} (ID: ${userId}) removed their ${emoji} reaction from a message in ${channelLabel}.`,
29
+ guidanceLine: 'Respond based on your identity and context. The user removed a reaction, which may signal a change of intent or retraction. Your response will be posted as a reply.',
30
+ };
31
+ }
32
+ function createReactionHandler(mode, params, queue, statusRef) {
33
+ const promptText = reactionPromptText(mode);
34
+ const logPrefix = mode === 'add' ? 'reaction' : 'reaction-remove';
35
+ const receivedMetric = mode === 'add' ? 'discord.reaction.received' : 'discord.reaction_remove.received';
36
+ const handlerErrorMetric = mode === 'add' ? 'discord.reaction.handler_error' : 'discord.reaction_remove.handler_error';
37
+ const wrapperErrorMetric = mode === 'add' ? 'discord.reaction.handler_wrapper_error' : 'discord.reaction_remove.handler_wrapper_error';
38
+ const eventLabel = mode === 'add' ? 'messageReactionAdd' : 'messageReactionRemove';
39
+ return async (reaction, user) => {
40
+ try {
41
+ const metrics = params.metrics ?? globalMetrics;
42
+ metrics.increment(receivedMetric);
43
+ // 1. Self-reaction guard — prevent infinite loops from bot's own reactions.
44
+ if (user.id === reaction.message.client.user?.id)
45
+ return;
46
+ // 2. Fetch partials.
47
+ try {
48
+ if (reaction.partial)
49
+ await reaction.fetch();
50
+ }
51
+ catch (err) {
52
+ params.log?.warn({ err }, `${logPrefix}:partial fetch failed (reaction)`);
53
+ return;
54
+ }
55
+ try {
56
+ if (reaction.message.partial)
57
+ await reaction.message.fetch();
58
+ }
59
+ catch (err) {
60
+ params.log?.warn({ err }, `${logPrefix}:partial fetch failed (message)`);
61
+ return;
62
+ }
63
+ // 3. Guild-only — skip DM reactions.
64
+ if (reaction.message.guildId == null)
65
+ return;
66
+ // 4. Allowlist check.
67
+ if (!isAllowlisted(params.allowUserIds, user.id))
68
+ return;
69
+ // 5. Reaction prompt interception — if this reaction resolves a pending prompt, capture
70
+ // the result and continue into the normal AI invocation flow with prompt-specific text.
71
+ // IMPORTANT: This check intentionally precedes the staleness guard (step 6) so that
72
+ // reactionPrompt resolution works even when reactionMaxAgeMs is configured short.
73
+ // The allowlist check above ensures only authorized users can resolve pending prompts.
74
+ let resolvedPrompt = null;
75
+ if (mode === 'add') {
76
+ // For custom emojis, build the full <:name:id> identifier so it matches the choice
77
+ // strings stored in the pending prompt. Unicode emojis use name directly.
78
+ const emojiForPrompt = reaction.emoji.id
79
+ ? `<:${reaction.emoji.name ?? ''}:${reaction.emoji.id}>`
80
+ : (reaction.emoji.name ?? '');
81
+ if (emojiForPrompt) {
82
+ resolvedPrompt = tryResolveReactionPrompt(reaction.message.id, emojiForPrompt);
83
+ }
84
+ }
85
+ // 4a. Abort intercept — 🛑 on a bot reply kills all active streams and any running forge.
86
+ // Placed after reaction prompt resolution (step 5) so pending prompts using 🛑 as a
87
+ // choice are resolved normally before this check. When resolvedPrompt is non-null, the
88
+ // entire block is skipped so the resolved choice flows through to AI invocation.
89
+ // On remove mode it silently consumes the event; on add mode with no resolved prompt
90
+ // it fires forge-aware cancellation and always consumes the event.
91
+ if (reaction.emoji.name === '🛑' &&
92
+ reaction.message.author?.id === reaction.message.client.user?.id &&
93
+ !resolvedPrompt) {
94
+ if (mode === 'remove')
95
+ return;
96
+ // add mode: abort all active streams and cancel any running forge.
97
+ const abortedCount = tryAbortAll();
98
+ if (abortedCount > 0)
99
+ metrics.increment('discord.reaction.abort');
100
+ const orch = getActiveOrchestrator();
101
+ if (orch?.isRunning)
102
+ orch.requestCancel();
103
+ return;
104
+ }
105
+ // 6. Staleness guard — skipped when a pending prompt was resolved (the prompt message
106
+ // may be old but the user's choice is still valid).
107
+ if (!resolvedPrompt) {
108
+ const msgTimestamp = reaction.message.createdTimestamp;
109
+ if (msgTimestamp && params.reactionMaxAgeMs > 0) {
110
+ const age = Date.now() - msgTimestamp;
111
+ if (age > params.reactionMaxAgeMs)
112
+ return;
113
+ }
114
+ }
115
+ // Resolve channel/thread info once, used by guards and the queue callback.
116
+ const ch = reaction.message.channel;
117
+ const isThread = typeof ch?.isThread === 'function' ? ch.isThread() : false;
118
+ const threadId = isThread ? String(ch.id ?? '') : null;
119
+ const threadParentId = isThread ? String(ch.parentId ?? '') : null;
120
+ // 7. Channel restriction.
121
+ if (params.allowChannelIds) {
122
+ const parentId = isThread ? String(ch.parentId ?? '') : '';
123
+ const allowed = params.allowChannelIds.has(reaction.message.channelId) ||
124
+ (parentId && params.allowChannelIds.has(parentId));
125
+ if (!allowed)
126
+ return;
127
+ }
128
+ // 8. Session key.
129
+ const sessionKey = discordSessionKey({
130
+ channelId: reaction.message.channelId,
131
+ authorId: user.id,
132
+ isDm: false,
133
+ threadId: threadId || null,
134
+ });
135
+ // 9. Queue.
136
+ await queue.run(sessionKey, async () => {
137
+ const msg = reaction.message;
138
+ let reply = null;
139
+ try {
140
+ // Join thread if needed.
141
+ if (params.autoJoinThreads && isThread) {
142
+ const joinable = typeof ch?.joinable === 'boolean' ? ch.joinable : true;
143
+ const joined = typeof ch?.joined === 'boolean' ? ch.joined : false;
144
+ if (joinable && !joined && typeof ch?.join === 'function') {
145
+ try {
146
+ await ch.join();
147
+ params.log?.info({ threadId: String(ch.id ?? ''), parentId: String(ch.parentId ?? '') }, `${logPrefix}:thread joined`);
148
+ }
149
+ catch (err) {
150
+ params.log?.warn({ err, threadId: String(ch?.id ?? '') }, `${logPrefix}:thread failed to join`);
151
+ }
152
+ }
153
+ }
154
+ reply = await msg.reply({
155
+ content: formatBoldLabel(thinkingLabel(0)),
156
+ allowedMentions: NO_MENTIONS,
157
+ });
158
+ const cwd = params.useGroupDirCwd
159
+ ? await ensureGroupDir(params.groupsDir, sessionKey, params.botDisplayName)
160
+ : params.workspaceCwd;
161
+ // Auto-index channel context.
162
+ if (params.discordChannelContext && params.autoIndexChannelContext) {
163
+ const id = (threadParentId && threadParentId.trim()) ? threadParentId : reaction.message.channelId;
164
+ const chName = String(ch?.name ?? ch?.parent?.name ?? '').trim();
165
+ try {
166
+ await ensureIndexedDiscordChannelContext({
167
+ ctx: params.discordChannelContext,
168
+ channelId: id,
169
+ channelName: chName || undefined,
170
+ log: params.log,
171
+ });
172
+ }
173
+ catch (err) {
174
+ params.log?.error({ err, channelId: id }, `${logPrefix}:context failed to ensure channel context`);
175
+ }
176
+ }
177
+ const channelCtx = resolveDiscordChannelContext({
178
+ ctx: params.discordChannelContext,
179
+ isDm: false,
180
+ channelId: reaction.message.channelId,
181
+ threadParentId,
182
+ });
183
+ if (params.requireChannelContext && !channelCtx.contextPath) {
184
+ params.log?.warn({ channelId: channelCtx.channelId }, `${logPrefix}:missing required channel context`);
185
+ await reply.edit({
186
+ content: mapRuntimeErrorToUserMessage('Configuration error: missing required channel context file for this channel ID.'),
187
+ allowedMentions: NO_MENTIONS,
188
+ });
189
+ return;
190
+ }
191
+ const paFiles = await loadWorkspacePaFiles(params.workspaceCwd, { skip: !!params.appendSystemPrompt });
192
+ const contextFiles = buildContextFiles(paFiles, params.discordChannelContext, channelCtx.contextPath);
193
+ const [durableSection, taskSection] = await Promise.all([
194
+ buildDurableMemorySection({
195
+ enabled: params.durableMemoryEnabled,
196
+ durableDataDir: params.durableDataDir,
197
+ userId: user.id,
198
+ durableInjectMaxChars: params.durableInjectMaxChars,
199
+ log: params.log,
200
+ }),
201
+ buildTaskThreadSection({
202
+ isThread,
203
+ threadId,
204
+ threadParentId,
205
+ taskCtx: params.taskCtx,
206
+ log: params.log,
207
+ }),
208
+ ]);
209
+ // Build prompt.
210
+ const emoji = reaction.emoji.name ?? '(unknown)';
211
+ const messageContent = String(msg.content ?? '').slice(0, 1500);
212
+ const messageAuthor = msg.author?.displayName || msg.author?.username || 'Unknown';
213
+ const messageAuthorId = msg.author?.id ?? 'unknown';
214
+ const reactingUser = user.displayName || user.username || 'Unknown';
215
+ // Channel label.
216
+ let channelLabel;
217
+ if (isThread) {
218
+ const threadName = String(ch?.name ?? 'unknown');
219
+ const parentName = String(ch?.parent?.name ?? 'unknown');
220
+ channelLabel = `thread ${threadName} in #${parentName}`;
221
+ }
222
+ else {
223
+ channelLabel = `#${channelCtx.channelName ?? 'unknown'}`;
224
+ }
225
+ const inlinedContext = await inlineContextFiles(contextFiles, { required: new Set(params.discordChannelContext?.paContextFiles ?? []) });
226
+ const eventLine = resolvedPrompt
227
+ ? `User chose ${resolvedPrompt.chosenEmoji} in response to: ${resolvedPrompt.question}`
228
+ : promptText.eventLine(reactingUser, user.id, emoji, channelLabel);
229
+ const guidanceLine = resolvedPrompt
230
+ ? 'Act on the user\'s choice. Do not re-ask the question.'
231
+ : promptText.guidanceLine;
232
+ let prompt = buildPromptPreamble(inlinedContext) + '\n\n' +
233
+ (taskSection
234
+ ? `---\n${taskSection}\n\n`
235
+ : '') +
236
+ (durableSection
237
+ ? `---\nDurable memory (user-specific notes):\n${durableSection}\n\n`
238
+ : '') +
239
+ `---\nThe sections above are internal system context. Never quote, reference, or explain them in your response. Respond only to the event below.\n\n` +
240
+ `---\nReaction event:\n` +
241
+ eventLine + `\n\n` +
242
+ `Original message by ${messageAuthor} (ID: ${messageAuthorId}):\n` +
243
+ messageContent;
244
+ // Download image attachments and non-image text attachments.
245
+ let inputImages;
246
+ if (msg.attachments && msg.attachments.size > 0) {
247
+ try {
248
+ const dlResult = await downloadMessageImages([...msg.attachments.values()]);
249
+ if (dlResult.images.length > 0) {
250
+ inputImages = dlResult.images;
251
+ params.log?.info({ imageCount: dlResult.images.length }, `${logPrefix}:images downloaded`);
252
+ }
253
+ if (dlResult.errors.length > 0) {
254
+ params.log?.warn({ errors: dlResult.errors }, `${logPrefix}:image download errors`);
255
+ metrics.increment('discord.image_download.errors', dlResult.errors.length);
256
+ prompt += `\n(Note: ${dlResult.errors.length} image(s) could not be loaded: ${dlResult.errors.join('; ')})`;
257
+ }
258
+ }
259
+ catch (err) {
260
+ params.log?.warn({ err }, `${logPrefix}:image download failed`);
261
+ }
262
+ // Download non-image text attachments.
263
+ try {
264
+ const nonImageAtts = [...msg.attachments.values()].filter(a => !resolveMediaType(a));
265
+ if (nonImageAtts.length > 0) {
266
+ const textResult = await downloadTextAttachments(nonImageAtts);
267
+ if (textResult.texts.length > 0) {
268
+ const sections = textResult.texts.map(t => `[Attached file: ${t.name}]\n\`\`\`\n${t.content}\n\`\`\``);
269
+ prompt += '\n\n' + sections.join('\n\n');
270
+ params.log?.info({ fileCount: textResult.texts.length }, `${logPrefix}:text attachments downloaded`);
271
+ }
272
+ if (textResult.errors.length > 0) {
273
+ prompt += '\n(' + textResult.errors.join('; ') + ')';
274
+ params.log?.info({ errors: textResult.errors }, `${logPrefix}:text attachment notes`);
275
+ }
276
+ }
277
+ }
278
+ catch (err) {
279
+ params.log?.warn({ err }, `${logPrefix}:text attachment download failed`);
280
+ }
281
+ }
282
+ // Embeds.
283
+ if (msg.embeds && msg.embeds.length > 0) {
284
+ const embedInfos = msg.embeds.map((e) => {
285
+ const parts = [];
286
+ if (e.title)
287
+ parts.push(e.title);
288
+ if (e.url)
289
+ parts.push(e.url);
290
+ return parts.join(' ') || '(embed)';
291
+ });
292
+ prompt += `\nEmbeds: ${embedInfos.join(', ')}`;
293
+ }
294
+ prompt += `\n\n${guidanceLine}`;
295
+ const isDm = reaction.message.guildId == null;
296
+ const actionFlags = {
297
+ channels: params.discordActionsChannels,
298
+ messaging: params.discordActionsMessaging,
299
+ guild: params.discordActionsGuild,
300
+ moderation: params.discordActionsModeration,
301
+ polls: params.discordActionsPolls,
302
+ tasks: params.discordActionsTasks ?? false,
303
+ crons: params.discordActionsCrons ?? false,
304
+ botProfile: params.discordActionsBotProfile ?? false,
305
+ forge: params.discordActionsForge ?? false,
306
+ plan: params.discordActionsPlan ?? false,
307
+ memory: params.discordActionsMemory ?? false,
308
+ config: params.discordActionsConfig ?? false,
309
+ defer: !isDm && (params.discordActionsDefer ?? false),
310
+ };
311
+ if (params.discordActionsEnabled && !isDm) {
312
+ prompt += '\n\n---\n' + discordActionsPromptSection(actionFlags, params.botDisplayName);
313
+ }
314
+ const addDirs = [];
315
+ if (params.useGroupDirCwd)
316
+ addDirs.push(params.workspaceCwd);
317
+ if (params.discordChannelContext)
318
+ addDirs.push(params.discordChannelContext.contentDir);
319
+ const tools = await resolveEffectiveTools({
320
+ workspaceCwd: params.workspaceCwd,
321
+ runtimeTools: params.runtimeTools,
322
+ runtimeCapabilities: params.runtime.capabilities,
323
+ runtimeId: params.runtime.id,
324
+ log: params.log,
325
+ });
326
+ const effectiveTools = tools.effectiveTools;
327
+ if (tools.permissionNote || tools.runtimeCapabilityNote) {
328
+ const noteLines = [
329
+ tools.permissionNote ? `Permission note: ${tools.permissionNote}` : null,
330
+ tools.runtimeCapabilityNote ? `Runtime capability note: ${tools.runtimeCapabilityNote}` : null,
331
+ ].filter((line) => Boolean(line));
332
+ prompt += `\n\n---\n${noteLines.join('\n')}\n`;
333
+ }
334
+ // Session continuity.
335
+ const sessionId = params.useRuntimeSessions
336
+ ? await params.sessionManager.getOrCreate(sessionKey)
337
+ : null;
338
+ params.log?.info({
339
+ sessionKey,
340
+ sessionId,
341
+ cwd,
342
+ emoji,
343
+ userId: user.id,
344
+ messageId: msg.id,
345
+ model: params.runtimeModel,
346
+ toolsCount: effectiveTools.length,
347
+ channelId: channelCtx.channelId,
348
+ channelName: channelCtx.channelName,
349
+ hasChannelContext: Boolean(channelCtx.contextPath),
350
+ permissionTier: tools.permissionTier,
351
+ }, `${logPrefix}:invoke:start`);
352
+ // Track this reply for graceful shutdown cleanup.
353
+ let dispose = registerInFlightReply(reply, reaction.message.channelId, reply.id, `${logPrefix}:${reaction.message.channelId}`);
354
+ // Tracks whether the reply was successfully replaced with real content (or deleted).
355
+ // If false when the finally block runs, the reply still shows thinking-format content
356
+ // and must be deleted to prevent a stale "Thinking..." message from persisting.
357
+ let replyFinalized = false;
358
+ let followUpDepth = 0;
359
+ let currentPrompt = prompt;
360
+ try {
361
+ // -- auto-follow-up loop --
362
+ while (true) {
363
+ if (followUpDepth > 0) {
364
+ dispose();
365
+ reply = await msg.reply({
366
+ content: formatBoldLabel('(following up...)'),
367
+ allowedMentions: NO_MENTIONS,
368
+ });
369
+ dispose = registerInFlightReply(reply, reaction.message.channelId, reply.id, `${logPrefix}:${reaction.message.channelId}:followup-${followUpDepth}`);
370
+ replyFinalized = false;
371
+ }
372
+ // Streaming pattern (matches discord.ts flat mode).
373
+ // Both add and remove handlers record under the 'reaction' invoke flow so
374
+ // latency lands in MetricsRegistry.latencies.reaction (avoids InvokeFlow
375
+ // type change). Volume is split by the separate received/error counters.
376
+ let hadTextFinal = false;
377
+ let finalText = '';
378
+ let deltaText = '';
379
+ const collectedImages = [];
380
+ let statusTick = 1;
381
+ const t0 = Date.now();
382
+ metrics.recordInvokeStart('reaction');
383
+ params.log?.info({ flow: 'reaction', sessionKey }, 'obs.invoke.start');
384
+ let invokeError = null;
385
+ let lastEditAt = 0;
386
+ const minEditIntervalMs = 1250;
387
+ let streamEditQueue = Promise.resolve();
388
+ const maybeEdit = async (force = false) => {
389
+ if (!reply)
390
+ return;
391
+ if (isShuttingDown())
392
+ return;
393
+ const currentReply = reply;
394
+ const now = Date.now();
395
+ if (!force && now - lastEditAt < minEditIntervalMs)
396
+ return;
397
+ lastEditAt = now;
398
+ const out = selectStreamingOutput({
399
+ deltaText, activityLabel: '', finalText,
400
+ statusTick: statusTick++,
401
+ showPreview: Date.now() - t0 >= 7000,
402
+ elapsedMs: Date.now() - t0,
403
+ });
404
+ streamEditQueue = streamEditQueue
405
+ .catch(() => undefined)
406
+ .then(async () => {
407
+ try {
408
+ await currentReply.edit({ content: out, allowedMentions: NO_MENTIONS });
409
+ }
410
+ catch {
411
+ // Ignore Discord edit errors during streaming.
412
+ }
413
+ });
414
+ await streamEditQueue;
415
+ };
416
+ // Stream stall warning state.
417
+ let lastEventAt = Date.now();
418
+ let activeToolCount = 0;
419
+ let stallWarned = false;
420
+ const keepalive = setInterval(() => {
421
+ // Stall warning: append to deltaText when events stop arriving.
422
+ if (params.streamStallWarningMs > 0) {
423
+ const stallElapsed = Date.now() - lastEventAt;
424
+ if (stallElapsed > params.streamStallWarningMs && activeToolCount === 0 && !stallWarned) {
425
+ stallWarned = true;
426
+ deltaText += (deltaText ? '\n' : '') + `\n*Stream may be stalled (${Math.round(stallElapsed / 1000)}s no activity)...*`;
427
+ }
428
+ }
429
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
430
+ maybeEdit(true);
431
+ }, 5000);
432
+ try {
433
+ for await (const evt of params.runtime.invoke({
434
+ prompt: currentPrompt,
435
+ model: resolveModel(params.runtimeModel, params.runtime.id),
436
+ cwd,
437
+ addDirs: addDirs.length > 0 ? Array.from(new Set(addDirs)) : undefined,
438
+ sessionId,
439
+ sessionKey,
440
+ tools: effectiveTools,
441
+ timeoutMs: params.runtimeTimeoutMs,
442
+ images: inputImages,
443
+ })) {
444
+ // Track event flow for stall warning.
445
+ lastEventAt = Date.now();
446
+ stallWarned = false;
447
+ if (evt.type === 'tool_start')
448
+ activeToolCount++;
449
+ else if (evt.type === 'tool_end')
450
+ activeToolCount = Math.max(0, activeToolCount - 1);
451
+ if (evt.type === 'text_final') {
452
+ hadTextFinal = true;
453
+ finalText = evt.text;
454
+ await maybeEdit(true);
455
+ }
456
+ else if (evt.type === 'text_delta') {
457
+ deltaText += evt.text;
458
+ await maybeEdit(false);
459
+ }
460
+ else if (evt.type === 'log_line') {
461
+ const prefix = evt.stream === 'stderr' ? '[stderr] ' : '[stdout] ';
462
+ deltaText += (deltaText && !deltaText.endsWith('\n') ? '\n' : '') + prefix + evt.line + '\n';
463
+ await maybeEdit(false);
464
+ }
465
+ else if (evt.type === 'image_data') {
466
+ collectedImages.push(evt.image);
467
+ }
468
+ else if (evt.type === 'error') {
469
+ invokeError = evt.message;
470
+ metrics.recordInvokeResult('reaction', Date.now() - t0, false, evt.message);
471
+ params.log?.error({ sessionKey, error: evt.message }, `${logPrefix}:runtime error`);
472
+ params.log?.warn({ flow: 'reaction', sessionKey, error: evt.message }, 'obs.invoke.error');
473
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
474
+ statusRef?.current?.runtimeError({ sessionKey, channelName: channelCtx.channelName }, evt.message);
475
+ finalText = mapRuntimeErrorToUserMessage(evt.message);
476
+ await maybeEdit(true);
477
+ replyFinalized = true;
478
+ return;
479
+ }
480
+ }
481
+ }
482
+ finally {
483
+ clearInterval(keepalive);
484
+ try {
485
+ await streamEditQueue;
486
+ }
487
+ catch { /* ignore */ }
488
+ streamEditQueue = Promise.resolve();
489
+ }
490
+ metrics.recordInvokeResult('reaction', Date.now() - t0, true);
491
+ params.log?.info({ flow: 'reaction', sessionKey, ms: Date.now() - t0, ok: true }, 'obs.invoke.end');
492
+ let processedText = finalText || deltaText || (collectedImages.length > 0 ? '' : '(no output)');
493
+ params.log?.info({ sessionKey, sessionId, ms: Date.now() - t0, hadError: Boolean(invokeError) }, `${logPrefix}:invoke:end`);
494
+ // Parse and execute Discord actions.
495
+ // Relax hadTextFinal requirement when stream completed without error
496
+ // but text contains action markers (same fix as message-coordinator).
497
+ if (!hadTextFinal && !invokeError && processedText.includes('<discord-action>')) {
498
+ params.log?.warn({ sessionKey, textLen: processedText.length }, 'discord:action fallback — hadTextFinal=false but text contains action markers');
499
+ }
500
+ const canParseActions = hadTextFinal || (!invokeError && processedText.includes('<discord-action>'));
501
+ let parsedActionCount = 0;
502
+ let parsedActions = [];
503
+ let actionResults = [];
504
+ let strippedUnrecognizedTypes = [];
505
+ if (params.discordActionsEnabled && msg.guild && canParseActions && !invokeError) {
506
+ const parsed = parseDiscordActions(processedText, actionFlags);
507
+ parsedActionCount = parsed.actions.length;
508
+ parsedActions = parsed.actions;
509
+ strippedUnrecognizedTypes = parsed.strippedUnrecognizedTypes;
510
+ if (parsed.actions.length > 0) {
511
+ const actCtx = {
512
+ guild: msg.guild,
513
+ client: msg.client,
514
+ channelId: msg.channelId,
515
+ messageId: msg.id,
516
+ threadParentId,
517
+ deferScheduler: params.deferScheduler,
518
+ confirmation: {
519
+ mode: 'automated',
520
+ },
521
+ };
522
+ // Construct per-event memoryCtx with the reacting user's ID and Discord metadata.
523
+ const perEventMemoryCtx = params.memoryCtx ? {
524
+ ...params.memoryCtx,
525
+ userId: user.id,
526
+ channelId: msg.channelId,
527
+ messageId: msg.id,
528
+ guildId: msg.guildId ?? undefined,
529
+ channelName: msg.channel?.name ?? undefined,
530
+ } : undefined;
531
+ const results = await executeDiscordActions(parsed.actions, actCtx, params.log, {
532
+ taskCtx: params.taskCtx,
533
+ cronCtx: params.cronCtx,
534
+ forgeCtx: params.forgeCtx,
535
+ planCtx: params.planCtx,
536
+ memoryCtx: perEventMemoryCtx,
537
+ configCtx: params.configCtx,
538
+ });
539
+ actionResults = results;
540
+ for (const result of results) {
541
+ metrics.recordActionResult(result.ok);
542
+ params.log?.info({ flow: 'reaction', sessionKey, ok: result.ok }, 'obs.action.result');
543
+ }
544
+ const displayLines = buildDisplayResultLines(parsed.actions, results);
545
+ const anyActionSucceeded = results.some((r) => r.ok);
546
+ processedText = displayLines.length > 0
547
+ ? parsed.cleanText.trimEnd() + '\n\n' + displayLines.join('\n')
548
+ : parsed.cleanText.trimEnd();
549
+ // When all display lines were suppressed (e.g. sendMessage-only) and there's
550
+ // no prose, delete the placeholder instead of posting "(no output)".
551
+ if (!processedText.trim()
552
+ && anyActionSucceeded
553
+ && collectedImages.length === 0
554
+ && strippedUnrecognizedTypes.length === 0) {
555
+ try {
556
+ await reply?.delete();
557
+ }
558
+ catch { /* ignore */ }
559
+ replyFinalized = true;
560
+ params.log?.info({ sessionKey }, `${logPrefix}:reply suppressed (actions-only, no display text)`);
561
+ return;
562
+ }
563
+ if (statusRef?.current) {
564
+ for (let i = 0; i < results.length; i++) {
565
+ if (!results[i].ok) {
566
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
567
+ statusRef.current.actionFailed(parsed.actions[i].type, results[i].error);
568
+ }
569
+ }
570
+ }
571
+ }
572
+ else {
573
+ processedText = parsed.cleanText;
574
+ }
575
+ }
576
+ processedText = appendUnavailableActionTypesNotice(processedText, strippedUnrecognizedTypes);
577
+ // Suppress empty responses and the HEARTBEAT_OK sentinel — delete placeholder and bail.
578
+ const strippedText = processedText.replace(/\s+/g, ' ').trim();
579
+ const isSuppressible = strippedText.length === 0 || strippedText === 'HEARTBEAT_OK' || strippedText === '(no output)';
580
+ if (parsedActionCount === 0 && collectedImages.length === 0 && isSuppressible) {
581
+ params.log?.info({ sessionKey, chars: strippedText.length }, `${logPrefix}:trivial response suppressed`);
582
+ try {
583
+ await reply?.delete();
584
+ replyFinalized = true;
585
+ }
586
+ catch (delErr) {
587
+ params.log?.warn({ sessionKey, err: delErr }, `${logPrefix}:placeholder delete failed`);
588
+ }
589
+ return;
590
+ }
591
+ if (!isShuttingDown()) {
592
+ try {
593
+ await editThenSendChunks(reply, msg.channel, processedText, collectedImages);
594
+ replyFinalized = true;
595
+ }
596
+ catch (editErr) {
597
+ if (editErr?.code === 50083) {
598
+ params.log?.info({ sessionKey }, `${logPrefix}:reply skipped (thread archived by action)`);
599
+ try {
600
+ await reply?.delete();
601
+ }
602
+ catch { /* best-effort cleanup */ }
603
+ replyFinalized = true;
604
+ }
605
+ else {
606
+ throw editErr;
607
+ }
608
+ }
609
+ }
610
+ else {
611
+ replyFinalized = true;
612
+ }
613
+ // -- auto-follow-up check --
614
+ if (followUpDepth >= params.actionFollowupDepth)
615
+ break;
616
+ if (parsedActions.length === 0)
617
+ break;
618
+ if (!hasQueryAction(parsedActions.map((a) => a.type)))
619
+ break;
620
+ const anyQuerySucceeded = parsedActions.some((a, i) => QUERY_ACTION_TYPES.has(a.type) && actionResults[i]?.ok);
621
+ if (!anyQuerySucceeded)
622
+ break;
623
+ // Build follow-up prompt with action results.
624
+ const followUpLines = buildAllResultLines(actionResults);
625
+ currentPrompt =
626
+ `[Auto-follow-up] Your previous response included Discord actions. Here are the results:\n\n` +
627
+ followUpLines.join('\n') +
628
+ `\n\nContinue your analysis based on these results. If you need additional information, you may emit further query actions.`;
629
+ followUpDepth++;
630
+ } // end while (true)
631
+ }
632
+ catch (innerErr) {
633
+ // Inner catch: attempt to show the error in the reply before the finally
634
+ // block runs dispose(). Setting replyFinalized = true on success prevents
635
+ // the finally's safety-net delete from removing the error message.
636
+ try {
637
+ if (reply && !isShuttingDown()) {
638
+ await reply.edit({
639
+ content: mapRuntimeErrorToUserMessage(String(innerErr)),
640
+ allowedMentions: NO_MENTIONS,
641
+ });
642
+ replyFinalized = true;
643
+ }
644
+ }
645
+ catch {
646
+ // Ignore secondary errors; outer catch will handle logging.
647
+ }
648
+ throw innerErr;
649
+ }
650
+ finally {
651
+ // Safety net runs before dispose() so cold-start recovery can still see
652
+ // the in-flight entry if the delete fails.
653
+ if (!replyFinalized && reply && !isShuttingDown()) {
654
+ try {
655
+ await reply.delete();
656
+ }
657
+ catch { /* best-effort */ }
658
+ }
659
+ dispose();
660
+ }
661
+ }
662
+ catch (err) {
663
+ metrics.increment(handlerErrorMetric);
664
+ params.log?.error({ err, sessionKey }, `${logPrefix}:handler failed`);
665
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
666
+ statusRef?.current?.handlerError({ sessionKey }, err);
667
+ try {
668
+ if (reply && !isShuttingDown()) {
669
+ await reply.edit({
670
+ content: mapRuntimeErrorToUserMessage(String(err)),
671
+ allowedMentions: NO_MENTIONS,
672
+ });
673
+ }
674
+ }
675
+ catch { /* ignore secondary Discord errors */ }
676
+ }
677
+ });
678
+ }
679
+ catch (err) {
680
+ const metrics = params.metrics ?? globalMetrics;
681
+ metrics.increment(wrapperErrorMetric);
682
+ params.log?.error({ err }, `${logPrefix}:${eventLabel} failed`);
683
+ }
684
+ };
685
+ }
686
+ export function createReactionAddHandler(params, queue, statusRef) {
687
+ return createReactionHandler('add', params, queue, statusRef);
688
+ }
689
+ export function createReactionRemoveHandler(params, queue, statusRef) {
690
+ return createReactionHandler('remove', params, queue, statusRef);
691
+ }