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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
3
3
  "versions": [
4
+ {
5
+ "version": "0.1.0-alpha.851",
6
+ "changes": [
7
+ "The Butler can fix release-numbering mismatches himself: typed Sonarr/Radarr release search, override grab, blocklist and manual import tools, scene numbering in media_episodes, and a free-text indexer search",
8
+ "media_blocklist_stalled treats a download unfinished after 72 hours as stuck",
9
+ "The engine asks once when a settle answer drops the reply written beside it, so Telegram and A2A get the full reply (never on the last provider iteration). When the only matching release has no seeders, the Butler still grabs it with the right mapping and says it may not finish"
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.850",
6
14
  "changes": [
@@ -14,7 +22,8 @@
14
22
  "The Butler can start a Sonarr series or Radarr movie search now (media_search_now) and blocklist-and-research stalled queue items (media_blocklist_stalled) without a Jellyseerr request id.",
15
23
  "Media title lookup matches partial and misspelled titles across Sonarr, Radarr and Jellyfin, and falls back to the TVDB/TMDB lookup before saying a title is not in the library.",
16
24
  "The Butler's always-loaded psyche now says to answer from primary sources rather than memory, re-read live state, label anything unreachable as unverified, and route missing tools to Claude Code instead of asking Ari to click.",
17
- "media_blocklist_stalled removes each season pack once, reads the item's whole queue, and reports a failed delete without skipping the re-search; media_search_now no longer searches a lone typo-distance title match."
25
+ "media_blocklist_stalled removes each season pack once, reads the item's whole queue, and reports a failed delete without skipping the re-search; media_search_now no longer searches a lone typo-distance title match.",
26
+ "The Butler can fix a release-numbering mismatch itself: media_episodes lists a series' episodes, media_release_search shows what indexers offer and how Sonarr parsed it, media_release_grab grabs a release with an explicit episode mapping, media_blocklist lists and removes blocklist entries, and media_manual_import maps downloaded files to episodes by title."
18
27
  ]
19
28
  },
20
29
  {
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.850",
2
+ "runtimeVersion": "0.1.0-alpha.851",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -36,10 +36,12 @@ const JELLYFIN_WEB = cred?.jellyfinWebUrl ?? "https://media.mendelow.cloud"
36
36
  const JELLYFIN = cred?.jellyfin ?? { url: "", apiKey: "" }
37
37
 
38
38
  const TIMEOUT_MS = 45_000
39
+ // Interactive release search queries every indexer live and can take minutes.
40
+ const SEARCH_TIMEOUT_MS = 180_000
39
41
 
40
42
  // ---------------------------------------------------------------- http helpers
41
43
 
42
- async function req(base, path, { key, header = "X-Api-Key", method = "GET", body, query } = {}) {
44
+ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body, query, timeoutMs = TIMEOUT_MS } = {}) {
43
45
  let url = `${base}${path}`
44
46
  if (query) {
45
47
  const parts = Object.entries(query)
@@ -52,7 +54,7 @@ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body
52
54
  if (body !== undefined) headers["Content-Type"] = "application/json"
53
55
  let res
54
56
  try {
55
- res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(TIMEOUT_MS) })
57
+ res = await fetch(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) })
56
58
  } catch (e) {
57
59
  throw new ServiceError(`${serviceOf(base)}_unreachable`, `${serviceOf(base)} did not respond: ${e.name === "TimeoutError" ? "timeout" : e.message}`)
58
60
  }
