@ouro.bot/cli 0.1.0-alpha.98 → 0.1.0-alpha.981

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 (763) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +171 -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 +6195 -16
  16. package/deploy/unraid/Dockerfile +58 -0
  17. package/deploy/unraid/INSTALL.md +64 -0
  18. package/deploy/unraid/README.txt +2997 -0
  19. package/deploy/unraid/audit-container-spec.sh +4 -0
  20. package/deploy/unraid/container-runtime.json +1 -0
  21. package/deploy/unraid/credit-gateway.md +95 -0
  22. package/deploy/unraid/docker-man-template-transaction.mjs +727 -0
  23. package/deploy/unraid/docker-man-template-xml.cjs +105 -0
  24. package/deploy/unraid/install.sh +274 -0
  25. package/deploy/unraid/migrate-sanctuary-bundle.mjs +63 -0
  26. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  27. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  28. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  29. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  30. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  31. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  32. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  33. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  34. package/deploy/unraid/sanctuary-authority-installation.json +36 -0
  35. package/deploy/unraid/sanctuary-authority-service.sh +30 -0
  36. package/deploy/unraid/sanctuary-butler-upgrade.mjs +1377 -0
  37. package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
  38. package/deploy/unraid/sanctuary-replay-gate.mjs +1776 -0
  39. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  40. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2088 -0
  41. package/deploy/unraid/sanctuary-unit16-run.sh +570 -0
  42. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  43. package/deploy/unraid/sanctuary.ouro/agent.json +66 -0
  44. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  45. package/deploy/unraid/sanctuary.ouro/awaiting/house-care-sweep.md +17 -0
  46. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  47. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  48. package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +2255 -0
  49. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  50. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  51. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +7 -0
  52. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +33 -0
  53. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +27 -0
  54. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +21 -0
  55. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  56. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +232 -0
  57. package/deploy/unraid/sanctuary.xml +29 -0
  58. package/dist/a2a/card.js +57 -0
  59. package/dist/a2a/client.js +250 -0
  60. package/dist/a2a/config.js +50 -0
  61. package/dist/a2a/delegated-command-grants.js +93 -0
  62. package/dist/a2a/delegated-command.js +197 -0
  63. package/dist/a2a/delegation-stores.js +56 -0
  64. package/dist/a2a/did-resolution.js +93 -0
  65. package/dist/a2a/escalation-grants.js +84 -0
  66. package/dist/a2a/escalation-wiring.js +60 -0
  67. package/dist/a2a/identity.js +258 -0
  68. package/dist/a2a/inbound-share.js +110 -0
  69. package/dist/a2a/mission-result-wire.js +196 -0
  70. package/dist/a2a/onboarding.js +133 -0
  71. package/dist/a2a/operator-trust.js +399 -0
  72. package/dist/a2a/outbox-client.js +57 -0
  73. package/dist/a2a/outbox-store.js +188 -0
  74. package/dist/a2a/outbox-wire.js +95 -0
  75. package/dist/a2a/pin-store.js +132 -0
  76. package/dist/a2a/replay-grant-holders.js +33 -0
  77. package/dist/a2a/replay-harness.js +188 -0
  78. package/dist/a2a/resolution-proof.js +53 -0
  79. package/dist/a2a/sealed-chat.js +113 -0
  80. package/dist/a2a/seen-ledger.js +120 -0
  81. package/dist/a2a/server.js +771 -0
  82. package/dist/a2a/task-store.js +69 -0
  83. package/dist/a2a/transport.js +45 -0
  84. package/dist/a2a/trust-commands.js +203 -0
  85. package/dist/a2a/trusted-files.js +185 -0
  86. package/dist/a2a/types.js +3 -0
  87. package/dist/arc/attention-types.js +8 -0
  88. package/dist/arc/cares.js +360 -0
  89. package/dist/arc/episodes.js +118 -0
  90. package/dist/arc/evolution.js +490 -0
  91. package/dist/arc/flight-recorder.js +1034 -0
  92. package/dist/arc/intentions.js +134 -0
  93. package/dist/arc/json-store.js +117 -0
  94. package/dist/arc/obligations.js +386 -0
  95. package/dist/arc/packets.js +336 -0
  96. package/dist/arc/presence.js +185 -0
  97. package/dist/arc/task-lifecycle.js +57 -0
  98. package/dist/commerce/store.js +755 -0
  99. package/dist/commerce/types.js +3 -0
  100. package/dist/gateway/keys.js +117 -0
  101. package/dist/gateway/server.js +429 -0
  102. package/dist/heart/active-work.js +798 -40
  103. package/dist/heart/agent-entry.js +252 -3
  104. package/dist/heart/approval-files.js +113 -0
  105. package/dist/heart/approval-store.js +960 -0
  106. package/dist/heart/attachments/image-normalize.js +201 -0
  107. package/dist/heart/attachments/materialize.js +97 -0
  108. package/dist/heart/attachments/originals.js +88 -0
  109. package/dist/heart/attachments/render.js +29 -0
  110. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  111. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  112. package/dist/heart/attachments/sources/index.js +18 -0
  113. package/dist/heart/attachments/sources/telegram.js +71 -0
  114. package/dist/heart/attachments/store.js +132 -0
  115. package/dist/heart/attachments/types.js +93 -0
  116. package/dist/heart/auth/auth-flow.js +511 -0
  117. package/dist/heart/autonomy-budget.js +478 -0
  118. package/dist/heart/awaiting/a2a-await-delivery.js +109 -0
  119. package/dist/heart/awaiting/await-alert.js +152 -0
  120. package/dist/heart/awaiting/await-expiry.js +143 -0
  121. package/dist/heart/awaiting/await-loader.js +91 -0
  122. package/dist/heart/awaiting/await-parser.js +152 -0
  123. package/dist/heart/awaiting/await-runtime-state.js +130 -0
  124. package/dist/heart/awaiting/await-scheduler.js +391 -0
  125. package/dist/heart/background-operations.js +281 -0
  126. package/dist/heart/bluebubbles-host-account.js +78 -0
  127. package/dist/heart/bridges/manager.js +137 -17
  128. package/dist/heart/bridges/store.js +14 -2
  129. package/dist/heart/bundle-state.js +168 -0
  130. package/dist/heart/commitments.js +44 -18
  131. package/dist/heart/config-registry.js +331 -0
  132. package/dist/heart/config.js +178 -131
  133. package/dist/heart/context-loss-gauntlet.js +387 -0
  134. package/dist/heart/context-loss-sentinel.js +1204 -0
  135. package/dist/heart/core.js +2377 -586
  136. package/dist/heart/cross-chat-delivery.js +9 -22
  137. package/dist/heart/daemon/agent-config-check.js +474 -0
  138. package/dist/heart/daemon/agent-discovery.js +283 -3
  139. package/dist/heart/daemon/agent-home.js +319 -0
  140. package/dist/heart/daemon/agent-service.js +542 -0
  141. package/dist/heart/daemon/agentic-repair.js +547 -0
  142. package/dist/heart/daemon/await-private-wake.js +59 -0
  143. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  144. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  145. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  146. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  147. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  148. package/dist/heart/daemon/cadence.js +180 -0
  149. package/dist/heart/daemon/cli-arg-redaction.js +26 -0
  150. package/dist/heart/daemon/cli-defaults.js +881 -0
  151. package/dist/heart/daemon/cli-desk.js +331 -0
  152. package/dist/heart/daemon/cli-exec.js +9272 -0
  153. package/dist/heart/daemon/cli-help.js +754 -0
  154. package/dist/heart/daemon/cli-parse.js +3329 -0
  155. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  156. package/dist/heart/daemon/cli-render.js +819 -0
  157. package/dist/heart/daemon/cli-types.js +8 -0
  158. package/dist/heart/daemon/connect-bay.js +323 -0
  159. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  160. package/dist/heart/daemon/container-healthcheck.js +112 -0
  161. package/dist/heart/daemon/container-runtime.js +97 -0
  162. package/dist/heart/daemon/container-spec-auditor-main.js +171 -0
  163. package/dist/heart/daemon/container-spec-auditor.js +241 -0
  164. package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
  165. package/dist/heart/daemon/daemon-cli.js +29 -2373
  166. package/dist/heart/daemon/daemon-entry.js +953 -11
  167. package/dist/heart/daemon/daemon-health.js +186 -0
  168. package/dist/heart/daemon/daemon-rollup.js +57 -0
  169. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  170. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  171. package/dist/heart/daemon/daemon.js +2110 -104
  172. package/dist/heart/daemon/desk-admin.js +420 -0
  173. package/dist/heart/daemon/dns-workflow.js +394 -0
  174. package/dist/heart/daemon/doctor-types.js +8 -0
  175. package/dist/heart/daemon/doctor.js +1766 -0
  176. package/dist/heart/daemon/freshness.js +345 -0
  177. package/dist/heart/daemon/habit-private-wake.js +61 -0
  178. package/dist/heart/daemon/health-monitor.js +154 -11
  179. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  180. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  181. package/dist/heart/daemon/http-health-probe.js +80 -0
  182. package/dist/heart/daemon/human-command-screens.js +234 -0
  183. package/dist/heart/daemon/human-readiness.js +114 -0
  184. package/dist/heart/daemon/inner-status.js +78 -0
  185. package/dist/heart/daemon/interactive-repair.js +394 -0
  186. package/dist/heart/daemon/launchd.js +37 -8
  187. package/dist/heart/daemon/log-tailer.js +79 -10
  188. package/dist/heart/daemon/logs-prune.js +132 -0
  189. package/dist/heart/daemon/mcp-canary.js +524 -0
  190. package/dist/heart/daemon/message-router.js +65 -5
  191. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  192. package/dist/heart/daemon/os-cron-deps.js +135 -0
  193. package/dist/heart/daemon/os-cron.js +22 -14
  194. package/dist/heart/daemon/ouro-bot-entry.js +6 -3
  195. package/dist/heart/daemon/ouro-entry.js +9 -3
  196. package/dist/heart/daemon/plugin-cli.js +432 -0
  197. package/dist/heart/daemon/process-manager.js +760 -61
  198. package/dist/heart/daemon/provider-discovery.js +137 -0
  199. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  200. package/dist/heart/daemon/prunable-bundle.js +144 -0
  201. package/dist/heart/daemon/pulse.js +511 -0
  202. package/dist/heart/daemon/readiness-repair.js +365 -0
  203. package/dist/heart/daemon/run-hooks.js +2 -0
  204. package/dist/heart/daemon/runtime-logging.js +47 -14
  205. package/dist/heart/daemon/runtime-metadata.js +2 -30
  206. package/dist/heart/daemon/runtime-mode.js +13 -2
  207. package/dist/heart/daemon/safe-mode.js +161 -0
  208. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2662 -0
  209. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1832 -0
  210. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  211. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +956 -0
  212. package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
  213. package/dist/heart/daemon/sanctuary-authority-epoch.js +291 -0
  214. package/dist/heart/daemon/sanctuary-authority-installation.js +161 -0
  215. package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
  216. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +1326 -0
  217. package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
  218. package/dist/heart/daemon/sanctuary-bundle-migration.js +851 -0
  219. package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
  220. package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +493 -0
  221. package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
  222. package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
  223. package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
  224. package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
  225. package/dist/heart/daemon/sanctuary-package-management.js +103 -0
  226. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  227. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  228. package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +475 -0
  229. package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
  230. package/dist/heart/daemon/sanctuary-telegram-authority-service.js +676 -0
  231. package/dist/heart/daemon/sense-manager.js +786 -48
  232. package/dist/heart/daemon/session-id-resolver.js +131 -0
  233. package/dist/heart/daemon/skill-management-installer.js +1 -1
  234. package/dist/heart/daemon/socket-client.js +180 -12
  235. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  236. package/dist/heart/daemon/startup-tui.js +339 -0
  237. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  238. package/dist/heart/daemon/task-scheduler.js +117 -39
  239. package/dist/heart/daemon/terminal-ui.js +503 -0
  240. package/dist/heart/daemon/thoughts.js +138 -54
  241. package/dist/heart/daemon/up-progress.js +366 -0
  242. package/dist/heart/daemon/vault-items.js +56 -0
  243. package/dist/heart/delegation.js +1 -4
  244. package/dist/heart/external-events/router.js +1531 -0
  245. package/dist/heart/failure-reports.js +336 -0
  246. package/dist/heart/file-lock.js +109 -0
  247. package/dist/heart/frontend-approval-runtime.js +506 -0
  248. package/dist/heart/frontend-journal.js +215 -0
  249. package/dist/heart/frontend-session-service.js +271 -0
  250. package/dist/heart/frontend-socket-client.js +173 -0
  251. package/dist/heart/frontend-socket.js +434 -0
  252. package/dist/heart/frontend-turn-errors.js +20 -0
  253. package/dist/heart/habits/habit-cancel.js +810 -0
  254. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  255. package/dist/heart/habits/habit-migration.js +194 -0
  256. package/dist/heart/habits/habit-parser.js +278 -0
  257. package/dist/heart/habits/habit-runtime-state.js +93 -0
  258. package/dist/heart/habits/habit-scheduler.js +653 -0
  259. package/dist/heart/habits/habit-session-summary.js +318 -0
  260. package/dist/heart/habits/habit-session.js +636 -0
  261. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  262. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  263. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  264. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  265. package/dist/heart/identity.js +239 -59
  266. package/dist/heart/kept-notes.js +289 -0
  267. package/dist/heart/kicks.js +1 -1
  268. package/dist/heart/machine-identity.js +175 -0
  269. package/dist/heart/mail-import-discovery.js +356 -0
  270. package/dist/heart/mailbox/mailbox-http-hooks.js +99 -0
  271. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  272. package/dist/heart/mailbox/mailbox-http-routes.js +406 -0
  273. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  274. package/dist/heart/mailbox/mailbox-http-transport.js +272 -0
  275. package/dist/heart/mailbox/mailbox-http.js +99 -0
  276. package/dist/heart/mailbox/mailbox-read.js +41 -0
  277. package/dist/heart/mailbox/mailbox-supervision.js +131 -0
  278. package/dist/heart/mailbox/mailbox-types.js +27 -0
  279. package/dist/heart/mailbox/mailbox-view.js +197 -0
  280. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  281. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  282. package/dist/heart/mailbox/readers/mail.js +375 -0
  283. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  284. package/dist/heart/mailbox/readers/sessions.js +231 -0
  285. package/dist/heart/mailbox/readers/shared.js +111 -0
  286. package/dist/heart/mcp/mcp-server.js +760 -0
  287. package/dist/heart/migrate-config.js +100 -0
  288. package/dist/heart/model-capabilities.js +83 -16
  289. package/dist/heart/orientation-frame.js +376 -0
  290. package/dist/heart/platform.js +81 -0
  291. package/dist/heart/primitives.js +1 -1
  292. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  293. package/dist/heart/private-runtime/index.js +18 -0
  294. package/dist/heart/private-runtime/ledger.js +221 -0
  295. package/dist/heart/private-runtime/policy.js +260 -0
  296. package/dist/heart/private-runtime/types.js +2 -0
  297. package/dist/heart/progress-story.js +1 -1
  298. package/dist/heart/provider-attempt.js +137 -0
  299. package/dist/heart/provider-binding-resolver.js +273 -0
  300. package/dist/heart/provider-credentials.js +449 -0
  301. package/dist/heart/provider-failover.js +311 -0
  302. package/dist/heart/provider-models.js +96 -0
  303. package/dist/heart/provider-ping.js +266 -0
  304. package/dist/heart/provider-readiness-cache.js +119 -0
  305. package/dist/heart/provider-visibility.js +188 -0
  306. package/dist/heart/providers/anthropic-token.js +131 -0
  307. package/dist/heart/providers/anthropic.js +248 -67
  308. package/dist/heart/providers/azure.js +23 -11
  309. package/dist/heart/providers/error-classification.js +129 -0
  310. package/dist/heart/providers/github-copilot.js +38 -39
  311. package/dist/heart/providers/minimax-vlm.js +189 -0
  312. package/dist/heart/providers/minimax.js +26 -8
  313. package/dist/heart/providers/openai-codex-token.js +349 -0
  314. package/dist/heart/providers/openai-codex.js +55 -40
  315. package/dist/heart/providers/openai-compatible.js +177 -0
  316. package/dist/heart/reach-channel.js +85 -0
  317. package/dist/heart/reply-style.js +52 -0
  318. package/dist/heart/run-ledger.js +247 -0
  319. package/dist/heart/runtime-capability-check.js +170 -0
  320. package/dist/heart/runtime-credentials.js +608 -0
  321. package/dist/heart/runtime-cwd.js +87 -0
  322. package/dist/heart/sense-truth.js +24 -4
  323. package/dist/heart/session-activity.js +94 -24
  324. package/dist/heart/session-events.js +1521 -0
  325. package/dist/heart/session-playback-cli-main.js +5 -0
  326. package/dist/heart/session-playback-cli.js +36 -0
  327. package/dist/heart/session-playback.js +231 -0
  328. package/dist/heart/session-redaction-repair-cli-main.js +31 -0
  329. package/dist/heart/session-redaction-repair.js +552 -0
  330. package/dist/heart/session-stats-cli-main.js +5 -0
  331. package/dist/heart/session-stats.js +183 -0
  332. package/dist/heart/session-transcript.js +133 -0
  333. package/dist/heart/shelf-claims.js +78 -0
  334. package/dist/heart/shepherd/custody-types.js +2 -0
  335. package/dist/heart/shepherd/mandates.js +352 -0
  336. package/dist/heart/shepherd/operation-commands.js +19 -0
  337. package/dist/heart/shepherd/operations.js +135 -0
  338. package/dist/heart/shepherd/supervision.js +114 -0
  339. package/dist/heart/shepherd/types.js +2 -0
  340. package/dist/heart/shepherd-config.js +123 -0
  341. package/dist/heart/source-grounding.js +313 -0
  342. package/dist/heart/start-of-turn-packet.js +372 -0
  343. package/dist/heart/steward-policy.js +656 -0
  344. package/dist/heart/streaming.js +631 -207
  345. package/dist/heart/structured-output.js +196 -0
  346. package/dist/heart/sync-classification.js +176 -0
  347. package/dist/heart/sync-health.js +152 -0
  348. package/dist/heart/sync.js +726 -0
  349. package/dist/heart/target-resolution.js +11 -7
  350. package/dist/heart/tempo.js +93 -0
  351. package/dist/heart/temporal-view.js +41 -0
  352. package/dist/heart/timeouts.js +101 -0
  353. package/dist/heart/tool-activity-callbacks.js +59 -0
  354. package/dist/heart/tool-approval.js +382 -0
  355. package/dist/heart/tool-description.js +155 -0
  356. package/dist/heart/tool-friction.js +55 -0
  357. package/dist/heart/tool-loop.js +206 -0
  358. package/dist/heart/turn-context.js +496 -0
  359. package/dist/heart/turn-execution-lease.js +30 -0
  360. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  361. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +3 -2
  362. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  363. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  364. package/dist/heart/versioning/ouro-version-manager.js +432 -0
  365. package/dist/heart/versioning/release-sources.js +173 -0
  366. package/dist/heart/versioning/self-update-defaults.js +294 -0
  367. package/dist/heart/versioning/self-update.js +196 -0
  368. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  369. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  370. package/dist/heart/versioning/update-hooks.js +154 -0
  371. package/dist/heart/versioning/update-state.js +126 -0
  372. package/dist/heart/versioning/updater-launchd.js +150 -0
  373. package/dist/heart/versioning/version-intent.js +114 -0
  374. package/dist/heart/work-card.js +380 -0
  375. package/dist/mailbox-ui/assets/index-Dbska1tZ.js +1 -0
  376. package/dist/mailbox-ui/assets/index-DsiKaPyH.css +1 -0
  377. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  378. package/dist/mailbox-ui/index.html +16 -0
  379. package/dist/mailroom/attention.js +167 -0
  380. package/dist/mailroom/autonomy.js +209 -0
  381. package/dist/mailroom/blob-store.js +889 -0
  382. package/dist/mailroom/body-cache.js +61 -0
  383. package/dist/mailroom/cache-sync-cli.js +58 -0
  384. package/dist/mailroom/core.js +803 -0
  385. package/dist/mailroom/entry.js +160 -0
  386. package/dist/mailroom/file-store.js +568 -0
  387. package/dist/mailroom/hosted-cache-sync.js +384 -0
  388. package/dist/mailroom/mbox-import.js +393 -0
  389. package/dist/mailroom/migration.js +164 -0
  390. package/dist/mailroom/outbound.js +380 -0
  391. package/dist/mailroom/policy.js +263 -0
  392. package/dist/mailroom/reader.js +292 -0
  393. package/dist/mailroom/search-cache.js +513 -0
  394. package/dist/mailroom/search-relevance.js +319 -0
  395. package/dist/mailroom/smtp-ingress.js +176 -0
  396. package/dist/mailroom/source-state.js +176 -0
  397. package/dist/mailroom/thread.js +109 -0
  398. package/dist/mailroom/travel-extract.js +89 -0
  399. package/dist/mind/bundle-manifest.js +24 -2
  400. package/dist/mind/context.js +328 -139
  401. package/dist/mind/desk-section.js +362 -0
  402. package/dist/mind/diary-integrity.js +60 -0
  403. package/dist/mind/{memory.js → diary.js} +68 -77
  404. package/dist/mind/embedding-provider.js +60 -0
  405. package/dist/mind/file-state.js +179 -0
  406. package/dist/mind/friend-link-codes.js +209 -0
  407. package/dist/mind/friend-merge.js +336 -0
  408. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  409. package/dist/mind/obligation-steering.js +199 -3
  410. package/dist/mind/outbound-record.js +168 -0
  411. package/dist/mind/pending.js +36 -10
  412. package/dist/mind/prompt-budget.js +479 -0
  413. package/dist/mind/prompt-refresh.js +3 -2
  414. package/dist/mind/prompt.js +1166 -203
  415. package/dist/mind/provenance-trust.js +26 -0
  416. package/dist/mind/record-paths.js +312 -0
  417. package/dist/mind/scrutiny.js +173 -0
  418. package/dist/mind/session-transaction.js +622 -0
  419. package/dist/nerves/cli-logging.js +7 -1
  420. package/dist/nerves/coverage/audit-rules.js +15 -6
  421. package/dist/nerves/coverage/audit.js +28 -2
  422. package/dist/nerves/coverage/cli.js +1 -1
  423. package/dist/nerves/coverage/contract.js +5 -5
  424. package/dist/nerves/coverage/file-completeness.js +143 -5
  425. package/dist/nerves/coverage/run-artifacts.js +55 -35
  426. package/dist/nerves/coverage/source-scanner.js +4 -4
  427. package/dist/nerves/event-buffer.js +111 -0
  428. package/dist/nerves/index.js +293 -9
  429. package/dist/nerves/observation.js +20 -0
  430. package/dist/nerves/redact.js +79 -0
  431. package/dist/nerves/review/cli-main.js +5 -0
  432. package/dist/nerves/review/cli.js +156 -0
  433. package/dist/nerves/review/core.js +159 -0
  434. package/dist/nerves/runtime.js +17 -1
  435. package/dist/repertoire/ado-client.js +15 -56
  436. package/dist/repertoire/ado-semantic.js +16 -10
  437. package/dist/repertoire/api-client.js +97 -0
  438. package/dist/repertoire/bitwarden-store.js +1199 -0
  439. package/dist/repertoire/bundle-templates.js +71 -0
  440. package/dist/repertoire/bw-installer.js +231 -0
  441. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  442. package/dist/repertoire/coding/context-pack.js +331 -0
  443. package/dist/repertoire/coding/feedback.js +146 -34
  444. package/dist/repertoire/coding/index.js +15 -3
  445. package/dist/repertoire/coding/manager.js +159 -10
  446. package/dist/repertoire/coding/spawner.js +40 -11
  447. package/dist/repertoire/coding/tools.js +182 -18
  448. package/dist/repertoire/coding/workbench-client.js +272 -0
  449. package/dist/repertoire/coding/workbench-manager.js +346 -0
  450. package/dist/repertoire/commerce-errors.js +109 -0
  451. package/dist/repertoire/commerce-self-test.js +156 -0
  452. package/dist/repertoire/credential-access.js +183 -0
  453. package/dist/repertoire/desk/classifier.js +362 -0
  454. package/dist/repertoire/duffel-client.js +185 -0
  455. package/dist/repertoire/github-client.js +14 -55
  456. package/dist/repertoire/graph-client.js +11 -52
  457. package/dist/repertoire/guardrails.js +971 -20
  458. package/dist/repertoire/house-sweep.js +471 -0
  459. package/dist/repertoire/mcp-client.js +58 -5
  460. package/dist/repertoire/mcp-manager.js +427 -139
  461. package/dist/repertoire/mcp-tools.js +132 -0
  462. package/dist/repertoire/mcp-write-guard.js +46 -0
  463. package/dist/repertoire/plugin-mcp.js +175 -0
  464. package/dist/repertoire/plugins.js +253 -0
  465. package/dist/repertoire/relationship-authorization.js +322 -0
  466. package/dist/repertoire/sanctuary-container-policy.js +50 -0
  467. package/dist/repertoire/shell-sessions.js +134 -0
  468. package/dist/repertoire/skills.js +48 -4
  469. package/dist/repertoire/stripe-client.js +131 -0
  470. package/dist/repertoire/tool-arguments.js +182 -0
  471. package/dist/repertoire/tool-results.js +29 -0
  472. package/dist/repertoire/tools-a2a.js +699 -0
  473. package/dist/repertoire/tools-attachments.js +322 -0
  474. package/dist/repertoire/tools-awaiting.js +897 -0
  475. package/dist/repertoire/tools-base.js +83 -1168
  476. package/dist/repertoire/tools-bluebubbles.js +2 -0
  477. package/dist/repertoire/tools-bridge.js +144 -0
  478. package/dist/repertoire/tools-bundle.js +995 -0
  479. package/dist/repertoire/tools-commerce.js +253 -0
  480. package/dist/repertoire/tools-config.js +186 -0
  481. package/dist/repertoire/tools-continuity.js +588 -0
  482. package/dist/repertoire/tools-credential.js +383 -0
  483. package/dist/repertoire/tools-escalation.js +84 -0
  484. package/dist/repertoire/tools-evolution.js +527 -0
  485. package/dist/repertoire/tools-files.js +344 -0
  486. package/dist/repertoire/tools-flight.js +290 -0
  487. package/dist/repertoire/tools-flow.js +119 -0
  488. package/dist/repertoire/tools-friend-link.js +57 -0
  489. package/dist/repertoire/tools-github.js +3 -8
  490. package/dist/repertoire/tools-habits.js +103 -0
  491. package/dist/repertoire/tools-house-care.js +174 -0
  492. package/dist/repertoire/tools-mail.js +1828 -0
  493. package/dist/repertoire/tools-notes.js +501 -0
  494. package/dist/repertoire/tools-obligations.js +182 -0
  495. package/dist/repertoire/tools-orientation.js +31 -0
  496. package/dist/repertoire/tools-record.js +482 -0
  497. package/dist/repertoire/tools-rsvp.js +139 -0
  498. package/dist/repertoire/tools-runtime.js +238 -0
  499. package/dist/repertoire/tools-sanctuary-host.js +202 -0
  500. package/dist/repertoire/tools-session.js +1141 -0
  501. package/dist/repertoire/tools-shell.js +154 -0
  502. package/dist/repertoire/tools-shepherd-guidance.js +171 -0
  503. package/dist/repertoire/tools-shepherd.js +325 -0
  504. package/dist/repertoire/tools-steward-policy.js +147 -0
  505. package/dist/repertoire/tools-stripe.js +224 -0
  506. package/dist/repertoire/tools-surface.js +369 -0
  507. package/dist/repertoire/tools-teams.js +50 -39
  508. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  509. package/dist/repertoire/tools-travel.js +125 -0
  510. package/dist/repertoire/tools-trip.js +982 -0
  511. package/dist/repertoire/tools-unraid.js +556 -0
  512. package/dist/repertoire/tools-user-profile.js +146 -0
  513. package/dist/repertoire/tools-vault.js +40 -0
  514. package/dist/repertoire/tools-voice-local.js +198 -0
  515. package/dist/repertoire/tools-voice.js +145 -0
  516. package/dist/repertoire/tools.js +518 -156
  517. package/dist/repertoire/travel-api-client.js +360 -0
  518. package/dist/repertoire/unraid-client.js +155 -0
  519. package/dist/repertoire/unraid-restart.js +339 -0
  520. package/dist/repertoire/user-profile.js +131 -0
  521. package/dist/repertoire/vault-setup.js +246 -0
  522. package/dist/repertoire/vault-unlock.js +686 -0
  523. package/dist/rsvp/aisleplanner-client.js +354 -0
  524. package/dist/rsvp/cli.js +936 -0
  525. package/dist/rsvp/config.js +327 -0
  526. package/dist/rsvp/cutover.js +453 -0
  527. package/dist/rsvp/diagnostics.js +232 -0
  528. package/dist/rsvp/diff-renderer.js +176 -0
  529. package/dist/rsvp/habit-policy.js +110 -0
  530. package/dist/rsvp/habit-stage.js +123 -0
  531. package/dist/rsvp/incident-bundle.js +97 -0
  532. package/dist/rsvp/migration.js +204 -0
  533. package/dist/rsvp/native-habit-runner.js +630 -0
  534. package/dist/rsvp/outbound-state.js +628 -0
  535. package/dist/rsvp/query.js +118 -0
  536. package/dist/rsvp/replay.js +233 -0
  537. package/dist/rsvp/snapshot.js +232 -0
  538. package/dist/rsvp/spend-ledger.js +175 -0
  539. package/dist/scripts/claude-code-hook.js +41 -0
  540. package/dist/scripts/claude-code-stop-hook.js +47 -0
  541. package/dist/senses/a2a-entry.js +132 -0
  542. package/dist/senses/acp-server.js +386 -0
  543. package/dist/senses/attention-queue.js +186 -0
  544. package/dist/senses/await-turn-message.js +65 -0
  545. package/dist/senses/bluebubbles/active-turns.js +216 -0
  546. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  547. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  548. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +393 -43
  549. package/dist/senses/bluebubbles/context-packet.js +207 -0
  550. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  551. package/dist/senses/bluebubbles/entry.js +90 -0
  552. package/dist/senses/bluebubbles/host-account-verifier.js +88 -0
  553. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  554. package/dist/senses/bluebubbles/index.js +4054 -0
  555. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  556. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  557. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  558. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  559. package/dist/senses/bluebubbles/outbound-state.js +335 -0
  560. package/dist/senses/bluebubbles/processed-log.js +133 -0
  561. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  562. package/dist/senses/bluebubbles/replay.js +284 -0
  563. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  564. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  565. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  566. package/dist/senses/bluebubbles/webhook-registration.js +257 -0
  567. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  568. package/dist/senses/cli/bracketed-paste.js +82 -0
  569. package/dist/senses/cli/image-paste.js +287 -0
  570. package/dist/senses/cli/image-ref-navigation.js +75 -0
  571. package/dist/senses/cli/ink-app.js +156 -0
  572. package/dist/senses/cli/inline-diff.js +64 -0
  573. package/dist/senses/cli/input-keys.js +174 -0
  574. package/dist/senses/cli/kill-ring.js +86 -0
  575. package/dist/senses/cli/message-list.js +51 -0
  576. package/dist/senses/cli/ouro-tui.js +607 -0
  577. package/dist/senses/cli/spinner-imperative.js +135 -0
  578. package/dist/senses/cli/spinner.js +101 -0
  579. package/dist/senses/cli/status-line.js +60 -0
  580. package/dist/senses/cli/streaming-markdown.js +526 -0
  581. package/dist/senses/cli/tool-display.js +85 -0
  582. package/dist/senses/cli/tool-render.js +85 -0
  583. package/dist/senses/cli/tui-store.js +241 -0
  584. package/dist/senses/cli/virtual-list.js +35 -0
  585. package/dist/senses/cli-entry.js +60 -8
  586. package/dist/senses/cli.js +570 -309
  587. package/dist/senses/commands.js +66 -3
  588. package/dist/senses/context-packet-ledger.js +371 -0
  589. package/dist/senses/context-packets.js +154 -0
  590. package/dist/senses/habit-lifecycle-guard.js +24 -0
  591. package/dist/senses/habit-turn-message.js +160 -0
  592. package/dist/senses/ingress-evidence.js +2 -0
  593. package/dist/senses/mail-entry.js +66 -0
  594. package/dist/senses/mail.js +524 -0
  595. package/dist/senses/pipeline.js +1158 -204
  596. package/dist/senses/private-runtime-worker.js +857 -0
  597. package/dist/senses/private-runtime.js +1627 -0
  598. package/dist/senses/proactive-content-guard.js +51 -0
  599. package/dist/senses/root-host-approval-port.js +345 -0
  600. package/dist/senses/root-host-approval-runtime.js +393 -0
  601. package/dist/senses/sanctuary-authority-resident.js +102 -0
  602. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  603. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  604. package/dist/senses/sanctuary-grounding.js +118 -0
  605. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  606. package/dist/senses/sanctuary-health-acceptance-probe.js +786 -0
  607. package/dist/senses/sanctuary-health-runner.js +106 -0
  608. package/dist/senses/sanctuary-health.js +442 -0
  609. package/dist/senses/sanctuary-house.js +97 -0
  610. package/dist/senses/sanctuary-install-state-contract.js +169 -0
  611. package/dist/senses/sanctuary-interactive-control.js +410 -0
  612. package/dist/senses/sanctuary-media-catalog-contract.js +268 -0
  613. package/dist/senses/sanctuary-media-optimization.js +517 -0
  614. package/dist/senses/sanctuary-runtime.js +390 -0
  615. package/dist/senses/sanctuary-sab.js +85 -0
  616. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  617. package/dist/senses/shared-turn.js +832 -0
  618. package/dist/senses/shepherd/agent-presentation.js +88 -0
  619. package/dist/senses/shepherd/attention-page.js +166 -0
  620. package/dist/senses/shepherd/attention-sidebar.js +76 -0
  621. package/dist/senses/shepherd/attention.js +295 -0
  622. package/dist/senses/shepherd/checkin-config.js +76 -0
  623. package/dist/senses/shepherd/checkin-store.js +386 -0
  624. package/dist/senses/shepherd/client.js +196 -0
  625. package/dist/senses/shepherd/cmux.js +483 -0
  626. package/dist/senses/shepherd/composer.js +336 -0
  627. package/dist/senses/shepherd/copilot-app.js +648 -0
  628. package/dist/senses/shepherd/feedback-reply.js +108 -0
  629. package/dist/senses/shepherd/feedback-source.js +20 -0
  630. package/dist/senses/shepherd/feedback-store.js +331 -0
  631. package/dist/senses/shepherd/feedback-submit.js +182 -0
  632. package/dist/senses/shepherd/feedback-worker.js +208 -0
  633. package/dist/senses/shepherd/guidance.js +273 -0
  634. package/dist/senses/shepherd/herdr.js +427 -0
  635. package/dist/senses/shepherd/host.js +17 -0
  636. package/dist/senses/shepherd/human-decision-guard.js +29 -0
  637. package/dist/senses/shepherd/judge-evaluation-command.js +183 -0
  638. package/dist/senses/shepherd/judge-evaluation-entry.js +13 -0
  639. package/dist/senses/shepherd/judge-evaluation.js +155 -0
  640. package/dist/senses/shepherd/judge.js +210 -0
  641. package/dist/senses/shepherd/notice-outbox.js +262 -0
  642. package/dist/senses/shepherd/observation-publisher.js +320 -0
  643. package/dist/senses/shepherd/operations-defaults.js +86 -0
  644. package/dist/senses/shepherd/operations.js +159 -0
  645. package/dist/senses/shepherd/owner-notice.js +489 -0
  646. package/dist/senses/shepherd/prompt-state.js +60 -0
  647. package/dist/senses/shepherd/recovery.js +543 -0
  648. package/dist/senses/shepherd/redact.js +66 -0
  649. package/dist/senses/shepherd/returns.js +79 -0
  650. package/dist/senses/shepherd/sense.js +849 -0
  651. package/dist/senses/shepherd/shepherd-judge-corpus-v1.json +170 -0
  652. package/dist/senses/shepherd/surface.js +123 -0
  653. package/dist/senses/shepherd/target-binding.js +13 -0
  654. package/dist/senses/shepherd/terminal-process.js +147 -0
  655. package/dist/senses/shepherd-entry.js +98 -0
  656. package/dist/senses/surface-tool.js +109 -0
  657. package/dist/senses/teams-entry.js +68 -8
  658. package/dist/senses/teams-image.js +119 -0
  659. package/dist/senses/teams.js +998 -337
  660. package/dist/senses/telegram-admission.js +718 -0
  661. package/dist/senses/telegram-approval-runtime.js +793 -0
  662. package/dist/senses/telegram-attachments.js +186 -0
  663. package/dist/senses/telegram-audit-ledger.js +220 -0
  664. package/dist/senses/telegram-authority-transport.js +231 -0
  665. package/dist/senses/telegram-client.js +1755 -0
  666. package/dist/senses/telegram-effect-adapter.js +904 -0
  667. package/dist/senses/telegram-entry.js +83 -0
  668. package/dist/senses/telegram-handoff.js +154 -0
  669. package/dist/senses/telegram.js +2474 -0
  670. package/dist/senses/trust-gate.js +104 -9
  671. package/dist/senses/voice/audio-playback.js +237 -0
  672. package/dist/senses/voice/audio-routing.js +119 -0
  673. package/dist/senses/voice/call-auth.js +250 -0
  674. package/dist/senses/voice/elevenlabs.js +202 -0
  675. package/dist/senses/voice/floor-control.js +431 -0
  676. package/dist/senses/voice/floor-controller.js +115 -0
  677. package/dist/senses/voice/golden-path.js +116 -0
  678. package/dist/senses/voice/index.js +29 -0
  679. package/dist/senses/voice/local-audio-control.js +344 -0
  680. package/dist/senses/voice/local-audio-devices.js +516 -0
  681. package/dist/senses/voice/local-audio-disclosure.js +174 -0
  682. package/dist/senses/voice/local-audio-identity.js +103 -0
  683. package/dist/senses/voice/local-audio-join.js +325 -0
  684. package/dist/senses/voice/local-audio-mute.js +199 -0
  685. package/dist/senses/voice/local-audio-notify.js +124 -0
  686. package/dist/senses/voice/local-audio-prompts.js +116 -0
  687. package/dist/senses/voice/local-audio-transport.js +427 -0
  688. package/dist/senses/voice/local-audio.js +281 -0
  689. package/dist/senses/voice/local-media-stream.js +145 -0
  690. package/dist/senses/voice/meeting.js +113 -0
  691. package/dist/senses/voice/outbound.js +190 -0
  692. package/dist/senses/voice/phone.js +33 -0
  693. package/dist/senses/voice/playback.js +139 -0
  694. package/dist/senses/voice/process-guards.js +51 -0
  695. package/dist/senses/voice/realtime-eval.js +496 -0
  696. package/dist/senses/voice/realtime-tool-schema.js +195 -0
  697. package/dist/senses/voice/realtime-trace.js +531 -0
  698. package/dist/senses/voice/transcript.js +70 -0
  699. package/dist/senses/voice/turn.js +191 -0
  700. package/dist/senses/voice/twilio-phone-runtime.js +836 -0
  701. package/dist/senses/voice/twilio-phone.js +5454 -0
  702. package/dist/senses/voice/types.js +2 -0
  703. package/dist/senses/voice/whisper.js +161 -0
  704. package/dist/senses/voice-entry.js +83 -0
  705. package/dist/senses/voice-realtime-eval-command.js +99 -0
  706. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  707. package/dist/senses/voice-twilio-entry.js +87 -0
  708. package/dist/trips/core.js +138 -0
  709. package/dist/trips/store.js +265 -0
  710. package/dist/util/frontmatter.js +69 -0
  711. package/npm-shrinkwrap.json +8342 -0
  712. package/package.json +76 -13
  713. package/skills/agent-commerce.md +113 -0
  714. package/skills/browser-navigation.md +117 -0
  715. package/skills/commerce-setup-guide.md +116 -0
  716. package/skills/commerce-setup.md +84 -0
  717. package/skills/configure-dev-tools.md +99 -0
  718. package/skills/travel-planning.md +138 -0
  719. package/dist/heart/daemon/auth-flow.js +0 -430
  720. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  721. package/dist/heart/daemon/ouro-version-manager.js +0 -171
  722. package/dist/heart/daemon/update-hooks.js +0 -138
  723. package/dist/heart/obligations.js +0 -191
  724. package/dist/heart/safe-workspace.js +0 -381
  725. package/dist/heart/session-recall.js +0 -116
  726. package/dist/mind/friends/channel.js +0 -84
  727. package/dist/mind/friends/group-context.js +0 -144
  728. package/dist/mind/friends/resolver.js +0 -84
  729. package/dist/mind/friends/store-file.js +0 -190
  730. package/dist/mind/friends/store.js +0 -4
  731. package/dist/mind/friends/tokens.js +0 -26
  732. package/dist/mind/friends/trust-explanation.js +0 -74
  733. package/dist/mind/friends/types.js +0 -29
  734. package/dist/repertoire/tasks/board.js +0 -134
  735. package/dist/repertoire/tasks/index.js +0 -224
  736. package/dist/repertoire/tasks/lifecycle.js +0 -80
  737. package/dist/repertoire/tasks/middleware.js +0 -65
  738. package/dist/repertoire/tasks/parser.js +0 -173
  739. package/dist/repertoire/tasks/scanner.js +0 -132
  740. package/dist/repertoire/tasks/transitions.js +0 -144
  741. package/dist/senses/bluebubbles-entry.js +0 -13
  742. package/dist/senses/bluebubbles.js +0 -1181
  743. package/dist/senses/debug-activity.js +0 -154
  744. package/dist/senses/inner-dialog-worker.js +0 -90
  745. package/dist/senses/inner-dialog.js +0 -525
  746. package/subagents/README.md +0 -7
  747. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  748. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  749. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  750. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  751. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  752. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  753. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  754. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  755. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  756. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  757. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  758. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  759. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  760. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  761. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  762. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  763. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,2255 @@
