@ouro.bot/cli 0.1.0-alpha.854 → 0.1.0-alpha.855

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.855",
6
+ "changes": [
7
+ "The Butler no longer deletes a partial download that is not confirmed dead: media_blocklist_stalled keeps anything with an ETA or not yet seen without progress across 30 minutes (age alone no longer counts), removes a named download early only with the owner's quoted words, and stall observations now persist atomically across restarts.",
8
+ "media_fill_missing reports a matching release that is only on the blocklist (blocklisted_matches) and, given the owner's words in retry_blocklisted, grabs it with the right mapping and lifts its blocklist entry only for the release actually grabbed; a grab with no seeders is reported as possibly never finishing, with an await_condition follow-up.",
9
+ "Butler guidance: never offer to clear a stalled download that media_fill_missing kept, treat a yes as another media_fill_missing call, and promise a follow-up only after filing it with await_condition."
10
+ ]
11
+ },
4
12
  {
5
13
  "version": "0.1.0-alpha.854",
6
14
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.854",
2
+ "runtimeVersion": "0.1.0-alpha.855",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -9,7 +9,8 @@
9
9
  //
10
10
  // Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
11
11
 
12
- import { readFileSync } from "node:fs"
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
13
+ import { dirname } from "node:path"
13
14
  import { pathToFileURL } from "node:url"
14
15
 
15
16
  const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
@@ -877,6 +878,20 @@ export async function mediaSearchNow(a) {
877
878
  return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
878
879
  }
879
880
 
881
+ // Whether a download may be removed without the owner naming it. A download that has never
882
+ // started is dead once past the stall window. A partial one is dead only when observed with no
883
+ // ETA and no progress across 30 minutes: age alone is not enough (a 75.9% pack with no seeders
884
+ // today can finish when one returns), and anything with an ETA is still moving.
885
+ const hasEta = (row) => row.timeleft !== undefined && row.timeleft !== null && row.timeleft !== "00:00:00"
886
+ export function removalVerdict(row, nowMs, previous) {
887
+ if (hasEta(row)) return "still_downloading"
888
+ const left = row.sizeleft ?? 0
889
+ if ((row.size ?? 0) > 0 && left >= row.size) return isStalledRow(row, nowMs) ? "dead" : "not_started_yet"
890
+ if (row.status === "failed") return "dead"
891
+ const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
892
+ return unchanged ? "dead" : "stall_unconfirmed"
893
+ }
894
+
880
895
  export async function mediaBlocklistStalled(a) {
881
896
  if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
882
897
  if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
@@ -901,6 +916,27 @@ export async function mediaBlocklistStalled(a) {
901
916
  seen.add(key)
902
917
  return true
903
918
  })
919
+ // Removing a download deletes its progress. Unless the owner asked for this download to go,
920
+ // only a download confirmed dead is removed: one still moving, or stalled without a second
921
+ // observation 30 minutes apart, may yet finish (2026-10-05: a 75.9% pack, the only copy, was
922
+ // deleted on "yes please get them").
923
+ const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
924
+ if (ownerWords && !(Array.isArray(a.queue_ids) && a.queue_ids.length)) {
925
+ return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows) }
926
+ }
927
+ const kept = []
928
+ if (!ownerWords) {
929
+ targets = targets.filter((r) => {
930
+ const verdict = removalVerdict(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now))
931
+ if (verdict === "dead") return true
932
+ kept.push({ queue_id: r.id, title: r.title ?? null, percent: pct(r), reason: verdict })
933
+ return false
934
+ })
935
+ }
936
+ if (!targets.length && kept.length) {
937
+ return { result: "kept_downloads", kept, queue: queueReport(rows),
938
+ message: "Nothing removed. These downloads are not confirmed dead, and removing one deletes its progress. A partial download counts as dead only after two calls at least 30 minutes apart see no progress and no ETA; call again later to confirm. For missing episodes call media_fill_missing instead: it replaces a stalled download only when a seeded alternative exists. Pass owner_words only with the owner's own words asking to remove, clear or cancel this download; 'get them' is not that." }
939
+ }
904
940
  if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
905
941
  const removed = []
906
942
  const failed = []
