@ouro.bot/cli 0.1.0-alpha.848 → 0.1.0-alpha.849

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,15 @@
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.849",
6
+ "changes": [
7
+ "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.",
8
+ "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.",
9
+ "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.",
10
+ "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."
11
+ ]
12
+ },
4
13
  {
5
14
  "version": "0.1.0-alpha.848",
6
15
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.848",
2
+ "runtimeVersion": "0.1.0-alpha.849",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -31,6 +31,9 @@ const SONARR = cred?.sonarr ?? { url: "", apiKey: "" }
31
31
  const RADARR = cred?.radarr ?? { url: "", apiKey: "" }
32
32
  const PROWLARR = cred?.prowlarr ?? { url: "", apiKey: "" }
33
33
  const JELLYFIN_WEB = cred?.jellyfinWebUrl ?? "https://media.mendelow.cloud"
34
+ // Optional: when the credential file carries { jellyfin: { url, apiKey } } the library
35
+ // is searched in Jellyfin too. Absent, title resolution uses Sonarr and Radarr only.
36
+ const JELLYFIN = cred?.jellyfin ?? { url: "", apiKey: "" }
34
37
 
35
38
  const TIMEOUT_MS = 45_000
36
39
 
@@ -66,6 +69,7 @@ function serviceOf(base) {
66
69
  if (base === SONARR.url) return "sonarr"
67
70
  if (base === RADARR.url) return "radarr"
68
71
  if (base === PROWLARR.url) return "prowlarr"
72
+ if (JELLYFIN.url && base === JELLYFIN.url) return "jellyfin"
69
73
  return "service"
70
74
  }
71
75
 
@@ -125,9 +129,128 @@ function shelfItem(item) {
125
129
  }
126
130
  }
127
131
 
