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

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.826",
6
+ "changes": [
7
+ "Sanctuary: a download sitting at zero bytes is reported as stalled rather than as progress, with a blocklist-and-research repair."
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.825",
12
+ "changes": [
13
+ "Sanctuary: a single nested emphasis no longer makes a whole Butler reply render as literal asterisks."
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.824",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.824",
2
+ "runtimeVersion": "0.1.0-alpha.826",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -73,6 +73,10 @@ class ServiceError extends Error {
73
73
  }
74
74
 
75
75
  const seerr = (path, opts = {}) => req(SEERR.url, `/api/v1${path}`, { key: SEERR.apiKey, ...opts })
76
+ // Long enough that an ordinary slow start is not called dead, short enough that
77
+ // nobody waits a day for a release that was never going to arrive.
78
+ const STALL_AFTER_HOURS = 6
79
+
76
80
  const sonarr = (path, opts = {}) => req(SONARR.url, `/api/v3${path}`, { key: SONARR.apiKey, ...opts })
77
81
  const radarr = (path, opts = {}) => req(RADARR.url, `/api/v3${path}`, { key: RADARR.apiKey, ...opts })
78
82
  const prowlarr = (path, opts = {}) => req(PROWLARR.url, `/api/v1${path}`, { key: PROWLARR.apiKey, ...opts })
@@ -427,14 +431,35 @@ async function mediaRequestStatus(a) {
427
431
  const byDownload = new Map()
428
432
  for (const r of queueRecs) byDownload.set(r.downloadId ?? `row:${r.id}`, r)
429
433
  const downloads = [...byDownload.values()]
434
+ const sizeLeft = downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0)
435
+ 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
+ const stalledItems = downloads
443
+ .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)
445
+ .map((r) => ({
446
+ title: r.title ?? null,
447
+ added_at: r.added ?? null,
448
+ age_hours: Math.round((now - Date.parse(r.added)) / 3_600_000),
449
+ size_gb: Number(((r.size ?? 0) / 1073741824).toFixed(2)),
450
+ queue_id: r.id ?? null,
451
+ }))
430
452
  result.download = {
431
453
  active_downloads: downloads.length,
432
454
  active_items: queueRecs.length,
433
455
  states: [...new Set(downloads.map((r) => r.status))],
434
456
  tracked_states: [...new Set(downloads.map((r) => r.trackedDownloadState).filter(Boolean))],
435
457
  errors: [...new Set(downloads.map((r) => r.errorMessage).filter(Boolean))],
436
- size_left_bytes: downloads.reduce((s, r) => s + (r.sizeleft ?? 0), 0),
437
- size_total_bytes: downloads.reduce((s, r) => s + (r.size ?? 0), 0),
458
+ size_left_bytes: sizeLeft,
459
+ size_total_bytes: sizeTotal,
460
+ percent_complete: sizeTotal > 0 ? Math.round(((sizeTotal - sizeLeft) / sizeTotal) * 100) : null,
461
+ stalled: stalledItems.length > 0,
462
+ stalled_items: stalledItems,
438
463
  }
439
464
 
440
465
  const chain = await chainHealth()
@@ -453,6 +478,15 @@ function diagnose({ shelf, result, chain, entity, kind }) {
453
478
  return { stuck_stage: null, stuck_reason: null, likely_fix: null, human_action_required: false,
454
479
  summary: "On the shelf and complete. Nothing is stuck." }
455
480
  }
481
+ if (result.download.stalled) {
482
+ const items = result.download.stalled_items
483
+ const oldest = Math.max(...items.map((i) => i.age_hours))
484
+ return { stuck_stage: "download", stuck_reason: "stalled_no_progress",
485
+ likely_fix: "blocklist_and_research", human_action_required: false,
486
+ percent_complete: result.download.percent_complete,
487
+ detail: items,
488
+ summary: `Stalled, not slow. ${items.length === 1 ? "The release" : `${items.length} releases`} ${items.length === 1 ? "has" : "have"} not downloaded a single byte in ${oldest} hours, which means no seeders rather than a quiet queue. Waiting will not fix it; blocklist the release and search again for a different one.` }
489
+ }
456
490
  if (result.download.active_downloads > 0) {
457
491
  const left = result.download.size_left_bytes
458
492
  const total = result.download.size_total_bytes
@@ -528,9 +562,23 @@ async function mediaDiagnoseAndFix(a) {
528
562
  ? (action === "force_import_scan" ? { name: "RescanSeries", seriesId: svcId } : { name: "SeriesSearch", seriesId: svcId })
529
563
  : (action === "force_import_scan" ? { name: "RescanMovie", movieIds: [svcId] } : { name: "MoviesSearch", movieIds: [svcId] })
530
564
  commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
565
+ } else if (action === "blocklist_and_research") {
566
+ // Removing with blocklist=true is what stops the same dead release being
567
+ // grabbed straight back. The search that follows is then free to pick a
568
+ // different one.
569
+ const stalled = before.download.stalled_items ?? []
570
+ if (!stalled.length) return { action, result: "nothing_to_blocklist", before, after: null, human_action_required: false,
571
+ human_action_reason: "No download has been sitting at zero long enough to call it stalled." }
572
+ for (const item of stalled) {
573
+ if (item.queue_id === null) continue
574
+ const client = kind === "series" ? sonarr : radarr
575
+ await client(`/queue/${item.queue_id}`, { method: "DELETE", query: { removeFromClient: true, blocklist: true, skipRedownload: true } })
576
+ }
577
+ const cmd = kind === "series" ? { name: "SeriesSearch", seriesId: svcId } : { name: "MoviesSearch", movieIds: [svcId] }
578
+ commanded = kind === "series" ? await sonarr("/command", { method: "POST", body: cmd }) : await radarr("/command", { method: "POST", body: cmd })
531
579
  } else {
532
580
  return { action, result: "unsupported_action", before, after: null, human_action_required: true,
533
- human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, report_only.` }
581
+ human_action_reason: `Action "${action}" is not implemented. Supported: rescan, enable_monitoring, force_import_scan, blocklist_and_research, report_only.` }
534
582
  }
535
583
 
536
584
  await new Promise((r) => setTimeout(r, 12_000))
@@ -553,6 +601,7 @@ function describeAction(action) {
553
601
  rescan: "Trigger a fresh indexer search and grab the best acceptable release.",
554
602
  enable_monitoring: "Mark it monitored, then search.",
555
603
  force_import_scan: "Re-scan the disk so an already-downloaded file gets imported.",
604
+ blocklist_and_research: "Blocklist the stalled release so it cannot be grabbed again, then search for a different one.",
556
605
  report_only: "Report only; change nothing.",
557
606
  }[action] ?? "Unknown action."
558
607
  }
@@ -625,7 +674,7 @@ const TOOLS = [
625
674
  },
626
675
  {
627
676
  name: "media_request_status",
628
- description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. Never infer the cause yourself — read diagnosis.summary.",
677
+ description: "One call, every stage: request, indexer search, download, file on disk, plus acquisition-chain health and a deterministic `diagnosis` block naming what is stuck, why, and the fix. Use this for any 'is it here yet?' or 'why hasn't X downloaded?' question. A queue row is not proof of progress: a release sitting at zero bytes comes back as stalled, not as downloading. Never infer the cause yourself — read diagnosis.summary.",
629
678
  inputSchema: { type: "object", properties: {
630
679
  request_id: { type: "string", description: "e.g. jellyseerr:42" },
631
680
  tmdb_id: { type: "number" },
@@ -634,10 +683,10 @@ const TOOLS = [
634
683
  },
635
684
  {
636
685
  name: "media_diagnose_and_fix",
637
- description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
686
+ description: "Act on a stuck request. Omit `action` to apply the fix that media_request_status already identified, including 'blocklist_and_research' for a release that has stalled at zero bytes. Refuses to act (result 'rejected_fix_unsafe') when the acquisition chain itself is down, because re-searching cannot help then. Use dry_run to preview.",
638
687
  inputSchema: { type: "object", properties: {
639
688
  request_id: { type: "string" }, tmdb_id: { type: "number" }, title: { type: "string" },
640
- action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "report_only"] },
689
+ action: { type: "string", enum: ["rescan", "enable_monitoring", "force_import_scan", "blocklist_and_research", "report_only"] },
641
690
  dry_run: { type: "boolean" },
642
691
  } },
643
692
  },
@@ -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.824</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.826</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -76,25 +76,54 @@ function preparedTexts(effect) {
76
76
  return [requireText(effect.text, "Telegram text")];
77
77
  return [effect.text?.trim() || null];
78
78
  }
79
+ // Bold spans may carry one level of emphasis inside them, because `**bold with
80
+ // *italic* inside**` is ordinary model prose. The inner span is rendered by the
81
+ // same conservative scan, so a bold run that does not resolve cleanly still
82
+ // degrades the whole message to literal text rather than emitting partial
83
+ // markup. Everything else stays as strict as before: a span must open on a
84
+ // letter or digit and close on a non-space, which is what keeps `**/src/**`,
85
+ // `2 ** 3` and `a_b_c` literal.
86
+ const TELEGRAM_BOLD_WITH_NESTING = /`([^`\n]+)`|\*\*([\p{L}\p{N}](?:[^*_`\n]|\*(?!\*)|_)*\S|[\p{L}\p{N}])\*\*|\*([\p{L}\p{N}](?:[^*_`\n]*\S)?)\*|_([\p{L}\p{N}](?:[^*_`\n]*\S)?)_/gu;
87
+ const TELEGRAM_EMPHASIS_ONLY = /`([^`\n]+)`|\*([\p{L}\p{N}](?:[^*_`\n]*\S)?)\*|_([\p{L}\p{N}](?:[^*_`\n]*\S)?)_/gu;
79
88
  function renderTelegramButlerHtml(text) {
89
+ return renderTelegramStyledSpan(text, true) ?? (0, telegram_client_1.escapeTelegramHtml)(text);
90
+ }
91
+ // Returns null when the span cannot be rendered cleanly, so every caller falls
92
+ // back to escaping rather than emitting half-applied markup.
93
+ function renderTelegramStyledSpan(text, allowBold) {
80
94
  let html = "";
81
95
  let cursor = 0;
82
- const styledText = /`([^`\n]+)`|\*\*([\p{L}\p{N}](?:[^*_`\n]*\S)?)\*\*|\*([\p{L}\p{N}](?:[^*_`\n]*\S)?)\*|_([\p{L}\p{N}](?:[^*_`\n]*\S)?)_/gu;
83
- for (const match of text.matchAll(styledText)) {
96
+ const pattern = allowBold ? TELEGRAM_BOLD_WITH_NESTING : TELEGRAM_EMPHASIS_ONLY;
97
+ pattern.lastIndex = 0;
98
+ for (const match of text.matchAll(pattern)) {
84
99
  const gap = text.slice(cursor, match.index);
85
100
  if (/[*_`]/u.test(gap))
86
- return (0, telegram_client_1.escapeTelegramHtml)(text);
101
+ return null;
87
102
  html += (0, telegram_client_1.escapeTelegramHtml)(gap);
88
- if (match[1] !== undefined)
89
- html += `<code>${(0, telegram_client_1.escapeTelegramHtml)(match[1])}</code>`;
90
- else if (match[2] !== undefined || match[3] !== undefined)
91
- html += `<b>${(0, telegram_client_1.escapeTelegramHtml)((match[2] ?? match[3]))}</b>`;
103
+ const code = match[1];
104
+ const bold = allowBold ? match[2] : undefined;
105
+ const italic = allowBold ? match[3] : match[2];
106
+ const underscored = allowBold ? match[4] : match[3];
107
+ if (code !== undefined)
108
+ html += `<code>${(0, telegram_client_1.escapeTelegramHtml)(code)}</code>`;
109
+ else if (bold !== undefined) {
110
+ const inner = renderTelegramStyledSpan(bold, false);
111
+ if (inner === null)
112
+ return null;
113
+ html += `<b>${inner}</b>`;
114
+ // A single-marker span is bold at the top level, which is the long-standing
115
+ // convention here, but inside a bold run it is the nested emphasis the
116
+ // author meant - and nesting <b> in <b> would be markup Telegram has no
117
+ // reason to accept.
118
+ }
119
+ else if (italic !== undefined)
120
+ html += allowBold ? `<b>${(0, telegram_client_1.escapeTelegramHtml)(italic)}</b>` : `<i>${(0, telegram_client_1.escapeTelegramHtml)(italic)}</i>`;
92
121
  else
93
- html += `<i>${(0, telegram_client_1.escapeTelegramHtml)(match[4])}</i>`;
122
+ html += `<i>${(0, telegram_client_1.escapeTelegramHtml)(underscored)}</i>`;
94
123
  cursor = match.index + match[0].length;
95
124
  }
96
125
  const tail = text.slice(cursor);
97
- return /[*_`]/u.test(tail) ? (0, telegram_client_1.escapeTelegramHtml)(text) : html + (0, telegram_client_1.escapeTelegramHtml)(tail);
126
+ return /[*_`]/u.test(tail) ? null : html + (0, telegram_client_1.escapeTelegramHtml)(tail);
98
127
  }
99
128
  function assertEffectTarget(target, effect, idempotencyKey) {
100
129
  if (target.kind === "admission_gate") {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.824",
3
+ "version": "0.1.0-alpha.826",
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.824",
9
+ "version": "0.1.0-alpha.826",
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.824",
3
+ "version": "0.1.0-alpha.826",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },