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,100 @@
1
+ import { resolveModel } from '../runtime/model-tiers.js';
2
+ // ---------------------------------------------------------------------------
3
+ // Purpose classification (AI)
4
+ // ---------------------------------------------------------------------------
5
+ export async function autoTagCron(runtime, name, prompt, availableTags, opts) {
6
+ if (availableTags.length === 0)
7
+ return [];
8
+ const tagList = availableTags.join(', ');
9
+ const classifyPrompt = `Classify this scheduled task into 1-3 tags from the following list. ` +
10
+ `Reply with ONLY comma-separated tag names, nothing else.\n\n` +
11
+ `Available tags: ${tagList}\n\n` +
12
+ `Rules:\n` +
13
+ `- reporting: generates reports, summaries, digests\n` +
14
+ `- monitoring: health checks, alerts, status polling\n` +
15
+ `- cleanup: deletes old data, archives, purges\n` +
16
+ `- notifications: sends reminders, alerts, announcements\n` +
17
+ `- sync: data synchronization, imports, exports\n` +
18
+ `- backup: backups, snapshots, data preservation\n` +
19
+ `- maintenance: updates, migrations, housekeeping\n` +
20
+ `- analytics: metrics, tracking, dashboards\n\n` +
21
+ `Job name: ${name}\n` +
22
+ `Instruction: ${prompt.slice(0, 500)}`;
23
+ let finalText = '';
24
+ let deltaText = '';
25
+ for await (const evt of runtime.invoke({
26
+ prompt: classifyPrompt,
27
+ model: resolveModel(opts?.model ?? 'fast', runtime.id),
28
+ cwd: opts?.cwd ?? '.',
29
+ timeoutMs: opts?.timeoutMs ?? 15_000,
30
+ tools: [],
31
+ })) {
32
+ if (evt.type === 'text_final') {
33
+ finalText = evt.text;
34
+ }
35
+ else if (evt.type === 'text_delta') {
36
+ deltaText += evt.text;
37
+ }
38
+ else if (evt.type === 'error') {
39
+ return [];
40
+ }
41
+ }
42
+ const output = (finalText || deltaText).trim();
43
+ if (!output)
44
+ return [];
45
+ const tagSet = new Set(availableTags.map((t) => t.toLowerCase()));
46
+ const candidates = output.split(/[,\n]+/).map((t) => t.trim()).filter(Boolean);
47
+ const result = [];
48
+ for (const candidate of candidates) {
49
+ const match = availableTags.find((t) => t.toLowerCase() === candidate.toLowerCase());
50
+ if (match && tagSet.has(candidate.toLowerCase())) {
51
+ result.push(match);
52
+ }
53
+ if (result.length >= 3)
54
+ break;
55
+ }
56
+ return result;
57
+ }
58
+ // ---------------------------------------------------------------------------
59
+ // Model tier classification
60
+ // ---------------------------------------------------------------------------
61
+ /**
62
+ * Classify whether a cron job needs capable-tier or can run on fast.
63
+ *
64
+ * Two-step logic:
65
+ * 1. Cadence default: frequent/hourly (>1x/day) → fast immediately (cost optimization).
66
+ * 2. AI classification for daily+ crons: ask fast-tier model to decide.
67
+ */
68
+ export async function classifyCronModel(runtime, name, prompt, cadence, opts) {
69
+ // High-frequency crons default to fast — skip AI call for cost.
70
+ if (cadence === 'frequent' || cadence === 'hourly') {
71
+ return 'fast';
72
+ }
73
+ const classifyPrompt = `Does this scheduled task require advanced reasoning (complex analysis, ` +
74
+ `multi-step planning, nuanced writing) or can it be handled with basic ` +
75
+ `capabilities (simple lookups, templated responses, data formatting)?\n\n` +
76
+ `Reply with ONLY one word: "capable" or "fast"\n\n` +
77
+ `Job name: ${name}\n` +
78
+ `Instruction: ${prompt.slice(0, 500)}`;
79
+ let finalText = '';
80
+ let deltaText = '';
81
+ for await (const evt of runtime.invoke({
82
+ prompt: classifyPrompt,
83
+ model: resolveModel(opts?.model ?? 'fast', runtime.id),
84
+ cwd: opts?.cwd ?? '.',
85
+ timeoutMs: opts?.timeoutMs ?? 15_000,
86
+ tools: [],
87
+ })) {
88
+ if (evt.type === 'text_final') {
89
+ finalText = evt.text;
90
+ }
91
+ else if (evt.type === 'text_delta') {
92
+ deltaText += evt.text;
93
+ }
94
+ else if (evt.type === 'error') {
95
+ return 'fast';
96
+ }
97
+ }
98
+ const output = (finalText || deltaText).trim().toLowerCase();
99
+ return output === 'capable' ? 'capable' : 'fast';
100
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { autoTagCron, classifyCronModel } from './auto-tag.js';
3
+ function makeMockRuntime(output) {
4
+ return {
5
+ id: 'other',
6
+ capabilities: new Set(),
7
+ async *invoke() {
8
+ yield { type: 'text_final', text: output };
9
+ },
10
+ };
11
+ }
12
+ function makeMockErrorRuntime() {
13
+ return {
14
+ id: 'other',
15
+ capabilities: new Set(),
16
+ async *invoke() {
17
+ yield { type: 'error', message: 'fail' };
18
+ },
19
+ };
20
+ }
21
+ const TAGS = ['reporting', 'monitoring', 'cleanup', 'notifications', 'sync', 'backup', 'maintenance', 'analytics'];
22
+ describe('autoTagCron', () => {
23
+ it('returns valid tags from AI output', async () => {
24
+ const runtime = makeMockRuntime('monitoring, cleanup');
25
+ const result = await autoTagCron(runtime, 'Daily Cleanup', 'Remove old logs', TAGS);
26
+ expect(result).toEqual(['monitoring', 'cleanup']);
27
+ });
28
+ it('drops unknown tags', async () => {
29
+ const runtime = makeMockRuntime('monitoring, unknown, backup');
30
+ const result = await autoTagCron(runtime, 'Backup Job', 'Backup database', TAGS);
31
+ expect(result).toEqual(['monitoring', 'backup']);
32
+ });
33
+ it('limits to 3 tags', async () => {
34
+ const runtime = makeMockRuntime('monitoring, cleanup, backup, sync, analytics');
35
+ const result = await autoTagCron(runtime, 'Big Job', 'Do many things', TAGS);
36
+ expect(result).toHaveLength(3);
37
+ });
38
+ it('handles case-insensitive matching', async () => {
39
+ const runtime = makeMockRuntime('Monitoring, CLEANUP');
40
+ const result = await autoTagCron(runtime, 'Test', 'Test', TAGS);
41
+ expect(result).toEqual(['monitoring', 'cleanup']);
42
+ });
43
+ it('returns empty on error', async () => {
44
+ const result = await autoTagCron(makeMockErrorRuntime(), 'Test', 'Test', TAGS);
45
+ expect(result).toEqual([]);
46
+ });
47
+ it('returns empty with no available tags', async () => {
48
+ const result = await autoTagCron(makeMockRuntime('monitoring'), 'Test', 'Test', []);
49
+ expect(result).toEqual([]);
50
+ });
51
+ it('returns empty for empty AI output', async () => {
52
+ const result = await autoTagCron(makeMockRuntime(''), 'Test', 'Test', TAGS);
53
+ expect(result).toEqual([]);
54
+ });
55
+ });
56
+ describe('classifyCronModel', () => {
57
+ it('returns fast for frequent cadence without AI call', async () => {
58
+ // Even if runtime would say capable, frequent → fast.
59
+ const runtime = makeMockRuntime('capable');
60
+ const result = await classifyCronModel(runtime, 'Check', 'Check health', 'frequent');
61
+ expect(result).toBe('fast');
62
+ });
63
+ it('returns fast for hourly cadence without AI call', async () => {
64
+ const runtime = makeMockRuntime('capable');
65
+ const result = await classifyCronModel(runtime, 'Check', 'Check health', 'hourly');
66
+ expect(result).toBe('fast');
67
+ });
68
+ it('returns capable when AI says capable for daily cron', async () => {
69
+ const runtime = makeMockRuntime('capable');
70
+ const result = await classifyCronModel(runtime, 'Report', 'Write detailed analysis report', 'daily');
71
+ expect(result).toBe('capable');
72
+ });
73
+ it('returns fast when AI says fast for daily cron', async () => {
74
+ const runtime = makeMockRuntime('fast');
75
+ const result = await classifyCronModel(runtime, 'Ping', 'Check if server is alive', 'daily');
76
+ expect(result).toBe('fast');
77
+ });
78
+ it('defaults to fast on unclear AI response', async () => {
79
+ const runtime = makeMockRuntime('maybe something');
80
+ const result = await classifyCronModel(runtime, 'Test', 'Test', 'weekly');
81
+ expect(result).toBe('fast');
82
+ });
83
+ it('defaults to fast on error', async () => {
84
+ const result = await classifyCronModel(makeMockErrorRuntime(), 'Test', 'Test', 'daily');
85
+ expect(result).toBe('fast');
86
+ });
87
+ it('defaults to fast on empty response', async () => {
88
+ const result = await classifyCronModel(makeMockRuntime(''), 'Test', 'Test', 'monthly');
89
+ expect(result).toBe('fast');
90
+ });
91
+ });
@@ -0,0 +1,74 @@
1
+ import { Cron } from 'croner';
2
+ /**
3
+ * Count distinct values in a cron field like "1", "1,7", "3-6", "1-3,7".
4
+ * Returns Infinity for wildcards/steps (unbounded).
5
+ */
6
+ function countFieldValues(field) {
7
+ if (field === '*' || /^\*\/\d+$/.test(field))
8
+ return Infinity;
9
+ let count = 0;
10
+ for (const part of field.split(',')) {
11
+ const range = part.split('-');
12
+ if (range.length === 2) {
13
+ const lo = parseInt(range[0], 10);
14
+ const hi = parseInt(range[1], 10);
15
+ if (!isNaN(lo) && !isNaN(hi) && hi >= lo) {
16
+ count += hi - lo + 1;
17
+ }
18
+ else {
19
+ count += 1;
20
+ }
21
+ }
22
+ else {
23
+ count += 1;
24
+ }
25
+ }
26
+ return count;
27
+ }
28
+ /**
29
+ * Detect the cadence of a cron schedule expression.
30
+ *
31
+ * Uses the same 5-field format that croner accepts. Validates the schedule
32
+ * parses via croner before classifying — if it throws, returns 'daily' as fallback.
33
+ *
34
+ * Logic:
35
+ * - single specific month (e.g., "2") → yearly
36
+ * - multi-month (e.g., "1,7" or "3-6") → fall through to normal classification
37
+ * - minute is * or *\/N → frequent (runs multiple times per hour)
38
+ * - minute specific + hour * → hourly
39
+ * - minute+hour specific, dom+dow * → daily
40
+ * - dow not * → weekly
41
+ * - dom not * → monthly
42
+ * - fallback: daily
43
+ */
44
+ export function detectCadence(schedule) {
45
+ // Validate the schedule parses.
46
+ try {
47
+ new Cron(schedule).stop();
48
+ }
49
+ catch {
50
+ return 'daily';
51
+ }
52
+ const parts = schedule.trim().split(/\s+/);
53
+ if (parts.length < 5)
54
+ return 'daily';
55
+ const [minute, hour, dom, month, dow] = parts;
56
+ const isWild = (f) => f === '*';
57
+ const isStep = (f) => /^\*\/\d+$/.test(f);
58
+ // Single specific month → fires once a year (annual schedule).
59
+ // Multi-month patterns (e.g., "1,7" or "3-6") fall through to normal classification.
60
+ if (!isWild(month) && !isStep(month) && countFieldValues(month) === 1)
61
+ return 'yearly';
62
+ // Minute is wildcard or step → runs multiple times per hour → frequent.
63
+ if (isWild(minute) || isStep(minute))
64
+ return 'frequent';
65
+ // Minute specific, hour is wildcard or step → runs every hour.
66
+ if (isWild(hour) || isStep(hour))
67
+ return 'hourly';
68
+ // Minute + hour specific, check day fields.
69
+ if (!isWild(dow))
70
+ return 'weekly';
71
+ if (!isWild(dom))
72
+ return 'monthly';
73
+ return 'daily';
74
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { detectCadence } from './cadence.js';
3
+ describe('detectCadence', () => {
4
+ it('detects frequent for wildcard minute', () => {
5
+ expect(detectCadence('* * * * *')).toBe('frequent');
6
+ });
7
+ it('detects frequent for step minute', () => {
8
+ expect(detectCadence('*/5 * * * *')).toBe('frequent');
9
+ expect(detectCadence('*/15 * * * *')).toBe('frequent');
10
+ });
11
+ it('detects hourly for specific minute + wildcard hour', () => {
12
+ expect(detectCadence('0 * * * *')).toBe('hourly');
13
+ expect(detectCadence('30 * * * *')).toBe('hourly');
14
+ });
15
+ it('detects daily for specific minute + hour', () => {
16
+ expect(detectCadence('0 7 * * *')).toBe('daily');
17
+ expect(detectCadence('30 12 * * *')).toBe('daily');
18
+ });
19
+ it('detects weekly for specific dow', () => {
20
+ expect(detectCadence('0 7 * * 1-5')).toBe('weekly');
21
+ expect(detectCadence('0 9 * * 1')).toBe('weekly');
22
+ expect(detectCadence('0 0 * * 0')).toBe('weekly');
23
+ });
24
+ it('detects monthly for specific dom', () => {
25
+ expect(detectCadence('0 7 1 * *')).toBe('monthly');
26
+ expect(detectCadence('0 0 15 * *')).toBe('monthly');
27
+ });
28
+ it('returns daily for invalid schedule', () => {
29
+ expect(detectCadence('not a cron')).toBe('daily');
30
+ });
31
+ it('returns daily for empty string', () => {
32
+ expect(detectCadence('')).toBe('daily');
33
+ });
34
+ it('detects yearly for single specific month', () => {
35
+ expect(detectCadence('0 6 11 2 *')).toBe('yearly');
36
+ expect(detectCadence('0 0 1 1 *')).toBe('yearly');
37
+ expect(detectCadence('0 12 25 12 *')).toBe('yearly');
38
+ });
39
+ it('falls through for multi-month patterns', () => {
40
+ // "1,7" = Jan + Jul → 2 months → not yearly, classify by other fields.
41
+ expect(detectCadence('0 7 * 1,7 *')).toBe('daily');
42
+ // "3-6" = Mar–Jun → 4 months → not yearly.
43
+ expect(detectCadence('0 7 * 3-6 *')).toBe('daily');
44
+ // Multi-month with specific dom → monthly.
45
+ expect(detectCadence('0 7 1 1,7 *')).toBe('monthly');
46
+ // Multi-month with wildcard minute → frequent.
47
+ expect(detectCadence('* * * 1,2,3 *')).toBe('frequent');
48
+ });
49
+ it('handles complex schedules', () => {
50
+ expect(detectCadence('0 7,19 * * *')).toBe('daily');
51
+ expect(detectCadence('0 7 * * 1,3,5')).toBe('weekly');
52
+ });
53
+ });
@@ -0,0 +1,66 @@
1
+ import { reloadCronTagMapInPlace } from './tag-map.js';
2
+ import { runCronSync } from './cron-sync.js';
3
+ /**
4
+ * Shared cron sync coordinator wrapping runCronSync() with a concurrency guard,
5
+ * tag-map reload, and deterministic snapshotting. Mirrors TaskSyncCoordinator.
6
+ */
7
+ export class CronSyncCoordinator {
8
+ opts;
9
+ syncing = false;
10
+ pendingSync = false;
11
+ constructor(opts) {
12
+ this.opts = opts;
13
+ }
14
+ /** Update the auto-tag model at runtime (called by modelSet propagation). */
15
+ setAutoTagModel(model) {
16
+ this.opts = { ...this.opts, autoTagModel: model };
17
+ }
18
+ /**
19
+ * Run sync with concurrency guard.
20
+ * Returns null when coalesced into a running sync's follow-up.
21
+ */
22
+ async sync() {
23
+ if (this.syncing) {
24
+ this.pendingSync = true;
25
+ return null;
26
+ }
27
+ this.syncing = true;
28
+ try {
29
+ // Reload tag map from disk before sync
30
+ if (this.opts.tagMapPath) {
31
+ try {
32
+ await reloadCronTagMapInPlace(this.opts.tagMapPath, this.opts.tagMap);
33
+ }
34
+ catch (err) {
35
+ this.opts.log?.warn({ err, tagMapPath: this.opts.tagMapPath }, 'cron:coordinator tag-map reload failed; using cached map');
36
+ }
37
+ }
38
+ // Snapshot for deterministic behavior within this sync run
39
+ const tagMapSnapshot = { ...this.opts.tagMap };
40
+ const result = await runCronSync({
41
+ client: this.opts.client,
42
+ forumId: this.opts.forumId,
43
+ scheduler: this.opts.scheduler,
44
+ statsStore: this.opts.statsStore,
45
+ runtime: this.opts.runtime,
46
+ tagMap: tagMapSnapshot,
47
+ autoTag: this.opts.autoTag,
48
+ autoTagModel: this.opts.autoTagModel,
49
+ cwd: this.opts.cwd,
50
+ log: this.opts.log,
51
+ });
52
+ this.opts.forumCountSync?.requestUpdate();
53
+ return result;
54
+ }
55
+ finally {
56
+ this.syncing = false;
57
+ if (this.pendingSync) {
58
+ this.pendingSync = false;
59
+ // Fire-and-forget follow-up for coalesced triggers
60
+ this.sync().catch((err) => {
61
+ this.opts.log?.warn({ err }, 'cron:coordinator follow-up sync failed');
62
+ });
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { CronSyncCoordinator } from './cron-sync-coordinator.js';
3
+ // Mock the dependencies
4
+ vi.mock('./tag-map.js', () => ({
5
+ reloadCronTagMapInPlace: vi.fn(async () => 2),
6
+ }));
7
+ vi.mock('./cron-sync.js', () => ({
8
+ runCronSync: vi.fn(async () => ({
9
+ tagsApplied: 1,
10
+ namesUpdated: 0,
11
+ statusMessagesUpdated: 1,
12
+ orphansDetected: 0,
13
+ })),
14
+ }));
15
+ import { reloadCronTagMapInPlace } from './tag-map.js';
16
+ import { runCronSync } from './cron-sync.js';
17
+ const mockReload = vi.mocked(reloadCronTagMapInPlace);
18
+ const mockRunCronSync = vi.mocked(runCronSync);
19
+ function mockLog() {
20
+ return { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
21
+ }
22
+ function makeOpts(overrides) {
23
+ return {
24
+ client: {},
25
+ forumId: 'forum-1',
26
+ scheduler: {},
27
+ statsStore: {},
28
+ runtime: {},
29
+ tagMap: { monitoring: 'tag-1', daily: 'tag-2' },
30
+ tagMapPath: '/tmp/tags.json',
31
+ autoTag: true,
32
+ autoTagModel: 'haiku',
33
+ cwd: '/tmp',
34
+ log: mockLog(),
35
+ ...overrides,
36
+ };
37
+ }
38
+ describe('CronSyncCoordinator', () => {
39
+ beforeEach(() => {
40
+ vi.resetAllMocks();
41
+ mockReload.mockResolvedValue(2);
42
+ mockRunCronSync.mockResolvedValue({
43
+ tagsApplied: 1, namesUpdated: 0, statusMessagesUpdated: 1, orphansDetected: 0,
44
+ });
45
+ });
46
+ it('reloads tag map before sync when tagMapPath is set', async () => {
47
+ const opts = makeOpts();
48
+ const coordinator = new CronSyncCoordinator(opts);
49
+ await coordinator.sync();
50
+ expect(mockReload).toHaveBeenCalledWith('/tmp/tags.json', opts.tagMap);
51
+ });
52
+ it('falls back to cached map on reload failure', async () => {
53
+ mockReload.mockRejectedValue(new Error('ENOENT'));
54
+ const opts = makeOpts();
55
+ const coordinator = new CronSyncCoordinator(opts);
56
+ const result = await coordinator.sync();
57
+ expect(result).not.toBeNull();
58
+ expect(opts.log?.warn).toHaveBeenCalled();
59
+ expect(mockRunCronSync).toHaveBeenCalled();
60
+ });
61
+ it('passes snapshot to runCronSync (same values, different ref)', async () => {
62
+ const opts = makeOpts();
63
+ const coordinator = new CronSyncCoordinator(opts);
64
+ await coordinator.sync();
65
+ const callArgs = mockRunCronSync.mock.calls[0][0];
66
+ expect(callArgs.tagMap).toEqual(opts.tagMap);
67
+ expect(callArgs.tagMap).not.toBe(opts.tagMap);
68
+ });
69
+ it('coalesced concurrent sync returns null', async () => {
70
+ let resolveSync;
71
+ mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
72
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
73
+ }));
74
+ const coordinator = new CronSyncCoordinator(makeOpts());
75
+ const first = coordinator.sync();
76
+ const second = coordinator.sync();
77
+ expect(await second).toBeNull();
78
+ resolveSync();
79
+ const firstResult = await first;
80
+ expect(firstResult).not.toBeNull();
81
+ });
82
+ it('fires follow-up after coalesced sync', async () => {
83
+ let resolveSync;
84
+ let callCount = 0;
85
+ mockRunCronSync.mockImplementation(() => new Promise((resolve) => {
86
+ callCount++;
87
+ if (callCount === 1) {
88
+ resolveSync = () => resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
89
+ }
90
+ else {
91
+ resolve({ tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 });
92
+ }
93
+ }));
94
+ const coordinator = new CronSyncCoordinator(makeOpts());
95
+ const first = coordinator.sync();
96
+ // Let the reload await resolve so runCronSync gets called and resolveSync is assigned
97
+ await new Promise((r) => setTimeout(r, 10));
98
+ coordinator.sync(); // coalesced
99
+ resolveSync();
100
+ await first;
101
+ // Wait for follow-up fire-and-forget
102
+ await new Promise((r) => setTimeout(r, 50));
103
+ expect(mockRunCronSync).toHaveBeenCalledTimes(2);
104
+ });
105
+ it('calls forumCountSync.requestUpdate on success', async () => {
106
+ const forumCountSync = { requestUpdate: vi.fn(), stop: vi.fn() };
107
+ const coordinator = new CronSyncCoordinator(makeOpts({ forumCountSync: forumCountSync }));
108
+ await coordinator.sync();
109
+ expect(forumCountSync.requestUpdate).toHaveBeenCalled();
110
+ });
111
+ it('setAutoTagModel updates the model used by subsequent syncs', async () => {
112
+ const coordinator = new CronSyncCoordinator(makeOpts({ autoTagModel: 'haiku' }));
113
+ coordinator.setAutoTagModel('opus');
114
+ await coordinator.sync();
115
+ const callArgs = mockRunCronSync.mock.calls[0][0];
116
+ expect(callArgs.autoTagModel).toBe('opus');
117
+ });
118
+ });
@@ -0,0 +1,165 @@
1
+ import { CADENCE_TAGS } from './run-stats.js';
2
+ import { detectCadence } from './cadence.js';
3
+ import { autoTagCron, classifyCronModel } from './auto-tag.js';
4
+ import { buildCronThreadName, ensureStatusMessage, resolveForumChannel } from './discord-sync.js';
5
+ // ---------------------------------------------------------------------------
6
+ // Helpers
7
+ // ---------------------------------------------------------------------------
8
+ async function sleep(ms) {
9
+ const n = ms ?? 0;
10
+ if (n <= 0)
11
+ return;
12
+ await new Promise((r) => setTimeout(r, n));
13
+ }
14
+ function purposeTagNames(tagMap) {
15
+ const cadenceSet = new Set(CADENCE_TAGS);
16
+ return Object.keys(tagMap).filter((k) => !cadenceSet.has(k));
17
+ }
18
+ // ---------------------------------------------------------------------------
19
+ // 4-phase sync
20
+ // ---------------------------------------------------------------------------
21
+ export async function runCronSync(opts) {
22
+ const { client, forumId, scheduler, statsStore, runtime, autoTag, autoTagModel, cwd, log } = opts;
23
+ const throttleMs = opts.throttleMs ?? 250;
24
+ const forum = await resolveForumChannel(client, forumId);
25
+ if (!forum) {
26
+ log?.warn({ forumId }, 'cron-sync: forum not found');
27
+ return { tagsApplied: 0, namesUpdated: 0, statusMessagesUpdated: 0, orphansDetected: 0 };
28
+ }
29
+ const tagMap = opts.tagMap;
30
+ const purposeTags = purposeTagNames(tagMap);
31
+ let tagsApplied = 0;
32
+ let namesUpdated = 0;
33
+ let statusMessagesUpdated = 0;
34
+ let orphansDetected = 0;
35
+ // Get all active threads in the forum.
36
+ let threads = new Map();
37
+ try {
38
+ const fetched = await forum.threads.fetchActive();
39
+ threads = fetched.threads;
40
+ }
41
+ catch (err) {
42
+ log?.warn({ err, forumId }, 'cron-sync: failed to fetch active threads; continuing with metadata/status phases only');
43
+ }
44
+ // Get all registered jobs.
45
+ const jobs = scheduler.listJobs();
46
+ const jobThreadIds = new Set(jobs.map((j) => j.id));
47
+ // Phase 1: Tag + model sync.
48
+ for (const job of jobs) {
49
+ const fullJob = scheduler.getJob(job.id);
50
+ if (!fullJob)
51
+ continue;
52
+ const record = statsStore.getRecordByThreadId(fullJob.threadId);
53
+ if (!record)
54
+ continue;
55
+ const needsCadence = !record.cadence;
56
+ const needsTags = autoTag && record.purposeTags.length === 0 && purposeTags.length > 0;
57
+ const needsModel = !record.model;
58
+ const needsMetadataUpdate = needsCadence || needsTags || needsModel;
59
+ try {
60
+ const updates = {};
61
+ if (needsMetadataUpdate) {
62
+ if (needsCadence) {
63
+ const cadence = fullJob.def.schedule ? detectCadence(fullJob.def.schedule) : null;
64
+ updates.cadence = cadence;
65
+ }
66
+ if (needsTags) {
67
+ const classified = await autoTagCron(runtime, fullJob.name, fullJob.def.prompt, purposeTags, { model: autoTagModel, cwd });
68
+ if (classified.length > 0)
69
+ updates.purposeTags = classified;
70
+ }
71
+ if (needsModel) {
72
+ const cadence = updates.cadence ?? record.cadence ?? (fullJob.def.schedule ? detectCadence(fullJob.def.schedule) : null);
73
+ if (cadence !== null) {
74
+ const model = await classifyCronModel(runtime, fullJob.name, fullJob.def.prompt, cadence, { model: autoTagModel, cwd });
75
+ updates.model = model;
76
+ }
77
+ }
78
+ await statsStore.upsertRecord(record.cronId, record.threadId, updates);
79
+ }
80
+ // Apply tags to Discord thread.
81
+ const thread = threads.get(fullJob.threadId);
82
+ if (thread) {
83
+ const desiredPurposeTags = updates.purposeTags ?? record.purposeTags;
84
+ const desiredCadence = updates.cadence ?? record.cadence;
85
+ const allTags = [
86
+ ...desiredPurposeTags,
87
+ ];
88
+ if (desiredCadence)
89
+ allTags.push(desiredCadence);
90
+ const desiredTagIds = allTags
91
+ .map((t) => tagMap[t])
92
+ .filter((id) => Boolean(id));
93
+ const uniqueTagIds = [...new Set(desiredTagIds)].slice(0, 5);
94
+ const currentTagIds = Array.isArray(thread.appliedTags)
95
+ ? thread.appliedTags.filter((id) => typeof id === 'string')
96
+ : [];
97
+ const desiredSet = new Set(uniqueTagIds);
98
+ const tagsOutOfSync = currentTagIds.length !== uniqueTagIds.length
99
+ || currentTagIds.some((id) => !desiredSet.has(id));
100
+ if (tagsOutOfSync) {
101
+ try {
102
+ await thread.edit({ appliedTags: uniqueTagIds });
103
+ tagsApplied++;
104
+ }
105
+ catch (err) {
106
+ log?.warn({ err, threadId: fullJob.threadId }, 'cron-sync:phase1 tag apply failed');
107
+ }
108
+ }
109
+ }
110
+ }
111
+ catch (err) {
112
+ log?.warn({ err, jobId: job.id }, 'cron-sync:phase1 failed');
113
+ }
114
+ await sleep(throttleMs);
115
+ }
116
+ // Phase 2: Name sync.
117
+ for (const job of jobs) {
118
+ const fullJob = scheduler.getJob(job.id);
119
+ if (!fullJob)
120
+ continue;
121
+ const record = statsStore.getRecordByThreadId(fullJob.threadId);
122
+ const cadence = record?.cadence ?? null;
123
+ const expectedName = buildCronThreadName(fullJob.name, cadence);
124
+ const thread = threads.get(fullJob.threadId);
125
+ if (thread && thread.name !== expectedName) {
126
+ try {
127
+ await thread.setName(expectedName);
128
+ namesUpdated++;
129
+ log?.info({ threadId: fullJob.threadId, oldName: thread.name, newName: expectedName }, 'cron-sync:phase2 name updated');
130
+ }
131
+ catch (err) {
132
+ log?.warn({ err, threadId: fullJob.threadId }, 'cron-sync:phase2 name update failed');
133
+ }
134
+ await sleep(throttleMs);
135
+ }
136
+ }
137
+ // Phase 3: Status message sync.
138
+ for (const job of jobs) {
139
+ const fullJob = scheduler.getJob(job.id);
140
+ if (!fullJob?.cronId)
141
+ continue;
142
+ const record = statsStore.getRecord(fullJob.cronId);
143
+ if (!record)
144
+ continue;
145
+ try {
146
+ await ensureStatusMessage(client, fullJob.threadId, fullJob.cronId, record, statsStore, { log });
147
+ statusMessagesUpdated++;
148
+ }
149
+ catch (err) {
150
+ log?.warn({ err, jobId: job.id }, 'cron-sync:phase3 status message failed');
151
+ }
152
+ await sleep(throttleMs);
153
+ }
154
+ // Phase 4: Orphan detection (non-destructive, log only).
155
+ for (const thread of threads.values()) {
156
+ if (thread.parentId !== forumId)
157
+ continue;
158
+ if (!jobThreadIds.has(thread.id)) {
159
+ orphansDetected++;
160
+ log?.warn({ threadId: thread.id, name: thread.name }, 'cron-sync:phase4 orphan thread (no registered job)');
161
+ }
162
+ }
163
+ log?.info({ tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected }, 'cron-sync: complete');
164
+ return { tagsApplied, namesUpdated, statusMessagesUpdated, orphansDetected };
165
+ }