132
+ // ------------------------------------------------------- title resolution
133
+ //
134
+ // One resolver for every tool that takes a title. A title typed from memory is
135
+ // rarely the catalogue's title: "chef" is "The Chef Show", "chef shwo" is a typo.
136
+ // The 2026-09-29 failure was a library search that required the whole title and
137
+ // so reported a tracked series as nonexistent. Matching is therefore partial and
138
+ // typo-tolerant, in-library candidates always come first, and the TVDB/TMDB
139
+ // lookup only runs when the library has nothing.
140
+
141
+ export function normalizeTitle(s) {
142
+ return String(s ?? "")
143
+ .normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
144
+ .toLowerCase().replace(/&/g, " and ").replace(/['\u2019]/g, "")
145
+ .replace(/[^a-z0-9]+/g, " ").trim().replace(/^the /, "")
146
+ }
147
+
148
+ // Optimal-string-alignment distance: an adjacent swap ("shwo") costs one edit.
149
+ function editDistance(a, b) {
150
+ const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)])
151
+ for (let j = 1; j <= b.length; j += 1) d[0][j] = j
152
+ for (let i = 1; i <= a.length; i += 1) {
153
+ for (let j = 1; j <= b.length; j += 1) {
154
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
155
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
156
+ }
157
+ }
158
+ return d[a.length][b.length]
159
+ }
160
+
161
+ const MATCH_RANK = { exact: 0, prefix: 1, token: 2, fuzzy: 3 }
162
+
163
+ // Returns "exact" | "prefix" | "token" | "fuzzy" | null.
164
+ export function matchKind(query, title) {
165
+ const nq = normalizeTitle(query)
166
+ const nt = normalizeTitle(title)
167
+ if (!nq || !nt) return null
168
+ if (nq === nt) return "exact"
169
+ if (nt.startsWith(nq)) return "prefix"
170
+ const qt = nq.split(" ")
171
+ const tt = nt.split(" ")
172
+ if (qt.every((q) => tt.some((t) => t === q || (q.length >= 3 && t.includes(q))))) return "token"
173
+ if (nq.length >= 3 && nt.includes(nq)) return "token"
174
+ // Typos. Short tokens must be exact; a 4-letter token may only be fuzzy when
175
+ // another query token already matched exactly, so "chef" never drifts to "chess".
176
+ const exactHit = (q) => tt.includes(q)
177
+ const fuzzyHit = (q) => tt.some((t) => {
178
+ if (q.length < 4 || t.length < 4) return false
179
+ const tol = q.length >= 8 ? 2 : 1
180
+ return editDistance(q, t) <= tol
181
+ })
182
+ const anyExact = qt.some(exactHit)
183
+ const ok = qt.every((q) => exactHit(q) || (fuzzyHit(q) && (q.length >= 5 || (qt.length > 1 && anyExact))))
184
+ return ok ? "fuzzy" : null
185
+ }
186
+
187
+ function rankCandidates(term, items) {
188
+ return items
189
+ .map((c) => ({ ...c, match: matchKind(term, c.title) }))
190
+ .filter((c) => c.match)
191
+ .sort((x, y) => MATCH_RANK[x.match] - MATCH_RANK[y.match]
192
+ || Math.abs(normalizeTitle(x.title).length - normalizeTitle(term).length) - Math.abs(normalizeTitle(y.title).length - normalizeTitle(term).length))
193
+ }
194
+
195
+ const libraryCandidate = (kind, i) => ({ kind, source: kind === "series" ? "sonarr" : "radarr", service_id: i.id, title: i.title,
196
+ year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: i.tvdbId ?? null, in_library: true })
197
+
198
+ // Every library item whose title matches, best first. Sonarr and Radarr are the
199
+ // source of truth for what is tracked; Jellyfin (when configured) adds what is
200
+ // on the shelf and marks items it shares with them.
201
+ export async function findLibraryCandidates(term, kind = "any") {
202
+ const jobs = []
203
+ if (kind !== "movie") jobs.push(sonarr("/series").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("series", i))))
204
+ if (kind !== "series") jobs.push(radarr("/movie").then((l) => (Array.isArray(l) ? l : []).map((i) => libraryCandidate("movie", i))))
205
+ // One service being down must not hide the other's matches; say which failed.
206
+ const settled = await Promise.allSettled(jobs)
207
+ const notes = []
208
+ for (const r of settled) if (r.status === "rejected") notes.push(`library_unavailable: ${r.reason?.message ?? r.reason}`)
209
+ if (settled.every((r) => r.status === "rejected")) throw settled[0].reason
210
+ let items = settled.flatMap((r) => (r.status === "fulfilled" ? r.value : []))
211
+ if (JELLYFIN.url && JELLYFIN.apiKey) {
212
+ try {
213
+ const body = await req(JELLYFIN.url, "/Items", { key: JELLYFIN.apiKey, header: "X-Emby-Token",
214
+ query: { searchTerm: term, IncludeItemTypes: kind === "series" ? "Series" : kind === "movie" ? "Movie" : "Series,Movie", Recursive: true, Limit: 25 } })
215
+ for (const j of body?.Items ?? []) {
216
+ const k = j.Type === "Series" ? "series" : "movie"
217
+ const tmdb = Number(j.ProviderIds?.Tmdb ?? j.ProviderIds?.tmdb) || null
218
+ const twin = items.find((i) => i.kind === k && ((tmdb && i.tmdb_id === tmdb) || normalizeTitle(i.title) === normalizeTitle(j.Name)))
219
+ if (twin) twin.in_jellyfin = true
220
+ else items.push({ kind: k, source: "jellyfin", service_id: null, jellyfin_id: j.Id ?? null, title: j.Name, year: j.ProductionYear ?? null,
221
+ tmdb_id: tmdb, tvdb_id: null, in_library: true, in_jellyfin: true })
222
+ }
223
+ } catch (e) {
224
+ notes.push(`jellyfin_unavailable: ${e.message}`)
225
+ }
226
+ }
227
+ const ranked = rankCandidates(term, items)
228
+ if (notes.length) Object.defineProperty(ranked, "notes", { value: notes, enumerable: false })
229
+ return ranked
230
+ }
231
+
232
+ // The catalogue fallback, used only when the library has no candidate. These are
233
+ // titles Sonarr/Radarr could add, so they are labelled not_in_library.
234
+ export async function lookupCandidates(term, kind = "any") {
235
+ const out = []
236
+ const jobs = []
237
+ if (kind !== "movie") jobs.push(sonarr("/series/lookup", { query: { term } }).then((l) => (Array.isArray(l) ? l : []).forEach((i) => out.push({ kind: "series", source: "sonarr_lookup", service_id: i.id ?? null, title: i.title, year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: i.tvdbId ?? null, in_library: Boolean(i.id), match: matchKind(term, i.title) ?? "lookup" }))).catch(() => {}))
238
+ if (kind !== "series") jobs.push(radarr("/movie/lookup", { query: { term } }).then((l) => (Array.isArray(l) ? l : []).forEach((i) => out.push({ kind: "movie", source: "radarr_lookup", service_id: i.id ?? null, title: i.title, year: i.year ?? null, tmdb_id: i.tmdbId ?? null, tvdb_id: null, in_library: Boolean(i.id), match: matchKind(term, i.title) ?? "lookup" }))).catch(() => {}))
239
+ await Promise.all(jobs)
240
+ return out.sort((x, y) => (MATCH_RANK[x.match] ?? 4) - (MATCH_RANK[y.match] ?? 4)).slice(0, 10)
241
+ }
242
+
243
+ // In-library candidates first; the lookup fallback only when there are none.
244
+ export async function resolveTitle(term, kind = "any") {
245
+ const library = await findLibraryCandidates(term, kind)
246
+ if (library.length) return { library_matches: library, lookup_candidates: [], not_in_library: false, notes: library.notes ?? [] }
247
+ const lookup = (await lookupCandidates(term, kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
248
+ return { library_matches: [], lookup_candidates: lookup, not_in_library: true, notes: [] }
249
+ }
250
+
128
251
  // ------------------------------------------------------------- tool: search
129
252
 
130
- async function mediaSearch(a) {
253
+ export async function mediaSearch(a) {
131
254
  const kind = a.kind ?? "any"
132
255
  const limit = Math.min(Math.max(a.limit ?? 12, 1), 20)
133
256
  const onShelf = a.on_shelf // true | false | undefined(any)
@@ -175,6 +298,10 @@ async function mediaSearch(a) {
175
298
  }
176
299
  }
177
300
 
301
+ // Partial and typo-tolerant library match, so a half-remembered title still
302
+ // finds what is tracked. Lookup candidates appear only when the library has none.
303
+ const resolution = a.query ? await resolveTitle(a.query, kind).catch((e) => ({ library_matches: [], lookup_candidates: [], not_in_library: null, notes: [`title_resolution_failed: ${e.message}`] })) : null
304
+
178
305
  let items = raw.filter((r) => r.mediaType !== "person").map(shelfItem)
179
306
  if (kind !== "any") items = items.filter((i) => i.kind === kind)
180
307
  if (a.year_range) {
@@ -189,9 +316,12 @@ async function mediaSearch(a) {
189
316
  source,
190
317
  matched: items.length,
191
318
  items: items.slice(0, limit),
319
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates, notes: resolution.notes } : {}),
192
320
  applied_filters: { kind, year_range: a.year_range ?? null, genres: a.genres ?? [], keywords: a.keywords ?? [], on_shelf: onShelf ?? "any" },
193
321
  // 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,
322
+ note: resolution?.library_matches.length ? "library_matches lists items already tracked in Sonarr/Radarr (or on the Jellyfin shelf) whose titles match, best first. Offer these before anything else."
323
+ : items.length === 0 && resolution?.lookup_candidates.length ? "Nothing in the library matches; lookup_candidates are TVDB/TMDB titles that are not in the library."
324
+ : items.length === 0 ? "No matches for these filters. Loosen year_range/genres/keywords before concluding the shelf lacks it." : null,
195
325
  }
196
326
  }
197
327
 
@@ -360,15 +490,25 @@ async function resolveTarget(a) {
360
490
  const found = await findExistingRequest(pick.id, pick.mediaType === "tv" ? "series" : "movie")
361
491
  if (found?.hasRequest) return found
362
492
  }
493
+ // Jellyseerr's own search missed it: try the partial/typo-tolerant library
494
+ // match, and follow its best candidate back to a request by TMDB id.
495
+ const best = (await findLibraryCandidates(a.title).catch(() => [])).find((c) => c.tmdb_id)
496
+ if (best) {
497
+ const found = await findExistingRequest(best.tmdb_id, best.kind)
498
+ if (found?.hasRequest) return found
499
+ }
363
500
  }