@@ -915,7 +951,7 @@ export async function mediaBlocklistStalled(a) {
915
951
  }
916
952
  }
917
953
  const command = a.research === false ? null : await runSearch(a.kind, serviceId)
918
- return { removed, ...(failed.length ? { failed } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
954
+ return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
919
955
  }
920
956
 
921
957
  // ------------------------------- tools: episodes / releases / blocklist / import
@@ -1198,14 +1234,22 @@ function parseAgrees(release, episode) {
1198
1234
  }
1199
1235
 
1200
1236
  // Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
1201
- export function matchReleaseToEpisodes(release, { seriesId, seriesTitle, seriesYear, wanted, have }) {
1237
+ // A blocklisted release is still rejected, but says which wanted episodes it would hold, so a
1238
+ // release blocklisted by mistake can be offered back to the owner.
1239
+ export function matchReleaseToEpisodes(release, ctx) {
1240
+ const rejections = rejectionTexts(release)
1241
+ if (!rejections.some((x) => /blocklist/i.test(x))) return matchUnblocked(release, ctx, rejections)
1242
+ const m = matchUnblocked(release, ctx, rejections.filter((x) => !/blocklist/i.test(x)))
1243
+ if (m.reject === "other_series") return m
1244
+ return { reject: "blocklisted", ...(m.episode_ids ? { would_match: { episode_ids: m.episode_ids, basis: m.basis } } : {}) }
1245
+ }
1246
+
1247
+ function matchUnblocked(release, { seriesId, seriesTitle, seriesYear, wanted, have }, rejections) {
1202
1248
  const title = release.title ?? ""
1203
1249
  const norm = normalizeTitle(title)
1204
1250
  const base = seriesBaseTitle(seriesTitle)
1205
- const rejections = rejectionTexts(release)
1206
1251
  if (!hasWord(norm, base)) return { reject: "other_series" }
1207
1252
  if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
1208
- if (rejections.some((x) => /blocklist/i.test(x))) return { reject: "blocklisted" }
1209
1253
  if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
1210
1254
  const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
1211
1255
  // The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
@@ -1249,12 +1293,53 @@ export function stallReason(row, nowMs, previous) {
1249
1293
  }
1250
1294
 
1251
1295
  // Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
1252
- const progressSeen = new Map()
1296
+ // Kept on disk: every Butler restart starts a new MCP process, and an observation lost with it
1297
+ // would leave a dead download unconfirmable (or make a fresh one look confirmed too early).
1253
1298
  const PROGRESS_FORGET_MS = 14 * 24 * 3_600_000
1299
+ // Observations further apart than this are not continuous: the download may have been paused
1300
+ // and resumed in between, so the older one stops counting.
1301
+ const PROGRESS_CONTINUITY_MS = 6 * 3_600_000
1302
+ const progressPath = () => process.env.SANCTUARY_MEDIA_STATE ?? "/home/ouro/AgentBundles/sanctuary.ouro/state/media/download-progress.json"
1303
+ let progressSeen = null
1304
+ function readProgressFile() {
1305
+ const out = new Map()
1306
+ try {
1307
+ for (const [k, v] of Object.entries(JSON.parse(readFileSync(progressPath(), "utf8")))) {
1308
+ if (typeof v?.sizeleft === "number" && typeof v?.at === "number") out.set(k, { sizeleft: v.sizeleft, at: v.at, seen: typeof v.seen === "number" ? v.seen : v.at })
1309
+ }
1310
+ } catch { /* first run, or unreadable: start empty */ }
1311
+ return out
1312
+ }
1313
+ function loadProgress() {
1314
+ if (!progressSeen) progressSeen = readProgressFile()
1315
+ return progressSeen
1316
+ }
1317
+ // Merge with what another process (an overlapping restart) may have saved, keeping the
1318
+ // later sighting per download, then replace the file atomically so a crash cannot truncate it.
1319
+ function saveProgress(nowMs) {
1320
+ try {
1321
+ for (const [k, v] of readProgressFile()) {
1322
+ const mine = progressSeen.get(k)
1323
+ if (nowMs - v.seen <= PROGRESS_FORGET_MS && (!mine || v.seen > mine.seen)) progressSeen.set(k, v)
1324
+ }
1325
+ mkdirSync(dirname(progressPath()), { recursive: true, mode: 0o700 })
1326
+ const tmp = `${progressPath()}.tmp-${process.pid}`
1327
+ writeFileSync(tmp, JSON.stringify(Object.fromEntries(progressSeen)), { mode: 0o600 })
1328
+ renameSync(tmp, progressPath())
1329
+ } catch (e) {
1330
+ // Best effort: confirmation then waits for a later observation in this process.
1331
+ process.stderr.write(`media-mcp: download progress not saved: ${e.message}\n`)
1332
+ }
1333
+ }
1334
+ export const progressKey = (row, kind = "series") => `${kind}:${row.downloadId ?? `row:${row.id}`}`.toUpperCase()
1254
1335
  function observeProgress(downloadId, sizeleft, nowMs) {
1255
- for (const [k, v] of progressSeen) if (nowMs - v.at > PROGRESS_FORGET_MS) progressSeen.delete(k)
1256
- const previous = progressSeen.get(downloadId)
1257
- if (!previous || previous.sizeleft !== sizeleft) progressSeen.set(downloadId, { sizeleft, at: nowMs })
1336
+ const seen = loadProgress()
1337
+ for (const [k, v] of seen) if (nowMs - v.seen > PROGRESS_FORGET_MS) seen.delete(k)
1338
+ let previous = seen.get(downloadId)
1339
+ if (previous && nowMs - previous.seen > PROGRESS_CONTINUITY_MS) previous = undefined
1340
+ if (!previous || previous.sizeleft !== sizeleft) seen.set(downloadId, { sizeleft, at: nowMs, seen: nowMs })
1341
+ else seen.set(downloadId, { ...previous, seen: nowMs })
1342
+ saveProgress(nowMs)
1258
1343
  return previous
1259
1344
  }
1260
1345
 
@@ -1392,7 +1477,7 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1392
1477
  ids.forEach((id) => covered.add(id))
1393
1478
  continue
1394
1479
  }
1395
- const why = stallReason(d.row, nowMs, observeProgress(d.key, d.row.sizeleft ?? 0, nowMs))
1480
+ const why = stallReason(d.row, nowMs, observeProgress(progressKey(d.row), d.row.sizeleft ?? 0, nowMs))
1396
1481
  const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
1397
1482
  if (why) stalled.push({ ...info, ids, ...why })
1398
1483
  else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
@@ -1405,12 +1490,14 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1405
1490
  const searchedEpisodes = new Set()
1406
1491
  const candidates = new Map()
1407
1492
  const rejected = new Map()
1493
+ const blockedMatches = new Map()
1408
1494
  const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
1409
1495
  const consider = (raw) => {
1410
1496
  for (const r of Array.isArray(raw) ? raw : []) {
1411
1497
  if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
1412
1498
  const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
1413
1499
  const sameAsInFlight = inFlightTitles.has(normalizeTitle(r.title))
1500
+ if (m.would_match && !sameAsInFlight) blockedMatches.set(r.guid, { raw: r, ids: m.would_match.episode_ids, basis: m.would_match.basis })
1414
1501
  if (m.reject || sameAsInFlight) { rejected.set(r.guid, { title: r.title ?? null, seeders: r.seeders ?? null, reason: sameAsInFlight ? "same_release_as_stalled_download" : m.reject }); continue }
1415
1502
  cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
1416
1503
  candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
@@ -1438,6 +1525,33 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1438
1525
  consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
1439
1526
  }
1440
1527
 
1528
+ // Episodes whose only matching releases are on the blocklist. With the owner's words such a
1529
+ // release joins the candidates and its blocklist entry is lifted only once it is actually being
1530
+ // grabbed (an unseeded one then waits for peers and is kept); without, it is reported.
1531
+ const covers = (id) => [...candidates.values()].some((c) => c.ids.includes(id))
1532
+ const blockedOnly = [...blockedMatches.values()].filter((b) => b.ids.some((id) => !covers(id)))
1533
+ const ownerRetry = typeof a.retry_blocklisted === "string" ? a.retry_blocklisted.trim() : ""
1534
+ const blockedLeft = new Map()
1535
+ if (blockedOnly.length) {
1536
+ const entries = (await attempt("read blocklist", () => itemBlocklist("series", seriesId))) ?? []
1537
+ for (const b of blockedOnly) {
1538
+ const ids = entries.filter((x) => normalizeTitle(x.sourceTitle) === normalizeTitle(b.raw.title)).map((x) => x.id)
1539
+ blockedLeft.set(b.raw.guid, { b, blocklist_ids: ids })
1540
+ if (ownerRetry && ids.length) {
1541
+ cacheRelease(`series:${b.raw.guid}`, { ...releaseRow("series", b.raw), service_id: seriesId, season_number: null, quality_raw: b.raw.quality ?? null, languages_raw: b.raw.languages ?? null })
1542
+ candidates.set(b.raw.guid, { ...b, blocklistIds: ids })
1543
+ }
1544
+ }
1545
+ }
1546
+ const lifted = new Set()
1547
+ const liftFor = async (c) => {
1548
+ const ids = c.blocklistIds.filter((id) => !lifted.has(id))
1549
+ if (ids.length && !(await attempt("remove from blocklist", async () => { await sonarr("/blocklist/bulk", { method: "DELETE", body: { ids } }); return true }))) return false
1550
+ ids.forEach((id) => lifted.add(id))
1551
+ if (ids.length) actions.push({ action: "removed_from_blocklist", title: c.raw.title ?? null, owner_words: ownerRetry })
1552
+ return true
1553
+ }
1554
+
1441
1555
  // Seeded releases first, then the ones that cover the most, then the best seeded.
1442
1556
  const ranked = [...candidates.values()].sort((x, y) =>
1443
1557
  Number((y.raw.seeders ?? 0) > 0) - Number((x.raw.seeders ?? 0) > 0) || y.ids.length - x.ids.length || (y.raw.seeders ?? 0) - (x.raw.seeders ?? 0))
@@ -1457,7 +1571,19 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1457
1571
  if (!ids.length || !c.raw.quality) continue
1458
1572
  if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
1459
1573
  const body = { guid: c.raw.guid, indexerId: c.raw.indexerId, seriesId, episodeIds: ids, shouldOverride: true, quality: c.raw.quality, languages: Array.isArray(c.raw.languages) ? c.raw.languages : [] }
1460
- const res = await attempt(`grab ${c.raw.title}`, async () => (await sonarr("/release", { method: "POST", body })) ?? {})
1574
+ const post = async () => (await sonarr("/release", { method: "POST", body })) ?? {}
1575
+ let res
1576
+ if (c.blocklistIds) {
1577
+ // Grab first; lift the entry after it succeeds, or before one retry if Sonarr refused it as blocklisted.
1578
+ try { res = await post() } catch { res = undefined }
1579
+ const liftedOk = await liftFor(c)
1580
+ if (!res && liftedOk) res = await attempt(`grab ${c.raw.title}`, post)
1581
+ if (!res) continue
1582
+ blockedLeft.delete(c.raw.guid)
1583
+ for (const [g, x] of blockedLeft) if (normalizeTitle(x.b.raw.title) === normalizeTitle(c.raw.title)) blockedLeft.delete(g)
1584
+ } else {
1585
+ res = await attempt(`grab ${c.raw.title}`, post)
1586
+ }
1461
1587
  if (!res) continue
1462
1588
  grabs += 1
1463
1589
  ids.forEach((id) => remaining.delete(id))
@@ -1505,14 +1631,22 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1505
1631
  evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
1506
1632
  }
1507
1633
  }
1634
+ const blockedReport = []
1635
+ for (const { b, blocklist_ids } of blockedLeft.values()) {
1636
+ if (blockedReport.some((x) => normalizeTitle(x.title) === normalizeTitle(b.raw.title))) continue
1637
+ blockedReport.push({ title: b.raw.title ?? null, seeders: b.raw.seeders ?? null, episodes: b.ids.map((id) => sxe(byId.get(id))), blocklist_ids })
1638
+ }
1508
1639
  const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
1509
1640
  const moving = inFlight.filter((x) => x.state !== "stalled").length
1641
+ const unseeded = actions.filter((x) => x.action === "grabbed" && x.may_never_finish)
1510
1642
  const summary = [
1511
1643
  `${have.length} of ${base.on_shelf.of} aired episodes are on the shelf; ${wanted.length} monitored ${wanted.length === 1 ? "episode is" : "episodes are"} missing.`,
1512
1644
  grabs ? `Grabbed ${grabs} release${grabs === 1 ? "" : "s"} now.` : "",
1645
+ unseeded.length ? `${unseeded.map((x) => x.title).join("; ")} ${unseeded.length === 1 ? "has no seeders: it waits" : "have no seeders: they wait"} for peers and may never finish.` : "",
1513
1646
  moving ? `${moving} download(s) in progress.` : "",
1514
1647
  stuck.length ? `${stuck.length} download(s) stalled with no peers and no other usable release (${stuck.map((s) => `${s.title} at ${s.percent}%`).join("; ")}).` : "",
1515
1648
  notFound.length ? `No usable release found for ${notFound.map((e) => e.code).join(", ")}.` : "",
1649
+ blockedReport.length ? `The only matching release${blockedReport.length === 1 ? " is" : "s are"} on the blocklist: ${blockedReport.map((x) => `${x.title} (${x.seeders ?? 0} seeders)`).join("; ")}.` : "",
1516
1650
  deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
1517
1651
  errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
1518
1652
  ].filter(Boolean).join(" ")
@@ -1520,11 +1654,16 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
1520
1654
  ? "This pass did not finish everything. Tell the owner what it did, then call media_fill_missing again to continue; do not act by hand."
1521
1655
  : stuck.length || notFound.length
1522
1656
  ? "Tell the owner exactly this outcome with the evidence; do not grab, import or blocklist anything by hand. Call media_fill_missing again later to retry."
1523
- : "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
1657
+ : unseeded.length
1658
+ ? "Tell the owner what was grabbed and that it has no seeders, so it may never finish. File a follow-up with await_condition to call media_fill_missing again later. Do not search or grab anything else now."
1659
+ : "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
1660
+ const blockedHint = !blockedReport.length ? ""
1661
+ : ownerRetry ? " The owner's retry could not take the blocklisted release back (see errors and blocklisted_matches); tell the owner that plainly."
1662
+ : " The only matching release is on the blocklist: tell the owner, and if the owner then asks to get it anyway, call media_fill_missing again with retry_blocklisted set to the owner's own words. Do not remove blocklist entries or grab by hand."
1524
1663
  return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
1525
1664
  ...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
1526
- searched, ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
1527
- ...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep }
1665
+ searched, ...(blockedReport.length ? { blocklisted_matches: blockedReport } : {}), ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
1666
+ ...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep + blockedHint }
1528
1667
  }
