@ouro.bot/cli 0.1.0-alpha.83 → 0.1.0-alpha.831

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 (647) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +154 -23
  3. package/RepairGuide.ouro/agent.json +5 -0
  4. package/RepairGuide.ouro/psyche/IDENTITY.md +19 -0
  5. package/RepairGuide.ouro/psyche/SOUL.md +55 -0
  6. package/RepairGuide.ouro/skills/diagnose-broken-remote.md +63 -0
  7. package/RepairGuide.ouro/skills/diagnose-stacked-typed-issues.md +35 -0
  8. package/RepairGuide.ouro/skills/diagnose-sync-blocked.md +54 -0
  9. package/RepairGuide.ouro/skills/diagnose-vault-expired.md +60 -0
  10. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/agent.json +4 -2
  11. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/SOUL.md +2 -2
  12. package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-serpent.md +1 -1
  13. package/assets/bluebubbles-host +264 -0
  14. package/assets/sanctuary-host-launcher.sh +16 -0
  15. package/changelog.json +5308 -16
  16. package/deploy/unraid/Dockerfile +54 -0
  17. package/deploy/unraid/README.txt +2983 -0
  18. package/deploy/unraid/audit-container-spec.sh +4 -0
  19. package/deploy/unraid/container-runtime.json +1 -0
  20. package/deploy/unraid/docker-man-template-transaction.mjs +711 -0
  21. package/deploy/unraid/docker-man-template-xml.cjs +105 -0
  22. package/deploy/unraid/migrate-sanctuary-bundle.mjs +60 -0
  23. package/deploy/unraid/ouro-events/bootstrap-spool.sh +92 -0
  24. package/deploy/unraid/ouro-events/emit-event.mjs +310 -0
  25. package/deploy/unraid/ouro-events/emit-usenet-event.sh +54 -0
  26. package/deploy/unraid/ouro-events/install-usenet-guard.sh +435 -0
  27. package/deploy/unraid/ouro-events/usenet-health.sh +176 -0
  28. package/deploy/unraid/sanctuary-acceptance-adapter.sh +43 -0
  29. package/deploy/unraid/sanctuary-acceptance-contract.json +198 -0
  30. package/deploy/unraid/sanctuary-acceptance-harness.sh +23 -0
  31. package/deploy/unraid/sanctuary-authority-installation.json +36 -0
  32. package/deploy/unraid/sanctuary-authority-service.sh +30 -0
  33. package/deploy/unraid/sanctuary-deployment-target.mjs +710 -0
  34. package/deploy/unraid/sanctuary-safe-rename.py +24 -0
  35. package/deploy/unraid/sanctuary-unit16-host-broker.mjs +2084 -0
  36. package/deploy/unraid/sanctuary-unit16-run.sh +560 -0
  37. package/deploy/unraid/sanctuary-unit18-target-audit.sh +27 -0
  38. package/deploy/unraid/sanctuary.ouro/agent.json +66 -0
  39. package/deploy/unraid/sanctuary.ouro/arc/README.md +3 -0
  40. package/deploy/unraid/sanctuary.ouro/bundle-meta.json +5 -0
  41. package/deploy/unraid/sanctuary.ouro/habits/sanctuary-health.md +8 -0
  42. package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +795 -0
  43. package/deploy/unraid/sanctuary.ouro/provider-readiness.json +11 -0
  44. package/deploy/unraid/sanctuary.ouro/psyche/ASPIRATIONS.md +5 -0
  45. package/deploy/unraid/sanctuary.ouro/psyche/IDENTITY.md +5 -0
  46. package/deploy/unraid/sanctuary.ouro/psyche/LORE.md +15 -0
  47. package/deploy/unraid/sanctuary.ouro/psyche/SOUL.md +25 -0
  48. package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +15 -0
  49. package/deploy/unraid/sanctuary.ouro/state/policy/steward.json +7 -0
  50. package/deploy/unraid/sanctuary.ouro/tool-profiles.json +135 -0
  51. package/deploy/unraid/sanctuary.xml +28 -0
  52. package/dist/a2a/card.js +57 -0
  53. package/dist/a2a/client.js +180 -0
  54. package/dist/a2a/config.js +50 -0
  55. package/dist/a2a/delegation-stores.js +56 -0
  56. package/dist/a2a/did-resolution.js +93 -0
  57. package/dist/a2a/identity.js +170 -0
  58. package/dist/a2a/inbound-share.js +107 -0
  59. package/dist/a2a/mission-result-wire.js +196 -0
  60. package/dist/a2a/onboarding.js +115 -0
  61. package/dist/a2a/pin-store.js +132 -0
  62. package/dist/a2a/seen-ledger.js +120 -0
  63. package/dist/a2a/server.js +604 -0
  64. package/dist/a2a/task-store.js +69 -0
  65. package/dist/a2a/transport.js +45 -0
  66. package/dist/a2a/types.js +3 -0
  67. package/dist/arc/attention-types.js +8 -0
  68. package/dist/arc/cares.js +360 -0
  69. package/dist/arc/episodes.js +118 -0
  70. package/dist/arc/evolution.js +490 -0
  71. package/dist/arc/flight-recorder.js +1034 -0
  72. package/dist/arc/intentions.js +134 -0
  73. package/dist/arc/json-store.js +117 -0
  74. package/dist/arc/obligations.js +386 -0
  75. package/dist/arc/packets.js +336 -0
  76. package/dist/arc/presence.js +185 -0
  77. package/dist/arc/task-lifecycle.js +57 -0
  78. package/dist/commerce/store.js +755 -0
  79. package/dist/commerce/types.js +3 -0
  80. package/dist/heart/active-work.js +798 -40
  81. package/dist/heart/agent-entry.js +252 -3
  82. package/dist/heart/approval-files.js +113 -0
  83. package/dist/heart/approval-store.js +960 -0
  84. package/dist/heart/attachments/image-normalize.js +194 -0
  85. package/dist/heart/attachments/materialize.js +97 -0
  86. package/dist/heart/attachments/originals.js +88 -0
  87. package/dist/heart/attachments/render.js +29 -0
  88. package/dist/heart/attachments/sources/bluebubbles.js +156 -0
  89. package/dist/heart/attachments/sources/cli-local-file.js +78 -0
  90. package/dist/heart/attachments/sources/index.js +18 -0
  91. package/dist/heart/attachments/sources/telegram.js +71 -0
  92. package/dist/heart/attachments/store.js +132 -0
  93. package/dist/heart/attachments/types.js +93 -0
  94. package/dist/heart/auth/auth-flow.js +495 -0
  95. package/dist/heart/autonomy-budget.js +478 -0
  96. package/dist/heart/awaiting/await-alert.js +148 -0
  97. package/dist/heart/awaiting/await-expiry.js +143 -0
  98. package/dist/heart/awaiting/await-loader.js +91 -0
  99. package/dist/heart/awaiting/await-parser.js +149 -0
  100. package/dist/heart/awaiting/await-runtime-state.js +111 -0
  101. package/dist/heart/awaiting/await-scheduler.js +382 -0
  102. package/dist/heart/background-operations.js +281 -0
  103. package/dist/heart/bridges/manager.js +137 -17
  104. package/dist/heart/bridges/store.js +14 -2
  105. package/dist/heart/bundle-state.js +168 -0
  106. package/dist/heart/commitments.js +44 -18
  107. package/dist/heart/config-registry.js +331 -0
  108. package/dist/heart/config.js +174 -131
  109. package/dist/heart/context-loss-gauntlet.js +387 -0
  110. package/dist/heart/context-loss-sentinel.js +1157 -0
  111. package/dist/heart/core.js +2169 -581
  112. package/dist/heart/cross-chat-delivery.js +9 -22
  113. package/dist/heart/daemon/agent-config-check.js +451 -0
  114. package/dist/heart/daemon/agent-discovery.js +185 -3
  115. package/dist/heart/daemon/agent-service.js +542 -0
  116. package/dist/heart/daemon/agentic-repair.js +547 -0
  117. package/dist/heart/daemon/await-private-wake.js +59 -0
  118. package/dist/heart/daemon/bluebubbles-cli-integration.js +71 -0
  119. package/dist/heart/daemon/bluebubbles-health-diagnostics.js +122 -0
  120. package/dist/heart/daemon/bluebubbles-host-protocol.js +433 -0
  121. package/dist/heart/daemon/bluebubbles-host.js +273 -0
  122. package/dist/heart/daemon/boot-sync-probe.js +197 -0
  123. package/dist/heart/daemon/cadence.js +180 -0
  124. package/dist/heart/daemon/cli-defaults.js +854 -0
  125. package/dist/heart/daemon/cli-desk.js +322 -0
  126. package/dist/heart/daemon/cli-exec.js +8822 -0
  127. package/dist/heart/daemon/cli-help.js +716 -0
  128. package/dist/heart/daemon/cli-parse.js +2771 -0
  129. package/dist/heart/daemon/cli-render-doctor.js +57 -0
  130. package/dist/heart/daemon/cli-render.js +777 -0
  131. package/dist/heart/daemon/cli-types.js +8 -0
  132. package/dist/heart/daemon/connect-bay.js +323 -0
  133. package/dist/heart/daemon/container-credential-bootstrap.js +273 -0
  134. package/dist/heart/daemon/container-healthcheck.js +112 -0
  135. package/dist/heart/daemon/container-runtime.js +97 -0
  136. package/dist/heart/daemon/container-spec-auditor-main.js +171 -0
  137. package/dist/heart/daemon/container-spec-auditor.js +229 -0
  138. package/dist/heart/daemon/daemon-bootstrap-startup.js +115 -0
  139. package/dist/heart/daemon/daemon-cli.js +29 -2349
  140. package/dist/heart/daemon/daemon-entry.js +930 -11
  141. package/dist/heart/daemon/daemon-health.js +186 -0
  142. package/dist/heart/daemon/daemon-rollup.js +57 -0
  143. package/dist/heart/daemon/daemon-runtime-sync.js +88 -13
  144. package/dist/heart/daemon/daemon-tombstone.js +236 -0
  145. package/dist/heart/daemon/daemon.js +2084 -105
  146. package/dist/heart/daemon/dns-workflow.js +394 -0
  147. package/dist/heart/daemon/doctor-types.js +8 -0
  148. package/dist/heart/daemon/doctor.js +1755 -0
  149. package/dist/heart/daemon/freshness.js +345 -0
  150. package/dist/heart/daemon/habit-private-wake.js +61 -0
  151. package/dist/heart/daemon/health-monitor.js +142 -11
  152. package/dist/heart/daemon/hooks/agent-config-v2.js +33 -0
  153. package/dist/heart/daemon/hooks/bundle-meta.js +135 -1
  154. package/dist/heart/daemon/http-health-probe.js +80 -0
  155. package/dist/heart/daemon/human-command-screens.js +234 -0
  156. package/dist/heart/daemon/human-readiness.js +114 -0
  157. package/dist/heart/daemon/inner-status.js +78 -0
  158. package/dist/heart/daemon/interactive-repair.js +394 -0
  159. package/dist/heart/daemon/launchd.js +37 -8
  160. package/dist/heart/daemon/log-tailer.js +79 -10
  161. package/dist/heart/daemon/logs-prune.js +132 -0
  162. package/dist/heart/daemon/mcp-canary.js +524 -0
  163. package/dist/heart/daemon/message-router.js +65 -5
  164. package/dist/heart/daemon/migrate-to-desk.js +848 -0
  165. package/dist/heart/daemon/os-cron-deps.js +135 -0
  166. package/dist/heart/daemon/os-cron.js +22 -14
  167. package/dist/heart/daemon/ouro-bot-entry.js +4 -2
  168. package/dist/heart/daemon/ouro-entry.js +3 -1
  169. package/dist/heart/daemon/plugin-cli.js +432 -0
  170. package/dist/heart/daemon/process-manager.js +733 -61
  171. package/dist/heart/daemon/provider-discovery.js +137 -0
  172. package/dist/heart/daemon/provider-ping-progress.js +83 -0
  173. package/dist/heart/daemon/prunable-bundle.js +144 -0
  174. package/dist/heart/daemon/pulse.js +511 -0
  175. package/dist/heart/daemon/readiness-repair.js +365 -0
  176. package/dist/heart/daemon/run-hooks.js +2 -0
  177. package/dist/heart/daemon/runtime-logging.js +47 -14
  178. package/dist/heart/daemon/runtime-metadata.js +2 -30
  179. package/dist/heart/daemon/runtime-mode.js +13 -2
  180. package/dist/heart/daemon/safe-mode.js +161 -0
  181. package/dist/heart/daemon/sanctuary-acceptance-adapter.js +2654 -0
  182. package/dist/heart/daemon/sanctuary-acceptance-harness.js +1830 -0
  183. package/dist/heart/daemon/sanctuary-acceptance-marker.js +267 -0
  184. package/dist/heart/daemon/sanctuary-acceptance-scenarios.js +955 -0
  185. package/dist/heart/daemon/sanctuary-authority-codec.js +86 -0
  186. package/dist/heart/daemon/sanctuary-authority-epoch.js +256 -0
  187. package/dist/heart/daemon/sanctuary-authority-installation.js +140 -0
  188. package/dist/heart/daemon/sanctuary-authority-ledger.js +241 -0
  189. package/dist/heart/daemon/sanctuary-authority-root-lifecycle.js +819 -0
  190. package/dist/heart/daemon/sanctuary-authority-vault-migration.js +79 -0
  191. package/dist/heart/daemon/sanctuary-bundle-migration.js +729 -0
  192. package/dist/heart/daemon/sanctuary-host-authority.js +1008 -0
  193. package/dist/heart/daemon/sanctuary-host-detached-supervisor.js +494 -0
  194. package/dist/heart/daemon/sanctuary-host-executor.js +670 -0
  195. package/dist/heart/daemon/sanctuary-host-linux-kernel.js +334 -0
  196. package/dist/heart/daemon/sanctuary-host-supervisor-entry.js +207 -0
  197. package/dist/heart/daemon/sanctuary-host-supervisor.js +95 -0
  198. package/dist/heart/daemon/sanctuary-package-management.js +103 -0
  199. package/dist/heart/daemon/sanctuary-scheduler-liveness.js +227 -0
  200. package/dist/heart/daemon/sanctuary-scheduler-origin.js +132 -0
  201. package/dist/heart/daemon/sanctuary-telegram-authority-entry.js +445 -0
  202. package/dist/heart/daemon/sanctuary-telegram-authority-gateway.js +585 -0
  203. package/dist/heart/daemon/sanctuary-telegram-authority-service.js +630 -0
  204. package/dist/heart/daemon/sense-manager.js +699 -48
  205. package/dist/heart/daemon/session-id-resolver.js +131 -0
  206. package/dist/heart/daemon/skill-management-installer.js +1 -1
  207. package/dist/heart/daemon/socket-client.js +180 -12
  208. package/dist/heart/daemon/stale-bundle-prune.js +113 -0
  209. package/dist/heart/daemon/startup-tui.js +339 -0
  210. package/dist/heart/daemon/supercronic-supervisor.js +282 -0
  211. package/dist/heart/daemon/task-scheduler.js +117 -39
  212. package/dist/heart/daemon/terminal-ui.js +499 -0
  213. package/dist/heart/daemon/thoughts.js +138 -54
  214. package/dist/heart/daemon/up-progress.js +366 -0
  215. package/dist/heart/daemon/vault-items.js +56 -0
  216. package/dist/heart/delegation.js +1 -4
  217. package/dist/heart/external-events/router.js +1430 -0
  218. package/dist/heart/frontend-approval-runtime.js +506 -0
  219. package/dist/heart/frontend-journal.js +215 -0
  220. package/dist/heart/frontend-session-service.js +237 -0
  221. package/dist/heart/frontend-socket-client.js +164 -0
  222. package/dist/heart/frontend-socket.js +400 -0
  223. package/dist/heart/habits/habit-cancel.js +810 -0
  224. package/dist/heart/habits/habit-lifecycle.js +1327 -0
  225. package/dist/heart/habits/habit-migration.js +194 -0
  226. package/dist/heart/habits/habit-parser.js +278 -0
  227. package/dist/heart/habits/habit-runtime-state.js +93 -0
  228. package/dist/heart/habits/habit-scheduler.js +653 -0
  229. package/dist/heart/habits/habit-session-summary.js +318 -0
  230. package/dist/heart/habits/habit-session.js +636 -0
  231. package/dist/heart/{daemon → hatch}/hatch-flow.js +55 -62
  232. package/dist/heart/{daemon → hatch}/hatch-specialist.js +6 -8
  233. package/dist/heart/{daemon → hatch}/specialist-prompt.js +12 -9
  234. package/dist/heart/{daemon → hatch}/specialist-tools.js +53 -18
  235. package/dist/heart/identity.js +196 -59
  236. package/dist/heart/kept-notes.js +289 -0
  237. package/dist/heart/kicks.js +1 -1
  238. package/dist/heart/machine-identity.js +161 -0
  239. package/dist/heart/mail-import-discovery.js +356 -0
  240. package/dist/heart/mailbox/mailbox-http-hooks.js +98 -0
  241. package/dist/heart/mailbox/mailbox-http-response.js +7 -0
  242. package/dist/heart/mailbox/mailbox-http-routes.js +398 -0
  243. package/dist/heart/mailbox/mailbox-http-static.js +103 -0
  244. package/dist/heart/mailbox/mailbox-http-transport.js +129 -0
  245. package/dist/heart/mailbox/mailbox-http.js +99 -0
  246. package/dist/heart/mailbox/mailbox-read.js +39 -0
  247. package/dist/heart/mailbox/mailbox-types.js +27 -0
  248. package/dist/heart/mailbox/mailbox-view.js +197 -0
  249. package/dist/heart/mailbox/readers/agent-machine.js +428 -0
  250. package/dist/heart/mailbox/readers/continuity-readers.js +329 -0
  251. package/dist/heart/mailbox/readers/mail.js +375 -0
  252. package/dist/heart/mailbox/readers/runtime-readers.js +823 -0
  253. package/dist/heart/mailbox/readers/sessions.js +231 -0
  254. package/dist/heart/mailbox/readers/shared.js +111 -0
  255. package/dist/heart/mcp/mcp-server.js +696 -0
  256. package/dist/heart/migrate-config.js +100 -0
  257. package/dist/heart/model-capabilities.js +11 -0
  258. package/dist/heart/orientation-frame.js +345 -0
  259. package/dist/heart/platform.js +81 -0
  260. package/dist/heart/primitives.js +1 -1
  261. package/dist/heart/private-runtime/decision-renderer.js +158 -0
  262. package/dist/heart/private-runtime/index.js +18 -0
  263. package/dist/heart/private-runtime/ledger.js +221 -0
  264. package/dist/heart/private-runtime/policy.js +252 -0
  265. package/dist/heart/private-runtime/types.js +2 -0
  266. package/dist/heart/progress-story.js +1 -1
  267. package/dist/heart/provider-attempt.js +137 -0
  268. package/dist/heart/provider-binding-resolver.js +273 -0
  269. package/dist/heart/provider-credentials.js +449 -0
  270. package/dist/heart/provider-failover.js +311 -0
  271. package/dist/heart/provider-models.js +96 -0
  272. package/dist/heart/provider-ping.js +265 -0
  273. package/dist/heart/provider-readiness-cache.js +119 -0
  274. package/dist/heart/provider-visibility.js +188 -0
  275. package/dist/heart/providers/anthropic-token.js +131 -0
  276. package/dist/heart/providers/anthropic.js +173 -60
  277. package/dist/heart/providers/azure.js +23 -11
  278. package/dist/heart/providers/error-classification.js +127 -0
  279. package/dist/heart/providers/github-copilot.js +34 -38
  280. package/dist/heart/providers/minimax-vlm.js +189 -0
  281. package/dist/heart/providers/minimax.js +26 -8
  282. package/dist/heart/providers/openai-codex-token.js +349 -0
  283. package/dist/heart/providers/openai-codex.js +55 -40
  284. package/dist/heart/providers/openai-compatible.js +177 -0
  285. package/dist/heart/run-ledger.js +247 -0
  286. package/dist/heart/runtime-capability-check.js +170 -0
  287. package/dist/heart/runtime-credentials.js +607 -0
  288. package/dist/heart/runtime-cwd.js +87 -0
  289. package/dist/heart/sense-truth.js +22 -4
  290. package/dist/heart/session-activity.js +92 -24
  291. package/dist/heart/session-events.js +1504 -0
  292. package/dist/heart/session-playback-cli-main.js +5 -0
  293. package/dist/heart/session-playback-cli.js +36 -0
  294. package/dist/heart/session-playback.js +231 -0
  295. package/dist/heart/session-redaction-repair-cli-main.js +31 -0
  296. package/dist/heart/session-redaction-repair.js +552 -0
  297. package/dist/heart/session-stats-cli-main.js +5 -0
  298. package/dist/heart/session-stats.js +183 -0
  299. package/dist/heart/session-transcript.js +133 -0
  300. package/dist/heart/start-of-turn-packet.js +372 -0
  301. package/dist/heart/steward-policy.js +618 -0
  302. package/dist/heart/streaming.js +604 -207
  303. package/dist/heart/structured-output.js +196 -0
  304. package/dist/heart/sync-classification.js +176 -0
  305. package/dist/heart/sync.js +449 -0
  306. package/dist/heart/target-resolution.js +11 -7
  307. package/dist/heart/tempo.js +93 -0
  308. package/dist/heart/temporal-view.js +41 -0
  309. package/dist/heart/timeouts.js +101 -0
  310. package/dist/heart/tool-activity-callbacks.js +59 -0
  311. package/dist/heart/tool-approval.js +382 -0
  312. package/dist/heart/tool-description.js +155 -0
  313. package/dist/heart/tool-friction.js +55 -0
  314. package/dist/heart/tool-loop.js +200 -0
  315. package/dist/heart/turn-context.js +482 -0
  316. package/dist/heart/turn-execution-lease.js +30 -0
  317. package/dist/heart/{daemon → versioning}/ouro-bot-global-installer.js +6 -5
  318. package/dist/heart/{daemon → versioning}/ouro-bot-wrapper.js +1 -1
  319. package/dist/heart/versioning/ouro-path-installer.js +432 -0
  320. package/dist/heart/versioning/ouro-recovery-launcher.js +76 -0
  321. package/dist/heart/versioning/ouro-version-manager.js +409 -0
  322. package/dist/heart/{daemon → versioning}/staged-restart.js +40 -8
  323. package/dist/heart/{daemon → versioning}/update-checker.js +6 -1
  324. package/dist/heart/versioning/update-hooks.js +154 -0
  325. package/dist/heart/versioning/version-intent.js +114 -0
  326. package/dist/heart/work-card.js +380 -0
  327. package/dist/mailbox-ui/assets/index-Cyv4mw3Y.js +1 -0
  328. package/dist/mailbox-ui/assets/index-Du_9G9WO.css +1 -0
  329. package/dist/mailbox-ui/assets/vendor-CcN1XpQ9.js +61 -0
  330. package/dist/mailbox-ui/index.html +16 -0
  331. package/dist/mailroom/attention.js +167 -0
  332. package/dist/mailroom/autonomy.js +209 -0
  333. package/dist/mailroom/blob-store.js +889 -0
  334. package/dist/mailroom/body-cache.js +61 -0
  335. package/dist/mailroom/cache-sync-cli.js +58 -0
  336. package/dist/mailroom/core.js +803 -0
  337. package/dist/mailroom/entry.js +160 -0
  338. package/dist/mailroom/file-store.js +568 -0
  339. package/dist/mailroom/hosted-cache-sync.js +384 -0
  340. package/dist/mailroom/mbox-import.js +393 -0
  341. package/dist/mailroom/migration.js +164 -0
  342. package/dist/mailroom/outbound.js +380 -0
  343. package/dist/mailroom/policy.js +263 -0
  344. package/dist/mailroom/reader.js +292 -0
  345. package/dist/mailroom/search-cache.js +513 -0
  346. package/dist/mailroom/search-relevance.js +319 -0
  347. package/dist/mailroom/smtp-ingress.js +176 -0
  348. package/dist/mailroom/source-state.js +176 -0
  349. package/dist/mailroom/thread.js +109 -0
  350. package/dist/mailroom/travel-extract.js +89 -0
  351. package/dist/mind/bundle-manifest.js +24 -2
  352. package/dist/mind/context.js +309 -111
  353. package/dist/mind/desk-section.js +362 -0
  354. package/dist/mind/diary-integrity.js +60 -0
  355. package/dist/mind/{memory.js → diary.js} +68 -77
  356. package/dist/mind/embedding-provider.js +60 -0
  357. package/dist/mind/file-state.js +179 -0
  358. package/dist/mind/{associative-recall.js → note-search.js} +47 -58
  359. package/dist/mind/obligation-steering.js +199 -3
  360. package/dist/mind/pending.js +36 -10
  361. package/dist/mind/prompt-budget.js +479 -0
  362. package/dist/mind/prompt-refresh.js +3 -2
  363. package/dist/mind/prompt.js +1050 -189
  364. package/dist/mind/provenance-trust.js +26 -0
  365. package/dist/mind/record-paths.js +312 -0
  366. package/dist/mind/scrutiny.js +173 -0
  367. package/dist/mind/session-transaction.js +564 -0
  368. package/dist/nerves/cli-logging.js +7 -1
  369. package/dist/nerves/coverage/audit-rules.js +15 -6
  370. package/dist/nerves/coverage/audit.js +28 -2
  371. package/dist/nerves/coverage/cli.js +1 -1
  372. package/dist/nerves/coverage/contract.js +5 -5
  373. package/dist/nerves/coverage/file-completeness.js +139 -5
  374. package/dist/nerves/coverage/run-artifacts.js +55 -35
  375. package/dist/nerves/coverage/source-scanner.js +4 -4
  376. package/dist/nerves/event-buffer.js +111 -0
  377. package/dist/nerves/index.js +293 -9
  378. package/dist/nerves/observation.js +20 -0
  379. package/dist/nerves/redact.js +79 -0
  380. package/dist/nerves/review/cli-main.js +5 -0
  381. package/dist/nerves/review/cli.js +156 -0
  382. package/dist/nerves/review/core.js +159 -0
  383. package/dist/nerves/runtime.js +17 -1
  384. package/dist/repertoire/ado-client.js +15 -56
  385. package/dist/repertoire/ado-semantic.js +16 -10
  386. package/dist/repertoire/api-client.js +97 -0
  387. package/dist/repertoire/bitwarden-store.js +1045 -0
  388. package/dist/repertoire/bundle-templates.js +71 -0
  389. package/dist/repertoire/bw-installer.js +180 -0
  390. package/dist/repertoire/coding/codex-jsonl.js +64 -0
  391. package/dist/repertoire/coding/context-pack.js +331 -0
  392. package/dist/repertoire/coding/feedback.js +174 -32
  393. package/dist/repertoire/coding/index.js +15 -3
  394. package/dist/repertoire/coding/manager.js +159 -10
  395. package/dist/repertoire/coding/spawner.js +58 -11
  396. package/dist/repertoire/coding/tools.js +221 -15
  397. package/dist/repertoire/coding/workbench-client.js +272 -0
  398. package/dist/repertoire/coding/workbench-manager.js +346 -0
  399. package/dist/repertoire/commerce-errors.js +109 -0
  400. package/dist/repertoire/commerce-self-test.js +156 -0
  401. package/dist/repertoire/credential-access.js +178 -0
  402. package/dist/repertoire/desk/classifier.js +362 -0
  403. package/dist/repertoire/duffel-client.js +185 -0
  404. package/dist/repertoire/github-client.js +14 -55
  405. package/dist/repertoire/graph-client.js +11 -52
  406. package/dist/repertoire/guardrails.js +811 -18
  407. package/dist/repertoire/mcp-client.js +44 -3
  408. package/dist/repertoire/mcp-manager.js +427 -139
  409. package/dist/repertoire/mcp-tools.js +124 -0
  410. package/dist/repertoire/plugin-mcp.js +175 -0
  411. package/dist/repertoire/plugins.js +253 -0
  412. package/dist/repertoire/relationship-authorization.js +322 -0
  413. package/dist/repertoire/shell-sessions.js +134 -0
  414. package/dist/repertoire/skills.js +48 -4
  415. package/dist/repertoire/stripe-client.js +131 -0
  416. package/dist/repertoire/tool-arguments.js +170 -0
  417. package/dist/repertoire/tool-results.js +29 -0
  418. package/dist/repertoire/tools-a2a.js +699 -0
  419. package/dist/repertoire/tools-attachments.js +322 -0
  420. package/dist/repertoire/tools-awaiting.js +575 -0
  421. package/dist/repertoire/tools-base.js +77 -1138
  422. package/dist/repertoire/tools-bluebubbles.js +2 -0
  423. package/dist/repertoire/tools-bridge.js +144 -0
  424. package/dist/repertoire/tools-bundle.js +993 -0
  425. package/dist/repertoire/tools-commerce.js +253 -0
  426. package/dist/repertoire/tools-config.js +186 -0
  427. package/dist/repertoire/tools-continuity.js +575 -0
  428. package/dist/repertoire/tools-credential.js +383 -0
  429. package/dist/repertoire/tools-evolution.js +527 -0
  430. package/dist/repertoire/tools-files.js +344 -0
  431. package/dist/repertoire/tools-flight.js +290 -0
  432. package/dist/repertoire/tools-flow.js +119 -0
  433. package/dist/repertoire/tools-github.js +3 -8
  434. package/dist/repertoire/tools-habits.js +103 -0
  435. package/dist/repertoire/tools-mail.js +1828 -0
  436. package/dist/repertoire/tools-notes.js +485 -0
  437. package/dist/repertoire/tools-obligations.js +182 -0
  438. package/dist/repertoire/tools-orientation.js +31 -0
  439. package/dist/repertoire/tools-record.js +482 -0
  440. package/dist/repertoire/tools-rsvp.js +139 -0
  441. package/dist/repertoire/tools-runtime.js +150 -0
  442. package/dist/repertoire/tools-sanctuary-host.js +202 -0
  443. package/dist/repertoire/tools-session.js +1133 -0
  444. package/dist/repertoire/tools-shell.js +149 -0
  445. package/dist/repertoire/tools-steward-policy.js +119 -0
  446. package/dist/repertoire/tools-stripe.js +224 -0
  447. package/dist/repertoire/tools-surface.js +369 -0
  448. package/dist/repertoire/tools-teams.js +12 -39
  449. package/dist/repertoire/tools-telegram-contacts.js +37 -0
  450. package/dist/repertoire/tools-travel.js +125 -0
  451. package/dist/repertoire/tools-trip.js +982 -0
  452. package/dist/repertoire/tools-unraid.js +531 -0
  453. package/dist/repertoire/tools-user-profile.js +146 -0
  454. package/dist/repertoire/tools-vault.js +40 -0
  455. package/dist/repertoire/tools-voice.js +145 -0
  456. package/dist/repertoire/tools.js +494 -148
  457. package/dist/repertoire/travel-api-client.js +360 -0
  458. package/dist/repertoire/unraid-client.js +155 -0
  459. package/dist/repertoire/unraid-restart.js +339 -0
  460. package/dist/repertoire/user-profile.js +131 -0
  461. package/dist/repertoire/vault-setup.js +246 -0
  462. package/dist/repertoire/vault-unlock.js +628 -0
  463. package/dist/rsvp/aisleplanner-client.js +354 -0
  464. package/dist/rsvp/cli.js +926 -0
  465. package/dist/rsvp/config.js +327 -0
  466. package/dist/rsvp/cutover.js +433 -0
  467. package/dist/rsvp/diagnostics.js +232 -0
  468. package/dist/rsvp/diff-renderer.js +176 -0
  469. package/dist/rsvp/habit-policy.js +110 -0
  470. package/dist/rsvp/habit-stage.js +123 -0
  471. package/dist/rsvp/incident-bundle.js +97 -0
  472. package/dist/rsvp/migration.js +204 -0
  473. package/dist/rsvp/native-habit-runner.js +630 -0
  474. package/dist/rsvp/outbound-state.js +628 -0
  475. package/dist/rsvp/query.js +118 -0
  476. package/dist/rsvp/replay.js +233 -0
  477. package/dist/rsvp/snapshot.js +232 -0
  478. package/dist/rsvp/spend-ledger.js +175 -0
  479. package/dist/scripts/claude-code-hook.js +41 -0
  480. package/dist/scripts/claude-code-stop-hook.js +47 -0
  481. package/dist/senses/a2a-entry.js +115 -0
  482. package/dist/senses/acp-server.js +386 -0
  483. package/dist/senses/attention-queue.js +186 -0
  484. package/dist/senses/await-turn-message.js +58 -0
  485. package/dist/senses/bluebubbles/active-turns.js +216 -0
  486. package/dist/senses/bluebubbles/attachment-cache.js +53 -0
  487. package/dist/senses/bluebubbles/attachment-download.js +137 -0
  488. package/dist/senses/{bluebubbles-client.js → bluebubbles/client.js} +355 -40
  489. package/dist/senses/bluebubbles/context-packet.js +207 -0
  490. package/dist/senses/bluebubbles/context-smoke.js +144 -0
  491. package/dist/senses/bluebubbles/entry.js +90 -0
  492. package/dist/senses/{bluebubbles-inbound-log.js → bluebubbles/inbound-log.js} +21 -3
  493. package/dist/senses/bluebubbles/index.js +3841 -0
  494. package/dist/senses/bluebubbles/latest-turn.js +311 -0
  495. package/dist/senses/{bluebubbles-media.js → bluebubbles/media.js} +121 -71
  496. package/dist/senses/{bluebubbles-model.js → bluebubbles/model.js} +159 -28
  497. package/dist/senses/{bluebubbles-mutation-log.js → bluebubbles/mutation-log.js} +24 -13
  498. package/dist/senses/bluebubbles/outbound-state.js +308 -0
  499. package/dist/senses/bluebubbles/processed-log.js +133 -0
  500. package/dist/senses/bluebubbles/reaction-policy.js +47 -0
  501. package/dist/senses/bluebubbles/replay.js +284 -0
  502. package/dist/senses/{bluebubbles-runtime-state.js → bluebubbles/runtime-state.js} +30 -2
  503. package/dist/senses/bluebubbles/semantic-receipts.js +1986 -0
  504. package/dist/senses/{bluebubbles-session-cleanup.js → bluebubbles/session-cleanup.js} +1 -1
  505. package/dist/senses/bluebubbles/webhook-registration.js +228 -0
  506. package/dist/senses/bluebubbles-meta-guard.js +39 -0
  507. package/dist/senses/cli/bracketed-paste.js +82 -0
  508. package/dist/senses/cli/image-paste.js +287 -0
  509. package/dist/senses/cli/image-ref-navigation.js +75 -0
  510. package/dist/senses/cli/ink-app.js +156 -0
  511. package/dist/senses/cli/inline-diff.js +64 -0
  512. package/dist/senses/cli/input-keys.js +174 -0
  513. package/dist/senses/cli/kill-ring.js +86 -0
  514. package/dist/senses/cli/message-list.js +51 -0
  515. package/dist/senses/cli/ouro-tui.js +607 -0
  516. package/dist/senses/cli/spinner-imperative.js +135 -0
  517. package/dist/senses/cli/spinner.js +101 -0
  518. package/dist/senses/cli/status-line.js +60 -0
  519. package/dist/senses/cli/streaming-markdown.js +526 -0
  520. package/dist/senses/cli/tool-display.js +85 -0
  521. package/dist/senses/cli/tool-render.js +85 -0
  522. package/dist/senses/cli/tui-store.js +241 -0
  523. package/dist/senses/cli/virtual-list.js +35 -0
  524. package/dist/senses/cli-entry.js +60 -8
  525. package/dist/senses/cli.js +604 -320
  526. package/dist/senses/commands.js +66 -3
  527. package/dist/senses/context-packet-ledger.js +371 -0
  528. package/dist/senses/context-packets.js +154 -0
  529. package/dist/senses/habit-lifecycle-guard.js +24 -0
  530. package/dist/senses/habit-turn-message.js +160 -0
  531. package/dist/senses/ingress-evidence.js +2 -0
  532. package/dist/senses/mail-entry.js +66 -0
  533. package/dist/senses/mail.js +524 -0
  534. package/dist/senses/pipeline.js +1115 -204
  535. package/dist/senses/private-runtime-worker.js +859 -0
  536. package/dist/senses/private-runtime.js +1517 -0
  537. package/dist/senses/proactive-content-guard.js +51 -0
  538. package/dist/senses/root-host-approval-port.js +345 -0
  539. package/dist/senses/root-host-approval-runtime.js +393 -0
  540. package/dist/senses/sanctuary-authority-resident.js +102 -0
  541. package/dist/senses/sanctuary-download-credit-presentation.js +31 -0
  542. package/dist/senses/sanctuary-full-visibility-contract.js +302 -0
  543. package/dist/senses/sanctuary-grounding.js +118 -0
  544. package/dist/senses/sanctuary-health-acceptance-probe-entry.js +12 -0
  545. package/dist/senses/sanctuary-health-acceptance-probe.js +774 -0
  546. package/dist/senses/sanctuary-health-runner.js +100 -0
  547. package/dist/senses/sanctuary-health.js +408 -0
  548. package/dist/senses/sanctuary-install-state-contract.js +126 -0
  549. package/dist/senses/sanctuary-interactive-control.js +410 -0
  550. package/dist/senses/sanctuary-media-catalog-contract.js +297 -0
  551. package/dist/senses/sanctuary-media-optimization.js +514 -0
  552. package/dist/senses/sanctuary-runtime.js +340 -0
  553. package/dist/senses/sanctuary-sab.js +85 -0
  554. package/dist/senses/sanctuary-storage-optimization-contract.js +32 -0
  555. package/dist/senses/shared-turn.js +772 -0
  556. package/dist/senses/surface-tool.js +109 -0
  557. package/dist/senses/teams-entry.js +60 -8
  558. package/dist/senses/teams.js +519 -218
  559. package/dist/senses/telegram-admission.js +718 -0
  560. package/dist/senses/telegram-approval-runtime.js +793 -0
  561. package/dist/senses/telegram-attachments.js +186 -0
  562. package/dist/senses/telegram-audit-ledger.js +220 -0
  563. package/dist/senses/telegram-authority-transport.js +231 -0
  564. package/dist/senses/telegram-client.js +1749 -0
  565. package/dist/senses/telegram-effect-adapter.js +896 -0
  566. package/dist/senses/telegram-entry.js +83 -0
  567. package/dist/senses/telegram.js +2324 -0
  568. package/dist/senses/trust-gate.js +104 -9
  569. package/dist/senses/voice/audio-playback.js +237 -0
  570. package/dist/senses/voice/audio-routing.js +119 -0
  571. package/dist/senses/voice/elevenlabs.js +202 -0
  572. package/dist/senses/voice/floor-control.js +431 -0
  573. package/dist/senses/voice/floor-controller.js +115 -0
  574. package/dist/senses/voice/golden-path.js +116 -0
  575. package/dist/senses/voice/index.js +29 -0
  576. package/dist/senses/voice/meeting.js +113 -0
  577. package/dist/senses/voice/outbound.js +190 -0
  578. package/dist/senses/voice/phone.js +33 -0
  579. package/dist/senses/voice/playback.js +139 -0
  580. package/dist/senses/voice/realtime-eval.js +496 -0
  581. package/dist/senses/voice/realtime-trace.js +531 -0
  582. package/dist/senses/voice/transcript.js +70 -0
  583. package/dist/senses/voice/turn.js +191 -0
  584. package/dist/senses/voice/twilio-phone-runtime.js +807 -0
  585. package/dist/senses/voice/twilio-phone.js +5031 -0
  586. package/dist/senses/voice/types.js +2 -0
  587. package/dist/senses/voice/whisper.js +161 -0
  588. package/dist/senses/voice-entry.js +81 -0
  589. package/dist/senses/voice-realtime-eval-command.js +99 -0
  590. package/dist/senses/voice-realtime-eval-entry.js +21 -0
  591. package/dist/senses/voice-twilio-entry.js +87 -0
  592. package/dist/trips/core.js +138 -0
  593. package/dist/trips/store.js +265 -0
  594. package/dist/util/frontmatter.js +69 -0
  595. package/npm-shrinkwrap.json +8138 -0
  596. package/package.json +72 -13
  597. package/skills/agent-commerce.md +113 -0
  598. package/skills/browser-navigation.md +117 -0
  599. package/skills/commerce-setup-guide.md +116 -0
  600. package/skills/commerce-setup.md +84 -0
  601. package/skills/configure-dev-tools.md +99 -0
  602. package/skills/travel-planning.md +138 -0
  603. package/dist/heart/daemon/auth-flow.js +0 -430
  604. package/dist/heart/daemon/ouro-path-installer.js +0 -260
  605. package/dist/heart/daemon/ouro-version-manager.js +0 -171
  606. package/dist/heart/daemon/update-hooks.js +0 -138
  607. package/dist/heart/obligations.js +0 -191
  608. package/dist/heart/safe-workspace.js +0 -228
  609. package/dist/heart/session-recall.js +0 -116
  610. package/dist/mind/friends/channel.js +0 -84
  611. package/dist/mind/friends/group-context.js +0 -144
  612. package/dist/mind/friends/resolver.js +0 -84
  613. package/dist/mind/friends/store-file.js +0 -190
  614. package/dist/mind/friends/store.js +0 -4
  615. package/dist/mind/friends/tokens.js +0 -26
  616. package/dist/mind/friends/trust-explanation.js +0 -74
  617. package/dist/mind/friends/types.js +0 -29
  618. package/dist/repertoire/tasks/board.js +0 -134
  619. package/dist/repertoire/tasks/index.js +0 -224
  620. package/dist/repertoire/tasks/lifecycle.js +0 -80
  621. package/dist/repertoire/tasks/middleware.js +0 -65
  622. package/dist/repertoire/tasks/parser.js +0 -173
  623. package/dist/repertoire/tasks/scanner.js +0 -132
  624. package/dist/repertoire/tasks/transitions.js +0 -144
  625. package/dist/senses/bluebubbles-entry.js +0 -13
  626. package/dist/senses/bluebubbles.js +0 -1181
  627. package/dist/senses/debug-activity.js +0 -154
  628. package/dist/senses/inner-dialog-worker.js +0 -90
  629. package/dist/senses/inner-dialog.js +0 -525
  630. package/subagents/README.md +0 -7
  631. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/basilisk.md +0 -0
  632. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jafar.md +0 -0
  633. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/jormungandr.md +0 -0
  634. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/kaa.md +0 -0
  635. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/medusa.md +0 -0
  636. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/monty.md +0 -0
  637. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/nagini.md +0 -0
  638. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/ouroboros.md +0 -0
  639. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/python.md +0 -0
  640. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/quetzalcoatl.md +0 -0
  641. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/sir-hiss.md +0 -0
  642. /package/{AdoptionSpecialist.ouro → SerpentGuide.ouro}/psyche/identities/the-snake.md +0 -0
  643. /package/dist/{repertoire/tasks/types.js → heart/attachments/sources/adapter.js} +0 -0
  644. /package/dist/heart/{daemon → hatch}/hatch-animation.js +0 -0
  645. /package/dist/heart/{daemon → hatch}/specialist-orchestrator.js +0 -0
  646. /package/dist/heart/{daemon → versioning}/ouro-uti.js +0 -0
  647. /package/dist/heart/{daemon → versioning}/wrapper-publish-guard.js +0 -0