364
501
  return null
365
502
  }
366
503
 
367
- async function mediaRequestStatus(a) {
504
+ export async function mediaRequestStatus(a) {
368
505
  const request = await resolveTarget(a)
369
506
  if (!request) {
507
+ const resolution = a.title ? await resolveTitle(a.title).catch(() => null) : null
370
508
  return { found: false, reason: "no_matching_request",
371
509
  searched: { request_id: a.request_id ?? null, tmdb_id: a.tmdb_id ?? null, title: a.title ?? null },
510
+ ...(resolution ? { library_matches: resolution.library_matches.slice(0, 10), lookup_candidates: resolution.lookup_candidates,
511
+ note: resolution.library_matches.length ? "Not requested through Jellyseerr, but these titles are already in the library. Use media_search_now with the service_id to act on one." : undefined } : {}),
372
512
  diagnosis: { stuck_stage: "request", stuck_reason: "never_requested",
373
513
  likely_fix: "Submit it with media_request.", human_action_required: false } }
374
514
  }
@@ -446,6 +586,12 @@ async function mediaRequestStatus(a) {
446
586
  // torrent that has not moved a byte since it was grabbed is dead, not slow:
447
587
  // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
448
588
  // progress is measured against age rather than trusted from the row.
589
+ // The one stall rule: nothing downloaded yet, and older than the stall window.
590
+ 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
593
+ }
594
+
449
595
  export function computeDownloadState(queueRecs, nowMs) {
450
596
  const byDownload = new Map()
451
597
  for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
@@ -453,8 +599,7 @@ export function computeDownloadState(queueRecs, nowMs) {
453
599
  const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
454
600
  const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
455
601
  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)
602
+ .filter((r) => isStalledRow(r, nowMs))
458
603
  .map((r) => ({
459
604
  title: r.title ?? null,
460
605
  added_at: r.added ?? null,
@@ -652,12 +797,122 @@ async function mediaChainHealth() {
652
797
  }
653
798
  }
654
799
 
800
+ // ------------------------------------- tools: search_now / blocklist_stalled
801
+
802
+ // Act on an item already in Sonarr/Radarr directly; no Jellyseerr request needed.
803
+ // Never adds a library item: an unknown title is reported, not created.
804
+ const arrFor = (kind) => (kind === "series" ? sonarr : radarr)
805
+
806
+ async function resolveLibraryItem(a) {
807
+ let candidates
808
+ if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id) {
809
+ const list = await arrFor(a.kind)(a.kind === "series" ? "/series" : "/movie")
810
+ const items = (Array.isArray(list) ? list : []).map((i) => libraryCandidate(a.kind, i))
811
+ const hit = items.filter((i) => (a.service_id !== undefined && a.service_id !== null ? i.service_id === Number(a.service_id) : i.tmdb_id === Number(a.tmdb_id)))
812
+ if (hit.length === 1) return { item: { id: hit[0].service_id, title: hit[0].title } }
813
+ candidates = hit
814
+ } else if (a.title) {
815
+ const ranked = (await findLibraryCandidates(a.title, a.kind)).filter((c) => c.service_id !== null)
816
+ // Act only when the best tier holds exactly one item and it is not merely a
817
+ // typo-distance match; otherwise name the candidates and let the caller pick.
818
+ const bestTier = ranked.filter((c) => c.match === ranked[0]?.match)
819
+ if (bestTier.length === 1 && bestTier[0].match !== "fuzzy") return { item: { id: bestTier[0].service_id, title: bestTier[0].title } }
820
+ if (!ranked.length) {
821
+ const lookup = (await lookupCandidates(a.title, a.kind)).filter((c) => !c.in_library).map((c) => ({ ...c, not_in_library: true }))
822
+ return { result: "not_found", candidates: lookup }
823
+ }
824
+ candidates = ranked
825
+ } else candidates = []
826
+ return { result: candidates.length ? "ambiguous" : "not_found", candidates: candidates.slice(0, 10) }
827
+ }
828
+
829
+ const QUEUE_PAGE_SIZE = 200
830
+ const QUEUE_MAX_PAGES = 20
831
+
832
+ // The item's queue rows. Filtered server-side (seriesIds/movieIds) and paged, so a
833
+ // long queue cannot hide a row; the client-side filter stays as the backstop.
834
+ async function itemQueue(kind, serviceId) {
835
+ const client = arrFor(kind)
836
+ const idKey = kind === "series" ? "seriesId" : "movieId"
837
+ const filter = kind === "series" ? { seriesIds: serviceId, includeSeries: true } : { movieIds: serviceId }
838
+ const rows = []
839
+ for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
840
+ const q = await client("/queue", { query: { ...filter, page, pageSize: QUEUE_PAGE_SIZE } })
841
+ const records = q?.records ?? []
842
+ rows.push(...records)
843
+ if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE)) break
844
+ }
845
+ return rows.filter((r) => r[idKey] === serviceId)
846
+ }
847
+
848
+ function queueReport(rows) {
849
+ const now = Date.now()
850
+ return rows.map((r) => ({ queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
851
+ size_left_bytes: r.sizeleft ?? 0, stalled: isStalledRow(r, now) }))
852
+ }
853
+
854
+ async function runSearch(kind, serviceId) {
855
+ const body = kind === "series" ? { name: "SeriesSearch", seriesId: serviceId } : { name: "MoviesSearch", movieIds: [serviceId] }
856
+ const c = await arrFor(kind)("/command", { method: "POST", body })
857
+ return { id: c?.id ?? null, name: c?.name ?? body.name, status: c?.status ?? null }
858
+ }
859
+
860
+ export async function mediaSearchNow(a) {
861
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
862
+ const found = await resolveLibraryItem(a)
863
+ if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
864
+ const { item } = found
865
+ const command = await runSearch(a.kind, item.id)
866
+ return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
867
+ }
868
+
869
+ export async function mediaBlocklistStalled(a) {
870
+ if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
871
+ if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
872
+ const serviceId = Number(a.service_id)
873
+ const rows = await itemQueue(a.kind, serviceId)
874
+ const now = Date.now()
875
+ let targets
876
+ if (Array.isArray(a.queue_ids) && a.queue_ids.length) {
877
+ const own = new Set(rows.map((r) => r.id))
878
+ const foreign = a.queue_ids.filter((id) => !own.has(id))
879
+ if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows) }
880
+ targets = rows.filter((r) => a.queue_ids.includes(r.id))
881
+ } else {
882
+ targets = rows.filter((r) => isStalledRow(r, now))
883
+ }
884
+ // A season pack is many queue rows sharing one downloadId; deleting one removes
885
+ // the download for all, so delete once per download.
886
+ const seen = new Set()
887
+ targets = targets.filter((r) => {
888
+ const key = r.downloadId ? `d:${r.downloadId}` : `q:${r.id}`
889
+ if (seen.has(key)) return false
890
+ seen.add(key)
891
+ return true
892
+ })
893
+ if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
894
+ const removed = []
895
+ const failed = []
896
+ for (const row of targets) {
897
+ try {
898
+ await arrFor(a.kind)(`/queue/${row.id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
899
+ removed.push(row.id)
900
+ } catch (e) {
901
+ // 404: the row went away (finished or removed with its pack); not a failure to retry.
902
+ if (/_http_404$/.test(e.code ?? "")) removed.push(row.id)
903
+ else failed.push({ queue_id: row.id, error: e.message })
904
+ }
905
+ }
906
+ const command = a.research === false ? null : await runSearch(a.kind, serviceId)
907
+ return { removed, ...(failed.length ? { failed } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
908
+ }
909
+
655
910
  // ------------------------------------------------------------------- schema
656
911
 
657
912
  const TOOLS = [
658
913
  {
659
914
  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.",
915
+ description: "Search or browse the household media catalogue. Use `query` for a title lookup: partial and misspelled titles are matched against the library first (Sonarr, Radarr, Jellyfin) and returned as `library_matches` with their kind and service id, and when nothing in the library matches, `lookup_candidates` (TVDB/TMDB) cover titles not in the library. Never say a title does not exist without reading both; use `genres`/`keywords`/`year_range` to browse by vibe (e.g. keywords ['autumn','thanksgiving'] for cozy fall films). `on_shelf: true` restricts to what is already downloaded, `false` to what is not, omit for both. Every item says whether it is on the shelf and carries its tmdb_id so a follow-up media_request needs no second lookup.",
661
916
  inputSchema: { type: "object", properties: {
662
917
  query: { type: "string", description: "Title or free text. Omit to browse by filters." },
663
918
  kind: { type: "string", enum: ["movie", "series", "any"], description: "Default any." },
@@ -710,6 +965,26 @@ const TOOLS = [
710
965
  request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
711
966
  } },
712
967
  },
968
+ {
969
+ name: "media_search_now",
970
+ description: "Trigger a Sonarr/Radarr search for a series or movie that is already in the library, and report its queue. Use this when the owner asks to search, re-search, or look again for something already in Sonarr/Radarr; no Jellyseerr request id is needed. Identify it by service_id, tmdb_id, or exact title. Never adds anything new: zero or several matches come back as 'not_found' or 'ambiguous' with candidates. Never tell the owner to click Search in the web UI.",
971
+ inputSchema: { type: "object", properties: {
972
+ kind: { type: "string", enum: ["series", "movie"] },
973
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
974
+ title: { type: "string" },
975
+ tmdb_id: { type: "number" },
976
+ }, required: ["kind"] },
977
+ },
978
+ {
979
+ name: "media_blocklist_stalled",
980
+ description: "Clear stuck downloads for a series or movie already in Sonarr/Radarr: remove stalled queue rows from the download client, blocklist those releases so they are not grabbed again, then search again (research, default true). Use this when the owner asks to clear stuck or stalled downloads; no Jellyseerr request id is needed. Without queue_ids it targets the item's stalled rows; with queue_ids only those, and each must belong to the item. Explicit queue_ids are removed even if still downloading, so pass them only when the owner named those downloads. Rows of one season pack are removed once; a delete that fails is reported in `failed` and the re-search still runs.",
981
+ inputSchema: { type: "object", properties: {
982
+ kind: { type: "string", enum: ["series", "movie"] },
983
+ service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
984
+ queue_ids: { type: "array", items: { type: "number" } },
985
+ research: { type: "boolean", description: "Search again afterwards. Default true." },
986
+ }, required: ["kind", "service_id"] },
987
+ },
713
988
  ]
714
989
 
715
990
  const HANDLERS = {
@@ -719,6 +994,8 @@ const HANDLERS = {
719
994
  media_diagnose_and_fix: mediaDiagnoseAndFix,
720
995
  media_chain_health: mediaChainHealth,
721
996
  media_play_or_resolve: mediaPlayOrResolve,
997
+ media_search_now: mediaSearchNow,
998
+ media_blocklist_stalled: mediaBlocklistStalled,
722
999
  }
723
1000
 
724
1001
  // ------------------------------------------------------------ jsonrpc stdio
@@ -13,3 +13,5 @@
13
13
  - Never touch array/parity/disk configuration, permissions, network/security settings, or `/mnt/user` paths.
14
14
  - Never send server data off-box or enter credentials into another system.
15
15
  - When state is ambiguous, investigate safely, say what is known, and ask only if the answer changes the next safe move.
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
+ - 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.
@@ -2,7 +2,7 @@
2
2
  "version": 2,
3
3
  "profiles": {
4
4
  "sanctuary-owner": {
5
- "version": 10,
5
+ "version": 11,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -64,7 +64,9 @@
64
64
  "media_request_status",
65
65
  "media_diagnose_and_fix",
66
66
  "media_chain_health",
67
- "media_play_or_resolve"
67
+ "media_play_or_resolve",
68
+ "media_search_now",
69
+ "media_blocklist_stalled"
68
70
  ],
69
71
  "effectScopes": [
70
72
  "telegram.proactive",
@@ -132,7 +134,7 @@
132
134
  ]
133
135
  },
134
136
  "sanctuary-agent-peer": {
135
- "version": 1,
137
+ "version": 2,
136
138
  "toolNames": [
137
139
  "external_event_disposition",
138
140
  "query_active_work",
@@ -187,7 +189,9 @@
187
189
  "media_request_status",
188
190
  "media_diagnose_and_fix",
189
191
  "media_chain_health",
190
- "media_play_or_resolve"
192
+ "media_play_or_resolve",
193
+ "media_search_now",
194
+ "media_blocklist_stalled"
191
195
  ],
192
196
  "contextScopes": [
193
197
  "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.848</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.849</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -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": 10, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 11, "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,6 +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
77
  ]);
77
78
  /** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
78
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.848",
3
+ "version": "0.1.0-alpha.849",
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.848",
9
+ "version": "0.1.0-alpha.849",
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.848",
3
+ "version": "0.1.0-alpha.849",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },