@ouro.bot/cli 0.1.0-alpha.862 → 0.1.0-alpha.864
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 +14 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +160 -22
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +1 -0
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +4 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/repertoire/tools-awaiting.js +17 -5
- package/dist/repertoire/tools-unraid.js +1 -1
- package/dist/repertoire/tools.js +1 -1
- package/dist/senses/private-runtime.js +10 -1
- package/npm-shrinkwrap.json +5 -5
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
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.864",
|
|
6
|
+
"changes": [
|
|
7
|
+
"Butler media status, diagnosis and queue now share one stall classification (never_started, unfinished_for_days, no_peers): a partial download with no ETA is reported as stalled with its percent, age and unchanged-since time instead of as downloading, and the diagnosis asks the owner (keep_waiting, find_another_release, give_up) instead of steering to a blocklist that would delete the partial.",
|
|
8
|
+
"A partial download older than 72 hours that still reports a real ETA is now reported as slow but moving, not stalled, and a stalled partial reported beside a never-started download keeps the blocklist advice for the latter."
|
|
9
|
+
]
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"version": "0.1.0-alpha.863",
|
|
13
|
+
"changes": [
|
|
14
|
+
"Fixed await ticks always failing resolve_await and cancel_await: the inbound pipeline rewrote the turn's current session to the private-runtime session, so the relationship guard never matched the await being ticked. The tick now carries its own binding, covering only the await being ticked.",
|
|
15
|
+
"Added media_queue, a read-only Sonarr/Radarr queue view (torrent and usenet: status, trackedDownloadState, percent, timeleft, stalled) for the Butler, and made sanctuary_get_download_queue say it only sees the usenet queue, and the await tick now receives the MCP tools its profile allows (it was given none)."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
4
18
|
{
|
|
5
19
|
"version": "0.1.0-alpha.862",
|
|
6
20
|
"changes": [
|
|
@@ -571,7 +571,8 @@ export async function mediaRequestStatus(a) {
|
|
|
571
571
|
last_grab_title: grabs[0]?.sourceTitle ?? null,
|
|
572
572
|
monitored: entity ? Boolean(entity.monitored) : null,
|
|
573
573
|
}
|
|
574
|
-
|
|
574
|
+
const statusNow = Date.now()
|
|
575
|
+
result.download = computeDownloadState(queueRecs, statusNow, (r) => observeProgress(progressKey(r, kind), r.sizeleft ?? 0, statusNow))
|
|
575
576
|
|
|
576
577
|
const chain = await chainHealth()
|
|
577
578
|
result.chain = chain
|
|
@@ -604,21 +605,51 @@ export function isStalledRow(r, nowMs) {
|
|
|
604
605
|
return left > 0 && ageHours >= STUCK_INCOMPLETE_AFTER_HOURS
|
|
605
606
|
}
|
|
606
607
|
|
|
607
|
-
|
|
608
|
+
const rowPercent = (r) => ((r.size ?? 0) > 0 ? Math.round((((r.size ?? 0) - (r.sizeleft ?? 0)) / r.size) * 100) : null)
|
|
609
|
+
|
|
610
|
+
// The one stall classification, shared by the status, diagnosis, queue, fill and blocklist
|
|
611
|
+
// surfaces. `previous` is the persisted progress memory for this download (or undefined).
|
|
612
|
+
// Returns null while the download may still be moving; otherwise the kind, whether the stall
|
|
613
|
+
// is confirmed (only confirmed stalls are acted on) and the facts the owner is told.
|
|
614
|
+
export function classifyStall(row, nowMs, previous) {
|
|
615
|
+
const why = stallReason(row, nowMs, previous)
|
|
616
|
+
if (!why) return null
|
|
617
|
+
const left = row.sizeleft ?? 0
|
|
618
|
+
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
619
|
+
const unchanged = previous && previous.sizeleft === left ? previous.at : null
|
|
620
|
+
return {
|
|
621
|
+
kind: why.stall, confirmed: why.confirmed, state: why.confirmed ? "stalled" : "suspected",
|
|
622
|
+
percent_complete: rowPercent(row),
|
|
623
|
+
age_hours: row.added ? Math.round((nowMs - Date.parse(row.added)) / 3_600_000) : null,
|
|
624
|
+
no_eta: noEta,
|
|
625
|
+
unchanged_since: unchanged === null ? null : new Date(unchanged).toISOString(),
|
|
626
|
+
unchanged_hours: unchanged === null ? null : Math.round((nowMs - unchanged) / 3_600_000),
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// `previousFor(row)` reads the progress memory for a queue row; without it nothing is confirmed
|
|
631
|
+
// beyond the age rules.
|
|
632
|
+
export function computeDownloadState(queueRecs, nowMs, previousFor = () => undefined) {
|
|
608
633
|
const byDownload = new Map()
|
|
609
634
|
for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
|
|
610
635
|
const downloads = [...byDownload.values()]
|
|
611
636
|
const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
|
|
612
637
|
const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
|
|
613
|
-
const
|
|
614
|
-
.
|
|
615
|
-
.
|
|
638
|
+
const classified = downloads
|
|
639
|
+
.map((r) => ({ r, c: classifyStall(r, nowMs, previousFor(r)) }))
|
|
640
|
+
.filter((x) => x.c)
|
|
641
|
+
.map(({ r, c }) => ({
|
|
616
642
|
title: r.title ?? null,
|
|
617
643
|
added_at: r.added ?? null,
|
|
618
|
-
age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000),
|
|
619
644
|
size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
|
|
620
645
|
queue_id: r.id ?? null,
|
|
646
|
+
...c,
|
|
621
647
|
}))
|
|
648
|
+
const slowItems = downloads
|
|
649
|
+
.filter((r) => isStalledRow(r, nowMs) && (r.sizeleft ?? 0) < (r.size ?? 0) && !classifyStall(r, nowMs, previousFor(r)))
|
|
650
|
+
.map((r) => ({ title: r.title ?? null, percent_complete: rowPercent(r), timeleft: r.timeleft ?? null, age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000) }))
|
|
651
|
+
const stalledItems = classified.filter((i) => i.confirmed)
|
|
652
|
+
const suspectedItems = classified.filter((i) => !i.confirmed)
|
|
622
653
|
return {
|
|
623
654
|
active_downloads: downloads.length,
|
|
624
655
|
active_items: queueRecs.length,
|
|
@@ -630,6 +661,8 @@ export function computeDownloadState(queueRecs, nowMs) {
|
|
|
630
661
|
percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
|
|
631
662
|
stalled: stalledItems.length > 0,
|
|
632
663
|
stalled_items: stalledItems,
|
|
664
|
+
suspected_items: suspectedItems,
|
|
665
|
+
slow_items: slowItems,
|
|
633
666
|
}
|
|
634
667
|
}
|
|
635
668
|
|
|
@@ -645,12 +678,29 @@ export function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
645
678
|
}
|
|
646
679
|
if (result.download.stalled) {
|
|
647
680
|
const items = result.download.stalled_items
|
|
681
|
+
const partials = items.filter((i) => i.kind !== "never_started")
|
|
682
|
+
const neverStarted = items.filter((i) => i.kind === "never_started")
|
|
683
|
+
if (partials.length) {
|
|
684
|
+
// A partial download holds progress that removing it loses, and the owner's standing
|
|
685
|
+
// instruction for this case is to be asked, so nothing here acts on its own.
|
|
686
|
+
const facts = partials.map((i) => {
|
|
687
|
+
const since = i.unchanged_since ? ` since ${i.unchanged_since} (${i.unchanged_hours} hours)` : ""
|
|
688
|
+
return `${i.title ?? "A release"} is stuck at ${i.percent_complete}% with no download activity${since}; no seeders are serving the rest`
|
|
689
|
+
})
|
|
690
|
+
return { stuck_stage: "download", stuck_reason: "stalled_partial_download",
|
|
691
|
+
likely_fix: "ask_owner", human_action_required: true,
|
|
692
|
+
choices: ["keep_waiting", "find_another_release", "give_up"],
|
|
693
|
+
percent_complete: result.download.percent_complete,
|
|
694
|
+
detail: items,
|
|
695
|
+
...(neverStarted.length ? { never_started: { likely_fix: "blocklist_and_research", items: neverStarted } } : {}),
|
|
696
|
+
summary: `${facts.join(". ")}. Ask the owner whether to keep waiting, find another release, or give up; do not remove it on your own, because removing a partial download loses its progress.${neverStarted.length ? ` Separately, ${neverStarted.length} download(s) never started (${neverStarted.map((i) => i.title ?? "a release").join("; ")}); those can be blocklisted and searched again (blocklist_and_research) without asking.` : ""} media_fill_missing can look for a seeded alternative without deleting the partial first; it removes the partial only after a seeded replacement is grabbed.` }
|
|
697
|
+
}
|
|
648
698
|
const oldest = Math.max(...items.map((i) => i.age_hours))
|
|
649
699
|
return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
|
|
650
700
|
likely_fix: "blocklist_and_research", human_action_required: false,
|
|
651
701
|
percent_complete: result.download.percent_complete,
|
|
652
702
|
detail: items,
|
|
653
|
-
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded
|
|
703
|
+
summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded anything in ${oldest} hours, which means no seeders rather than a quiet queue. Nothing was lost by waiting this long; blocklist the release and search again for a different one.` }
|
|
654
704
|
}
|
|
655
705
|
if (result.download.active_downloads > 0) {
|
|
656
706
|
const left = result.download.size_left_bytes
|
|
@@ -660,9 +710,12 @@ export function diagnose({ shelf, result, chain, entity, kind }) {
|
|
|
660
710
|
const what = result.download.active_downloads === 1 && result.download.active_items > 1
|
|
661
711
|
? `a ${result.download.active_items}-episode pack`
|
|
662
712
|
: `${result.download.active_downloads} download(s)`
|
|
713
|
+
const suspected = result.download.suspected_items ?? []
|
|
714
|
+
const slow = result.download.slow_items ?? []
|
|
663
715
|
return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
|
|
664
716
|
percent_complete: pct,
|
|
665
|
-
|
|
717
|
+
...(suspected.length ? { stall_suspected: true, detail: suspected } : {}),
|
|
718
|
+
summary: `Downloading now — ${what}${pct === null ? "" : `, ${pct}% done`}, about ${gb} GB to go.${slow.length ? ` Slow but still moving: ${slow.map((i) => `${i.title ?? "a release"} is ${i.percent_complete}% done after ${i.age_hours} hours with an ETA of ${i.timeleft}`).join("; ")}.` : ""}${suspected.length ? ` It reports no ETA after ${Math.max(...suspected.map((i) => i.age_hours))} hours, so it may be stalled; the next check 30 minutes or more later confirms it. Do not promise an ETA.` : ""}` }
|
|
666
719
|
}
|
|
667
720
|
if (result.download.errors.length) {
|
|
668
721
|
return { stuck_stage: "download", stuck_reason: "download_client_error", likely_fix: "retry_or_replace_release",
|
|
@@ -707,6 +760,12 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
707
760
|
human_action_required: true,
|
|
708
761
|
human_action_reason: "The acquisition chain itself is down (see before.chain.problems). Re-searching cannot help until indexers are restored." }
|
|
709
762
|
}
|
|
763
|
+
if (action === "ask_owner") {
|
|
764
|
+
return { action, result: "owner_decision_needed", before, after: null, human_action_required: true,
|
|
765
|
+
choices: before.diagnosis?.choices ?? ["keep_waiting", "find_another_release", "give_up"],
|
|
766
|
+
...(before.diagnosis?.never_started ? { never_started: before.diagnosis.never_started } : {}),
|
|
767
|
+
human_action_reason: before.diagnosis?.summary ?? "The owner decides." }
|
|
768
|
+
}
|
|
710
769
|
if (a.dry_run) {
|
|
711
770
|
return { action, dry_run: true, result: "would_apply", before,
|
|
712
771
|
would_do: describeAction(action), human_action_required: false }
|
|
@@ -731,9 +790,10 @@ async function mediaDiagnoseAndFix(a) {
|
|
|
731
790
|
// Removing with blocklist=true is what stops the same dead release being
|
|
732
791
|
// grabbed straight back. The search that follows is then free to pick a
|
|
733
792
|
// different one.
|
|
734
|
-
|
|
793
|
+
// Only a download that never started: a partial holds progress and is the owner's call.
|
|
794
|
+
const stalled = (before.download.stalled_items ?? []).filter((i) => i.kind === "never_started")
|
|
735
795
|
if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
|
|
736
|
-
human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
|
|
796
|
+
human_action_reason: "No download has been sitting at zero long enough to call it stalled. A partial download is not removed this way: ask the owner." }
|
|
737
797
|
for (const item of stalled) {
|
|
738
798
|
if (item.queue_id === null) continue
|
|
739
799
|
const client = kind === "series" ? sonarr : radarr
|
|
@@ -857,10 +917,66 @@ async function itemQueue(kind, serviceId) {
|
|
|
857
917
|
return rows.filter((r) => r[idKey] === serviceId)
|
|
858
918
|
}
|
|
859
919
|
|
|
860
|
-
function queueReport(rows) {
|
|
920
|
+
function queueReport(rows, kind = "series") {
|
|
921
|
+
const now = Date.now()
|
|
922
|
+
return rows.map((r) => {
|
|
923
|
+
const c = classifyStall(r, now, peekProgress(progressKey(r, kind), r.sizeleft ?? 0, now))
|
|
924
|
+
return { queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
|
|
925
|
+
size_left_bytes: r.sizeleft ?? 0, stalled: Boolean(c?.confirmed), ...(c ? { stall_kind: c.kind } : {}) }
|
|
926
|
+
})
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ------------------------------------------------------------ tool: media_queue
|
|
930
|
+
|
|
931
|
+
const QUEUE_VIEW_MAX_ROWS = 100
|
|
932
|
+
|
|
933
|
+
// One queue row as the owner's torrent or usenet client reports it through Sonarr/Radarr.
|
|
934
|
+
export function queueRowView(r, nowMs, previous) {
|
|
935
|
+
const c = classifyStall(r, nowMs, previous)
|
|
936
|
+
return {
|
|
937
|
+
queue_id: r.id ?? null, title: r.title ?? null, status: r.status ?? null,
|
|
938
|
+
tracked_download_state: r.trackedDownloadState ?? null, tracked_download_status: r.trackedDownloadStatus ?? null,
|
|
939
|
+
percent: pct(r), timeleft: r.timeleft ?? null, size_left_bytes: r.sizeleft ?? 0, size_bytes: r.size ?? 0,
|
|
940
|
+
protocol: r.protocol ?? null, download_client: r.downloadClient ?? null, indexer: r.indexer ?? null,
|
|
941
|
+
added: r.added ?? null, stalled: Boolean(c?.confirmed),
|
|
942
|
+
...(c ? { stall: { kind: c.kind, state: c.state, percent_complete: c.percent_complete, age_hours: c.age_hours, no_eta: c.no_eta, unchanged_since: c.unchanged_since } } : {}),
|
|
943
|
+
...(r.errorMessage ? { error_message: String(r.errorMessage).slice(0, 300) } : {}),
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Read-only: the Sonarr/Radarr download queue (torrent and usenet alike). Grabs, removes and blocklists nothing.
|
|
948
|
+
export async function mediaQueue(a) {
|
|
949
|
+
if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
|
|
950
|
+
let rows
|
|
951
|
+
let item = null
|
|
952
|
+
let total = null
|
|
953
|
+
if ((a.service_id !== undefined && a.service_id !== null) || a.tmdb_id || a.title) {
|
|
954
|
+
const found = await resolveLibraryItem(a)
|
|
955
|
+
if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
|
|
956
|
+
item = found.item
|
|
957
|
+
rows = await itemQueue(a.kind, item.id)
|
|
958
|
+
} else {
|
|
959
|
+
rows = []
|
|
960
|
+
const client = arrFor(a.kind)
|
|
961
|
+
for (let page = 1; page <= QUEUE_MAX_PAGES; page++) {
|
|
962
|
+
const q = await client("/queue", { query: { page, pageSize: QUEUE_PAGE_SIZE, ...(a.kind === "series" ? { includeSeries: true } : {}) } })
|
|
963
|
+
const records = q?.records ?? []
|
|
964
|
+
rows.push(...records)
|
|
965
|
+
if (Number.isFinite(Number(q?.totalRecords))) total = Number(q.totalRecords)
|
|
966
|
+
if (!records.length || !(Number(q?.totalRecords) > page * QUEUE_PAGE_SIZE) || rows.length >= QUEUE_VIEW_MAX_ROWS) break
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
total = Math.max(total ?? 0, rows.length)
|
|
861
970
|
const now = Date.now()
|
|
862
|
-
|
|
863
|
-
|
|
971
|
+
// Looking records the sighting in the local progress memory (never in Sonarr/Radarr), so this call can confirm a stall too.
|
|
972
|
+
const shown = rows.slice(0, QUEUE_VIEW_MAX_ROWS).map((r) => queueRowView(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now)))
|
|
973
|
+
return {
|
|
974
|
+
kind: a.kind, ...(item ? { service_id: item.id, title: item.title } : {}),
|
|
975
|
+
count: total, shown: shown.length, ...(total > shown.length ? { truncated: true } : {}), queue: shown,
|
|
976
|
+
summary: !total ? "Nothing for this is in the Sonarr/Radarr queue."
|
|
977
|
+
: `${total} queue row${total === 1 ? "" : "s"}${total > shown.length ? ` (showing ${shown.length})` : ""}; ${shown.filter((r) => r.stalled).length} stalled${shown.some((r) => r.stall?.state === "suspected") ? `, ${shown.filter((r) => r.stall?.state === "suspected").length} suspected (no ETA, to be confirmed by a check 30 minutes later)` : ""}.`,
|
|
978
|
+
note: "Sonarr/Radarr do not report seeders or peers. A row stuck at the same percent with no timeleft is the stall signal; for a release's seeder count use media_indexer_search.",
|
|
979
|
+
}
|
|
864
980
|
}
|
|
865
981
|
|
|
866
982
|
async function runSearch(kind, serviceId) {
|
|
@@ -875,7 +991,7 @@ export async function mediaSearchNow(a) {
|
|
|
875
991
|
if (!found.item) return { result: found.result, kind: a.kind, candidates: found.candidates }
|
|
876
992
|
const { item } = found
|
|
877
993
|
const command = await runSearch(a.kind, item.id)
|
|
878
|
-
return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
|
|
994
|
+
return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id), a.kind) }
|
|
879
995
|
}
|
|
880
996
|
|
|
881
997
|
// Whether a download may be removed without the owner naming it. A download that has never
|
|
@@ -902,10 +1018,11 @@ export async function mediaBlocklistStalled(a) {
|
|
|
902
1018
|
if (Array.isArray(a.queue_ids) && a.queue_ids.length) {
|
|
903
1019
|
const own = new Set(rows.map((r) => r.id))
|
|
904
1020
|
const foreign = a.queue_ids.filter((id) => !own.has(id))
|
|
905
|
-
if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows) }
|
|
1021
|
+
if (foreign.length) return { result: "queue_id_not_for_item", foreign_queue_ids: foreign, queue: queueReport(rows, a.kind) }
|
|
906
1022
|
targets = rows.filter((r) => a.queue_ids.includes(r.id))
|
|
907
1023
|
} else {
|
|
908
|
-
|
|
1024
|
+
// Candidates only: removalVerdict below confirms a no-ETA partial against the progress memory.
|
|
1025
|
+
targets = rows.filter((r) => stallReason(r, now, undefined) !== null)
|
|
909
1026
|
}
|
|
910
1027
|
// A season pack is many queue rows sharing one downloadId; deleting one removes
|
|
911
1028
|
// the download for all, so delete once per download.
|
|
@@ -922,7 +1039,7 @@ export async function mediaBlocklistStalled(a) {
|
|
|
922
1039
|
// deleted on "yes please get them").
|
|
923
1040
|
const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
|
|
924
1041
|
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) }
|
|
1042
|
+
return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows, a.kind) }
|
|
926
1043
|
}
|
|
927
1044
|
const kept = []
|
|
928
1045
|
if (!ownerWords) {
|
|
@@ -934,10 +1051,10 @@ export async function mediaBlocklistStalled(a) {
|
|
|
934
1051
|
})
|
|
935
1052
|
}
|
|
936
1053
|
if (!targets.length && kept.length) {
|
|
937
|
-
return { result: "kept_downloads", kept, queue: queueReport(rows),
|
|
1054
|
+
return { result: "kept_downloads", kept, queue: queueReport(rows, a.kind),
|
|
938
1055
|
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
1056
|
}
|
|
940
|
-
if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
|
|
1057
|
+
if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows, a.kind) }
|
|
941
1058
|
const removed = []
|
|
942
1059
|
const failed = []
|
|
943
1060
|
for (const row of targets) {
|
|
@@ -951,7 +1068,7 @@ export async function mediaBlocklistStalled(a) {
|
|
|
951
1068
|
}
|
|
952
1069
|
}
|
|
953
1070
|
const command = a.research === false ? null : await runSearch(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)) }
|
|
1071
|
+
return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId), a.kind) }
|
|
955
1072
|
}
|
|
956
1073
|
|
|
957
1074
|
// ------------------------------- tools: episodes / releases / blocklist / import
|
|
@@ -1282,11 +1399,16 @@ function matchUnblocked(release, { seriesId, seriesTitle, seriesYear, wanted, ha
|
|
|
1282
1399
|
// `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
|
|
1283
1400
|
// download is reported but never replaced.
|
|
1284
1401
|
export function stallReason(row, nowMs, previous) {
|
|
1285
|
-
if (isStalledRow(row, nowMs)) return { stall: (row.sizeleft ?? 0) >= (row.size ?? 0) ? "never_started" : "unfinished_for_days", confirmed: true }
|
|
1286
1402
|
const left = row.sizeleft ?? 0
|
|
1403
|
+
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
1404
|
+
const unchangedFor30 = Boolean(previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS)
|
|
1405
|
+
if (isStalledRow(row, nowMs)) {
|
|
1406
|
+
if (left >= (row.size ?? 0)) return { stall: "never_started", confirmed: true }
|
|
1407
|
+
// Old and unfinished is not dead by age alone: a slow torrent with a real ETA is still moving.
|
|
1408
|
+
return noEta || unchangedFor30 ? { stall: "unfinished_for_days", confirmed: true } : null
|
|
1409
|
+
}
|
|
1287
1410
|
const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
|
|
1288
1411
|
const moving = row.status === "downloading" || row.status === "warning"
|
|
1289
|
-
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
1290
1412
|
if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
|
|
1291
1413
|
const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
|
|
1292
1414
|
return { stall: "no_peers", confirmed: Boolean(unchanged) }
|
|
@@ -1331,6 +1453,11 @@ function saveProgress(nowMs) {
|
|
|
1331
1453
|
process.stderr.write(`media-mcp: download progress not saved: ${e.message}\n`)
|
|
1332
1454
|
}
|
|
1333
1455
|
}
|
|
1456
|
+
// Read-only look at the memory: what observeProgress would return, without recording a sighting.
|
|
1457
|
+
function peekProgress(key, sizeleft, nowMs) {
|
|
1458
|
+
const previous = loadProgress().get(key)
|
|
1459
|
+
return previous && nowMs - previous.seen <= PROGRESS_CONTINUITY_MS ? previous : undefined
|
|
1460
|
+
}
|
|
1334
1461
|
export const progressKey = (row, kind = "series") => `${kind}:${row.downloadId ?? `row:${row.id}`}`.toUpperCase()
|
|
1335
1462
|
function observeProgress(downloadId, sizeleft, nowMs) {
|
|
1336
1463
|
const seen = loadProgress()
|
|
@@ -1730,6 +1857,16 @@ const TOOL_LIST = [
|
|
|
1730
1857
|
request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
|
|
1731
1858
|
} },
|
|
1732
1859
|
},
|
|
1860
|
+
{
|
|
1861
|
+
name: "media_queue",
|
|
1862
|
+
description: "Read-only view of the Sonarr/Radarr download queue, torrent and usenet alike: for each row the title, status, trackedDownloadState, percent, timeleft, size left, download client and whether it is stalled. Use this to see whether a series or movie download is progressing (this is the tool for torrent downloads; sanctuary_get_download_queue only sees the usenet/SABnzbd queue). Give service_id, tmdb_id or title for one item, or omit them for the whole queue of that kind. It grabs, removes and blocklists nothing. Seeders and peers are not reported by Sonarr/Radarr; use media_indexer_search for a release's seeders.",
|
|
1863
|
+
inputSchema: { type: "object", properties: {
|
|
1864
|
+
kind: { type: "string", enum: ["series", "movie"] },
|
|
1865
|
+
service_id: { type: "number", description: "Sonarr series id or Radarr movie id." },
|
|
1866
|
+
tmdb_id: { type: "number" },
|
|
1867
|
+
title: { type: "string", description: "Exact or near-exact library title." },
|
|
1868
|
+
}, required: ["kind"] },
|
|
1869
|
+
},
|
|
1733
1870
|
{
|
|
1734
1871
|
name: "media_search_now",
|
|
1735
1872
|
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.",
|
|
@@ -1856,6 +1993,7 @@ const HANDLERS = {
|
|
|
1856
1993
|
media_chain_health: mediaChainHealth,
|
|
1857
1994
|
media_play_or_resolve: mediaPlayOrResolve,
|
|
1858
1995
|
media_search_now: mediaSearchNow,
|
|
1996
|
+
media_queue: mediaQueue,
|
|
1859
1997
|
media_blocklist_stalled: mediaBlocklistStalled,
|
|
1860
1998
|
media_episodes: mediaEpisodes,
|
|
1861
1999
|
media_fill_missing: mediaFillMissing,
|
|
@@ -16,6 +16,7 @@
|
|
|
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
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
|
+
- To see whether a series or movie download is progressing, including a torrent, call `media_queue`. `sanctuary_get_download_queue` reads only the usenet queue: an idle answer from it says nothing about a Sonarr or torrent download. A repeated ticking check keeps the download alive with `resolve_await` verdict `no` until `media_fill_missing` reports `goal_met`.
|
|
19
20
|
- 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
21
|
- 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
22
|
- 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.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 2,
|
|
3
3
|
"profiles": {
|
|
4
4
|
"sanctuary-owner": {
|
|
5
|
-
"version":
|
|
5
|
+
"version": 14,
|
|
6
6
|
"contextScopes": [
|
|
7
7
|
"household.status",
|
|
8
8
|
"household.policy",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"media_chain_health",
|
|
67
67
|
"media_play_or_resolve",
|
|
68
68
|
"media_search_now",
|
|
69
|
+
"media_queue",
|
|
69
70
|
"media_blocklist_stalled",
|
|
70
71
|
"media_episodes",
|
|
71
72
|
"media_indexer_search",
|
|
@@ -141,7 +142,7 @@
|
|
|
141
142
|
]
|
|
142
143
|
},
|
|
143
144
|
"sanctuary-agent-peer": {
|
|
144
|
-
"version":
|
|
145
|
+
"version": 5,
|
|
145
146
|
"toolNames": [
|
|
146
147
|
"external_event_disposition",
|
|
147
148
|
"query_active_work",
|
|
@@ -198,6 +199,7 @@
|
|
|
198
199
|
"media_chain_health",
|
|
199
200
|
"media_play_or_resolve",
|
|
200
201
|
"media_search_now",
|
|
202
|
+
"media_queue",
|
|
201
203
|
"media_blocklist_stalled",
|
|
202
204
|
"media_episodes",
|
|
203
205
|
"media_indexer_search",
|
|
@@ -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.864</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": 14, "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");
|
|
@@ -83,6 +83,20 @@ function validateName(name) {
|
|
|
83
83
|
return "name must be alphanumeric, underscores, or hyphens";
|
|
84
84
|
return null;
|
|
85
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* The relationship scope a resolve/cancel is judged against. A turn that is ticking an await carries that await's own
|
|
88
|
+
* binding (`awaitTick`), because the pipeline rewrites `currentSession` to the private-runtime session; that binding covers the
|
|
89
|
+
* ticked await only, so a tick can never touch any other await. Every other turn is judged by its current session.
|
|
90
|
+
*/
|
|
91
|
+
function relationshipScopeFor(ctx, awaitName) {
|
|
92
|
+
const tick = ctx.awaitTick;
|
|
93
|
+
if (tick) {
|
|
94
|
+
return tick.awaitName === awaitName
|
|
95
|
+
? { session: { friendId: tick.friendId, channel: tick.channel, key: tick.key }, requestId: tick.requestId ?? undefined }
|
|
96
|
+
: { session: undefined, requestId: undefined };
|
|
97
|
+
}
|
|
98
|
+
return { session: ctx.currentSession, requestId: ctx.relationshipAuthorization?.requestId };
|
|
99
|
+
}
|
|
86
100
|
function readAwaitDefinition(agentRoot, name) {
|
|
87
101
|
const filePath = awaitFilePath(agentRoot, name);
|
|
88
102
|
try {
|
|
@@ -537,10 +551,9 @@ exports.awaitingToolDefinitions = [
|
|
|
537
551
|
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
538
552
|
const agentName = (0, identity_1.getAgentName)();
|
|
539
553
|
if (ctx?.relationshipAuthorization) {
|
|
540
|
-
const session = ctx.currentSession;
|
|
541
|
-
const requestId = ctx.relationshipAuthorization.requestId;
|
|
542
|
-
const existing = readAwaitDefinition(agentRoot, String(a.name ?? ""));
|
|
543
554
|
const awaitName = String(a.name ?? "");
|
|
555
|
+
const { session, requestId } = relationshipScopeFor(ctx, awaitName);
|
|
556
|
+
const existing = readAwaitDefinition(agentRoot, awaitName);
|
|
544
557
|
let binding = null;
|
|
545
558
|
if (session?.channel === "external-event" && existing?.wake_at) {
|
|
546
559
|
binding = inspectExternalEventAwait(agentName, { recordPath: session.key, awaitName, wakeAt: existing.wake_at });
|
|
@@ -590,8 +603,7 @@ exports.awaitingToolDefinitions = [
|
|
|
590
603
|
const agentRoot = (0, identity_1.getAgentRoot)();
|
|
591
604
|
const agentName = (0, identity_1.getAgentName)();
|
|
592
605
|
if (ctx?.relationshipAuthorization) {
|
|
593
|
-
const session = ctx.
|
|
594
|
-
const requestId = ctx.relationshipAuthorization.requestId;
|
|
606
|
+
const { session, requestId } = relationshipScopeFor(ctx, String(a.name ?? ""));
|
|
595
607
|
if (!session || !requestId || !hasActiveRelationshipFollowUp(agentRoot, {
|
|
596
608
|
friendId: session.friendId,
|
|
597
609
|
channel: session.channel,
|
|
@@ -444,7 +444,7 @@ exports.unraidToolDefinitions = [
|
|
|
444
444
|
readDefinition("unraid_get_system", "Read bounded Sanctuary system and version health.", "getSystem"),
|
|
445
445
|
readDefinition("sanctuary_get_install_state", "Check whether this Butler installation matches its verified release and report any repair needed.", "getInstallState"),
|
|
446
446
|
readDefinition("unraid_check_services", "Freshly check the fixed public Sanctuary service endpoints and return bounded status with an observation timestamp.", "checkServices"),
|
|
447
|
-
readDefinition("sanctuary_get_download_queue", "Read the bounded live
|
|
447
|
+
readDefinition("sanctuary_get_download_queue", "Read the bounded live usenet (SABnzbd) download queue state only, including whether the spend guard has paused it. It cannot see torrents or Sonarr/Radarr progress: an idle result here does not mean a download is absent. For a series or movie download, including torrents, use media_queue.", "getDownloadQueue"),
|
|
448
448
|
{
|
|
449
449
|
tool: { type: "function", function: { name: "sanctuary_resume_download_queue", description: "Resume the household download queue after Ari confirms the provider is ready, then independently verify paused=false. This can spend prepaid download credit.", parameters: emptyParameters } },
|
|
450
450
|
handler: async (_args, ctx) => JSON.stringify(ctx?.sanctuary ? await ctx.sanctuary.resumeDownloadQueue() : JSON.parse(missingRuntime())),
|
package/dist/repertoire/tools.js
CHANGED
|
@@ -73,7 +73,7 @@ const SANCTUARY_RELATIONSHIP_BASE_TOOLS = new Set([
|
|
|
73
73
|
exports.SANCTUARY_MCP_PROVIDED_TOOLS = new Set([
|
|
74
74
|
"media_search", "media_request", "media_request_status",
|
|
75
75
|
"media_diagnose_and_fix", "media_chain_health", "media_play_or_resolve",
|
|
76
|
-
"media_search_now", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
|
|
76
|
+
"media_search_now", "media_queue", "media_blocklist_stalled", "media_episodes", "media_indexer_search", "media_release_search", "media_release_grab", "media_blocklist", "media_manual_import", "media_fill_missing",
|
|
77
77
|
]);
|
|
78
78
|
/** Channels a Sanctuary turn can arrive on from the host itself, not from a remote party. */
|
|
79
79
|
const SANCTUARY_LOCAL_CHANNELS = new Set(["inner", "cli", "mcp"]);
|
|
@@ -1383,8 +1383,9 @@ async function runPrivateRuntimeTurn(options) {
|
|
|
1383
1383
|
})
|
|
1384
1384
|
.filter((tool) => !externalEventExcludedTools.has(tool.function.name))
|
|
1385
1385
|
: undefined;
|
|
1386
|
+
// The await tick is relationship-scoped but still needs the MCP tools its profile allows (media_queue for a download await).
|
|
1386
1387
|
const relationshipAwaitToolsResolved = relationshipAwait
|
|
1387
|
-
? (0, tools_1.getToolsForChannel)(innerCapabilities, undefined, undefined, undefined,
|
|
1388
|
+
? (0, tools_1.getToolsForChannel)(innerCapabilities, undefined, undefined, undefined, mcpManager, undefined, {
|
|
1388
1389
|
agentName, relationshipAuthorization: relationshipAwait.relationshipAuthorization,
|
|
1389
1390
|
})
|
|
1390
1391
|
: undefined;
|
|
@@ -1509,6 +1510,14 @@ async function runPrivateRuntimeTurn(options) {
|
|
|
1509
1510
|
friendStore: relationshipAwait.store,
|
|
1510
1511
|
context: relationshipAwait.context,
|
|
1511
1512
|
currentSession: relationshipAwait.currentSession,
|
|
1513
|
+
// The pipeline rewrites currentSession to the private-runtime session; resolve_await/cancel_await need the await's own binding.
|
|
1514
|
+
awaitTick: Object.freeze({
|
|
1515
|
+
awaitName: parsedAwait.name,
|
|
1516
|
+
friendId: relationshipAwait.currentSession.friendId,
|
|
1517
|
+
channel: relationshipAwait.currentSession.channel,
|
|
1518
|
+
key: relationshipAwait.currentSession.key,
|
|
1519
|
+
requestId: relationshipAwaitCoordinatesValue.requestId,
|
|
1520
|
+
}),
|
|
1512
1521
|
relationshipAuthorization: relationshipAwait.relationshipAuthorization,
|
|
1513
1522
|
} : {}),
|
|
1514
1523
|
...(options?.externalEvent ? {
|
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.864",
|
|
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.864",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|
|
@@ -7034,9 +7034,9 @@
|
|
|
7034
7034
|
}
|
|
7035
7035
|
},
|
|
7036
7036
|
"node_modules/shell-quote": {
|
|
7037
|
-
"version": "1.
|
|
7038
|
-
"resolved": "https://
|
|
7039
|
-
"integrity": "
|
|
7037
|
+
"version": "1.12.0",
|
|
7038
|
+
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz",
|
|
7039
|
+
"integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==",
|
|
7040
7040
|
"license": "MIT",
|
|
7041
7041
|
"engines": {
|
|
7042
7042
|
"node": ">= 0.4"
|