1
+ #!/usr/bin/env node
2
+ // Sanctuary media MCP server — the 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 { appendFileSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
13
+ import { dirname } from "node:path"
14
+ import { pathToFileURL } from "node:url"
15
+
16
+ const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
17
+
18
+ // Credentials are host-specific and deliberately NOT packaged. A fresh install
19
+ // has none, and the server must still start and list its tools — otherwise the
20
+ // whole MCP server fails to connect and the agent silently loses the surface.
21
+ // Missing credentials become a named, reportable per-call failure instead.
22
+ let cred = null
23
+ let credError = null
24
+ try {
25
+ cred = JSON.parse(readFileSync(CRED_PATH, "utf8"))
26
+ } catch (e) {
27
+ 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.`
28
+ }
29
+
30
+ const SEERR = cred?.jellyseerr ?? { url: "", apiKey: "" }
31
+ const SONARR = cred?.sonarr ?? { url: "", apiKey: "" }
32
+ const RADARR = cred?.radarr ?? { url: "", apiKey: "" }
33
+ const PROWLARR = cred?.prowlarr ?? { url: "", apiKey: "" }
34
+ // The household's Jellyfin web address: the credential file wins, then house/house.json.
35
+ // With neither, search results carry no play link rather than a guessed one.
36
+ function houseJellyfinWebUrl() {
37
+ try {
38
+ const house = JSON.parse(readFileSync(new URL("../house/house.json", pathToFileURL(CRED_PATH)), "utf8"))
39
+ return typeof house?.jellyfinWebUrl === "string" ? house.jellyfinWebUrl : ""
40
+ } catch {
41
+ return ""
42
+ }
43
+ }
44
+ const JELLYFIN_WEB = String(cred?.jellyfinWebUrl ?? houseJellyfinWebUrl()).replace(/\/+$/u, "")
45
+ // Optional: when the credential file carries { jellyfin: { url, apiKey } } the library
46
+ // is searched in Jellyfin too. Absent, title resolution uses Sonarr and Radarr only.
47
+ const JELLYFIN = cred?.jellyfin ?? { url: "", apiKey: "" }
48
+
49
+ const TIMEOUT_MS = 45_000
50
+ // Interactive release search queries every indexer live and can take minutes.
51
+ const SEARCH_TIMEOUT_MS = 180_000
52
+
53
+ // ---------------------------------------------------------------- http helpers
54
+
55
+ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body, query, timeoutMs = TIMEOUT_MS } = {}) {
56
+ let url = `${base}${path}`
57
+ if (query) {
58
+ const parts = Object.entries(query)
59
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
60
+ // encodeURIComponent, not URLSearchParams: Jellyseerr rejects '+' for spaces.
61
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
62
+ if (parts.length) url += `?${parts.join("&")}`
63
+ }
64
+ const headers = { [header]: key, Accept: "application/json" }
65
+ if (body !== undefined) headers["Content-Type"] = "application/json"
66
+ let res
67
+ try {
68
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) })
69
+ } catch (e) {
70
+ throw new ServiceError(`${serviceOf(base)}_unreachable`, `${serviceOf(base)} did not respond: ${e.name === "TimeoutError" ? "timeout" : e.message}`)
71
+ }
72
+ if (!res.ok) {
73
+ const text = await res.text().catch(() => "")
74
+ throw new ServiceError(`${serviceOf(base)}_http_${res.status}`, `${serviceOf(base)} returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`)
75
+ }
76
+ if (res.status === 204) return null
77
+ // Sonarr/Radarr answer some DELETEs with 200 and an empty body; that is success, not a parse failure.
78
+ const raw = await res.text()
79
+ return raw.trim() ? JSON.parse(raw) : null
80
+ }
81
+
82
+ function serviceOf(base) {
83
+ if (base === SEERR.url) return "jellyseerr"
84
+ if (base === SONARR.url) return "sonarr"
85
+ if (base === RADARR.url) return "radarr"
86
+ if (base === PROWLARR.url) return "prowlarr"
87
+ if (JELLYFIN.url && base === JELLYFIN.url) return "jellyfin"
88
+ return "service"
89
+ }
90
+
91
+ class ServiceError extends Error {
92
+ constructor(code, message) { super(message); this.code = code }
93
+ }
94
+
95
+ const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
96
+ // Long enough that an ordinary slow start is not called dead, short enough that
97
+ // nobody waits a day for a release that was never going to arrive.
98
+ const STALL_AFTER_HOURS = 6
99
+
100
+ const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
101
+ const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
102
+ const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
103
+
104
+ // ------------------------------------------------------------------ mapping
105
+
106
+ // Jellyseerr media status codes.
107
+ const MEDIA_STATUS = { 1: "unknown", 2: "pending", 4: "partially_available", 5: "available" }
108
+ const REQUEST_STATUS = { 1: "pending_approval", 2: "approved", 3: "declined", 4: "failed" }
109
+
110
+ const TMDB_GENRES = {
111
+ 28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", 80: "Crime", 99: "Documentary",
112
+ 18: "Drama", 10751: "Family", 14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music",
113
+ 9648: "Mystery", 10749: "Romance", 878: "Science Fiction", 10770: "TV Movie", 53: "Thriller",
114
+ 10752: "War", 37: "Western", 10759: "Action & Adventure", 10762: "Kids", 10763: "News",
115
+ 10764: "Reality", 10765: "Sci-Fi & Fantasy", 10766: "Soap", 10767: "Talk", 10768: "War & Politics",
116
+ }
117
+
118
+ function genreNames(item) {
119
+ const ids = item.genreIds ?? (item.genres ?? []).map((g) => g.id)
120
+ return ids.map((id) => TMDB_GENRES[id]).filter(Boolean)
121
+ }
122
+
123
+ // Jellyseerr's media status 3 only means Sonarr/Radarr track the title; it says nothing about
124
+ // a download or a request. Name what is actually known so nobody reads it as "processing".
125
+ function shelfStateOf(kind, info) {
126
+ if (info.status === 3) return info.downloadStatus?.length ? "downloading" : `in_${kind === "series" ? "sonarr" : "radarr"}_not_downloading`
127
+ return MEDIA_STATUS[info.status] ?? "not_on_shelf"
128
+ }
129
+
130
+ // A request exists only if Jellyseerr lists one; the media status is not a request.
131
+ function requestStateOf(info) {
132
+ if (!Array.isArray(info.requests)) return info.status ? "unknown_check_media_request_status" : "never_requested"
133
+ if (info.requests.length === 0) return "never_requested"
134
+ return REQUEST_STATUS[info.requests[info.requests.length - 1].status] ?? "requested"
135
+ }
136
+
137
+ export function shelfItem(item) {
138
+ const kind = item.mediaType === "tv" ? "series" : item.mediaType === "movie" ? "movie" : (item.firstAirDate ? "series" : "movie")
139
+ const date = item.releaseDate ?? item.firstAirDate ?? ""
140
+ const info = item.mediaInfo ?? {}
141
+ const status = shelfStateOf(kind, info)
142
+ return {
143
+ title: item.title ?? item.name ?? null,
144
+ year: date ? Number(date.slice(0, 4)) : null,
145
+ kind,
146
+ tmdb_id: item.id ?? null,
147
+ tvdb_id: info.tvdbId ?? null,
148
+ genres: genreNames(item),
149
+ overview: typeof item.overview === "string" ? item.overview.slice(0, 400) : null,
150
+ // The two fields that stop a second call:
151
+ on_shelf: status === "available" || status === "partially_available",
152
+ shelf_state: status,
153
+ request_state: requestStateOf(info),
154
+ sonarr_id: kind === "series" ? (info.externalServiceId ?? null) : null,
155
+ radarr_id: kind === "movie" ? (info.externalServiceId ?? null) : null,
156
+ poster_path: item.posterPath ? `https://image.tmdb.org/t/p/w500${item.posterPath}` : null,
157
+ vote_average: item.voteAverage ?? null,
158
+ }
159
+ }
160
+
161
+ // ------------------------------------------------------- title resolution
162
+ //
163
+ // One resolver for every tool that takes a title. A title typed from memory is
164
+ // rarely the catalogue's title: "chef" is "The Chef Show", "chef shwo" is a typo.
165
+ // The 2026-09-29 failure was a library search that required the whole title and
166
+ // so reported a tracked series as nonexistent. Matching is therefore partial and
167
+ // typo-tolerant, in-library candidates always come first, and the TVDB/TMDB
168
+ // lookup only runs when the library has nothing.
169
+
170
+ export function normalizeTitle(s) {
171
+ return String(s ?? "")
172
+ .normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
173
+ .toLowerCase().replace(/&/g, " and ").replace(/['\u2019]/g, "")
174
+ .replace(/[^a-z0-9]+/g, " ").trim().replace(/^the /, "")
175
+ }
176
+
177
+ // Optimal-string-alignment distance: an adjacent swap ("shwo") costs one edit.
178
+ function editDistance(a, b) {
179
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
180
+ for (let j = 1; j <= b.length; j += 1) d[0][j] = j
181
+ for (let i = 1; i <= a.length; i += 1) {
182
+ for (let j = 1; j <= b.length; j += 1) {
183
+ 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))
184
+ 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)
185
+ }
186
+ }
187
+ return d[a.length][b.length]
188
+ }
189
+
190
+ const MATCH_RANK = { exact: 0, prefix: 1, token: 2, fuzzy: 3 }
191
+
192
+ // Returns "exact" | "prefix" | "token" | "fuzzy" | null.
193
+ export function matchKind(query, title) {
194
+ const nq = normalizeTitle(query)
195
+ const nt = normalizeTitle(title)
196
+ if (!nq || !nt) return null
197
+ if (nq === nt) return "exact"
198
+ if (nt.startsWith(nq)) return "prefix"
199
+ const qt = nq.split(" ")
200
+ const tt = nt.split(" ")
201
+ if (qt.every((q) => tt.some((t) => t === q || (q.length >= 3 && t.includes(q))))) return "token"
202
+ if (nq.length >= 3 && nt.includes(nq)) return "token"
203
+ // Typos. Short tokens must be exact; a 4-letter token may only be fuzzy when
204
+ // another query token already matched exactly, so "chef" never drifts to "chess".
205
+ const exactHit = (q) => tt.includes(q)
206
+ const fuzzyHit = (q) => tt.some((t) => {
207
+ if (q.length < 4 || t.length < 4) return false
208
+ const tol = q.length >= 8 ? 2 : 1
209
+ return editDistance(q, t) <= tol
210
+ })
211
+ const anyExact = qt.some(exactHit)
212
+ const ok = qt.every((q) => exactHit(q) || (fuzzyHit(q) && (q.length >= 5 || (qt.length > 1 && anyExact))))
213
+ return ok ? "fuzzy" : null
214
+ }
215
+
216
+ function rankCandidates(term, items) {
217
+ return items
218
+ .map((c) => ({ ...c, match: matchKind(term, c.title) }))
219
+ .filter((c) => c.match)
220
+ .sort((x, y) => MATCH_RANK[x.match] - MATCH_RANK[y.match]
221
+ || Math.abs(normalizeTitle(x.title).length - normalizeTitle(term).length) - Math.abs(normalizeTitle(y.title).length - normalizeTitle(term).length))
222
+ }
223
+
224
+ const libraryCandidate = (kind, i) => ({ kind, source: kind === "series" ? "sonarr" : "radarr", service_id: i.id, title: i.title,
225
+ year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: i.tvdbId ?? null, in_library: true })
226
+
227
+ // Every library item whose title matches, best first. Sonarr and Radarr are the
228
+ // source of truth for what is tracked; Jellyfin (when configured) adds what is
229
+ // on the shelf and marks items it shares with them.
230
+ export async function findLibraryCandidates(term, kind = "any") {
231
+ const jobs = []
232
+ if (kind !== "movie") jobs.push(sonarr("/series").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("series", i))))
233
+ if (kind !== "series") jobs.push(radarr("/movie").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("movie", i))))
234
+ // One service being down must not hide the other's matches; say which failed.
235
+ const settled = await Promise.allSettled(jobs)
236
+ const notes = []
237
+ for (const r of settled) if (r.status === "rejected") notes.push(`library_unavailable: ${r.reason?.message ?? r.reason}`)
238
+ if (settled.every((r) => r.status === "rejected")) throw settled[0].reason
239
+ let items = settled.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
240
+ if (JELLYFIN.url && JELLYFIN.apiKey) {
241
+ try {
242
+ const body = await req(JELLYFIN.url, "/Items", { key: JELLYFIN.apiKey, header: "X-Emby-Token",
243
+ query: { searchTerm: term, IncludeItemTypes: kind === "series" ? "Series" : kind === "movie" ? "Movie" : "Series,Movie", Recursive: true, Limit: 25 } })
244
+ for (const j of body?.Items ?? []) {
245
+ const k = j.Type === "Series" ? "series" : "movie"
246
+ const tmdb = Number(j.ProviderIds?.Tmdb ?? j.ProviderIds?.tmdb) || null
247
+ const twin = items.find((i) => i.kind === k && ((tmdb && i.tmdb_id === tmdb) || normalizeTitle(i.title) === normalizeTitle(j.Name)))
248
+ if (twin) twin.in_jellyfin = true
249
+ else items.push({ kind: k, source: "jellyfin", service_id: null, jellyfin_id: j.Id ?? null, title: j.Name, year: j.ProductionYear ?? null,
250
+ tmdb_id: tmdb, tvdb_id: null, in_library: true, in_jellyfin: true })
251
+ }
252
+ } catch (e) {
253
+ notes.push(`jellyfin_unavailable: ${e.message}`)
254
+ }
255
+ }
256
+ const ranked = rankCandidates(term, items)
257
+ if (notes.length) Object.defineProperty(ranked, "notes", { value: notes, enumerable: false })
258
+ return ranked
259
+ }
260
+
261
+ // The catalogue fallback, used only when the library has no candidate. These are
262
+ // titles Sonarr/Radarr could add, so they are labelled not_in_library.
263
+ export async function lookupCandidates(term, kind = "any") {
264
+ const out = []
265
+ const jobs = []
266
+ 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(() => {}))
267
+ 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(() => {}))
268
+ await Promise.all(jobs)
269
+ return out.sort((x, y) => (MATCH_RANK[x.match] ?? 4) - (MATCH_RANK[y.match] ?? 4)).slice(0, 10)
270
+ }
271
+
272
+ // In-library candidates first; the lookup fallback only when there are none.
273
+ export async function resolveTitle(term, kind = "any") {
274
+ const library = await findLibraryCandidates(term, kind)
275
+ if (library.length) return { library_matches: library, lookup_candidates: [], not_in_library: false, notes: library.notes ?? [] }
276
+ const lookup = (await lookupCandidates(term, kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
277
+ return { library_matches: [], lookup_candidates: lookup, not_in_library: true, notes: [] }
278
+ }
279
+
280
+ // ------------------------------------------------------------- tool: search
281
+
282
+ export async function mediaSearch(a) {
283
+ const kind = a.kind ?? "any"
284
+ const limit = Math.min(Math.max(a.limit ?? 12, 1), 20)
285
+ const onShelf = a.on_shelf // true | false | undefined(any)
286
+ let raw = []
287
+ let source
288
+
289
+ if (a.query) {
290
+ source = "title_search"
291
+ const body = await seerr("/search", { query: { query: a.query, page: 1 } })
292
+ raw = body.results ?? []
293
+ } else {
294
+ source = "discover"
295
+ const keywordIds = []
296
+ for (const kw of a.keywords ?? []) {
297
+ const hit = await seerr("/search/keyword", { query: { query: kw, page: 1 } })
298
+ const exact = (hit.results ?? []).find((k) => k.name.toLowerCase() === kw.toLowerCase()) ?? (hit.results ?? [])[0]
299
+ if (exact) keywordIds.push(exact.id)
300
+ }
301
+ const genreIds = (a.genres ?? [])
302
+ .map((name) => Number(Object.entries(TMDB_GENRES).find(([, v]) => v.toLowerCase() === String(name).toLowerCase())?.[0]))
303
+ .filter((n) => Number.isFinite(n))
304
+ const [yMin, yMax] = a.year_range ?? []
305
+ const paths = kind === "series" ? ["/discover/tv"] : kind === "movie" ? ["/discover/movies"] : ["/discover/movies", "/discover/tv"]
306
+ // One page of discover is ~20 popular titles, of which only a couple are
307
+ // usually on this shelf — answering "what cozy autumn films do we own?" from
308
+ // that reads as though the shelf is nearly empty. When the caller filters by
309
+ // shelf state, walk further pages until enough matches accumulate.
310
+ const maxPages = onShelf === undefined ? 1 : 5
311
+ for (const p of paths) {
312
+ const isTv = p.endsWith("/tv")
313
+ for (let page = 1; page <= maxPages; page += 1) {
314
+ const q = { page, sortBy: a.sort === "release_year" ? "primary_release_date.desc" : "popularity.desc" }
315
+ if (genreIds.length) q.genre = genreIds.join(",")
316
+ if (keywordIds.length) q.keywords = keywordIds.join(",")
317
+ if (yMin) q[isTv ? "firstAirDateGte" : "primaryReleaseDateGte"] = `${yMin}-01-01`
318
+ if (yMax) q[isTv ? "firstAirDateLte" : "primaryReleaseDateLte"] = `${yMax}-12-31`
319
+ const body = await seerr(p, { query: q })
320
+ const results = body.results ?? []
321
+ raw.push(...results.map((r) => ({ ...r, mediaType: isTv ? "tv" : "movie" })))
322
+ const matchesSoFar = raw
323
+ .map((r) => (r.mediaInfo?.status === 5 || r.mediaInfo?.status === 4))
324
+ .filter((hit) => hit === (onShelf === true)).length
325
+ if (results.length === 0 || page >= (body.totalPages ?? 1) || matchesSoFar >= limit) break
326
+ }
327
+ }
328
+ }
329
+
330
+ // Partial and typo-tolerant library match, so a half-remembered title still
331
+ // finds what is tracked. Lookup candidates appear only when the library has none.
332
+ 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
333
+
334
+ let items = raw.filter((r) => r.mediaType !== "person").map(shelfItem)
335
+ if (kind !== "any") items = items.filter((i) => i.kind === kind)
336
+ if (a.year_range) {
337
+ const [lo, hi] = a.year_range
338
+ items = items.filter((i) => i.year && (!lo || i.year >= lo) && (!hi || i.year <= hi))
339
+ }
340
+ if (onShelf === true) items = items.filter((i) => i.on_shelf)
341
+ if (onShelf === false) items = items.filter((i) => !i.on_shelf)
342
+ if (a.sort === "release_year") items.sort((x, y) => (y.year ?? 0) - (x.year ?? 0))
343
+
344
+ return {
345
+ source,
346
+ matched: items.length,
347
+ items: items.slice(0, limit),
348
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates, notes: resolution.notes } : {}),
349
+ applied_filters: { kind, year_range: a.year_range ?? null, genres: a.genres ?? [], keywords: a.keywords ?? [], on_shelf: onShelf ?? "any" },
350
+ // So the agent never claims something is missing when the filter caused it:
351
+ 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."
352
+ : items.length === 0 && resolution?.lookup_candidates.length ? "Nothing in the library matches; lookup_candidates are TVDB/TMDB titles that are not in the library."
353
+ : items.length === 0 ? "No matches for these filters. Loosen year_range/genres/keywords before concluding the shelf lacks it." : null,
354
+ }
355
+ }
356
+
357
+ // ------------------------------------------------------------ tool: request
358
+
359
+ // Resolve by TMDB id against the title itself rather than scanning the request
360
+ // list: this household has over a thousand requests, so a page-limited scan
361
+ // silently misses older ones and would file a duplicate.
362
+ async function findExistingRequest(tmdbId, kind) {
363
+ const kinds = kind ? [kind] : ["movie", "series"]
364
+ for (const k of kinds) {
365
+ let detail
366
+ try {
367
+ detail = await seerr(`/${k === "series" ? "tv" : "movie"}/${tmdbId}`)
368
+ } catch {
369
+ continue
370
+ }
371
+ const info = detail?.mediaInfo
372
+ if (!info) continue
373
+ const requests = info.requests ?? []
374
+ const latest = requests.length ? requests[requests.length - 1] : null
375
+ return {
376
+ id: latest?.id ?? null,
377
+ status: latest?.status ?? null,
378
+ createdAt: latest?.createdAt ?? null,
379
+ type: k === "series" ? "tv" : "movie",
380
+ media: { ...info, tmdbId, title: detail.title ?? detail.name ?? null },
381
+ hasRequest: requests.length > 0,
382
+ }
383
+ }
384
+ return null
385
+ }
386
+
387
+ async function mediaRequest(a) {
388
+ let tmdbId = a.tmdb_id
389
+ let kind = a.kind ?? "movie"
390
+ let resolvedTitle = a.title ?? null
391
+ let resolvedYear = a.year ?? null
392
+
393
+ if (!tmdbId) {
394
+ if (!a.title) throw new ServiceError("bad_arguments", "media_request needs tmdb_id, or title (year optional).")
395
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
396
+ let cands = (body.results ?? []).filter((r) => r.mediaType === "movie" || r.mediaType === "tv")
397
+ if (a.kind) cands = cands.filter((r) => (a.kind === "series" ? r.mediaType === "tv" : r.mediaType === "movie"))
398
+ if (a.year) cands = cands.filter((r) => ((r.releaseDate ?? r.firstAirDate ?? "").slice(0, 4)) === String(a.year))
399
+ const pick = cands[0]
400
+ if (!pick) {
401
+ return { resolved: false, reason: "no_tmdb_match", searched: { title: a.title, year: a.year ?? null, kind: a.kind ?? "any" },
402
+ human_action_required: true, human_action_reason: `No TMDB match for "${a.title}"${a.year ? ` (${a.year})` : ""}. Check the title or give a year.` }
403
+ }
404
+ tmdbId = pick.id
405
+ kind = pick.mediaType === "tv" ? "series" : "movie"
406
+ resolvedTitle = pick.title ?? pick.name
407
+ resolvedYear = Number((pick.releaseDate ?? pick.firstAirDate ?? "").slice(0, 4)) || null
408
+ }
409
+
410
+ const existing = await findExistingRequest(tmdbId, kind)
411
+ if (existing?.hasRequest) {
412
+ const shelf = shelfStateOf(kind, existing.media ?? {})
413
+ return {
414
+ resolved: true, title: resolvedTitle ?? existing.media?.title ?? null, year: resolvedYear, kind, tmdb_id: tmdbId,
415
+ request_id: `jellyseerr:${existing.id}`,
416
+ approval_state: "already_requested",
417
+ duplicate_of: `jellyseerr:${existing.id}`,
418
+ jellyseerr_status: REQUEST_STATUS[existing.status] ?? "unknown",
419
+ shelf_state: shelf,
420
+ submitted_at: existing.createdAt,
421
+ tvdb_id: existing.media?.tvdbId ?? null,
422
+ sonarr_id: kind === "series" ? (existing.media?.externalServiceId ?? null) : null,
423
+ radarr_id: kind === "movie" ? (existing.media?.externalServiceId ?? null) : null,
424
+ note: shelf === "available" ? "Already on the shelf." : "Already requested; use media_request_status to see why it has not landed.",
425
+ }
426
+ }
427
+
428
+ const payload = { mediaType: kind === "series" ? "tv" : "movie", mediaId: tmdbId }
429
+ if (kind === "series") {
430
+ payload.seasons = a.season_number ? [a.season_number] : "all"
431
+ }
432
+ const created = await seerr("/request", { method: "POST", body: payload })
433
+ return {
434
+ resolved: true, title: resolvedTitle, year: resolvedYear, kind, tmdb_id: tmdbId,
435
+ request_id: `jellyseerr:${created.id}`,
436
+ approval_state: REQUEST_STATUS[created.status] ?? "submitted",
437
+ duplicate_of: null,
438
+ jellyseerr_status: REQUEST_STATUS[created.status] ?? "submitted",
439
+ shelf_state: created.media?.status ? shelfStateOf(kind, created.media) : "not_on_shelf",
440
+ submitted_at: created.createdAt,
441
+ tvdb_id: created.media?.tvdbId ?? null,
442
+ sonarr_id: kind === "series" ? (created.media?.externalServiceId ?? null) : null,
443
+ radarr_id: kind === "movie" ? (created.media?.externalServiceId ?? null) : null,
444
+ seasons_requested: kind === "series" ? (a.season_number ? [a.season_number] : "all") : null,
445
+ note: "Submitted. The indexer hunt runs next; media_request_status will show whether a release was grabbed.",
446
+ }
447
+ }
448
+
449
+ // -------------------------------------------------- acquisition chain health
450
+
451
+ // The failure that stalled this house for three weeks: Prowlarr had zero indexers,
452
+ // so Sonarr reported "0 active indexers" and every request sat at "requested"
453
+ // forever. That must be a first-class, named answer — never something the agent
454
+ // has to infer from an empty release list.
455
+ async function chainHealth() {
456
+ const out = { indexers: { total: 0, enabled: 0, failing: [], ok: [] }, apps: [], problems: [], healthy: true }
457
+ try {
458
+ const list = await prowlarr("/indexer")
459
+ out.indexers.total = list.length
460
+ out.indexers.enabled = list.filter((i) => i.enable).length
461
+ const status = await prowlarr("/indexerstatus").catch(() => [])
462
+ const disabled = new Set((status ?? []).map((s) => s.indexerId))
463
+ for (const i of list) {
464
+ if (!i.enable) continue
465
+ ;(disabled.has(i.id) ? out.indexers.failing : out.indexers.ok).push(i.name)
466
+ }
467
+ const health = await prowlarr("/health").catch(() => [])
468
+ for (const h of health ?? []) {
469
+ if (h.type === "error" && /indexer/i.test(h.source ?? "")) out.problems.push({ service: "prowlarr", message: h.message })
470
+ }
471
+ out.apps = (await prowlarr("/applications").catch(() => [])).map((x) => ({ name: x.name, sync: x.syncLevel }))
472
+ } catch (e) {
473
+ out.problems.push({ service: "prowlarr", message: e.message })
474
+ }
475
+
476
+ if (out.indexers.total === 0) {
477
+ out.healthy = false
478
+ out.problems.push({ service: "prowlarr", message: "Prowlarr has zero indexers configured. Nothing can ever be found or downloaded until indexers are added." })
479
+ } else if (out.indexers.ok.length === 0) {
480
+ out.healthy = false
481
+ out.problems.push({ service: "prowlarr", message: "Every enabled indexer is currently failing. Searches will return nothing." })
482
+ }
483
+ if (out.apps.length === 0) {
484
+ out.healthy = false
485
+ out.problems.push({ service: "prowlarr", message: "Prowlarr is not syncing to Sonarr/Radarr, so they have no working indexers." })
486
+ }
487
+
488
+ for (const [name, call] of [["sonarr", sonarr], ["radarr", radarr]]) {
489
+ try {
490
+ const h = await call("/health")
491
+ for (const item of h ?? []) {
492
+ if (/indexer/i.test(item.source ?? "") && (item.type === "error" || /unavailable/i.test(item.message ?? ""))) {
493
+ out.problems.push({ service: name, message: item.message })
494
+ if (/all/i.test(item.message ?? "")) out.healthy = false
495
+ }
496
+ }
497
+ } catch (e) { out.problems.push({ service: name, message: e.message }) }
498
+ }
499
+ return out
500
+ }
501
+
502
+ // --------------------------------------------------- tool: request status
503
+
504
+ async function resolveTarget(a) {
505
+ if (a.tmdb_id) {
506
+ const r = await findExistingRequest(a.tmdb_id, a.kind)
507
+ if (r?.hasRequest) return r
508
+ }
509
+ if (a.request_id) {
510
+ const id = Number(String(a.request_id).replace(/^jellyseerr:/, ""))
511
+ if (Number.isFinite(id)) {
512
+ try { return await seerr(`/request/${id}`) } catch { /* fall through to title */ }
513
+ }
514
+ }
515
+ if (a.title) {
516
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
517
+ // Only titles Jellyseerr already knows about can have a request behind them.
518
+ for (const pick of (body.results ?? []).filter((r) => r.mediaInfo)) {
519
+ const found = await findExistingRequest(pick.id, pick.mediaType === "tv" ? "series" : "movie")
520
+ if (found?.hasRequest) return found
521
+ }
522
+ // Jellyseerr's own search missed it: try the partial/typo-tolerant library
523
+ // match, and follow its best candidate back to a request by TMDB id.
524
+ const best = (await findLibraryCandidates(a.title).catch(() => [])).find((c) => c.tmdb_id)
525
+ if (best) {
526
+ const found = await findExistingRequest(best.tmdb_id, best.kind)
527
+ if (found?.hasRequest) return found
528
+ }
529
+ }
530
+ return null
531
+ }
532
+
533
+ export async function mediaRequestStatus(a) {
534
+ const request = await resolveTarget(a)
535
+ if (!request) {
536
+ const resolution = a.title ? await resolveTitle(a.title).catch(() => null) : null
537
+ return { found: false, reason: "no_matching_request",
538
+ searched: { request_id: a.request_id ?? null, tmdb_id: a.tmdb_id ?? null, title: a.title ?? null },
539
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates,
540
+ 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 } : {}),
541
+ diagnosis: { stuck_stage: "request", stuck_reason: "never_requested",
542
+ likely_fix: "Submit it with media_request.", human_action_required: false } }
543
+ }
544
+
545
+ const kind = request.type === "tv" ? "series" : "movie"
546
+ const media = request.media ?? {}
547
+ const shelf = shelfStateOf(kind, media)
548
+ const svcId = media.externalServiceId ?? null
549
+
550
+ const result = {
551
+ found: true,
552
+ request: {
553
+ id: `jellyseerr:${request.id}`,
554
+ kind,
555
+ tmdb_id: media.tmdbId ?? null,
556
+ tvdb_id: media.tvdbId ?? null,
557
+ jellyseerr_status: REQUEST_STATUS[request.status] ?? "unknown",
558
+ shelf_state: shelf,
559
+ submitted_at: request.createdAt,
560
+ sonarr_id: kind === "series" ? svcId : null,
561
+ radarr_id: kind === "movie" ? svcId : null,
562
+ },
563
+ search: null, download: null, file: null, chain: null, diagnosis: null,
564
+ }
565
+
566
+ let entity = null, queueRecs = [], history = []
567
+ try {
568
+ if (kind === "series" && svcId) {
569
+ entity = await sonarr(`/series/${svcId}`)
570
+ const q = await sonarr("/queue", { query: { pageSize: 200, includeSeries: true } })
571
+ queueRecs = (q.records ?? []).filter((r) => r.seriesId === svcId)
572
+ history = await sonarr("/history/series", { query: { seriesId: svcId } }).catch(() => [])
573
+ result.request.title = entity.title
574
+ result.file = { on_shelf: (entity.statistics?.episodeFileCount ?? 0) > 0,
575
+ episodes_on_disk: entity.statistics?.episodeFileCount ?? 0,
576
+ episodes_total: entity.statistics?.episodeCount ?? 0,
577
+ size_bytes: entity.statistics?.sizeOnDisk ?? 0,
578
+ path: entity.path ?? null }
579
+ } else if (kind === "movie" && svcId) {
580
+ entity = await radarr(`/movie/${svcId}`)
581
+ const q = await radarr("/queue", { query: { pageSize: 200 } })
582
+ queueRecs = (q.records ?? []).filter((r) => r.movieId === svcId)
583
+ history = await radarr("/history/movie", { query: { movieId: svcId } }).catch(() => [])
584
+ result.request.title = entity.title
585
+ result.file = { on_shelf: Boolean(entity.hasFile), size_bytes: entity.sizeOnDisk ?? 0, path: entity.path ?? null }
586
+ }
587
+ } catch (e) {
588
+ result.chain_error = e.message
589
+ }
590
+
591
+ const grabs = (history ?? []).filter((h) => h.eventType === "grabbed")
592
+ result.search = {
593
+ grab_attempts: grabs.length,
594
+ last_grab_at: grabs[0]?.date ?? null,
595
+ last_grab_title: grabs[0]?.sourceTitle ?? null,
596
+ monitored: entity ? Boolean(entity.monitored) : null,
597
+ }
598
+ const statusNow = Date.now()
599
+ result.download = computeDownloadState(queueRecs, statusNow, (r) => observeProgress(progressKey(r, kind), r.sizeleft ?? 0, statusNow))
600
+
601
+ const chain = await chainHealth()
602
+ result.chain = chain
603
+ result.diagnosis = diagnose({ shelf, result, chain, entity, kind })
604
+ return result
605
+ }
606
+
607
+ // The deterministic core, split out so it can be tested without a live
608
+ // Sonarr/Radarr. `computeDownloadState` and `diagnose` are the whole reason the
609
+ // media surface can answer "why hasn't this downloaded" without the agent
610
+ // guessing, and D-013 (a stalled download read as progress) shipped verified
611
+ // only in production because there was no seam to test them through.
612
+ //
613
+ // A season pack shows up as one queue row per episode, all sharing one
614
+ // downloadId. Summing rows would report a 45 GB pack as 450 GB, so size and
615
+ // "how many downloads" are counted per distinct download, not per row. A
616
+ // torrent that has not moved a byte since it was grabbed is dead, not slow:
617
+ // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
618
+ // progress is measured against age rather than trusted from the row.
619
+ // The one stall rule: nothing downloaded yet, and older than the stall window.
620
+ // A download that never started, or one still unfinished days later (e.g. stuck at
621
+ // 99.85% with no seeders for the last piece), will not finish on its own.
622
+ const STUCK_INCOMPLETE_AFTER_HOURS = 72
623
+
624
+ export function isStalledRow(r, nowMs) {
625
+ if (!((r.size ?? 0) > 0) || !r.added) return false
626
+ const ageHours = (nowMs - Date.parse(r.added)) / 3_600_000
627
+ const left = r.sizeleft ?? 0
628
+ if (left >= r.size) return ageHours >= STALL_AFTER_HOURS
629
+ return left > 0 && ageHours >= STUCK_INCOMPLETE_AFTER_HOURS
630
+ }
631
+
632
+ const rowPercent = (r) => ((r.size ?? 0) > 0 ? Math.round((((r.size ?? 0) - (r.sizeleft ?? 0)) / r.size) * 100) : null)
633
+
634
+ // The one stall classification, shared by the status, diagnosis, queue, fill and blocklist
635
+ // surfaces. `previous` is the persisted progress memory for this download (or undefined).
636
+ // Returns null while the download may still be moving; otherwise the kind, whether the stall
637
+ // is confirmed (only confirmed stalls are acted on) and the facts the owner is told.
638
+ // The owner's three choices when a download is confirmed dead; one source for every surface that asks.
639
+ export const STALL_OWNER_CHOICES = ["keep_waiting", "find_another_release", "give_up"]
640
+
641
+ export function classifyStall(row, nowMs, previous) {
642
+ const why = stallReason(row, nowMs, previous)
643
+ if (!why) return null
644
+ const left = row.sizeleft ?? 0
645
+ const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
646
+ const unchanged = previous && previous.sizeleft === left ? previous.at : null
647
+ return {
648
+ kind: why.stall, confirmed: why.confirmed, state: why.confirmed ? "stalled" : "suspected",
649
+ percent_complete: rowPercent(row),
650
+ age_hours: row.added ? Math.round((nowMs - Date.parse(row.added)) / 3_600_000) : null,
651
+ no_eta: noEta,
652
+ unchanged_since: unchanged === null ? null : new Date(unchanged).toISOString(),
653
+ unchanged_hours: unchanged === null ? null : Math.round((nowMs - unchanged) / 3_600_000),
654
+ }
655
+ }
656
+
657
+ // `previousFor(row)` reads the progress memory for a queue row; without it nothing is confirmed
658
+ // beyond the age rules.
659
+ export function computeDownloadState(queueRecs, nowMs, previousFor = () => undefined) {
660
+ const byDownload = new Map()
661
+ for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
662
+ const downloads = [...byDownload.values()]
663
+ const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
664
+ const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
665
+ const classified = downloads
666
+ .map((r) => ({ r, c: classifyStall(r, nowMs, previousFor(r)) }))
667
+ .filter((x) => x.c)
668
+ .map(({ r, c }) => ({
669
+ title: r.title ?? null,
670
+ added_at: r.added ?? null,
671
+ size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
672
+ queue_id: r.id ?? null,
673
+ ...c,
674
+ }))
675
+ const slowItems = downloads
676
+ .filter((r) => isStalledRow(r, nowMs) && (r.sizeleft ?? 0) < (r.size ?? 0) && !classifyStall(r, nowMs, previousFor(r)))
677
+ .map((r) => ({ title: r.title ?? null, percent_complete: rowPercent(r), timeleft: r.timeleft ?? null, age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000) }))
678
+ const stalledItems = classified.filter((i) => i.confirmed)
679
+ const suspectedItems = classified.filter((i) => !i.confirmed)
680
+ return {
681
+ active_downloads: downloads.length,
682
+ active_items: queueRecs.length,
683
+ states: [...new Set(downloads.map((r) => r.status))],
684
+ tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
685
+ errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
686
+ size_left_bytes: sizeLeft,
687
+ size_total_bytes: sizeTotal,
688
+ percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
689
+ stalled: stalledItems.length > 0,
690
+ stalled_items: stalledItems,
691
+ suspected_items: suspectedItems,
692
+ slow_items: slowItems,
693
+ }
694
+ }
695
+
696
+ // The deterministic core. The agent must never have to work this out itself.
697
+ export function diagnose({ shelf, result, chain, entity, kind }) {
698
+ const file = result.file ?? {}
699
+ const onShelf = kind === "series" ? (file.episodes_on_disk ?? 0) > 0 : Boolean(file.on_shelf)
700
+ const complete = kind === "series" ? (file.episodes_on_disk ?? 0) >= (file.episodes_total ?? Infinity) : onShelf
701
+
702
+ if (complete) {
703
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
704
+ summary: "On the shelf and complete. Nothing is stuck." }
705
+ }
706
+ if (result.download.stalled) {
707
+ const items = result.download.stalled_items
708
+ const partials = items.filter((i) => i.kind !== "never_started")
709
+ const neverStarted = items.filter((i) => i.kind === "never_started")
710
+ if (partials.length) {
711
+ // A partial download holds progress that removing it loses, and the owner's standing
712
+ // instruction for this case is to be asked, so nothing here acts on its own.
713
+ const facts = partials.map((i) => {
714
+ const since = i.unchanged_since ? ` since ${i.unchanged_since} (${i.unchanged_hours} hours)` : ""
715
+ return `${i.title ?? "A release"} is stuck at ${i.percent_complete}% with no download activity${since}; no seeders are serving the rest`
716
+ })
717
+ return { stuck_stage: "download", stuck_reason: "stalled_partial_download",
718
+ likely_fix: "ask_owner", human_action_required: true,
719
+ choices: STALL_OWNER_CHOICES,
720
+ percent_complete: result.download.percent_complete,
721
+ detail: items,
722
+ ...(neverStarted.length ? { never_started: { likely_fix: "blocklist_and_research", items: neverStarted } } : {}),
723
+ summary: `${facts.join(". ")}. Ask the owner whether to keep waiting, find another release, or give up; do not remove it on your own, because removing a partial download loses its progress.${neverStarted.length ? ` Separately, ${neverStarted.length} download(s) never started (${neverStarted.map((i) => i.title ?? "a release").join("; ")}); those can be blocklisted and searched again (blocklist_and_research) without asking.` : ""} media_fill_missing can look for a seeded alternative without deleting the partial first; it removes the partial only after a seeded replacement is grabbed.` }
724
+ }
725
+ const oldest = Math.max(...items.map((i) => i.age_hours))
726
+ return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
727
+ likely_fix: "blocklist_and_research", human_action_required: false,
728
+ percent_complete: result.download.percent_complete,
729
+ detail: items,
730
+ summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded anything in ${oldest} hours, which means no seeders rather than a quiet queue. Nothing was lost by waiting this long; blocklist the release and search again for a different one.` }
731
+ }
732
+ if (result.download.active_downloads > 0) {
733
+ const left = result.download.size_left_bytes
734
+ const total = result.download.size_total_bytes
735
+ const gb = (left / 1073741824).toFixed(1)
736
+ const pct = total > 0 ? Math.round(((total - left) / total) * 100) : null
737
+ const what = result.download.active_downloads === 1 && result.download.active_items > 1
738
+ ? `a ${result.download.active_items}-episode pack`
739
+ : `${result.download.active_downloads} download(s)`
740
+ const suspected = result.download.suspected_items ?? []
741
+ const slow = result.download.slow_items ?? []
742
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
743
+ percent_complete: pct,
744
+ ...(suspected.length ? { stall_suspected: true, detail: suspected } : {}),
745
+ summary: `Downloading now — ${what}${pct === null ? "" : `, ${pct}% done`}, about ${gb} GB to go.${slow.length ? ` Slow but still moving: ${slow.map((i) => `${i.title ?? "a release"} is ${i.percent_complete}% done after ${i.age_hours} hours with an ETA of ${i.timeleft}`).join("; ")}.` : ""}${suspected.length ? ` It reports no ETA after ${Math.max(...suspected.map((i) => i.age_hours))} hours, so it may be stalled; the next check 30 minutes or more later confirms it. Do not promise an ETA.` : ""}` }
746
+ }
747
+ if (result.download.errors.length) {
748
+ return { stuck_stage: "download", stuck_reason: "download_client_error", likely_fix: "retry_or_replace_release",
749
+ human_action_required: false, detail: result.download.errors,
750
+ summary: `The download client reported: ${result.download.errors.join("; ")}` }
751
+ }
752
+ if (!chain.healthy) {
753
+ return { stuck_stage: "indexers", stuck_reason: "acquisition_chain_down",
754
+ likely_fix: "restore_indexers", human_action_required: true,
755
+ detail: chain.problems,
756
+ summary: `Nothing can download: ${chain.problems.map((p) => p.message).join(" ")} This blocks every request, not just this one.` }
757
+ }
758
+ if (entity && entity.monitored === false) {
759
+ return { stuck_stage: "request", stuck_reason: "not_monitored", likely_fix: "enable_monitoring",
760
+ human_action_required: false, summary: "It is not monitored, so no search will ever run for it." }
761
+ }
762
+ if (result.search.grab_attempts === 0) {
763
+ return { stuck_stage: "search", stuck_reason: "no_search_run_or_no_acceptable_release",
764
+ likely_fix: "rescan", human_action_required: false,
765
+ summary: "Indexers are healthy but nothing has ever been grabbed. A fresh search (media_diagnose_and_fix action=rescan) is the next step." }
766
+ }
767
+ if (onShelf && !complete) {
768
+ return { stuck_stage: "import", stuck_reason: "partially_imported", likely_fix: "rescan",
769
+ human_action_required: false,
770
+ summary: `Partly here: ${file.episodes_on_disk} of ${file.episodes_total} episodes. The rest still need grabbing.` }
771
+ }
772
+ return { stuck_stage: "import", stuck_reason: "grabbed_but_not_imported", likely_fix: "force_import_scan",
773
+ human_action_required: false,
774
+ summary: `It was grabbed (${result.search.last_grab_title ?? "unknown release"}) but never landed on the shelf.` }
775
+ }
776
+
777
+ // -------------------------------------------------- tool: diagnose_and_fix
778
+
779
+ async function mediaDiagnoseAndFix(a) {
780
+ const before = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
781
+ if (!before.found) return { result: "no_such_request", before, after: null, human_action_required: true,
782
+ human_action_reason: "Nothing to fix — it was never requested." }
783
+ const action = a.action ?? (before.diagnosis?.likely_fix ?? "rescan")
784
+
785
+ if (before.diagnosis?.stuck_reason === "acquisition_chain_down" && action !== "report_only") {
786
+ return { action, dry_run: Boolean(a.dry_run), result: "rejected_fix_unsafe", before, after: null,
787
+ human_action_required: true,
788
+ human_action_reason: "The acquisition chain itself is down (see before.chain.problems). Re-searching cannot help until indexers are restored." }
789
+ }
790
+ if (action === "ask_owner") {
791
+ return { action, result: "owner_decision_needed", before, after: null, human_action_required: true,
792
+ choices: before.diagnosis?.choices ?? STALL_OWNER_CHOICES,
793
+ ...(before.diagnosis?.never_started ? { never_started: before.diagnosis.never_started } : {}),
794
+ human_action_reason: before.diagnosis?.summary ?? "The owner decides." }
795
+ }
796
+ if (a.dry_run) {
797
+ return { action, dry_run: true, result: "would_apply", before,
798
+ would_do: describeAction(action), human_action_required: false }
799
+ }
800
+
801
+ const kind = before.request.kind
802
+ const svcId = kind === "series" ? before.request.sonarr_id : before.request.radarr_id
803
+ if (!svcId) return { action, result: "manual_required", before, after: null, human_action_required: true,
804
+ human_action_reason: "This request is not yet mapped into Sonarr/Radarr, so no search can be triggered." }
805
+
806
+ let commanded = null
807
+ if (action === "rescan" || action === "enable_monitoring" || action === "force_import_scan") {
808
+ if (action === "enable_monitoring") {
809
+ if (kind === "series") await sonarr(`/series/${svcId}`, { method: "PUT", body: { ...(await sonarr(`/series/${svcId}`)), monitored: true } })
810
+ else await radarr(`/movie/${svcId}`, { method: "PUT", body: { ...(await radarr(`/movie/${svcId}`)), monitored: true } })
811
+ }
812
+ const cmd = kind === "series"
813
+ ? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
814
+ : (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
815
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
816
+ } else if (action === "blocklist_and_research") {
817
+ // Removing with blocklist=true is what stops the same dead release being
818
+ // grabbed straight back. The search that follows is then free to pick a
819
+ // different one.
820
+ // Only a download that never started: a partial holds progress and is the owner's call.
821
+ const stalled = (before.download.stalled_items ?? []).filter((i) => i.kind === "never_started")
822
+ if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
823
+ human_action_reason: "No download has been sitting at zero long enough to call it stalled. A partial download is not removed this way: ask the owner." }
824
+ for (const item of stalled) {
825
+ if (item.queue_id === null) continue
826
+ const client = kind === "series" ? sonarr : radarr
827
+ await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
828
+ }
829
+ const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
830
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
831
+ } else {
832
+ return { action, result: "unsupported_action", before, after: null, human_action_required: true,
833
+ human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
834
+ }
835
+
836
+ await new Promise((r) => setTimeout(r, 12_000))
837
+ const after = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
838
+ return {
839
+ action, dry_run: false, applied_at: new Date().toISOString(),
840
+ command: { id: commanded?.id ?? null, name: commanded?.name ?? null },
841
+ before: { diagnosis: before.diagnosis, grab_attempts: before.search.grab_attempts, active_downloads: before.download.active_downloads },
842
+ after: { diagnosis: after.diagnosis, grab_attempts: after.search.grab_attempts, active_downloads: after.download.active_downloads },
843
+ result: after.download.active_downloads > before.download.active_downloads ? "grabbed_and_downloading"
844
+ : after.search.grab_attempts > before.search.grab_attempts ? "grabbed"
845
+ : "queued_for_search",
846
+ human_action_required: Boolean(after.diagnosis?.human_action_required),
847
+ human_action_reason: after.diagnosis?.human_action_required ? after.diagnosis.summary : null,
848
+ }
849
+ }
850
+
851
+ function describeAction(action) {
852
+ return {
853
+ rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
854
+ enable_monitoring: "Mark it monitored, then search.",
855
+ force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
856
+ blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
857
+ report_only: "Report only; change nothing.",
858
+ }[action] ?? "Unknown action."
859
+ }
860
+
861
+ // ------------------------------------------------------- tool: play/resolve
862
+
863
+ async function mediaPlayOrResolve(a) {
864
+ const status = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
865
+ if (!status.found || !status.file) {
866
+ return { playable: false, reason: "not_on_shelf", diagnosis: status.diagnosis ?? null,
867
+ human_action_required: true, human_action_reason: "It is not on the shelf yet." }
868
+ }
869
+ const onShelf = status.request.kind === "series" ? (status.file.episodes_on_disk ?? 0) > 0 : status.file.on_shelf
870
+ return {
871
+ playable: Boolean(onShelf),
872
+ title: status.request.title ?? null,
873
+ kind: status.request.kind,
874
+ tmdb_id: status.request.tmdb_id,
875
+ episodes_on_disk: status.file.episodes_on_disk ?? null,
876
+ episodes_total: status.file.episodes_total ?? null,
877
+ path: status.file.path,
878
+ size_bytes: status.file.size_bytes,
879
+ play_url: onShelf && JELLYFIN_WEB ? `${JELLYFIN_WEB}/web/index.html#!/search.html?query=${encodeURIComponent(status.request.title ?? "")}` : null,
880
+ diagnosis: status.diagnosis,
881
+ }
882
+ }
883
+
884
+ // --------------------------------------------------------- tool: chain health
885
+
886
+ async function mediaChainHealth() {
887
+ const chain = await chainHealth()
888
+ return {
889
+ healthy: chain.healthy,
890
+ indexers: chain.indexers,
891
+ app_sync: chain.apps,
892
+ problems: chain.problems,
893
+ summary: chain.healthy
894
+ ? `Acquisition chain healthy: ${chain.indexers.ok.length} of ${chain.indexers.enabled} enabled indexers responding${chain.indexers.failing.length ? `, failing: ${chain.indexers.failing.join(", ")}` : ""}.`
895
+ : `Acquisition chain DOWN. ${chain.problems.map((p) => p.message).join(" ")}`,
896
+ }
897
+ }
898
+
899
+ // ------------------------------------- tools: search_now / blocklist_stalled
900
+
901
+ // Act on an item already in Sonarr/Radarr directly; no Jellyseerr request needed.
902
+ // Never adds a library item: an unknown title is reported, not created.
903
+ const arrFor = (kind) => (kind === "series" ? sonarr : radarr)
904
+
905
+ async function resolveLibraryItem(a) {
906
+ let candidates
907
+ if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id) {
908
+ const list = await arrFor(a.kind)(a.kind === "series" ? "/series" : "/movie")
909
+ const items = (Array.isArray(list) ? list : []).map((i) => libraryCandidate(a.kind, i))
910
+ 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)))
911
+ if (hit.length === 1) return { item: { id: hit[0].service_id, title: hit[0].title } }
912
+ candidates = hit
913
+ } else if (a.title) {
914
+ const ranked = (await findLibraryCandidates(a.title, a.kind)).filter((c) => c.service_id !== null)
915
+ // Act only when the best tier holds exactly one item and it is not merely a
916
+ // typo-distance match; otherwise name the candidates and let the caller pick.
917
+ const bestTier = ranked.filter((c) => c.match === ranked[0]?.match)
918
+ if (bestTier.length === 1 && bestTier[0].match !== "fuzzy") return { item: { id: bestTier[0].service_id, title: bestTier[0].title } }
919
+ if (!ranked.length) {
920
+ const lookup = (await lookupCandidates(a.title, a.kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
921
+ return { result: "not_found", candidates: lookup }
922
+ }
923
+ candidates = ranked
924
+ } else candidates = []
925
+ return { result: candidates.length ? "ambiguous" : "not_found", candidates: candidates.slice(0, 10) }
926
+ }
927
+
928
+ const QUEUE_PAGE_SIZE = 200
929
+ const QUEUE_MAX_PAGES = 20
930
+
931
+ // The item's queue rows. Filtered server-side (seriesIds/movieIds) and paged, so a
932
+ // long queue cannot hide a row; the client-side filter stays as the backstop.
933
+ async function itemQueue(kind, serviceId) {
934
+ const client = arrFor(kind)
935
+ const idKey = kind === "series" ? "seriesId" : "movieId"
936
+ const filter = kind === "series" ? { seriesIds: serviceId, includeSeries: true } : { movieIds: serviceId }
937
+ const rows = []
938
+ for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
939
+ const q = await client("/queue", { query: { ...filter, page, pageSize: QUEUE_PAGE_SIZE } })
940
+ const records = q?.records ?? []
941
+ rows.push(...records)
942
+ if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE)) break
943
+ }
944
+ return rows.filter((r) => r[idKey] === serviceId)
945
+ }
946
+
947
+ function queueReport(rows, kind = "series") {
948
+ const now = Date.now()
949
+ return rows.map((r) => {
950
+ const c = classifyStall(r, now, peekProgress(progressKey(r, kind), r.sizeleft ?? 0, now))
951
+ return { queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
952
+ size_left_bytes: r.sizeleft ?? 0, stalled: Boolean(c?.confirmed), ...(c ? { stall_kind: c.kind } : {}) }
953
+ })
954
+ }
955
+
956
+ // ------------------------------------------------------------ tool: media_queue
957
+
958
+ const QUEUE_VIEW_MAX_ROWS = 100
959
+
960
+ // One queue row as the owner's torrent or usenet client reports it through Sonarr/Radarr.
961
+ export function queueRowView(r, nowMs, previous) {
962
+ const c = classifyStall(r, nowMs, previous)
963
+ return {
964
+ queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
965
+ tracked_download_state: r.trackedDownloadState ?? null, tracked_download_status: r.trackedDownloadStatus ?? null,
966
+ percent: pct(r), timeleft: r.timeleft ?? null, size_left_bytes: r.sizeleft ?? 0, size_bytes: r.size ?? 0,
967
+ protocol: r.protocol ?? null, download_client: r.downloadClient ?? null, indexer: r.indexer ?? null,
968
+ added: r.added ?? null, stalled: Boolean(c?.confirmed),
969
+ ...(c ? { stall: { kind: c.kind, state: c.state, percent_complete: c.percent_complete, age_hours: c.age_hours, no_eta: c.no_eta, unchanged_since: c.unchanged_since,
970
+ ...(c.confirmed ? { owner_decision_needed: true, likely_fix: "ask_owner", choices: STALL_OWNER_CHOICES } : {}) } } : {}),
971
+ ...(r.errorMessage ? { error_message: String(r.errorMessage).slice(0, 300) } : {}),
972
+ }
973
+ }
974
+
975
+ // Read-only: the Sonarr/Radarr download queue (torrent and usenet alike). Grabs, removes and blocklists nothing.
976
+ export async function mediaQueue(a) {
977
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
978
+ let rows
979
+ let item = null
980
+ let total = null
981
+ if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id || a.title) {
982
+ const found = await resolveLibraryItem(a)
983
+ if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
984
+ item = found.item
985
+ rows = await itemQueue(a.kind, item.id)
986
+ } else {
987
+ rows = []
988
+ const client = arrFor(a.kind)
989
+ for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
990
+ const q = await client("/queue", { query: { page, pageSize: QUEUE_PAGE_SIZE, ...(a.kind === "series" ? { includeSeries: true } : {}) } })
991
+ const records = q?.records ?? []
992
+ rows.push(...records)
993
+ if (Number.isFinite(Number(q?.totalRecords))) total = Number(q.totalRecords)
994
+ if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE) || rows.length >= QUEUE_VIEW_MAX_ROWS) break
995
+ }
996
+ }
997
+ total = Math.max(total ?? 0, rows.length)
998
+ const now = Date.now()
999
+ // Looking records the sighting in the local progress memory (never in Sonarr/Radarr), so this call can confirm a stall too.
1000
+ const shown = rows.slice(0, QUEUE_VIEW_MAX_ROWS).map((r) => queueRowView(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now)))
1001
+ return {
1002
+ kind: a.kind, ...(item ? { service_id: item.id, title: item.title } : {}),
1003
+ count: total, shown: shown.length, ...(total > shown.length ? { truncated: true } : {}), queue: shown,
1004
+ summary: !total ? "Nothing for this is in the Sonarr/Radarr queue."
1005
+ : `${total} queue row${total === 1 ? "" : "s"}${total > shown.length ? ` (showing ${shown.length})` : ""}; ${shown.filter((r) => r.stalled).length} stalled${shown.some((r) => r.stall?.state === "suspected") ? `, ${shown.filter((r) => r.stall?.state === "suspected").length} suspected (no ETA, to be confirmed by a check 30 minutes later)` : ""}.`,
1006
+ note: "Sonarr/Radarr do not report seeders or peers. A row stuck at the same percent with no timeleft is the stall signal; for a release's seeder count use media_indexer_search.",
1007
+ }
1008
+ }
1009
+
1010
+ async function runSearch(kind, serviceId) {
1011
+ const body = kind === "series" ? { name: "SeriesSearch", seriesId: serviceId } : { name: "MoviesSearch", movieIds: [serviceId] }
1012
+ const c = await arrFor(kind)("/command", { method: "POST", body })
1013
+ return { id: c?.id ?? null, name: c?.name ?? body.name, status: c?.status ?? null }
1014
+ }
1015
+
1016
+ export async function mediaSearchNow(a) {
1017
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
1018
+ const found = await resolveLibraryItem(a)
1019
+ if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
1020
+ const { item } = found
1021
+ const command = await runSearch(a.kind, item.id)
1022
+ return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id), a.kind) }
1023
+ }
1024
+
1025
+ // Whether a download may be removed without the owner naming it. A download that has never
1026
+ // started is dead once past the stall window. A partial one is dead only when observed with no
1027
+ // ETA and no progress across 30 minutes: age alone is not enough (a 75.9% pack with no seeders
1028
+ // today can finish when one returns), and anything with an ETA is still moving.
1029
+ const hasEta = (row) => row.timeleft !== undefined && row.timeleft !== null && row.timeleft !== "00:00:00"
1030
+ export function removalVerdict(row, nowMs, previous) {
1031
+ if (hasEta(row)) return "still_downloading"
1032
+ const left = row.sizeleft ?? 0
1033
+ if ((row.size ?? 0) > 0 && left >= row.size) return isStalledRow(row, nowMs) ? "dead" : "not_started_yet"
1034
+ if (row.status === "failed") return "dead"
1035
+ const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
1036
+ return unchanged ? "dead" : "stall_unconfirmed"
1037
+ }
1038
+
1039
+ export async function mediaBlocklistStalled(a) {
1040
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
1041
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1042
+ const serviceId = Number(a.service_id)
1043
+ const rows = await itemQueue(a.kind, serviceId)
1044
+ const now = Date.now()
1045
+ let targets
1046
+ if (Array.isArray(a.queue_ids) && a.queue_ids.length) {
1047
+ const own = new Set(rows.map((r) => r.id))
1048
+ const foreign = a.queue_ids.filter((id) => !own.has(id))
1049
+ if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows, a.kind) }
1050
+ targets = rows.filter((r) => a.queue_ids.includes(r.id))
1051
+ } else {
1052
+ // Candidates only: removalVerdict below confirms a no-ETA partial against the progress memory.
1053
+ targets = rows.filter((r) => stallReason(r, now, undefined) !== null)
1054
+ }
1055
+ // A season pack is many queue rows sharing one downloadId; deleting one removes
1056
+ // the download for all, so delete once per download.
1057
+ const seen = new Set()
1058
+ targets = targets.filter((r) => {
1059
+ const key = r.downloadId ? `d:${r.downloadId}` : `q:${r.id}`
1060
+ if (seen.has(key)) return false
1061
+ seen.add(key)
1062
+ return true
1063
+ })
1064
+ // Removing a download deletes its progress. Unless the owner asked for this download to go,
1065
+ // only a download confirmed dead is removed: one still moving, or stalled without a second
1066
+ // observation 30 minutes apart, may yet finish (2026-10-05: a 75.9% pack, the only copy, was
1067
+ // deleted on "yes please get them").
1068
+ const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
1069
+ if (ownerWords && !(Array.isArray(a.queue_ids) && a.queue_ids.length)) {
1070
+ return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows, a.kind) }
1071
+ }
1072
+ const kept = []
1073
+ if (!ownerWords) {
1074
+ targets = targets.filter((r) => {
1075
+ const verdict = removalVerdict(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now))
1076
+ if (verdict === "dead") return true
1077
+ kept.push({ queue_id: r.id, title: r.title ?? null, percent: pct(r), reason: verdict })
1078
+ return false
1079
+ })
1080
+ }
1081
+ if (!targets.length && kept.length) {
1082
+ return { result: "kept_downloads", kept, queue: queueReport(rows, a.kind),
1083
+ message: "Nothing removed. These downloads are not confirmed dead, and removing one deletes its progress. A partial download counts as dead only after two calls at least 30 minutes apart see no progress and no ETA; call again later to confirm. For missing episodes call media_fill_missing instead: it replaces a stalled download only when a seeded alternative exists. Pass owner_words only with the owner's own words asking to remove, clear or cancel this download; 'get them' is not that." }
1084
+ }
1085
+ if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows, a.kind) }
1086
+ const removed = []
1087
+ const failed = []
1088
+ for (const row of targets) {
1089
+ try {
1090
+ await arrFor(a.kind)(`/queue/${row.id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
1091
+ removed.push(row.id)
1092
+ } catch (e) {
1093
+ // 404: the row went away (finished or removed with its pack); not a failure to retry.
1094
+ if (/_http_404$/.test(e.code ?? "")) removed.push(row.id)
1095
+ else failed.push({ queue_id: row.id, error: e.message })
1096
+ }
1097
+ }
1098
+ const command = a.research === false ? null : await runSearch(a.kind, serviceId)
1099
+ return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId), a.kind) }
1100
+ }
1101
+
1102
+ // ------------------------------- tools: episodes / releases / blocklist / import
1103
+
1104
+ // When an automatic search finds nothing, the Butler works the release by hand:
1105
+ // list the episodes, read what the indexers offer and how Sonarr parsed it, grab
1106
+ // the right one with an explicit episode mapping, and map downloaded files by title.
1107
+ // All scoped to series/movies already in the library; nothing here adds one.
1108
+
1109
+ const kindOk = (a) => a.kind === "series" || a.kind === "movie"
1110
+ const invalidKind = () => ({ result: "invalid_kind", message: "kind must be 'series' or 'movie'." })
1111
+ const idKeyOf = (kind) => (kind === "series" ? "seriesId" : "movieId")
1112
+ const code2 = (n) => String(n).padStart(2, "0")
1113
+ const sxe = (e) => `S${code2(e.seasonNumber)}E${code2(e.episodeNumber)}`
1114
+
1115
+ async function seriesEpisodes(seriesId, seasonNumber) {
1116
+ const list = await sonarr("/episode", { query: { seriesId, seasonNumber } })
1117
+ return Array.isArray(list) ? list : []
1118
+ }
1119
+
1120
+ export async function mediaEpisodes(a) {
1121
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1122
+ const seriesId = Number(a.service_id)
1123
+ const eps = await seriesEpisodes(seriesId, a.season_number)
1124
+ return {
1125
+ series_id: seriesId,
1126
+ season_number: a.season_number ?? null,
1127
+ count: eps.length,
1128
+ episodes: eps.map((e) => ({ episode_id: e.id, code: sxe(e), season: e.seasonNumber, episode: e.episodeNumber, title: e.title ?? null,
1129
+ air_date: e.airDate ?? null, has_file: Boolean(e.hasFile), monitored: Boolean(e.monitored),
1130
+ // Release names follow scene (often streaming) numbering; Sonarr's own mapping, when it has one.
1131
+ scene_code: Number.isInteger(e.sceneSeasonNumber) && Number.isInteger(e.sceneEpisodeNumber)
1132
+ ? `S${String(e.sceneSeasonNumber).padStart(2, "0")}E${String(e.sceneEpisodeNumber).padStart(2, "0")}` : null })),
1133
+ }
1134
+ }
1135
+
1136
+ // Free-text search across every Prowlarr indexer: discovery only, it grabs nothing.
1137
+ // Sonarr's own search only asks for the season/episode codes it expects; this finds
1138
+ // what exists under any name (another numbering, a "Season 2 (2020)" pack, an episode title).
1139
+ export async function mediaIndexerSearch(a) {
1140
+ const query = typeof a.query === "string" ? a.query.trim() : ""
1141
+ if (!query) return { result: "query_required" }
1142
+ const raw = await prowlarr("/search", { query: { query, type: "search", limit: 100 }, timeoutMs: SEARCH_TIMEOUT_MS })
1143
+ const limit = Math.min(Math.max(Number(a.limit) || 25, 1), 50)
1144
+ const rows = (Array.isArray(raw) ? raw : [])
1145
+ .map((r) => ({ indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null, seeders: r.seeders ?? null,
1146
+ age_days: r.age ?? null, protocol: r.protocol ?? null }))
1147
+ .sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1))
1148
+ return { query, count: rows.length, releases: rows.slice(0, limit),
1149
+ 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." }
1150
+ }
1151
+
1152
+ // The last search's compact rows, keyed by kind + guid, so a grab can check what Sonarr parsed.
1153
+ // Sonarr/Radarr forget a search's releases after about 30 minutes; expire ours a little sooner.
1154
+ const releaseCache = new Map()
1155
+ const RELEASE_CACHE_TTL_MS = 25 * 60_000
1156
+ const RELEASE_CACHE_MAX = 500
1157
+ const MAX_EPISODE_SEARCHES = 6
1158
+
1159
+ function cacheRelease(key, row, nowMs = Date.now()) {
1160
+ for (const [k, v] of releaseCache) if (nowMs - v.at > RELEASE_CACHE_TTL_MS) releaseCache.delete(k)
1161
+ releaseCache.delete(key)
1162
+ releaseCache.set(key, { ...row, at: nowMs })
1163
+ while (releaseCache.size > RELEASE_CACHE_MAX) releaseCache.delete(releaseCache.keys().next().value)
1164
+ }
1165
+
1166
+ function releaseRow(kind, r) {
1167
+ const rejections = Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : []
1168
+ const row = {
1169
+ guid: r.guid, indexer_id: r.indexerId ?? null, indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null,
1170
+ seeders: r.seeders ?? null, age_days: r.age ?? null, quality: r.quality?.quality?.name ?? null, protocol: r.protocol ?? null,
1171
+ rejected: Boolean(r.rejected), rejections, blocklisted: rejections.some((x) => /blocklist/i.test(x)),
1172
+ }
1173
+ if (kind === "series") {
1174
+ return { ...row, full_season: Boolean(r.fullSeason), parsed_season: r.seasonNumber ?? null, parsed_episodes: r.episodeNumbers ?? [],
1175
+ mapped_episodes: (r.mappedEpisodeInfo ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
1176
+ mapped_series_id: r.mappedSeriesId ?? null }
1177
+ }
1178
+ return { ...row, mapped_movie_id: r.mappedMovieId ?? null }
1179
+ }
1180
+
1181
+ export async function mediaReleaseSearch(a) {
1182
+ if (!kindOk(a)) return invalidKind()
1183
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1184
+ const serviceId = Number(a.service_id)
1185
+ let raw = []
1186
+ if (a.kind === "movie") {
1187
+ raw = await radarr("/release", { query: { movieId: serviceId }, timeoutMs: SEARCH_TIMEOUT_MS })
1188
+ } else if (Array.isArray(a.episode_ids) && a.episode_ids.length) {
1189
+ // Sonarr searches one episode per call.
1190
+ 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." }
1191
+ for (const id of a.episode_ids) raw.push(...await sonarr("/release", { query: { episodeId: id }, timeoutMs: SEARCH_TIMEOUT_MS }))
1192
+ } else if (a.season_number !== undefined && a.season_number !== null) {
1193
+ raw = await sonarr("/release", { query: { seriesId: serviceId, seasonNumber: a.season_number }, timeoutMs: SEARCH_TIMEOUT_MS })
1194
+ } else return { result: "season_number_or_episode_ids_required" }
1195
+ const seen = new Set()
1196
+ const unique = (Array.isArray(raw) ? raw : []).filter((r) => (seen.has(r.guid) ? false : seen.add(r.guid)))
1197
+ // Sonarr's override grab needs the release's own quality and languages objects back.
1198
+ const rawByGuid = new Map(unique.map((r) => [r.guid, r]))
1199
+ let rows = unique.map((r) => releaseRow(a.kind, r))
1200
+ const total = rows.length
1201
+ if (a.query) {
1202
+ const q = String(a.query).toLowerCase()
1203
+ rows = rows.filter((r) => (r.title ?? "").toLowerCase().includes(q))
1204
+ }
1205
+ rows.sort((x, y) => Number(y.seeders ?? 0) - Number(x.seeders ?? 0))
1206
+ const limit = Math.min(Math.max(Number(a.limit) || 40, 1), 100)
1207
+ for (const r of rows) {
1208
+ const source = rawByGuid.get(r.guid)
1209
+ cacheRelease(`${a.kind}:${r.guid}`, { ...r, service_id: serviceId, season_number: a.season_number ?? null,
1210
+ quality_raw: source?.quality ?? null, languages_raw: source?.languages ?? null })
1211
+ }
1212
+ return { kind: a.kind, service_id: serviceId, total_found: total, matched: rows.length, releases: rows.slice(0, limit) }
1213
+ }
1214
+
1215
+ export async function mediaReleaseGrab(a) {
1216
+ if (!kindOk(a)) return invalidKind()
1217
+ if (!a.guid || a.indexer_id === undefined || a.indexer_id === null) return { result: "guid_and_indexer_id_required" }
1218
+ const cached = releaseCache.get(`${a.kind}:${a.guid}`)
1219
+ if (!cached || Date.now() - cached.at > RELEASE_CACHE_TTL_MS) {
1220
+ return { result: "search_first", message: "Run media_release_search first; a release can only be grabbed from a recent search (within 25 minutes)." }
1221
+ }
1222
+ if (cached.indexer_id !== null && Number(a.indexer_id) !== Number(cached.indexer_id)) return { result: "indexer_mismatch", expected_indexer_id: cached.indexer_id }
1223
+ const body = { guid: a.guid, indexerId: Number(a.indexer_id) }
1224
+ const hasEpisodes = Array.isArray(a.episode_ids) && a.episode_ids.length > 0
1225
+ if (a.kind === "series") {
1226
+ if (!hasEpisodes) {
1227
+ // A pack Sonarr parsed as another season would be mapped to the wrong episodes.
1228
+ const target = a.season_number ?? cached.season_number
1229
+ if (cached.full_season && (target === undefined || target === null || Number(target) !== Number(cached.parsed_season))) {
1230
+ return { result: "season_pack_mismatch", parsed_season: cached.parsed_season, target_season: target ?? null, title: cached.title,
1231
+ 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." }
1232
+ }
1233
+ } else {
1234
+ if (a.series_id === undefined || a.series_id === null) return { result: "series_id_required_with_episode_ids" }
1235
+ const seriesId = Number(a.series_id)
1236
+ 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." }
1237
+ const own = new Set((await seriesEpisodes(seriesId)).map((e) => e.id))
1238
+ const foreign = a.episode_ids.filter((id) => !own.has(id))
1239
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1240
+ // Sonarr v4 refuses an override without quality and languages (ArgumentNullException, HTTP 500).
1241
+ if (!cached.quality_raw) return { result: "release_quality_unknown", message: "Sonarr's search did not report this release's quality; search again." }
1242
+ Object.assign(body, { seriesId, episodeIds: a.episode_ids.map(Number), shouldOverride: true,
1243
+ quality: cached.quality_raw, languages: Array.isArray(cached.languages_raw) ? cached.languages_raw : [] })
1244
+ if (a.download_client_id !== undefined && a.download_client_id !== null) body.downloadClientId = Number(a.download_client_id)
1245
+ }
1246
+ }
1247
+ const res = await arrFor(a.kind)("/release", { method: "POST", body })
1248
+ return { result: "grabbed", kind: a.kind, title: cached.title, request: body,
1249
+ response: { approved: res?.approved ?? null, rejected: res?.rejected ?? null, rejections: res?.rejections ?? [], download_id: res?.downloadId ?? null } }
1250
+ }
1251
+
1252
+ const BLOCKLIST_PAGE_SIZE = 100
1253
+ const BLOCKLIST_MAX_PAGES = 20
1254
+
1255
+ async function itemBlocklist(kind, serviceId) {
1256
+ const idKey = idKeyOf(kind)
1257
+ const filter = kind === "series" ? { seriesIds: serviceId } : { movieIds: serviceId }
1258
+ const rows = []
1259
+ for (let page = 1; page <= BLOCKLIST_MAX_PAGES; page++) {
1260
+ const q = await arrFor(kind)("/blocklist", { query: { ...filter, page, pageSize: BLOCKLIST_PAGE_SIZE } })
1261
+ const records = q?.records ?? []
1262
+ rows.push(...records)
1263
+ if (!records.length || !(Number(q?.totalRecords) > page * BLOCKLIST_PAGE_SIZE)) return rows.filter((r) => r[idKey] === serviceId)
1264
+ }
1265
+ const own = rows.filter((r) => r[idKey] === serviceId)
1266
+ own.truncated = true
1267
+ return own
1268
+ }
1269
+
1270
+ const blocklistReport = (rows) => rows.map((r) => ({ blocklist_id: r.id, title: r.sourceTitle ?? null, date: r.date ?? null, indexer: r.indexer ?? null,
1271
+ quality: r.quality?.quality?.name ?? null, episode_ids: r.episodeIds ?? [], message: r.message ?? null }))
1272
+
1273
+ export async function mediaBlocklist(a) {
1274
+ if (!kindOk(a)) return invalidKind()
1275
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1276
+ const serviceId = Number(a.service_id)
1277
+ const rows = await itemBlocklist(a.kind, serviceId)
1278
+ if (!Array.isArray(a.remove_ids) || !a.remove_ids.length) {
1279
+ return { kind: a.kind, service_id: serviceId, count: rows.length, ...(rows.truncated ? { truncated: true } : {}), entries: blocklistReport(rows) }
1280
+ }
1281
+ const own = new Set(rows.map((r) => r.id))
1282
+ const foreign = a.remove_ids.filter((id) => !own.has(id))
1283
+ if (foreign.length) return { result: "blocklist_id_not_for_item", foreign_blocklist_ids: foreign, entries: blocklistReport(rows) }
1284
+ await arrFor(a.kind)("/blocklist/bulk", { method: "DELETE", body: { ids: a.remove_ids.map(Number) } })
1285
+ const left = await itemBlocklist(a.kind, serviceId)
1286
+ return { result: "removed", removed: a.remove_ids, count: left.length, entries: blocklistReport(left) }
1287
+ }
1288
+
1289
+ async function previewRows(seriesId, downloadId) {
1290
+ const rows = await sonarr("/manualimport", { query: { downloadId, seriesId, filterExistingFiles: false } })
1291
+ return Array.isArray(rows) ? rows : []
1292
+ }
1293
+
1294
+ const previewReport = (rows) => rows.map((r) => ({ path: r.path, name: r.name ?? null, parsed_season: r.seasonNumber ?? null,
1295
+ mapped_episodes: (r.episodes ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
1296
+ quality: r.quality?.quality?.name ?? null, rejections: (r.rejections ?? []).map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) }))
1297
+
1298
+ export async function mediaManualImport(a) {
1299
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1300
+ if (!a.download_id) return { result: "download_id_required" }
1301
+ const seriesId = Number(a.service_id)
1302
+ const rows = await previewRows(seriesId, a.download_id)
1303
+ if (a.mode !== "import") return { mode: "preview", series_id: seriesId, download_id: a.download_id, files: previewReport(rows) }
1304
+ if (!Array.isArray(a.files) || !a.files.length || a.files.some((f) => !f || typeof f.path !== "string")) return { result: "files_required" }
1305
+ const paths = a.files.map((f) => f.path)
1306
+ if (new Set(paths).size !== paths.length) return { result: "duplicate_path" }
1307
+ const byPath = new Map(rows.map((r) => [r.path, r]))
1308
+ const unknown = a.files.filter((f) => !byPath.has(f.path)).map((f) => f.path)
1309
+ if (unknown.length) return { result: "path_not_in_preview", paths: unknown, files: previewReport(rows) }
1310
+ const seriesEps = await seriesEpisodes(seriesId)
1311
+ const own = new Set(seriesEps.map((e) => e.id))
1312
+ const foreign = a.files.flatMap((f) => (f.episode_ids ?? []).filter((id) => !own.has(id)))
1313
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1314
+ const empty = a.files.filter((f) => !Array.isArray(f.episode_ids) || !f.episode_ids.length).map((f) => f.path)
1315
+ if (empty.length) return { result: "episode_ids_required", paths: empty }
1316
+ const targets = a.files.flatMap((f) => f.episode_ids.map(Number))
1317
+ if (new Set(targets).size !== targets.length) return { result: "episode_id_on_two_files" }
1318
+ const files = a.files.map((f) => {
1319
+ const r = byPath.get(f.path)
1320
+ return { path: f.path, seriesId, episodeIds: f.episode_ids.map(Number), quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1321
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId: a.download_id }
1322
+ })
1323
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1324
+ // Episodes that already had a file: Sonarr treats these imports as replacements.
1325
+ const replacing = seriesEps.filter((e) => e.hasFile && targets.includes(e.id)).map((e) => ({ episode_id: e.id, code: sxe(e) }))
1326
+ 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 } }
1327
+ }
1328
+
1329
+ // ------------------------------------------------------- tool: fill missing
1330
+ //
1331
+ // The whole "get the missing episodes" job as one deterministic pass, so a long
1332
+ // request no longer depends on the model chaining a dozen calls correctly
1333
+ // (2026-10-03: The Chef Show took an owner hint, a wrong pack, an invented
1334
+ // parameter and the step cap). Each call re-reads Sonarr, so it is safe to call
1335
+ // again: in-flight grabs are never repeated, and once nothing is missing it says
1336
+ // stop. Release names follow streaming numbering, so a release is matched to
1337
+ // episodes only on evidence: an episode title, a season plus the air year, or a
1338
+ // Sonarr parse whose own numbering agrees with the episode's TVDB or scene code.
1339
+
1340
+ const FILL_MAX_GRABS = 3
1341
+ const FILL_DEADLINE_MS = 150_000
1342
+ const FILL_RECENT_GRAB_MS = 15 * 60_000
1343
+ const FILL_EVIDENCE_ROWS = 5
1344
+ // A torrent with no ETA is called stalled only after its remaining size has not moved for this long.
1345
+ const FILL_STALL_CONFIRM_MS = 30 * 60_000
1346
+
1347
+ // Years in a release name, ignoring resolutions such as 1920x1080 and 2160p.
1348
+ const yearsIn = (title) => new Set((String(title ?? "").match(/(?<![0-9x])(?:19|20)[0-9]{2}(?![0-9px])/gi) ?? []).map(Number))
1349
+ const airYear = (e) => (e.airDate ? Number(String(e.airDate).slice(0, 4)) : null)
1350
+ const hasWord = (haystack, needle) => needle.length > 0 && ` ${haystack} `.includes(` ${needle} `)
1351
+ const rejectionTexts = (r) => (Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : [])
1352
+ // "Doctor Who (2005)" and "The Office (US)" are named without the suffix in most releases.
1353
+ export const seriesBaseTitle = (title) => normalizeTitle(String(title ?? "").replace(/\s*\((?:19|20)[0-9]{2}\)\s*$/, "").replace(/\s*\((?:us|uk|au|nz|ca)\)\s*$/i, ""))
1354
+
1355
+ // An episode title is evidence only when it is distinctive enough not to appear by chance:
1356
+ // several words, or one long word that is not a generic release term.
1357
+ const GENERIC_TITLE_WORDS = new Set(["special", "specials", "episode", "finale", "premiere", "extended", "complete", "unknown", "untitled", "trailer", "preview", "bonus"])
1358
+ function episodeTitleIn(releaseNorm, episode) {
1359
+ const t = normalizeTitle(episode.title)
1360
+ const distinctive = t.includes(" ") ? t.length >= 6 : t.length >= 7 && !GENERIC_TITLE_WORDS.has(t) && !/^[0-9]+$/.test(t)
1361
+ return distinctive && hasWord(releaseNorm, t)
1362
+ }
1363
+
1364
+ // Season named in a release title: "Season 2", "Temporada 2", "S02" (not "S02E01").
1365
+ export function seasonInTitle(title) {
1366
+ const s = String(title ?? "")
1367
+ const word = s.match(/\b(?:season|temporada|saison|staffel)\s*0*([0-9]{1,2})\b/i)
1368
+ if (word) return Number(word[1])
1369
+ const code = s.match(/\bS0*([0-9]{1,2})(?![0-9]|\s*E[0-9])\b/i)
1370
+ return code ? Number(code[1]) : null
1371
+ }
1372
+
1373
+ // Sonarr's mapping is trusted only when the numbering in the name is the episode's own or scene code.
1374
+ function parseAgrees(release, episode) {
1375
+ const season = release.seasonNumber
1376
+ const numbers = Array.isArray(release.episodeNumbers) ? release.episodeNumbers : []
1377
+ const codes = [[episode.seasonNumber, episode.episodeNumber], [episode.sceneSeasonNumber, episode.sceneEpisodeNumber]]
1378
+ return codes.some(([s, e]) => Number.isInteger(s) && s === season && (numbers.length ? numbers.includes(e) : Boolean(release.fullSeason)))
1379
+ }
1380
+
1381
+ // Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
1382
+ // A blocklisted release is still rejected, but says which wanted episodes it would hold, so a
1383
+ // release blocklisted by mistake can be offered back to the owner.
1384
+ export function matchReleaseToEpisodes(release, ctx) {
1385
+ const rejections = rejectionTexts(release)
1386
+ if (!rejections.some((x) => /blocklist/i.test(x))) return matchUnblocked(release, ctx, rejections)
1387
+ const m = matchUnblocked(release, ctx, rejections.filter((x) => !/blocklist/i.test(x)))
1388
+ if (m.reject === "other_series") return m
1389
+ return { reject: "blocklisted", ...(m.episode_ids ? { would_match: { episode_ids: m.episode_ids, basis: m.basis } } : {}) }
1390
+ }
1391
+
1392
+ function matchUnblocked(release, { seriesId, seriesTitle, seriesYear, wanted, have }, rejections) {
1393
+ const title = release.title ?? ""
1394
+ const norm = normalizeTitle(title)
1395
+ const base = seriesBaseTitle(seriesTitle)
1396
+ if (!hasWord(norm, base)) return { reject: "other_series" }
1397
+ if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
1398
+ if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
1399
+ const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
1400
+ // The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
1401
+ // unless the missing episodes aired that year too.
1402
+ const years = new Set([...yearsIn(title)].filter((y) => y !== seriesYear || wantedYears.has(y)))
1403
+ if (years.size && ![...years].some((y) => wantedYears.has(y))) return { reject: "year_mismatch", years: [...years] }
1404
+ // Episode titles are looked for in the name with the series name taken out.
1405
+ const rest = ` ${norm} `.replace(` ${base} `, " ").trim()
1406
+ const byTitle = wanted.filter((e) => episodeTitleIn(rest, e))
1407
+ if (byTitle.length) return { episode_ids: byTitle.map((e) => e.id), basis: "episode_title" }
1408
+ if (have.some((e) => episodeTitleIn(rest, e))) return { reject: "holds_episodes_we_have" }
1409
+ const wantedById = new Map(wanted.map((e) => [e.id, e]))
1410
+ const mapped = (release.mappedEpisodeInfo ?? []).map((e) => e.id).filter((id) => id !== undefined && id !== null)
1411
+ const mappedWanted = mapped.filter((id) => wantedById.has(id))
1412
+ if (mappedWanted.length) {
1413
+ if (mappedWanted.every((id) => parseAgrees(release, wantedById.get(id)))) return { episode_ids: mappedWanted, basis: "sonarr_parse" }
1414
+ return { reject: "parse_disagrees_with_numbering" }
1415
+ }
1416
+ // A season pack named with the season and the air year of the missing episodes
1417
+ // ("The Chef Show [Season 2] (2020)"), whatever Sonarr's scene mapping says it holds.
1418
+ const season = seasonInTitle(title) ?? (release.fullSeason ? release.seasonNumber ?? null : null)
1419
+ if (season !== null && years.size && !/\bE[0-9]{1,3}\b/i.test(title)) {
1420
+ const covered = wanted.filter((e) => e.seasonNumber === season && years.has(airYear(e)))
1421
+ if (covered.length) return { episode_ids: covered.map((e) => e.id), basis: "season_and_air_year" }
1422
+ }
1423
+ return { reject: mapped.length ? "holds_episodes_we_have" : "no_episode_match" }
1424
+ }
1425
+
1426
+ // Why a download is not going to finish on its own, or null while it may still be moving.
1427
+ // `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
1428
+ // download is reported but never replaced.
1429
+ export function stallReason(row, nowMs, previous) {
1430
+ const left = row.sizeleft ?? 0
1431
+ const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
1432
+ const unchangedFor30 = Boolean(previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS)
1433
+ if (isStalledRow(row, nowMs)) {
1434
+ if (left >= (row.size ?? 0)) return { stall: "never_started", confirmed: true }
1435
+ // Old and unfinished is not dead by age alone: a slow torrent with a real ETA is still moving.
1436
+ return noEta || unchangedFor30 ? { stall: "unfinished_for_days", confirmed: true } : null
1437
+ }
1438
+ const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
1439
+ const moving = row.status === "downloading" || row.status === "warning"
1440
+ if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
1441
+ const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
1442
+ return { stall: "no_peers", confirmed: Boolean(unchanged) }
1443
+ }
1444
+
1445
+ // Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
1446
+ // Kept on disk: every Butler restart starts a new MCP process, and an observation lost with it
1447
+ // would leave a dead download unconfirmable (or make a fresh one look confirmed too early).
1448
+ const PROGRESS_FORGET_MS = 14 * 24 * 3_600_000
1449
+ // Observations further apart than this are not continuous: the download may have been paused
1450
+ // and resumed in between, so the older one stops counting.
1451
+ const PROGRESS_CONTINUITY_MS = 6 * 3_600_000
1452
+ const progressPath = () => process.env.SANCTUARY_MEDIA_STATE ?? "/home/ouro/AgentBundles/sanctuary.ouro/state/media/download-progress.json"
1453
+ let progressSeen = null
1454
+ function readProgressFile() {
1455
+ const out = new Map()
1456
+ try {
1457
+ for (const [k, v] of Object.entries(JSON.parse(readFileSync(progressPath(), "utf8")))) {
1458
+ if (typeof v?.sizeleft === "number" && typeof v?.at === "number") out.set(k, { sizeleft: v.sizeleft, at: v.at, seen: typeof v.seen === "number" ? v.seen : v.at })
1459
+ }
1460
+ } catch { /* first run, or unreadable: start empty */ }
1461
+ return out
1462
+ }
1463
+ function loadProgress() {
1464
+ if (!progressSeen) progressSeen = readProgressFile()
1465
+ return progressSeen
1466
+ }
1467
+ // Merge with what another process (an overlapping restart) may have saved, keeping the
1468
+ // later sighting per download, then replace the file atomically so a crash cannot truncate it.
1469
+ function saveProgress(nowMs) {
1470
+ try {
1471
+ for (const [k, v] of readProgressFile()) {
1472
+ const mine = progressSeen.get(k)
1473
+ if (nowMs - v.seen <= PROGRESS_FORGET_MS && (!mine || v.seen > mine.seen)) progressSeen.set(k, v)
1474
+ }
1475
+ mkdirSync(dirname(progressPath()), { recursive: true, mode: 0o700 })
1476
+ const tmp = `${progressPath()}.tmp-${process.pid}`
1477
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(progressSeen)), { mode: 0o600 })
1478
+ renameSync(tmp, progressPath())
1479
+ } catch (e) {
1480
+ // Best effort: confirmation then waits for a later observation in this process.
1481
+ process.stderr.write(`media-mcp: download progress not saved: ${e.message}\n`)
1482
+ }
1483
+ }
1484
+ // Read-only look at the memory: what observeProgress would return, without recording a sighting.
1485
+ function peekProgress(key, sizeleft, nowMs) {
1486
+ const previous = loadProgress().get(key)
1487
+ return previous && nowMs - previous.seen <= PROGRESS_CONTINUITY_MS ? previous : undefined
1488
+ }
1489
+ export const progressKey = (row, kind = "series") => `${kind}:${row.downloadId ?? `row:${row.id}`}`.toUpperCase()
1490
+ function observeProgress(downloadId, sizeleft, nowMs) {
1491
+ const seen = loadProgress()
1492
+ for (const [k, v] of seen) if (nowMs - v.seen > PROGRESS_FORGET_MS) seen.delete(k)
1493
+ let previous = seen.get(downloadId)
1494
+ if (previous && nowMs - previous.seen > PROGRESS_CONTINUITY_MS) previous = undefined
1495
+ if (!previous || previous.sizeleft !== sizeleft) seen.set(downloadId, { sizeleft, at: nowMs, seen: nowMs })
1496
+ else seen.set(downloadId, { ...previous, seen: nowMs })
1497
+ saveProgress(nowMs)
1498
+ return previous
1499
+ }
1500
+
1501
+ const importDone = (row) => (row.sizeleft ?? 0) === 0 || row.status === "completed"
1502
+ const importNeeded = (row) => importDone(row) && ["importPending", "importBlocked", "importFailed"].includes(row.trackedDownloadState)
1503
+
1504
+ async function seriesGrabHistory(seriesId) {
1505
+ const list = await sonarr("/history/series", { query: { seriesId, eventType: "grabbed" } })
1506
+ return Array.isArray(list) ? list : []
1507
+ }
1508
+
1509
+ async function runningImportFor(downloadId) {
1510
+ const list = await sonarr("/command")
1511
+ return (Array.isArray(list) ? list : []).some((c) => c.name === "ManualImport" && ["queued", "started"].includes(c.status)
1512
+ && (c.body?.files ?? []).some((f) => String(f.downloadId ?? "").toUpperCase() === String(downloadId).toUpperCase()))
1513
+ }
1514
+
1515
+ // Map a finished download's files to the wanted episodes by title. Only a file whose name
1516
+ // holds exactly one wanted episode title, and the only file holding it, is imported.
1517
+ async function importByTitle(seriesId, seriesTitle, downloadId, wantedEps) {
1518
+ if (await runningImportFor(downloadId)) return { action: "import_already_running", download_id: downloadId }
1519
+ const rows = await previewRows(seriesId, downloadId)
1520
+ const base = seriesBaseTitle(seriesTitle)
1521
+ const picks = []
1522
+ const unmatched = []
1523
+ for (const r of rows) {
1524
+ const rest = ` ${normalizeTitle(`${r.name ?? ""} ${r.relativePath ?? ""}`)} `.replace(` ${base} `, " ").trim()
1525
+ const ids = wantedEps.filter((e) => episodeTitleIn(rest, e)).map((e) => e.id)
1526
+ if (ids.length === 1) picks.push({ row: r, ids })
1527
+ else unmatched.push(r.name ?? r.path)
1528
+ }
1529
+ const counts = new Map()
1530
+ for (const p of picks) counts.set(p.ids[0], (counts.get(p.ids[0]) ?? 0) + 1)
1531
+ const unique = picks.filter((p) => counts.get(p.ids[0]) === 1)
1532
+ for (const p of picks) if (!unique.includes(p)) unmatched.push(p.row.name ?? p.row.path)
1533
+ if (!unique.length) {
1534
+ return { action: "import_needs_mapping", download_id: downloadId, unmatched_files: unmatched,
1535
+ note: "No file names an episode title. Map them with media_manual_import (preview, then import by title from media_episodes)." }
1536
+ }
1537
+ const files = unique.map(({ row: r, ids }) => ({ path: r.path, seriesId, episodeIds: ids, quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1538
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId }))
1539
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1540
+ return { action: "imported_by_title", download_id: downloadId, command_id: c?.id ?? null,
1541
+ files: unique.map(({ row: r, ids }) => ({ file: r.name ?? r.path, episode_ids: ids })), ...(unmatched.length ? { unmatched_files: unmatched } : {}) }
1542
+ }
1543
+
1544
+ const pct = (row) => ((row.size ?? 0) > 0 ? Math.round((((row.size ?? 0) - (row.sizeleft ?? 0)) / row.size) * 1000) / 10 : null)
1545
+ const fillRunning = new Set()
1546
+ // When each season or episode of a series was last searched, so repeat calls reach the ones a deadline cut off.
1547
+ const lastSearched = new Map()
1548
+ const FILL_ACTION_GRACE_MS = 45_000
1549
+
1550
+ export async function mediaFillMissing(a, opts = {}) {
1551
+ const found = await resolveLibraryItem({ kind: "series", service_id: a.service_id, title: a.series })
1552
+ if (!found.item) return { result: found.result, candidates: found.candidates, message: "Name the series by its Sonarr service_id or exact title." }
1553
+ const seriesId = found.item.id
1554
+ // One pass per series at a time: a retry after a timeout must not grab beside a pass still running.
1555
+ if (fillRunning.has(seriesId)) return { result: "already_running", series_id: seriesId, message: "A media_fill_missing pass for this series is still working. Wait a minute and call again; do not act by hand." }
1556
+ fillRunning.add(seriesId)
1557
+ try {
1558
+ return await fillPass(a, seriesId, found.item.title, opts)
1559
+ } finally {
1560
+ fillRunning.delete(seriesId)
1561
+ }
1562
+ }
1563
+
1564
+ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadlineMs = FILL_DEADLINE_MS } = {}) {
1565
+ const startedAt = Date.now()
1566
+ const remainingMs = () => deadlineMs - (Date.now() - startedAt)
1567
+ const timeLeft = () => remainingMs() > 0
1568
+ // Grabs and imports may run a little past the search deadline, never past the harness timeout.
1569
+ const mayAct = () => remainingMs() > -FILL_ACTION_GRACE_MS
1570
+ const errors = []
1571
+ // Each step after the first reads is isolated: one failure is reported, the rest of the pass and its record survive.
1572
+ const attempt = async (step, fn) => {
1573
+ try { return await fn() } catch (e) { errors.push({ step, error: e.code ?? "failed", message: e.message }); return undefined }
1574
+ }
1575
+ const seriesYear = Number((await attempt("read series", () => sonarr(`/series/${seriesId}`)))?.year) || null
1576
+ const season = a.season_number === undefined || a.season_number === null ? null : Number(a.season_number)
1577
+ const scope = (await seriesEpisodes(seriesId, season ?? undefined)).filter((e) => (season === null ? e.seasonNumber > 0 : true))
1578
+ const aired = (e) => Boolean(e.airDateUtc ?? e.airDate) && Date.parse(e.airDateUtc ?? e.airDate) <= nowMs
1579
+ const missing = scope.filter((e) => !e.hasFile && aired(e))
1580
+ const wanted = missing.filter((e) => e.monitored)
1581
+ const have = scope.filter((e) => e.hasFile)
1582
+ const code = (e) => ({ episode_id: e.id, code: sxe(e), title: e.title ?? null, air_date: e.airDate ?? null })
1583
+ const base = { series_id: seriesId, series: seriesTitle, season_number: season, checked_at: new Date(nowMs).toISOString(),
1584
+ on_shelf: { count: have.length, of: scope.filter(aired).length },
1585
+ ...(missing.length > wanted.length ? { not_monitored_missing: missing.filter((e) => !e.monitored).map(code) } : {}) }
1586
+ if (!wanted.length) {
1587
+ return { ...base, goal_met: true, in_flight: [], not_found: [], actions: [],
1588
+ summary: `Nothing monitored is missing: ${have.length} of ${base.on_shelf.of} aired episodes are on the shelf.`,
1589
+ next_step: "Goal met. Stop here and tell the owner; do not search or grab anything else." }
1590
+ }
1591
+
1592
+ const actions = []
1593
+ const byId = new Map(wanted.map((e) => [e.id, e]))
1594
+ const queue = await itemQueue("series", seriesId)
1595
+ const history = await seriesGrabHistory(seriesId)
1596
+ const grabbedFor = new Map()
1597
+ for (const h of history) {
1598
+ // A client without download ids (e.g. a blackhole) is tracked by release title instead.
1599
+ const key = h.downloadId ? String(h.downloadId).toUpperCase() : h.sourceTitle ? `TITLE:${normalizeTitle(h.sourceTitle)}` : null
1600
+ if (!key) continue
1601
+ if (!grabbedFor.has(key)) grabbedFor.set(key, { ids: new Set(), date: h.date, title: h.sourceTitle })
1602
+ grabbedFor.get(key).ids.add(h.episodeId)
1603
+ }
1604
+ // Sonarr re-parses a tracked download's name, so its queue rows can point at the
1605
+ // wrong episodes; the grab history keeps the mapping the grab was made with.
1606
+ const downloads = new Map()
1607
+ for (const r of queue) {
1608
+ const key = String(r.downloadId ?? `row:${r.id}`).toUpperCase()
1609
+ if (!downloads.has(key)) downloads.set(key, { key, row: r, ids: new Set(grabbedFor.get(key)?.ids ?? []) })
1610
+ if (!grabbedFor.has(key)) downloads.get(key).ids.add(r.episodeId)
1611
+ }
1612
+ const queuedTitles = new Set(queue.map((r) => normalizeTitle(r.title)))
1613
+ for (const [key, g] of grabbedFor) {
1614
+ const recent = nowMs - Date.parse(g.date) < FILL_RECENT_GRAB_MS
1615
+ if (recent && !downloads.has(key) && !queuedTitles.has(normalizeTitle(g.title))) downloads.set(key, { key, row: null, ids: g.ids, title: g.title })
1616
+ }
1617
+
1618
+ let cutShort = false
1619
+ const covered = new Set()
1620
+ const stalled = []
1621
+ const inFlight = []
1622
+ for (const d of downloads.values()) {
1623
+ const ids = wanted.map((e) => e.id).filter((id) => d.ids.has(id))
1624
+ if (!ids.length) continue
1625
+ const entry = { title: d.row?.title ?? d.title ?? null, download_id: d.row?.downloadId ?? d.key, episodes: ids.map((id) => sxe(byId.get(id))) }
1626
+ if (!d.row) { inFlight.push({ ...entry, state: "just_grabbed" }); ids.forEach((id) => covered.add(id)); continue }
1627
+ if (importNeeded(d.row)) {
1628
+ const done = mayAct() ? await attempt(`import ${d.row.downloadId}`, () => importByTitle(seriesId, seriesTitle, d.row.downloadId, ids.map((id) => byId.get(id)))) : undefined
1629
+ if (done) actions.push(done)
1630
+ inFlight.push({ ...entry, state: done?.action === "import_needs_mapping" ? "downloaded_needs_mapping" : done ? "importing" : mayAct() ? "downloaded_import_failed" : "downloaded_import_next_call" })
1631
+ if (!done) cutShort = true
1632
+ ids.forEach((id) => covered.add(id))
1633
+ continue
1634
+ }
1635
+ const why = stallReason(d.row, nowMs, observeProgress(progressKey(d.row), d.row.sizeleft ?? 0, nowMs))
1636
+ const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
1637
+ if (why) stalled.push({ ...info, ids, ...why })
1638
+ else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
1639
+ }
1640
+
1641
+ // Search for everything not covered by a moving download, stalled ones included.
1642
+ const need = wanted.filter((e) => !covered.has(e.id))
1643
+ const searched = []
1644
+ const searchedSeasons = new Set()
1645
+ const searchedEpisodes = new Set()
1646
+ const candidates = new Map()
1647
+ const rejected = new Map()
1648
+ const blockedMatches = new Map()
1649
+ const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
1650
+ const consider = (raw) => {
1651
+ for (const r of Array.isArray(raw) ? raw : []) {
1652
+ if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
1653
+ const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
1654
+ const sameAsInFlight = inFlightTitles.has(normalizeTitle(r.title))
1655
+ if (m.would_match && !sameAsInFlight) blockedMatches.set(r.guid, { raw: r, ids: m.would_match.episode_ids, basis: m.would_match.basis })
1656
+ if (m.reject || sameAsInFlight) { rejected.set(r.guid, { title: r.title ?? null, seeders: r.seeders ?? null, reason: sameAsInFlight ? "same_release_as_stalled_download" : m.reject }); continue }
1657
+ cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
1658
+ candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
1659
+ }
1660
+ }
1661
+ const searchTimeout = () => Math.max(5_000, Math.min(SEARCH_TIMEOUT_MS, remainingMs()))
1662
+ const hasSeededCandidate = (e) => [...candidates.values()].some((c) => (c.raw.seeders ?? 0) > 0 && c.ids.includes(e.id))
1663
+ const searchedAt = (key) => lastSearched.get(`${seriesId}:${key}`) ?? 0
1664
+ const markSearched = (key) => lastSearched.set(`${seriesId}:${key}`, Date.now())
1665
+ // Least recently searched first, so a deadline never starves the same seasons on every call.
1666
+ const seasons = [...new Set(need.map((e) => e.seasonNumber))].sort((x, y) => searchedAt(`s${x}`) - searchedAt(`s${y}`) || x - y)
1667
+ for (const s of seasons) {
1668
+ if (!timeLeft()) break
1669
+ searchedSeasons.add(s)
1670
+ markSearched(`s${s}`)
1671
+ searched.push(`Sonarr season search S${code2(s)}`)
1672
+ consider(await attempt(`season search S${code2(s)}`, () => sonarr("/release", { query: { seriesId, seasonNumber: s }, timeoutMs: searchTimeout() })))
1673
+ }
1674
+ const episodeOrder = need.filter((x) => !hasSeededCandidate(x)).sort((x, y) => searchedAt(`e${x.id}`) - searchedAt(`e${y.id}`))
1675
+ for (const e of episodeOrder.slice(0, MAX_EPISODE_SEARCHES)) {
1676
+ if (!timeLeft()) break
1677
+ searchedEpisodes.add(e.id)
1678
+ markSearched(`e${e.id}`)
1679
+ searched.push(`Sonarr episode search ${sxe(e)}`)
1680
+ consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
1681
+ }
1682
+
1683
+ // Episodes whose only matching releases are on the blocklist. With the owner's words such a
1684
+ // release joins the candidates and its blocklist entry is lifted only once it is actually being
1685
+ // grabbed (an unseeded one then waits for peers and is kept); without, it is reported.
1686
+ const covers = (id) => [...candidates.values()].some((c) => c.ids.includes(id))
1687
+ const blockedOnly = [...blockedMatches.values()].filter((b) => b.ids.some((id) => !covers(id)))
1688
+ const ownerRetry = typeof a.retry_blocklisted === "string" ? a.retry_blocklisted.trim() : ""
1689
+ const blockedLeft = new Map()
1690
+ if (blockedOnly.length) {
1691
+ const entries = (await attempt("read blocklist", () => itemBlocklist("series", seriesId))) ?? []
1692
+ for (const b of blockedOnly) {
1693
+ const ids = entries.filter((x) => normalizeTitle(x.sourceTitle) === normalizeTitle(b.raw.title)).map((x) => x.id)
1694
+ blockedLeft.set(b.raw.guid, { b, blocklist_ids: ids })
1695
+ if (ownerRetry && ids.length) {
1696
+ cacheRelease(`series:${b.raw.guid}`, { ...releaseRow("series", b.raw), service_id: seriesId, season_number: null, quality_raw: b.raw.quality ?? null, languages_raw: b.raw.languages ?? null })
1697
+ candidates.set(b.raw.guid, { ...b, blocklistIds: ids })
1698
+ }
1699
+ }
1700
+ }
1701
+ const lifted = new Set()
1702
+ const liftFor = async (c) => {
1703
+ const ids = c.blocklistIds.filter((id) => !lifted.has(id))
1704
+ if (ids.length && !(await attempt("remove from blocklist", async () => { await sonarr("/blocklist/bulk", { method: "DELETE", body: { ids } }); return true }))) return false
1705
+ ids.forEach((id) => lifted.add(id))
1706
+ if (ids.length) actions.push({ action: "removed_from_blocklist", title: c.raw.title ?? null, owner_words: ownerRetry })
1707
+ return true
1708
+ }
1709
+
1710
+ // Seeded releases first, then the ones that cover the most, then the best seeded.
1711
+ const ranked = [...candidates.values()].sort((x, y) =>
1712
+ Number((y.raw.seeders ?? 0) > 0) - Number((x.raw.seeders ?? 0) > 0) || y.ids.length - x.ids.length || (y.raw.seeders ?? 0) - (x.raw.seeders ?? 0))
1713
+ const remaining = new Set(need.map((e) => e.id))
1714
+ const stalledIds = new Set(stalled.flatMap((s) => s.ids))
1715
+ // Until a stall is confirmed on a later call, its episodes are not fetched a second time.
1716
+ const unconfirmedIds = new Set(stalled.filter((s) => !s.confirmed).flatMap((s) => s.ids))
1717
+ for (const s of stalled.filter((x) => !x.confirmed)) {
1718
+ s.alternative = ranked.some((c) => (c.raw.seeders ?? 0) > 0 && s.ids.every((id) => c.ids.includes(id)))
1719
+ }
1720
+ const deferred = new Set()
1721
+ let grabs = 0
1722
+ for (const c of ranked) {
1723
+ const seeded = (c.raw.seeders ?? 0) > 0
1724
+ // An unseeded release is no better than a stalled download: never fetch those episodes twice that way.
1725
+ const ids = c.ids.filter((id) => remaining.has(id) && !unconfirmedIds.has(id) && (seeded || !stalledIds.has(id)))
1726
+ if (!ids.length || !c.raw.quality) continue
1727
+ if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
1728
+ const body = { guid: c.raw.guid, indexerId: c.raw.indexerId, seriesId, episodeIds: ids, shouldOverride: true, quality: c.raw.quality, languages: Array.isArray(c.raw.languages) ? c.raw.languages : [] }
1729
+ const post = async () => (await sonarr("/release", { method: "POST", body })) ?? {}
1730
+ let res
1731
+ if (c.blocklistIds) {
1732
+ // Grab first; lift the entry after it succeeds, or before one retry if Sonarr refused it as blocklisted.
1733
+ try { res = await post() } catch { res = undefined }
1734
+ const liftedOk = await liftFor(c)
1735
+ if (!res && liftedOk) res = await attempt(`grab ${c.raw.title}`, post)
1736
+ if (!res) continue
1737
+ blockedLeft.delete(c.raw.guid)
1738
+ for (const [g, x] of blockedLeft) if (normalizeTitle(x.b.raw.title) === normalizeTitle(c.raw.title)) blockedLeft.delete(g)
1739
+ } else {
1740
+ res = await attempt(`grab ${c.raw.title}`, post)
1741
+ }
1742
+ if (!res) continue
1743
+ grabs += 1
1744
+ ids.forEach((id) => remaining.delete(id))
1745
+ actions.push({ action: "grabbed", title: c.raw.title ?? null, indexer: c.raw.indexer ?? null, seeders: c.raw.seeders ?? null, matched_by: c.basis,
1746
+ episodes: ids.map((id) => sxe(byId.get(id))), download_id: res.downloadId ?? null, ...(seeded ? {} : { may_never_finish: true }) })
1747
+ inFlight.push({ title: c.raw.title ?? null, download_id: res.downloadId ?? null, episodes: ids.map((id) => sxe(byId.get(id))), state: "just_grabbed" })
1748
+ // Only after the replacement is grabbed, and only for a confirmed stall it fully covers with a seeded release.
1749
+ if (!seeded) continue
1750
+ for (const s of stalled.filter((x) => !x.replaced && x.confirmed && x.ids.every((id) => ids.includes(id)))) {
1751
+ const removed = await attempt(`blocklist ${s.title}`, async () => { await sonarr(`/queue/${s.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } }); return true })
1752
+ if (!removed) continue
1753
+ s.replaced = true
1754
+ actions.push({ action: "blocklisted_stalled", title: s.title, percent: s.percent, stall: s.stall })
1755
+ }
1756
+ }
1757
+ for (const s of stalled.filter((x) => !x.replaced)) {
1758
+ const alsoGrabbed = s.ids.every((id) => !remaining.has(id))
1759
+ inFlight.push({ title: s.title, download_id: s.download_id, episodes: s.episodes, percent: s.percent, queue_id: s.queue_id, added: s.added, state: "stalled", stall: s.stall,
1760
+ ...(s.confirmed ? {} : { stall_confirmed: false }),
1761
+ note: !s.confirmed && s.alternative ? "A seeded alternative exists; a later call replaces this download once the stall is confirmed (no progress for 30 minutes)."
1762
+ : alsoGrabbed ? "Releases for some of these episodes were grabbed; this download was kept for the rest."
1763
+ : "No other usable release was found, so this download was kept in case peers return." })
1764
+ s.ids.forEach((id) => remaining.delete(id))
1765
+ }
1766
+
1767
+ const open = need.filter((e) => remaining.has(e.id) && !deferred.has(e.id))
1768
+ const unsearched = open.filter((e) => !searchedSeasons.has(e.seasonNumber) && !searchedEpisodes.has(e.id))
1769
+ const notFound = open.filter((e) => !unsearched.includes(e)).map(code)
1770
+ const stuck = inFlight.filter((x) => x.state === "stalled")
1771
+ let evidence
1772
+ if ((notFound.length || stuck.length) && timeLeft()) {
1773
+ // What exists on the indexers under any name, so a "can't find it" is backed by a listing. Best effort.
1774
+ evidence = []
1775
+ const target = notFound.length ? need.filter((e) => notFound.some((n) => n.episode_id === e.id)) : wanted
1776
+ const ys = [...new Set(target.map((e) => e.airDate?.slice(0, 4)).filter(Boolean))].slice(0, 2)
1777
+ const scene = [...new Set(target.map((e) => e.sceneSeasonNumber).filter((n) => Number.isInteger(n)))].slice(0, 1)
1778
+ const queries = [...(ys.length ? ys : [""]).map((y) => `${seriesTitle} ${y}`.trim()), ...scene.map((n) => `${seriesTitle} S${code2(n)}`)]
1779
+ for (const q of queries) {
1780
+ if (!timeLeft()) break
1781
+ const raw = await attempt(`indexer evidence "${q}"`, () => prowlarr("/search", { query: { query: q, type: "search", limit: 100 }, timeoutMs: searchTimeout() }))
1782
+ if (!Array.isArray(raw)) continue
1783
+ // Indexers answer free text loosely ("Vegas Chef Prizefight"); keep only releases named for this series.
1784
+ const own = raw.filter((r) => hasWord(normalizeTitle(r.title), seriesBaseTitle(seriesTitle)))
1785
+ const rows = own.sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1)).slice(0, FILL_EVIDENCE_ROWS)
1786
+ evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
1787
+ }
1788
+ }
1789
+ const blockedReport = []
1790
+ for (const { b, blocklist_ids } of blockedLeft.values()) {
1791
+ if (blockedReport.some((x) => normalizeTitle(x.title) === normalizeTitle(b.raw.title))) continue
1792
+ blockedReport.push({ title: b.raw.title ?? null, seeders: b.raw.seeders ?? null, episodes: b.ids.map((id) => sxe(byId.get(id))), blocklist_ids })
1793
+ }
1794
+ const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
1795
+ const moving = inFlight.filter((x) => x.state !== "stalled").length
1796
+ const unseeded = actions.filter((x) => x.action === "grabbed" && x.may_never_finish)
1797
+ const summary = [
1798
+ `${have.length} of ${base.on_shelf.of} aired episodes are on the shelf; ${wanted.length} monitored ${wanted.length === 1 ? "episode is" : "episodes are"} missing.`,
1799
+ grabs ? `Grabbed ${grabs} release${grabs === 1 ? "" : "s"} now.` : "",
1800
+ unseeded.length ? `${unseeded.map((x) => x.title).join("; ")} ${unseeded.length === 1 ? "has no seeders: it waits" : "have no seeders: they wait"} for peers and may never finish.` : "",
1801
+ moving ? `${moving} download(s) in progress.` : "",
1802
+ stuck.length ? `${stuck.length} download(s) stalled with no peers and no other usable release (${stuck.map((s) => `${s.title} at ${s.percent}%`).join("; ")}).` : "",
1803
+ notFound.length ? `No usable release found for ${notFound.map((e) => e.code).join(", ")}.` : "",
1804
+ blockedReport.length ? `The only matching release${blockedReport.length === 1 ? " is" : "s are"} on the blocklist: ${blockedReport.map((x) => `${x.title} (${x.seeders ?? 0} seeders)`).join("; ")}.` : "",
1805
+ deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
1806
+ errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
1807
+ ].filter(Boolean).join(" ")
1808
+ const nextStep = incomplete
1809
+ ? "This pass did not finish everything. Tell the owner what it did, then call media_fill_missing again to continue; do not act by hand."
1810
+ : stuck.length || notFound.length
1811
+ ? "Tell the owner exactly this outcome with the evidence; do not grab, import or blocklist anything by hand. Call media_fill_missing again later to retry."
1812
+ : unseeded.length
1813
+ ? "Tell the owner what was grabbed and that it has no seeders, so it may never finish. File a follow-up with await_condition to call media_fill_missing again later. Do not search or grab anything else now."
1814
+ : "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
1815
+ const blockedHint = !blockedReport.length ? ""
1816
+ : ownerRetry ? " The owner's retry could not take the blocklisted release back (see errors and blocklisted_matches); tell the owner that plainly."
1817
+ : " The only matching release is on the blocklist: tell the owner, and if the owner then asks to get it anyway, call media_fill_missing again with retry_blocklisted set to the owner's own words. Do not remove blocklist entries or grab by hand."
1818
+ return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
1819
+ ...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
1820
+ searched, ...(blockedReport.length ? { blocklisted_matches: blockedReport } : {}), ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
1821
+ ...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep + blockedHint }
1822
+ }
1823
+
1824
+ // ------------------------------------------------- tool: quality profiles
1825
+
1826
+ // Radarr and Sonarr keep the upgrade rules (whether a better release may replace a file, and the quality at which they
1827
+ // stop) in a quality profile. The Butler once told the owner this was "config-screen-only"; it is an ordinary API, so it
1828
+ // is a typed tool: read profiles, set cutoff and upgradeAllowed, and put a movie or series on a profile. Writes are a
1829
+ // dry run unless dry_run is false, and return before, after and applied_at like media_diagnose_and_fix.
1830
+
1831
+ const itemQualityId = (i) => i.quality?.id ?? i.id
1832
+ const itemQualityName = (i) => i.quality?.name ?? i.name
1833
+
1834
+ function profileView(p) {
1835
+ const items = p.items ?? []
1836
+ const cutoffItem = items.find((i) => itemQualityId(i) === p.cutoff)
1837
+ return { id: p.id, name: p.name, upgrade_allowed: Boolean(p.upgradeAllowed), cutoff: p.cutoff ?? null,
1838
+ cutoff_name: cutoffItem ? itemQualityName(cutoffItem) : null,
1839
+ qualities: items.map((i) => ({ id: itemQualityId(i), name: itemQualityName(i), allowed: Boolean(i.allowed), ...(i.items?.length ? { group: true } : {}) })) }
1840
+ }
1841
+
1842
+ const profileNotFound = (id) => ({ result: "no_such_profile", profile_id: id, message: "No quality profile has that id; call media_quality_profile with action read to list them." })
1843
+ const isHttp404 = (e) => /_http_404$/.test(e?.code ?? "")
1844
+
1845
+ async function getProfile(kind, id) {
1846
+ try { return await arrFor(kind)(`/qualityprofile/${id}`) } catch (e) { if (isHttp404(e)) return null; throw e }
1847
+ }
1848
+
1849
+ async function getItem(kind, serviceId) {
1850
+ try { return await arrFor(kind)(`/${kind === "series" ? "series" : "movie"}/${serviceId}`) } catch (e) { if (isHttp404(e)) return null; throw e }
1851
+ }
1852
+
1853
+ // One line per applied change: what changed, the owner's words that asked for it, who called, and when.
1854
+ const qualityAuditPath = () => process.env.SANCTUARY_MEDIA_AUDIT ?? "/home/ouro/AgentBundles/sanctuary.ouro/state/media/quality-profile-audit.ndjson"
1855
+ function auditQualityChange(entry) {
1856
+ try {
1857
+ mkdirSync(dirname(qualityAuditPath()), { recursive: true })
1858
+ appendFileSync(qualityAuditPath(), `${JSON.stringify(entry)}\n`)
1859
+ return null
1860
+ } catch (e) { return `${e.code ?? e.message}` }
1861
+ }
1862
+ const titlesUsing = async (kind, profileId) => (await arrFor(kind)(kind === "series" ? "/series" : "/movie")).filter((t) => t.qualityProfileId === profileId).length
1863
+ const OWNER_WORDS_NOTE = "owner_words is logged as the caller's claim in the audit file; the tool cannot verify the owner said it."
1864
+ const OWNER_WORDS_NOTE_AUDIT = "claimed by the caller; not verified"
1865
+ const OWNER_WORDS_REQUIRED = { result: "owner_words_required", message: "Pass owner_words with the owner's own words asking for this change (a verbatim quote), or leave dry_run true. Nothing was changed." }
1866
+
1867
+ const itemView = (item) => ({ service_id: item.id, title: item.title ?? null, quality_profile_id: item.qualityProfileId ?? null })
1868
+ const UPGRADE_NEXT_STEP = "The profile now allows the upgrade. Call media_search_now (or media_release_search) for the item so Radarr/Sonarr looks for a better release; do not ask the owner to open a settings page."
1869
+
1870
+ export async function mediaQualityProfile(a) {
1871
+ if (!kindOk(a)) return invalidKind()
1872
+ const action = a.action ?? "read"
1873
+ const dryRun = a.dry_run !== false
1874
+ const hasProfileId = a.profile_id !== undefined && a.profile_id !== null
1875
+ const hasServiceId = a.service_id !== undefined && a.service_id !== null
1876
+ const client = arrFor(a.kind)
1877
+ const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
1878
+ if (!dryRun && action !== "read" && !ownerWords) return OWNER_WORDS_REQUIRED
1879
+ const audit = (entry) => {
1880
+ const failed = auditQualityChange({ at: new Date().toISOString(), tool: "media_quality_profile", action, kind: a.kind, owner_words: ownerWords, owner_words_note: OWNER_WORDS_NOTE_AUDIT, caller: typeof a.caller === "string" ? a.caller : "unknown", ...entry })
1881
+ return failed ? { audit_error: failed, owner_words_note: OWNER_WORDS_NOTE } : { owner_words_note: OWNER_WORDS_NOTE }
1882
+ }
1883
+
1884
+ if (action === "read") {
1885
+ let profiles
1886
+ if (hasProfileId) {
1887
+ const one = await getProfile(a.kind, Number(a.profile_id))
1888
+ if (!one) return profileNotFound(Number(a.profile_id))
1889
+ profiles = [one]
1890
+ } else {
1891
+ profiles = await client("/qualityprofile")
1892
+ }
1893
+ const out = { kind: a.kind, result: "profiles", profiles: profiles.map(profileView) }
1894
+ if (hasServiceId) {
1895
+ const item = await getItem(a.kind, Number(a.service_id))
1896
+ if (!item) return { result: "no_such_item", kind: a.kind, service_id: Number(a.service_id) }
1897
+ out.item = itemView(item)
1898
+ }
1899
+ return out
1900
+ }
1901
+
1902
+ if (action === "set_upgrade") {
1903
+ if (!hasProfileId) return { result: "profile_id_required" }
1904
+ const wantsCutoff = a.cutoff !== undefined || a.cutoff_name !== undefined
1905
+ if (!wantsCutoff && a.upgrade_allowed === undefined) {
1906
+ return { result: "no_change_requested", message: "Pass upgrade_allowed and/or cutoff (a quality id) or cutoff_name." }
1907
+ }
1908
+ const current = await getProfile(a.kind, Number(a.profile_id))
1909
+ if (!current) return profileNotFound(Number(a.profile_id))
1910
+ const next = { ...current }
1911
+ if (wantsCutoff) {
1912
+ const wanted = (current.items ?? []).find((i) => (a.cutoff !== undefined ? itemQualityId(i) === Number(a.cutoff) : String(itemQualityName(i)).toLowerCase() === String(a.cutoff_name).toLowerCase()))
1913
+ if (!wanted) return { result: "cutoff_not_in_profile", profile_id: current.id, qualities: profileView(current).qualities }
1914
+ next.cutoff = itemQualityId(wanted)
1915
+ }
1916
+ if (a.upgrade_allowed !== undefined) next.upgradeAllowed = Boolean(a.upgrade_allowed)
1917
+ const before = profileView(current)
1918
+ const after = profileView(next)
1919
+ if (before.cutoff === after.cutoff && before.upgrade_allowed === after.upgrade_allowed) return { action, dry_run: dryRun, result: "unchanged", before, after: before }
1920
+ if (dryRun) {
1921
+ const shared = await titlesUsing(a.kind, current.id)
1922
+ return { action, dry_run: true, result: "would_apply", before, after, titles_using_profile: shared,
1923
+ warning: `This profile is used by ${shared} ${a.kind === "series" ? "series" : "movie"} title(s) and the change applies to all of them. To change one title only, use action assign with that title's service_id and another profile.`,
1924
+ would_do: `Set ${a.kind === "series" ? "Sonarr" : "Radarr"} profile "${current.name}" to upgrade_allowed=${after.upgrade_allowed} with cutoff ${after.cutoff_name ?? after.cutoff}. Call again with dry_run false to apply.` }
1925
+ }
1926
+ const saved = await client(`/qualityprofile/${current.id}`, { method: "PUT", body: next })
1927
+ const appliedAfter = profileView(saved ?? next)
1928
+ return { action, dry_run: false, applied_at: new Date().toISOString(), result: "applied", before, after: appliedAfter, ...audit({ profile_id: current.id, before, after: appliedAfter }), next_step: UPGRADE_NEXT_STEP }
1929
+ }
1930
+
1931
+ if (action === "assign") {
1932
+ if (!hasServiceId) return { result: "service_id_required" }
1933
+ if (!hasProfileId) return { result: "profile_id_required" }
1934
+ const item = await getItem(a.kind, Number(a.service_id))
1935
+ if (!item) return { result: "no_such_item", kind: a.kind, service_id: Number(a.service_id) }
1936
+ const profile = await getProfile(a.kind, Number(a.profile_id))
1937
+ if (!profile) return profileNotFound(Number(a.profile_id))
1938
+ const before = itemView(item)
1939
+ const after = itemView({ ...item, qualityProfileId: profile.id })
1940
+ if (before.quality_profile_id === profile.id) return { action, dry_run: dryRun, result: "unchanged", before, after: before }
1941
+ if (dryRun) {
1942
+ return { action, dry_run: true, result: "would_apply", before, after, profile: profileView(profile),
1943
+ would_do: `Put "${item.title ?? item.id}" on the "${profile.name}" profile. Call again with dry_run false to apply.` }
1944
+ }
1945
+ const saved = await client(`/${a.kind === "series" ? "series" : "movie"}/${item.id}`, { method: "PUT", body: { ...item, qualityProfileId: profile.id } })
1946
+ const assignedAfter = itemView(saved ?? { ...item, qualityProfileId: profile.id })
1947
+ return { action, dry_run: false, applied_at: new Date().toISOString(), result: "applied", before, after: assignedAfter,
1948
+ ...audit({ service_id: item.id, profile_id: profile.id, before, after: assignedAfter }), profile: profileView(profile), next_step: UPGRADE_NEXT_STEP }
1949
+ }
1950
+
1951
+ return { result: "invalid_action", message: "action must be read, set_upgrade or assign." }
1952
+ }
1953
+
1954
+ // ------------------------------------------------------------------- schema
1955
+
1956
+ // The harness waits 30 s for a tool call unless the tool declares longer; indexer searches can take minutes.
1957
+ const TIMEOUT_META_KEY = "ouro.bot/timeoutMs"
1958
+
1959
+ const TOOL_LIST = [
1960
+ {
1961
+ name: "media_search",
1962
+ annotations: { readOnlyHint: true },
1963
+ 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.",
1964
+ inputSchema: { type: "object", properties: {
1965
+ query: { type: "string", description: "Title or free text. Omit to browse by filters." },
1966
+ kind: { type: "string", enum: ["movie", "series", "any"], description: "Default any." },
1967
+ year_range: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "[minYear, maxYear]" },
1968
+ genres: { type: "array", items: { type: "string" }, description: "TMDB genre names, e.g. ['Romance','Drama']." },
1969
+ keywords: { type: "array", items: { type: "string" }, description: "TMDB keywords, e.g. ['autumn','small town']. Best lever for mood/vibe requests." },
1970
+ on_shelf: { type: "boolean", description: "true = only what we already have; false = only what we do not; omit = both." },
1971
+ limit: { type: "number", description: "1-20, default 12." },
1972
+ sort: { type: "string", enum: ["relevance", "release_year"] },
1973
+ } },
1974
+ },
1975
+ {
1976
+ name: "media_request",
1977
+ 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.",
1978
+ inputSchema: { type: "object", properties: {
1979
+ tmdb_id: { type: "number" },
1980
+ title: { type: "string" },
1981
+ year: { type: "number" },
1982
+ kind: { type: "string", enum: ["movie", "series"] },
1983
+ season_number: { type: "number", description: "Series only. Omit to request all seasons." },
1984
+ } },
1985
+ },
1986
+ {
1987
+ name: "media_request_status",
1988
+ annotations: { readOnlyHint: true },
1989
+ 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.",
1990
+ inputSchema: { type: "object", properties: {
1991
+ request_id: { type: "string", description: "e.g. jellyseerr:42" },
1992
+ tmdb_id: { type: "number" },
1993
+ title: { type: "string" },
1994
+ } },
1995
+ },
1996
+ {
1997
+ name: "media_diagnose_and_fix",
1998
+ 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.",
1999
+ inputSchema: { type: "object", properties: {
2000
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
2001
+ action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
2002
+ dry_run: { type: "boolean" },
2003
+ } },
2004
+ },
2005
+ {
2006
+ name: "media_chain_health",
2007
+ annotations: { readOnlyHint: true },
2008
+ 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.",
2009
+ inputSchema: { type: "object", properties: {} },
2010
+ },
2011
+ {
2012
+ name: "media_play_or_resolve",
2013
+ 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.",
2014
+ inputSchema: { type: "object", properties: {
2015
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
2016
+ } },
2017
+ },
2018
+ {
2019
+ name: "media_queue",
2020
+ description: "Read-only view of the Sonarr/Radarr download queue, torrent and usenet alike: for each row the title, status, trackedDownloadState, percent, timeleft, size left, download client and whether it is stalled. Use this to see whether a series or movie download is progressing (this is the tool for torrent downloads; sanctuary_get_download_queue only sees the usenet/SABnzbd queue). Give service_id, tmdb_id or title for one item, or omit them for the whole queue of that kind. It grabs, removes and blocklists nothing. Seeders and peers are not reported by Sonarr/Radarr; use media_indexer_search for a release's seeders.",
2021
+ inputSchema: { type: "object", properties: {
2022
+ kind: { type: "string", enum: ["series", "movie"] },
2023
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
2024
+ tmdb_id: { type: "number" },
2025
+ title: { type: "string", description: "Exact or near-exact library title." },
2026
+ }, required: ["kind"] },
2027
+ },
2028
+ {
2029
+ name: "media_search_now",
2030
+ 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.",
2031
+ inputSchema: { type: "object", properties: {
2032
+ kind: { type: "string", enum: ["series", "movie"] },
2033
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
2034
+ title: { type: "string" },
2035
+ tmdb_id: { type: "number" },
2036
+ }, required: ["kind"] },
2037
+ },
2038
+ {
2039
+ name: "media_blocklist_stalled",
2040
+ 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. A download still moving, or stalled but not yet confirmed dead, is kept (result kept_downloads) because removing it deletes its progress: a partial download counts as dead only after two calls at least 30 minutes apart see no ETA and no progress. Pass owner_words, with queue_ids, only when the owner's own message asked to remove, clear or cancel those downloads. For missing episodes use media_fill_missing instead. Rows of one season pack are removed once; a delete that fails is reported in `failed` and the re-search still runs.",
2041
+ inputSchema: { type: "object", properties: {
2042
+ kind: { type: "string", enum: ["series", "movie"] },
2043
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
2044
+ queue_ids: { type: "array", items: { type: "number" } },
2045
+ research: { type: "boolean", description: "Search again afterwards. Default true." },
2046
+ owner_words: { type: "string", description: "The owner's exact words asking to remove, clear or cancel these downloads; requires queue_ids. Not for 'get them'." },
2047
+ }, required: ["kind", "service_id"] },
2048
+ },
2049
+ {
2050
+ name: "media_fill_missing",
2051
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
2052
+ description: "Get a series' missing episodes, start to finish, in one call. Use this first whenever the owner asks why episodes are missing or asks to get them. It re-reads Sonarr, finds the monitored aired episodes without files, searches the indexers, matches releases to episodes by episode title and air year as well as Sonarr's parse (so streaming-volume numbering cannot fool it), prefers seeded releases, skips packs of episodes already on the shelf, grabs with the right episode mapping, imports finished downloads by title, and replaces a stalled download when a usable alternative exists. Returns on_shelf, in_flight, not_found, actions, indexer_evidence, summary and next_step. Safe to call again: it never grabs what is already in flight. When goal_met is true, stop. Report summary and next_step as they are; do not redo its steps by hand.",
2053
+ inputSchema: { type: "object", properties: {
2054
+ series: { type: "string", description: "Series title as the owner said it." },
2055
+ service_id: { type: "number", description: "Sonarr series id, when known." },
2056
+ season_number: { type: "number", description: "Limit to one season. Omit for the whole series (specials excluded)." },
2057
+ retry_blocklisted: { type: "string", description: "The owner's own words asking to get episodes whose only matching release is on the blocklist (reported in blocklisted_matches). Takes those releases off the blocklist and grabs them." },
2058
+ } },
2059
+ },
2060
+ {
2061
+ name: "media_episodes",
2062
+ annotations: { readOnlyHint: true },
2063
+ 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.",
2064
+ inputSchema: { type: "object", properties: {
2065
+ service_id: { type: "number", description: "Sonarr series id." },
2066
+ season_number: { type: "number" },
2067
+ }, required: ["service_id"] },
2068
+ },
2069
+ {
2070
+ name: "media_indexer_search",
2071
+ annotations: { readOnlyHint: true },
2072
+ _meta: { [TIMEOUT_META_KEY]: 200_000 },
2073
+ 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.",
2074
+ inputSchema: { type: "object", properties: {
2075
+ query: { type: "string" },
2076
+ limit: { type: "number", description: "1-50, default 25." },
2077
+ }, required: ["query"] },
2078
+ },
2079
+ {
2080
+ name: "media_release_search",
2081
+ annotations: { readOnlyHint: true },
2082
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
2083
+ 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.",
2084
+ inputSchema: { type: "object", properties: {
2085
+ kind: { type: "string", enum: ["series", "movie"] },
2086
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
2087
+ season_number: { type: "number" },
2088
+ episode_ids: { type: "array", items: { type: "number" } },
2089
+ query: { type: "string", description: "Case-insensitive text the release title must contain." },
2090
+ limit: { type: "number", description: "1-100, default 40." },
2091
+ }, required: ["kind", "service_id"] },
2092
+ },
2093
+ {
2094
+ name: "media_release_grab",
2095
+ 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.",
2096
+ inputSchema: { type: "object", properties: {
2097
+ kind: { type: "string", enum: ["series", "movie"] },
2098
+ guid: { type: "string" },
2099
+ indexer_id: { type: "number" },
2100
+ series_id: { type: "number", description: "Required with episode_ids." },
2101
+ episode_ids: { type: "array", items: { type: "number" }, description: "Episodes this release really holds." },
2102
+ season_number: { type: "number", description: "Target season, checked against a season pack's parsed season when episode_ids is omitted." },
2103
+ download_client_id: { type: "number" },
2104
+ }, required: ["kind", "guid", "indexer_id"] },
2105
+ },
2106
+ {
2107
+ name: "media_blocklist",
2108
+ 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.",
2109
+ inputSchema: { type: "object", properties: {
2110
+ kind: { type: "string", enum: ["series", "movie"] },
2111
+ service_id: { type: "number" },
2112
+ remove_ids: { type: "array", items: { type: "number" } },
2113
+ }, required: ["kind", "service_id"] },
2114
+ },
2115
+ {
2116
+ name: "media_quality_profile",
2117
+ description: "Read and change the Radarr/Sonarr quality profiles that decide whether a better release may replace a file ('Quality Profile does not allow upgrades'). This is a normal API, never a settings screen the owner has to click through. action read (default) lists the profiles for the kind with their cutoff, upgrade flag and ordered qualities, or one profile with profile_id; add service_id to also see which profile a movie or series is on. action set_upgrade changes upgrade_allowed and/or the cutoff (a quality id from the profile, or cutoff_name) of profile_id. action assign puts the movie or series service_id on profile_id. Both writes are a dry run that shows before and after and changes nothing until dry_run is false; a change to a shared profile affects every title on it, so prefer assign to move one title when the owner asked about one title. Applying needs owner_words, the owner's exact words asking for the change, and only the owner can apply; every applied change is written to an audit log. After an applied change, call media_search_now so the upgrade is searched for.",
2118
+ inputSchema: { type: "object", properties: {
2119
+ kind: { type: "string", enum: ["movie", "series"], description: "movie = Radarr, series = Sonarr." },
2120
+ action: { type: "string", enum: ["read", "set_upgrade", "assign"], description: "Default read." },
2121
+ profile_id: { type: "number", description: "Quality profile id (from action read)." },
2122
+ service_id: { type: "number", description: "Radarr movie id or Sonarr series id (media_search returns it)." },
2123
+ upgrade_allowed: { type: "boolean", description: "set_upgrade: whether a better release may replace the file." },
2124
+ cutoff: { type: "number", description: "set_upgrade: quality id from the profile at which upgrades stop." },
2125
+ cutoff_name: { type: "string", description: "set_upgrade: the same, by quality name (for example Bluray-1080p)." },
2126
+ dry_run: { type: "boolean", description: "Writes only. Default true: report before and after without changing anything. Pass false to apply." },
2127
+ owner_words: { type: "string", description: "Required to apply (dry_run false): the owner's exact words asking for this change. A verbatim quote, not a paraphrase. It is logged as your claim and is not verified, so never invent it." },
2128
+ caller: { type: "string", description: "Filled in by the runtime for the audit line; leave it out." },
2129
+ }, required: ["kind"] },
2130
+ },
2131
+ {
2132
+ name: "media_manual_import",
2133
+ 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.",
2134
+ inputSchema: { type: "object", properties: {
2135
+ mode: { type: "string", enum: ["preview", "import"], description: "Default preview." },
2136
+ service_id: { type: "number", description: "Sonarr series id." },
2137
+ download_id: { type: "string", description: "Download client id of the completed download (queue row downloadId)." },
2138
+ files: { type: "array", items: { type: "object", properties: { path: { type: "string" }, episode_ids: { type: "array", items: { type: "number" } } }, required: ["path", "episode_ids"] } },
2139
+ }, required: ["service_id", "download_id"] },
2140
+ },
2141
+ ]
2142
+
2143
+ // Every schema is closed: an argument the tool does not define (an invented dry_run,
2144
+ // a misspelled id) is refused with its name, never silently ignored.
2145
+ function closeSchema(schema) {
2146
+ if (!schema || typeof schema !== "object" || schema.type !== "object") return schema
2147
+ const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, v?.type === "array" && v.items ? { ...v, items: closeSchema(v.items) } : closeSchema(v)]))
2148
+ return { ...schema, properties, additionalProperties: false }
2149
+ }
2150
+
2151
+ export const TOOLS = TOOL_LIST.map((t) => ({ ...t, inputSchema: closeSchema(t.inputSchema) }))
2152
+
2153
+ // The same check inside the server, for any caller that does not validate against the schema.
2154
+ export function unknownArguments(toolName, args) {
2155
+ const tool = TOOLS.find((t) => t.name === toolName)
2156
+ const allowed = Object.keys(tool?.inputSchema?.properties ?? {})
2157
+ const unknown = Object.keys(args ?? {}).filter((k) => !allowed.includes(k))
2158
+ return unknown.length ? { error: "unknown_parameter", unknown, allowed,
2159
+ message: `${toolName} does not take ${unknown.map((k) => `'${k}'`).join(", ")}. It accepts only: ${allowed.join(", ") || "no parameters"}. Call it again without the unknown parameter.` } : null
2160
+ }
2161
+
2162
+ const HANDLERS = {
2163
+ media_search: mediaSearch,
2164
+ media_request: mediaRequest,
2165
+ media_request_status: mediaRequestStatus,
2166
+ media_diagnose_and_fix: mediaDiagnoseAndFix,
2167
+ media_chain_health: mediaChainHealth,
2168
+ media_play_or_resolve: mediaPlayOrResolve,
2169
+ media_search_now: mediaSearchNow,
2170
+ media_queue: mediaQueue,
2171
+ media_blocklist_stalled: mediaBlocklistStalled,
2172
+ media_episodes: mediaEpisodes,
2173
+ media_fill_missing: mediaFillMissing,
2174
+ media_indexer_search: mediaIndexerSearch,
2175
+ media_release_search: mediaReleaseSearch,
2176
+ media_release_grab: mediaReleaseGrab,
2177
+ media_blocklist: mediaBlocklist,
2178
+ media_manual_import: mediaManualImport,
2179
+ media_quality_profile: mediaQualityProfile,
2180
+ }
2181
+
2182
+ // ------------------------------------------------------------ jsonrpc stdio
2183
+
2184
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n") }
2185
+
2186
+ async function handle(msg) {
2187
+ const { id, method, params } = msg
2188
+ if (method === "initialize") {
2189
+ return send({ jsonrpc: "2.0", id, result: {
2190
+ protocolVersion: "2024-11-05",
2191
+ capabilities: { tools: {} },
2192
+ serverInfo: { name: "sanctuary-media", version: "1.0.0" },
2193
+ } })
2194
+ }
2195
+ if (method === "initialized" || method === "notifications/initialized") return
2196
+ if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } })
2197
+ if (method === "tools/call") {
2198
+ const fn = HANDLERS[params?.name]
2199
+ if (!fn) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: `Unknown tool: ${params?.name}` }] } })
2200
+ if (credError) {
2201
+ const payload = { error: "credentials_unavailable", message: credError, human_action_required: true,
2202
+ human_action_reason: credError }
2203
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
2204
+ }
2205
+ const unknown = unknownArguments(params.name, params.arguments)
2206
+ if (unknown) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(unknown, null, 1) }] } })
2207
+ try {
2208
+ const out = await fn(params.arguments ?? {})
2209
+ return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
2210
+ } catch (e) {
2211
+ const payload = { error: e.code ?? "tool_failed", message: e.message,
2212
+ human_action_required: true,
2213
+ human_action_reason: `The media tool could not complete: ${e.message}. Report this failure rather than guessing the answer.` }
2214
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
2215
+ }
2216
+ }
2217
+ if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } })
2218
+ }
2219
+
2220
+ let buf = ""
2221
+ let inFlight = 0
2222
+ let stdinClosed = false
2223
+
2224
+ // Exit only when stdin is closed AND nothing is still running. Exiting on 'end'
2225
+ // alone drops in-flight tool calls, which silently truncates a slow answer.
2226
+ function exitWhenIdle() {
2227
+ if (stdinClosed && inFlight === 0) process.exit(0)
2228
+ }
2229
+
2230
+ // Only run the stdio server when invoked directly (node media-mcp.mjs, exactly
2231
+ // how agent.json launches it). Guarding this lets the test suite import the
2232
+ // module for computeDownloadState/diagnose without attaching to stdin or exiting
2233
+ // the test process. The launch is a plain node <abs-path>, so argv[1] is the
2234
+ // file's own path and this comparison holds.
2235
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
2236
+ process.stdin.setEncoding("utf8")
2237
+ process.stdin.on("data", (chunk) => {
2238
+ buf += chunk
2239
+ let nl
2240
+ while ((nl = buf.indexOf("\n")) >= 0) {
2241
+ const line = buf.slice(0, nl).trim()
2242
+ buf = buf.slice(nl + 1)
2243
+ if (!line) continue
2244
+ let msg
2245
+ try { msg = JSON.parse(line) } catch { continue }
2246
+ inFlight += 1
2247
+ handle(msg)
2248
+ .catch((e) => {
2249
+ if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } })
2250
+ })
2251
+ .finally(() => { inFlight -= 1; exitWhenIdle() })
2252
+ }
2253
+ })
2254
+ process.stdin.on("end", () => { stdinClosed = true; exitWhenIdle() })
2255
+ }