1529
1668
 
1530
1669
  // ------------------------------------------------------------------- schema
@@ -1603,12 +1742,13 @@ const TOOL_LIST = [
1603
1742
  },
1604
1743
  {
1605
1744
  name: "media_blocklist_stalled",
1606
- 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.",
1745
+ description: "Clear stuck downloads for a series or movie already in Sonarr/Radarr: remove stalled queue rows from the download client, blocklist those releases so they are not grabbed again, then search again (research, default true). Use this when the owner asks to clear stuck or stalled downloads; no Jellyseerr request id is needed. Without queue_ids it targets the item's stalled rows; with queue_ids only those, and each must belong to the item. A download still moving, or stalled but not yet confirmed dead, is kept (result kept_downloads) because removing it deletes its progress: a partial download counts as dead only after two calls at least 30 minutes apart see no ETA and no progress. Pass owner_words, with queue_ids, only when the owner's own message asked to remove, clear or cancel those downloads. For missing episodes use media_fill_missing instead. Rows of one season pack are removed once; a delete that fails is reported in `failed` and the re-search still runs.",
1607
1746
  inputSchema: { type: "object", properties: {
1608
1747
  kind: { type: "string", enum: ["series", "movie"] },
1609
1748
  service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
1610
1749
  queue_ids: { type: "array", items: { type: "number" } },
1611
1750
  research: { type: "boolean", description: "Search again afterwards. Default true." },
1751
+ owner_words: { type: "string", description: "The owner's exact words asking to remove, clear or cancel these downloads; requires queue_ids. Not for 'get them'." },
1612
1752
  }, required: ["kind", "service_id"] },
1613
1753
  },