@@ -0,0 +1,795 @@
1
+ #!/usr/bin/env node
2
+ // Sanctuary media MCP server — the Mendelow Cloud Butler's media surface.
3
+ //
4
+ // Design authority: the Butler's own tool-surface spec (Telegram msgs 1700-1713, 2026-09-19).
5
+ // Two hard constraints it set, enforced here:
6
+ // 1. Anything deterministic lives in the tool, not in the agent's reasoning.
7
+ // 2. No "call A, parse it, build B" — every return payload carries the cross-reference
8
+ // fields needed to answer the obvious follow-up without a second call.
9
+ //
10
+ // Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
11
+
12
+ import { readFileSync } from "node:fs"
13
+ import { pathToFileURL } from "node:url"
14
+
15
+ const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
16
+
17
+ // Credentials are host-specific and deliberately NOT packaged. A fresh install
18
+ // has none, and the server must still start and list its tools — otherwise the
19
+ // whole MCP server fails to connect and the agent silently loses the surface.
20
+ // Missing credentials become a named, reportable per-call failure instead.
21
+ let cred = null
22
+ let credError = null
23
+ try {
24
+ cred = JSON.parse(readFileSync(CRED_PATH, "utf8"))
25
+ } catch (e) {
26
+ credError = `Media credentials are unavailable at ${CRED_PATH} (${e.code ?? e.message}). The media tools cannot reach Jellyseerr/Sonarr/Radarr/Prowlarr until that file is created.`
27
+ }
28
+
29
+ const SEERR = cred?.jellyseerr ?? { url: "", apiKey: "" }
30
+ const SONARR = cred?.sonarr ?? { url: "", apiKey: "" }
31
+ const RADARR = cred?.radarr ?? { url: "", apiKey: "" }
32
+ const PROWLARR = cred?.prowlarr ?? { url: "", apiKey: "" }
33
+ const JELLYFIN_WEB = cred?.jellyfinWebUrl ?? "https://media.mendelow.cloud"
34
+
35
+ const TIMEOUT_MS = 45_000
36
+
37
+ // ---------------------------------------------------------------- http helpers
38
+
39
+ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body, query } = {}) {
40
+ let url = `${base}${path}`
41
+ if (query) {
42
+ const parts = Object.entries(query)
43
+ .filter(([, v]) => v !== undefined && v !== null && v !== "")
44
+ // encodeURIComponent, not URLSearchParams: Jellyseerr rejects '+' for spaces.
45
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
46
+ if (parts.length) url += `?${parts.join("&")}`
47
+ }
48
+ const headers = { [header]: key, Accept: "application/json" }
49
+ if (body !== undefined) headers["Content-Type"] = "application/json"
50
+ let res
51
+ try {
52
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS) })
53
+ } catch (e) {
54
+ throw new ServiceError(`${serviceOf(base)}_unreachable`, `${serviceOf(base)} did not respond: ${e.name === "TimeoutError" ? "timeout" : e.message}`)
55
+ }
56
+ if (!res.ok) {
57
+ const text = await res.text().catch(() => "")
58
+ throw new ServiceError(`${serviceOf(base)}_http_${res.status}`, `${serviceOf(base)} returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`)
59
+ }
60
+ if (res.status === 204) return null
61
+ return res.json()
62
+ }
63
+
64
+ function serviceOf(base) {
65
+ if (base === SEERR.url) return "jellyseerr"
66
+ if (base === SONARR.url) return "sonarr"
67
+ if (base === RADARR.url) return "radarr"
68
+ if (base === PROWLARR.url) return "prowlarr"
69
+ return "service"
70
+ }
71
+
72
+ class ServiceError extends Error {
73
+ constructor(code, message) { super(message); this.code = code }
74
+ }
75
+
76
+ const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
77
+ // Long enough that an ordinary slow start is not called dead, short enough that
78
+ // nobody waits a day for a release that was never going to arrive.
79
+ const STALL_AFTER_HOURS = 6
80
+
81
+ const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
82
+ const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
83
+ const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
84
+
85
+ // ------------------------------------------------------------------ mapping
86
+
87
+ // Jellyseerr media status codes.
88
+ const MEDIA_STATUS = { 1: "unknown", 2: "pending", 3: "processing", 4: "partially_available", 5: "available" }
89
+ const REQUEST_STATUS = { 1: "pending_approval", 2: "approved", 3: "declined", 4: "failed" }
90
+
91
+ const TMDB_GENRES = {
92
+ 28: "Action", 12: "Adventure", 16: "Animation", 35: "Comedy", 80: "Crime", 99: "Documentary",
93
+ 18: "Drama", 10751: "Family", 14: "Fantasy", 36: "History", 27: "Horror", 10402: "Music",
94
+ 9648: "Mystery", 10749: "Romance", 878: "Science Fiction", 10770: "TV Movie", 53: "Thriller",
95
+ 10752: "War", 37: "Western", 10759: "Action & Adventure", 10762: "Kids", 10763: "News",
96
+ 10764: "Reality", 10765: "Sci-Fi & Fantasy", 10766: "Soap", 10767: "Talk", 10768: "War & Politics",
97
+ }
98
+
99
+ function genreNames(item) {
100
+ const ids = item.genreIds ?? (item.genres ?? []).map((g) => g.id)
101
+ return ids.map((id) => TMDB_GENRES[id]).filter(Boolean)
102
+ }
103
+
104
+ function shelfItem(item) {
105
+ const kind = item.mediaType === "tv" ? "series" : item.mediaType === "movie" ? "movie" : (item.firstAirDate ? "series" : "movie")
106
+ const date = item.releaseDate ?? item.firstAirDate ?? ""
107
+ const info = item.mediaInfo ?? {}
108
+ const status = MEDIA_STATUS[info.status] ?? "not_on_shelf"
109
+ return {
110
+ title: item.title ?? item.name ?? null,
111
+ year: date ? Number(date.slice(0, 4)) : null,
112
+ kind,
113
+ tmdb_id: item.id ?? null,
114
+ tvdb_id: info.tvdbId ?? null,
115
+ genres: genreNames(item),
116
+ overview: typeof item.overview === "string" ? item.overview.slice(0, 400) : null,
117
+ // The two fields that stop a second call:
118
+ on_shelf: status === "available" || status === "partially_available",
119
+ shelf_state: status,
120
+ request_state: info.status ? (status === "available" ? "available" : status) : "never_requested",
121
+ sonarr_id: info.externalServiceId ?? null,
122
+ radarr_id: kind === "movie" ? (info.externalServiceId ?? null) : null,
123
+ poster_path: item.posterPath ? `https://image.tmdb.org/t/p/w500${item.posterPath}` : null,
124
+ vote_average: item.voteAverage ?? null,
125
+ }
126
+ }
127
+
128
+ // ------------------------------------------------------------- tool: search
129
+
130
+ async function mediaSearch(a) {
131
+ const kind = a.kind ?? "any"
132
+ const limit = Math.min(Math.max(a.limit ?? 12, 1), 20)
133
+ const onShelf = a.on_shelf // true | false | undefined(any)
134
+ let raw = []
135
+ let source
136
+
137
+ if (a.query) {
138
+ source = "title_search"
139
+ const body = await seerr("/search", { query: { query: a.query, page: 1 } })
140
+ raw = body.results ?? []
141
+ } else {
142
+ source = "discover"
143
+ const keywordIds = []
144
+ for (const kw of a.keywords ?? []) {
145
+ const hit = await seerr("/search/keyword", { query: { query: kw, page: 1 } })
146
+ const exact = (hit.results ?? []).find((k) => k.name.toLowerCase() === kw.toLowerCase()) ?? (hit.results ?? [])[0]
147
+ if (exact) keywordIds.push(exact.id)
148
+ }
149
+ const genreIds = (a.genres ?? [])
150
+ .map((name) => Number(Object.entries(TMDB_GENRES).find(([, v]) => v.toLowerCase() === String(name).toLowerCase())?.[0]))
151
+ .filter((n) => Number.isFinite(n))
152
+ const [yMin, yMax] = a.year_range ?? []
153
+ const paths = kind === "series" ? ["/discover/tv"] : kind === "movie" ? ["/discover/movies"] : ["/discover/movies", "/discover/tv"]
154
+ // One page of discover is ~20 popular titles, of which only a couple are
155
+ // usually on this shelf — answering "what cozy autumn films do we own?" from
156
+ // that reads as though the shelf is nearly empty. When the caller filters by
157
+ // shelf state, walk further pages until enough matches accumulate.
158
+ const maxPages = onShelf === undefined ? 1 : 5
159
+ for (const p of paths) {
160
+ const isTv = p.endsWith("/tv")
161
+ for (let page = 1; page <= maxPages; page += 1) {
162
+ const q = { page, sortBy: a.sort === "release_year" ? "primary_release_date.desc" : "popularity.desc" }
163
+ if (genreIds.length) q.genre = genreIds.join(",")
164
+ if (keywordIds.length) q.keywords = keywordIds.join(",")
165
+ if (yMin) q[isTv ? "firstAirDateGte" : "primaryReleaseDateGte"] = `${yMin}-01-01`
166
+ if (yMax) q[isTv ? "firstAirDateLte" : "primaryReleaseDateLte"] = `${yMax}-12-31`
167
+ const body = await seerr(p, { query: q })
168
+ const results = body.results ?? []
169
+ raw.push(...results.map((r) => ({ ...r, mediaType: isTv ? "tv" : "movie" })))
170
+ const matchesSoFar = raw
171
+ .map((r) => (r.mediaInfo?.status === 5 || r.mediaInfo?.status === 4))
172
+ .filter((hit) => hit === (onShelf === true)).length
173
+ if (results.length === 0 || page >= (body.totalPages ?? 1) || matchesSoFar >= limit) break
174
+ }
175
+ }
176
+ }
177
+
178
+ let items = raw.filter((r) => r.mediaType !== "person").map(shelfItem)
179
+ if (kind !== "any") items = items.filter((i) => i.kind === kind)
180
+ if (a.year_range) {
181
+ const [lo, hi] = a.year_range
182
+ items = items.filter((i) => i.year && (!lo || i.year >= lo) && (!hi || i.year <= hi))
183
+ }
184
+ if (onShelf === true) items = items.filter((i) => i.on_shelf)
185
+ if (onShelf === false) items = items.filter((i) => !i.on_shelf)
186
+ if (a.sort === "release_year") items.sort((x, y) => (y.year ?? 0) - (x.year ?? 0))
187
+
188
+ return {
189
+ source,
190
+ matched: items.length,
191
+ items: items.slice(0, limit),
192
+ applied_filters: { kind, year_range: a.year_range ?? null, genres: a.genres ?? [], keywords: a.keywords ?? [], on_shelf: onShelf ?? "any" },
193
+ // So the agent never claims something is missing when the filter caused it:
194
+ note: items.length === 0 ? "No matches for these filters. Loosen year_range/genres/keywords before concluding the shelf lacks it." : null,
195
+ }
196
+ }
197
+
198
+ // ------------------------------------------------------------ tool: request
199
+
200
+ // Resolve by TMDB id against the title itself rather than scanning the request
201
+ // list: this household has over a thousand requests, so a page-limited scan
202
+ // silently misses older ones and would file a duplicate.
203
+ async function findExistingRequest(tmdbId, kind) {
204
+ const kinds = kind ? [kind] : ["movie", "series"]
205
+ for (const k of kinds) {
206
+ let detail
207
+ try {
208
+ detail = await seerr(`/${k === "series" ? "tv" : "movie"}/${tmdbId}`)
209
+ } catch {
210
+ continue
211
+ }
212
+ const info = detail?.mediaInfo
213
+ if (!info) continue
214
+ const requests = info.requests ?? []
215
+ const latest = requests.length ? requests[requests.length - 1] : null
216
+ return {
217
+ id: latest?.id ?? null,
218
+ status: latest?.status ?? null,
219
+ createdAt: latest?.createdAt ?? null,
220
+ type: k === "series" ? "tv" : "movie",
221
+ media: { ...info, tmdbId, title: detail.title ?? detail.name ?? null },
222
+ hasRequest: requests.length > 0,
223
+ }
224
+ }
225
+ return null
226
+ }
227
+
228
+ async function mediaRequest(a) {
229
+ let tmdbId = a.tmdb_id
230
+ let kind = a.kind ?? "movie"
231
+ let resolvedTitle = a.title ?? null
232
+ let resolvedYear = a.year ?? null
233
+
234
+ if (!tmdbId) {
235
+ if (!a.title) throw new ServiceError("bad_arguments", "media_request needs tmdb_id, or title (year optional).")
236
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
237
+ let cands = (body.results ?? []).filter((r) => r.mediaType === "movie" || r.mediaType === "tv")
238
+ if (a.kind) cands = cands.filter((r) => (a.kind === "series" ? r.mediaType === "tv" : r.mediaType === "movie"))
239
+ if (a.year) cands = cands.filter((r) => ((r.releaseDate ?? r.firstAirDate ?? "").slice(0, 4)) === String(a.year))
240
+ const pick = cands[0]
241
+ if (!pick) {
242
+ return { resolved: false, reason: "no_tmdb_match", searched: { title: a.title, year: a.year ?? null, kind: a.kind ?? "any" },
243
+ human_action_required: true, human_action_reason: `No TMDB match for "${a.title}"${a.year ? ` (${a.year})` : ""}. Check the title or give a year.` }
244
+ }
245
+ tmdbId = pick.id
246
+ kind = pick.mediaType === "tv" ? "series" : "movie"
247
+ resolvedTitle = pick.title ?? pick.name
248
+ resolvedYear = Number((pick.releaseDate ?? pick.firstAirDate ?? "").slice(0, 4)) || null
249
+ }
250
+
251
+ const existing = await findExistingRequest(tmdbId, kind)
252
+ if (existing?.hasRequest) {
253
+ const shelf = MEDIA_STATUS[existing.media?.status] ?? "unknown"
254
+ return {
255
+ resolved: true, title: resolvedTitle ?? existing.media?.title ?? null, year: resolvedYear, kind, tmdb_id: tmdbId,
256
+ request_id: `jellyseerr:${existing.id}`,
257
+ approval_state: "already_requested",
258
+ duplicate_of: `jellyseerr:${existing.id}`,
259
+ jellyseerr_status: REQUEST_STATUS[existing.status] ?? "unknown",
260
+ shelf_state: shelf,
261
+ submitted_at: existing.createdAt,
262
+ tvdb_id: existing.media?.tvdbId ?? null,
263
+ sonarr_id: kind === "series" ? (existing.media?.externalServiceId ?? null) : null,
264
+ radarr_id: kind === "movie" ? (existing.media?.externalServiceId ?? null) : null,
265
+ note: shelf === "available" ? "Already on the shelf." : "Already requested; use media_request_status to see why it has not landed.",
266
+ }
267
+ }
268
+
269
+ const payload = { mediaType: kind === "series" ? "tv" : "movie", mediaId: tmdbId }
270
+ if (kind === "series") {
271
+ payload.seasons = a.season_number ? [a.season_number] : "all"
272
+ }
273
+ const created = await seerr("/request", { method: "POST", body: payload })
274
+ return {
275
+ resolved: true, title: resolvedTitle, year: resolvedYear, kind, tmdb_id: tmdbId,
276
+ request_id: `jellyseerr:${created.id}`,
277
+ approval_state: REQUEST_STATUS[created.status] ?? "submitted",
278
+ duplicate_of: null,
279
+ jellyseerr_status: REQUEST_STATUS[created.status] ?? "submitted",
280
+ shelf_state: MEDIA_STATUS[created.media?.status] ?? "processing",
281
+ submitted_at: created.createdAt,
282
+ tvdb_id: created.media?.tvdbId ?? null,
283
+ sonarr_id: kind === "series" ? (created.media?.externalServiceId ?? null) : null,
284
+ radarr_id: kind === "movie" ? (created.media?.externalServiceId ?? null) : null,
285
+ seasons_requested: kind === "series" ? (a.season_number ? [a.season_number] : "all") : null,
286
+ note: "Submitted. The indexer hunt runs next; media_request_status will show whether a release was grabbed.",
287
+ }
288
+ }
289
+
290
+ // -------------------------------------------------- acquisition chain health
291
+
292
+ // The failure that stalled this house for three weeks: Prowlarr had zero indexers,
293
+ // so Sonarr reported "0 active indexers" and every request sat at "requested"
294
+ // forever. That must be a first-class, named answer — never something the agent
295
+ // has to infer from an empty release list.
296
+ async function chainHealth() {
297
+ const out = { indexers: { total: 0, enabled: 0, failing: [], ok: [] }, apps: [], problems: [], healthy: true }
298
+ try {
299
+ const list = await prowlarr("/indexer")
300
+ out.indexers.total = list.length
301
+ out.indexers.enabled = list.filter((i) => i.enable).length
302
+ const status = await prowlarr("/indexerstatus").catch(() => [])
303
+ const disabled = new Set((status ?? []).map((s) => s.indexerId))
304
+ for (const i of list) {
305
+ if (!i.enable) continue
306
+ ;(disabled.has(i.id) ? out.indexers.failing : out.indexers.ok).push(i.name)
307
+ }
308
+ const health = await prowlarr("/health").catch(() => [])
309
+ for (const h of health ?? []) {
310
+ if (h.type === "error" && /indexer/i.test(h.source ?? "")) out.problems.push({ service: "prowlarr", message: h.message })
311
+ }
312
+ out.apps = (await prowlarr("/applications").catch(() => [])).map((x) => ({ name: x.name, sync: x.syncLevel }))
313
+ } catch (e) {
314
+ out.problems.push({ service: "prowlarr", message: e.message })
315
+ }
316
+
317
+ if (out.indexers.total === 0) {
318
+ out.healthy = false
319
+ out.problems.push({ service: "prowlarr", message: "Prowlarr has zero indexers configured. Nothing can ever be found or downloaded until indexers are added." })
320
+ } else if (out.indexers.ok.length === 0) {
321
+ out.healthy = false
322
+ out.problems.push({ service: "prowlarr", message: "Every enabled indexer is currently failing. Searches will return nothing." })
323
+ }
324
+ if (out.apps.length === 0) {
325
+ out.healthy = false
326
+ out.problems.push({ service: "prowlarr", message: "Prowlarr is not syncing to Sonarr/Radarr, so they have no working indexers." })
327
+ }
328
+
329
+ for (const [name, call] of [["sonarr", sonarr], ["radarr", radarr]]) {
330
+ try {
331
+ const h = await call("/health")
332
+ for (const item of h ?? []) {
333
+ if (/indexer/i.test(item.source ?? "") && (item.type === "error" || /unavailable/i.test(item.message ?? ""))) {
334
+ out.problems.push({ service: name, message: item.message })
335
+ if (/all/i.test(item.message ?? "")) out.healthy = false
336
+ }
337
+ }
338
+ } catch (e) { out.problems.push({ service: name, message: e.message }) }
339
+ }
340
+ return out
341
+ }
342
+
343
+ // --------------------------------------------------- tool: request status
344
+
345
+ async function resolveTarget(a) {
346
+ if (a.tmdb_id) {
347
+ const r = await findExistingRequest(a.tmdb_id, a.kind)
348
+ if (r?.hasRequest) return r
349
+ }
350
+ if (a.request_id) {
351
+ const id = Number(String(a.request_id).replace(/^jellyseerr:/, ""))
352
+ if (Number.isFinite(id)) {
353
+ try { return await seerr(`/request/${id}`) } catch { /* fall through to title */ }
354
+ }
355
+ }
356
+ if (a.title) {
357
+ const body = await seerr("/search", { query: { query: a.title, page: 1 } })
358
+ // Only titles Jellyseerr already knows about can have a request behind them.
359
+ for (const pick of (body.results ?? []).filter((r) => r.mediaInfo)) {
360
+ const found = await findExistingRequest(pick.id, pick.mediaType === "tv" ? "series" : "movie")
361
+ if (found?.hasRequest) return found
362
+ }
363
+ }
364
+ return null
365
+ }
366
+
367
+ async function mediaRequestStatus(a) {
368
+ const request = await resolveTarget(a)
369
+ if (!request) {
370
+ return { found: false, reason: "no_matching_request",
371
+ searched: { request_id: a.request_id ?? null, tmdb_id: a.tmdb_id ?? null, title: a.title ?? null },
372
+ diagnosis: { stuck_stage: "request", stuck_reason: "never_requested",
373
+ likely_fix: "Submit it with media_request.", human_action_required: false } }
374
+ }
375
+
376
+ const kind = request.type === "tv" ? "series" : "movie"
377
+ const media = request.media ?? {}
378
+ const shelf = MEDIA_STATUS[media.status] ?? "unknown"
379
+ const svcId = media.externalServiceId ?? null
380
+
381
+ const result = {
382
+ found: true,
383
+ request: {
384
+ id: `jellyseerr:${request.id}`,
385
+ kind,
386
+ tmdb_id: media.tmdbId ?? null,
387
+ tvdb_id: media.tvdbId ?? null,
388
+ jellyseerr_status: REQUEST_STATUS[request.status] ?? "unknown",
389
+ shelf_state: shelf,
390
+ submitted_at: request.createdAt,
391
+ sonarr_id: kind === "series" ? svcId : null,
392
+ radarr_id: kind === "movie" ? svcId : null,
393
+ },
394
+ search: null, download: null, file: null, chain: null, diagnosis: null,
395
+ }
396
+
397
+ let entity = null, queueRecs = [], history = []
398
+ try {
399
+ if (kind === "series" && svcId) {
400
+ entity = await sonarr(`/series/${svcId}`)
401
+ const q = await sonarr("/queue", { query: { pageSize: 200, includeSeries: true } })
402
+ queueRecs = (q.records ?? []).filter((r) => r.seriesId === svcId)
403
+ history = await sonarr("/history/series", { query: { seriesId: svcId } }).catch(() => [])
404
+ result.request.title = entity.title
405
+ result.file = { on_shelf: (entity.statistics?.episodeFileCount ?? 0) > 0,
406
+ episodes_on_disk: entity.statistics?.episodeFileCount ?? 0,
407
+ episodes_total: entity.statistics?.episodeCount ?? 0,
408
+ size_bytes: entity.statistics?.sizeOnDisk ?? 0,
409
+ path: entity.path ?? null }
410
+ } else if (kind === "movie" && svcId) {
411
+ entity = await radarr(`/movie/${svcId}`)
412
+ const q = await radarr("/queue", { query: { pageSize: 200 } })
413
+ queueRecs = (q.records ?? []).filter((r) => r.movieId === svcId)
414
+ history = await radarr("/history/movie", { query: { movieId: svcId } }).catch(() => [])
415
+ result.request.title = entity.title
416
+ result.file = { on_shelf: Boolean(entity.hasFile), size_bytes: entity.sizeOnDisk ?? 0, path: entity.path ?? null }
417
+ }
418
+ } catch (e) {
419
+ result.chain_error = e.message
420
+ }
421
+
422
+ const grabs = (history ?? []).filter((h) => h.eventType === "grabbed")
423
+ result.search = {
424
+ grab_attempts: grabs.length,
425
+ last_grab_at: grabs[0]?.date ?? null,
426
+ last_grab_title: grabs[0]?.sourceTitle ?? null,
427
+ monitored: entity ? Boolean(entity.monitored) : null,
428
+ }
429
+ result.download = computeDownloadState(queueRecs, Date.now())
430
+
431
+ const chain = await chainHealth()
432
+ result.chain = chain
433
+ result.diagnosis = diagnose({ shelf, result, chain, entity, kind })
434
+ return result
435
+ }
436
+
437
+ // The deterministic core, split out so it can be tested without a live
438
+ // Sonarr/Radarr. `computeDownloadState` and `diagnose` are the whole reason the
439
+ // media surface can answer "why hasn't this downloaded" without the agent
440
+ // guessing, and D-013 (a stalled download read as progress) shipped verified
441
+ // only in production because there was no seam to test them through.
442
+ //
443
+ // A season pack shows up as one queue row per episode, all sharing one
444
+ // downloadId. Summing rows would report a 45 GB pack as 450 GB, so size and
445
+ // "how many downloads" are counted per distinct download, not per row. A
446
+ // torrent that has not moved a byte since it was grabbed is dead, not slow:
447
+ // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
448
+ // progress is measured against age rather than trusted from the row.
449
+ export function computeDownloadState(queueRecs, nowMs) {
450
+ const byDownload = new Map()
451
+ for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
452
+ const downloads = [...byDownload.values()]
453
+ const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
454
+ const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
455
+ const stalledItems = downloads
456
+ .filter((r) => (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && r.added
457
+ && (nowMs - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
458
+ .map((r) => ({
459
+ title: r.title ?? null,
460
+ added_at: r.added ?? null,
461
+ age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000),
462
+ size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
463
+ queue_id: r.id ?? null,
464
+ }))
465
+ return {
466
+ active_downloads: downloads.length,
467
+ active_items: queueRecs.length,
468
+ states: [...new Set(downloads.map((r) => r.status))],
469
+ tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
470
+ errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
471
+ size_left_bytes: sizeLeft,
472
+ size_total_bytes: sizeTotal,
473
+ percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
474
+ stalled: stalledItems.length > 0,
475
+ stalled_items: stalledItems,
476
+ }
477
+ }
478
+
479
+ // The deterministic core. The agent must never have to work this out itself.
480
+ export function diagnose({ shelf, result, chain, entity, kind }) {
481
+ const file = result.file ?? {}
482
+ const onShelf = kind === "series" ? (file.episodes_on_disk ?? 0) > 0 : Boolean(file.on_shelf)
483
+ const complete = kind === "series" ? (file.episodes_on_disk ?? 0) >= (file.episodes_total ?? Infinity) : onShelf
484
+
485
+ if (complete) {
486
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
487
+ summary: "On the shelf and complete. Nothing is stuck." }
488
+ }
489
+ if (result.download.stalled) {
490
+ const items = result.download.stalled_items
491
+ const oldest = Math.max(...items.map((i) => i.age_hours))
492
+ return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
493
+ likely_fix: "blocklist_and_research", human_action_required: false,
494
+ percent_complete: result.download.percent_complete,
495
+ detail: items,
496
+ summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
497
+ }
498
+ if (result.download.active_downloads > 0) {
499
+ const left = result.download.size_left_bytes
500
+ const total = result.download.size_total_bytes
501
+ const gb = (left / 1073741824).toFixed(1)
502
+ const pct = total > 0 ? Math.round(((total - left) / total) * 100) : null
503
+ const what = result.download.active_downloads === 1 && result.download.active_items > 1
504
+ ? `a ${result.download.active_items}-episode pack`
505
+ : `${result.download.active_downloads} download(s)`
506
+ return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
507
+ percent_complete: pct,
508
+ summary: `Downloading now — ${what}${pct === null ? "" : `, ${pct}% done`}, about ${gb} GB to go.` }
509
+ }
510
+ if (result.download.errors.length) {
511
+ return { stuck_stage: "download", stuck_reason: "download_client_error", likely_fix: "retry_or_replace_release",
512
+ human_action_required: false, detail: result.download.errors,
513
+ summary: `The download client reported: ${result.download.errors.join("; ")}` }
514
+ }
515
+ if (!chain.healthy) {
516
+ return { stuck_stage: "indexers", stuck_reason: "acquisition_chain_down",
517
+ likely_fix: "restore_indexers", human_action_required: true,
518
+ detail: chain.problems,
519
+ summary: `Nothing can download: ${chain.problems.map((p) => p.message).join(" ")} This blocks every request, not just this one.` }
520
+ }
521
+ if (entity && entity.monitored === false) {
522
+ return { stuck_stage: "request", stuck_reason: "not_monitored", likely_fix: "enable_monitoring",
523
+ human_action_required: false, summary: "It is not monitored, so no search will ever run for it." }
524
+ }
525
+ if (result.search.grab_attempts === 0) {
526
+ return { stuck_stage: "search", stuck_reason: "no_search_run_or_no_acceptable_release",
527
+ likely_fix: "rescan", human_action_required: false,
528
+ summary: "Indexers are healthy but nothing has ever been grabbed. A fresh search (media_diagnose_and_fix action=rescan) is the next step." }
529
+ }
530
+ if (onShelf && !complete) {
531
+ return { stuck_stage: "import", stuck_reason: "partially_imported", likely_fix: "rescan",
532
+ human_action_required: false,
533
+ summary: `Partly here: ${file.episodes_on_disk} of ${file.episodes_total} episodes. The rest still need grabbing.` }
534
+ }
535
+ return { stuck_stage: "import", stuck_reason: "grabbed_but_not_imported", likely_fix: "force_import_scan",
536
+ human_action_required: false,
537
+ summary: `It was grabbed (${result.search.last_grab_title ?? "unknown release"}) but never landed on the shelf.` }
538
+ }
539
+
540
+ // -------------------------------------------------- tool: diagnose_and_fix
541
+
542
+ async function mediaDiagnoseAndFix(a) {
543
+ const before = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
544
+ if (!before.found) return { result: "no_such_request", before, after: null, human_action_required: true,
545
+ human_action_reason: "Nothing to fix — it was never requested." }
546
+ const action = a.action ?? (before.diagnosis?.likely_fix ?? "rescan")
547
+
548
+ if (before.diagnosis?.stuck_reason === "acquisition_chain_down" && action !== "report_only") {
549
+ return { action, dry_run: Boolean(a.dry_run), result: "rejected_fix_unsafe", before, after: null,
550
+ human_action_required: true,
551
+ human_action_reason: "The acquisition chain itself is down (see before.chain.problems). Re-searching cannot help until indexers are restored." }
552
+ }
553
+ if (a.dry_run) {
554
+ return { action, dry_run: true, result: "would_apply", before,
555
+ would_do: describeAction(action), human_action_required: false }
556
+ }
557
+
558
+ const kind = before.request.kind
559
+ const svcId = kind === "series" ? before.request.sonarr_id : before.request.radarr_id
560
+ if (!svcId) return { action, result: "manual_required", before, after: null, human_action_required: true,
561
+ human_action_reason: "This request is not yet mapped into Sonarr/Radarr, so no search can be triggered." }
562
+
563
+ let commanded = null
564
+ if (action === "rescan" || action === "enable_monitoring" || action === "force_import_scan") {
565
+ if (action === "enable_monitoring") {
566
+ if (kind === "series") await sonarr(`/series/${svcId}`, { method: "PUT", body: { ...(await sonarr(`/series/${svcId}`)), monitored: true } })
567
+ else await radarr(`/movie/${svcId}`, { method: "PUT", body: { ...(await radarr(`/movie/${svcId}`)), monitored: true } })
568
+ }
569
+ const cmd = kind === "series"
570
+ ? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
571
+ : (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
572
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
573
+ } else if (action === "blocklist_and_research") {
574
+ // Removing with blocklist=true is what stops the same dead release being
575
+ // grabbed straight back. The search that follows is then free to pick a
576
+ // different one.
577
+ const stalled = before.download.stalled_items ?? []
578
+ if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
579
+ human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
580
+ for (const item of stalled) {
581
+ if (item.queue_id === null) continue
582
+ const client = kind === "series" ? sonarr : radarr
583
+ await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
584
+ }
585
+ const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
586
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
587
+ } else {
588
+ return { action, result: "unsupported_action", before, after: null, human_action_required: true,
589
+ human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
590
+ }
591
+
592
+ await new Promise((r) => setTimeout(r, 12_000))
593
+ const after = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
594
+ return {
595
+ action, dry_run: false, applied_at: new Date().toISOString(),
596
+ command: { id: commanded?.id ?? null, name: commanded?.name ?? null },
597
+ before: { diagnosis: before.diagnosis, grab_attempts: before.search.grab_attempts, active_downloads: before.download.active_downloads },
598
+ after: { diagnosis: after.diagnosis, grab_attempts: after.search.grab_attempts, active_downloads: after.download.active_downloads },
599
+ result: after.download.active_downloads > before.download.active_downloads ? "grabbed_and_downloading"
600
+ : after.search.grab_attempts > before.search.grab_attempts ? "grabbed"
601
+ : "queued_for_search",
602
+ human_action_required: Boolean(after.diagnosis?.human_action_required),
603
+ human_action_reason: after.diagnosis?.human_action_required ? after.diagnosis.summary : null,
604
+ }
605
+ }
606
+
607
+ function describeAction(action) {
608
+ return {
609
+ rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
610
+ enable_monitoring: "Mark it monitored, then search.",
611
+ force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
612
+ blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
613
+ report_only: "Report only; change nothing.",
614
+ }[action] ?? "Unknown action."
615
+ }
616
+
617
+ // ------------------------------------------------------- tool: play/resolve
618
+
619
+ async function mediaPlayOrResolve(a) {
620
+ const status = await mediaRequestStatus({ request_id: a.request_id, tmdb_id: a.tmdb_id, title: a.title })
621
+ if (!status.found || !status.file) {
622
+ return { playable: false, reason: "not_on_shelf", diagnosis: status.diagnosis ?? null,
623
+ human_action_required: true, human_action_reason: "It is not on the shelf yet." }
624
+ }
625
+ const onShelf = status.request.kind === "series" ? (status.file.episodes_on_disk ?? 0) > 0 : status.file.on_shelf
626
+ return {
627
+ playable: Boolean(onShelf),
628
+ title: status.request.title ?? null,
629
+ kind: status.request.kind,
630
+ tmdb_id: status.request.tmdb_id,
631
+ episodes_on_disk: status.file.episodes_on_disk ?? null,
632
+ episodes_total: status.file.episodes_total ?? null,
633
+ path: status.file.path,
634
+ size_bytes: status.file.size_bytes,
635
+ play_url: onShelf ? `${JELLYFIN_WEB}/web/index.html#!/search.html?query=${encodeURIComponent(status.request.title ?? "")}` : null,
636
+ diagnosis: status.diagnosis,
637
+ }
638
+ }
639
+
640
+ // --------------------------------------------------------- tool: chain health
641
+
642
+ async function mediaChainHealth() {
643
+ const chain = await chainHealth()
644
+ return {
645
+ healthy: chain.healthy,
646
+ indexers: chain.indexers,
647
+ app_sync: chain.apps,
648
+ problems: chain.problems,
649
+ summary: chain.healthy
650
+ ? `Acquisition chain healthy: ${chain.indexers.ok.length} of ${chain.indexers.enabled} enabled indexers responding${chain.indexers.failing.length ? `, failing: ${chain.indexers.failing.join(", ")}` : ""}.`
651
+ : `Acquisition chain DOWN. ${chain.problems.map((p) => p.message).join(" ")}`,
652
+ }
653
+ }
654
+
655
+ // ------------------------------------------------------------------- schema
656
+
657
+ const TOOLS = [
658
+ {
659
+ name: "media_search",
660
+ description: "Search or browse the household media catalogue. Use `query` for a title lookup; 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.",
661
+ inputSchema: { type: "object", properties: {
662
+ query: { type: "string", description: "Title or free text. Omit to browse by filters." },
663
+ kind: { type: "string", enum: ["movie", "series", "any"], description: "Default any." },
664
+ year_range: { type: "array", items: { type: "number" }, minItems: 2, maxItems: 2, description: "[minYear, maxYear]" },
665
+ genres: { type: "array", items: { type: "string" }, description: "TMDB genre names, e.g. ['Romance','Drama']." },
666
+ keywords: { type: "array", items: { type: "string" }, description: "TMDB keywords, e.g. ['autumn','small town']. Best lever for mood/vibe requests." },
667
+ on_shelf: { type: "boolean", description: "true = only what we already have; false = only what we do not; omit = both." },
668
+ limit: { type: "number", description: "1-20, default 12." },
669
+ sort: { type: "string", enum: ["relevance", "release_year"] },
670
+ } },
671
+ },
672
+ {
673
+ name: "media_request",
674
+ 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.",
675
+ inputSchema: { type: "object", properties: {
676
+ tmdb_id: { type: "number" },
677
+ title: { type: "string" },
678
+ year: { type: "number" },
679
+ kind: { type: "string", enum: ["movie", "series"] },
680
+ season_number: { type: "number", description: "Series only. Omit to request all seasons." },
681
+ } },
682
+ },
683
+ {
684
+ name: "media_request_status",
685
+ 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.",
686
+ inputSchema: { type: "object", properties: {
687
+ request_id: { type: "string", description: "e.g. jellyseerr:42" },
688
+ tmdb_id: { type: "number" },
689
+ title: { type: "string" },
690
+ } },
691
+ },
692
+ {
693
+ name: "media_diagnose_and_fix",
694
+ 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.",
695
+ inputSchema: { type: "object", properties: {
696
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
697
+ action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
698
+ dry_run: { type: "boolean" },
699
+ } },
700
+ },
701
+ {
702
+ name: "media_chain_health",
703
+ 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.",
704
+ inputSchema: { type: "object", properties: {} },
705
+ },
706
+ {
707
+ name: "media_play_or_resolve",
708
+ 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.",
709
+ inputSchema: { type: "object", properties: {
710
+ request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
711
+ } },
712
+ },
713
+ ]
714
+
715
+ const HANDLERS = {
716
+ media_search: mediaSearch,
717
+ media_request: mediaRequest,
718
+ media_request_status: mediaRequestStatus,
719
+ media_diagnose_and_fix: mediaDiagnoseAndFix,
720
+ media_chain_health: mediaChainHealth,
721
+ media_play_or_resolve: mediaPlayOrResolve,
722
+ }
723
+
724
+ // ------------------------------------------------------------ jsonrpc stdio
725
+
726
+ function send(msg) { process.stdout.write(JSON.stringify(msg) + "\n") }
727
+
728
+ async function handle(msg) {
729
+ const { id, method, params } = msg
730
+ if (method === "initialize") {
731
+ return send({ jsonrpc: "2.0", id, result: {
732
+ protocolVersion: "2024-11-05",
733
+ capabilities: { tools: {} },
734
+ serverInfo: { name: "sanctuary-media", version: "1.0.0" },
735
+ } })
736
+ }
737
+ if (method === "initialized" || method === "notifications/initialized") return
738
+ if (method === "tools/list") return send({ jsonrpc: "2.0", id, result: { tools: TOOLS } })
739
+ if (method === "tools/call") {
740
+ const fn = HANDLERS[params?.name]
741
+ if (!fn) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: `Unknown tool: ${params?.name}` }] } })
742
+ if (credError) {
743
+ const payload = { error: "credentials_unavailable", message: credError, human_action_required: true,
744
+ human_action_reason: credError }
745
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
746
+ }
747
+ try {
748
+ const out = await fn(params.arguments ?? {})
749
+ return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
750
+ } catch (e) {
751
+ const payload = { error: e.code ?? "tool_failed", message: e.message,
752
+ human_action_required: true,
753
+ human_action_reason: `The media tool could not complete: ${e.message}. Report this failure rather than guessing the answer.` }
754
+ return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
755
+ }
756
+ }
757
+ if (id !== undefined) send({ jsonrpc: "2.0", id, error: { code: -32601, message: `Method not found: ${method}` } })
758
+ }
759
+
760
+ let buf = ""
761
+ let inFlight = 0
762
+ let stdinClosed = false
763
+
764
+ // Exit only when stdin is closed AND nothing is still running. Exiting on 'end'
765
+ // alone drops in-flight tool calls, which silently truncates a slow answer.
766
+ function exitWhenIdle() {
767
+ if (stdinClosed && inFlight === 0) process.exit(0)
768
+ }
769
+
770
+ // Only run the stdio server when invoked directly (node media-mcp.mjs, exactly
771
+ // how agent.json launches it). Guarding this lets the test suite import the
772
+ // module for computeDownloadState/diagnose without attaching to stdin or exiting
773
+ // the test process. The launch is a plain node <abs-path>, so argv[1] is the
774
+ // file's own path and this comparison holds.
775
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
776
+ process.stdin.setEncoding("utf8")
777
+ process.stdin.on("data", (chunk) => {
778
+ buf += chunk
779
+ let nl
780
+ while ((nl = buf.indexOf("\n")) >= 0) {
781
+ const line = buf.slice(0, nl).trim()
782
+ buf = buf.slice(nl + 1)
783
+ if (!line) continue
784
+ let msg
785
+ try { msg = JSON.parse(line) } catch { continue }
786
+ inFlight += 1
787
+ handle(msg)
788
+ .catch((e) => {
789
+ if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } })
790
+ })
791
+ .finally(() => { inFlight -= 1; exitWhenIdle() })
792
+ }
793
+ })
794
+ process.stdin.on("end", () => { stdinClosed = true; exitWhenIdle() })
795
+ }