@@ -587,9 +589,16 @@ export async function mediaRequestStatus(a) {
587
589
  // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
588
590
  // progress is measured against age rather than trusted from the row.
589
591
  // The one stall rule: nothing downloaded yet, and older than the stall window.
592
+ // A download that never started, or one still unfinished days later (e.g. stuck at
593
+ // 99.85% with no seeders for the last piece), will not finish on its own.
594
+ const STUCK_INCOMPLETE_AFTER_HOURS = 72
595
+
590
596
  export function isStalledRow(r, nowMs) {
591
- return (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && Boolean(r.added)
592
- && (nowMs - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS
597
+ if (!((r.size ?? 0) > 0) || !r.added) return false
598
+ const ageHours = (nowMs - Date.parse(r.added)) / 3_600_000
599
+ const left = r.sizeleft ?? 0
600
+ if (left >= r.size) return ageHours >= STALL_AFTER_HOURS
601
+ return left > 0 && ageHours >= STUCK_INCOMPLETE_AFTER_HOURS
593
602
  }
594
603
 
595
604
  export function computeDownloadState(queueRecs, nowMs) {
@@ -907,6 +916,223 @@ export async function mediaBlocklistStalled(a) {
907
916
  return { removed, ...(failed.length ? { failed } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
908
917
  }
909
918
 
919
+ // ------------------------------- tools: episodes / releases / blocklist / import
920
+
921
+ // When an automatic search finds nothing, the Butler works the release by hand:
922
+ // list the episodes, read what the indexers offer and how Sonarr parsed it, grab
923
+ // the right one with an explicit episode mapping, and map downloaded files by title.
924
+ // All scoped to series/movies already in the library; nothing here adds one.
925
+
926
+ const kindOk = (a) => a.kind === "series" || a.kind === "movie"
927
+ const invalidKind = () => ({ result: "invalid_kind", message: "kind must be 'series' or 'movie'." })
928
+ const idKeyOf = (kind) => (kind === "series" ? "seriesId" : "movieId")
929
+ const code2 = (n) => String(n).padStart(2, "0")
930
+ const sxe = (e) => `S${code2(e.seasonNumber)}E${code2(e.episodeNumber)}`
931
+
932
+ async function seriesEpisodes(seriesId, seasonNumber) {
933
+ const list = await sonarr("/episode", { query: { seriesId, seasonNumber } })
934
+ return Array.isArray(list) ? list : []
935
+ }
936
+
937
+ export async function mediaEpisodes(a) {
938
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
939
+ const seriesId = Number(a.service_id)
940
+ const eps = await seriesEpisodes(seriesId, a.season_number)
941
+ return {
942
+ series_id: seriesId,
943
+ season_number: a.season_number ?? null,
944
+ count: eps.length,
945
+ episodes: eps.map((e) => ({ episode_id: e.id, code: sxe(e), season: e.seasonNumber, episode: e.episodeNumber, title: e.title ?? null,
946
+ air_date: e.airDate ?? null, has_file: Boolean(e.hasFile), monitored: Boolean(e.monitored),
947
+ // Release names follow scene (often streaming) numbering; Sonarr's own mapping, when it has one.
948
+ scene_code: Number.isInteger(e.sceneSeasonNumber) && Number.isInteger(e.sceneEpisodeNumber)
949
+ ? `S${String(e.sceneSeasonNumber).padStart(2, "0")}E${String(e.sceneEpisodeNumber).padStart(2, "0")}` : null })),
950
+ }
951
+ }
952
+
953
+ // Free-text search across every Prowlarr indexer: discovery only, it grabs nothing.
954
+ // Sonarr's own search only asks for the season/episode codes it expects; this finds
955
+ // what exists under any name (another numbering, a "Season 2 (2020)" pack, an episode title).
956
+ export async function mediaIndexerSearch(a) {
957
+ const query = typeof a.query === "string" ? a.query.trim() : ""
958
+ if (!query) return { result: "query_required" }
959
+ const raw = await prowlarr("/search", { query: { query, type: "search", limit: 100 }, timeoutMs: SEARCH_TIMEOUT_MS })
960
+ const limit = Math.min(Math.max(Number(a.limit) || 25, 1), 50)
961
+ const rows = (Array.isArray(raw) ? raw : [])
962
+ .map((r) => ({ indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null, seeders: r.seeders ?? null,
963
+ age_days: r.age ?? null, protocol: r.protocol ?? null }))
964
+ .sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1))
965
+ return { query, count: rows.length, releases: rows.slice(0, limit),
966
+ note: "Discovery only. To grab, run media_release_search for the item so Sonarr/Radarr caches the release, then media_release_grab with an explicit episode mapping." }
967
+ }
968
+
969
+ // The last search's compact rows, keyed by kind + guid, so a grab can check what Sonarr parsed.
970
+ // Sonarr/Radarr forget a search's releases after about 30 minutes; expire ours a little sooner.
971
+ const releaseCache = new Map()
972
+ const RELEASE_CACHE_TTL_MS = 25 * 60_000
973
+ const RELEASE_CACHE_MAX = 500
974
+ const MAX_EPISODE_SEARCHES = 6
975
+
976
+ function cacheRelease(key, row, nowMs = Date.now()) {
977
+ for (const [k, v] of releaseCache) if (nowMs - v.at > RELEASE_CACHE_TTL_MS) releaseCache.delete(k)
978
+ releaseCache.delete(key)
979
+ releaseCache.set(key, { ...row, at: nowMs })
980
+ while (releaseCache.size > RELEASE_CACHE_MAX) releaseCache.delete(releaseCache.keys().next().value)
981
+ }
982
+
983
+ function releaseRow(kind, r) {
984
+ const rejections = Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : []
985
+ const row = {
986
+ guid: r.guid, indexer_id: r.indexerId ?? null, indexer: r.indexer ?? null, title: r.title ?? null, size_bytes: r.size ?? null,
987
+ seeders: r.seeders ?? null, age_days: r.age ?? null, quality: r.quality?.quality?.name ?? null, protocol: r.protocol ?? null,
988
+ rejected: Boolean(r.rejected), rejections, blocklisted: rejections.some((x) => /blocklist/i.test(x)),
989
+ }
990
+ if (kind === "series") {
991
+ return { ...row, full_season: Boolean(r.fullSeason), parsed_season: r.seasonNumber ?? null, parsed_episodes: r.episodeNumbers ?? [],
992
+ mapped_episodes: (r.mappedEpisodeInfo ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
993
+ mapped_series_id: r.mappedSeriesId ?? null }
994
+ }
995
+ return { ...row, mapped_movie_id: r.mappedMovieId ?? null }
996
+ }
997
+
998
+ export async function mediaReleaseSearch(a) {
999
+ if (!kindOk(a)) return invalidKind()
1000
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1001
+ const serviceId = Number(a.service_id)
1002
+ let raw = []
1003
+ if (a.kind === "movie") {
1004
+ raw = await radarr("/release", { query: { movieId: serviceId }, timeoutMs: SEARCH_TIMEOUT_MS })
1005
+ } else if (Array.isArray(a.episode_ids) && a.episode_ids.length) {
1006
+ // Sonarr searches one episode per call.
1007
+ if (a.episode_ids.length > MAX_EPISODE_SEARCHES) return { result: "too_many_episode_ids", max: MAX_EPISODE_SEARCHES, message: "Search the season instead, or fewer episodes at a time." }
1008
+ for (const id of a.episode_ids) raw.push(...await sonarr("/release", { query: { episodeId: id }, timeoutMs: SEARCH_TIMEOUT_MS }))
1009
+ } else if (a.season_number !== undefined && a.season_number !== null) {
1010
+ raw = await sonarr("/release", { query: { seriesId: serviceId, seasonNumber: a.season_number }, timeoutMs: SEARCH_TIMEOUT_MS })
1011
+ } else return { result: "season_number_or_episode_ids_required" }
1012
+ const seen = new Set()
1013
+ let rows = (Array.isArray(raw) ? raw : []).filter((r) => (seen.has(r.guid) ? false : seen.add(r.guid))).map((r) => releaseRow(a.kind, r))
1014
+ const total = rows.length
1015
+ if (a.query) {
1016
+ const q = String(a.query).toLowerCase()
1017
+ rows = rows.filter((r) => (r.title ?? "").toLowerCase().includes(q))
1018
+ }
1019
+ rows.sort((x, y) => Number(y.seeders ?? 0) - Number(x.seeders ?? 0))
1020
+ const limit = Math.min(Math.max(Number(a.limit) || 40, 1), 100)
1021
+ for (const r of rows) cacheRelease(`${a.kind}:${r.guid}`, { ...r, service_id: serviceId, season_number: a.season_number ?? null })
1022
+ return { kind: a.kind, service_id: serviceId, total_found: total, matched: rows.length, releases: rows.slice(0, limit) }
1023
+ }
1024
+
1025
+ export async function mediaReleaseGrab(a) {
1026
+ if (!kindOk(a)) return invalidKind()
1027
+ if (!a.guid || a.indexer_id === undefined || a.indexer_id === null) return { result: "guid_and_indexer_id_required" }
1028
+ const cached = releaseCache.get(`${a.kind}:${a.guid}`)
1029
+ if (!cached || Date.now() - cached.at > RELEASE_CACHE_TTL_MS) {
1030
+ return { result: "search_first", message: "Run media_release_search first; a release can only be grabbed from a recent search (within 25 minutes)." }
1031
+ }
1032
+ if (cached.indexer_id !== null && Number(a.indexer_id) !== Number(cached.indexer_id)) return { result: "indexer_mismatch", expected_indexer_id: cached.indexer_id }
1033
+ const body = { guid: a.guid, indexerId: Number(a.indexer_id) }
1034
+ const hasEpisodes = Array.isArray(a.episode_ids) && a.episode_ids.length > 0
1035
+ if (a.kind === "series") {
1036
+ if (!hasEpisodes) {
1037
+ // A pack Sonarr parsed as another season would be mapped to the wrong episodes.
1038
+ const target = a.season_number ?? cached.season_number
1039
+ if (cached.full_season && (target === undefined || target === null || Number(target) !== Number(cached.parsed_season))) {
1040
+ return { result: "season_pack_mismatch", parsed_season: cached.parsed_season, target_season: target ?? null, title: cached.title,
1041
+ message: "Sonarr parsed this season pack as a different season than the target. Pass series_id and episode_ids to say which episodes it holds." }
1042
+ }
1043
+ } else {
1044
+ if (a.series_id === undefined || a.series_id === null) return { result: "series_id_required_with_episode_ids" }
1045
+ const seriesId = Number(a.series_id)
1046
+ if (seriesId !== Number(cached.service_id)) return { result: "series_mismatch", searched_series_id: cached.service_id, message: "This release came from a search for another series." }
1047
+ const own = new Set((await seriesEpisodes(seriesId)).map((e) => e.id))
1048
+ const foreign = a.episode_ids.filter((id) => !own.has(id))
1049
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1050
+ Object.assign(body, { seriesId, episodeIds: a.episode_ids.map(Number), shouldOverride: true })
1051
+ if (a.download_client_id !== undefined && a.download_client_id !== null) body.downloadClientId = Number(a.download_client_id)
1052
+ }
1053
+ }
1054
+ const res = await arrFor(a.kind)("/release", { method: "POST", body })
1055
+ return { result: "grabbed", kind: a.kind, title: cached.title, request: body,
1056
+ response: { approved: res?.approved ?? null, rejected: res?.rejected ?? null, rejections: res?.rejections ?? [], download_id: res?.downloadId ?? null } }
1057
+ }
1058
+
1059
+ const BLOCKLIST_PAGE_SIZE = 100
1060
+ const BLOCKLIST_MAX_PAGES = 20
1061
+
1062
+ async function itemBlocklist(kind, serviceId) {
1063
+ const idKey = idKeyOf(kind)
1064
+ const filter = kind === "series" ? { seriesIds: serviceId } : { movieIds: serviceId }
1065
+ const rows = []
1066
+ for (let page = 1; page <= BLOCKLIST_MAX_PAGES; page++) {
1067
+ const q = await arrFor(kind)("/blocklist", { query: { ...filter, page, pageSize: BLOCKLIST_PAGE_SIZE } })
1068
+ const records = q?.records ?? []
1069
+ rows.push(...records)
1070
+ if (!records.length || !(Number(q?.totalRecords) > page * BLOCKLIST_PAGE_SIZE)) return rows.filter((r) => r[idKey] === serviceId)
1071
+ }
1072
+ const own = rows.filter((r) => r[idKey] === serviceId)
1073
+ own.truncated = true
1074
+ return own
1075
+ }
1076
+
1077
+ const blocklistReport = (rows) => rows.map((r) => ({ blocklist_id: r.id, title: r.sourceTitle ?? null, date: r.date ?? null, indexer: r.indexer ?? null,
1078
+ quality: r.quality?.quality?.name ?? null, episode_ids: r.episodeIds ?? [], message: r.message ?? null }))
1079
+
1080
+ export async function mediaBlocklist(a) {
1081
+ if (!kindOk(a)) return invalidKind()
1082
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1083
+ const serviceId = Number(a.service_id)
1084
+ const rows = await itemBlocklist(a.kind, serviceId)
1085
+ if (!Array.isArray(a.remove_ids) || !a.remove_ids.length) {
1086
+ return { kind: a.kind, service_id: serviceId, count: rows.length, ...(rows.truncated ? { truncated: true } : {}), entries: blocklistReport(rows) }
1087
+ }
1088
+ const own = new Set(rows.map((r) => r.id))
1089
+ const foreign = a.remove_ids.filter((id) => !own.has(id))
1090
+ if (foreign.length) return { result: "blocklist_id_not_for_item", foreign_blocklist_ids: foreign, entries: blocklistReport(rows) }
1091
+ await arrFor(a.kind)("/blocklist/bulk", { method: "DELETE", body: { ids: a.remove_ids.map(Number) } })
1092
+ const left = await itemBlocklist(a.kind, serviceId)
1093
+ return { result: "removed", removed: a.remove_ids, count: left.length, entries: blocklistReport(left) }
1094
+ }
1095
+
1096
+ async function previewRows(seriesId, downloadId) {
1097
+ const rows = await sonarr("/manualimport", { query: { downloadId, seriesId, filterExistingFiles: false } })
1098
+ return Array.isArray(rows) ? rows : []
1099
+ }
1100
+
1101
+ const previewReport = (rows) => rows.map((r) => ({ path: r.path, name: r.name ?? null, parsed_season: r.seasonNumber ?? null,
1102
+ mapped_episodes: (r.episodes ?? []).map((e) => ({ episode_id: e.id ?? null, code: sxe(e), title: e.title ?? null })),
1103
+ quality: r.quality?.quality?.name ?? null, rejections: (r.rejections ?? []).map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) }))
1104
+
1105
+ export async function mediaManualImport(a) {
1106
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
1107
+ if (!a.download_id) return { result: "download_id_required" }
1108
+ const seriesId = Number(a.service_id)
1109
+ const rows = await previewRows(seriesId, a.download_id)
1110
+ if (a.mode !== "import") return { mode: "preview", series_id: seriesId, download_id: a.download_id, files: previewReport(rows) }
1111
+ if (!Array.isArray(a.files) || !a.files.length || a.files.some((f) => !f || typeof f.path !== "string")) return { result: "files_required" }
1112
+ const paths = a.files.map((f) => f.path)
1113
+ if (new Set(paths).size !== paths.length) return { result: "duplicate_path" }
1114
+ const byPath = new Map(rows.map((r) => [r.path, r]))
1115
+ const unknown = a.files.filter((f) => !byPath.has(f.path)).map((f) => f.path)
1116
+ if (unknown.length) return { result: "path_not_in_preview", paths: unknown, files: previewReport(rows) }
1117
+ const seriesEps = await seriesEpisodes(seriesId)
1118
+ const own = new Set(seriesEps.map((e) => e.id))
1119
+ const foreign = a.files.flatMap((f) => (f.episode_ids ?? []).filter((id) => !own.has(id)))
1120
+ if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1121
+ const empty = a.files.filter((f) => !Array.isArray(f.episode_ids) || !f.episode_ids.length).map((f) => f.path)
1122
+ if (empty.length) return { result: "episode_ids_required", paths: empty }
1123
+ const targets = a.files.flatMap((f) => f.episode_ids.map(Number))
1124
+ if (new Set(targets).size !== targets.length) return { result: "episode_id_on_two_files" }
1125
+ const files = a.files.map((f) => {
1126
+ const r = byPath.get(f.path)
1127
+ return { path: f.path, seriesId, episodeIds: f.episode_ids.map(Number), quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1128
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId: a.download_id }
1129
+ })
1130
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1131
+ // Episodes that already had a file: Sonarr treats these imports as replacements.
1132
+ const replacing = seriesEps.filter((e) => e.hasFile && targets.includes(e.id)).map((e) => ({ episode_id: e.id, code: sxe(e) }))
1133
+ return { mode: "import", imported: a.files.map((f) => ({ path: f.path, episode_ids: f.episode_ids })), ...(replacing.length ? { replacing } : {}), command: { id: c?.id ?? null, name: c?.name ?? "ManualImport", status: c?.status ?? null } }
1134
+ }
1135
+
910
1136
  // ------------------------------------------------------------------- schema
911
1137
 
912
1138
  const TOOLS = [
@@ -985,6 +1211,66 @@ const TOOLS = [
985
1211
  research: { type: "boolean", description: "Search again afterwards. Default true." },
986
1212
  }, required: ["kind", "service_id"] },
987
1213
  },
1214
+ {
1215
+ name: "media_episodes",
1216
+ description: "List a Sonarr series' episodes (optionally one season): episode_id, SxxEyy code, scene_code (the numbering release names use, when Sonarr has a mapping), title, air date, has_file. Use it to see what the TVDB numbering calls each episode before searching for releases by hand.",
1217
+ inputSchema: { type: "object", properties: {
1218
+ service_id: { type: "number", description: "Sonarr series id." },
1219
+ season_number: { type: "number" },
1220
+ }, required: ["service_id"] },
1221
+ },
1222
+ {
1223
+ name: "media_indexer_search",
1224
+ description: "Free-text search across every indexer (Prowlarr), sorted by seeders. Discovery only, it grabs nothing. Use it when Sonarr/Radarr's own search finds nothing: try the scene code from media_episodes, 'Season N', the year, or an episode title, to learn whether any release exists and how it is named. Zero seeders means it cannot download right now.",
1225
+ inputSchema: { type: "object", properties: {
1226
+ query: { type: "string" },
1227
+ limit: { type: "number", description: "1-50, default 25." },
1228
+ }, required: ["query"] },
1229
+ },
1230
+ {
1231
+ name: "media_release_search",
1232
+ description: "Interactive release search for a series or movie already in the library: what the indexers offer and how Sonarr/Radarr parsed each release (full_season, parsed_season, parsed_episodes, mapped_episodes), whether it is rejected and why, and whether it is blocklisted. Series: give season_number or episode_ids. Can take a minute or more. `query` filters returned titles. Use it when the automatic search found nothing, then grab with media_release_grab.",
1233
+ inputSchema: { type: "object", properties: {
1234
+ kind: { type: "string", enum: ["series", "movie"] },
1235
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
1236
+ season_number: { type: "number" },
1237
+ episode_ids: { type: "array", items: { type: "number" } },
1238
+ query: { type: "string", description: "Case-insensitive text the release title must contain." },
1239
+ limit: { type: "number", description: "1-100, default 40." },
1240
+ }, required: ["kind", "service_id"] },
1241
+ },
1242
+ {
1243
+ name: "media_release_grab",
1244
+ description: "Grab one release from the last media_release_search by guid and indexer_id. For a release Sonarr mis-parsed (streaming numbering vs TVDB), pass series_id plus episode_ids to override the mapping; episode_ids must belong to the series. Without episode_ids, a season pack that Sonarr parsed as a different season than season_number is refused ('season_pack_mismatch'). Returns the exact request sent and Sonarr's answer.",
1245
+ inputSchema: { type: "object", properties: {
1246
+ kind: { type: "string", enum: ["series", "movie"] },
1247
+ guid: { type: "string" },
1248
+ indexer_id: { type: "number" },
1249
+ series_id: { type: "number", description: "Required with episode_ids." },
1250
+ episode_ids: { type: "array", items: { type: "number" }, description: "Episodes this release really holds." },
1251
+ season_number: { type: "number", description: "Target season, checked against a season pack's parsed season when episode_ids is omitted." },
1252
+ download_client_id: { type: "number" },
1253
+ }, required: ["kind", "guid", "indexer_id"] },
1254
+ },
1255
+ {
1256
+ name: "media_blocklist",
1257
+ description: "List the blocklist for a series or movie in Sonarr/Radarr, and optionally remove entries (remove_ids) so the release can be grabbed again. Each id must belong to the item; a foreign id removes nothing.",
1258
+ inputSchema: { type: "object", properties: {
1259
+ kind: { type: "string", enum: ["series", "movie"] },
1260
+ service_id: { type: "number" },
1261
+ remove_ids: { type: "array", items: { type: "number" } },
1262
+ }, required: ["kind", "service_id"] },
1263
+ },
1264
+ {
1265
+ name: "media_manual_import",
1266
+ description: "Map downloaded files to episodes by hand when their names carry the wrong numbering (Sonarr only). mode 'preview' lists the files of a download with the parsed and mapped episodes and rejections. mode 'import' takes files [{path, episode_ids}]: paths must come from that download's preview and episode_ids must belong to the series; quality and languages come from the preview. Match files to episodes by title using media_episodes.",
1267
+ inputSchema: { type: "object", properties: {
1268
+ mode: { type: "string", enum: ["preview", "import"], description: "Default preview." },
1269
+ service_id: { type: "number", description: "Sonarr series id." },
1270
+ download_id: { type: "string", description: "Download client id of the completed download (queue row downloadId)." },
1271
+ files: { type: "array", items: { type: "object", properties: { path: { type: "string" }, episode_ids: { type: "array", items: { type: "number" } } }, required: ["path", "episode_ids"] } },
1272
+ }, required: ["service_id", "download_id"] },
1273
+ },
988
1274
  ]
989
1275
 
990
1276
  const HANDLERS = {
@@ -996,6 +1282,12 @@ const HANDLERS = {
996
1282
  media_play_or_resolve: mediaPlayOrResolve,
997
1283
  media_search_now: mediaSearchNow,
998
1284
  media_blocklist_stalled: mediaBlocklistStalled,
1285
+ media_episodes: mediaEpisodes,
1286
+ media_indexer_search: mediaIndexerSearch,
1287
+ media_release_search: mediaReleaseSearch,
1288
+ media_release_grab: mediaReleaseGrab,
1289
+ media_blocklist: mediaBlocklist,
1290
+ media_manual_import: mediaManualImport,
999
1291
  }
1000
1292
 
1001
1293
  // ------------------------------------------------------------ jsonrpc stdio
@@ -15,3 +15,4 @@
15
15
  - When state is ambiguous, investigate safely, say what is known, and ask only if the answer changes the next safe move.
16
16
  - When a tool for the job is missing, say so in one line and route the work: ask Claude Code over A2A or record it as a follow-up. Never ask Ari to click through a web UI for work the house is meant to do.
17
17
  - Answer from primary sources, never from memory. For any factual claim about the world or the house (whether a title or series exists, what is on the shelf, a container's state, a version, a download's progress, a book's metadata, what Ari said before), look it up with the tools first (Sonarr/Radarr/Jellyfin and their TVDB/TMDB lookup, the Unraid tools, calibre-web, libgen, HEY, web_search, your own state files) and answer from what they return. Re-read live state instead of repeating a status from an earlier reply. When no tool can reach the source, say it is unverified rather than asserting it. Never say a title does not exist without a catalog lookup, and when the library holds a strong partial match, offer it first.
18
+ - When an automatic search finds nothing for episodes that exist, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 11,
5
+ "version": 12,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -66,7 +66,13 @@
66
66
  "media_chain_health",
67
67
  "media_play_or_resolve",
68
68
  "media_search_now",
69
- "media_blocklist_stalled"
69
+ "media_blocklist_stalled",
70
+ "media_episodes",
71
+ "media_indexer_search",
72
+ "media_release_search",
73
+ "media_release_grab",
74
+ "media_blocklist",
75
+ "media_manual_import"
70
76
  ],
71
77
  "effectScopes": [
72
78
  "telegram.proactive",
@@ -134,7 +140,7 @@
134
140
  ]
135
141
  },
136
142
  "sanctuary-agent-peer": {
137
- "version": 2,
143
+ "version": 3,
138
144
  "toolNames": [
139
145
  "external_event_disposition",
140
146
  "query_active_work",
@@ -191,7 +197,13 @@
191
197
  "media_chain_health",
192
198
  "media_play_or_resolve",
193
199
  "media_search_now",
194
- "media_blocklist_stalled"
200
+ "media_blocklist_stalled",
201
+ "media_episodes",
202
+ "media_indexer_search",
203
+ "media_release_search",
204
+ "media_release_grab",
205
+ "media_blocklist",
206
+ "media_manual_import"
195
207
  ],
196
208
  "contextScopes": [
197
209
  "household.status",
@@ -1,7 +1,7 @@
1
1
  <?xml version="1.0"?>
2
2
  <Container version="2">
3
3
  <Name>ouro-butler</Name>
4
- <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.850</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.851</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -14,6 +14,7 @@ exports.resumeApprovalContinuation = resumeApprovalContinuation;
14
14
  exports.isChatStyleChannel = isChatStyleChannel;
15
15
  exports.buildPonderResult = buildPonderResult;
16
16
  exports.isExternalStateQuery = isExternalStateQuery;
17
+ exports.settleContentMismatchError = settleContentMismatchError;
17
18
  exports.getSettleRetryError = getSettleRetryError;
18
19
  exports.stripLastToolCalls = stripLastToolCalls;
19
20
  exports.repairOrphanedToolCalls = repairOrphanedToolCalls;
@@ -754,6 +755,27 @@ function isExternalStateQuery(toolName, args) {
754
755
  const cmd = String(args.command ?? "");
755
756
  return /\bgh\s+(pr|run|api|issue)\b/.test(cmd) || /\bnpm\s+(view|info|show)\b/.test(cmd);
756
757
  }
758
+ const SETTLE_CONTENT_MISMATCH_MIN_CHARS = 40;
759
+ // Markdown markers and smart punctuation differ between a reply and its settle copy without changing it.
760
+ const normalizeForSettleComparison = (text) => text.replace(/[*_`>#~]/g, "").replace(/[\u2018\u2019]/g, "'").replace(/[\u201c\u201d]/g, '"').replace(/[\u2013\u2014]/g, "-").replace(/\s+/g, " ").trim().toLowerCase();
761
+ /**
762
+ * Only `settle.answer` reaches the friend; assistant message text in the same
763
+ * message does not. When a model writes its real reply as message text and puts
764
+ * something else (often a third-person recap) in the answer, the friend gets the
765
+ * recap. Ask once for the reply to be resent as the answer. Short preambles and
766
+ * text the answer already contains (or that contains the answer) pass.
767
+ */
768
+ function settleContentMismatchError(content, answer) {
769
+ if (typeof content !== "string")
770
+ return null;
771
+ const text = normalizeForSettleComparison(content);
772
+ if (text.length < SETTLE_CONTENT_MISMATCH_MIN_CHARS)
773
+ return null;
774
+ const reply = normalizeForSettleComparison(answer);
775
+ if (reply.includes(text) || text.includes(reply))
776
+ return null;
777
+ return "only settle.answer reaches the person; the message text you wrote alongside it does not. if that text is your reply, call settle again with the complete reply, in your own voice, as answer. if it was only working notes, call settle again with the same answer.";
778
+ }
757
779
  function getSettleRetryError(mustResolveBeforeHandoff, intent, sawSteeringFollowUp, _delegationDecision, sawSendMessageSelf, sawPonder, _sawQuerySession, currentObligation, innerJob, _sawExternalStateQuery) {
758
780
  // Delegation adherence removed: the delegation decision is surfaced in the
759
781
  // system prompt as a suggestion. Hard-gating settle caused infinite
@@ -1120,6 +1142,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1120
1142
  let currentReasoningEffort = "medium";
1121
1143
  let sawSendMessageSelf = false;
1122
1144
  let sawPonder = false;
1145
+ let sawSettleContentMismatch = false;
1123
1146
  let sawQuerySession = false;
1124
1147
  let sawBridgeManage = false;
1125
1148
  let sawExternalStateQuery = false;
@@ -1884,7 +1907,14 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1884
1907
  // The provider finalizer has already established a top-level string
1885
1908
  // answer before ordinary settle handling reaches this point.
1886
1909
  const deliveredAnswer = answer;
1887
- const retryError = privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
1910
+ // Never spend the last provider iteration on this optional retry: delivering the recap beats delivering nothing.
1911
+ const contentMismatch = sawSettleContentMismatch || providerIterations >= exports.MAX_PROVIDER_ITERATIONS - 1 ? null : settleContentMismatchError(msg.content, deliveredAnswer);
1912
+ if (contentMismatch) {
1913
+ sawSettleContentMismatch = true;
1914
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
1915
+ }
1916
+ const retryError = contentMismatch
1917
+ ?? privateReturnAckLeakError(deliveredAnswer, privateReturnHeldTokens)
1888
1918
  ?? privateReturnMissingPonderError({
1889
1919
  latestUserRequest: latestUserMessageText(messages),
1890
1920
  answer: deliveredAnswer,
@@ -60,7 +60,7 @@ function exactSanctuaryContainmentProfileBoundaries(value) {
60
60
  if (!value || typeof value !== "object" || Array.isArray(value))
61
61
  return false;
62
62
  const boundaries = value;
63
- const versions = { "sanctuary-owner": 11, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 12, "sanctuary-household": 5, "sanctuary-event": 4 };
64
64
  if (!(0, node_util_1.isDeepStrictEqual)(Object.keys(boundaries).sort(), Object.keys(versions).sort()))
65
65
  return false;
66
66
  const packageRoot = path.resolve(__dirname, "../../../deploy/unraid/sanctuary.ouro");
@@ -73,7 +73,7 @@ const SANCTUARY_RELATIONSHIP_BASE_TOOLS = new Set([
73
73
  exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
74
74
  "media_search", "media_request", "media_request_status",
75
75
  "media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
76
- "media_search_now", "media_blocklist_stalled",
76
+ "media_search_now", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import",
77
77
  ]);
78
78
  /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
79
79
  const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.850",
3
+ "version": "0.1.0-alpha.851",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@ouro.bot/cli",
9
- "version": "0.1.0-alpha.850",
9
+ "version": "0.1.0-alpha.851",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.850",
3
+ "version": "0.1.0-alpha.851",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },