@ouro.bot/cli 0.1.0-alpha.854 → 0.1.0-alpha.856
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/changelog.json +14 -0
- package/deploy/unraid/sanctuary.ouro/bundle-meta.json +1 -1
- package/deploy/unraid/sanctuary.ouro/mcp/media-mcp.mjs +156 -15
- package/deploy/unraid/sanctuary.ouro/psyche/TACIT.md +2 -2
- package/deploy/unraid/sanctuary.xml +1 -1
- package/dist/heart/mailbox/mailbox-http-transport.js +172 -29
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
package/changelog.json
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_note": "This changelog is maintained as part of the PR/version-bump workflow. Agent-curated, not auto-generated. Agents read this file directly via read_file to understand what changed between versions.",
|
|
3
3
|
"versions": [
|
|
4
|
+
{
|
|
5
|
+
"version": "0.1.0-alpha.856",
|
|
6
|
+
"changes": [
|
|
7
|
+
"The daemon no longer freezes for minutes after an upgrade. Mailbox watched the whole agent bundle with Node's JavaScript recursive watcher on Linux, which blocked the event loop for about 7 s at every boot and for about 150 s when a host-side chown touched every bundle file, long enough to fail the container healthcheck and get the Butler restarted. Mailbox now watches directories without blocking: async discovery, event callbacks that never touch the filesystem, a catch-up report for new directories, a 3 s cap on event debouncing, and a warning event when watches run out."
|
|
8
|
+
]
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"version": "0.1.0-alpha.855",
|
|
12
|
+
"changes": [
|
|
13
|
+
"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.",
|
|
14
|
+
"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.",
|
|
15
|
+
"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."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
4
18
|
{
|
|
5
19
|
"version": "0.1.0-alpha.854",
|
|
6
20
|
"changes": [
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
|
|
11
11
|
|
|
12
|
-
import { readFileSync } from "node:fs"
|
|
12
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"
|
|
13
|
+
import { dirname } from "node:path"
|
|
13
14
|
import { pathToFileURL } from "node:url"
|
|
14
15
|
|
|
15
16
|
const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
|
|
@@ -877,6 +878,20 @@ export async function mediaSearchNow(a) {
|
|
|
877
878
|
return { kind: a.kind, service_id: item.id, title: item.title, command, queue: queueReport(await itemQueue(a.kind, item.id)) }
|
|
878
879
|
}
|
|
879
880
|
|
|
881
|
+
// Whether a download may be removed without the owner naming it. A download that has never
|
|
882
|
+
// started is dead once past the stall window. A partial one is dead only when observed with no
|
|
883
|
+
// ETA and no progress across 30 minutes: age alone is not enough (a 75.9% pack with no seeders
|
|
884
|
+
// today can finish when one returns), and anything with an ETA is still moving.
|
|
885
|
+
const hasEta = (row) => row.timeleft !== undefined && row.timeleft !== null && row.timeleft !== "00:00:00"
|
|
886
|
+
export function removalVerdict(row, nowMs, previous) {
|
|
887
|
+
if (hasEta(row)) return "still_downloading"
|
|
888
|
+
const left = row.sizeleft ?? 0
|
|
889
|
+
if ((row.size ?? 0) > 0 && left >= row.size) return isStalledRow(row, nowMs) ? "dead" : "not_started_yet"
|
|
890
|
+
if (row.status === "failed") return "dead"
|
|
891
|
+
const unchanged = previous && previous.sizeleft === left && nowMs - previous.at >= FILL_STALL_CONFIRM_MS
|
|
892
|
+
return unchanged ? "dead" : "stall_unconfirmed"
|
|
893
|
+
}
|
|
894
|
+
|
|
880
895
|
export async function mediaBlocklistStalled(a) {
|
|
881
896
|
if (a.kind !== "series" && a.kind !== "movie") return { result: "invalid_kind", message: "kind must be 'series' or 'movie'." }
|
|
882
897
|
if (a.service_id === undefined || a.service_id === null) return { result: "service_id_required" }
|
|
@@ -901,6 +916,27 @@ export async function mediaBlocklistStalled(a) {
|
|
|
901
916
|
seen.add(key)
|
|
902
917
|
return true
|
|
903
918
|
})
|
|
919
|
+
// Removing a download deletes its progress. Unless the owner asked for this download to go,
|
|
920
|
+
// only a download confirmed dead is removed: one still moving, or stalled without a second
|
|
921
|
+
// observation 30 minutes apart, may yet finish (2026-10-05: a 75.9% pack, the only copy, was
|
|
922
|
+
// deleted on "yes please get them").
|
|
923
|
+
const ownerWords = typeof a.owner_words === "string" ? a.owner_words.trim() : ""
|
|
924
|
+
if (ownerWords && !(Array.isArray(a.queue_ids) && a.queue_ids.length)) {
|
|
925
|
+
return { result: "owner_words_need_queue_ids", message: "owner_words removes only downloads the owner named: pass their queue_ids too.", queue: queueReport(rows) }
|
|
926
|
+
}
|
|
927
|
+
const kept = []
|
|
928
|
+
if (!ownerWords) {
|
|
929
|
+
targets = targets.filter((r) => {
|
|
930
|
+
const verdict = removalVerdict(r, now, observeProgress(progressKey(r, a.kind), r.sizeleft ?? 0, now))
|
|
931
|
+
if (verdict === "dead") return true
|
|
932
|
+
kept.push({ queue_id: r.id, title: r.title ?? null, percent: pct(r), reason: verdict })
|
|
933
|
+
return false
|
|
934
|
+
})
|
|
935
|
+
}
|
|
936
|
+
if (!targets.length && kept.length) {
|
|
937
|
+
return { result: "kept_downloads", kept, queue: queueReport(rows),
|
|
938
|
+
message: "Nothing removed. These downloads are not confirmed dead, and removing one deletes its progress. A partial download counts as dead only after two calls at least 30 minutes apart see no progress and no ETA; call again later to confirm. For missing episodes call media_fill_missing instead: it replaces a stalled download only when a seeded alternative exists. Pass owner_words only with the owner's own words asking to remove, clear or cancel this download; 'get them' is not that." }
|
|
939
|
+
}
|
|
904
940
|
if (!targets.length) return { result: "nothing_to_blocklist", queue: queueReport(rows) }
|
|
905
941
|
const removed = []
|
|
906
942
|
const failed = []
|
|
@@ -915,7 +951,7 @@ export async function mediaBlocklistStalled(a) {
|
|
|
915
951
|
}
|
|
916
952
|
}
|
|
917
953
|
const command = a.research === false ? null : await runSearch(a.kind, serviceId)
|
|
918
|
-
return { removed, ...(failed.length ? { failed } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
|
|
954
|
+
return { removed, ...(failed.length ? { failed } : {}), ...(kept.length ? { kept } : {}), ...(ownerWords ? { removed_on_owner_words: ownerWords } : {}), command, queue: queueReport(await itemQueue(a.kind, serviceId)) }
|
|
919
955
|
}
|
|
920
956
|
|
|
921
957
|
// ------------------------------- tools: episodes / releases / blocklist / import
|
|
@@ -1198,14 +1234,22 @@ function parseAgrees(release, episode) {
|
|
|
1198
1234
|
}
|
|
1199
1235
|
|
|
1200
1236
|
// Which wanted episodes one release really holds, and on what evidence; or why it is unusable.
|
|
1201
|
-
|
|
1237
|
+
// A blocklisted release is still rejected, but says which wanted episodes it would hold, so a
|
|
1238
|
+
// release blocklisted by mistake can be offered back to the owner.
|
|
1239
|
+
export function matchReleaseToEpisodes(release, ctx) {
|
|
1240
|
+
const rejections = rejectionTexts(release)
|
|
1241
|
+
if (!rejections.some((x) => /blocklist/i.test(x))) return matchUnblocked(release, ctx, rejections)
|
|
1242
|
+
const m = matchUnblocked(release, ctx, rejections.filter((x) => !/blocklist/i.test(x)))
|
|
1243
|
+
if (m.reject === "other_series") return m
|
|
1244
|
+
return { reject: "blocklisted", ...(m.episode_ids ? { would_match: { episode_ids: m.episode_ids, basis: m.basis } } : {}) }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function matchUnblocked(release, { seriesId, seriesTitle, seriesYear, wanted, have }, rejections) {
|
|
1202
1248
|
const title = release.title ?? ""
|
|
1203
1249
|
const norm = normalizeTitle(title)
|
|
1204
1250
|
const base = seriesBaseTitle(seriesTitle)
|
|
1205
|
-
const rejections = rejectionTexts(release)
|
|
1206
1251
|
if (!hasWord(norm, base)) return { reject: "other_series" }
|
|
1207
1252
|
if (release.mappedSeriesId && seriesId && release.mappedSeriesId !== seriesId) return { reject: "other_series" }
|
|
1208
|
-
if (rejections.some((x) => /blocklist/i.test(x))) return { reject: "blocklisted" }
|
|
1209
1253
|
if (rejections.some((x) => /not wanted in profile|below .*minimum|custom format/i.test(x))) return { reject: "quality_not_wanted" }
|
|
1210
1254
|
const wantedYears = new Set(wanted.map(airYear).filter(Boolean))
|
|
1211
1255
|
// The series' own premiere year ("Show (2019) Season 2") says nothing about which season it is,
|
|
@@ -1249,12 +1293,53 @@ export function stallReason(row, nowMs, previous) {
|
|
|
1249
1293
|
}
|
|
1250
1294
|
|
|
1251
1295
|
// Remaining size per download, as first seen at that size, so a later call can tell stopped from slow.
|
|
1252
|
-
|
|
1296
|
+
// Kept on disk: every Butler restart starts a new MCP process, and an observation lost with it
|
|
1297
|
+
// would leave a dead download unconfirmable (or make a fresh one look confirmed too early).
|
|
1253
1298
|
const PROGRESS_FORGET_MS = 14 * 24 * 3_600_000
|
|
1299
|
+
// Observations further apart than this are not continuous: the download may have been paused
|
|
1300
|
+
// and resumed in between, so the older one stops counting.
|
|
1301
|
+
const PROGRESS_CONTINUITY_MS = 6 * 3_600_000
|
|
1302
|
+
const progressPath = () => process.env.SANCTUARY_MEDIA_STATE ?? "/home/ouro/AgentBundles/sanctuary.ouro/state/media/download-progress.json"
|
|
1303
|
+
let progressSeen = null
|
|
1304
|
+
function readProgressFile() {
|
|
1305
|
+
const out = new Map()
|
|
1306
|
+
try {
|
|
1307
|
+
for (const [k, v] of Object.entries(JSON.parse(readFileSync(progressPath(), "utf8")))) {
|
|
1308
|
+
if (typeof v?.sizeleft === "number" && typeof v?.at === "number") out.set(k, { sizeleft: v.sizeleft, at: v.at, seen: typeof v.seen === "number" ? v.seen : v.at })
|
|
1309
|
+
}
|
|
1310
|
+
} catch { /* first run, or unreadable: start empty */ }
|
|
1311
|
+
return out
|
|
1312
|
+
}
|
|
1313
|
+
function loadProgress() {
|
|
1314
|
+
if (!progressSeen) progressSeen = readProgressFile()
|
|
1315
|
+
return progressSeen
|
|
1316
|
+
}
|
|
1317
|
+
// Merge with what another process (an overlapping restart) may have saved, keeping the
|
|
1318
|
+
// later sighting per download, then replace the file atomically so a crash cannot truncate it.
|
|
1319
|
+
function saveProgress(nowMs) {
|
|
1320
|
+
try {
|
|
1321
|
+
for (const [k, v] of readProgressFile()) {
|
|
1322
|
+
const mine = progressSeen.get(k)
|
|
1323
|
+
if (nowMs - v.seen <= PROGRESS_FORGET_MS && (!mine || v.seen > mine.seen)) progressSeen.set(k, v)
|
|
1324
|
+
}
|
|
1325
|
+
mkdirSync(dirname(progressPath()), { recursive: true, mode: 0o700 })
|
|
1326
|
+
const tmp = `${progressPath()}.tmp-${process.pid}`
|
|
1327
|
+
writeFileSync(tmp, JSON.stringify(Object.fromEntries(progressSeen)), { mode: 0o600 })
|
|
1328
|
+
renameSync(tmp, progressPath())
|
|
1329
|
+
} catch (e) {
|
|
1330
|
+
// Best effort: confirmation then waits for a later observation in this process.
|
|
1331
|
+
process.stderr.write(`media-mcp: download progress not saved: ${e.message}\n`)
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
export const progressKey = (row, kind = "series") => `${kind}:${row.downloadId ?? `row:${row.id}`}`.toUpperCase()
|
|
1254
1335
|
function observeProgress(downloadId, sizeleft, nowMs) {
|
|
1255
|
-
|
|
1256
|
-
const
|
|
1257
|
-
|
|
1336
|
+
const seen = loadProgress()
|
|
1337
|
+
for (const [k, v] of seen) if (nowMs - v.seen > PROGRESS_FORGET_MS) seen.delete(k)
|
|
1338
|
+
let previous = seen.get(downloadId)
|
|
1339
|
+
if (previous && nowMs - previous.seen > PROGRESS_CONTINUITY_MS) previous = undefined
|
|
1340
|
+
if (!previous || previous.sizeleft !== sizeleft) seen.set(downloadId, { sizeleft, at: nowMs, seen: nowMs })
|
|
1341
|
+
else seen.set(downloadId, { ...previous, seen: nowMs })
|
|
1342
|
+
saveProgress(nowMs)
|
|
1258
1343
|
return previous
|
|
1259
1344
|
}
|
|
1260
1345
|
|
|
@@ -1392,7 +1477,7 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1392
1477
|
ids.forEach((id) => covered.add(id))
|
|
1393
1478
|
continue
|
|
1394
1479
|
}
|
|
1395
|
-
const why = stallReason(d.row, nowMs, observeProgress(d.
|
|
1480
|
+
const why = stallReason(d.row, nowMs, observeProgress(progressKey(d.row), d.row.sizeleft ?? 0, nowMs))
|
|
1396
1481
|
const info = { ...entry, percent: pct(d.row), queue_id: d.row.id, added: d.row.added ?? null }
|
|
1397
1482
|
if (why) stalled.push({ ...info, ids, ...why })
|
|
1398
1483
|
else { inFlight.push({ ...info, state: importDone(d.row) ? "importing" : "downloading" }); ids.forEach((id) => covered.add(id)) }
|
|
@@ -1405,12 +1490,14 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1405
1490
|
const searchedEpisodes = new Set()
|
|
1406
1491
|
const candidates = new Map()
|
|
1407
1492
|
const rejected = new Map()
|
|
1493
|
+
const blockedMatches = new Map()
|
|
1408
1494
|
const inFlightTitles = new Set([...downloads.values()].map((d) => normalizeTitle(d.row?.title ?? d.title)).filter(Boolean))
|
|
1409
1495
|
const consider = (raw) => {
|
|
1410
1496
|
for (const r of Array.isArray(raw) ? raw : []) {
|
|
1411
1497
|
if (!r?.guid || candidates.has(r.guid) || rejected.has(r.guid)) continue
|
|
1412
1498
|
const m = matchReleaseToEpisodes(r, { seriesId, seriesTitle, seriesYear, wanted: need, have })
|
|
1413
1499
|
const sameAsInFlight = inFlightTitles.has(normalizeTitle(r.title))
|
|
1500
|
+
if (m.would_match && !sameAsInFlight) blockedMatches.set(r.guid, { raw: r, ids: m.would_match.episode_ids, basis: m.would_match.basis })
|
|
1414
1501
|
if (m.reject || sameAsInFlight) { rejected.set(r.guid, { title: r.title ?? null, seeders: r.seeders ?? null, reason: sameAsInFlight ? "same_release_as_stalled_download" : m.reject }); continue }
|
|
1415
1502
|
cacheRelease(`series:${r.guid}`, { ...releaseRow("series", r), service_id: seriesId, season_number: null, quality_raw: r.quality ?? null, languages_raw: r.languages ?? null })
|
|
1416
1503
|
candidates.set(r.guid, { raw: r, ids: m.episode_ids, basis: m.basis })
|
|
@@ -1438,6 +1525,33 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1438
1525
|
consider(await attempt(`episode search ${sxe(e)}`, () => sonarr("/release", { query: { episodeId: e.id }, timeoutMs: searchTimeout() })))
|
|
1439
1526
|
}
|
|
1440
1527
|
|
|
1528
|
+
// Episodes whose only matching releases are on the blocklist. With the owner's words such a
|
|
1529
|
+
// release joins the candidates and its blocklist entry is lifted only once it is actually being
|
|
1530
|
+
// grabbed (an unseeded one then waits for peers and is kept); without, it is reported.
|
|
1531
|
+
const covers = (id) => [...candidates.values()].some((c) => c.ids.includes(id))
|
|
1532
|
+
const blockedOnly = [...blockedMatches.values()].filter((b) => b.ids.some((id) => !covers(id)))
|
|
1533
|
+
const ownerRetry = typeof a.retry_blocklisted === "string" ? a.retry_blocklisted.trim() : ""
|
|
1534
|
+
const blockedLeft = new Map()
|
|
1535
|
+
if (blockedOnly.length) {
|
|
1536
|
+
const entries = (await attempt("read blocklist", () => itemBlocklist("series", seriesId))) ?? []
|
|
1537
|
+
for (const b of blockedOnly) {
|
|
1538
|
+
const ids = entries.filter((x) => normalizeTitle(x.sourceTitle) === normalizeTitle(b.raw.title)).map((x) => x.id)
|
|
1539
|
+
blockedLeft.set(b.raw.guid, { b, blocklist_ids: ids })
|
|
1540
|
+
if (ownerRetry && ids.length) {
|
|
1541
|
+
cacheRelease(`series:${b.raw.guid}`, { ...releaseRow("series", b.raw), service_id: seriesId, season_number: null, quality_raw: b.raw.quality ?? null, languages_raw: b.raw.languages ?? null })
|
|
1542
|
+
candidates.set(b.raw.guid, { ...b, blocklistIds: ids })
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
const lifted = new Set()
|
|
1547
|
+
const liftFor = async (c) => {
|
|
1548
|
+
const ids = c.blocklistIds.filter((id) => !lifted.has(id))
|
|
1549
|
+
if (ids.length && !(await attempt("remove from blocklist", async () => { await sonarr("/blocklist/bulk", { method: "DELETE", body: { ids } }); return true }))) return false
|
|
1550
|
+
ids.forEach((id) => lifted.add(id))
|
|
1551
|
+
if (ids.length) actions.push({ action: "removed_from_blocklist", title: c.raw.title ?? null, owner_words: ownerRetry })
|
|
1552
|
+
return true
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1441
1555
|
// Seeded releases first, then the ones that cover the most, then the best seeded.
|
|
1442
1556
|
const ranked = [...candidates.values()].sort((x, y) =>
|
|
1443
1557
|
Number((y.raw.seeders ?? 0) > 0) - Number((x.raw.seeders ?? 0) > 0) || y.ids.length - x.ids.length || (y.raw.seeders ?? 0) - (x.raw.seeders ?? 0))
|
|
@@ -1457,7 +1571,19 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1457
1571
|
if (!ids.length || !c.raw.quality) continue
|
|
1458
1572
|
if (grabs >= FILL_MAX_GRABS || !mayAct()) { ids.forEach((id) => deferred.add(id)); cutShort ||= !mayAct(); continue }
|
|
1459
1573
|
const body = { guid: c.raw.guid, indexerId: c.raw.indexerId, seriesId, episodeIds: ids, shouldOverride: true, quality: c.raw.quality, languages: Array.isArray(c.raw.languages) ? c.raw.languages : [] }
|
|
1460
|
-
const
|
|
1574
|
+
const post = async () => (await sonarr("/release", { method: "POST", body })) ?? {}
|
|
1575
|
+
let res
|
|
1576
|
+
if (c.blocklistIds) {
|
|
1577
|
+
// Grab first; lift the entry after it succeeds, or before one retry if Sonarr refused it as blocklisted.
|
|
1578
|
+
try { res = await post() } catch { res = undefined }
|
|
1579
|
+
const liftedOk = await liftFor(c)
|
|
1580
|
+
if (!res && liftedOk) res = await attempt(`grab ${c.raw.title}`, post)
|
|
1581
|
+
if (!res) continue
|
|
1582
|
+
blockedLeft.delete(c.raw.guid)
|
|
1583
|
+
for (const [g, x] of blockedLeft) if (normalizeTitle(x.b.raw.title) === normalizeTitle(c.raw.title)) blockedLeft.delete(g)
|
|
1584
|
+
} else {
|
|
1585
|
+
res = await attempt(`grab ${c.raw.title}`, post)
|
|
1586
|
+
}
|
|
1461
1587
|
if (!res) continue
|
|
1462
1588
|
grabs += 1
|
|
1463
1589
|
ids.forEach((id) => remaining.delete(id))
|
|
@@ -1505,14 +1631,22 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1505
1631
|
evidence.push({ query: q, results: own.length, top: rows.map((r) => ({ title: r.title ?? null, seeders: r.seeders ?? null, indexer: r.indexer ?? null })) })
|
|
1506
1632
|
}
|
|
1507
1633
|
}
|
|
1634
|
+
const blockedReport = []
|
|
1635
|
+
for (const { b, blocklist_ids } of blockedLeft.values()) {
|
|
1636
|
+
if (blockedReport.some((x) => normalizeTitle(x.title) === normalizeTitle(b.raw.title))) continue
|
|
1637
|
+
blockedReport.push({ title: b.raw.title ?? null, seeders: b.raw.seeders ?? null, episodes: b.ids.map((id) => sxe(byId.get(id))), blocklist_ids })
|
|
1638
|
+
}
|
|
1508
1639
|
const incomplete = cutShort || deferred.size > 0 || unsearched.length > 0 || !timeLeft() || errors.length > 0
|
|
1509
1640
|
const moving = inFlight.filter((x) => x.state !== "stalled").length
|
|
1641
|
+
const unseeded = actions.filter((x) => x.action === "grabbed" && x.may_never_finish)
|
|
1510
1642
|
const summary = [
|
|
1511
1643
|
`${have.length} of ${base.on_shelf.of} aired episodes are on the shelf; ${wanted.length} monitored ${wanted.length === 1 ? "episode is" : "episodes are"} missing.`,
|
|
1512
1644
|
grabs ? `Grabbed ${grabs} release${grabs === 1 ? "" : "s"} now.` : "",
|
|
1645
|
+
unseeded.length ? `${unseeded.map((x) => x.title).join("; ")} ${unseeded.length === 1 ? "has no seeders: it waits" : "have no seeders: they wait"} for peers and may never finish.` : "",
|
|
1513
1646
|
moving ? `${moving} download(s) in progress.` : "",
|
|
1514
1647
|
stuck.length ? `${stuck.length} download(s) stalled with no peers and no other usable release (${stuck.map((s) => `${s.title} at ${s.percent}%`).join("; ")}).` : "",
|
|
1515
1648
|
notFound.length ? `No usable release found for ${notFound.map((e) => e.code).join(", ")}.` : "",
|
|
1649
|
+
blockedReport.length ? `The only matching release${blockedReport.length === 1 ? " is" : "s are"} on the blocklist: ${blockedReport.map((x) => `${x.title} (${x.seeders ?? 0} seeders)`).join("; ")}.` : "",
|
|
1516
1650
|
deferred.size || unsearched.length ? `${deferred.size + unsearched.length} episode(s) not handled yet in this pass.` : "",
|
|
1517
1651
|
errors.length ? `${errors.length} step(s) failed: ${errors.map((x) => x.step).join("; ")}.` : "",
|
|
1518
1652
|
].filter(Boolean).join(" ")
|
|
@@ -1520,11 +1654,16 @@ async function fillPass(a, seriesId, seriesTitle, { nowMs = Date.now(), deadline
|
|
|
1520
1654
|
? "This pass did not finish everything. Tell the owner what it did, then call media_fill_missing again to continue; do not act by hand."
|
|
1521
1655
|
: stuck.length || notFound.length
|
|
1522
1656
|
? "Tell the owner exactly this outcome with the evidence; do not grab, import or blocklist anything by hand. Call media_fill_missing again later to retry."
|
|
1523
|
-
:
|
|
1657
|
+
: unseeded.length
|
|
1658
|
+
? "Tell the owner what was grabbed and that it has no seeders, so it may never finish. File a follow-up with await_condition to call media_fill_missing again later. Do not search or grab anything else now."
|
|
1659
|
+
: "Work is in progress. Tell the owner what is downloading; call media_fill_missing again later to import and confirm. Do not search or grab anything else now."
|
|
1660
|
+
const blockedHint = !blockedReport.length ? ""
|
|
1661
|
+
: ownerRetry ? " The owner's retry could not take the blocklisted release back (see errors and blocklisted_matches); tell the owner that plainly."
|
|
1662
|
+
: " The only matching release is on the blocklist: tell the owner, and if the owner then asks to get it anyway, call media_fill_missing again with retry_blocklisted set to the owner's own words. Do not remove blocklist entries or grab by hand."
|
|
1524
1663
|
return { ...base, goal_met: false, in_flight: inFlight, not_found: notFound, actions,
|
|
1525
1664
|
...(deferred.size ? { deferred: need.filter((e) => deferred.has(e.id)).map(code) } : {}), ...(unsearched.length ? { unsearched: unsearched.map(code) } : {}),
|
|
1526
|
-
searched, ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
|
|
1527
|
-
...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep }
|
|
1665
|
+
searched, ...(blockedReport.length ? { blocklisted_matches: blockedReport } : {}), ...(rejected.size ? { rejected_releases: [...rejected.values()].slice(0, 12) } : {}), ...(evidence ? { indexer_evidence: evidence } : {}),
|
|
1666
|
+
...(errors.length ? { errors } : {}), ...(incomplete ? { incomplete: true } : {}), summary, next_step: nextStep + blockedHint }
|
|
1528
1667
|
}
|
|
1529
1668
|
|
|
1530
1669
|
// ------------------------------------------------------------------- schema
|
|
@@ -1603,12 +1742,13 @@ const TOOL_LIST = [
|
|
|
1603
1742
|
},
|
|
1604
1743
|
{
|
|
1605
1744
|
name: "media_blocklist_stalled",
|
|
1606
|
-
description: "Clear stuck downloads for a series or movie already in Sonarr/Radarr: remove stalled queue rows from the download client, blocklist those releases so they are not grabbed again, then search again (research, default true). Use this when the owner asks to clear stuck or stalled downloads; no Jellyseerr request id is needed. Without queue_ids it targets the item's stalled rows; with queue_ids only those, and each must belong to the item.
|
|
1745
|
+
description: "Clear stuck downloads for a series or movie already in Sonarr/Radarr: remove stalled queue rows from the download client, blocklist those releases so they are not grabbed again, then search again (research, default true). Use this when the owner asks to clear stuck or stalled downloads; no Jellyseerr request id is needed. Without queue_ids it targets the item's stalled rows; with queue_ids only those, and each must belong to the item. A download still moving, or stalled but not yet confirmed dead, is kept (result kept_downloads) because removing it deletes its progress: a partial download counts as dead only after two calls at least 30 minutes apart see no ETA and no progress. Pass owner_words, with queue_ids, only when the owner's own message asked to remove, clear or cancel those downloads. For missing episodes use media_fill_missing instead. Rows of one season pack are removed once; a delete that fails is reported in `failed` and the re-search still runs.",
|
|
1607
1746
|
inputSchema: { type: "object", properties: {
|
|
1608
1747
|
kind: { type: "string", enum: ["series", "movie"] },
|
|
1609
1748
|
service_id: { type: "number", description: "Sonarr series id or Radarr movie id (media_search_now returns it)." },
|
|
1610
1749
|
queue_ids: { type: "array", items: { type: "number" } },
|
|
1611
1750
|
research: { type: "boolean", description: "Search again afterwards. Default true." },
|
|
1751
|
+
owner_words: { type: "string", description: "The owner's exact words asking to remove, clear or cancel these downloads; requires queue_ids. Not for 'get them'." },
|
|
1612
1752
|
}, required: ["kind", "service_id"] },
|
|
1613
1753
|
},
|
|
1614
1754
|
{
|
|
@@ -1619,6 +1759,7 @@ const TOOL_LIST = [
|
|
|
1619
1759
|
series: { type: "string", description: "Series title as the owner said it." },
|
|
1620
1760
|
service_id: { type: "number", description: "Sonarr series id, when known." },
|
|
1621
1761
|
season_number: { type: "number", description: "Limit to one season. Omit for the whole series (specials excluded)." },
|
|
1762
|
+
retry_blocklisted: { type: "string", description: "The owner's own words asking to get episodes whose only matching release is on the blocklist (reported in blocklisted_matches). Takes those releases off the blocklist and grabs them." },
|
|
1622
1763
|
} },
|
|
1623
1764
|
},
|
|
1624
1765
|
{
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
- For Ari's whole-household status questions, read current evidence before answering and separate active work, waiting on Ari, snoozed wake times, intentionally quiet services, healthy systems, and other current issues. Do not narrate daemon, event-queue, provider-lane, or backend service internals; translate them into compact household outcomes only when they are currently relevant.
|
|
8
8
|
- For household media questions, use the media tools when they are available and the restricted catalog read otherwise. Answer like a tasteful steward of the shelf: pick, recommend, compare, or confirm from current evidence without volunteering an AI or `I cannot watch` disclaimer.
|
|
9
9
|
- Check shelf state before naming a title. Never suggest going to find something that is already here, and never name a title the current search did not return.
|
|
10
|
-
- A promise of follow-up is an obligation, not a pleasantry. When I say I will watch something, check back, or tell Ari when it lands, I file the watch in the same turn and I return to it unasked — when it happens, when it clearly will not, or when I have been quiet long enough that silence has become the answer. Ari should never have to ask whether I am still on it.
|
|
10
|
+
- A promise of follow-up is an obligation, not a pleasantry. When I say I will watch something, check back, or tell Ari when it lands, I file the watch with `await_condition` in the same turn and I return to it unasked; if I have not filed it, I do not promise it — when it happens, when it clearly will not, or when I have been quiet long enough that silence has become the answer. Ari should never have to ask whether I am still on it.
|
|
11
11
|
- Closing a loop is worth a short message even when the news is dull. "Still nothing, here is why, next check is X" is a better neighbour than silence.
|
|
12
12
|
- Do not open a reply by announcing its honesty. Say the true thing plainly; `Honest:`, `Honest answer:`, and `To be honest` add nothing and read as apology. Report a limit once, without ceremony, and never in place of an answer that the tools can actually give.
|
|
13
13
|
- Never touch array/parity/disk configuration, permissions, network/security settings, or `/mnt/user` paths.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
- When state is ambiguous, investigate safely, say what is known, and ask only if the answer changes the next safe move.
|
|
16
16
|
- When a tool for the job is missing, say so in one line and route the work: ask Claude Code over A2A or record it as a follow-up. Never ask Ari to click through a web UI for work the house is meant to do.
|
|
17
17
|
- Answer from primary sources, never from memory. For any factual claim about the world or the house (whether a title or series exists, what is on the shelf, a container's state, a version, a download's progress, a book's metadata, what Ari said before), look it up with the tools first (Sonarr/Radarr/Jellyfin and their TVDB/TMDB lookup, the Unraid tools, calibre-web, libgen, HEY, web_search, your own state files) and answer from what they return. Re-read live state instead of repeating a status from an earlier reply. When no tool can reach the source, say it is unverified rather than asserting it. Never say a title does not exist without a catalog lookup, and when the library holds a strong partial match, offer it first.
|
|
18
|
-
- When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
|
|
18
|
+
- When episodes are missing, or Ari asks to get them, call `media_fill_missing` first. It re-reads Sonarr, matches releases by episode title and air year, grabs, imports by title and replaces stalled downloads in one call, and it is safe to call again. Relay its `summary` with the evidence it returns and follow its `next_step`; do not redo its steps by hand. When it keeps a stalled download, that download is the only copy and may still finish: never offer to clear, cancel or blocklist it. A yes or "get them" after that answer means call `media_fill_missing` again, never `media_blocklist_stalled`. When it reports `blocklisted_matches`, the only matching release is on the blocklist: tell Ari and offer to retry it; if he says yes, call `media_fill_missing` again with `retry_blocklisted` set to his words. When it grabs something that may never finish, say so plainly and file the follow-up with `await_condition` before ending the turn. When it reports `goal_met`, or an episode Ari asked for is imported, stop: say so and end the turn without further probing.
|
|
19
19
|
- Before stating whether an episode is monitored, missing, downloading or on disk, read it from a tool in this turn (`media_fill_missing` or `media_episodes`). Never infer it from an earlier reply or from a tool that did not report it.
|
|
20
20
|
- Use only the parameters a tool lists. If a call is refused for an unknown parameter or a wrong value, read the allowed ones from the refusal and call again correctly; never invent flags such as `dry_run`.
|
|
21
21
|
- When an automatic search finds nothing for episodes that exist and `media_fill_missing` cannot place them, suspect numbering (streaming vs TVDB order, specials, absolute numbers). Read `media_episodes` (its `scene_code` is the numbering release names use), compare its titles with the `media_release_search` results (how Sonarr parsed each release) and with what `media_blocklist` holds, grab the right pack with `media_release_grab` and an explicit episode mapping, and after it downloads, map the files to episodes by title with `media_manual_import`. When Sonarr's search finds nothing, use `media_indexer_search` with the scene code, 'Season N', the year or an episode title to learn whether any release exists at all; if the only matching release has no seeders, still grab it with the right mapping (peers can reappear) and say plainly it may never finish instead of promising it will arrive. Report each step with what Sonarr returned.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<?xml version="1.0"?>
|
|
2
2
|
<Container version="2">
|
|
3
3
|
<Name>ouro-butler</Name>
|
|
4
|
-
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.
|
|
4
|
+
<Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.856</Repository>
|
|
5
5
|
<Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
|
|
6
6
|
<Network>host</Network>
|
|
7
7
|
<Shell>sh</Shell>
|
|
@@ -37,9 +37,13 @@ exports.createSseBroadcaster = createSseBroadcaster;
|
|
|
37
37
|
exports.createStateChangedBroadcast = createStateChangedBroadcast;
|
|
38
38
|
exports.createBundleWatcher = createBundleWatcher;
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const runtime_1 = require("../../nerves/runtime");
|
|
40
42
|
const DEFAULT_BUNDLE_WATCHER_DEPS = {
|
|
43
|
+
platform: process.platform,
|
|
41
44
|
existsSync: fs.existsSync,
|
|
42
|
-
watch: fs.watch,
|
|
45
|
+
watch: (targetPath, options, listener) => fs.watch(targetPath, options, listener),
|
|
46
|
+
readdir: (targetPath) => fs.promises.readdir(targetPath, { withFileTypes: true }),
|
|
43
47
|
setTimeout,
|
|
44
48
|
clearTimeout,
|
|
45
49
|
};
|
|
@@ -81,49 +85,188 @@ function createStateChangedBroadcast(sse) {
|
|
|
81
85
|
sse.broadcast("state-changed", { at: new Date().toISOString() });
|
|
82
86
|
};
|
|
83
87
|
}
|
|
88
|
+
const BUNDLE_WATCH_DEBOUNCE_MS = 500;
|
|
89
|
+
// A continuous event stream must not postpone onChange forever.
|
|
90
|
+
const BUNDLE_WATCH_MAX_WAIT_MS = 3_000;
|
|
91
|
+
const BUNDLE_WATCH_DEGRADED_LOG_INTERVAL_MS = 60_000;
|
|
92
|
+
const BUNDLE_WATCH_SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
|
|
93
|
+
function closeQuietly(handle) {
|
|
94
|
+
try {
|
|
95
|
+
handle.close();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Already closed.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Report "something under the bundles root changed" to Mailbox, debounced.
|
|
103
|
+
*
|
|
104
|
+
* On Linux, Node implements `fs.watch(root, { recursive: true })` in JavaScript: it stats and
|
|
105
|
+
* inotify-watches every file in the tree before returning (7-10 s of blocked event loop for a
|
|
106
|
+
* 12k-file bundle), and it answers every file event on a directory by re-reading that whole
|
|
107
|
+
* directory. A host-side `chown -R` of a bundle holding directories of ~3.5k receipts therefore
|
|
108
|
+
* keeps the daemon's event loop busy for ~150 s, long enough to fail the container healthcheck.
|
|
109
|
+
*
|
|
110
|
+
* So on Linux we watch each directory (not each file) non-recursively. The tree is discovered
|
|
111
|
+
* with async readdir, and event callbacks never touch the filesystem: they only schedule a
|
|
112
|
+
* debounced onChange, plus, for create/delete/rename events, a debounced async re-read of that
|
|
113
|
+
* one directory to pick up added or removed subdirectories. macOS and Windows have a native
|
|
114
|
+
* recursive watcher that is cheap, so they keep it. Symlinked directories are not followed, and
|
|
115
|
+
* .git and node_modules are not watched.
|
|
116
|
+
*/
|
|
84
117
|
function createBundleWatcher(bundlesRoot, onChange, deps = DEFAULT_BUNDLE_WATCHER_DEPS) {
|
|
85
|
-
const watchers =
|
|
118
|
+
const watchers = new Map();
|
|
119
|
+
const pendingDirectories = new Set();
|
|
86
120
|
let debounceTimer = null;
|
|
87
|
-
|
|
121
|
+
let rescanTimer = null;
|
|
122
|
+
let maxWaitTimer = null;
|
|
123
|
+
let rescanRunning = false;
|
|
124
|
+
let stopped = false;
|
|
125
|
+
let resourceExhausted = false;
|
|
126
|
+
let lastDegradedLogAt = Number.NEGATIVE_INFINITY;
|
|
127
|
+
function flushOnChange() {
|
|
128
|
+
if (debounceTimer)
|
|
129
|
+
deps.clearTimeout(debounceTimer);
|
|
130
|
+
if (maxWaitTimer)
|
|
131
|
+
deps.clearTimeout(maxWaitTimer);
|
|
132
|
+
debounceTimer = null;
|
|
133
|
+
maxWaitTimer = null;
|
|
134
|
+
onChange();
|
|
135
|
+
}
|
|
88
136
|
function debouncedOnChange() {
|
|
89
137
|
if (debounceTimer)
|
|
90
138
|
deps.clearTimeout(debounceTimer);
|
|
91
|
-
debounceTimer = deps.setTimeout(
|
|
139
|
+
debounceTimer = deps.setTimeout(flushOnChange, BUNDLE_WATCH_DEBOUNCE_MS);
|
|
140
|
+
if (!maxWaitTimer)
|
|
141
|
+
maxWaitTimer = deps.setTimeout(flushOnChange, BUNDLE_WATCH_MAX_WAIT_MS);
|
|
92
142
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
143
|
+
function reportWatchDegraded(targetPath, error) {
|
|
144
|
+
const code = error?.code;
|
|
145
|
+
if (code === "ENOSPC" || code === "EMFILE")
|
|
146
|
+
resourceExhausted = true;
|
|
147
|
+
const now = Date.now();
|
|
148
|
+
if (now - lastDegradedLogAt < BUNDLE_WATCH_DEGRADED_LOG_INTERVAL_MS)
|
|
149
|
+
return;
|
|
150
|
+
lastDegradedLogAt = now;
|
|
151
|
+
const reason = typeof code === "string" ? code : error instanceof Error ? error.message : String(error);
|
|
152
|
+
(0, runtime_1.emitNervesEvent)({
|
|
153
|
+
level: "warn",
|
|
154
|
+
component: "daemon",
|
|
155
|
+
event: "daemon.mailbox_watch_degraded",
|
|
156
|
+
message: `mailbox watch degraded: ${reason} at ${targetPath}`,
|
|
157
|
+
meta: { path: targetPath, reason },
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function watchPath(targetPath, recursive, listener) {
|
|
161
|
+
try {
|
|
162
|
+
const handle = deps.watch(targetPath, { recursive }, listener);
|
|
163
|
+
handle.on?.("error", (error) => {
|
|
164
|
+
closeQuietly(handle);
|
|
165
|
+
if (watchers.get(targetPath) === handle)
|
|
166
|
+
watchers.delete(targetPath);
|
|
167
|
+
if (stopped)
|
|
168
|
+
return;
|
|
169
|
+
reportWatchDegraded(targetPath, error);
|
|
106
170
|
debouncedOnChange();
|
|
107
171
|
});
|
|
108
|
-
watchers.
|
|
172
|
+
watchers.set(targetPath, handle);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// Watching is best-effort; manual broadcasts still keep Mailbox usable.
|
|
177
|
+
reportWatchDegraded(targetPath, error);
|
|
178
|
+
return false;
|
|
109
179
|
}
|
|
110
180
|
}
|
|
111
|
-
|
|
112
|
-
|
|
181
|
+
function unwatchTree(directory) {
|
|
182
|
+
for (const [watched, handle] of watchers) {
|
|
183
|
+
if (watched === directory || watched.startsWith(`${directory}${path.sep}`)) {
|
|
184
|
+
closeQuietly(handle);
|
|
185
|
+
watchers.delete(watched);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function scheduleRescan(directory) {
|
|
190
|
+
pendingDirectories.add(directory);
|
|
191
|
+
if (rescanTimer)
|
|
192
|
+
return;
|
|
193
|
+
rescanTimer = deps.setTimeout(() => {
|
|
194
|
+
rescanTimer = null;
|
|
195
|
+
void drainRescans();
|
|
196
|
+
}, BUNDLE_WATCH_DEBOUNCE_MS);
|
|
197
|
+
}
|
|
198
|
+
async function drainRescans() {
|
|
199
|
+
if (rescanRunning)
|
|
200
|
+
return;
|
|
201
|
+
rescanRunning = true;
|
|
202
|
+
try {
|
|
203
|
+
while (!stopped && pendingDirectories.size > 0) {
|
|
204
|
+
const [directory] = pendingDirectories;
|
|
205
|
+
pendingDirectories.delete(directory);
|
|
206
|
+
await reconcileDirectory(directory, true);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
rescanRunning = false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function watchDirectory(directory) {
|
|
214
|
+
return watchPath(directory, false, (eventType) => {
|
|
215
|
+
debouncedOnChange();
|
|
216
|
+
if (eventType === "rename")
|
|
217
|
+
scheduleRescan(directory);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
// catchUp is true for rescans after boot: files written into a directory before its watch existed
|
|
221
|
+
// produced no event, so a newly watched directory reports one change itself.
|
|
222
|
+
async function reconcileDirectory(directory, catchUp) {
|
|
223
|
+
let entries;
|
|
224
|
+
try {
|
|
225
|
+
entries = await deps.readdir(directory);
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
unwatchTree(directory);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (stopped)
|
|
232
|
+
return;
|
|
233
|
+
const subdirectories = new Set(entries.filter((entry) => entry.isDirectory() && !BUNDLE_WATCH_SKIPPED_DIRECTORIES.has(entry.name)).map((entry) => path.join(directory, entry.name)));
|
|
234
|
+
for (const watched of [...watchers.keys()]) {
|
|
235
|
+
if (path.dirname(watched) === directory && !subdirectories.has(watched))
|
|
236
|
+
unwatchTree(watched);
|
|
237
|
+
}
|
|
238
|
+
for (const subdirectory of subdirectories) {
|
|
239
|
+
if (stopped || resourceExhausted)
|
|
240
|
+
return;
|
|
241
|
+
if (watchers.has(subdirectory) || !watchDirectory(subdirectory))
|
|
242
|
+
continue;
|
|
243
|
+
if (catchUp)
|
|
244
|
+
debouncedOnChange();
|
|
245
|
+
await reconcileDirectory(subdirectory, catchUp);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
if (deps.existsSync(bundlesRoot)) {
|
|
249
|
+
if (deps.platform === "linux") {
|
|
250
|
+
if (watchDirectory(bundlesRoot))
|
|
251
|
+
void reconcileDirectory(bundlesRoot, false);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
watchPath(bundlesRoot, true, debouncedOnChange);
|
|
255
|
+
}
|
|
113
256
|
}
|
|
114
257
|
return {
|
|
115
258
|
stop() {
|
|
259
|
+
stopped = true;
|
|
116
260
|
if (debounceTimer)
|
|
117
261
|
deps.clearTimeout(debounceTimer);
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
watchers.length = 0;
|
|
262
|
+
if (maxWaitTimer)
|
|
263
|
+
deps.clearTimeout(maxWaitTimer);
|
|
264
|
+
if (rescanTimer)
|
|
265
|
+
deps.clearTimeout(rescanTimer);
|
|
266
|
+
pendingDirectories.clear();
|
|
267
|
+
for (const handle of watchers.values())
|
|
268
|
+
closeQuietly(handle);
|
|
269
|
+
watchers.clear();
|
|
127
270
|
},
|
|
128
271
|
};
|
|
129
272
|
}
|
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.856",
|
|
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.856",
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@anthropic-ai/sdk": "^0.78.0",
|
|
12
12
|
"@azure/identity": "^4.13.0",
|