@ouro.bot/cli 0.1.0-alpha.85 → 0.1.0-alpha.851

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 (650) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -23
  3. package/RepairGuide.ouro/agent.json +5 -0
  4. package/RepairGuide.ouro/psyche/IDENTITY.md +19 -0
  5. package/RepairGuide.ouro/psyche/SOUL.md +55 -0
  6. package/RepairGuide.ouro/skills/diagnose-broken-remote.md +63 -0
  7. package/RepairGuide.ouro/skills/diagnose-stacked-typed-issues.md +35 -0
  8. package/RepairGuide.ouro/skills/diagnose-sync-blocked.md +54 -0
  9. package/RepairGuide.ouro/skills/diagnose-vault-expired.md +60 -0
  10. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +4 -2
  11. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +2 -2
  12. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +1 -1
  13. package/assets/bluebubbles-host +264 -0
  14. package/assets/sanctuary-host-launcher.sh +16 -0
  15. package/changelog.json +5434 -16
  16. package/deploy/unraid/Dockerfile +58 -0
  17. package/deploy/unraid/README.txt +2983 -0
  18. package/deploy/unraid/audit-container-spec.sh +4 -0
  19. package/deploy/unraid/container-runtime.json +1 -0
  20. package/deploy/unraid/docker-man-template-transaction.mjs +711 -0
  21. package/deploy/unraid/docker-man-template-xml.cjs +105 -0
  22. package/deploy/unraid/migrate-sanctuary-bundle.mjs +60 -0
  23. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  24. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  25. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  26. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  27. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  28. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  29. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  30. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  31. package/deploy/unraid/sanctuary-authority-installation.json +36 -0
  32. package/deploy/unraid/sanctuary-authority-service.sh +30 -0
  33. package/deploy/unraid/sanctuary-butler-upgrade.mjs +933 -0
  34. package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
  35. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  36. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2084 -0
  37. package/deploy/unraid/sanctuary-unit16-run.sh +560 -0
  38. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  39. package/deploy/unraid/sanctuary.ouro/agent.json +66 -0
  40. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  41. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  42. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  43. package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +1364 -0
  44. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  45. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  46. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +5 -0
  47. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +15 -0
  48. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +25 -0
  49. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +18 -0
  50. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  51. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +220 -0
  52. package/deploy/unraid/sanctuary.xml +28 -0
  53. package/dist/a2a/card.js +57 -0
  54. package/dist/a2a/client.js +237 -0
  55. package/dist/a2a/config.js +50 -0
  56. package/dist/a2a/delegated-command.js +85 -0
  57. package/dist/a2a/delegation-stores.js +56 -0
  58. package/dist/a2a/did-resolution.js +93 -0
  59. package/dist/a2a/identity.js +204 -0
  60. package/dist/a2a/inbound-share.js +110 -0
  61. package/dist/a2a/mission-result-wire.js +196 -0
  62. package/dist/a2a/onboarding.js +133 -0
  63. package/dist/a2a/pin-store.js +132 -0
  64. package/dist/a2a/sealed-chat.js +113 -0
  65. package/dist/a2a/seen-ledger.js +120 -0
  66. package/dist/a2a/server.js +700 -0
  67. package/dist/a2a/task-store.js +69 -0
  68. package/dist/a2a/transport.js +45 -0
  69. package/dist/a2a/types.js +3 -0
  70. package/dist/arc/attention-types.js +8 -0
  71. package/dist/arc/cares.js +360 -0
  72. package/dist/arc/episodes.js +118 -0
  73. package/dist/arc/evolution.js +490 -0
  74. package/dist/arc/flight-recorder.js +1034 -0
  75. package/dist/arc/intentions.js +134 -0
  76. package/dist/arc/json-store.js +117 -0
  77. package/dist/arc/obligations.js +386 -0
  78. package/dist/arc/packets.js +336 -0
  79. package/dist/arc/presence.js +185 -0
  80. package/dist/arc/task-lifecycle.js +57 -0
  81. package/dist/commerce/store.js +755 -0
  82. package/dist/commerce/types.js +3 -0
  83. package/dist/heart/active-work.js +798 -40
  84. package/dist/heart/agent-entry.js +252 -3
  85. package/dist/heart/approval-files.js +113 -0
  86. package/dist/heart/approval-store.js +960 -0
  87. package/dist/heart/attachments/image-normalize.js +194 -0
  88. package/dist/heart/attachments/materialize.js +97 -0
  89. package/dist/heart/attachments/originals.js +88 -0
  90. package/dist/heart/attachments/render.js +29 -0
  91. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  92. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  93. package/dist/heart/attachments/sources/index.js +18 -0
  94. package/dist/heart/attachments/sources/telegram.js +71 -0
  95. package/dist/heart/attachments/store.js +132 -0
  96. package/dist/heart/attachments/types.js +93 -0
  97. package/dist/heart/auth/auth-flow.js +495 -0
  98. package/dist/heart/autonomy-budget.js +478 -0
  99. package/dist/heart/awaiting/await-alert.js +148 -0
  100. package/dist/heart/awaiting/await-expiry.js +143 -0
  101. package/dist/heart/awaiting/await-loader.js +91 -0
  102. package/dist/heart/awaiting/await-parser.js +149 -0
  103. package/dist/heart/awaiting/await-runtime-state.js +111 -0
  104. package/dist/heart/awaiting/await-scheduler.js +382 -0
  105. package/dist/heart/background-operations.js +281 -0
  106. package/dist/heart/bridges/manager.js +137 -17
  107. package/dist/heart/bridges/store.js +14 -2
  108. package/dist/heart/bundle-state.js +168 -0
  109. package/dist/heart/commitments.js +44 -18
  110. package/dist/heart/config-registry.js +331 -0
  111. package/dist/heart/config.js +174 -131
  112. package/dist/heart/context-loss-gauntlet.js +387 -0
  113. package/dist/heart/context-loss-sentinel.js +1157 -0
  114. package/dist/heart/core.js +2199 -581
  115. package/dist/heart/cross-chat-delivery.js +9 -22
  116. package/dist/heart/daemon/agent-config-check.js +451 -0
  117. package/dist/heart/daemon/agent-discovery.js +185 -3
  118. package/dist/heart/daemon/agent-service.js +542 -0
  119. package/dist/heart/daemon/agentic-repair.js +547 -0
  120. package/dist/heart/daemon/await-private-wake.js +59 -0
  121. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  122. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  123. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  124. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  125. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  126. package/dist/heart/daemon/cadence.js +180 -0
  127. package/dist/heart/daemon/cli-defaults.js +854 -0
  128. package/dist/heart/daemon/cli-desk.js +322 -0
  129. package/dist/heart/daemon/cli-exec.js +8897 -0
  130. package/dist/heart/daemon/cli-help.js +716 -0
  131. package/dist/heart/daemon/cli-parse.js +2877 -0
  132. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  133. package/dist/heart/daemon/cli-render.js +777 -0
  134. package/dist/heart/daemon/cli-types.js +8 -0
  135. package/dist/heart/daemon/connect-bay.js +323 -0
  136. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  137. package/dist/heart/daemon/container-healthcheck.js +112 -0
  138. package/dist/heart/daemon/container-runtime.js +97 -0
  139. package/dist/heart/daemon/container-spec-auditor-main.js +171 -0
  140. package/dist/heart/daemon/container-spec-auditor.js +229 -0
  141. package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
  142. package/dist/heart/daemon/daemon-cli.js +29 -2349
  143. package/dist/heart/daemon/daemon-entry.js +930 -11
  144. package/dist/heart/daemon/daemon-health.js +186 -0
  145. package/dist/heart/daemon/daemon-rollup.js +57 -0
  146. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  147. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  148. package/dist/heart/daemon/daemon.js +2084 -105
  149. package/dist/heart/daemon/dns-workflow.js +394 -0
  150. package/dist/heart/daemon/doctor-types.js +8 -0
  151. package/dist/heart/daemon/doctor.js +1755 -0
  152. package/dist/heart/daemon/freshness.js +345 -0
  153. package/dist/heart/daemon/habit-private-wake.js +61 -0
  154. package/dist/heart/daemon/health-monitor.js +142 -11
  155. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  156. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  157. package/dist/heart/daemon/http-health-probe.js +80 -0
  158. package/dist/heart/daemon/human-command-screens.js +234 -0
  159. package/dist/heart/daemon/human-readiness.js +114 -0
  160. package/dist/heart/daemon/inner-status.js +78 -0
  161. package/dist/heart/daemon/interactive-repair.js +394 -0
  162. package/dist/heart/daemon/launchd.js +37 -8
  163. package/dist/heart/daemon/log-tailer.js +79 -10
  164. package/dist/heart/daemon/logs-prune.js +132 -0
  165. package/dist/heart/daemon/mcp-canary.js +524 -0
  166. package/dist/heart/daemon/message-router.js +65 -5
  167. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  168. package/dist/heart/daemon/os-cron-deps.js +135 -0
  169. package/dist/heart/daemon/os-cron.js +22 -14
  170. package/dist/heart/daemon/ouro-bot-entry.js +4 -2
  171. package/dist/heart/daemon/ouro-entry.js +3 -1
  172. package/dist/heart/daemon/plugin-cli.js +432 -0
  173. package/dist/heart/daemon/process-manager.js +733 -61
  174. package/dist/heart/daemon/provider-discovery.js +137 -0
  175. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  176. package/dist/heart/daemon/prunable-bundle.js +144 -0
  177. package/dist/heart/daemon/pulse.js +511 -0
  178. package/dist/heart/daemon/readiness-repair.js +365 -0
  179. package/dist/heart/daemon/run-hooks.js +2 -0
  180. package/dist/heart/daemon/runtime-logging.js +47 -14
  181. package/dist/heart/daemon/runtime-metadata.js +2 -30
  182. package/dist/heart/daemon/runtime-mode.js +13 -2
  183. package/dist/heart/daemon/safe-mode.js +161 -0
  184. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2654 -0
  185. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1830 -0
  186. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  187. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +955 -0
  188. package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
  189. package/dist/heart/daemon/sanctuary-authority-epoch.js +281 -0
  190. package/dist/heart/daemon/sanctuary-authority-installation.js +161 -0
  191. package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
  192. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +1165 -0
  193. package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
  194. package/dist/heart/daemon/sanctuary-bundle-migration.js +729 -0
  195. package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
  196. package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +493 -0
  197. package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
  198. package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
  199. package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
  200. package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
  201. package/dist/heart/daemon/sanctuary-package-management.js +103 -0
  202. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  203. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  204. package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +445 -0
  205. package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
  206. package/dist/heart/daemon/sanctuary-telegram-authority-service.js +663 -0
  207. package/dist/heart/daemon/sense-manager.js +699 -48
  208. package/dist/heart/daemon/session-id-resolver.js +131 -0
  209. package/dist/heart/daemon/skill-management-installer.js +1 -1
  210. package/dist/heart/daemon/socket-client.js +180 -12
  211. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  212. package/dist/heart/daemon/startup-tui.js +339 -0
  213. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  214. package/dist/heart/daemon/task-scheduler.js +117 -39
  215. package/dist/heart/daemon/terminal-ui.js +499 -0
  216. package/dist/heart/daemon/thoughts.js +138 -54
  217. package/dist/heart/daemon/up-progress.js +366 -0
  218. package/dist/heart/daemon/vault-items.js +56 -0
  219. package/dist/heart/delegation.js +1 -4
  220. package/dist/heart/external-events/router.js +1430 -0
  221. package/dist/heart/frontend-approval-runtime.js +506 -0
  222. package/dist/heart/frontend-journal.js +215 -0
  223. package/dist/heart/frontend-session-service.js +237 -0
  224. package/dist/heart/frontend-socket-client.js +164 -0
  225. package/dist/heart/frontend-socket.js +400 -0
  226. package/dist/heart/habits/habit-cancel.js +810 -0
  227. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  228. package/dist/heart/habits/habit-migration.js +194 -0
  229. package/dist/heart/habits/habit-parser.js +278 -0
  230. package/dist/heart/habits/habit-runtime-state.js +93 -0
  231. package/dist/heart/habits/habit-scheduler.js +653 -0
  232. package/dist/heart/habits/habit-session-summary.js +318 -0
  233. package/dist/heart/habits/habit-session.js +636 -0
  234. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  235. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  236. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  237. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  238. package/dist/heart/identity.js +196 -59
  239. package/dist/heart/kept-notes.js +289 -0
  240. package/dist/heart/kicks.js +1 -1
  241. package/dist/heart/machine-identity.js +161 -0
  242. package/dist/heart/mail-import-discovery.js +356 -0
  243. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  244. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  245. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  246. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  247. package/dist/heart/mailbox/mailbox-http-transport.js +129 -0
  248. package/dist/heart/mailbox/mailbox-http.js +99 -0
  249. package/dist/heart/mailbox/mailbox-read.js +39 -0
  250. package/dist/heart/mailbox/mailbox-types.js +27 -0
  251. package/dist/heart/mailbox/mailbox-view.js +197 -0
  252. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  253. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  254. package/dist/heart/mailbox/readers/mail.js +375 -0
  255. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  256. package/dist/heart/mailbox/readers/sessions.js +231 -0
  257. package/dist/heart/mailbox/readers/shared.js +111 -0
  258. package/dist/heart/mcp/mcp-server.js +696 -0
  259. package/dist/heart/migrate-config.js +100 -0
  260. package/dist/heart/model-capabilities.js +11 -0
  261. package/dist/heart/orientation-frame.js +345 -0
  262. package/dist/heart/platform.js +81 -0
  263. package/dist/heart/primitives.js +1 -1
  264. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  265. package/dist/heart/private-runtime/index.js +18 -0
  266. package/dist/heart/private-runtime/ledger.js +221 -0
  267. package/dist/heart/private-runtime/policy.js +252 -0
  268. package/dist/heart/private-runtime/types.js +2 -0
  269. package/dist/heart/progress-story.js +1 -1
  270. package/dist/heart/provider-attempt.js +137 -0
  271. package/dist/heart/provider-binding-resolver.js +273 -0
  272. package/dist/heart/provider-credentials.js +449 -0
  273. package/dist/heart/provider-failover.js +311 -0
  274. package/dist/heart/provider-models.js +96 -0
  275. package/dist/heart/provider-ping.js +265 -0
  276. package/dist/heart/provider-readiness-cache.js +119 -0
  277. package/dist/heart/provider-visibility.js +188 -0
  278. package/dist/heart/providers/anthropic-token.js +131 -0
  279. package/dist/heart/providers/anthropic.js +173 -60
  280. package/dist/heart/providers/azure.js +23 -11
  281. package/dist/heart/providers/error-classification.js +127 -0
  282. package/dist/heart/providers/github-copilot.js +34 -38
  283. package/dist/heart/providers/minimax-vlm.js +189 -0
  284. package/dist/heart/providers/minimax.js +26 -8
  285. package/dist/heart/providers/openai-codex-token.js +349 -0
  286. package/dist/heart/providers/openai-codex.js +55 -40
  287. package/dist/heart/providers/openai-compatible.js +177 -0
  288. package/dist/heart/run-ledger.js +247 -0
  289. package/dist/heart/runtime-capability-check.js +170 -0
  290. package/dist/heart/runtime-credentials.js +607 -0
  291. package/dist/heart/runtime-cwd.js +87 -0
  292. package/dist/heart/sense-truth.js +22 -4
  293. package/dist/heart/session-activity.js +92 -24
  294. package/dist/heart/session-events.js +1504 -0
  295. package/dist/heart/session-playback-cli-main.js +5 -0
  296. package/dist/heart/session-playback-cli.js +36 -0
  297. package/dist/heart/session-playback.js +231 -0
  298. package/dist/heart/session-redaction-repair-cli-main.js +31 -0
  299. package/dist/heart/session-redaction-repair.js +552 -0
  300. package/dist/heart/session-stats-cli-main.js +5 -0
  301. package/dist/heart/session-stats.js +183 -0
  302. package/dist/heart/session-transcript.js +133 -0
  303. package/dist/heart/start-of-turn-packet.js +372 -0
  304. package/dist/heart/steward-policy.js +626 -0
  305. package/dist/heart/streaming.js +604 -207
  306. package/dist/heart/structured-output.js +196 -0
  307. package/dist/heart/sync-classification.js +176 -0
  308. package/dist/heart/sync.js +505 -0
  309. package/dist/heart/target-resolution.js +11 -7
  310. package/dist/heart/tempo.js +93 -0
  311. package/dist/heart/temporal-view.js +41 -0
  312. package/dist/heart/timeouts.js +101 -0
  313. package/dist/heart/tool-activity-callbacks.js +59 -0
  314. package/dist/heart/tool-approval.js +382 -0
  315. package/dist/heart/tool-description.js +155 -0
  316. package/dist/heart/tool-friction.js +55 -0
  317. package/dist/heart/tool-loop.js +200 -0
  318. package/dist/heart/turn-context.js +487 -0
  319. package/dist/heart/turn-execution-lease.js +30 -0
  320. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  321. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  322. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  323. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  324. package/dist/heart/versioning/ouro-version-manager.js +409 -0
  325. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  326. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  327. package/dist/heart/versioning/update-hooks.js +154 -0
  328. package/dist/heart/versioning/version-intent.js +114 -0
  329. package/dist/heart/work-card.js +380 -0
  330. package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +1 -0
  331. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  332. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  333. package/dist/mailbox-ui/index.html +16 -0
  334. package/dist/mailroom/attention.js +167 -0
  335. package/dist/mailroom/autonomy.js +209 -0
  336. package/dist/mailroom/blob-store.js +889 -0
  337. package/dist/mailroom/body-cache.js +61 -0
  338. package/dist/mailroom/cache-sync-cli.js +58 -0
  339. package/dist/mailroom/core.js +803 -0
  340. package/dist/mailroom/entry.js +160 -0
  341. package/dist/mailroom/file-store.js +568 -0
  342. package/dist/mailroom/hosted-cache-sync.js +384 -0
  343. package/dist/mailroom/mbox-import.js +393 -0
  344. package/dist/mailroom/migration.js +164 -0
  345. package/dist/mailroom/outbound.js +380 -0
  346. package/dist/mailroom/policy.js +263 -0
  347. package/dist/mailroom/reader.js +292 -0
  348. package/dist/mailroom/search-cache.js +513 -0
  349. package/dist/mailroom/search-relevance.js +319 -0
  350. package/dist/mailroom/smtp-ingress.js +176 -0
  351. package/dist/mailroom/source-state.js +176 -0
  352. package/dist/mailroom/thread.js +109 -0
  353. package/dist/mailroom/travel-extract.js +89 -0
  354. package/dist/mind/bundle-manifest.js +24 -2
  355. package/dist/mind/context.js +309 -111
  356. package/dist/mind/desk-section.js +362 -0
  357. package/dist/mind/diary-integrity.js +60 -0
  358. package/dist/mind/{memory.js → diary.js} +68 -77
  359. package/dist/mind/embedding-provider.js +60 -0
  360. package/dist/mind/file-state.js +179 -0
  361. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  362. package/dist/mind/obligation-steering.js +199 -3
  363. package/dist/mind/pending.js +36 -10
  364. package/dist/mind/prompt-budget.js +479 -0
  365. package/dist/mind/prompt-refresh.js +3 -2
  366. package/dist/mind/prompt.js +1048 -198
  367. package/dist/mind/provenance-trust.js +26 -0
  368. package/dist/mind/record-paths.js +312 -0
  369. package/dist/mind/scrutiny.js +173 -0
  370. package/dist/mind/session-transaction.js +564 -0
  371. package/dist/nerves/cli-logging.js +7 -1
  372. package/dist/nerves/coverage/audit-rules.js +15 -6
  373. package/dist/nerves/coverage/audit.js +28 -2
  374. package/dist/nerves/coverage/cli.js +1 -1
  375. package/dist/nerves/coverage/contract.js +5 -5
  376. package/dist/nerves/coverage/file-completeness.js +139 -5
  377. package/dist/nerves/coverage/run-artifacts.js +55 -35
  378. package/dist/nerves/coverage/source-scanner.js +4 -4
  379. package/dist/nerves/event-buffer.js +111 -0
  380. package/dist/nerves/index.js +293 -9
  381. package/dist/nerves/observation.js +20 -0
  382. package/dist/nerves/redact.js +79 -0
  383. package/dist/nerves/review/cli-main.js +5 -0
  384. package/dist/nerves/review/cli.js +156 -0
  385. package/dist/nerves/review/core.js +159 -0
  386. package/dist/nerves/runtime.js +17 -1
  387. package/dist/repertoire/ado-client.js +15 -56
  388. package/dist/repertoire/ado-semantic.js +16 -10
  389. package/dist/repertoire/api-client.js +97 -0
  390. package/dist/repertoire/bitwarden-store.js +1045 -0
  391. package/dist/repertoire/bundle-templates.js +71 -0
  392. package/dist/repertoire/bw-installer.js +180 -0
  393. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  394. package/dist/repertoire/coding/context-pack.js +331 -0
  395. package/dist/repertoire/coding/feedback.js +174 -32
  396. package/dist/repertoire/coding/index.js +15 -3
  397. package/dist/repertoire/coding/manager.js +159 -10
  398. package/dist/repertoire/coding/spawner.js +58 -11
  399. package/dist/repertoire/coding/tools.js +221 -15
  400. package/dist/repertoire/coding/workbench-client.js +272 -0
  401. package/dist/repertoire/coding/workbench-manager.js +346 -0
  402. package/dist/repertoire/commerce-errors.js +109 -0
  403. package/dist/repertoire/commerce-self-test.js +156 -0
  404. package/dist/repertoire/credential-access.js +178 -0
  405. package/dist/repertoire/desk/classifier.js +362 -0
  406. package/dist/repertoire/duffel-client.js +185 -0
  407. package/dist/repertoire/github-client.js +14 -55
  408. package/dist/repertoire/graph-client.js +11 -52
  409. package/dist/repertoire/guardrails.js +811 -18
  410. package/dist/repertoire/mcp-client.js +44 -3
  411. package/dist/repertoire/mcp-manager.js +427 -139
  412. package/dist/repertoire/mcp-tools.js +124 -0
  413. package/dist/repertoire/plugin-mcp.js +175 -0
  414. package/dist/repertoire/plugins.js +253 -0
  415. package/dist/repertoire/relationship-authorization.js +322 -0
  416. package/dist/repertoire/shell-sessions.js +134 -0
  417. package/dist/repertoire/skills.js +48 -4
  418. package/dist/repertoire/stripe-client.js +131 -0
  419. package/dist/repertoire/tool-arguments.js +170 -0
  420. package/dist/repertoire/tool-results.js +29 -0
  421. package/dist/repertoire/tools-a2a.js +699 -0
  422. package/dist/repertoire/tools-attachments.js +322 -0
  423. package/dist/repertoire/tools-awaiting.js +575 -0
  424. package/dist/repertoire/tools-base.js +77 -1168
  425. package/dist/repertoire/tools-bluebubbles.js +2 -0
  426. package/dist/repertoire/tools-bridge.js +144 -0
  427. package/dist/repertoire/tools-bundle.js +993 -0
  428. package/dist/repertoire/tools-commerce.js +253 -0
  429. package/dist/repertoire/tools-config.js +186 -0
  430. package/dist/repertoire/tools-continuity.js +575 -0
  431. package/dist/repertoire/tools-credential.js +383 -0
  432. package/dist/repertoire/tools-evolution.js +527 -0
  433. package/dist/repertoire/tools-files.js +344 -0
  434. package/dist/repertoire/tools-flight.js +290 -0
  435. package/dist/repertoire/tools-flow.js +119 -0
  436. package/dist/repertoire/tools-github.js +3 -8
  437. package/dist/repertoire/tools-habits.js +103 -0
  438. package/dist/repertoire/tools-mail.js +1828 -0
  439. package/dist/repertoire/tools-notes.js +485 -0
  440. package/dist/repertoire/tools-obligations.js +182 -0
  441. package/dist/repertoire/tools-orientation.js +31 -0
  442. package/dist/repertoire/tools-record.js +482 -0
  443. package/dist/repertoire/tools-rsvp.js +139 -0
  444. package/dist/repertoire/tools-runtime.js +150 -0
  445. package/dist/repertoire/tools-sanctuary-host.js +202 -0
  446. package/dist/repertoire/tools-session.js +1133 -0
  447. package/dist/repertoire/tools-shell.js +149 -0
  448. package/dist/repertoire/tools-steward-policy.js +136 -0
  449. package/dist/repertoire/tools-stripe.js +224 -0
  450. package/dist/repertoire/tools-surface.js +369 -0
  451. package/dist/repertoire/tools-teams.js +12 -39
  452. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  453. package/dist/repertoire/tools-travel.js +125 -0
  454. package/dist/repertoire/tools-trip.js +982 -0
  455. package/dist/repertoire/tools-unraid.js +531 -0
  456. package/dist/repertoire/tools-user-profile.js +146 -0
  457. package/dist/repertoire/tools-vault.js +40 -0
  458. package/dist/repertoire/tools-voice.js +145 -0
  459. package/dist/repertoire/tools.js +504 -156
  460. package/dist/repertoire/travel-api-client.js +360 -0
  461. package/dist/repertoire/unraid-client.js +155 -0
  462. package/dist/repertoire/unraid-restart.js +339 -0
  463. package/dist/repertoire/user-profile.js +131 -0
  464. package/dist/repertoire/vault-setup.js +246 -0
  465. package/dist/repertoire/vault-unlock.js +628 -0
  466. package/dist/rsvp/aisleplanner-client.js +354 -0
  467. package/dist/rsvp/cli.js +926 -0
  468. package/dist/rsvp/config.js +327 -0
  469. package/dist/rsvp/cutover.js +433 -0
  470. package/dist/rsvp/diagnostics.js +232 -0
  471. package/dist/rsvp/diff-renderer.js +176 -0
  472. package/dist/rsvp/habit-policy.js +110 -0
  473. package/dist/rsvp/habit-stage.js +123 -0
  474. package/dist/rsvp/incident-bundle.js +97 -0
  475. package/dist/rsvp/migration.js +204 -0
  476. package/dist/rsvp/native-habit-runner.js +630 -0
  477. package/dist/rsvp/outbound-state.js +628 -0
  478. package/dist/rsvp/query.js +118 -0
  479. package/dist/rsvp/replay.js +233 -0
  480. package/dist/rsvp/snapshot.js +232 -0
  481. package/dist/rsvp/spend-ledger.js +175 -0
  482. package/dist/scripts/claude-code-hook.js +41 -0
  483. package/dist/scripts/claude-code-stop-hook.js +47 -0
  484. package/dist/senses/a2a-entry.js +126 -0
  485. package/dist/senses/acp-server.js +386 -0
  486. package/dist/senses/attention-queue.js +186 -0
  487. package/dist/senses/await-turn-message.js +58 -0
  488. package/dist/senses/bluebubbles/active-turns.js +216 -0
  489. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  490. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  491. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +355 -40
  492. package/dist/senses/bluebubbles/context-packet.js +207 -0
  493. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  494. package/dist/senses/bluebubbles/entry.js +90 -0
  495. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  496. package/dist/senses/bluebubbles/index.js +3841 -0
  497. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  498. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  499. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  500. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  501. package/dist/senses/bluebubbles/outbound-state.js +308 -0
  502. package/dist/senses/bluebubbles/processed-log.js +133 -0
  503. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  504. package/dist/senses/bluebubbles/replay.js +284 -0
  505. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  506. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  507. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  508. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  509. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  510. package/dist/senses/cli/bracketed-paste.js +82 -0
  511. package/dist/senses/cli/image-paste.js +287 -0
  512. package/dist/senses/cli/image-ref-navigation.js +75 -0
  513. package/dist/senses/cli/ink-app.js +156 -0
  514. package/dist/senses/cli/inline-diff.js +64 -0
  515. package/dist/senses/cli/input-keys.js +174 -0
  516. package/dist/senses/cli/kill-ring.js +86 -0
  517. package/dist/senses/cli/message-list.js +51 -0
  518. package/dist/senses/cli/ouro-tui.js +607 -0
  519. package/dist/senses/cli/spinner-imperative.js +135 -0
  520. package/dist/senses/cli/spinner.js +101 -0
  521. package/dist/senses/cli/status-line.js +60 -0
  522. package/dist/senses/cli/streaming-markdown.js +526 -0
  523. package/dist/senses/cli/tool-display.js +85 -0
  524. package/dist/senses/cli/tool-render.js +85 -0
  525. package/dist/senses/cli/tui-store.js +241 -0
  526. package/dist/senses/cli/virtual-list.js +35 -0
  527. package/dist/senses/cli-entry.js +60 -8
  528. package/dist/senses/cli.js +604 -320
  529. package/dist/senses/commands.js +66 -3
  530. package/dist/senses/context-packet-ledger.js +371 -0
  531. package/dist/senses/context-packets.js +154 -0
  532. package/dist/senses/habit-lifecycle-guard.js +24 -0
  533. package/dist/senses/habit-turn-message.js +160 -0
  534. package/dist/senses/ingress-evidence.js +2 -0
  535. package/dist/senses/mail-entry.js +66 -0
  536. package/dist/senses/mail.js +524 -0
  537. package/dist/senses/pipeline.js +1115 -204
  538. package/dist/senses/private-runtime-worker.js +859 -0
  539. package/dist/senses/private-runtime.js +1517 -0
  540. package/dist/senses/proactive-content-guard.js +51 -0
  541. package/dist/senses/root-host-approval-port.js +345 -0
  542. package/dist/senses/root-host-approval-runtime.js +393 -0
  543. package/dist/senses/sanctuary-authority-resident.js +102 -0
  544. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  545. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  546. package/dist/senses/sanctuary-grounding.js +118 -0
  547. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  548. package/dist/senses/sanctuary-health-acceptance-probe.js +774 -0
  549. package/dist/senses/sanctuary-health-runner.js +100 -0
  550. package/dist/senses/sanctuary-health.js +408 -0
  551. package/dist/senses/sanctuary-install-state-contract.js +169 -0
  552. package/dist/senses/sanctuary-interactive-control.js +410 -0
  553. package/dist/senses/sanctuary-media-catalog-contract.js +297 -0
  554. package/dist/senses/sanctuary-media-optimization.js +514 -0
  555. package/dist/senses/sanctuary-runtime.js +340 -0
  556. package/dist/senses/sanctuary-sab.js +85 -0
  557. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  558. package/dist/senses/shared-turn.js +789 -0
  559. package/dist/senses/surface-tool.js +109 -0
  560. package/dist/senses/teams-entry.js +60 -8
  561. package/dist/senses/teams.js +519 -218
  562. package/dist/senses/telegram-admission.js +718 -0
  563. package/dist/senses/telegram-approval-runtime.js +793 -0
  564. package/dist/senses/telegram-attachments.js +186 -0
  565. package/dist/senses/telegram-audit-ledger.js +220 -0
  566. package/dist/senses/telegram-authority-transport.js +231 -0
  567. package/dist/senses/telegram-client.js +1749 -0
  568. package/dist/senses/telegram-effect-adapter.js +896 -0
  569. package/dist/senses/telegram-entry.js +83 -0
  570. package/dist/senses/telegram.js +2347 -0
  571. package/dist/senses/trust-gate.js +104 -9
  572. package/dist/senses/voice/audio-playback.js +237 -0
  573. package/dist/senses/voice/audio-routing.js +119 -0
  574. package/dist/senses/voice/elevenlabs.js +202 -0
  575. package/dist/senses/voice/floor-control.js +431 -0
  576. package/dist/senses/voice/floor-controller.js +115 -0
  577. package/dist/senses/voice/golden-path.js +116 -0
  578. package/dist/senses/voice/index.js +29 -0
  579. package/dist/senses/voice/meeting.js +113 -0
  580. package/dist/senses/voice/outbound.js +190 -0
  581. package/dist/senses/voice/phone.js +33 -0
  582. package/dist/senses/voice/playback.js +139 -0
  583. package/dist/senses/voice/realtime-eval.js +496 -0
  584. package/dist/senses/voice/realtime-trace.js +531 -0
  585. package/dist/senses/voice/transcript.js +70 -0
  586. package/dist/senses/voice/turn.js +191 -0
  587. package/dist/senses/voice/twilio-phone-runtime.js +807 -0
  588. package/dist/senses/voice/twilio-phone.js +5031 -0
  589. package/dist/senses/voice/types.js +2 -0
  590. package/dist/senses/voice/whisper.js +161 -0
  591. package/dist/senses/voice-entry.js +81 -0
  592. package/dist/senses/voice-realtime-eval-command.js +99 -0
  593. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  594. package/dist/senses/voice-twilio-entry.js +87 -0
  595. package/dist/trips/core.js +138 -0
  596. package/dist/trips/store.js +265 -0
  597. package/dist/util/frontmatter.js +69 -0
  598. package/npm-shrinkwrap.json +8096 -0
  599. package/package.json +72 -13
  600. package/skills/agent-commerce.md +113 -0
  601. package/skills/browser-navigation.md +117 -0
  602. package/skills/commerce-setup-guide.md +116 -0
  603. package/skills/commerce-setup.md +84 -0
  604. package/skills/configure-dev-tools.md +99 -0
  605. package/skills/travel-planning.md +138 -0
  606. package/dist/heart/daemon/auth-flow.js +0 -430
  607. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  608. package/dist/heart/daemon/ouro-version-manager.js +0 -171
  609. package/dist/heart/daemon/update-hooks.js +0 -138
  610. package/dist/heart/obligations.js +0 -191
  611. package/dist/heart/safe-workspace.js +0 -269
  612. package/dist/heart/session-recall.js +0 -116
  613. package/dist/mind/friends/channel.js +0 -84
  614. package/dist/mind/friends/group-context.js +0 -144
  615. package/dist/mind/friends/resolver.js +0 -84
  616. package/dist/mind/friends/store-file.js +0 -190
  617. package/dist/mind/friends/store.js +0 -4
  618. package/dist/mind/friends/tokens.js +0 -26
  619. package/dist/mind/friends/trust-explanation.js +0 -74
  620. package/dist/mind/friends/types.js +0 -29
  621. package/dist/repertoire/tasks/board.js +0 -134
  622. package/dist/repertoire/tasks/index.js +0 -224
  623. package/dist/repertoire/tasks/lifecycle.js +0 -80
  624. package/dist/repertoire/tasks/middleware.js +0 -65
  625. package/dist/repertoire/tasks/parser.js +0 -173
  626. package/dist/repertoire/tasks/scanner.js +0 -132
  627. package/dist/repertoire/tasks/transitions.js +0 -144
  628. package/dist/senses/bluebubbles-entry.js +0 -13
  629. package/dist/senses/bluebubbles.js +0 -1181
  630. package/dist/senses/debug-activity.js +0 -154
  631. package/dist/senses/inner-dialog-worker.js +0 -90
  632. package/dist/senses/inner-dialog.js +0 -525
  633. package/subagents/README.md +0 -7
  634. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  635. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  636. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  637. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  638. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  639. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  640. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  641. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  642. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  643. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  644. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  645. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  646. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  647. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  648. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  649. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  650. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,1364 @@
