@ouro.bot/cli 0.1.0-alpha.853 → 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,22 @@
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
+ },
12
+ {
13
+ "version": "0.1.0-alpha.854",
14
+ "changes": [
15
+ "The Butler gets missing episodes in one call: media_fill_missing re-reads Sonarr, matches releases to episodes only on evidence (episode title, season plus air year, or a Sonarr parse that agrees with the TVDB or scene code), grabs, imports by title, replaces a stalled download only after the stall is confirmed and a seeded replacement is grabbed, searches least recently searched seasons first so repeat calls reach every season, and reports what is on the shelf, in flight, deferred and not found with indexer evidence.",
16
+ "Long tool-heavy turns get more steps while tools make progress (base 8, +4 per extension, ceiling 24); when the budget runs out the agent settles with a progress report instead of saying it ran out of steps.",
17
+ "Tools refuse invented parameters by name: media tool schemas are closed, and the engine names an unknown parameter and an enum's allowed values. MCP tools can declare a longer call timeout for slow indexer searches."
18
+ ]
19
+ },
4
20
  {
5
21
  "version": "0.1.0-alpha.853",
6
22
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.853",
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
@@ -1145,9 +1181,497 @@ export async function mediaManualImport(a) {
1145
1181
  return { mode: "import", imported: a.files.map((f) => ({ path: f.path, episode_ids: f.episode_ids })), ...(replacing.length ? { replacing } : {}), command: { id: c?.id ?? null, name: c?.name ?? "ManualImport", status: c?.status ?? null } }
1146
1182
  }
1147
1183
 
1184
+ // ------------------------------------------------------- tool: fill missing
1185
+ //
1186
+ // The whole "get the missing episodes" job as one deterministic pass, so a long
1187
+ // request no longer depends on the model chaining a dozen calls correctly
1188
+ // (2026-10-03: The Chef Show took an owner hint, a wrong pack, an invented
1189
+ // parameter and the step cap). Each call re-reads Sonarr, so it is safe to call
1190
+ // again: in-flight grabs are never repeated, and once nothing is missing it says
1191
+ // stop. Release names follow streaming numbering, so a release is matched to
1192
+ // episodes only on evidence: an episode title, a season plus the air year, or a
1193
+ // Sonarr parse whose own numbering agrees with the episode's TVDB or scene code.
1194
+
1195
+ const FILL_MAX_GRABS = 3
1196
+ const FILL_DEADLINE_MS = 150_000
1197
+ const FILL_RECENT_GRAB_MS = 15 * 60_000
1198
+ const FILL_EVIDENCE_ROWS = 5
1199
+ // A torrent with no ETA is called stalled only after its remaining size has not moved for this long.
1200
+ const FILL_STALL_CONFIRM_MS = 30 * 60_000
1201
+
1202
+ // Years in a release name, ignoring resolutions such as 1920x1080 and 2160p.
1203
+ const yearsIn = (title) => new Set((String(title ?? "").match(/(?<![0-9x])(?:19|20)[0-9]{2}(?![0-9px])/gi) ?? []).map(Number))
1204
+ const airYear = (e) => (e.airDate ? Number(String(e.airDate).slice(0, 4)) : null)
1205
+ const hasWord = (haystack, needle) => needle.length > 0 && ` ${haystack} `.includes(` ${needle} `)
1206
+ const rejectionTexts = (r) => (Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : [])
1207
+ // "Doctor Who (2005)" and "The Office (US)" are named without the suffix in most releases.
1208
+ export const seriesBaseTitle = (title) => normalizeTitle(String(title ?? "").replace(/\s*\((?:19|20)[0-9]{2}\)\s*$/, "").replace(/\s*\((?:us|uk|au|nz|ca)\)\s*$/i, ""))
1209
+
1210
+ // An episode title is evidence only when it is distinctive enough not to appear by chance:
1211
+ // several words, or one long word that is not a generic release term.
1212
+ const GENERIC_TITLE_WORDS = new Set(["special", "specials", "episode", "finale", "premiere", "extended", "complete", "unknown", "untitled", "trailer", "preview", "bonus"])
1213
+ function episodeTitleIn(releaseNorm, episode) {
1214
+ const t = normalizeTitle(episode.title)
1215
+ const distinctive = t.includes(" ") ? t.length >= 6 : t.length >= 7 && !GENERIC_TITLE_WORDS.has(t) && !/^[0-9]+$/.test(t)
1216
+ return distinctive && hasWord(releaseNorm, t)
1217
+ }
1218
+
1219
+ // Season named in a release title: "Season 2", "Temporada 2", "S02" (not "S02E01").
1220
+ export function seasonInTitle(title) {
1221
+ const s = String(title ?? "")
1222
+ const word = s.match(/\b(?:season|temporada|saison|staffel)\s*0*([0-9]{1,2})\b/i)
1223
+ if (word) return Number(word[1])
1224
+ const code = s.match(/\bS0*([0-9]{1,2})(?![0-9]|\s*E[0-9])\b/i)
1225
+ return code ? Number(code[1]) : null
1226
+ }
1227
+
1228
+ // Sonarr's mapping is trusted only when the numbering in the name is the episode's own or scene code.
1229
+ function parseAgrees(release, episode) {
1230
+ const season = release.seasonNumber
1231
+ const numbers = Array.isArray(release.episodeNumbers) ? release.episodeNumbers : []
1232
+ const codes = [[episode.seasonNumber, episode.episodeNumber], [episode.sceneSeasonNumber, episode.sceneEpisodeNumber]]
1233
+ return codes.some(([s, e]) => Number.isInteger(s) && s === season && (numbers.length ? numbers.includes(e) : Boolean(release.fullSeason)))
1234
+ }
1235
+
1236
+ // Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
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) {
1248
+ const title = release.title ?? ""
1249
+ const norm = normalizeTitle(title)
1250
+ const base = seriesBaseTitle(seriesTitle)
1251
+ if (!hasWord(norm, base)) return { reject: "other_series" }
1252
+ if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
1253
+ if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
1254
+ const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
1255
+ // The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
1256
+ // unless the missing episodes aired that year too.
1257
+ const years = new Set([...yearsIn(title)].filter((y) => y !== seriesYear || wantedYears.has(y)))
1258
+ if (years.size && ![...years].some((y) => wantedYears.has(y))) return { reject: "year_mismatch", years: [...years] }
1259
+ // Episode titles are looked for in the name with the series name taken out.
1260
+ const rest = ` ${norm} `.replace(` ${base} `, " ").trim()
1261
+ const byTitle = wanted.filter((e) => episodeTitleIn(rest, e))
1262
+ if (byTitle.length) return { episode_ids: byTitle.map((e) => e.id), basis: "episode_title" }
1263
+ if (have.some((e) => episodeTitleIn(rest, e))) return { reject: "holds_episodes_we_have" }
1264
+ const wantedById = new Map(wanted.map((e) => [e.id, e]))
1265
+ const mapped = (release.mappedEpisodeInfo ?? []).map((e) => e.id).filter((id) => id !== undefined && id !== null)
1266
+ const mappedWanted = mapped.filter((id) => wantedById.has(id))
1267
+ if (mappedWanted.length) {
1268
+ if (mappedWanted.every((id) => parseAgrees(release, wantedById.get(id)))) return { episode_ids: mappedWanted, basis: "sonarr_parse" }
1269
+ return { reject: "parse_disagrees_with_numbering" }
1270
+ }
1271
+ // A season pack named with the season and the air year of the missing episodes
1272
+ // ("The Chef Show [Season 2] (2020)"), whatever Sonarr's scene mapping says it holds.
1273
+ const season = seasonInTitle(title) ?? (release.fullSeason ? release.seasonNumber ?? null : null)
1274
+ if (season !== null && years.size && !/\bE[0-9]{1,3}\b/i.test(title)) {
1275
+ const covered = wanted.filter((e) => e.seasonNumber === season && years.has(airYear(e)))
1276
+ if (covered.length) return { episode_ids: covered.map((e) => e.id), basis: "season_and_air_year" }
1277
+ }
1278
+ return { reject: mapped.length ? "holds_episodes_we_have" : "no_episode_match" }
1279
+ }
1280
+
1281
+ // Why a download is not going to finish on its own, or null while it may still be moving.
1282
+ // `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
1283
+ // download is reported but never replaced.
1284
+ 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
+ const left = row.sizeleft ?? 0
1287
+ const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
1288
+ const moving = row.status === "downloading" || row.status === "warning"
1289
+ const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
1290
+ if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
1291
+ const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
1292
+ return { stall: "no_peers", confirmed: Boolean(unchanged) }
1293
+ }
1294
+
1295
+ // Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
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).
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()
1335
+ function observeProgress(downloadId, sizeleft, 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)
1343
+ return previous
1344
+ }
1345
+
1346
+ const importDone = (row) => (row.sizeleft ?? 0) === 0 || row.status === "completed"
1347
+ const importNeeded = (row) => importDone(row) && ["importPending", "importBlocked", "importFailed"].includes(row.trackedDownloadState)
1348
+
1349
+ async function seriesGrabHistory(seriesId) {
1350
+ const list = await sonarr("/history/series", { query: { seriesId, eventType: "grabbed" } })
1351
+ return Array.isArray(list) ? list : []
1352
+ }
1353
+
1354
+ async function runningImportFor(downloadId) {
1355
+ const list = await sonarr("/command")
1356
+ return (Array.isArray(list) ? list : []).some((c) => c.name === "ManualImport" && ["queued", "started"].includes(c.status)
1357
+ && (c.body?.files ?? []).some((f) => String(f.downloadId ?? "").toUpperCase() === String(downloadId).toUpperCase()))
1358
+ }
1359
+
1360
+ // Map a finished download's files to the wanted episodes by title. Only a file whose name
1361
+ // holds exactly one wanted episode title, and the only file holding it, is imported.
1362
+ async function importByTitle(seriesId, seriesTitle, downloadId, wantedEps) {
1363
+ if (await runningImportFor(downloadId)) return { action: "import_already_running", download_id: downloadId }
1364
+ const rows = await previewRows(seriesId, downloadId)
1365
+ const base = seriesBaseTitle(seriesTitle)
1366
+ const picks = []
1367
+ const unmatched = []
1368
+ for (const r of rows) {
1369
+ const rest = ` ${normalizeTitle(`${r.name ?? ""} ${r.relativePath ?? ""}`)} `.replace(` ${base} `, " ").trim()
1370
+ const ids = wantedEps.filter((e) => episodeTitleIn(rest, e)).map((e) => e.id)
1371
+ if (ids.length === 1) picks.push({ row: r, ids })
1372
+ else unmatched.push(r.name ?? r.path)
1373
+ }
1374
+ const counts = new Map()
1375
+ for (const p of picks) counts.set(p.ids[0], (counts.get(p.ids[0]) ?? 0) + 1)
1376
+ const unique = picks.filter((p) => counts.get(p.ids[0]) === 1)
1377
+ for (const p of picks) if (!unique.includes(p)) unmatched.push(p.row.name ?? p.row.path)
1378
+ if (!unique.length) {
1379
+ return { action: "import_needs_mapping", download_id: downloadId, unmatched_files: unmatched,
1380
+ note: "No file names an episode title. Map them with media_manual_import (preview, then import by title from media_episodes)." }
1381
+ }
1382
+ const files = unique.map(({ row: r, ids }) => ({ path: r.path, seriesId, episodeIds: ids, quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
1383
+ indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId }))
1384
+ const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
1385
+ return { action: "imported_by_title", download_id: downloadId, command_id: c?.id ?? null,
1386
+ files: unique.map(({ row: r, ids }) => ({ file: r.name ?? r.path, episode_ids: ids })), ...(unmatched.length ? { unmatched_files: unmatched } : {}) }
1387
+ }
1388
+
1389
+ const pct = (row) => ((row.size ?? 0) > 0 ? Math.round((((row.size ?? 0) - (row.sizeleft ?? 0)) / row.size) * 1000) / 10 : null)
1390
+ const fillRunning = new Set()
1391
+ // When each season or episode of a series was last searched, so repeat calls reach the ones a deadline cut off.
1392
+ const lastSearched = new Map()
1393
+ const FILL_ACTION_GRACE_MS = 45_000
1394
+
1395
+ export async function mediaFillMissing(a, opts = {}) {
1396
+ const found = await resolveLibraryItem({ kind: "series", service_id: a.service_id, title: a.series })
1397
+ if (!found.item) return { result: found.result, candidates: found.candidates, message: "Name the series by its Sonarr service_id or exact title." }
1398
+ const seriesId = found.item.id
1399
+ // One pass per series at a time: a retry after a timeout must not grab beside a pass still running.
1400
+ if (fillRunning.has(seriesId)) return { result: "already_running", series_id: seriesId, message: "A media_fill_missing pass for this series is still working. Wait a minute and call again; do not act by hand." }
1401
+ fillRunning.add(seriesId)
1402
+ try {
1403
+ return await fillPass(a, seriesId, found.item.title, opts)
1404
+ } finally {
1405
+ fillRunning.delete(seriesId)
1406
+ }
1407
+ }
1408
+
1409
+ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadlineMs = FILL_DEADLINE_MS } = {}) {
1410
+ const startedAt = Date.now()
1411
+ const remainingMs = () => deadlineMs - (Date.now() - startedAt)
1412
+ const timeLeft = () => remainingMs() > 0
1413
+ // Grabs and imports may run a little past the search deadline, never past the harness timeout.
1414
+ const mayAct = () => remainingMs() > -FILL_ACTION_GRACE_MS
1415
+ const errors = []
1416
+ // Each step after the first reads is isolated: one failure is reported, the rest of the pass and its record survive.
1417
+ const attempt = async (step, fn) => {
1418
+ try { return await fn() } catch (e) { errors.push({ step, error: e.code ?? "failed", message: e.message }); return undefined }
1419
+ }
1420
+ const seriesYear = Number((await attempt("read series", () => sonarr(`/series/${seriesId}`)))?.year) || null
1421
+ const season = a.season_number === undefined || a.season_number === null ? null : Number(a.season_number)
1422
+ const scope = (await seriesEpisodes(seriesId, season ?? undefined)).filter((e) => (season === null ? e.seasonNumber > 0 : true))
1423
+ const aired = (e) => Boolean(e.airDateUtc ?? e.airDate) && Date.parse(e.airDateUtc ?? e.airDate) <= nowMs
1424
+ const missing = scope.filter((e) => !e.hasFile && aired(e))
1425
+ const wanted = missing.filter((e) => e.monitored)
1426
+ const have = scope.filter((e) => e.hasFile)
1427
+ const code = (e) => ({ episode_id: e.id, code: sxe(e), title: e.title ?? null, air_date: e.airDate ?? null })
1428
+ const base = { series_id: seriesId, series: seriesTitle, season_number: season, checked_at: new Date(nowMs).toISOString(),
1429
+ on_shelf: { count: have.length, of: scope.filter(aired).length },
1430
+ ...(missing.length > wanted.length ? { not_monitored_missing: missing.filter((e) => !e.monitored).map(code) } : {}) }
1431
+ if (!wanted.length) {
1432
+ return { ...base, goal_met: true, in_flight: [], not_found: [], actions: [],
1433
+ summary: `Nothing monitored is missing: ${have.length} of ${base.on_shelf.of} aired episodes are on the shelf.`,
1434
+ next_step: "Goal met. Stop here and tell the owner; do not search or grab anything else." }
1435
+ }
1436
+
1437
+ const actions = []
1438
+ const byId = new Map(wanted.map((e) => [e.id, e]))
1439
+ const queue = await itemQueue("series", seriesId)
1440
+ const history = await seriesGrabHistory(seriesId)
1441
+ const grabbedFor = new Map()
1442
+ for (const h of history) {
1443
+ // A client without download ids (e.g. a blackhole) is tracked by release title instead.
1444
+ const key = h.downloadId ? String(h.downloadId).toUpperCase() : h.sourceTitle ? `TITLE:${normalizeTitle(h.sourceTitle)}` : null
1445
+ if (!key) continue
1446
+ if (!grabbedFor.has(key)) grabbedFor.set(key, { ids: new Set(), date: h.date, title: h.sourceTitle })
1447
+ grabbedFor.get(key).ids.add(h.episodeId)
1448
+ }
1449
+ // Sonarr re-parses a tracked download's name, so its queue rows can point at the
1450
+ // wrong episodes; the grab history keeps the mapping the grab was made with.
1451
+ const downloads = new Map()
1452
+ for (const r of queue) {
1453
+ const key = String(r.downloadId ?? `row:${r.id}`).toUpperCase()
1454
+ if (!downloads.has(key)) downloads.set(key, { key, row: r, ids: new Set(grabbedFor.get(key)?.ids ?? []) })
1455
+ if (!grabbedFor.has(key)) downloads.get(key).ids.add(r.episodeId)
1456
+ }
1457
+ const queuedTitles = new Set(queue.map((r) => normalizeTitle(r.title)))
1458
+ for (const [key, g] of grabbedFor) {
1459
+ const recent = nowMs - Date.parse(g.date) < FILL_RECENT_GRAB_MS
1460
+ if (recent && !downloads.has(key) && !queuedTitles.has(normalizeTitle(g.title))) downloads.set(key, { key, row: null, ids: g.ids, title: g.title })
1461
+ }
1462
+
1463
+ let cutShort = false
1464
+ const covered = new Set()
1465
+ const stalled = []
1466
+ const inFlight = []
1467
+ for (const d of downloads.values()) {
1468
+ const ids = wanted.map((e) => e.id).filter((id) => d.ids.has(id))
1469
+ if (!ids.length) continue
1470
+ const entry = { title: d.row?.title ?? d.title ?? null, download_id: d.row?.downloadId ?? d.key, episodes: ids.map((id) => sxe(byId.get(id))) }
1471
+ if (!d.row) { inFlight.push({ ...entry, state: "just_grabbed" }); ids.forEach((id) => covered.add(id)); continue }
1472
+ if (importNeeded(d.row)) {
1473
+ const done = mayAct() ? await attempt(`import ${d.row.downloadId}`, () => importByTitle(seriesId, seriesTitle, d.row.downloadId, ids.map((id) => byId.get(id)))) : undefined
1474
+ if (done) actions.push(done)
1475
+ inFlight.push({ ...entry, state: done?.action === "import_needs_mapping" ? "downloaded_needs_mapping" : done ? "importing" : mayAct() ? "downloaded_import_failed" : "downloaded_import_next_call" })
1476
+ if (!done) cutShort = true
1477
+ ids.forEach((id) => covered.add(id))
1478
+ continue
1479
+ }
1480
+ const why = stallReason(d.row, nowMs, observeProgress(progressKey(d.row), d.row.sizeleft ?? 0, nowMs))
1481
+ const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
1482
+ if (why) stalled.push({ ...info, ids, ...why })
1483
+ else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
1484
+ }
1485
+
1486
+ // Search for everything not covered by a moving download, stalled ones included.
1487
+ const need = wanted.filter((e) => !covered.has(e.id))
1488
+ const searched = []
1489
+ const searchedSeasons = new Set()
1490
+ const searchedEpisodes = new Set()
1491
+ const candidates = new Map()
1492
+ const rejected = new Map()
1493
+ const blockedMatches = new Map()
1494
+ const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
1495
+ const consider = (raw) => {
1496
+ for (const r of Array.isArray(raw) ? raw : []) {
1497
+ if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
1498
+ const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
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 })
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 }
1502
+ cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
1503
+ candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
1504
+ }
1505
+ }
1506
+ const searchTimeout = () => Math.max(5_000, Math.min(SEARCH_TIMEOUT_MS, remainingMs()))
1507
+ const hasSeededCandidate = (e) => [...candidates.values()].some((c) => (c.raw.seeders ?? 0) > 0 && c.ids.includes(e.id))
1508
+ const searchedAt = (key) => lastSearched.get(`${seriesId}:${key}`) ?? 0
1509
+ const markSearched = (key) => lastSearched.set(`${seriesId}:${key}`, Date.now())
1510
+ // Least recently searched first, so a deadline never starves the same seasons on every call.
1511
+ const seasons = [...new Set(need.map((e) => e.seasonNumber))].sort((x, y) => searchedAt(`s${x}`) - searchedAt(`s${y}`) || x - y)
1512
+ for (const s of seasons) {
1513
+ if (!timeLeft()) break
1514
+ searchedSeasons.add(s)
1515
+ markSearched(`s${s}`)
1516
+ searched.push(`Sonarr season search S${code2(s)}`)
1517
+ consider(await attempt(`season search S${code2(s)}`, () => sonarr("/release", { query: { seriesId, seasonNumber: s }, timeoutMs: searchTimeout() })))
1518
+ }
1519
+ const episodeOrder = need.filter((x) => !hasSeededCandidate(x)).sort((x, y) => searchedAt(`e${x.id}`) - searchedAt(`e${y.id}`))
1520
+ for (const e of episodeOrder.slice(0, MAX_EPISODE_SEARCHES)) {
1521
+ if (!timeLeft()) break
1522
+ searchedEpisodes.add(e.id)
1523
+ markSearched(`e${e.id}`)
1524
+ searched.push(`Sonarr episode search ${sxe(e)}`)
1525
+ consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
1526
+ }
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
+
1555
+ // Seeded releases first, then the ones that cover the most, then the best seeded.
1556
+ const ranked = [...candidates.values()].sort((x, y) =>
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))
1558
+ const remaining = new Set(need.map((e) => e.id))
1559
+ const stalledIds = new Set(stalled.flatMap((s) => s.ids))
1560
+ // Until a stall is confirmed on a later call, its episodes are not fetched a second time.
1561
+ const unconfirmedIds = new Set(stalled.filter((s) => !s.confirmed).flatMap((s) => s.ids))
1562
+ for (const s of stalled.filter((x) => !x.confirmed)) {
1563
+ s.alternative = ranked.some((c) => (c.raw.seeders ?? 0) > 0 && s.ids.every((id) => c.ids.includes(id)))
1564
+ }
1565
+ const deferred = new Set()
1566
+ let grabs = 0
1567
+ for (const c of ranked) {
1568
+ const seeded = (c.raw.seeders ?? 0) > 0
1569
+ // An unseeded release is no better than a stalled download: never fetch those episodes twice that way.
1570
+ const ids = c.ids.filter((id) => remaining.has(id) && !unconfirmedIds.has(id) && (seeded || !stalledIds.has(id)))
1571
+ if (!ids.length || !c.raw.quality) continue
1572
+ if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
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 : [] }
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
+ }
1587
+ if (!res) continue
1588
+ grabs += 1
1589
+ ids.forEach((id) => remaining.delete(id))
1590
+ actions.push({ action: "grabbed", title: c.raw.title ?? null, indexer: c.raw.indexer ?? null, seeders: c.raw.seeders ?? null, matched_by: c.basis,
1591
+ episodes: ids.map((id) => sxe(byId.get(id))), download_id: res.downloadId ?? null, ...(seeded ? {} : { may_never_finish: true }) })
1592
+ inFlight.push({ title: c.raw.title ?? null, download_id: res.downloadId ?? null, episodes: ids.map((id) => sxe(byId.get(id))), state: "just_grabbed" })
1593
+ // Only after the replacement is grabbed, and only for a confirmed stall it fully covers with a seeded release.
1594
+ if (!seeded) continue
1595
+ for (const s of stalled.filter((x) => !x.replaced && x.confirmed && x.ids.every((id) => ids.includes(id)))) {
1596
+ const removed = await attempt(`blocklist ${s.title}`, async () => { await sonarr(`/queue/${s.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } }); return true })
1597
+ if (!removed) continue
1598
+ s.replaced = true
1599
+ actions.push({ action: "blocklisted_stalled", title: s.title, percent: s.percent, stall: s.stall })
1600
+ }
1601
+ }
1602
+ for (const s of stalled.filter((x) => !x.replaced)) {
1603
+ const alsoGrabbed = s.ids.every((id) => !remaining.has(id))
1604
+ inFlight.push({ title: s.title, download_id: s.download_id, episodes: s.episodes, percent: s.percent, queue_id: s.queue_id, added: s.added, state: "stalled", stall: s.stall,
1605
+ ...(s.confirmed ? {} : { stall_confirmed: false }),
1606
+ note: !s.confirmed && s.alternative ? "A seeded alternative exists; a later call replaces this download once the stall is confirmed (no progress for 30 minutes)."
1607
+ : alsoGrabbed ? "Releases for some of these episodes were grabbed; this download was kept for the rest."
1608
+ : "No other usable release was found, so this download was kept in case peers return." })
1609
+ s.ids.forEach((id) => remaining.delete(id))
1610
+ }
1611
+
1612
+ const open = need.filter((e) => remaining.has(e.id) && !deferred.has(e.id))
1613
+ const unsearched = open.filter((e) => !searchedSeasons.has(e.seasonNumber) && !searchedEpisodes.has(e.id))
1614
+ const notFound = open.filter((e) => !unsearched.includes(e)).map(code)
1615
+ const stuck = inFlight.filter((x) => x.state === "stalled")
1616
+ let evidence
1617
+ if ((notFound.length || stuck.length) && timeLeft()) {
1618
+ // What exists on the indexers under any name, so a "can't find it" is backed by a listing. Best effort.
1619
+ evidence = []
1620
+ const target = notFound.length ? need.filter((e) => notFound.some((n) => n.episode_id === e.id)) : wanted
1621
+ const ys = [...new Set(target.map((e) => e.airDate?.slice(0, 4)).filter(Boolean))].slice(0, 2)
1622
+ const scene = [...new Set(target.map((e) => e.sceneSeasonNumber).filter((n) => Number.isInteger(n)))].slice(0, 1)
1623
+ const queries = [...(ys.length ? ys : [""]).map((y) => `${seriesTitle} ${y}`.trim()), ...scene.map((n) => `${seriesTitle} S${code2(n)}`)]
1624
+ for (const q of queries) {
1625
+ if (!timeLeft()) break
1626
+ const raw = await attempt(`indexer evidence "${q}"`, () => prowlarr("/search", { query: { query: q, type: "search", limit: 100 }, timeoutMs: searchTimeout() }))
1627
+ if (!Array.isArray(raw)) continue
1628
+ // Indexers answer free text loosely ("Vegas Chef Prizefight"); keep only releases named for this series.
1629
+ const own = raw.filter((r) => hasWord(normalizeTitle(r.title), seriesBaseTitle(seriesTitle)))
1630
+ const rows = own.sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1)).slice(0, FILL_EVIDENCE_ROWS)
1631
+ evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
1632
+ }
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
+ }
1639
+ const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
1640
+ const moving = inFlight.filter((x) => x.state !== "stalled").length
1641
+ const unseeded = actions.filter((x) => x.action === "grabbed" && x.may_never_finish)
1642
+ const summary = [
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.`,
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.` : "",
1646
+ moving ? `${moving} download(s) in progress.` : "",
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("; ")}).` : "",
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("; ")}.` : "",
1650
+ deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
1651
+ errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
1652
+ ].filter(Boolean).join(" ")
1653
+ const nextStep = incomplete
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."
1655
+ : stuck.length || notFound.length
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."
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."
1663
+ return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
1664
+ ...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
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 }
1667
+ }
1668
+
1148
1669
  // ------------------------------------------------------------------- schema
1149
1670
 
1150
- export const TOOLS = [
1671
+ // The harness waits 30 s for a tool call unless the tool declares longer; indexer searches can take minutes.
1672
+ const TIMEOUT_META_KEY = "ouro.bot/timeoutMs"
1673
+
1674
+ const TOOL_LIST = [
1151
1675
  {
1152
1676
  name: "media_search",
1153
1677
  annotations: { readOnlyHint: true },
@@ -1218,14 +1742,26 @@ export const TOOLS = [
1218
1742
  },
1219
1743
  {
1220
1744
  name: "media_blocklist_stalled",
1221
- 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.",
1222
1746
  inputSchema: { type: "object", properties: {
1223
1747
  kind: { type: "string", enum: ["series", "movie"] },
1224
1748
  service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
1225
1749
  queue_ids: { type: "array", items: { type: "number" } },
1226
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'." },
1227
1752
  }, required: ["kind", "service_id"] },
1228
1753
  },
1754
+ {
1755
+ name: "media_fill_missing",
1756
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
1757
+ description: "Get a series' missing episodes, start to finish, in one call. Use this first whenever the owner asks why episodes are missing or asks to get them. It re-reads Sonarr, finds the monitored aired episodes without files, searches the indexers, matches releases to episodes by episode title and air year as well as Sonarr's parse (so streaming-volume numbering cannot fool it), prefers seeded releases, skips packs of episodes already on the shelf, grabs with the right episode mapping, imports finished downloads by title, and replaces a stalled download when a usable alternative exists. Returns on_shelf, in_flight, not_found, actions, indexer_evidence, summary and next_step. Safe to call again: it never grabs what is already in flight. When goal_met is true, stop. Report summary and next_step as they are; do not redo its steps by hand.",
1758
+ inputSchema: { type: "object", properties: {
1759
+ series: { type: "string", description: "Series title as the owner said it." },
1760
+ service_id: { type: "number", description: "Sonarr series id, when known." },
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." },
1763
+ } },
1764
+ },
1229
1765
  {
1230
1766
  name: "media_episodes",
1231
1767
  annotations: { readOnlyHint: true },
@@ -1238,6 +1774,7 @@ export const TOOLS = [
1238
1774
  {
1239
1775
  name: "media_indexer_search",
1240
1776
  annotations: { readOnlyHint: true },
1777
+ _meta: { [TIMEOUT_META_KEY]: 200_000 },
1241
1778
  description: "Free-text search across every indexer (Prowlarr), sorted by seeders. Discovery only, it grabs nothing. Use it when Sonarr/Radarr's own search finds nothing: try the scene code from media_episodes, 'Season N', the year, or an episode title, to learn whether any release exists and how it is named. Zero seeders means it cannot download right now.",
1242
1779
  inputSchema: { type: "object", properties: {
1243
1780
  query: { type: "string" },
@@ -1247,6 +1784,7 @@ export const TOOLS = [
1247
1784
  {
1248
1785
  name: "media_release_search",
1249
1786
  annotations: { readOnlyHint: true },
1787
+ _meta: { [TIMEOUT_META_KEY]: 240_000 },
1250
1788
  description: "Interactive release search for a series or movie already in the library: what the indexers offer and how Sonarr/Radarr parsed each release (full_season, parsed_season, parsed_episodes, mapped_episodes), whether it is rejected and why, and whether it is blocklisted. Series: give season_number or episode_ids. Can take a minute or more. `query` filters returned titles. Use it when the automatic search found nothing, then grab with media_release_grab.",
1251
1789
  inputSchema: { type: "object", properties: {
1252
1790
  kind: { type: "string", enum: ["series", "movie"] },
@@ -1291,6 +1829,25 @@ export const TOOLS = [
1291
1829
  },
1292
1830
  ]
1293
1831
 
1832
+ // Every schema is closed: an argument the tool does not define (an invented dry_run,
1833
+ // a misspelled id) is refused with its name, never silently ignored.
1834
+ function closeSchema(schema) {
1835
+ if (!schema || typeof schema !== "object" || schema.type !== "object") return schema
1836
+ const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, v?.type === "array" && v.items ? { ...v, items: closeSchema(v.items) } : closeSchema(v)]))
1837
+ return { ...schema, properties, additionalProperties: false }
1838
+ }
1839
+
1840
+ export const TOOLS = TOOL_LIST.map((t) => ({ ...t, inputSchema: closeSchema(t.inputSchema) }))
1841
+
1842
+ // The same check inside the server, for any caller that does not validate against the schema.
1843
+ export function unknownArguments(toolName, args) {
1844
+ const tool = TOOLS.find((t) => t.name === toolName)
1845
+ const allowed = Object.keys(tool?.inputSchema?.properties ?? {})
1846
+ const unknown = Object.keys(args ?? {}).filter((k) => !allowed.includes(k))
1847
+ return unknown.length ? { error: "unknown_parameter", unknown, allowed,
1848
+ message: `${toolName} does not take ${unknown.map((k) => `'${k}'`).join(", ")}. It accepts only: ${allowed.join(", ") || "no parameters"}. Call it again without the unknown parameter.` } : null
1849
+ }
1850
+
1294
1851
  const HANDLERS = {
1295
1852
  media_search: mediaSearch,
1296
1853
  media_request: mediaRequest,
@@ -1301,6 +1858,7 @@ const HANDLERS = {
1301
1858
  media_search_now: mediaSearchNow,
1302
1859
  media_blocklist_stalled: mediaBlocklistStalled,
1303
1860
  media_episodes: mediaEpisodes,
1861
+ media_fill_missing: mediaFillMissing,
1304
1862
  media_indexer_search: mediaIndexerSearch,
1305
1863
  media_release_search: mediaReleaseSearch,
1306
1864
  media_release_grab: mediaReleaseGrab,
@@ -1331,6 +1889,8 @@ async function handle(msg) {
1331
1889
  human_action_reason: credError }
1332
1890
  return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
1333
1891
  }
1892
+ const unknown = unknownArguments(params.name, params.arguments)
1893
+ if (unknown) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(unknown, null, 1) }] } })
1334
1894
  try {
1335
1895
  const out = await fn(params.arguments ?? {})
1336
1896
  return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
@@ -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,4 +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 an automatic search finds nothing for episodes that exist, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
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
+ - 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
+ - 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
+ - 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": 12,
5
+ "version": 13,
6
6
  "contextScopes": [
7
7
  "household.status",
8
8
  "household.policy",
@@ -72,7 +72,8 @@
72
72
  "media_release_search",
73
73
  "media_release_grab",
74
74
  "media_blocklist",
75
- "media_manual_import"
75
+ "media_manual_import",
76
+ "media_fill_missing"
76
77
  ],
77
78
  "effectScopes": [
78
79
  "telegram.proactive",
@@ -140,7 +141,7 @@
140
141
  ]
141
142
  },
142
143
  "sanctuary-agent-peer": {
143
- "version": 3,
144
+ "version": 4,
144
145
  "toolNames": [
145
146
  "external_event_disposition",
146
147
  "query_active_work",
@@ -203,7 +204,8 @@
203
204
  "media_release_search",
204
205
  "media_release_grab",
205
206
  "media_blocklist",
206
- "media_manual_import"
207
+ "media_manual_import",
208
+ "media_fill_missing"
207
209
  ],
208
210
  "contextScopes": [
209
211
  "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.853</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,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MAX_PROVIDER_ITERATIONS = void 0;
3
+ exports.MAX_PROVIDER_ITERATIONS_CEILING = exports.STEP_BUDGET_EXTENSION = exports.MAX_PROVIDER_ITERATIONS = void 0;
4
4
  exports.validateToolCallBatchAtProductionBoundary = validateToolCallBatchAtProductionBoundary;
5
5
  exports.createProviderRegistry = createProviderRegistry;
6
6
  exports.getProviderRuntime = getProviderRuntime;
@@ -274,7 +274,13 @@ function createHabitCallbackBuffer(callbacks) {
274
274
  function createFinalOnlyTextBuffer(callbacks) {
275
275
  return createCallbackBuffer(callbacks, "text-only");
276
276
  }
277
+ /** Base per-turn provider-call budget. Grows while tool calls keep making progress. */
277
278
  exports.MAX_PROVIDER_ITERATIONS = 8;
279
+ /** How many provider calls each progress-earned extension adds. */
280
+ exports.STEP_BUDGET_EXTENSION = 4;
281
+ /** Hard ceiling on the per-turn provider-call budget, however much progress is made. */
282
+ exports.MAX_PROVIDER_ITERATIONS_CEILING = 24;
283
+ const STEP_BUDGET_REPORT_INSTRUCTION = "the step budget for this turn is used up. do not call any more work tools; none of the tool calls in your last message were executed. call settle now with a short progress report: what is done (with evidence from tool results above), what is still in flight, and what you will do next. say you can continue where you left off when the conversation resumes.";
278
284
  function continuationCallbacks(callbacks, text) {
279
285
  const noop = () => undefined;
280
286
  return {
@@ -1164,6 +1170,20 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1164
1170
  let unresolvedHistoricalEffects = [];
1165
1171
  let historicalToolFailureRetries = 0;
1166
1172
  let providerIterations = 0;
1173
+ // Adaptive step budget: starts at the base, grows while tool calls make progress.
1174
+ let stepBudget = exports.MAX_PROVIDER_ITERATIONS;
1175
+ let lastIterationMadeProgress = false;
1176
+ let stepReportIteration = null;
1177
+ const budgetExhausted = () => {
1178
+ if (providerIterations < stepBudget)
1179
+ return false;
1180
+ if (lastIterationMadeProgress && stepBudget < exports.MAX_PROVIDER_ITERATIONS_CEILING) {
1181
+ stepBudget = Math.min(exports.MAX_PROVIDER_ITERATIONS_CEILING, stepBudget + exports.STEP_BUDGET_EXTENSION);
1182
+ (0, runtime_1.emitNervesEvent)({ component: "engine", event: "engine.step_budget_extended", message: "tool progress extended the per-turn step budget", meta: { stepBudget } });
1183
+ return providerIterations >= stepBudget;
1184
+ }
1185
+ return true;
1186
+ };
1167
1187
  const requiredToolCallNames = [...new Set(options?.requiredToolCalls?.names ?? [])];
1168
1188
  const dispatchedRequiredToolCalls = new Set();
1169
1189
  const rejectAttempt = (assistant, controls) => {
@@ -1182,8 +1202,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1182
1202
  : null;
1183
1203
  };
1184
1204
  const queueRequiredCorrection = (assistant, message, limitContext) => {
1185
- if (providerIterations >= exports.MAX_PROVIDER_ITERATIONS)
1186
- throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} ${limitContext}`);
1205
+ if (budgetExhausted())
1206
+ throw new Error(`provider iteration limit exhausted at response ${stepBudget} ${limitContext}`);
1187
1207
  rejectAttempt(assistant, [{ role: "user", content: message }]);
1188
1208
  };
1189
1209
  const toolLoopState = (0, tool_loop_1.createToolLoopState)();
@@ -1323,9 +1343,11 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1323
1343
  .map((effect) => effect.name)
1324
1344
  .filter((name) => candidateToolNames.has(name)));
1325
1345
  const forcingHistoricalEffect = forcedHistoricalToolNames.size > 0;
1326
- const activeTools = forcingHistoricalEffect
1327
- ? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
1328
- : candidateActiveTools;
1346
+ const activeTools = stepReportIteration !== null
1347
+ ? candidateActiveTools.filter((tool) => tool.function.name === "settle")
1348
+ : forcingHistoricalEffect
1349
+ ? candidateActiveTools.filter((tool) => forcedHistoricalToolNames.has(tool.function.name))
1350
+ : candidateActiveTools;
1329
1351
  const activeToolNames = new Set(activeTools.map((tool) => tool.function.name));
1330
1352
  const steeringFollowUps = options?.drainSteeringFollowUps?.() ?? [];
1331
1353
  if (steeringFollowUps.length > 0) {
@@ -1482,8 +1504,12 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1482
1504
  const result = attempt.value;
1483
1505
  nextAttemptControls = [];
1484
1506
  providerIterations += 1;
1485
- if (providerIterations === exports.MAX_PROVIDER_ITERATIONS && result.toolCalls.length > 0) {
1486
- throw new Error(`provider iteration limit exhausted at response ${exports.MAX_PROVIDER_ITERATIONS} before tool execution`);
1507
+ if (stepReportIteration !== null) {
1508
+ // The progress-report call is the last one: it must settle, or the turn errors.
1509
+ const settledReport = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1510
+ if (providerIterations > stepReportIteration || (result.toolCalls.length > 0 && !settledReport)) {
1511
+ throw new Error(`provider iteration limit exhausted at response ${providerIterations} before the progress report settled`);
1512
+ }
1487
1513
  }
1488
1514
  const streamCallbackBuffer = turnCallbackBufferRef.current;
1489
1515
  turnCallbackBufferRef.current = null;
@@ -1550,6 +1576,24 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1550
1576
  if (thinkingItems.length > 0) {
1551
1577
  msg._thinking_blocks = thinkingItems;
1552
1578
  }
1579
+ const settlingNow = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
1580
+ if (stepReportIteration === null && result.toolCalls.length > 0 && !settlingNow && budgetExhausted()) {
1581
+ if (!candidateToolNames.has("settle")) {
1582
+ throw new Error(`provider iteration limit exhausted at response ${stepBudget} before tool execution`);
1583
+ }
1584
+ // Do not error into the reply: give the model one settle-only call to report progress.
1585
+ streamCallbackBuffer?.discard();
1586
+ stepReportIteration = providerIterations + 1;
1587
+ rejectAttempt(msg, [
1588
+ ...result.toolCalls.map((tc) => ({ role: "tool", tool_call_id: tc.id, content: "not executed: the step budget for this turn is used up." })),
1589
+ { role: "user", content: STEP_BUDGET_REPORT_INSTRUCTION },
1590
+ ]);
1591
+ callbacks.onClearText?.();
1592
+ (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.step_budget_exhausted", message: "step budget used up; requesting a settle-only progress report", meta: { stepBudget, providerIterations } });
1593
+ continue;
1594
+ }
1595
+ if (result.toolCalls.length > 0)
1596
+ lastIterationMadeProgress = false;
1553
1597
  // Phase annotation for Codex provider
1554
1598
  const hasPhaseAnnotation = providerRuntime.capabilities.has("phase-annotation");
1555
1599
  const isSoleSettle = result.toolCalls.length === 1 && result.toolCalls[0].name === "settle";
@@ -1908,7 +1952,7 @@ async function runAgent(messages, callbacks, channel, signal, options) {
1908
1952
  // answer before ordinary settle handling reaches this point.
1909
1953
  const deliveredAnswer = answer;
1910
1954
  // Never spend the last provider iteration on this optional retry: delivering the recap beats delivering nothing.
1911
- const contentMismatch = sawSettleContentMismatch || providerIterations >= exports.MAX_PROVIDER_ITERATIONS - 1 ? null : settleContentMismatchError(msg.content, deliveredAnswer);
1955
+ const contentMismatch = sawSettleContentMismatch || providerIterations >= stepBudget - 1 ? null : settleContentMismatchError(msg.content, deliveredAnswer);
1912
1956
  if (contentMismatch) {
1913
1957
  sawSettleContentMismatch = true;
1914
1958
  (0, runtime_1.emitNervesEvent)({ level: "warn", component: "engine", event: "engine.settle_content_mismatch", message: "settle answer differs from the reply text written beside it; asking once for the reply as the answer", meta: { answerLength: deliveredAnswer.length, contentLength: msg.content.length } });
@@ -2446,6 +2490,8 @@ async function runAgent(messages, callbacks, channel, signal, options) {
2446
2490
  invoked,
2447
2491
  sideEffect: success && toolRiskProfile?.mutates !== "none",
2448
2492
  });
2493
+ if (success && !(0, tool_loop_1.hasRecordedToolCall)(toolLoopState, tc.name, args))
2494
+ lastIterationMadeProgress = true;
2449
2495
  (0, tool_loop_1.recordToolOutcome)(toolLoopState, tc.name, args, modelResult, success);
2450
2496
  callbacks.onToolEnd(tc.name, (0, tools_1.buildToolResultSummary)(tc.name, args, modelResult, success, toolSelection), success);
2451
2497
  callbacks.onToolResult?.(messages);
@@ -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": 12, "sanctuary-household": 5, "sanctuary-event": 4 };
63
+ const versions = { "sanctuary-owner": 13, "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");
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.GLOBAL_CIRCUIT_BREAKER_LIMIT = exports.PING_PONG_WINDOW = exports.GENERIC_REPEAT_LIMIT = exports.POLL_NO_PROGRESS_LIMIT = exports.TOOL_LOOP_HISTORY_LIMIT = void 0;
37
37
  exports.createToolLoopState = createToolLoopState;
38
38
  exports.recordToolOutcome = recordToolOutcome;
39
+ exports.hasRecordedToolCall = hasRecordedToolCall;
39
40
  exports.detectToolLoop = detectToolLoop;
40
41
  const crypto = __importStar(require("crypto"));
41
42
  const runtime_1 = require("../nerves/runtime");
@@ -178,6 +179,11 @@ function recordToolOutcome(state, toolName, args, result, success) {
178
179
  state.history.splice(0, state.history.length - exports.TOOL_LOOP_HISTORY_LIMIT);
179
180
  }
180
181
  }
182
+ /** True when this exact call (tool name + canonicalized arguments) was already recorded this turn. */
183
+ function hasRecordedToolCall(state, toolName, args) {
184
+ const callHash = digest(normalizeArgs(toolName, args));
185
+ return state.history.some((record) => record.toolName === toolName && record.callHash === callHash);
186
+ }
181
187
  // Tools that must never be blocked by the circuit breaker.
182
188
  // settle = end the turn, surface = deliver results outward.
183
189
  // ponder = continue thinking (private runtime) or hand off to private runtime (outer).
@@ -1,14 +1,18 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.McpClient = void 0;
3
+ exports.McpClient = exports.MCP_TOOL_TIMEOUT_META_KEY = void 0;
4
4
  exports.isMcpTransportError = isMcpTransportError;
5
5
  const child_process_1 = require("child_process");
6
6
  const readline_1 = require("readline");
7
7
  const runtime_1 = require("../nerves/runtime");
8
8
  const runtime_cwd_1 = require("../heart/runtime-cwd");
9
+ /** Metadata key a server uses to declare how long one call to a tool may take. */
10
+ exports.MCP_TOOL_TIMEOUT_META_KEY = "ouro.bot/timeoutMs";
11
+ const MAX_TOOL_CALL_TIMEOUT = 600_000;
9
12
  const MCP_PROTOCOL_VERSION = "2024-11-05";
10
13
  const DEFAULT_REQUEST_TIMEOUT = 10_000;
11
14
  const DEFAULT_TOOL_CALL_TIMEOUT = 30_000;
15
+ const MIN_TOOL_CALL_TIMEOUT = 1_000;
12
16
  function isMcpTransportError(error) {
13
17
  const message = error instanceof Error ? error.message : String(error);
14
18
  const normalized = message.toLowerCase();
@@ -97,11 +101,19 @@ class McpClient {
97
101
  this.cachedTools = allTools;
98
102
  return allTools;
99
103
  }
104
+ /** A tool that searches slow services may declare a longer timeout; it is clamped to 1 s .. 10 min. */
105
+ declaredToolTimeout(name) {
106
+ const declared = this.cachedTools?.find((tool) => tool.name === name)?._meta?.[exports.MCP_TOOL_TIMEOUT_META_KEY];
107
+ if (typeof declared !== "number" || !Number.isFinite(declared))
108
+ return DEFAULT_TOOL_CALL_TIMEOUT;
109
+ return Math.min(Math.max(Math.round(declared), MIN_TOOL_CALL_TIMEOUT), MAX_TOOL_CALL_TIMEOUT);
110
+ }
100
111
  async refreshTools() {
101
112
  this.cachedTools = null;
102
113
  return this.listTools();
103
114
  }
104
- async callTool(name, args, timeout = DEFAULT_TOOL_CALL_TIMEOUT) {
115
+ async callTool(name, args, timeoutOverride) {
116
+ const timeout = timeoutOverride ?? this.declaredToolTimeout(name);
105
117
  (0, runtime_1.emitNervesEvent)({
106
118
  event: "mcp.tool_call_start",
107
119
  component: "repertoire",
@@ -66,8 +66,20 @@ function canonicalize(value) {
66
66
  function digest(value) {
67
67
  return (0, node_crypto_1.createHash)("sha256").update(value, "utf8").digest("hex");
68
68
  }
69
+ // The model reads these: name the offending parameter and the allowed values so it
70
+ // can correct the call instead of guessing again (e.g. an invented dry_run flag).
71
+ function renderError(error) {
72
+ const where = error.instancePath || "/";
73
+ if (error.keyword === "additionalProperties") {
74
+ return `${where} has unknown parameter '${String(error.params.additionalProperty)}': this tool does not accept it; use only the parameters in its schema`;
75
+ }
76
+ if (error.keyword === "enum" && Array.isArray(error.params.allowedValues)) {
77
+ return `${where} must be one of: ${error.params.allowedValues.map((value) => JSON.stringify(value)).join(", ")}`;
78
+ }
79
+ return `${where} ${error.message}`;
80
+ }
69
81
  function renderErrors(errors) {
70
- return errors.map((error) => `${error.instancePath || "/"} ${error.message}`).join("; ");
82
+ return errors.map(renderError).join("; ");
71
83
  }
72
84
  function digestJson(value) {
73
85
  return digest(canonicalize(value));
@@ -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",
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",
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"]);
@@ -342,7 +342,7 @@ function getSenseSessionPath(agentName, friendId, channel, sessionKey, agentRoot
342
342
  */
343
343
  function erroredTurnReply(errorMessage) {
344
344
  return /iteration limit exhausted/u.test(errorMessage ?? "")
345
- ? "I couldn't finish that one: I ran out of steps before I had an answer I could stand behind. Ask me again, or ask something narrower, and I'll take another run at it."
345
+ ? "I hit my step limit in the middle of that job and couldn't finish it. Tell me to continue and I'll pick up from where I stopped."
346
346
  : "I couldn't finish that one: something went wrong before I had an answer. Ask me again and I'll retry.";
347
347
  }
348
348
  async function runSenseTurn(options) {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.853",
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.853",
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.853",
3
+ "version": "0.1.0-alpha.855",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },