@ouro.bot/cli 0.1.0-alpha.826 → 0.1.0-alpha.828

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/changelog.json CHANGED
@@ -1,6 +1,18 @@
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.828",
6
+ "changes": [
7
+ "The media MCP's deterministic core (download-state and diagnosis) is now a tested, importable seam instead of only being verifiable in production."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.827",
12
+ "changes": [
13
+ "Awaits advance their own cadence on dispatch, so a filed watch no longer fires forever without recording a check."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.826",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.826",
2
+ "runtimeVersion": "0.1.0-alpha.828",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -10,6 +10,7 @@
10
10
  // Protocol: JSON-RPC 2.0 over stdio, newline framed, MCP 2024-11-05.
11
11
 
12
12
  import { readFileSync } from "node:fs"
13
+ import { pathToFileURL } from "node:url"
13
14
 
14
15
  const CRED_PATH = process.env.SANCTUARY_MEDIA_CREDENTIALS ?? "/home/ouro/AgentBundles/sanctuary.ouro/mcp/media-credentials.json"
15
16
 
@@ -425,31 +426,43 @@ async function mediaRequestStatus(a) {
425
426
  last_grab_title: grabs[0]?.sourceTitle ?? null,
426
427
  monitored: entity ? Boolean(entity.monitored) : null,
427
428
  }
428
- // A season pack shows up as one queue row per episode, all sharing one
429
- // downloadId. Summing rows would report a 45 GB pack as 450 GB, so size and
430
- // "how many downloads" are both counted per distinct download, not per row.
429
+ result.download = computeDownloadState(queueRecs, Date.now())
430
+
431
+ const chain = await chainHealth()
432
+ result.chain = chain
433
+ result.diagnosis = diagnose({ shelf, result, chain, entity, kind })
434
+ return result
435
+ }
436
+
437
+ // The deterministic core, split out so it can be tested without a live
438
+ // Sonarr/Radarr. `computeDownloadState` and `diagnose` are the whole reason the
439
+ // media surface can answer "why hasn't this downloaded" without the agent
440
+ // guessing, and D-013 (a stalled download read as progress) shipped verified
441
+ // only in production because there was no seam to test them through.
442
+ //
443
+ // A season pack shows up as one queue row per episode, all sharing one
444
+ // downloadId. Summing rows would report a 45 GB pack as 450 GB, so size and
445
+ // "how many downloads" are counted per distinct download, not per row. A
446
+ // torrent that has not moved a byte since it was grabbed is dead, not slow:
447
+ // Radarr reports trackedDownloadStatus "ok" for these indefinitely, so
448
+ // progress is measured against age rather than trusted from the row.
449
+ export function computeDownloadState(queueRecs, nowMs) {
431
450
  const byDownload = new Map()
432
451
  for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
433
452
  const downloads = [...byDownload.values()]
434
453
  const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
435
454
  const sizeTotal = downloads.reduce((s, r) => s + (r.size ?? 0), 0)
436
- // A torrent that has not moved a byte since it was grabbed is not slow, it is
437
- // dead: no seeders, or a release the client cannot fetch. Radarr goes on
438
- // reporting trackedDownloadStatus "ok" for these indefinitely, so a queue row
439
- // on its own reads as healthy and any answer built from it reassures instead
440
- // of acting. Measuring progress against age is what separates the two.
441
- const now = Date.now()
442
455
  const stalledItems = downloads
443
456
  .filter((r) => (r.size ?? 0) > 0 && (r.sizeleft ?? 0) >= (r.size ?? 0) && r.added
444
- && (now - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
457
+ && (nowMs - Date.parse(r.added)) / 3_600_000 >= STALL_AFTER_HOURS)
445
458
  .map((r) => ({
446
459
  title: r.title ?? null,
447
460
  added_at: r.added ?? null,
448
- age_hours: Math.round((now - Date.parse(r.added)) / 3_600_000),
461
+ age_hours: Math.round((nowMs - Date.parse(r.added)) / 3_600_000),
449
462
  size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
450
463
  queue_id: r.id ?? null,
451
464
  }))
452
- result.download = {
465
+ return {
453
466
  active_downloads: downloads.length,
454
467
  active_items: queueRecs.length,
455
468
  states: [...new Set(downloads.map((r) => r.status))],
@@ -461,15 +474,10 @@ async function mediaRequestStatus(a) {
461
474
  stalled: stalledItems.length > 0,
462
475
  stalled_items: stalledItems,
463
476
  }
464
-
465
- const chain = await chainHealth()
466
- result.chain = chain
467
- result.diagnosis = diagnose({ shelf, result, chain, entity, kind })
468
- return result
469
477
  }
470
478
 
471
479
  // The deterministic core. The agent must never have to work this out itself.
472
- function diagnose({ shelf, result, chain, entity, kind }) {
480
+ export function diagnose({ shelf, result, chain, entity, kind }) {
473
481
  const file = result.file ?? {}
474
482
  const onShelf = kind === "series" ? (file.episodes_on_disk ?? 0) > 0 : Boolean(file.on_shelf)
475
483
  const complete = kind === "series" ? (file.episodes_on_disk ?? 0) >= (file.episodes_total ?? Infinity) : onShelf
@@ -759,22 +767,29 @@ function exitWhenIdle() {
759
767
  if (stdinClosed && inFlight === 0) process.exit(0)
760
768
  }
761
769
 
762
- process.stdin.setEncoding("utf8")
763
- process.stdin.on("data", (chunk) => {
764
- buf += chunk
765
- let nl
766
- while ((nl = buf.indexOf("\n")) >= 0) {
767
- const line = buf.slice(0, nl).trim()
768
- buf = buf.slice(nl + 1)
769
- if (!line) continue
770
- let msg
771
- try { msg = JSON.parse(line) } catch { continue }
772
- inFlight += 1
773
- handle(msg)
774
- .catch((e) => {
775
- if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } })
776
- })
777
- .finally(() => { inFlight -= 1; exitWhenIdle() })
778
- }
779
- })
780
- process.stdin.on("end", () => { stdinClosed = true; exitWhenIdle() })
770
+ // Only run the stdio server when invoked directly (node media-mcp.mjs, exactly
771
+ // how agent.json launches it). Guarding this lets the test suite import the
772
+ // module for computeDownloadState/diagnose without attaching to stdin or exiting
773
+ // the test process. The launch is a plain node <abs-path>, so argv[1] is the
774
+ // file's own path and this comparison holds.
775
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
776
+ process.stdin.setEncoding("utf8")
777
+ process.stdin.on("data", (chunk) => {
778
+ buf += chunk
779
+ let nl
780
+ while ((nl = buf.indexOf("\n")) >= 0) {
781
+ const line = buf.slice(0, nl).trim()
782
+ buf = buf.slice(nl + 1)
783
+ if (!line) continue
784
+ let msg
785
+ try { msg = JSON.parse(line) } catch { continue }
786
+ inFlight += 1
787
+ handle(msg)
788
+ .catch((e) => {
789
+ if (msg.id !== undefined) send({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: e.message } })
790
+ })
791
+ .finally(() => { inFlight -= 1; exitWhenIdle() })
792
+ }
793
+ })
794
+ process.stdin.on("end", () => { stdinClosed = true; exitWhenIdle() })
795
+ }
@@ -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.826</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.828</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -36,6 +36,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readAwaitRuntimeState = readAwaitRuntimeState;
37
37
  exports.applyAwaitRuntimeState = applyAwaitRuntimeState;
38
38
  exports.writeAwaitRuntimeState = writeAwaitRuntimeState;
39
+ exports.recordAwaitDispatch = recordAwaitDispatch;
39
40
  exports.recordAwaitCheck = recordAwaitCheck;
40
41
  const path = __importStar(require("path"));
41
42
  const json_store_1 = require("../../arc/json-store");
@@ -89,6 +90,16 @@ function writeAwaitRuntimeState(agentRoot, name, partial) {
89
90
  meta: { agentRoot, name, last_checked: merged.last_checked, checked_count: merged.checked_count },
90
91
  });