1
+ #!/usr/bin/env node
2
+ // Sanctuary media MCP server — the Mendelow Cloud Butler's media surface.
3
+ //
4
+ // Design authority: the Butler's own tool-surface spec (Telegram msgs 1700-1713, 2026-09-19).
5
+ // Two hard constraints it set, enforced here:
6
+ // 1. Anything deterministic lives in the tool, not in the agent's reasoning.
7
+ // 2. No "call A, parse it, build B" — every return payload carries the cross-reference
8
+ // fields needed to answer the obvious follow-up without a second call.
9
+ //
10
+ // Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
11
+
12
+ import { readFileSync } from "node:fs"
13
+ import { pathToFileURL } from "node:url"
14
+
15
+ const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
16
+
17
+ // Credentials are host-specific and deliberately NOT packaged. A fresh install
18
+ // has none, and the server must still start and list its tools — otherwise the
19
+ // whole MCP server fails to connect and the agent silently loses the surface.
20
+ // Missing credentials become a named, reportable per-call failure instead.
21
+ let cred = null
22
+ let credError = null
23
+ try {
24
+ cred = JSON.parse(readFileSync(CRED_PATH, "utf8"))
25
+ } catch (e) {
26
+ credError = `Media credentials are unavailable at ${CRED_PATH} (${e.code ?? e.message}). The media tools cannot reach Jellyseerr/Sonarr/Radarr/Prowlarr until that file is created.`
27
+ }
28
+
29
+ const SEERR = cred?.jellyseerr ?? { url: "", apiKey: "" }
30
+ const SONARR = cred?.sonarr ?? { url: "", apiKey: "" }
31
+ const RADARR = cred?.radarr ?? { url: "", apiKey: "" }
32
+ const PROWLARR = cred?.prowlarr ?? { url: "", apiKey: "" }
33
+ const JELLYFIN_WEB = cred?.jellyfinWebUrl ?? "https://media.mendelow.cloud"
34
+ // Optional: when the credential file carries { jellyfin: { url, apiKey } } the library
35
+ // is searched in Jellyfin too. Absent, title resolution uses Sonarr and Radarr only.
36
+ const JELLYFIN = cred?.jellyfin ?? { url: "", apiKey: "" }
37
+
38
+ const TIMEOUT_MS = 45_000
39
+ // Interactive release search queries every indexer live and can take minutes.
40
+ const SEARCH_TIMEOUT_MS = 180_000
41
+
42
+ // ---------------------------------------------------------------- http helpers
43
+
44
+ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body, query, timeoutMs = TIMEOUT_MS } = {}) {
45
+ let url = `${base}${path}`
46
+ if (query) {
47
+ const parts = Object.entries(query)
48
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
49
+ // encodeURIComponent, not URLSearchParams: Jellyseerr rejects '+' for spaces.
50
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
51
+ if (parts.length) url += `?${parts.join("&")}`
52
+ }
53
+ const headers = { [header]: key, Accept: "application/json" }
54
+ if (body !== undefined) headers["Content-Type"] = "application/json"
55
+ let res
56
+ try {
57
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) })
58
+ } catch (e) {
59
+ throw new ServiceError(`${serviceOf(base)}_unreachable`, `${serviceOf(base)} did not respond: ${e.name === "TimeoutError" ? "timeout" : e.message}`)
60
+ }
61
+ if (!res.ok) {
62
+ const text = await res.text().catch(() => "")
63
+ throw new ServiceError(`${serviceOf(base)}_http_${res.status}`, `${serviceOf(base)} returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`)
64
+ }
65
+ if (res.status === 204) return null
66
+ return res.json()
67
+ }
68
+
69
+ function serviceOf(base) {
70
+ if (base === SEERR.url) return "jellyseerr"
71
+ if (base === SONARR.url) return "sonarr"
72
+ if (base === RADARR.url) return "radarr"
73
+ if (base === PROWLARR.url) return "prowlarr"
74
+ if (JELLYFIN.url && base === JELLYFIN.url) return "jellyfin"
75
+ return "service"
76
+ }
77
+
78
+ class ServiceError extends Error {
79
+ constructor(code, message) { super(message); this.code = code }
80
+ }
81
+
82
+ const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
83
+ // Long enough that an ordinary slow start is not called dead, short enough that
84
+ // nobody waits a day for a release that was never going to arrive.
85
+ const STALL_AFTER_HOURS = 6
86
+
87
+ const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
88
+ const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
89
+ const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
90
+
91
+ // ------------------------------------------------------------------ mapping
92
+
93
+ // Jellyseerr media status codes.
94
+ const MEDIA_STATUS = { 1: "unknown", 2: "pending", 3: "processing", 4: "partially_available", 5: "available" }
95
+ const REQUEST_STATUS = { 1: "pending_approval", 2: "approved", 3: "declined", 4: "failed" }
96
+
97
+ const TMDB_GENRES = {
98
+ 28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", 80: "Crime", 99: "Documentary",
99
+ 18: "Drama", 10751: "Family", 14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music",
100
+ 9648: "Mystery", 10749: "Romance", 878: "Science Fiction", 10770: "TV Movie", 53: "Thriller",
101
+ 10752: "War", 37: "Western", 10759: "Action & Adventure", 10762: "Kids", 10763: "News",
102
+ 10764: "Reality", 10765: "Sci-Fi & Fantasy", 10766: "Soap", 10767: "Talk", 10768: "War & Politics",
103
+ }
104
+
105
+ function genreNames(item) {
106
+ const ids = item.genreIds ?? (item.genres ?? []).map((g) => g.id)
107
+ return ids.map((id) => TMDB_GENRES[id]).filter(Boolean)
108
+ }
109
+
110
+ function shelfItem(item) {
111
+ const kind = item.mediaType === "tv" ? "series" : item.mediaType === "movie" ? "movie" : (item.firstAirDate ? "series" : "movie")
112
+ const date = item.releaseDate ?? item.firstAirDate ?? ""
113
+ const info = item.mediaInfo ?? {}
114
+ const status = MEDIA_STATUS[info.status] ?? "not_on_shelf"
115
+ return {
116
+ title: item.title ?? item.name ?? null,
117
+ year: date ? Number(date.slice(0, 4)) : null,
118
+ kind,
119
+ tmdb_id: item.id ?? null,
120
+ tvdb_id: info.tvdbId ?? null,
121
+ genres: genreNames(item),
122
+ overview: typeof item.overview === "string" ? item.overview.slice(0, 400) : null,
123
+ // The two fields that stop a second call:
124
+ on_shelf: status === "available" || status === "partially_available",
125
+ shelf_state: status,
126
+ request_state: info.status ? (status === "available" ? "available" : status) : "never_requested",
127
+ sonarr_id: info.externalServiceId ?? null,
128
+ radarr_id: kind === "movie" ? (info.externalServiceId ?? null) : null,
129
+ poster_path: item.posterPath ? `https://image.tmdb.org/t/p/w500${item.posterPath}` : null,
130
+ vote_average: item.voteAverage ?? null,
131
+ }
132
+ }
133
+
134
+ // ------------------------------------------------------- title resolution
135
+ //
136
+ // One resolver for every tool that takes a title. A title typed from memory is
137
+ // rarely the catalogue's title: "chef" is "The Chef Show", "chef shwo" is a typo.
138
+ // The 2026-09-29 failure was a library search that required the whole title and
139
+ // so reported a tracked series as nonexistent. Matching is therefore partial and
140
+ // typo-tolerant, in-library candidates always come first, and the TVDB/TMDB
141
+ // lookup only runs when the library has nothing.
142
+
143
+ export function normalizeTitle(s) {
144
+ return String(s ?? "")
145
+ .normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
146
+ .toLowerCase().replace(/&/g, " and ").replace(/['\u2019]/g, "")
147
+ .replace(/[^a-z0-9]+/g, " ").trim().replace(/^the /, "")
148
+ }
149
+
150
+ // Optimal-string-alignment distance: an adjacent swap ("shwo") costs one edit.
151
+ function editDistance(a, b) {
152
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
153
+ for (let j = 1; j <= b.length; j += 1) d[0][j] = j
154
+ for (let i = 1; i <= a.length; i += 1) {
155
+ for (let j = 1; j <= b.length; j += 1) {
156
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
157
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
158
+ }
159
+ }
160
+ return d[a.length][b.length]
161
+ }
162
+
163
+ const MATCH_RANK = { exact: 0, prefix: 1, token: 2, fuzzy: 3 }
164
+
165
+ // Returns "exact" | "prefix" | "token" | "fuzzy" | null.
166
+ export function matchKind(query, title) {
167
+ const nq = normalizeTitle(query)
168
+ const nt = normalizeTitle(title)
169
+ if (!nq || !nt) return null
170
+ if (nq === nt) return "exact"
171
+ if (nt.startsWith(nq)) return "prefix"
172
+ const qt = nq.split(" ")
173
+ const tt = nt.split(" ")
174
+ if (qt.every((q) => tt.some((t) => t === q || (q.length >= 3 && t.includes(q))))) return "token"
175
+ if (nq.length >= 3 && nt.includes(nq)) return "token"
176
+ // Typos. Short tokens must be exact; a 4-letter token may only be fuzzy when
177
+ // another query token already matched exactly, so "chef" never drifts to "chess".
178
+ const exactHit = (q) => tt.includes(q)
179
+ const fuzzyHit = (q) => tt.some((t) => {
180
+ if (q.length < 4 || t.length < 4) return false
181
+ const tol = q.length >= 8 ? 2 : 1
182
+ return editDistance(q, t) <= tol
183
+ })
184
+ const anyExact = qt.some(exactHit)
185
+ const ok = qt.every((q) => exactHit(q) || (fuzzyHit(q) && (q.length >= 5 || (qt.length > 1 && anyExact))))
186
+ return ok ? "fuzzy" : null
187
+ }
188
+
189
+ function rankCandidates(term, items) {
190
+ return items
191
+ .map((c) => ({ ...c, match: matchKind(term, c.title) }))
192
+ .filter((c) => c.match)
193
+ .sort((x, y) => MATCH_RANK[x.match] - MATCH_RANK[y.match]
194
+ || Math.abs(normalizeTitle(x.title).length - normalizeTitle(term).length) - Math.abs(normalizeTitle(y.title).length - normalizeTitle(term).length))
195
+ }
196
+
197
+ const libraryCandidate = (kind, i) => ({ kind, source: kind === "series" ? "sonarr" : "radarr", service_id: i.id, title: i.title,
198
+ year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: i.tvdbId ?? null, in_library: true })
199
+
200
+ // Every library item whose title matches, best first. Sonarr and Radarr are the
201
+ // source of truth for what is tracked; Jellyfin (when configured) adds what is
202
+ // on the shelf and marks items it shares with them.
203
+ export async function findLibraryCandidates(term, kind = "any") {
204
+ const jobs = []
205
+ if (kind !== "movie") jobs.push(sonarr("/series").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("series", i))))
206
+ if (kind !== "series") jobs.push(radarr("/movie").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("movie", i))))
207
+ // One service being down must not hide the other's matches; say which failed.
208
+ const settled = await Promise.allSettled(jobs)
209
+ const notes = []
210
+ for (const r of settled) if (r.status === "rejected") notes.push(`library_unavailable: ${r.reason?.message ?? r.reason}`)
211
+ if (settled.every((r) => r.status === "rejected")) throw settled[0].reason
212
+ let items = settled.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
213
+ if (JELLYFIN.url && JELLYFIN.apiKey) {
214
+ try {
215
+ const body = await req(JELLYFIN.url, "/Items", { key: JELLYFIN.apiKey, header: "X-Emby-Token",
216
+ query: { searchTerm: term, IncludeItemTypes: kind === "series" ? "Series" : kind === "movie" ? "Movie" : "Series,Movie", Recursive: true, Limit: 25 } })
217
+ for (const j of body?.Items ?? []) {
218
+ const k = j.Type === "Series" ? "series" : "movie"
219
+ const tmdb = Number(j.ProviderIds?.Tmdb ?? j.ProviderIds?.tmdb) || null
220
+ const twin = items.find((i) => i.kind === k && ((tmdb && i.tmdb_id === tmdb) || normalizeTitle(i.title) === normalizeTitle(j.Name)))
221
+ if (twin) twin.in_jellyfin = true
222
+ else items.push({ kind: k, source: "jellyfin", service_id: null, jellyfin_id: j.Id ?? null, title: j.Name, year: j.ProductionYear ?? null,
223
+ tmdb_id: tmdb, tvdb_id: null, in_library: true, in_jellyfin: true })
224
+ }
225
+ } catch (e) {
226
+ notes.push(`jellyfin_unavailable: ${e.message}`)
227
+ }
228
+ }
229
+ const ranked = rankCandidates(term, items)
230
+ if (notes.length) Object.defineProperty(ranked, "notes", { value: notes, enumerable: false })
231
+ return ranked
232
+ }
233
+
234
+ // The catalogue fallback, used only when the library has no candidate. These are
235
+ // titles Sonarr/Radarr could add, so they are labelled not_in_library.
236
+ export async function lookupCandidates(term, kind = "any") {
237
+ const out = []
238
+ const jobs = []
239
+ if (kind !== "movie") jobs.push(sonarr("/series/lookup", { query: { term } }).then((l) => (Array.isArray(l) ? l : []).forEach((i) => out.push({ kind: "series", source: "sonarr_lookup", service_id: i.id ?? null, title: i.title, year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: i.tvdbId ?? null, in_library: Boolean(i.id), match: matchKind(term, i.title) ?? "lookup" }))).catch(() => {}))
240
+ if (kind !== "series") jobs.push(radarr("/movie/lookup", { query: { term } }).then((l) => (Array.isArray(l) ? l : []).forEach((i) => out.push({ kind: "movie", source: "radarr_lookup", service_id: i.id ?? null, title: i.title, year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: null, in_library: Boolean(i.id), match: matchKind(term, i.title) ?? "lookup" }))).catch(() => {}))
241
+ await Promise.all(jobs)
242
+ return out.sort((x, y) => (MATCH_RANK[x.match] ?? 4) - (MATCH_RANK[y.match] ?? 4)).slice(0, 10)
243
+ }
244
+
245
+ // In-library candidates first; the lookup fallback only when there are none.
246
+ export async function resolveTitle(term, kind = "any") {
247
+ const library = await findLibraryCandidates(term, kind)
248
+ if (library.length) return { library_matches: library, lookup_candidates: [], not_in_library: false, notes: library.notes ?? [] }
249
+ const lookup = (await lookupCandidates(term, kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
250
+ return { library_matches: [], lookup_candidates: lookup, not_in_library: true, notes: [] }
251
+ }
252
+
253
+ // ------------------------------------------------------------- tool: search
254
+
255
+ export async function mediaSearch(a) {
256
+ const kind = a.kind ?? "any"
257
+ const limit = Math.min(Math.max(a.limit ?? 12, 1), 20)
258
+ const onShelf = a.on_shelf // true | false | undefined(any)
259
+ let raw = []
260
+ let source
261
+
262
+ if (a.query) {
263
+ source = "title_search"
264
+ const body = await seerr("/search", { query: { query: a.query, page: 1 } })
265
+ raw = body.results ?? []
266
+ } else {
267
+ source = "discover"
268
+ const keywordIds = []
269
+ for (const kw of a.keywords ?? []) {
270
+ const hit = await seerr("/search/keyword", { query: { query: kw, page: 1 } })
271
+ const exact = (hit.results ?? []).find((k) => k.name.toLowerCase() === kw.toLowerCase()) ?? (hit.results ?? [])[0]
272
+ if (exact) keywordIds.push(exact.id)
273
+ }
274
+ const genreIds = (a.genres ?? [])
275
+ .map((name) => Number(Object.entries(TMDB_GENRES).find(([, v]) => v.toLowerCase() === String(name).toLowerCase())?.[0]))
276
+ .filter((n) => Number.isFinite(n))
277
+ const [yMin, yMax] = a.year_range ?? []
278
+ const paths = kind === "series" ? ["/discover/tv"] : kind === "movie" ? ["/discover/movies"] : ["/discover/movies", "/discover/tv"]
279
+ // One page of discover is ~20 popular titles, of which only a couple are
280
+ // usually on this shelf — answering "what cozy autumn films do we own?" from
281
+ // that reads as though the shelf is nearly empty. When the caller filters by
282
+ // shelf state, walk further pages until enough matches accumulate.
283
+ const maxPages = onShelf === undefined ? 1 : 5
284
+ for (const p of paths) {
285
+ const isTv = p.endsWith("/tv")
286
+ for (let page = 1; page <= maxPages; page += 1) {
287
+ const q = { page, sortBy: a.sort === "release_year" ? "primary_release_date.desc" : "popularity.desc" }
288
+ if (genreIds.length) q.genre = genreIds.join(",")
289
+ if (keywordIds.length) q.keywords = keywordIds.join(",")
290
+ if (yMin) q[isTv ? "firstAirDateGte" : "primaryReleaseDateGte"] = `${yMin}-01-01`
291
+ if (yMax) q[isTv ? "firstAirDateLte" : "primaryReleaseDateLte"] = `${yMax}-12-31`
292
+ const body = await seerr(p, { query: q })
293
+ const results = body.results ?? []
294
+ raw.push(...results.map((r) => ({ ...r, mediaType: isTv ? "tv" : "movie" })))
295
+ const matchesSoFar = raw
296
+ .map((r) => (r.mediaInfo?.status === 5 || r.mediaInfo?.status === 4))
297
+ .filter((hit) => hit === (onShelf === true)).length
298
+ if (results.length === 0 || page >= (body.totalPages ?? 1) || matchesSoFar >= limit) break
299
+ }
300
+ }
301
+ }
302
+
303
+ // Partial and typo-tolerant library match, so a half-remembered title still
304
+ // finds what is tracked. Lookup candidates appear only when the library has none.
305
+ const resolution = a.query ? await resolveTitle(a.query, kind).catch((e) => ({ library_matches: [], lookup_candidates: [], not_in_library: null, notes: [`title_resolution_failed: ${e.message}`] })) : null
306
+
307
+ let items = raw.filter((r) => r.mediaType !== "person").map(shelfItem)
308
+ if (kind !== "any") items = items.filter((i) => i.kind === kind)
309
+ if (a.year_range) {
310
+ const [lo, hi] = a.year_range
311
+ items = items.filter((i) => i.year && (!lo || i.year >= lo) && (!hi || i.year <= hi))
312
+ }
313
+ if (onShelf === true) items = items.filter((i) => i.on_shelf)
314
+ if (onShelf === false) items = items.filter((i) => !i.on_shelf)
315
+ if (a.sort === "release_year") items.sort((x, y) => (y.year ?? 0) - (x.year ?? 0))
316
+
317
+ return {
318
+ source,
319
+ matched: items.length,
320
+ items: items.slice(0, limit),
321
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates, notes: resolution.notes } : {}),
322
+ applied_filters: { kind, year_range: a.year_range ?? null, genres: a.genres ?? [], keywords: a.keywords ?? [], on_shelf: onShelf ?? "any" },
323
+ // So the agent never claims something is missing when the filter caused it:
324
+ note: resolution?.library_matches.length ? "library_matches lists items already tracked in Sonarr/Radarr (or on the Jellyfin shelf) whose titles match, best first. Offer these before anything else."
325
+ : items.length === 0 && resolution?.lookup_candidates.length ? "Nothing in the library matches; lookup_candidates are TVDB/TMDB titles that are not in the library."
326
+ : items.length === 0 ? "No matches for these filters. Loosen year_range/genres/keywords before concluding the shelf lacks it." : null,
327
+ }
328
+ }
329
+
330
+ // ------------------------------------------------------------ tool: request
331
+
332
+ // Resolve by TMDB id against the title itself rather than scanning the request
333
+ // list: this household has over a thousand requests, so a page-limited scan
334
+ // silently misses older ones and would file a duplicate.
335
+ async function findExistingRequest(tmdbId, kind) {
336
+ const kinds = kind ? [kind] : ["movie", "series"]
337
+ for (const k of kinds) {
338
+ let detail
339
+ try {
340
+ detail = await seerr(`/${k === "series" ? "tv" : "movie"}/${tmdbId}`)
341
+ } catch {
342
+ continue
343
+ }
344
+ const info = detail?.mediaInfo
345
+ if (!info) continue
346
+ const requests = info.requests ?? []
347
+ const latest = requests.length ? requests[requests.length - 1] : null
348
+ return {
349
+ id: latest?.id ?? null,
350
+ status: latest?.status ?? null,
351
+ createdAt: latest?.createdAt ?? null,
352
+ type: k === "series" ? "tv" : "movie",
353
+ media: { ...info, tmdbId, title: detail.title ?? detail.name ?? null },
354
+ hasRequest: requests.length > 0,
355
+ }
356
+ }
357
+ return null
358
+ }
359
+
360
+ async function mediaRequest(a) {
361
+ let tmdbId = a.tmdb_id
362
+ let kind = a.kind ?? "movie"
363
+ let resolvedTitle = a.title ?? null
364
+ let resolvedYear = a.year ?? null
365
+
366
+ if (!tmdbId) {
367
+ if (!a.title) throw new ServiceError("bad_arguments", "media_request needs tmdb_id, or title (year optional).")
368
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
369
+ let cands = (body.results ?? []).filter((r) => r.mediaType === "movie" || r.mediaType === "tv")
370
+ if (a.kind) cands = cands.filter((r) => (a.kind === "series" ? r.mediaType === "tv" : r.mediaType === "movie"))
371
+ if (a.year) cands = cands.filter((r) => ((r.releaseDate ?? r.firstAirDate ?? "").slice(0, 4)) === String(a.year))
372
+ const pick = cands[0]
373
+ if (!pick) {
374
+ return { resolved: false, reason: "no_tmdb_match", searched: { title: a.title, year: a.year ?? null, kind: a.kind ?? "any" },
375
+ human_action_required: true, human_action_reason: `No TMDB match for "${a.title}"${a.year ? ` (${a.year})` : ""}. Check the title or give a year.` }
376
+ }
377
+ tmdbId = pick.id
378
+ kind = pick.mediaType === "tv" ? "series" : "movie"
379
+ resolvedTitle = pick.title ?? pick.name
380
+ resolvedYear = Number((pick.releaseDate ?? pick.firstAirDate ?? "").slice(0, 4)) || null
381
+ }
382
+
383
+ const existing = await findExistingRequest(tmdbId, kind)
384
+ if (existing?.hasRequest) {
385
+ const shelf = MEDIA_STATUS[existing.media?.status] ?? "unknown"
386
+ return {
387
+ resolved: true, title: resolvedTitle ?? existing.media?.title ?? null, year: resolvedYear, kind, tmdb_id: tmdbId,
388
+ request_id: `jellyseerr:${existing.id}`,
389
+ approval_state: "already_requested",
390
+ duplicate_of: `jellyseerr:${existing.id}`,
391
+ jellyseerr_status: REQUEST_STATUS[existing.status] ?? "unknown",
392
+ shelf_state: shelf,
393
+ submitted_at: existing.createdAt,
394
+ tvdb_id: existing.media?.tvdbId ?? null,
395
+ sonarr_id: kind === "series" ? (existing.media?.externalServiceId ?? null) : null,
396
+ radarr_id: kind === "movie" ? (existing.media?.externalServiceId ?? null) : null,
397
+ note: shelf === "available" ? "Already on the shelf." : "Already requested; use media_request_status to see why it has not landed.",
398
+ }
399
+ }
400
+
401
+ const payload = { mediaType: kind === "series" ? "tv" : "movie", mediaId: tmdbId }
402
+ if (kind === "series") {
403
+ payload.seasons = a.season_number ? [a.season_number] : "all"
404
+ }
405
+ const created = await seerr("/request", { method: "POST", body: payload })
406
+ return {
407
+ resolved: true, title: resolvedTitle, year: resolvedYear, kind, tmdb_id: tmdbId,
408
+ request_id: `jellyseerr:${created.id}`,
409
+ approval_state: REQUEST_STATUS[created.status] ?? "submitted",
410
+ duplicate_of: null,
411
+ jellyseerr_status: REQUEST_STATUS[created.status] ?? "submitted",
412
+ shelf_state: MEDIA_STATUS[created.media?.status] ?? "processing",
413
+ submitted_at: created.createdAt,
414
+ tvdb_id: created.media?.tvdbId ?? null,
415
+ sonarr_id: kind === "series" ? (created.media?.externalServiceId ?? null) : null,
416
+ radarr_id: kind === "movie" ? (created.media?.externalServiceId ?? null) : null,
417
+ seasons_requested: kind === "series" ? (a.season_number ? [a.season_number] : "all") : null,
418
+ note: "Submitted. The indexer hunt runs next; media_request_status will show whether a release was grabbed.",
419
+ }
420
+ }
421
+
422
+ // -------------------------------------------------- acquisition chain health
423
+
424
+ // The failure that stalled this house for three weeks: Prowlarr had zero indexers,
425
+ // so Sonarr reported "0 active indexers" and every request sat at "requested"
426
+ // forever. That must be a first-class, named answer — never something the agent
427
+ // has to infer from an empty release list.
428
+ async function chainHealth() {
429
+ const out = { indexers: { total: 0, enabled: 0, failing: [], ok: [] }, apps: [], problems: [], healthy: true }
430
+ try {
431
+ const list = await prowlarr("/indexer")
432
+ out.indexers.total = list.length
433
+ out.indexers.enabled = list.filter((i) => i.enable).length
434
+ const status = await prowlarr("/indexerstatus").catch(() => [])
435
+ const disabled = new Set((status ?? []).map((s) => s.indexerId))
436
+ for (const i of list) {
437
+ if (!i.enable) continue
438
+ ;(disabled.has(i.id) ? out.indexers.failing : out.indexers.ok).push(i.name)
439
+ }
440
+ const health = await prowlarr("/health").catch(() => [])
441
+ for (const h of health ?? []) {
442
+ if (h.type === "error" && /indexer/i.test(h.source ?? "")) out.problems.push({ service: "prowlarr", message: h.message })
443
+ }
444
+ out.apps = (await prowlarr("/applications").catch(() => [])).map((x) => ({ name: x.name, sync: x.syncLevel }))
445
+ } catch (e) {
446
+ out.problems.push({ service: "prowlarr", message: e.message })
447
+ }
448
+
449
+ if (out.indexers.total === 0) {
450
+ out.healthy = false
451
+ out.problems.push({ service: "prowlarr", message: "Prowlarr has zero indexers configured. Nothing can ever be found or downloaded until indexers are added." })
452
+ } else if (out.indexers.ok.length === 0) {
453
+ out.healthy = false
454
+ out.problems.push({ service: "prowlarr", message: "Every enabled indexer is currently failing. Searches will return nothing." })
455
+ }
456
+ if (out.apps.length === 0) {
457
+ out.healthy = false
458
+ out.problems.push({ service: "prowlarr", message: "Prowlarr is not syncing to Sonarr/Radarr, so they have no working indexers." })
459
+ }
460
+
461
+ for (const [name, call] of [["sonarr", sonarr], ["radarr", radarr]]) {
462
+ try {
463
+ const h = await call("/health")
464
+ for (const item of h ?? []) {
465
+ if (/indexer/i.test(item.source ?? "") && (item.type === "error" || /unavailable/i.test(item.message ?? ""))) {
466
+ out.problems.push({ service: name, message: item.message })
467
+ if (/all/i.test(item.message ?? "")) out.healthy = false
468
+ }
469
+ }
470
+ } catch (e) { out.problems.push({ service: name, message: e.message }) }
471
+ }
472
+ return out
473
+ }
474
+
475
+ // --------------------------------------------------- tool: request status
476
+
477
+ async function resolveTarget(a) {
478
+ if (a.tmdb_id) {
479
+ const r = await findExistingRequest(a.tmdb_id, a.kind)
480
+ if (r?.hasRequest) return r
481
+ }
482
+ if (a.request_id) {
483
+ const id = Number(String(a.request_id).replace(/^jellyseerr:/, ""))
484
+ if (Number.isFinite(id)) {
485
+ try { return await seerr(`/request/${id}`) } catch { /* fall through to title */ }
486
+ }
487
+ }
488
+ if (a.title) {
489
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
490
+ // Only titles Jellyseerr already knows about can have a request behind them.
491
+ for (const pick of (body.results ?? []).filter((r) => r.mediaInfo)) {
492
+ const found = await findExistingRequest(pick.id, pick.mediaType === "tv" ? "series" : "movie")
493
+ if (found?.hasRequest) return found
494
+ }
495
+ // Jellyseerr's own search missed it: try the partial/typo-tolerant library
496
+ // match, and follow its best candidate back to a request by TMDB id.
497
+ const best = (await findLibraryCandidates(a.title).catch(() => [])).find((c) => c.tmdb_id)
498
+ if (best) {
499
+ const found = await findExistingRequest(best.tmdb_id, best.kind)
500
+ if (found?.hasRequest) return found
501
+ }
502
+ }
503
+ return null
504
+ }
505
+
506
+ export async function mediaRequestStatus(a) {
507
+ const request = await resolveTarget(a)
508
+ if (!request) {
509
+ const resolution = a.title ? await resolveTitle(a.title).catch(() => null) : null
510
+ return { found: false, reason: "no_matching_request",
511
+ searched: { request_id: a.request_id ?? null, tmdb_id: a.tmdb_id ?? null, title: a.title ?? null },
512
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates,
513
+ note: resolution.library_matches.length ? "Not requested through Jellyseerr, but these titles are already in the library. Use media_search_now with the service_id to act on one." : undefined } : {}),
514
+ diagnosis: { stuck_stage: "request", stuck_reason: "never_requested",
515
+ likely_fix: "Submit it with media_request.", human_action_required: false } }
516
+ }
517
+
518
+ const kind = request.type === "tv" ? "series" : "movie"
519
+ const media = request.media ?? {}
520
+ const shelf = MEDIA_STATUS[media.status] ?? "unknown"
521
+ const svcId = media.externalServiceId ?? null
522
+
523
+ const result = {
524
+ found: true,
525
+ request: {
526
+ id: `jellyseerr:${request.id}`,
527
+ kind,
528
+ tmdb_id: media.tmdbId ?? null,
529
+ tvdb_id: media.tvdbId ?? null,
530
+ jellyseerr_status: REQUEST_STATUS[request.status] ?? "unknown",
531
+ shelf_state: shelf,
532
+ submitted_at: request.createdAt,
533
+ sonarr_id: kind === "series" ? svcId : null,
534
+ radarr_id: kind === "movie" ? svcId : null,
535
+ },
536
+ search: null, download: null, file: null, chain: null, diagnosis: null,
537
+ }
538
+
539
+ let entity = null, queueRecs = [], history = []
540
+ try {
541
+ if (kind === "series" && svcId) {
542
+ entity = await sonarr(`/series/${svcId}`)
543
+ const q = await sonarr("/queue", { query: { pageSize: 200, includeSeries: true } })
544
+ queueRecs = (q.records ?? []).filter((r) => r.seriesId === svcId)
545
+ history = await sonarr("/history/series", { query: { seriesId: svcId } }).catch(() => [])
546
+ result.request.title = entity.title
547
+ result.file = { on_shelf: (entity.statistics?.episodeFileCount ?? 0) > 0,
548
+ episodes_on_disk: entity.statistics?.episodeFileCount ?? 0,
549
+ episodes_total: entity.statistics?.episodeCount ?? 0,
550
+ size_bytes: entity.statistics?.sizeOnDisk ?? 0,
551
+ path: entity.path ?? null }
552
+ } else if (kind === "movie" && svcId) {
553
+ entity = await radarr(`/movie/${svcId}`)
554
+ const q = await radarr("/queue", { query: { pageSize: 200 } })
555
+ queueRecs = (q.records ?? []).filter((r) => r.movieId === svcId)
556
+ history = await radarr("/history/movie", { query: { movieId: svcId } }).catch(() => [])
557
+ result.request.title = entity.title
558
+ result.file = { on_shelf: Boolean(entity.hasFile), size_bytes: entity.sizeOnDisk ?? 0, path: entity.path ?? null }
559
+ }
560
+ } catch (e) {
561
+ result.chain_error = e.message
562
+ }
563
+
564
+ const grabs = (history ?? []).filter((h) => h.eventType === "grabbed")
565
+ result.search = {
566
+ grab_attempts: grabs.length,
567
+ last_grab_at: grabs[0]?.date ?? null,
568
+ last_grab_title: grabs[0]?.sourceTitle ?? null,
569
+ monitored: entity ? Boolean(entity.monitored) : null,
570
+ }
571
+ result.download = computeDownloadState(queueRecs, Date.now())
572
+
573
+ const chain = await chainHealth()
574
+ result.chain = chain
575
+ result.diagnosis = diagnose({ shelf, result, chain, entity, kind })
576
+ return result
577
+ }
578
+
579
+ // The deterministic core, split out so it can be tested without a live
580
+ // Sonarr/Radarr. `computeDownloadState` and `diagnose` are the whole reason the
581
+ // media surface can answer "why hasn't this downloaded" without the agent
582
+ // guessing, and D-013 (a stalled download read as progress) shipped verified
583
+ // only in production because there was no seam to test them through.
584
+ //
585
+ // A season pack shows up as one queue row per episode, all sharing one
586
+ // downloadId. Summing rows would report a 45 GB pack as 450 GB, so size and
587
+ // "how many downloads" are counted per distinct download, not per row. A
588
+ // torrent that has not moved a byte since it was grabbed is dead, not slow:
589
+ // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
590
+ // progress is measured against age rather than trusted from the row.
591
+ // The one stall rule: nothing downloaded yet, and older than the stall window.
592
+ // A download that never started, or one still unfinished days later (e.g. stuck at
593
+ // 99.85% with no seeders for the last piece), will not finish on its own.
594
+ const STUCK_INCOMPLETE_AFTER_HOURS = 72
595
+
596
+ export function isStalledRow(r, nowMs) {
597
+ if (!((r.size ?? 0) > 0) || !r.added) return false
598
+ const ageHours = (nowMs - Date.parse(r.added)) / 3_600_000
599
+ const left = r.sizeleft ?? 0
600
+ if (left >= r.size) return ageHours >= STALL_AFTER_HOURS
601
+ return left > 0 && ageHours >= STUCK_INCOMPLETE_AFTER_HOURS
602
+ }
603
+
604
+ export function computeDownloadState(queueRecs, nowMs) {
605
+ const byDownload = new Map()
606
+ for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
607
+ const downloads = [...byDownload.values()]
608
+ const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
609
+ const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
610
+ const stalledItems = downloads
611
+ .filter((r) => isStalledRow(r, nowMs))
612
+ .map((r) => ({
613
+ title: r.title ?? null,
614
+ added_at: r.added ?? null,
615
+ age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000),
616
+ size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
617
+ queue_id: r.id ?? null,
618
+ }))
619
+ return {
620
+ active_downloads: downloads.length,
621
+ active_items: queueRecs.length,
622
+ states: [...new Set(downloads.map((r) => r.status))],
623
+ tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
624
+ errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
625
+ size_left_bytes: sizeLeft,
626
+ size_total_bytes: sizeTotal,
627
+ percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
628
+ stalled: stalledItems.length > 0,
629
+ stalled_items: stalledItems,
630
+ }
631
+ }
632
+
633
+ // The deterministic core. The agent must never have to work this out itself.
634
+ export function diagnose({ shelf, result, chain, entity, kind }) {
635
+ const file = result.file ?? {}
636
+ const onShelf = kind === "series" ? (file.episodes_on_disk ?? 0) > 0 : Boolean(file.on_shelf)
637
+ const complete = kind === "series" ? (file.episodes_on_disk ?? 0) >= (file.episodes_total ?? Infinity) : onShelf
638
+
639
+ if (complete) {
640
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
641
+ summary: "On the shelf and complete. Nothing is stuck." }
642
+ }
643
+ if (result.download.stalled) {
644
+ const items = result.download.stalled_items
645
+ const oldest = Math.max(...items.map((i) => i.age_hours))
646
+ return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
647
+ likely_fix: "blocklist_and_research", human_action_required: false,
648
+ percent_complete: result.download.percent_complete,
649
+ detail: items,
650
+ summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
651
+ }
652
+ if (result.download.active_downloads > 0) {
653
+ const left = result.download.size_left_bytes
654
+ const total = result.download.size_total_bytes
655
+ const gb = (left / 1073741824).toFixed(1)
656
+ const pct = total > 0 ? Math.round(((total - left) / total) * 100) : null
657
+ const what = result.download.active_downloads === 1 && result.download.active_items > 1
658
+ ? `a ${result.download.active_items}-episode pack`
659
+ : `${result.download.active_downloads} download(s)`
660
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
661
+ percent_complete: pct,
662
+ summary: `Downloading now — ${what}${pct === null ? "" : `, ${pct}% done`}, about ${gb} GB to go.` }
663
+ }
664
+ if (result.download.errors.length) {
665
+ return { stuck_stage: "download", stuck_reason: "download_client_error", likely_fix: "retry_or_replace_release",
666
+ human_action_required: false, detail: result.download.errors,
667
+ summary: `The download client reported: ${result.download.errors.join("; ")}` }
668
+ }
669
+ if (!chain.healthy) {
670
+ return { stuck_stage: "indexers", stuck_reason: "acquisition_chain_down",
671
+ likely_fix: "restore_indexers", human_action_required: true,
672
+ detail: chain.problems,
673
+ summary: `Nothing can download: ${chain.problems.map((p) => p.message).join(" ")} This blocks every request, not just this one.` }
674
+ }
675
+ if (entity && entity.monitored === false) {
676
+ return { stuck_stage: "request", stuck_reason: "not_monitored", likely_fix: "enable_monitoring",
677
+ human_action_required: false, summary: "It is not monitored, so no search will ever run for it." }
678
+ }
679
+ if (result.search.grab_attempts === 0) {
680
+ return { stuck_stage: "search", stuck_reason: "no_search_run_or_no_acceptable_release",
681
+ likely_fix: "rescan", human_action_required: false,
682
+ summary: "Indexers are healthy but nothing has ever been grabbed. A fresh search (media_diagnose_and_fix action=rescan) is the next step." }
683
+ }
684
+ if (onShelf && !complete) {
685
+ return { stuck_stage: "import", stuck_reason: "partially_imported", likely_fix: "rescan",
686
+ human_action_required: false,
687
+ summary: `Partly here: ${file.episodes_on_disk} of ${file.episodes_total} episodes. The rest still need grabbing.` }
688
+ }
689
+ return { stuck_stage: "import", stuck_reason: "grabbed_but_not_imported", likely_fix: "force_import_scan",
690
+ human_action_required: false,
691
+ summary: `It was grabbed (${result.search.last_grab_title ?? "unknown release"}) but never landed on the shelf.` }
692
+ }
693
+
694
+ // -------------------------------------------------- tool: diagnose_and_fix
695
+
696
+ async function mediaDiagnoseAndFix(a) {
697
+ const before = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
698
+ if (!before.found) return { result: "no_such_request", before, after: null, human_action_required: true,
699
+ human_action_reason: "Nothing to fix — it was never requested." }
700
+ const action = a.action ?? (before.diagnosis?.likely_fix ?? "rescan")
701
+
702
+ if (before.diagnosis?.stuck_reason === "acquisition_chain_down" && action !== "report_only") {
703
+ return { action, dry_run: Boolean(a.dry_run), result: "rejected_fix_unsafe", before, after: null,
704
+ human_action_required: true,
705
+ human_action_reason: "The acquisition chain itself is down (see before.chain.problems). Re-searching cannot help until indexers are restored." }
706
+ }
707
+ if (a.dry_run) {
708
+ return { action, dry_run: true, result: "would_apply", before,
709
+ would_do: describeAction(action), human_action_required: false }
710
+ }
711
+
712
+ const kind = before.request.kind
713
+ const svcId = kind === "series" ? before.request.sonarr_id : before.request.radarr_id
714
+ if (!svcId) return { action, result: "manual_required", before, after: null, human_action_required: true,
715
+ human_action_reason: "This request is not yet mapped into Sonarr/Radarr, so no search can be triggered." }
716
+
717
+ let commanded = null
718
+ if (action === "rescan" || action === "enable_monitoring" || action === "force_import_scan") {
719
+ if (action === "enable_monitoring") {
720
+ if (kind === "series") await sonarr(`/series/${svcId}`, { method: "PUT", body: { ...(await sonarr(`/series/${svcId}`)), monitored: true } })
721
+ else await radarr(`/movie/${svcId}`, { method: "PUT", body: { ...(await radarr(`/movie/${svcId}`)), monitored: true } })
722
+ }
723
+ const cmd = kind === "series"
724
+ ? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
725
+ : (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
726
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
727
+ } else if (action === "blocklist_and_research") {
728
+ // Removing with blocklist=true is what stops the same dead release being
729
+ // grabbed straight back. The search that follows is then free to pick a
730
+ // different one.
731
+ const stalled = before.download.stalled_items ?? []
732
+ if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
733
+ human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
734
+ for (const item of stalled) {
735
+ if (item.queue_id === null) continue
736
+ const client = kind === "series" ? sonarr : radarr
737
+ await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
738
+ }
739
+ const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
740
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
741
+ } else {
742
+ return { action, result: "unsupported_action", before, after: null, human_action_required: true,
743
+ human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
744
+ }
745
+
746
+ await new Promise((r) => setTimeout(r, 12_000))
747
+ const after = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
748
+ return {
749
+ action, dry_run: false, applied_at: new Date().toISOString(),
750
+ command: { id: commanded?.id ?? null, name: commanded?.name ?? null },
751
+ before: { diagnosis: before.diagnosis, grab_attempts: before.search.grab_attempts, active_downloads: before.download.active_downloads },
752
+ after: { diagnosis: after.diagnosis, grab_attempts: after.search.grab_attempts, active_downloads: after.download.active_downloads },
753
+ result: after.download.active_downloads > before.download.active_downloads ? "grabbed_and_downloading"
754
+ : after.search.grab_attempts > before.search.grab_attempts ? "grabbed"
755
+ : "queued_for_search",
756
+ human_action_required: Boolean(after.diagnosis?.human_action_required),
757
+ human_action_reason: after.diagnosis?.human_action_required ? after.diagnosis.summary : null,
758
+ }
759
+ }
760
+
761
+ function describeAction(action) {
762
+ return {
763
+ rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
764
+ enable_monitoring: "Mark it monitored, then search.",
765
+ force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
766
+ blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
767
+ report_only: "Report only; change nothing.",
768
+ }[action] ?? "Unknown action."
769
+ }
770
+
771
+ // ------------------------------------------------------- tool: play/resolve
772
+
773
+ async function mediaPlayOrResolve(a) {
774
+ const status = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
775
+ if (!status.found || !status.file) {
776
+ return { playable: false, reason: "not_on_shelf", diagnosis: status.diagnosis ?? null,
777
+ human_action_required: true, human_action_reason: "It is not on the shelf yet." }
778
+ }
779
+ const onShelf = status.request.kind === "series" ? (status.file.episodes_on_disk ?? 0) > 0 : status.file.on_shelf
780
+ return {
781
+ playable: Boolean(onShelf),
782
+ title: status.request.title ?? null,
783
+ kind: status.request.kind,
784
+ tmdb_id: status.request.tmdb_id,
785
+ episodes_on_disk: status.file.episodes_on_disk ?? null,
786
+ episodes_total: status.file.episodes_total ?? null,
787
+ path: status.file.path,
788
+ size_bytes: status.file.size_bytes,
789
+ play_url: onShelf ? `${JELLYFIN_WEB}/web/index.html#!/search.html?query=${encodeURIComponent(status.request.title ?? "")}` : null,
790
+ diagnosis: status.diagnosis,
791
+ }
792
+ }
793
+
794
+ // --------------------------------------------------------- tool: chain health
795
+
796
+ async function mediaChainHealth() {
797
+ const chain = await chainHealth()
798
+ return {
799
+ healthy: chain.healthy,
800
+ indexers: chain.indexers,
801
+ app_sync: chain.apps,
802
+ problems: chain.problems,
803
+ summary: chain.healthy
804
+ ? `Acquisition chain healthy: ${chain.indexers.ok.length} of ${chain.indexers.enabled} enabled indexers responding${chain.indexers.failing.length ? `, failing: ${chain.indexers.failing.join(", ")}` : ""}.`
805
+ : `Acquisition chain DOWN. ${chain.problems.map((p) => p.message).join(" ")}`,
806
+ }
807
+ }
808
+
809
+ // ------------------------------------- tools: search_now / blocklist_stalled
810
+
811
+ // Act on an item already in Sonarr/Radarr directly; no Jellyseerr request needed.
812
+ // Never adds a library item: an unknown title is reported, not created.
813
+ const arrFor = (kind) => (kind === "series" ? sonarr : radarr)
814
+
815
+ async function resolveLibraryItem(a) {
816
+ let candidates
817
+ if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id) {
818
+ const list = await arrFor(a.kind)(a.kind === "series" ? "/series" : "/movie")
819
+ const items = (Array.isArray(list) ? list : []).map((i) => libraryCandidate(a.kind, i))
820
+ const hit = items.filter((i) => (a.service_id !== undefined && a.service_id !== null ? i.service_id === Number(a.service_id) : i.tmdb_id === Number(a.tmdb_id)))
821
+ if (hit.length === 1) return { item: { id: hit[0].service_id, title: hit[0].title } }
822
+ candidates = hit
823
+ } else if (a.title) {
824
+ const ranked = (await findLibraryCandidates(a.title, a.kind)).filter((c) => c.service_id !== null)
825
+ // Act only when the best tier holds exactly one item and it is not merely a
826
+ // typo-distance match; otherwise name the candidates and let the caller pick.
827
+ const bestTier = ranked.filter((c) => c.match === ranked[0]?.match)
828
+ if (bestTier.length === 1 && bestTier[0].match !== "fuzzy") return { item: { id: bestTier[0].service_id, title: bestTier[0].title } }
829
+ if (!ranked.length) {
830
+ const lookup = (await lookupCandidates(a.title, a.kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
831
+ return { result: "not_found", candidates: lookup }
832
+ }
833
+ candidates = ranked
834
+ } else candidates = []
835
+ return { result: candidates.length ? "ambiguous" : "not_found", candidates: candidates.slice(0, 10) }
836
+ }
837
+
838
+ const QUEUE_PAGE_SIZE = 200
839
+ const QUEUE_MAX_PAGES = 20
840
+
841
+ // The item's queue rows. Filtered server-side (seriesIds/movieIds) and paged, so a
842
+ // long queue cannot hide a row; the client-side filter stays as the backstop.
843
+ async function itemQueue(kind, serviceId) {
844
+ const client = arrFor(kind)
845
+ const idKey = kind === "series" ? "seriesId" : "movieId"
846
+ const filter = kind === "series" ? { seriesIds: serviceId, includeSeries: true } : { movieIds: serviceId }
847
+ const rows = []
848
+ for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
849
+ const q = await client("/queue", { query: { ...filter, page, pageSize: QUEUE_PAGE_SIZE } })
850
+ const records = q?.records ?? []
851
+ rows.push(...records)
852
+ if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE)) break
853
+ }
854
+ return rows.filter((r) => r[idKey] === serviceId)
855
+ }
856
+
857
+ function queueReport(rows) {
858
+ const now = Date.now()
859
+ return rows.map((r) => ({ queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
860
+ size_left_bytes: r.sizeleft ?? 0, stalled: isStalledRow(r, now) }))
861
+ }
862
+
863
+ async function runSearch(kind, serviceId) {
864
+ const body = kind === "series" ? { name: "SeriesSearch", seriesId: serviceId } : { name: "MoviesSearch", movieIds: [serviceId] }
865
+ const c = await arrFor(kind)("/command", { method: "POST", body })
866
+ return { id: c?.id ?? null, name: c?.name ?? body.name, status: c?.status ?? null }
867
+ }
868
+
869
+ export async function mediaSearchNow(a) {
870
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
871
+ const found = await resolveLibraryItem(a)
872
+ if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
873
+ const { item } = found
874
+ const command = await runSearch(a.kind, item.id)
875
+ return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
876
+ }
877
+
878
+ export async function mediaBlocklistStalled(a) {
879
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
880
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
881
+ const serviceId = Number(a.service_id)
882
+ const rows = await itemQueue(a.kind, serviceId)
883
+ const now = Date.now()
884
+ let targets
885
+ if (Array.isArray(a.queue_ids) && a.queue_ids.length) {
886
+ const own = new Set(rows.map((r) => r.id))
887
+ const foreign = a.queue_ids.filter((id) => !own.has(id))
888
+ if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows) }
889
+ targets = rows.filter((r) => a.queue_ids.includes(r.id))
890
+ } else {
891
+ targets = rows.filter((r) => isStalledRow(r, now))
892
+ }
893
+ // A season pack is many queue rows sharing one downloadId; deleting one removes
894
+ // the download for all, so delete once per download.
895
+ const seen = new Set()
896
+ targets = targets.filter((r) => {
897
+ const key = r.downloadId ? `d:${r.downloadId}` : `q:${r.id}`
898
+ if (seen.has(key)) return false
899
+ seen.add(key)
900
+ return true
901
+ })
902
+ if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
903
+ const removed = []
904
+ const failed = []
905
+ for (const row of targets) {
906
+ try {
907
+ await arrFor(a.kind)(`/queue/${row.id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
908
+ removed.push(row.id)
909
+ } catch (e) {
910
+ // 404: the row went away (finished or removed with its pack); not a failure to retry.
911
+ if (/_http_404$/.test(e.code ?? "")) removed.push(row.id)
912
+ else failed.push({ queue_id: row.id, error: e.message })
913
+ }
914
+ }
915
+ const command = a.research === false ? null : await runSearch(a.kind, serviceId)
916
+ return { removed, ...(failed.length ? { failed } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
917
+ }
918
+
919
+ // ------------------------------- tools: episodes / releases / blocklist / import
920
+
921
+ // When an automatic search finds nothing, the Butler works the release by hand:
922
+ // list the episodes, read what the indexers offer and how Sonarr parsed it, grab
923
+ // the right one with an explicit episode mapping, and map downloaded files by title.
924
+ // All scoped to series/movies already in the library; nothing here adds one.
925
+
926
+ const kindOk = (a) => a.kind === "series" || a.kind === "movie"
927
+ const invalidKind = () => ({ result: "invalid_kind", message: "kind must be 'series' or 'movie'." })
928
+ const idKeyOf = (kind) => (kind === "series" ? "seriesId" : "movieId")
929
+ const code2 = (n) => String(n).padStart(2, "0")
930
+ const sxe = (e) => `S${code2(e.seasonNumber)}E${code2(e.episodeNumber)}`
931
+
932
+ async function seriesEpisodes(seriesId, seasonNumber) {
933
+ const list = await sonarr("/episode", { query: { seriesId, seasonNumber } })
934
+ return Array.isArray(list) ? list : []
935
+ }
936
+
937
+ export async function mediaEpisodes(a) {
938
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
939
+ const seriesId = Number(a.service_id)
940
+ const eps = await seriesEpisodes(seriesId, a.season_number)
941
+ return {
942
+ series_id: seriesId,
943
+ season_number: a.season_number ?? null,
944
+ count: eps.length,
945
+ episodes: eps.map((e) => ({ episode_id: e.id, code: sxe(e), season: e.seasonNumber, episode: e.episodeNumber, title: e.title ?? null,
946
+ air_date: e.airDate ?? null, has_file: Boolean(e.hasFile), monitored: Boolean(e.monitored),
947
+ // Release names follow scene (often streaming) numbering; Sonarr's own mapping, when it has one.
948
+ scene_code: Number.isInteger(e.sceneSeasonNumber) && Number.isInteger(e.sceneEpisodeNumber)
949
+ ? `S${String(e.sceneSeasonNumber).padStart(2, "0")}E${String(e.sceneEpisodeNumber).padStart(2, "0")}` : null })),
950
+ }
951
+ }
952
+
953
+ // Free-text search across every Prowlarr indexer: discovery only, it grabs nothing.
954
+ // Sonarr's own search only asks for the season/episode codes it expects; this finds
955
+ // what exists under any name (another numbering, a "Season 2 (2020)" pack, an episode title).
956
+ export async function mediaIndexerSearch(a) {
957
+ const query = typeof a.query === "string" ? a.query.trim() : ""
958
+ if (!query) return { result: "query_required" }
959
+ const raw = await prowlarr("/search", { query: { query, type: "search", limit: 100 }, timeoutMs: SEARCH_TIMEOUT_MS })
960
+ const limit = Math.min(Math.max(Number(a.limit) || 25, 1), 50)
961
+ const rows = (Array.isArray(raw) ? raw : [])
962
+ .map((r) => ({ indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null, seeders: r.seeders ?? null,
963
+ age_days: r.age ?? null, protocol: r.protocol ?? null }))
964
+ .sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1))
965
+ return { query, count: rows.length, releases: rows.slice(0, limit),
966
+ note: "Discovery only. To grab, run media_release_search for the item so Sonarr/Radarr caches the release, then media_release_grab with an explicit episode mapping." }
967
+ }
968
+
969
+ // The last search's compact rows, keyed by kind + guid, so a grab can check what Sonarr parsed.
970
+ // Sonarr/Radarr forget a search's releases after about 30 minutes; expire ours a little sooner.
971
+ const releaseCache = new Map()
972
+ const RELEASE_CACHE_TTL_MS = 25 * 60_000
973
+ const RELEASE_CACHE_MAX = 500
974
+ const MAX_EPISODE_SEARCHES = 6
975
+
976
+ function cacheRelease(key, row, nowMs = Date.now()) {
977
+ for (const [k, v] of releaseCache) if (nowMs - v.at > RELEASE_CACHE_TTL_MS) releaseCache.delete(k)
978
+ releaseCache.delete(key)
979
+ releaseCache.set(key, { ...row, at: nowMs })
980
+ while (releaseCache.size > RELEASE_CACHE_MAX) releaseCache.delete(releaseCache.keys().next().value)
981
+ }
982
+
983
+ function releaseRow(kind, r) {
984
+ const rejections = Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : []
985
+ const row = {
986
+ guid: r.guid, indexer_id: r.indexerId ?? null, indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null,
987
+ seeders: r.seeders ?? null, age_days: r.age ?? null, quality: r.quality?.quality?.name ?? null, protocol: r.protocol ?? null,
988
+ rejected: Boolean(r.rejected), rejections, blocklisted: rejections.some((x) => /blocklist/i.test(x)),
989
+ }
990
+ if (kind === "series") {
991
+ return { ...row, full_season: Boolean(r.fullSeason), parsed_season: r.seasonNumber ?? null, parsed_episodes: r.episodeNumbers ?? [],
992
+ mapped_episodes: (r.mappedEpisodeInfo ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
993
+ mapped_series_id: r.mappedSeriesId ?? null }
994
+ }
995
+ return { ...row, mapped_movie_id: r.mappedMovieId ?? null }
996
+ }
997
+
998
+ export async function mediaReleaseSearch(a) {
999
+ if (!kindOk(a)) return invalidKind()
1000
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1001
+ const serviceId = Number(a.service_id)
1002
+ let raw = []
1003
+ if (a.kind === "movie") {
1004
+ raw = await radarr("/release", { query: { movieId: serviceId }, timeoutMs: SEARCH_TIMEOUT_MS })
1005
+ } else if (Array.isArray(a.episode_ids) && a.episode_ids.length) {
1006
+ // Sonarr searches one episode per call.
1007
+ if (a.episode_ids.length > MAX_EPISODE_SEARCHES) return { result: "too_many_episode_ids", max: MAX_EPISODE_SEARCHES, message: "Search the season instead, or fewer episodes at a time." }
1008
+ for (const id of a.episode_ids) raw.push(...await sonarr("/release", { query: { episodeId: id }, timeoutMs: SEARCH_TIMEOUT_MS }))
1009
+ } else if (a.season_number !== undefined && a.season_number !== null) {
1010
+ raw = await sonarr("/release", { query: { seriesId: serviceId, seasonNumber: a.season_number }, timeoutMs: SEARCH_TIMEOUT_MS })
1011
+ } else return { result: "season_number_or_episode_ids_required" }
1012
+ const seen = new Set()
1013
+ let rows = (Array.isArray(raw) ? raw : []).filter((r) => (seen.has(r.guid) ? false : seen.add(r.guid))).map((r) => releaseRow(a.kind, r))
1014
+ const total = rows.length
1015
+ if (a.query) {
1016
+ const q = String(a.query).toLowerCase()
1017
+ rows = rows.filter((r) => (r.title ?? "").toLowerCase().includes(q))
1018
+ }
1019
+ rows.sort((x, y) => Number(y.seeders ?? 0) - Number(x.seeders ?? 0))
1020
+ const limit = Math.min(Math.max(Number(a.limit) || 40, 1), 100)
1021
+ for (const r of rows) cacheRelease(`${a.kind}:${r.guid}`, { ...r, service_id: serviceId, season_number: a.season_number ?? null })
1022
+ return { kind: a.kind, service_id: serviceId, total_found: total, matched: rows.length, releases: rows.slice(0, limit) }
1023
+ }
1024
+
1025
+ export async function mediaReleaseGrab(a) {
1026
+ if (!kindOk(a)) return invalidKind()
1027
+ if (!a.guid || a.indexer_id === undefined || a.indexer_id === null) return { result: "guid_and_indexer_id_required" }
1028
+ const cached = releaseCache.get(`${a.kind}:${a.guid}`)
1029
+ if (!cached || Date.now() - cached.at > RELEASE_CACHE_TTL_MS) {
1030
+ return { result: "search_first", message: "Run media_release_search first; a release can only be grabbed from a recent search (within 25 minutes)." }
1031
+ }
1032
+ if (cached.indexer_id !== null && Number(a.indexer_id) !== Number(cached.indexer_id)) return { result: "indexer_mismatch", expected_indexer_id: cached.indexer_id }
1033
+ const body = { guid: a.guid, indexerId: Number(a.indexer_id) }
1034
+ const hasEpisodes = Array.isArray(a.episode_ids) && a.episode_ids.length > 0
1035
+ if (a.kind === "series") {
1036
+ if (!hasEpisodes) {
1037
+ // A pack Sonarr parsed as another season would be mapped to the wrong episodes.
1038
+ const target = a.season_number ?? cached.season_number
1039
+ if (cached.full_season && (target === undefined || target === null || Number(target) !== Number(cached.parsed_season))) {
1040
+ return { result: "season_pack_mismatch", parsed_season: cached.parsed_season, target_season: target ?? null, title: cached.title,
1041
+ message: "Sonarr parsed this season pack as a different season than the target. Pass series_id and episode_ids to say which episodes it holds." }
1042
+ }
1043
+ } else {
1044
+ if (a.series_id === undefined || a.series_id === null) return { result: "series_id_required_with_episode_ids" }
1045
+ const seriesId = Number(a.series_id)
1046
+ if (seriesId !== Number(cached.service_id)) return { result: "series_mismatch", searched_series_id: cached.service_id, message: "This release came from a search for another series." }
1047
+ const own = new Set((await seriesEpisodes(seriesId)).map((e) => e.id))
1048
+ const foreign = a.episode_ids.filter((id) => !own.has(id))
1049
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1050
+ Object.assign(body, { seriesId, episodeIds: a.episode_ids.map(Number), shouldOverride: true })
1051
+ if (a.download_client_id !== undefined && a.download_client_id !== null) body.downloadClientId = Number(a.download_client_id)
1052
+ }
1053
+ }
1054
+ const res = await arrFor(a.kind)("/release", { method: "POST", body })
1055
+ return { result: "grabbed", kind: a.kind, title: cached.title, request: body,
1056
+ response: { approved: res?.approved ?? null, rejected: res?.rejected ?? null, rejections: res?.rejections ?? [], download_id: res?.downloadId ?? null } }
1057
+ }
1058
+
1059
+ const BLOCKLIST_PAGE_SIZE = 100
1060
+ const BLOCKLIST_MAX_PAGES = 20
1061
+
1062
+ async function itemBlocklist(kind, serviceId) {
1063
+ const idKey = idKeyOf(kind)
1064
+ const filter = kind === "series" ? { seriesIds: serviceId } : { movieIds: serviceId }
1065
+ const rows = []
1066
+ for (let page = 1; page <= BLOCKLIST_MAX_PAGES; page++) {
1067
+ const q = await arrFor(kind)("/blocklist", { query: { ...filter, page, pageSize: BLOCKLIST_PAGE_SIZE } })
1068
+ const records = q?.records ?? []
1069
+ rows.push(...records)
1070
+ if (!records.length || !(Number(q?.totalRecords) > page * BLOCKLIST_PAGE_SIZE)) return rows.filter((r) => r[idKey] === serviceId)
1071
+ }
1072
+ const own = rows.filter((r) => r[idKey] === serviceId)
1073
+ own.truncated = true
1074
+ return own
1075
+ }
1076
+
1077
+ const blocklistReport = (rows) => rows.map((r) => ({ blocklist_id: r.id, title: r.sourceTitle ?? null, date: r.date ?? null, indexer: r.indexer ?? null,
1078
+ quality: r.quality?.quality?.name ?? null, episode_ids: r.episodeIds ?? [], message: r.message ?? null }))
1079
+
1080
+ export async function mediaBlocklist(a) {
1081
+ if (!kindOk(a)) return invalidKind()
1082
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1083
+ const serviceId = Number(a.service_id)
1084
+ const rows = await itemBlocklist(a.kind, serviceId)
1085
+ if (!Array.isArray(a.remove_ids) || !a.remove_ids.length) {
1086
+ return { kind: a.kind, service_id: serviceId, count: rows.length, ...(rows.truncated ? { truncated: true } : {}), entries: blocklistReport(rows) }
1087
+ }
1088
+ const own = new Set(rows.map((r) => r.id))
1089
+ const foreign = a.remove_ids.filter((id) => !own.has(id))
1090
+ if (foreign.length) return { result: "blocklist_id_not_for_item", foreign_blocklist_ids: foreign, entries: blocklistReport(rows) }
1091
+ await arrFor(a.kind)("/blocklist/bulk", { method: "DELETE", body: { ids: a.remove_ids.map(Number) } })
1092
+ const left = await itemBlocklist(a.kind, serviceId)
1093
+ return { result: "removed", removed: a.remove_ids, count: left.length, entries: blocklistReport(left) }
1094
+ }
1095
+
1096
+ async function previewRows(seriesId, downloadId) {
1097
+ const rows = await sonarr("/manualimport", { query: { downloadId, seriesId, filterExistingFiles: false } })
1098
+ return Array.isArray(rows) ? rows : []
1099
+ }
1100
+
1101
+ const previewReport = (rows) => rows.map((r) => ({ path: r.path, name: r.name ?? null, parsed_season: r.seasonNumber ?? null,
1102
+ mapped_episodes: (r.episodes ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
1103
+ quality: r.quality?.quality?.name ?? null, rejections: (r.rejections ?? []).map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) }))
1104
+
1105
+ export async function mediaManualImport(a) {
1106
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1107
+ if (!a.download_id) return { result: "download_id_required" }
1108
+ const seriesId = Number(a.service_id)
1109
+ const rows = await previewRows(seriesId, a.download_id)
1110
+ if (a.mode !== "import") return { mode: "preview", series_id: seriesId, download_id: a.download_id, files: previewReport(rows) }
1111
+ if (!Array.isArray(a.files) || !a.files.length || a.files.some((f) => !f || typeof f.path !== "string")) return { result: "files_required" }
1112
+ const paths = a.files.map((f) => f.path)
1113
+ if (new Set(paths).size !== paths.length) return { result: "duplicate_path" }
1114
+ const byPath = new Map(rows.map((r) => [r.path, r]))
1115
+ const unknown = a.files.filter((f) => !byPath.has(f.path)).map((f) => f.path)
1116
+ if (unknown.length) return { result: "path_not_in_preview", paths: unknown, files: previewReport(rows) }
1117
+ const seriesEps = await seriesEpisodes(seriesId)
1118
+ const own = new Set(seriesEps.map((e) => e.id))
1119
+ const foreign = a.files.flatMap((f) => (f.episode_ids ?? []).filter((id) => !own.has(id)))
1120
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1121
+ const empty = a.files.filter((f) => !Array.isArray(f.episode_ids) || !f.episode_ids.length).map((f) => f.path)
1122
+ if (empty.length) return { result: "episode_ids_required", paths: empty }
1123
+ const targets = a.files.flatMap((f) => f.episode_ids.map(Number))
1124
+ if (new Set(targets).size !== targets.length) return { result: "episode_id_on_two_files" }
1125
+ const files = a.files.map((f) => {
1126
+ const r = byPath.get(f.path)
1127
+ return { path: f.path, seriesId, episodeIds: f.episode_ids.map(Number), quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1128
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId: a.download_id }
1129
+ })
1130
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1131
+ // Episodes that already had a file: Sonarr treats these imports as replacements.
1132
+ const replacing = seriesEps.filter((e) => e.hasFile && targets.includes(e.id)).map((e) => ({ episode_id: e.id, code: sxe(e) }))
1133
+ return { mode: "import", imported: a.files.map((f) => ({ path: f.path, episode_ids: f.episode_ids })), ...(replacing.length ? { replacing } : {}), command: { id: c?.id ?? null, name: c?.name ?? "ManualImport", status: c?.status ?? null } }
1134
+ }
1135
+
1136
+ // ------------------------------------------------------------------- schema
1137
+
1138
+ const TOOLS = [
1139
+ {
1140
+ name: "media_search",
1141
+ description: "Search or browse the household media catalogue. Use `query` for a title lookup: partial and misspelled titles are matched against the library first (Sonarr, Radarr, Jellyfin) and returned as `library_matches` with their kind and service id, and when nothing in the library matches, `lookup_candidates` (TVDB/TMDB) cover titles not in the library. Never say a title does not exist without reading both; use `genres`/`keywords`/`year_range` to browse by vibe (e.g. keywords ['autumn','thanksgiving'] for cozy fall films). `on_shelf: true` restricts to what is already downloaded, `false` to what is not, omit for both. Every item says whether it is on the shelf and carries its tmdb_id so a follow-up media_request needs no second lookup.",
1142
+ inputSchema: { type: "object", properties: {
1143
+ query: { type: "string", description: "Title or free text. Omit to browse by filters." },
1144
+ kind: { type: "string", enum: ["movie", "series", "any"], description: "Default any." },
1145
+ year_range: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "[minYear, maxYear]" },
1146
+ genres: { type: "array", items: { type: "string" }, description: "TMDB genre names, e.g. ['Romance','Drama']." },
1147
+ keywords: { type: "array", items: { type: "string" }, description: "TMDB keywords, e.g. ['autumn','small town']. Best lever for mood/vibe requests." },
1148
+ on_shelf: { type: "boolean", description: "true = only what we already have; false = only what we do not; omit = both." },
1149
+ limit: { type: "number", description: "1-20, default 12." },
1150
+ sort: { type: "string", enum: ["relevance", "release_year"] },
1151
+ } },
1152
+ },
1153
+ {
1154
+ name: "media_request",
1155
+ description: "Request a film or series so it gets downloaded. Give tmdb_id when known (from media_search), otherwise title plus optional year. Idempotent: if it was already requested you get approval_state 'already_requested' and duplicate_of, never a duplicate. Returns the downstream sonarr_id/radarr_id so media_request_status needs no re-resolution.",
1156
+ inputSchema: { type: "object", properties: {
1157
+ tmdb_id: { type: "number" },
1158
+ title: { type: "string" },
1159
+ year: { type: "number" },
1160
+ kind: { type: "string", enum: ["movie", "series"] },
1161
+ season_number: { type: "number", description: "Series only. Omit to request all seasons." },
1162
+ } },
1163
+ },
1164
+ {
1165
+ name: "media_request_status",
1166
+ description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. A queue row is not proof of progress: a release sitting at zero bytes comes back as stalled, not as downloading. Never infer the cause yourself — read diagnosis.summary.",
1167
+ inputSchema: { type: "object", properties: {
1168
+ request_id: { type: "string", description: "e.g. jellyseerr:42" },
1169
+ tmdb_id: { type: "number" },
1170
+ title: { type: "string" },
1171
+ } },
1172
+ },
1173
+ {
1174
+ name: "media_diagnose_and_fix",
1175
+ description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified, including 'blocklist_and_research' for a release that has stalled at zero bytes. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
1176
+ inputSchema: { type: "object", properties: {
1177
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
1178
+ action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
1179
+ dry_run: { type: "boolean" },
1180
+ } },
1181
+ },
1182
+ {
1183
+ name: "media_chain_health",
1184
+ description: "Health of the whole acquisition chain: Prowlarr indexers, their failure state, and whether Prowlarr is syncing to Sonarr/Radarr. Call this when several things are stuck at once, or before telling anyone a specific title is the problem — a dead chain blocks everything and is the single most likely cause.",
1185
+ inputSchema: { type: "object", properties: {} },
1186
+ },
1187
+ {
1188
+ name: "media_play_or_resolve",
1189
+ description: "Resolve something on the shelf to a playable link plus what is actually on disk (episode counts, size, path). If it is not playable you get the same diagnosis block explaining why.",
1190
+ inputSchema: { type: "object", properties: {
1191
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
1192
+ } },
1193
+ },
1194
+ {
1195
+ name: "media_search_now",
1196
+ description: "Trigger a Sonarr/Radarr search for a series or movie that is already in the library, and report its queue. Use this when the owner asks to search, re-search, or look again for something already in Sonarr/Radarr; no Jellyseerr request id is needed. Identify it by service_id, tmdb_id, or exact title. Never adds anything new: zero or several matches come back as 'not_found' or 'ambiguous' with candidates. Never tell the owner to click Search in the web UI.",
1197
+ inputSchema: { type: "object", properties: {
1198
+ kind: { type: "string", enum: ["series", "movie"] },
1199
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
1200
+ title: { type: "string" },
1201
+ tmdb_id: { type: "number" },
1202
+ }, required: ["kind"] },
1203
+ },
1204
+ {
1205
+ name: "media_blocklist_stalled",
1206
+ description: "Clear stuck downloads for a series or movie already in Sonarr/Radarr: remove stalled queue rows from the download client, blocklist those releases so they are not grabbed again, then search again (research, default true). Use this when the owner asks to clear stuck or stalled downloads; no Jellyseerr request id is needed. Without queue_ids it targets the item's stalled rows; with queue_ids only those, and each must belong to the item. Explicit queue_ids are removed even if still downloading, so pass them only when the owner named those downloads. Rows of one season pack are removed once; a delete that fails is reported in `failed` and the re-search still runs.",
1207
+ inputSchema: { type: "object", properties: {
1208
+ kind: { type: "string", enum: ["series", "movie"] },
1209
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
1210
+ queue_ids: { type: "array", items: { type: "number" } },
1211
+ research: { type: "boolean", description: "Search again afterwards. Default true." },
1212
+ }, required: ["kind", "service_id"] },
1213
+ },
1214
+ {
1215
+ name: "media_episodes",
1216
+ description: "List a Sonarr series' episodes (optionally one season): episode_id, SxxEyy code, scene_code (the numbering release names use, when Sonarr has a mapping), title, air date, has_file. Use it to see what the TVDB numbering calls each episode before searching for releases by hand.",
1217
+ inputSchema: { type: "object", properties: {
1218
+ service_id: { type: "number", description: "Sonarr series id." },
1219
+ season_number: { type: "number" },
1220
+ }, required: ["service_id"] },
1221
+ },
1222
+ {
1223
+ name: "media_indexer_search",
1224
+ description: "Free-text search across every indexer (Prowlarr), sorted by seeders. Discovery only, it grabs nothing. Use it when Sonarr/Radarr's own search finds nothing: try the scene code from media_episodes, 'Season N', the year, or an episode title, to learn whether any release exists and how it is named. Zero seeders means it cannot download right now.",
1225
+ inputSchema: { type: "object", properties: {
1226
+ query: { type: "string" },
1227
+ limit: { type: "number", description: "1-50, default 25." },
1228
+ }, required: ["query"] },
1229
+ },
1230
+ {
1231
+ name: "media_release_search",
1232
+ description: "Interactive release search for a series or movie already in the library: what the indexers offer and how Sonarr/Radarr parsed each release (full_season, parsed_season, parsed_episodes, mapped_episodes), whether it is rejected and why, and whether it is blocklisted. Series: give season_number or episode_ids. Can take a minute or more. `query` filters returned titles. Use it when the automatic search found nothing, then grab with media_release_grab.",
1233
+ inputSchema: { type: "object", properties: {
1234
+ kind: { type: "string", enum: ["series", "movie"] },
1235
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
1236
+ season_number: { type: "number" },
1237
+ episode_ids: { type: "array", items: { type: "number" } },
1238
+ query: { type: "string", description: "Case-insensitive text the release title must contain." },
1239
+ limit: { type: "number", description: "1-100, default 40." },
1240
+ }, required: ["kind", "service_id"] },
1241
+ },
1242
+ {
1243
+ name: "media_release_grab",
1244
+ description: "Grab one release from the last media_release_search by guid and indexer_id. For a release Sonarr mis-parsed (streaming numbering vs TVDB), pass series_id plus episode_ids to override the mapping; episode_ids must belong to the series. Without episode_ids, a season pack that Sonarr parsed as a different season than season_number is refused ('season_pack_mismatch'). Returns the exact request sent and Sonarr's answer.",
1245
+ inputSchema: { type: "object", properties: {
1246
+ kind: { type: "string", enum: ["series", "movie"] },
1247
+ guid: { type: "string" },
1248
+ indexer_id: { type: "number" },
1249
+ series_id: { type: "number", description: "Required with episode_ids." },
1250
+ episode_ids: { type: "array", items: { type: "number" }, description: "Episodes this release really holds." },
1251
+ season_number: { type: "number", description: "Target season, checked against a season pack's parsed season when episode_ids is omitted." },
1252
+ download_client_id: { type: "number" },
1253
+ }, required: ["kind", "guid", "indexer_id"] },
1254
+ },
1255
+ {
1256
+ name: "media_blocklist",
1257
+ description: "List the blocklist for a series or movie in Sonarr/Radarr, and optionally remove entries (remove_ids) so the release can be grabbed again. Each id must belong to the item; a foreign id removes nothing.",
1258
+ inputSchema: { type: "object", properties: {
1259
+ kind: { type: "string", enum: ["series", "movie"] },
1260
+ service_id: { type: "number" },
1261
+ remove_ids: { type: "array", items: { type: "number" } },
1262
+ }, required: ["kind", "service_id"] },
1263
+ },
1264
+ {
1265
+ name: "media_manual_import",
1266
+ description: "Map downloaded files to episodes by hand when their names carry the wrong numbering (Sonarr only). mode 'preview' lists the files of a download with the parsed and mapped episodes and rejections. mode 'import' takes files [{path, episode_ids}]: paths must come from that download's preview and episode_ids must belong to the series; quality and languages come from the preview. Match files to episodes by title using media_episodes.",
1267
+ inputSchema: { type: "object", properties: {
1268
+ mode: { type: "string", enum: ["preview", "import"], description: "Default preview." },
1269
+ service_id: { type: "number", description: "Sonarr series id." },
1270
+ download_id: { type: "string", description: "Download client id of the completed download (queue row downloadId)." },
1271
+ files: { type: "array", items: { type: "object", properties: { path: { type: "string" }, episode_ids: { type: "array", items: { type: "number" } } }, required: ["path", "episode_ids"] } },
1272
+ }, required: ["service_id", "download_id"] },
1273
+ },
1274
+ ]
1275
+
1276
+ const HANDLERS = {
1277
+ media_search: mediaSearch,
1278
+ media_request: mediaRequest,
1279
+ media_request_status: mediaRequestStatus,
1280
+ media_diagnose_and_fix: mediaDiagnoseAndFix,
1281
+ media_chain_health: mediaChainHealth,
1282
+ media_play_or_resolve: mediaPlayOrResolve,
1283
+ media_search_now: mediaSearchNow,
1284
+ media_blocklist_stalled: mediaBlocklistStalled,
1285
+ media_episodes: mediaEpisodes,
1286
+ media_indexer_search: mediaIndexerSearch,
1287
+ media_release_search: mediaReleaseSearch,
1288
+ media_release_grab: mediaReleaseGrab,
1289
+ media_blocklist: mediaBlocklist,
1290
+ media_manual_import: mediaManualImport,
1291
+ }
1292
+
1293
+ // ------------------------------------------------------------ jsonrpc stdio
1294
+
1295
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n") }
1296
+
1297
+ async function handle(msg) {
1298
+ const { id, method, params } = msg
1299
+ if (method === "initialize") {
1300
+ return send({ jsonrpc: "2.0", id, result: {
1301
+ protocolVersion: "2024-11-05",
1302
+ capabilities: { tools: {} },
1303
+ serverInfo: { name: "sanctuary-media", version: "1.0.0" },
1304
+ } })
1305
+ }
1306
+ if (method === "initialized" || method === "notifications/initialized") return
1307
+ if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } })
1308
+ if (method === "tools/call") {
1309
+ const fn = HANDLERS[params?.name]
1310
+ if (!fn) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: `Unknown tool: ${params?.name}` }] } })
1311
+ if (credError) {
1312
+ const payload = { error: "credentials_unavailable", message: credError, human_action_required: true,
1313
+ human_action_reason: credError }
1314
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
1315
+ }
1316
+ try {
1317
+ const out = await fn(params.arguments ?? {})
1318
+ return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
1319
+ } catch (e) {
1320
+ const payload = { error: e.code ?? "tool_failed", message: e.message,
1321
+ human_action_required: true,
1322
+ human_action_reason: `The media tool could not complete: ${e.message}. Report this failure rather than guessing the answer.` }
1323
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
1324
+ }
1325
+ }
1326
+ if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } })
1327
+ }
1328
+
1329
+ let buf = ""
1330
+ let inFlight = 0
1331
+ let stdinClosed = false
1332
+
1333
+ // Exit only when stdin is closed AND nothing is still running. Exiting on 'end'
1334
+ // alone drops in-flight tool calls, which silently truncates a slow answer.
1335
+ function exitWhenIdle() {
1336
+ if (stdinClosed && inFlight === 0) process.exit(0)
1337
+ }
1338
+
1339
+ // Only run the stdio server when invoked directly (node media-mcp.mjs, exactly
1340
+ // how agent.json launches it). Guarding this lets the test suite import the
1341
+ // module for computeDownloadState/diagnose without attaching to stdin or exiting
1342
+ // the test process. The launch is a plain node <abs-path>, so argv[1] is the
1343
+ // file's own path and this comparison holds.
1344
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
1345
+ process.stdin.setEncoding("utf8")
1346
+ process.stdin.on("data", (chunk) => {
1347
+ buf += chunk
1348
+ let nl
1349
+ while ((nl = buf.indexOf("\n")) >= 0) {
1350
+ const line = buf.slice(0, nl).trim()
1351
+ buf = buf.slice(nl + 1)
1352
+ if (!line) continue
1353
+ let msg
1354
+ try { msg = JSON.parse(line) } catch { continue }
1355
+ inFlight += 1
1356
+ handle(msg)
1357
+ .catch((e) => {
1358
+ if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } })
1359
+ })
1360
+ .finally(() => { inFlight -= 1; exitWhenIdle() })
1361
+ }
1362
+ })
1363
+ process.stdin.on("end", () => { stdinClosed = true; exitWhenIdle() })
1364
+ }