@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 +9 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +283 -6
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +2 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +8 -4
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/repertoire/tools.js +1 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
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": [
|
|
@@ -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:
|
|
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
|
|
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":
|
|
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":
|
|
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.
|
|
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":
|
|
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");
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -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"]);
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ouro.bot/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
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.
|
|
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",
|