91
92
  }
93
+ // The scheduler owns cadence, not the model. `recordAwaitCheck` is written only
94
+ // when a woken turn calls `resolve_await` with verdict "no", so an await whose
95
+ // turn answers in chat, errors, or simply never calls the tool leaves
96
+ // `last_checked` null forever: it reads as "never checked" on every
97
+ // reconciliation, re-fires, wakes the private runtime again and records
98
+ // nothing. Recording the dispatch itself keeps the cadence honest whatever the
99
+ // turn does; the observation from `resolve_await` still layers on top.
100
+ function recordAwaitDispatch(agentRoot, name, now) {
101
+ writeAwaitRuntimeState(agentRoot, name, { last_checked: now });
102
+ }
92
103
  function recordAwaitCheck(agentRoot, name, observation, now) {
93
104
  const existing = readAwaitRuntimeState(agentRoot, name);
94
105
  const nextCount = (existing?.checked_count ?? 0) + 1;
@@ -55,6 +55,7 @@ const habit_scheduler_1 = require("../habits/habit-scheduler");
55
55
  const habit_migration_1 = require("../habits/habit-migration");
56
56
  const await_scheduler_1 = require("../awaiting/await-scheduler");
57
57
  const await_expiry_1 = require("../awaiting/await-expiry");
58
+ const await_runtime_state_1 = require("../awaiting/await-runtime-state");
58
59
  const os_cron_deps_1 = require("./os-cron-deps");
59
60
  const os_cron_1 = require("./os-cron");
60
61
  const container_runtime_1 = require("./container-runtime");
@@ -801,7 +802,15 @@ void (0, daemon_bootstrap_startup_1.startDaemonAfterContainerCredentialBootstrap
801
802
  agent,
802
803
  awaitName,
803
804
  triggerSource: "await-scheduler",
804
- })).catch((error) => {
805
+ })).then(() => {
806
+ // Record the dispatch here so the cadence advances whatever the woken
807
+ // turn does. Without this the only writer of `last_checked` is the
808
+ // agent calling `resolve_await`, so a turn that does anything else
809
+ // leaves the await permanently "never checked" and it re-fires on
810
+ // every reconciliation. Recorded only on a successful wake: a failed
811
+ // dispatch is not a check.
812
+ (0, await_runtime_state_1.recordAwaitDispatch)(bundleRoot, awaitName, new Date().toISOString());
813
+ }).catch((error) => {
805
814
  emitAwaitPrivateWakeDispatchError({
806
815
  agent,
807
816
  awaitName,
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.826",
3
+ "version": "0.1.0-alpha.828",
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.826",
9
+ "version": "0.1.0-alpha.828",
10
10
  "dependencies": {
11
11
  "@anthropic-ai/sdk": "^0.78.0",
12
12
  "@azure/identity": "^4.13.0",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.826",
3
+ "version": "0.1.0-alpha.828",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },