@ouro.bot/cli 0.1.0-alpha.851 → 0.1.0-alpha.853

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.853",
6
+ "changes": [
7
+ "The Butler's override grab sends the release's quality and languages, which Sonarr v4 requires (it returned HTTP 500 without them)"
8
+ ]
9
+ },
10
+ {
11
+ "version": "0.1.0-alpha.852",
12
+ "changes": [
13
+ "An orientation hold no longer blocks read-only tools from the agent's own MCP servers (readOnlyHint); the Butler's media lookups are marked read-only. The media tools treat a 200 with an empty body as success, so clearing a stuck download goes on to re-search"
14
+ ]
15
+ },
4
16
  {
5
17
  "version": "0.1.0-alpha.851",
6
18
  "changes": [
@@ -1,5 +1,5 @@
1
1
  {
2
- "runtimeVersion": "0.1.0-alpha.851",
2
+ "runtimeVersion": "0.1.0-alpha.853",
3
3
  "bundleSchemaVersion": 3,
4
4
  "lastUpdated": "2026-09-03T00:00:00.000Z"
5
5
  }
@@ -63,7 +63,9 @@ async function req(base, path, { key, header = "X-Api-Key", method = "GET", body
63
63
  throw new ServiceError(`${serviceOf(base)}_http_${res.status}`, `${serviceOf(base)} returned ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`)
64
64
  }
65
65
  if (res.status === 204) return null
66
- return res.json()
66
+ // Sonarr/Radarr answer some DELETEs with 200 and an empty body; that is success, not a parse failure.
67
+ const raw = await res.text()
68
+ return raw.trim() ? JSON.parse(raw) : null
67
69
  }
68
70
 
69
71
  function serviceOf(base) {
@@ -1010,7 +1012,10 @@ export async function mediaReleaseSearch(a) {
1010
1012
  raw = await sonarr("/release", { query: { seriesId: serviceId, seasonNumber: a.season_number }, timeoutMs: SEARCH_TIMEOUT_MS })
1011
1013
  } else return { result: "season_number_or_episode_ids_required" }
1012
1014
  const seen = new Set()
1013
- let rows = (Array.isArray(raw) ? raw : []).filter((r) => (seen.has(r.guid) ? false : seen.add(r.guid))).map((r) => releaseRow(a.kind, r))
1015
+ const unique = (Array.isArray(raw) ? raw : []).filter((r) => (seen.has(r.guid) ? false : seen.add(r.guid)))
1016
+ // Sonarr's override grab needs the release's own quality and languages objects back.
1017
+ const rawByGuid = new Map(unique.map((r) => [r.guid, r]))
1018
+ let rows = unique.map((r) => releaseRow(a.kind, r))
1014
1019
  const total = rows.length
1015
1020
  if (a.query) {
1016
1021
  const q = String(a.query).toLowerCase()
@@ -1018,7 +1023,11 @@ export async function mediaReleaseSearch(a) {
1018
1023
  }
1019
1024
  rows.sort((x, y) => Number(y.seeders ?? 0) - Number(x.seeders ?? 0))
1020
1025
  const limit = Math.min(Math.max(Number(a.limit) || 40, 1), 100)
1021
- for (const r of rows) cacheRelease(`${a.kind}:${r.guid}`, { ...r, service_id: serviceId, season_number: a.season_number ?? null })
1026
+ for (const r of rows) {
1027
+ const source = rawByGuid.get(r.guid)
1028
+ cacheRelease(`${a.kind}:${r.guid}`, { ...r, service_id: serviceId, season_number: a.season_number ?? null,
1029
+ quality_raw: source?.quality ?? null, languages_raw: source?.languages ?? null })
1030
+ }
1022
1031
  return { kind: a.kind, service_id: serviceId, total_found: total, matched: rows.length, releases: rows.slice(0, limit) }
1023
1032
  }
1024
1033
 
@@ -1047,7 +1056,10 @@ export async function mediaReleaseGrab(a) {
1047
1056
  const own = new Set((await seriesEpisodes(seriesId)).map((e) => e.id))
1048
1057
  const foreign = a.episode_ids.filter((id) => !own.has(id))
1049
1058
  if (foreign.length) return { result: "episode_id_not_for_series", foreign_episode_ids: foreign }
1050
- Object.assign(body, { seriesId, episodeIds: a.episode_ids.map(Number), shouldOverride: true })
1059
+ // Sonarr v4 refuses an override without quality and languages (ArgumentNullException, HTTP 500).
1060
+ if (!cached.quality_raw) return { result: "release_quality_unknown", message: "Sonarr's search did not report this release's quality; search again." }
1061
+ Object.assign(body, { seriesId, episodeIds: a.episode_ids.map(Number), shouldOverride: true,
1062
+ quality: cached.quality_raw, languages: Array.isArray(cached.languages_raw) ? cached.languages_raw : [] })
1051
1063
  if (a.download_client_id !== undefined && a.download_client_id !== null) body.downloadClientId = Number(a.download_client_id)
1052
1064
  }
1053
1065
  }
@@ -1135,9 +1147,10 @@ export async function mediaManualImport(a) {
1135
1147
 
1136
1148
  // ------------------------------------------------------------------- schema
1137
1149
 
1138
- const TOOLS = [
1150
+ export const TOOLS = [
1139
1151
  {
1140
1152
  name: "media_search",
1153
+ annotations: { readOnlyHint: true },
1141
1154
  description: "Search or browse the household media catalogue. Use `query` for a title lookup: partial and misspelled titles are matched against the library first (Sonarr, Radarr, Jellyfin) and returned as `library_matches` with their kind and service id, and when nothing in the library matches, `lookup_candidates` (TVDB/TMDB) cover titles not in the library. Never say a title does not exist without reading both; use `genres`/`keywords`/`year_range` to browse by vibe (e.g. keywords ['autumn','thanksgiving'] for cozy fall films). `on_shelf: true` restricts to what is already downloaded, `false` to what is not, omit for both. Every item says whether it is on the shelf and carries its tmdb_id so a follow-up media_request needs no second lookup.",
1142
1155
  inputSchema: { type: "object", properties: {
1143
1156
  query: { type: "string", description: "Title or free text. Omit to browse by filters." },
@@ -1163,6 +1176,7 @@ const TOOLS = [
1163
1176
  },
1164
1177
  {
1165
1178
  name: "media_request_status",
1179
+ annotations: { readOnlyHint: true },
1166
1180
  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.",
1167
1181
  inputSchema: { type: "object", properties: {
1168
1182
  request_id: { type: "string", description: "e.g. jellyseerr:42" },
@@ -1181,6 +1195,7 @@ const TOOLS = [
1181
1195
  },
1182
1196
  {
1183
1197
  name: "media_chain_health",
1198
+ annotations: { readOnlyHint: true },
1184
1199
  description: "Health of the whole acquisition chain: Prowlarr indexers, their failure state, and whether Prowlarr is syncing to Sonarr/Radarr. Call this when several things are stuck at once, or before telling anyone a specific title is the problem — a dead chain blocks everything and is the single most likely cause.",
1185
1200
  inputSchema: { type: "object", properties: {} },
1186
1201
  },
@@ -1213,6 +1228,7 @@ const TOOLS = [
1213
1228
  },
1214
1229
  {
1215
1230
  name: "media_episodes",
1231
+ annotations: { readOnlyHint: true },
1216
1232
  description: "List a Sonarr series' episodes (optionally one season): episode_id, SxxEyy code, scene_code (the numbering release names use, when Sonarr has a mapping), title, air date, has_file. Use it to see what the TVDB numbering calls each episode before searching for releases by hand.",
1217
1233
  inputSchema: { type: "object", properties: {
1218
1234
  service_id: { type: "number", description: "Sonarr series id." },
@@ -1221,6 +1237,7 @@ const TOOLS = [
1221
1237
  },
1222
1238
  {
1223
1239
  name: "media_indexer_search",
1240
+ annotations: { readOnlyHint: true },
1224
1241
  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.",
1225
1242
  inputSchema: { type: "object", properties: {
1226
1243
  query: { type: "string" },
@@ -1229,6 +1246,7 @@ const TOOLS = [
1229
1246
  },
1230
1247
  {
1231
1248
  name: "media_release_search",
1249
+ annotations: { readOnlyHint: true },
1232
1250
  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.",
1233
1251
  inputSchema: { type: "object", properties: {
1234
1252
  kind: { type: "string", enum: ["series", "movie"] },
@@ -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.851</Repository>
4
+ <Repository>ghcr.io/ourostack/ouroboros-butler:0.1.0-alpha.853</Repository>
5
5
  <Registry>https://github.com/ourostack/ouroboros/pkgs/container/ouroboros-butler</Registry>
6
6
  <Network>host</Network>
7
7
  <Shell>sh</Shell>
@@ -67,11 +67,15 @@ function mcpToolsAsDefinitions(view) {
67
67
  });
68
68
  return {
69
69
  tool: schema,
70
- riskProfile: {
71
- mutates: "external_side_effect",
72
- risk: "high",
73
- reason: "MCP tools may mutate external systems",
74
- },
70
+ // A read-only hint is trusted only from the agent's own bundle (agent.json mcpServers),
71
+ // so an orientation hold still lets the agent inspect through those tools.
72
+ riskProfile: entry.source === "builtin" && tool.annotations?.readOnlyHint === true
73
+ ? { mutates: "none", risk: "low", reason: "read-only tool from the agent's own MCP server" }
74
+ : {
75
+ mutates: "external_side_effect",
76
+ risk: "high",
77
+ reason: "MCP tools may mutate external systems",
78
+ },
75
79
  handler: async (args, ctx) => {
76
80
  if (ctx?.agentName !== binding.agentName || ctx.agentRoot !== binding.agentRoot) {
77
81
  (0, runtime_1.emitNervesEvent)({
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@ouro.bot/cli",
3
- "version": "0.1.0-alpha.851",
3
+ "version": "0.1.0-alpha.853",
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.851",
9
+ "version": "0.1.0-alpha.853",
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.851",
3
+ "version": "0.1.0-alpha.853",
4
4
  "engines": {
5
5
  "node": ">=22"
6
6
  },