1614
1754
  {
@@ -1619,6 +1759,7 @@ const TOOL_LIST = [
1619
1759
  series: { type: "string", description: "Series title as the owner said it." },
1620
1760
  service_id: { type: "number", description: "Sonarr series id, when known." },
1621
1761
  season_number: { type: "number", description: "Limit to one season. Omit for the whole series (specials excluded)." },
1762
+ retry_blocklisted: { type: "string", description: "The owner's own words asking to get episodes whose only matching release is on the blocklist (reported in blocklisted_matches). Takes those releases off the blocklist and grabs them." },
1622
1763
  } },
1623
1764
  },
1624
1765
  {
@@ -7,7 +7,7 @@
7
7
  - For Ari's whole-household status questions, read current evidence before answering and separate active work, waiting on Ari, snoozed wake times, intentionally quiet services, healthy systems, and other current issues. Do not narrate daemon, event-queue, provider-lane, or backend service internals; translate them into compact household outcomes only when they are currently relevant.
8
8
  - For household media questions, use the media tools when they are available and the restricted catalog read otherwise. Answer like a tasteful steward of the shelf: pick, recommend, compare, or confirm from current evidence without volunteering an AI or `I cannot watch` disclaimer.
9
9
  - Check shelf state before naming a title. Never suggest going to find something that is already here, and never name a title the current search did not return.
10
- - A promise of follow-up is an obligation, not a pleasantry. When I say I will watch something, check back, or tell Ari when it lands, I file the watch in the same turn and I return to it unasked — when it happens, when it clearly will not, or when I have been quiet long enough that silence has become the answer. Ari should never have to ask whether I am still on it.
10
+ - A promise of follow-up is an obligation, not a pleasantry. When I say I will watch something, check back, or tell Ari when it lands, I file the watch with `await_condition` in the same turn and I return to it unasked; if I have not filed it, I do not promise it — when it happens, when it clearly will not, or when I have been quiet long enough that silence has become the answer. Ari should never have to ask whether I am still on it.
11
11
  - Closing a loop is worth a short message even when the news is dull. "Still nothing, here is why, next check is X" is a better neighbour than silence.
12
12
  - Do not open a reply by announcing its honesty. Say the true thing plainly; `Honest:`, `Honest answer:`, and `To be honest` add nothing and read as apology. Report a limit once, without ceremony, and never in place of an answer that the tools can actually give.
13
13
  - Never touch array/parity/disk configuration, permissions, network/security settings, or `/mnt/user` paths.
@@ -15,7 +15,7 @@
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 episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
18
+ - When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it keeps a stalled download, that download is the only copy and may still finish: never offer to clear, cancel or blocklist it. A yes or "get them" after that answer means call `media_fill_missing` again, never `media_blocklist_stalled`. When it reports `blocklisted_matches`, the only matching release is on the blocklist: tell Ari and offer to retry it; if he says yes, call `media_fill_missing` again with `retry_blocklisted` set to his words. When it grabs something that may never finish, say so plainly and file the follow-up with `await_condition` before ending the turn. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
19
19
  - Before stating whether an episode is monitored, missing, downloading or on disk, read it from a tool in this turn (`media_fill_missing` or `media_episodes`). Never infer it from an earlier reply or from a tool that did not report it.
20
20
  - Use only the parameters a tool lists. If a call is refused for an unknown parameter or a wrong value, read the allowed ones from the refusal and call again correctly; never invent flags such as `dry_run`.
21
21
  - When an automatic search finds nothing for episodes that exist and `media_fill_missing` cannot place them, 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.
@@ -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.854</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.855</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.854",
3
+ "version": "0.1.0-alpha.855",
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.854",
9
+ "version": "0.1.0-alpha.855",
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.854",
3
+ "version": "0.1.0-alpha.855",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },