@ouro.bot/cli 0.1.0-alpha.853 → 0.1.0-alpha.854
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 +8 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +420 -1
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +4 -1
- package/deploy/unraid/sanctuary.ouro/tool-profiles.json +6 -4
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/core.js +55 -9
- package/dist/heart/daemon/sanctuary-acceptance-harness.js +1 -1
- package/dist/heart/tool-loop.js +6 -0
- package/dist/repertoire/mcp-client.js +14 -2
- package/dist/repertoire/tool-arguments.js +13 -1
- package/dist/repertoire/tools.js +1 -1
- package/dist/senses/shared-turn.js +1 -1
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.854",
|
|
6
|
+
"changes": [
|
|
7
|
+
"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.",
|
|
8
|
+
"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.",
|
|
9
|
+
"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."
|
|
10
|
+
]
|
|
11
|
+
},
|
|
4
12
|
{
|
|
5
13
|
"version": "0.1.0-alpha.853",
|
|
6
14
|
"changes": [
|
|
@@ -1145,9 +1145,394 @@ export async function mediaManualImport(a) {
|
|
|
1145
1145
|
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
1146
|
}
|
|
1147
1147
|
|
|
1148
|
+
// ------------------------------------------------------- tool: fill missing
|
|
1149
|
+
//
|
|
1150
|
+
// The whole "get the missing episodes" job as one deterministic pass, so a long
|
|
1151
|
+
// request no longer depends on the model chaining a dozen calls correctly
|
|
1152
|
+
// (2026-10-03: The Chef Show took an owner hint, a wrong pack, an invented
|
|
1153
|
+
// parameter and the step cap). Each call re-reads Sonarr, so it is safe to call
|
|
1154
|
+
// again: in-flight grabs are never repeated, and once nothing is missing it says
|
|
1155
|
+
// stop. Release names follow streaming numbering, so a release is matched to
|
|
1156
|
+
// episodes only on evidence: an episode title, a season plus the air year, or a
|
|
1157
|
+
// Sonarr parse whose own numbering agrees with the episode's TVDB or scene code.
|
|
1158
|
+
|
|
1159
|
+
const FILL_MAX_GRABS = 3
|
|
1160
|
+
const FILL_DEADLINE_MS = 150_000
|
|
1161
|
+
const FILL_RECENT_GRAB_MS = 15 * 60_000
|
|
1162
|
+
const FILL_EVIDENCE_ROWS = 5
|
|
1163
|
+
// A torrent with no ETA is called stalled only after its remaining size has not moved for this long.
|
|
1164
|
+
const FILL_STALL_CONFIRM_MS = 30 * 60_000
|
|
1165
|
+
|
|
1166
|
+
// Years in a release name, ignoring resolutions such as 1920x1080 and 2160p.
|
|
1167
|
+
const yearsIn = (title) => new Set((String(title ?? "").match(/(?<![0-9x])(?:19|20)[0-9]{2}(?![0-9px])/gi) ?? []).map(Number))
|
|
1168
|
+
const airYear = (e) => (e.airDate ? Number(String(e.airDate).slice(0, 4)) : null)
|
|
1169
|
+
const hasWord = (haystack, needle) => needle.length > 0 && ` ${haystack} `.includes(` ${needle} `)
|
|
1170
|
+
const rejectionTexts = (r) => (Array.isArray(r.rejections) ? r.rejections.map((x) => (typeof x === "string" ? x : x?.reason ?? String(x))) : [])
|
|
1171
|
+
// "Doctor Who (2005)" and "The Office (US)" are named without the suffix in most releases.
|
|
1172
|
+
export const seriesBaseTitle = (title) => normalizeTitle(String(title ?? "").replace(/\s*\((?:19|20)[0-9]{2}\)\s*$/, "").replace(/\s*\((?:us|uk|au|nz|ca)\)\s*$/i, ""))
|
|
1173
|
+
|
|
1174
|
+
// An episode title is evidence only when it is distinctive enough not to appear by chance:
|
|
1175
|
+
// several words, or one long word that is not a generic release term.
|
|
1176
|
+
const GENERIC_TITLE_WORDS = new Set(["special", "specials", "episode", "finale", "premiere", "extended", "complete", "unknown", "untitled", "trailer", "preview", "bonus"])
|
|
1177
|
+
function episodeTitleIn(releaseNorm, episode) {
|
|
1178
|
+
const t = normalizeTitle(episode.title)
|
|
1179
|
+
const distinctive = t.includes(" ") ? t.length >= 6 : t.length >= 7 && !GENERIC_TITLE_WORDS.has(t) && !/^[0-9]+$/.test(t)
|
|
1180
|
+
return distinctive && hasWord(releaseNorm, t)
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Season named in a release title: "Season 2", "Temporada 2", "S02" (not "S02E01").
|
|
1184
|
+
export function seasonInTitle(title) {
|
|
1185
|
+
const s = String(title ?? "")
|
|
1186
|
+
const word = s.match(/\b(?:season|temporada|saison|staffel)\s*0*([0-9]{1,2})\b/i)
|
|
1187
|
+
if (word) return Number(word[1])
|
|
1188
|
+
const code = s.match(/\bS0*([0-9]{1,2})(?![0-9]|\s*E[0-9])\b/i)
|
|
1189
|
+
return code ? Number(code[1]) : null
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Sonarr's mapping is trusted only when the numbering in the name is the episode's own or scene code.
|
|
1193
|
+
function parseAgrees(release, episode) {
|
|
1194
|
+
const season = release.seasonNumber
|
|
1195
|
+
const numbers = Array.isArray(release.episodeNumbers) ? release.episodeNumbers : []
|
|
1196
|
+
const codes = [[episode.seasonNumber, episode.episodeNumber], [episode.sceneSeasonNumber, episode.sceneEpisodeNumber]]
|
|
1197
|
+
return codes.some(([s, e]) => Number.isInteger(s) && s === season && (numbers.length ? numbers.includes(e) : Boolean(release.fullSeason)))
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
|
|
1201
|
+
export function matchReleaseToEpisodes(release, { seriesId, seriesTitle, seriesYear, wanted, have }) {
|
|
1202
|
+
const title = release.title ?? ""
|
|
1203
|
+
const norm = normalizeTitle(title)
|
|
1204
|
+
const base = seriesBaseTitle(seriesTitle)
|
|
1205
|
+
const rejections = rejectionTexts(release)
|
|
1206
|
+
if (!hasWord(norm, base)) return { reject: "other_series" }
|
|
1207
|
+
if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
|
|
1208
|
+
if (rejections.some((x) => /blocklist/i.test(x))) return { reject: "blocklisted" }
|
|
1209
|
+
if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
|
|
1210
|
+
const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
|
|
1211
|
+
// The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
|
|
1212
|
+
// unless the missing episodes aired that year too.
|
|
1213
|
+
const years = new Set([...yearsIn(title)].filter((y) => y !== seriesYear || wantedYears.has(y)))
|
|
1214
|
+
if (years.size && ![...years].some((y) => wantedYears.has(y))) return { reject: "year_mismatch", years: [...years] }
|
|
1215
|
+
// Episode titles are looked for in the name with the series name taken out.
|
|
1216
|
+
const rest = ` ${norm} `.replace(` ${base} `, " ").trim()
|
|
1217
|
+
const byTitle = wanted.filter((e) => episodeTitleIn(rest, e))
|
|
1218
|
+
if (byTitle.length) return { episode_ids: byTitle.map((e) => e.id), basis: "episode_title" }
|
|
1219
|
+
if (have.some((e) => episodeTitleIn(rest, e))) return { reject: "holds_episodes_we_have" }
|
|
1220
|
+
const wantedById = new Map(wanted.map((e) => [e.id, e]))
|
|
1221
|
+
const mapped = (release.mappedEpisodeInfo ?? []).map((e) => e.id).filter((id) => id !== undefined && id !== null)
|
|
1222
|
+
const mappedWanted = mapped.filter((id) => wantedById.has(id))
|
|
1223
|
+
if (mappedWanted.length) {
|
|
1224
|
+
if (mappedWanted.every((id) => parseAgrees(release, wantedById.get(id)))) return { episode_ids: mappedWanted, basis: "sonarr_parse" }
|
|
1225
|
+
return { reject: "parse_disagrees_with_numbering" }
|
|
1226
|
+
}
|
|
1227
|
+
// A season pack named with the season and the air year of the missing episodes
|
|
1228
|
+
// ("The Chef Show [Season 2] (2020)"), whatever Sonarr's scene mapping says it holds.
|
|
1229
|
+
const season = seasonInTitle(title) ?? (release.fullSeason ? release.seasonNumber ?? null : null)
|
|
1230
|
+
if (season !== null && years.size && !/\bE[0-9]{1,3}\b/i.test(title)) {
|
|
1231
|
+
const covered = wanted.filter((e) => e.seasonNumber === season && years.has(airYear(e)))
|
|
1232
|
+
if (covered.length) return { episode_ids: covered.map((e) => e.id), basis: "season_and_air_year" }
|
|
1233
|
+
}
|
|
1234
|
+
return { reject: mapped.length ? "holds_episodes_we_have" : "no_episode_match" }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
// Why a download is not going to finish on its own, or null while it may still be moving.
|
|
1238
|
+
// `confirmed` is false when the only sign is a missing ETA not yet seen to persist; such a
|
|
1239
|
+
// download is reported but never replaced.
|
|
1240
|
+
export function stallReason(row, nowMs, previous) {
|
|
1241
|
+
if (isStalledRow(row, nowMs)) return { stall: (row.sizeleft ?? 0) >= (row.size ?? 0) ? "never_started" : "unfinished_for_days", confirmed: true }
|
|
1242
|
+
const left = row.sizeleft ?? 0
|
|
1243
|
+
const ageHours = row.added ? (nowMs - Date.parse(row.added)) / 3_600_000 : 0
|
|
1244
|
+
const moving = row.status === "downloading" || row.status === "warning"
|
|
1245
|
+
const noEta = row.timeleft === undefined || row.timeleft === null || row.timeleft === "00:00:00"
|
|
1246
|
+
if (!(moving && left > 0 && noEta && ageHours >= STALL_AFTER_HOURS)) return null
|
|
1247
|
+
const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
|
|
1248
|
+
return { stall: "no_peers", confirmed: Boolean(unchanged) }
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
|
|
1252
|
+
const progressSeen = new Map()
|
|
1253
|
+
const PROGRESS_FORGET_MS = 14 * 24 * 3_600_000
|
|
1254
|
+
function observeProgress(downloadId, sizeleft, nowMs) {
|
|
1255
|
+
for (const [k, v] of progressSeen) if (nowMs - v.at > PROGRESS_FORGET_MS) progressSeen.delete(k)
|
|
1256
|
+
const previous = progressSeen.get(downloadId)
|
|
1257
|
+
if (!previous || previous.sizeleft !== sizeleft) progressSeen.set(downloadId, { sizeleft, at: nowMs })
|
|
1258
|
+
return previous
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const importDone = (row) => (row.sizeleft ?? 0) === 0 || row.status === "completed"
|
|
1262
|
+
const importNeeded = (row) => importDone(row) && ["importPending", "importBlocked", "importFailed"].includes(row.trackedDownloadState)
|
|
1263
|
+
|
|
1264
|
+
async function seriesGrabHistory(seriesId) {
|
|
1265
|
+
const list = await sonarr("/history/series", { query: { seriesId, eventType: "grabbed" } })
|
|
1266
|
+
return Array.isArray(list) ? list : []
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
async function runningImportFor(downloadId) {
|
|
1270
|
+
const list = await sonarr("/command")
|
|
1271
|
+
return (Array.isArray(list) ? list : []).some((c) => c.name === "ManualImport" && ["queued", "started"].includes(c.status)
|
|
1272
|
+
&& (c.body?.files ?? []).some((f) => String(f.downloadId ?? "").toUpperCase() === String(downloadId).toUpperCase()))
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Map a finished download's files to the wanted episodes by title. Only a file whose name
|
|
1276
|
+
// holds exactly one wanted episode title, and the only file holding it, is imported.
|
|
1277
|
+
async function importByTitle(seriesId, seriesTitle, downloadId, wantedEps) {
|
|
1278
|
+
if (await runningImportFor(downloadId)) return { action: "import_already_running", download_id: downloadId }
|
|
1279
|
+
const rows = await previewRows(seriesId, downloadId)
|
|
1280
|
+
const base = seriesBaseTitle(seriesTitle)
|
|
1281
|
+
const picks = []
|
|
1282
|
+
const unmatched = []
|
|
1283
|
+
for (const r of rows) {
|
|
1284
|
+
const rest = ` ${normalizeTitle(`${r.name ?? ""} ${r.relativePath ?? ""}`)} `.replace(` ${base} `, " ").trim()
|
|
1285
|
+
const ids = wantedEps.filter((e) => episodeTitleIn(rest, e)).map((e) => e.id)
|
|
1286
|
+
if (ids.length === 1) picks.push({ row: r, ids })
|
|
1287
|
+
else unmatched.push(r.name ?? r.path)
|
|
1288
|
+
}
|
|
1289
|
+
const counts = new Map()
|
|
1290
|
+
for (const p of picks) counts.set(p.ids[0], (counts.get(p.ids[0]) ?? 0) + 1)
|
|
1291
|
+
const unique = picks.filter((p) => counts.get(p.ids[0]) === 1)
|
|
1292
|
+
for (const p of picks) if (!unique.includes(p)) unmatched.push(p.row.name ?? p.row.path)
|
|
1293
|
+
if (!unique.length) {
|
|
1294
|
+
return { action: "import_needs_mapping", download_id: downloadId, unmatched_files: unmatched,
|
|
1295
|
+
note: "No file names an episode title. Map them with media_manual_import (preview, then import by title from media_episodes)." }
|
|
1296
|
+
}
|
|
1297
|
+
const files = unique.map(({ row: r, ids }) => ({ path: r.path, seriesId, episodeIds: ids, quality: r.quality, languages: r.languages, releaseGroup: r.releaseGroup ?? "",
|
|
1298
|
+
indexerFlags: r.indexerFlags ?? 0, releaseType: r.releaseType ?? "unknown", downloadId }))
|
|
1299
|
+
const c = await sonarr("/command", { method: "POST", body: { name: "ManualImport", files, importMode: "auto" } })
|
|
1300
|
+
return { action: "imported_by_title", download_id: downloadId, command_id: c?.id ?? null,
|
|
1301
|
+
files: unique.map(({ row: r, ids }) => ({ file: r.name ?? r.path, episode_ids: ids })), ...(unmatched.length ? { unmatched_files: unmatched } : {}) }
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
const pct = (row) => ((row.size ?? 0) > 0 ? Math.round((((row.size ?? 0) - (row.sizeleft ?? 0)) / row.size) * 1000) / 10 : null)
|
|
1305
|
+
const fillRunning = new Set()
|
|
1306
|
+
// When each season or episode of a series was last searched, so repeat calls reach the ones a deadline cut off.
|
|
1307
|
+
const lastSearched = new Map()
|
|
1308
|
+
const FILL_ACTION_GRACE_MS = 45_000
|
|
1309
|
+
|
|
1310
|
+
export async function mediaFillMissing(a, opts = {}) {
|
|
1311
|
+
const found = await resolveLibraryItem({ kind: "series", service_id: a.service_id, title: a.series })
|
|
1312
|
+
if (!found.item) return { result: found.result, candidates: found.candidates, message: "Name the series by its Sonarr service_id or exact title." }
|
|
1313
|
+
const seriesId = found.item.id
|
|
1314
|
+
// One pass per series at a time: a retry after a timeout must not grab beside a pass still running.
|
|
1315
|
+
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." }
|
|
1316
|
+
fillRunning.add(seriesId)
|
|
1317
|
+
try {
|
|
1318
|
+
return await fillPass(a, seriesId, found.item.title, opts)
|
|
1319
|
+
} finally {
|
|
1320
|
+
fillRunning.delete(seriesId)
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadlineMs = FILL_DEADLINE_MS } = {}) {
|
|
1325
|
+
const startedAt = Date.now()
|
|
1326
|
+
const remainingMs = () => deadlineMs - (Date.now() - startedAt)
|
|
1327
|
+
const timeLeft = () => remainingMs() > 0
|
|
1328
|
+
// Grabs and imports may run a little past the search deadline, never past the harness timeout.
|
|
1329
|
+
const mayAct = () => remainingMs() > -FILL_ACTION_GRACE_MS
|
|
1330
|
+
const errors = []
|
|
1331
|
+
// Each step after the first reads is isolated: one failure is reported, the rest of the pass and its record survive.
|
|
1332
|
+
const attempt = async (step, fn) => {
|
|
1333
|
+
try { return await fn() } catch (e) { errors.push({ step, error: e.code ?? "failed", message: e.message }); return undefined }
|
|
1334
|
+
}
|
|
1335
|
+
const seriesYear = Number((await attempt("read series", () => sonarr(`/series/${seriesId}`)))?.year) || null
|
|
1336
|
+
const season = a.season_number === undefined || a.season_number === null ? null : Number(a.season_number)
|
|
1337
|
+
const scope = (await seriesEpisodes(seriesId, season ?? undefined)).filter((e) => (season === null ? e.seasonNumber > 0 : true))
|
|
1338
|
+
const aired = (e) => Boolean(e.airDateUtc ?? e.airDate) && Date.parse(e.airDateUtc ?? e.airDate) <= nowMs
|
|
1339
|
+
const missing = scope.filter((e) => !e.hasFile && aired(e))
|
|
1340
|
+
const wanted = missing.filter((e) => e.monitored)
|
|
1341
|
+
const have = scope.filter((e) => e.hasFile)
|
|
1342
|
+
const code = (e) => ({ episode_id: e.id, code: sxe(e), title: e.title ?? null, air_date: e.airDate ?? null })
|
|
1343
|
+
const base = { series_id: seriesId, series: seriesTitle, season_number: season, checked_at: new Date(nowMs).toISOString(),
|
|
1344
|
+
on_shelf: { count: have.length, of: scope.filter(aired).length },
|
|
1345
|
+
...(missing.length > wanted.length ? { not_monitored_missing: missing.filter((e) => !e.monitored).map(code) } : {}) }
|
|
1346
|
+
if (!wanted.length) {
|
|
1347
|
+
return { ...base, goal_met: true, in_flight: [], not_found: [], actions: [],
|
|
1348
|
+
summary: `Nothing monitored is missing: ${have.length} of ${base.on_shelf.of} aired episodes are on the shelf.`,
|
|
1349
|
+
next_step: "Goal met. Stop here and tell the owner; do not search or grab anything else." }
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const actions = []
|
|
1353
|
+
const byId = new Map(wanted.map((e) => [e.id, e]))
|
|
1354
|
+
const queue = await itemQueue("series", seriesId)
|
|
1355
|
+
const history = await seriesGrabHistory(seriesId)
|
|
1356
|
+
const grabbedFor = new Map()
|
|
1357
|
+
for (const h of history) {
|
|
1358
|
+
// A client without download ids (e.g. a blackhole) is tracked by release title instead.
|
|
1359
|
+
const key = h.downloadId ? String(h.downloadId).toUpperCase() : h.sourceTitle ? `TITLE:${normalizeTitle(h.sourceTitle)}` : null
|
|
1360
|
+
if (!key) continue
|
|
1361
|
+
if (!grabbedFor.has(key)) grabbedFor.set(key, { ids: new Set(), date: h.date, title: h.sourceTitle })
|
|
1362
|
+
grabbedFor.get(key).ids.add(h.episodeId)
|
|
1363
|
+
}
|
|
1364
|
+
// Sonarr re-parses a tracked download's name, so its queue rows can point at the
|
|
1365
|
+
// wrong episodes; the grab history keeps the mapping the grab was made with.
|
|
1366
|
+
const downloads = new Map()
|
|
1367
|
+
for (const r of queue) {
|
|
1368
|
+
const key = String(r.downloadId ?? `row:${r.id}`).toUpperCase()
|
|
1369
|
+
if (!downloads.has(key)) downloads.set(key, { key, row: r, ids: new Set(grabbedFor.get(key)?.ids ?? []) })
|
|
1370
|
+
if (!grabbedFor.has(key)) downloads.get(key).ids.add(r.episodeId)
|
|
1371
|
+
}
|
|
1372
|
+
const queuedTitles = new Set(queue.map((r) => normalizeTitle(r.title)))
|
|
1373
|
+
for (const [key, g] of grabbedFor) {
|
|
1374
|
+
const recent = nowMs - Date.parse(g.date) < FILL_RECENT_GRAB_MS
|
|
1375
|
+
if (recent && !downloads.has(key) && !queuedTitles.has(normalizeTitle(g.title))) downloads.set(key, { key, row: null, ids: g.ids, title: g.title })
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
let cutShort = false
|
|
1379
|
+
const covered = new Set()
|
|
1380
|
+
const stalled = []
|
|
1381
|
+
const inFlight = []
|
|
1382
|
+
for (const d of downloads.values()) {
|
|
1383
|
+
const ids = wanted.map((e) => e.id).filter((id) => d.ids.has(id))
|
|
1384
|
+
if (!ids.length) continue
|
|
1385
|
+
const entry = { title: d.row?.title ?? d.title ?? null, download_id: d.row?.downloadId ?? d.key, episodes: ids.map((id) => sxe(byId.get(id))) }
|
|
1386
|
+
if (!d.row) { inFlight.push({ ...entry, state: "just_grabbed" }); ids.forEach((id) => covered.add(id)); continue }
|
|
1387
|
+
if (importNeeded(d.row)) {
|
|
1388
|
+
const done = mayAct() ? await attempt(`import ${d.row.downloadId}`, () => importByTitle(seriesId, seriesTitle, d.row.downloadId, ids.map((id) => byId.get(id)))) : undefined
|
|
1389
|
+
if (done) actions.push(done)
|
|
1390
|
+
inFlight.push({ ...entry, state: done?.action === "import_needs_mapping" ? "downloaded_needs_mapping" : done ? "importing" : mayAct() ? "downloaded_import_failed" : "downloaded_import_next_call" })
|
|
1391
|
+
if (!done) cutShort = true
|
|
1392
|
+
ids.forEach((id) => covered.add(id))
|
|
1393
|
+
continue
|
|
1394
|
+
}
|
|
1395
|
+
const why = stallReason(d.row, nowMs, observeProgress(d.key, d.row.sizeleft ?? 0, nowMs))
|
|
1396
|
+
const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
|
|
1397
|
+
if (why) stalled.push({ ...info, ids, ...why })
|
|
1398
|
+
else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Search for everything not covered by a moving download, stalled ones included.
|
|
1402
|
+
const need = wanted.filter((e) => !covered.has(e.id))
|
|
1403
|
+
const searched = []
|
|
1404
|
+
const searchedSeasons = new Set()
|
|
1405
|
+
const searchedEpisodes = new Set()
|
|
1406
|
+
const candidates = new Map()
|
|
1407
|
+
const rejected = new Map()
|
|
1408
|
+
const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
|
|
1409
|
+
const consider = (raw) => {
|
|
1410
|
+
for (const r of Array.isArray(raw) ? raw : []) {
|
|
1411
|
+
if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
|
|
1412
|
+
const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
|
|
1413
|
+
const sameAsInFlight = inFlightTitles.has(normalizeTitle(r.title))
|
|
1414
|
+
if (m.reject || sameAsInFlight) { rejected.set(r.guid, { title: r.title ?? null, seeders: r.seeders ?? null, reason: sameAsInFlight ? "same_release_as_stalled_download" : m.reject }); continue }
|
|
1415
|
+
cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
|
|
1416
|
+
candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
const searchTimeout = () => Math.max(5_000, Math.min(SEARCH_TIMEOUT_MS, remainingMs()))
|
|
1420
|
+
const hasSeededCandidate = (e) => [...candidates.values()].some((c) => (c.raw.seeders ?? 0) > 0 && c.ids.includes(e.id))
|
|
1421
|
+
const searchedAt = (key) => lastSearched.get(`${seriesId}:${key}`) ?? 0
|
|
1422
|
+
const markSearched = (key) => lastSearched.set(`${seriesId}:${key}`, Date.now())
|
|
1423
|
+
// Least recently searched first, so a deadline never starves the same seasons on every call.
|
|
1424
|
+
const seasons = [...new Set(need.map((e) => e.seasonNumber))].sort((x, y) => searchedAt(`s${x}`) - searchedAt(`s${y}`) || x - y)
|
|
1425
|
+
for (const s of seasons) {
|
|
1426
|
+
if (!timeLeft()) break
|
|
1427
|
+
searchedSeasons.add(s)
|
|
1428
|
+
markSearched(`s${s}`)
|
|
1429
|
+
searched.push(`Sonarr season search S${code2(s)}`)
|
|
1430
|
+
consider(await attempt(`season search S${code2(s)}`, () => sonarr("/release", { query: { seriesId, seasonNumber: s }, timeoutMs: searchTimeout() })))
|
|
1431
|
+
}
|
|
1432
|
+
const episodeOrder = need.filter((x) => !hasSeededCandidate(x)).sort((x, y) => searchedAt(`e${x.id}`) - searchedAt(`e${y.id}`))
|
|
1433
|
+
for (const e of episodeOrder.slice(0, MAX_EPISODE_SEARCHES)) {
|
|
1434
|
+
if (!timeLeft()) break
|
|
1435
|
+
searchedEpisodes.add(e.id)
|
|
1436
|
+
markSearched(`e${e.id}`)
|
|
1437
|
+
searched.push(`Sonarr episode search ${sxe(e)}`)
|
|
1438
|
+
consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Seeded releases first, then the ones that cover the most, then the best seeded.
|
|
1442
|
+
const ranked = [...candidates.values()].sort((x, y) =>
|
|
1443
|
+
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))
|
|
1444
|
+
const remaining = new Set(need.map((e) => e.id))
|
|
1445
|
+
const stalledIds = new Set(stalled.flatMap((s) => s.ids))
|
|
1446
|
+
// Until a stall is confirmed on a later call, its episodes are not fetched a second time.
|
|
1447
|
+
const unconfirmedIds = new Set(stalled.filter((s) => !s.confirmed).flatMap((s) => s.ids))
|
|
1448
|
+
for (const s of stalled.filter((x) => !x.confirmed)) {
|
|
1449
|
+
s.alternative = ranked.some((c) => (c.raw.seeders ?? 0) > 0 && s.ids.every((id) => c.ids.includes(id)))
|
|
1450
|
+
}
|
|
1451
|
+
const deferred = new Set()
|
|
1452
|
+
let grabs = 0
|
|
1453
|
+
for (const c of ranked) {
|
|
1454
|
+
const seeded = (c.raw.seeders ?? 0) > 0
|
|
1455
|
+
// An unseeded release is no better than a stalled download: never fetch those episodes twice that way.
|
|
1456
|
+
const ids = c.ids.filter((id) => remaining.has(id) && !unconfirmedIds.has(id) && (seeded || !stalledIds.has(id)))
|
|
1457
|
+
if (!ids.length || !c.raw.quality) continue
|
|
1458
|
+
if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
|
|
1459
|
+
const body = { guid: c.raw.guid, indexerId: c.raw.indexerId, seriesId, episodeIds: ids, shouldOverride: true, quality: c.raw.quality, languages: Array.isArray(c.raw.languages) ? c.raw.languages : [] }
|
|
1460
|
+
const res = await attempt(`grab ${c.raw.title}`, async () => (await sonarr("/release", { method: "POST", body })) ?? {})
|
|
1461
|
+
if (!res) continue
|
|
1462
|
+
grabs += 1
|
|
1463
|
+
ids.forEach((id) => remaining.delete(id))
|
|
1464
|
+
actions.push({ action: "grabbed", title: c.raw.title ?? null, indexer: c.raw.indexer ?? null, seeders: c.raw.seeders ?? null, matched_by: c.basis,
|
|
1465
|
+
episodes: ids.map((id) => sxe(byId.get(id))), download_id: res.downloadId ?? null, ...(seeded ? {} : { may_never_finish: true }) })
|
|
1466
|
+
inFlight.push({ title: c.raw.title ?? null, download_id: res.downloadId ?? null, episodes: ids.map((id) => sxe(byId.get(id))), state: "just_grabbed" })
|
|
1467
|
+
// Only after the replacement is grabbed, and only for a confirmed stall it fully covers with a seeded release.
|
|
1468
|
+
if (!seeded) continue
|
|
1469
|
+
for (const s of stalled.filter((x) => !x.replaced && x.confirmed && x.ids.every((id) => ids.includes(id)))) {
|
|
1470
|
+
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 })
|
|
1471
|
+
if (!removed) continue
|
|
1472
|
+
s.replaced = true
|
|
1473
|
+
actions.push({ action: "blocklisted_stalled", title: s.title, percent: s.percent, stall: s.stall })
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
for (const s of stalled.filter((x) => !x.replaced)) {
|
|
1477
|
+
const alsoGrabbed = s.ids.every((id) => !remaining.has(id))
|
|
1478
|
+
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,
|
|
1479
|
+
...(s.confirmed ? {} : { stall_confirmed: false }),
|
|
1480
|
+
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)."
|
|
1481
|
+
: alsoGrabbed ? "Releases for some of these episodes were grabbed; this download was kept for the rest."
|
|
1482
|
+
: "No other usable release was found, so this download was kept in case peers return." })
|
|
1483
|
+
s.ids.forEach((id) => remaining.delete(id))
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
const open = need.filter((e) => remaining.has(e.id) && !deferred.has(e.id))
|
|
1487
|
+
const unsearched = open.filter((e) => !searchedSeasons.has(e.seasonNumber) && !searchedEpisodes.has(e.id))
|
|
1488
|
+
const notFound = open.filter((e) => !unsearched.includes(e)).map(code)
|
|
1489
|
+
const stuck = inFlight.filter((x) => x.state === "stalled")
|
|
1490
|
+
let evidence
|
|
1491
|
+
if ((notFound.length || stuck.length) && timeLeft()) {
|
|
1492
|
+
// What exists on the indexers under any name, so a "can't find it" is backed by a listing. Best effort.
|
|
1493
|
+
evidence = []
|
|
1494
|
+
const target = notFound.length ? need.filter((e) => notFound.some((n) => n.episode_id === e.id)) : wanted
|
|
1495
|
+
const ys = [...new Set(target.map((e) => e.airDate?.slice(0, 4)).filter(Boolean))].slice(0, 2)
|
|
1496
|
+
const scene = [...new Set(target.map((e) => e.sceneSeasonNumber).filter((n) => Number.isInteger(n)))].slice(0, 1)
|
|
1497
|
+
const queries = [...(ys.length ? ys : [""]).map((y) => `${seriesTitle} ${y}`.trim()), ...scene.map((n) => `${seriesTitle} S${code2(n)}`)]
|
|
1498
|
+
for (const q of queries) {
|
|
1499
|
+
if (!timeLeft()) break
|
|
1500
|
+
const raw = await attempt(`indexer evidence "${q}"`, () => prowlarr("/search", { query: { query: q, type: "search", limit: 100 }, timeoutMs: searchTimeout() }))
|
|
1501
|
+
if (!Array.isArray(raw)) continue
|
|
1502
|
+
// Indexers answer free text loosely ("Vegas Chef Prizefight"); keep only releases named for this series.
|
|
1503
|
+
const own = raw.filter((r) => hasWord(normalizeTitle(r.title), seriesBaseTitle(seriesTitle)))
|
|
1504
|
+
const rows = own.sort((x, y) => (y.seeders ?? -1) - (x.seeders ?? -1)).slice(0, FILL_EVIDENCE_ROWS)
|
|
1505
|
+
evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
|
|
1509
|
+
const moving = inFlight.filter((x) => x.state !== "stalled").length
|
|
1510
|
+
const summary = [
|
|
1511
|
+
`${have.length} of ${base.on_shelf.of} aired episodes are on the shelf; ${wanted.length} monitored ${wanted.length === 1 ? "episode is" : "episodes are"} missing.`,
|
|
1512
|
+
grabs ? `Grabbed ${grabs} release${grabs === 1 ? "" : "s"} now.` : "",
|
|
1513
|
+
moving ? `${moving} download(s) in progress.` : "",
|
|
1514
|
+
stuck.length ? `${stuck.length} download(s) stalled with no peers and no other usable release (${stuck.map((s) => `${s.title} at ${s.percent}%`).join("; ")}).` : "",
|
|
1515
|
+
notFound.length ? `No usable release found for ${notFound.map((e) => e.code).join(", ")}.` : "",
|
|
1516
|
+
deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
|
|
1517
|
+
errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
|
|
1518
|
+
].filter(Boolean).join(" ")
|
|
1519
|
+
const nextStep = incomplete
|
|
1520
|
+
? "This pass did not finish everything. Tell the owner what it did, then call media_fill_missing again to continue; do not act by hand."
|
|
1521
|
+
: stuck.length || notFound.length
|
|
1522
|
+
? "Tell the owner exactly this outcome with the evidence; do not grab, import or blocklist anything by hand. Call media_fill_missing again later to retry."
|
|
1523
|
+
: "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
|
|
1524
|
+
return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
|
|
1525
|
+
...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
|
|
1526
|
+
searched, ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
|
|
1527
|
+
...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1148
1530
|
// ------------------------------------------------------------------- schema
|
|
1149
1531
|
|
|
1150
|
-
|
|
1532
|
+
// The harness waits 30 s for a tool call unless the tool declares longer; indexer searches can take minutes.
|
|
1533
|
+
const TIMEOUT_META_KEY = "ouro.bot/timeoutMs"
|
|
1534
|
+
|
|
1535
|
+
const TOOL_LIST = [
|
|
1151
1536
|
{
|
|
1152
1537
|
name: "media_search",
|
|
1153
1538
|
annotations: { readOnlyHint: true },
|
|
@@ -1226,6 +1611,16 @@ export const TOOLS = [
|
|
|
1226
1611
|
research: { type: "boolean", description: "Search again afterwards. Default true." },
|
|
1227
1612
|
}, required: ["kind", "service_id"] },
|
|
1228
1613
|
},
|
|
1614
|
+
{
|
|
1615
|
+
name: "media_fill_missing",
|
|
1616
|
+
_meta: { [TIMEOUT_META_KEY]: 240_000 },
|
|
1617
|
+
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.",
|
|
1618
|
+
inputSchema: { type: "object", properties: {
|
|
1619
|
+
series: { type: "string", description: "Series title as the owner said it." },
|
|
1620
|
+
service_id: { type: "number", description: "Sonarr series id, when known." },
|
|
1621
|
+
season_number: { type: "number", description: "Limit to one season. Omit for the whole series (specials excluded)." },
|
|
1622
|
+
} },
|
|
1623
|
+
},
|
|
1229
1624
|
{
|
|
1230
1625
|
name: "media_episodes",
|
|
1231
1626
|
annotations: { readOnlyHint: true },
|
|
@@ -1238,6 +1633,7 @@ export const TOOLS = [
|
|
|
1238
1633
|
{
|
|
1239
1634
|
name: "media_indexer_search",
|
|
1240
1635
|
annotations: { readOnlyHint: true },
|
|
1636
|
+
_meta: { [TIMEOUT_META_KEY]: 200_000 },
|
|
1241
1637
|
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
1638
|
inputSchema: { type: "object", properties: {
|
|
1243
1639
|
query: { type: "string" },
|
|
@@ -1247,6 +1643,7 @@ export const TOOLS = [
|
|
|
1247
1643
|
{
|
|
1248
1644
|
name: "media_release_search",
|
|
1249
1645
|
annotations: { readOnlyHint: true },
|
|
1646
|
+
_meta: { [TIMEOUT_META_KEY]: 240_000 },
|
|
1250
1647
|
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
1648
|
inputSchema: { type: "object", properties: {
|
|
1252
1649
|
kind: { type: "string", enum: ["series", "movie"] },
|
|
@@ -1291,6 +1688,25 @@ export const TOOLS = [
|
|
|
1291
1688
|
},
|
|
1292
1689
|
]
|
|
1293
1690
|
|
|
1691
|
+
// Every schema is closed: an argument the tool does not define (an invented dry_run,
|
|
1692
|
+
// a misspelled id) is refused with its name, never silently ignored.
|
|
1693
|
+
function closeSchema(schema) {
|
|
1694
|
+
if (!schema || typeof schema !== "object" || schema.type !== "object") return schema
|
|
1695
|
+
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).map(([k, v]) => [k, v?.type === "array" && v.items ? { ...v, items: closeSchema(v.items) } : closeSchema(v)]))
|
|
1696
|
+
return { ...schema, properties, additionalProperties: false }
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
export const TOOLS = TOOL_LIST.map((t) => ({ ...t, inputSchema: closeSchema(t.inputSchema) }))
|
|
1700
|
+
|
|
1701
|
+
// The same check inside the server, for any caller that does not validate against the schema.
|
|
1702
|
+
export function unknownArguments(toolName, args) {
|
|
1703
|
+
const tool = TOOLS.find((t) => t.name === toolName)
|
|
1704
|
+
const allowed = Object.keys(tool?.inputSchema?.properties ?? {})
|
|
1705
|
+
const unknown = Object.keys(args ?? {}).filter((k) => !allowed.includes(k))
|
|
1706
|
+
return unknown.length ? { error: "unknown_parameter", unknown, allowed,
|
|
1707
|
+
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
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1294
1710
|
const HANDLERS = {
|
|
1295
1711
|
media_search: mediaSearch,
|
|
1296
1712
|
media_request: mediaRequest,
|
|
@@ -1301,6 +1717,7 @@ const HANDLERS = {
|
|
|
1301
1717
|
media_search_now: mediaSearchNow,
|
|
1302
1718
|
media_blocklist_stalled: mediaBlocklistStalled,
|
|
1303
1719
|
media_episodes: mediaEpisodes,
|
|
1720
|
+
media_fill_missing: mediaFillMissing,
|
|
1304
1721
|
media_indexer_search: mediaIndexerSearch,
|
|
1305
1722
|
media_release_search: mediaReleaseSearch,
|
|
1306
1723
|
media_release_grab: mediaReleaseGrab,
|
|
@@ -1331,6 +1748,8 @@ async function handle(msg) {
|
|
|
1331
1748
|
human_action_reason: credError }
|
|
1332
1749
|
return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(payload, null, 1) }] } })
|
|
1333
1750
|
}
|
|
1751
|
+
const unknown = unknownArguments(params.name, params.arguments)
|
|
1752
|
+
if (unknown) return send({ jsonrpc: "2.0", id, result: { isError: true, content: [{ type: "text", text: JSON.stringify(unknown, null, 1) }] } })
|
|
1334
1753
|
try {
|
|
1335
1754
|
const out = await fn(params.arguments ?? {})
|
|
1336
1755
|
return send({ jsonrpc: "2.0", id, result: { content: [{ type: "text", text: JSON.stringify(out, null, 1) }] } })
|
|
@@ -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
|
|
18
|
+
- When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
|
|
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":
|
|
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":
|
|
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.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.854</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
package/dist/heart/core.js
CHANGED
|
@@ -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 (
|
|
1186
|
-
throw new Error(`provider iteration limit exhausted at response ${
|
|
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 =
|
|
1327
|
-
? candidateActiveTools.filter((tool) =>
|
|
1328
|
-
:
|
|
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 (
|
|
1486
|
-
|
|
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 >=
|
|
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":
|
|
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");
|
package/dist/heart/tool-loop.js
CHANGED
|
@@ -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,
|
|
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(
|
|
82
|
+
return errors.map(renderError).join("; ");
|
|
71
83
|
}
|
|
72
84
|
function digestJson(value) {
|
|
73
85
|
return digest(canonicalize(value));
|
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",
|
|
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
|
|
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) {
|
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.854",
|
|
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.854",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|