| 1 | """Fix-prescription registry: the single remediation vocabulary (KTD 7). |
| 2 | |
| 3 | Each (source, failure mode) entry carries a cause line, a natural-language |
| 4 | fix, an exact CLI fix, and an optional CONFIGURATION.md anchor. Two real |
| 5 | consumers keep the vocabulary honest from day one: |
| 6 | |
| 7 | - ``lib/quality_nudge.py`` builds its post-research fix text from these |
| 8 | entries (only the fix strings migrated here; trigger logic is untouched). |
| 9 | - The doctor aggregator (U4) looks entries up per failed source/backend. |
| 10 | |
| 11 | Because both surfaces read the same entry, the nudge a user sees after a |
| 12 | degraded run and the prescription doctor prints for the same failure can |
| 13 | never drift apart. |
| 14 | |
| 15 | Composition with the other health layers (reference, don't restate): |
| 16 | |
| 17 | - U1 (``lib/health.py``) owns the machine-aware package-manager strings |
| 18 | (brew/pipx/apt/npx install-vs-reinstall, off-PATH PATH edits). Binary-class |
| 19 | entries here pull their static defaults from U1's tables, and |
| 20 | ``for_dependency_probe`` lets a live probe's machine-specific prescription |
| 21 | win the CLI form while the registry supplies cause/NL/anchor vocabulary. |
| 22 | - U2 (``lib/backends.py``) embeds this registry's CLI forms inside its |
| 23 | chain-failure prescriptions, so a backend finding and a registry lookup |
| 24 | agree on the command to run. |
| 25 | |
| 26 | No secrets: CLI forms use obvious ``<placeholder>`` values only. |
| 27 | """ |
| 28 | |
| 29 | from __future__ import annotations |
| 30 | |
| 31 | from dataclasses import dataclass, replace |
| 32 | from typing import Dict, Optional, Tuple |
| 33 | |
| 34 | from . import env, health |
| 35 | from .x_api import BEARER_COVERAGE_NOTE |
| 36 | |
| 37 | # Direct engine invocation prefix (scripting fallback; the slash-command UX |
| 38 | # is "ask the agent to run setup ...", which is the natural-language form). |
| 39 | ENGINE_CLI = "python3 skills/last30days/scripts/last30days.py" |
| 40 | SETUP_BROWSER_COOKIES_CLI = f"{ENGINE_CLI} setup --allow-browser-cookies" |
| 41 | SETUP_GITHUB_CLI = f"{ENGINE_CLI} setup --github" |
| 42 | |
| 43 | # U1 owns these remediation strings; reference them instead of restating. |
| 44 | _YTDLP_BREW_INSTALL, _YTDLP_BREW_REINSTALL = health.static_prescription("yt-dlp", "brew") |
| 45 | _YTDLP_PIPX_REINSTALL = health.static_prescription("yt-dlp", "pipx")[1] |
| 46 | _DIGG_PP_INSTALL_CLI = health.pp_install_cmd("digg") |
| 47 | |
| 48 | GENERIC_FIX_NL = "see CONFIGURATION.md for setup options for this source" |
| 49 | |
| 50 | |
| 51 | @dataclass(frozen=True) |
| 52 | class Prescription: |
| 53 | """Remediation for one (source, failure mode). |
| 54 | |
| 55 | ``fix_nl`` is the natural-language form ("ask the agent to run setup |
| 56 | with browser-cookie consent"); ``fix_cli`` is the exact command. |
| 57 | ``alt_cli`` carries per-platform alternates (Windows/pip) when the |
| 58 | primary CLI form is macOS/brew. ``anchor`` is a CONFIGURATION.md |
| 59 | heading anchor ("" when the doc has no dedicated section). |
| 60 | """ |
| 61 | |
| 62 | source: str |
| 63 | failure: str |
| 64 | cause: str |
| 65 | fix_nl: str |
| 66 | fix_cli: str |
| 67 | alt_cli: Tuple[str, ...] = () |
| 68 | anchor: str = "" |
| 69 | |
| 70 | |
| 71 | def _entry(source: str, failure: str, **kwargs) -> Tuple[Tuple[str, str], Prescription]: |
| 72 | return (source, failure), Prescription(source=source, failure=failure, **kwargs) |
| 73 | |
| 74 | |
| 75 | REGISTRY: Dict[Tuple[str, str], Prescription] = dict(( |
| 76 | _entry( |
| 77 | "x", "cookies_missing", |
| 78 | cause="X browser cookies (AUTH_TOKEN/CT0) are not configured", |
| 79 | fix_nl=( |
| 80 | "log into x.com in your browser and re-run (cookies detected " |
| 81 | "automatically), or add XAI_API_KEY to your .env (get key at " |
| 82 | "api.x.ai), or add XQUIK_API_KEY to your .env (get key at xquik.com)" |
| 83 | ), |
| 84 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 85 | anchor="api-keys-env", |
| 86 | ), |
| 87 | _entry( |
| 88 | "x", "cookies_expired", |
| 89 | cause="X errored this run: cookies are configured but likely expired or revoked", |
| 90 | fix_nl="log into x.com in your browser, then re-run", |
| 91 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 92 | anchor="api-keys-env", |
| 93 | ), |
| 94 | _entry( |
| 95 | "x", "grok_cli_missing", |
| 96 | cause="the Grok CLI is not installed, so the keyless X path is unavailable", |
| 97 | fix_nl=( |
| 98 | "install the Grok CLI (curl -fsSL https://x.ai/cli/install.sh | bash) " |
| 99 | "and sign in with `grok login` to search X without any X credential" |
| 100 | ), |
| 101 | fix_cli="npm install -g @xai-official/grok", |
| 102 | anchor="api-keys-env", |
| 103 | ), |
| 104 | _entry( |
| 105 | "x", "grok_not_authenticated", |
| 106 | cause="the Grok CLI is installed but not signed in", |
| 107 | fix_nl="sign in to Grok once; no X account or API key is needed after that", |
| 108 | fix_cli="grok login", |
| 109 | anchor="api-keys-env", |
| 110 | ), |
| 111 | # Official X path (an official-only host per env.x_policy, or an explicit |
| 112 | # xapi pin). Copy is limited to the connector lane, X_BEARER_TOKEN, |
| 113 | # XAI_API_KEY, and X API credits; the bearer path is described as |
| 114 | # about a week, never as parity with the connector. Anchors point at |
| 115 | # the CONFIGURATION.md Grok Bot subsection (slug grok-bot). |
| 116 | _entry( |
| 117 | "x", "bearer_missing", |
| 118 | cause=( |
| 119 | "no official X path is configured (X connector, X_BEARER_TOKEN, " |
| 120 | "or XAI_API_KEY)" |
| 121 | ), |
| 122 | fix_nl=( |
| 123 | "add the X for Grok Bot plugin and connect X in Grok Bot settings (full 30-day coverage), or set " |
| 124 | f"X_BEARER_TOKEN from the X developer console ({BEARER_COVERAGE_NOTE}), " |
| 125 | "or set XAI_API_KEY from console.x.ai" |
| 126 | ), |
| 127 | fix_cli="X_BEARER_TOKEN=<your-x-api-bearer-token>", |
| 128 | anchor="grok-bot", |
| 129 | ), |
| 130 | _entry( |
| 131 | "x", "bearer_invalid", |
| 132 | cause="the X API rejected X_BEARER_TOKEN (401/403)", |
| 133 | fix_nl=( |
| 134 | "set a valid X_BEARER_TOKEN from the X developer console " |
| 135 | f"({BEARER_COVERAGE_NOTE}), or add the X for Grok Bot plugin and connect X in Grok Bot settings " |
| 136 | "(full 30-day coverage)" |
| 137 | ), |
| 138 | fix_cli="X_BEARER_TOKEN=<your-x-api-bearer-token>", |
| 139 | anchor="grok-bot", |
| 140 | ), |
| 141 | _entry( |
| 142 | "x", "payment_required", |
| 143 | cause="X API credits are exhausted (HTTP 402)", |
| 144 | fix_nl=( |
| 145 | "top up X API credits in the X developer console, or connect X in " |
| 146 | "Grok Bot settings (full 30-day coverage)" |
| 147 | ), |
| 148 | fix_cli="X_BEARER_TOKEN=<bearer-from-a-project-with-credits>", |
| 149 | anchor="grok-bot", |
| 150 | ), |
| 151 | _entry( |
| 152 | "x", "connector_missing", |
| 153 | cause=( |
| 154 | "the X connector lane was declared but no connector result was " |
| 155 | "passed to the engine" |
| 156 | ), |
| 157 | fix_nl=( |
| 158 | "add the X for Grok Bot plugin and connect X in Grok Bot settings (full 30-day coverage) and pass the " |
| 159 | "connector's posts with --x-posts, or set X_BEARER_TOKEN from the X " |
| 160 | f"developer console ({BEARER_COVERAGE_NOTE})" |
| 161 | ), |
| 162 | fix_cli=f'{ENGINE_CLI} "<topic>" --x-posts <path-to-x-posts.json>', |
| 163 | anchor="grok-bot", |
| 164 | ), |
| 165 | _entry( |
| 166 | "scrapecreators", "key_missing", |
| 167 | cause="SCRAPECREATORS_API_KEY is not set", |
| 168 | fix_nl=( |
| 169 | "ask the agent to run setup with the GitHub device flow " |
| 170 | "(free 10,000-call signup; the key is persisted automatically)" |
| 171 | ), |
| 172 | fix_cli=SETUP_GITHUB_CLI, |
| 173 | anchor="api-keys-env", |
| 174 | ), |
| 175 | _entry( |
| 176 | "bluesky", "app_password_missing", |
| 177 | cause="BSKY_HANDLE and/or BSKY_APP_PASSWORD are not set", |
| 178 | fix_nl=( |
| 179 | "generate an app password at bsky.app/settings/app-passwords and " |
| 180 | "add BSKY_HANDLE plus BSKY_APP_PASSWORD to ~/.config/last30days/.env" |
| 181 | ), |
| 182 | fix_cli="BSKY_HANDLE=<your-handle> BSKY_APP_PASSWORD=<xxxx-xxxx-xxxx-xxxx>", |
| 183 | anchor="bluesky-app-password-format-and-search-host", |
| 184 | ), |
| 185 | _entry( |
| 186 | "youtube", "transcription_key_missing", |
| 187 | cause=( |
| 188 | "no transcription provider key for the caption-free transcript " |
| 189 | "backstop (GROQ_API_KEY or OPENAI_API_KEY)" |
| 190 | ), |
| 191 | fix_nl=( |
| 192 | "add a free Groq key from console.groq.com to " |
| 193 | "~/.config/last30days/.env so caption-free videos still get " |
| 194 | "transcripts (OPENAI_API_KEY also works as the paid backstop)" |
| 195 | ), |
| 196 | fix_cli="GROQ_API_KEY=<your-groq-key>", |
| 197 | anchor="api-keys-env", |
| 198 | ), |
| 199 | _entry( |
| 200 | "digg", "pp_cli_missing", |
| 201 | cause="digg-pp-cli is not installed", |
| 202 | fix_nl=( |
| 203 | "install the Digg CLI through the Printing Press library, then " |
| 204 | "re-run setup so the source activates" |
| 205 | ), |
| 206 | fix_cli=_DIGG_PP_INSTALL_CLI, |
| 207 | anchor="first-run-onboarding", |
| 208 | ), |
| 209 | _entry( |
| 210 | "digg", "pp_cli_broken", |
| 211 | cause=( |
| 212 | "digg-pp-cli resolves on PATH but won't execute (broken or " |
| 213 | "hanging binary left behind by a bad install)" |
| 214 | ), |
| 215 | fix_nl=( |
| 216 | "reinstall the Digg CLI (re-run the Printing Press install) so " |
| 217 | "the binary actually executes; it is installed but not serving" |
| 218 | ), |
| 219 | fix_cli=_DIGG_PP_INSTALL_CLI, |
| 220 | anchor="first-run-onboarding", |
| 221 | ), |
| 222 | _entry( |
| 223 | "digg", "pp_cli_off_path", |
| 224 | cause=( |
| 225 | "digg-pp-cli is installed but its directory is not on the " |
| 226 | "agent-subprocess PATH" |
| 227 | ), |
| 228 | fix_nl=( |
| 229 | "add the install directory (default ~/.local/bin) to the PATH the " |
| 230 | "agent subprocess uses; the engine gate only activates the source " |
| 231 | "when the binary resolves on PATH" |
| 232 | ), |
| 233 | fix_cli='export PATH="$HOME/.local/bin:$PATH"', |
| 234 | anchor="first-run-onboarding", |
| 235 | ), |
| 236 | _entry( |
| 237 | "youtube", "ytdlp_missing", |
| 238 | cause="yt-dlp is not installed on the agent-subprocess PATH", |
| 239 | fix_nl="install yt-dlp to enable the free local YouTube lane", |
| 240 | fix_cli=_YTDLP_BREW_INSTALL, |
| 241 | alt_cli=("scoop install yt-dlp", "pip install -U yt-dlp"), |
| 242 | ), |
| 243 | _entry( |
| 244 | "youtube", "ytdlp_stale", |
| 245 | cause=( |
| 246 | "yt-dlp is installed but stale: YouTube's caption format changes " |
| 247 | "frequently and old binaries silently fail every transcript" |
| 248 | ), |
| 249 | fix_nl="update yt-dlp via your package manager", |
| 250 | fix_cli="brew upgrade yt-dlp", |
| 251 | alt_cli=("scoop update yt-dlp", "pip install -U yt-dlp"), |
| 252 | ), |
| 253 | _entry( |
| 254 | "youtube", "ytdlp_broken", |
| 255 | cause=( |
| 256 | "yt-dlp resolves on PATH but won't execute (the stale-shim class: " |
| 257 | "a wrapper left behind by an interpreter upgrade)" |
| 258 | ), |
| 259 | fix_nl=( |
| 260 | "reinstall yt-dlp so the binary actually executes; a plain " |
| 261 | "install reads as a no-op because the broken shim is still present" |
| 262 | ), |
| 263 | fix_cli=_YTDLP_BREW_REINSTALL, |
| 264 | alt_cli=(_YTDLP_PIPX_REINSTALL,), |
| 265 | ), |
| 266 | _entry( |
| 267 | "truthsocial", "token_missing", |
| 268 | cause="TRUTHSOCIAL_TOKEN is not set", |
| 269 | fix_nl=( |
| 270 | "log into truthsocial.com in your browser and let setup read the " |
| 271 | "session cookie, or copy the bearer token from your browser's dev " |
| 272 | "tools into ~/.config/last30days/.env" |
| 273 | ), |
| 274 | fix_cli=SETUP_BROWSER_COOKIES_CLI, |
| 275 | anchor="api-keys-env", |
| 276 | ), |
| 277 | _entry( |
| 278 | "xiaohongshu", "service_unreachable", |
| 279 | cause=( |
| 280 | "Xiaohongshu browser-session service is unreachable or not logged " |
| 281 | "in; last30days auto-probes http://localhost:18060 and " |
| 282 | "http://host.docker.internal:18060 unless XIAOHONGSHU_API_BASE is set" |
| 283 | ), |
| 284 | fix_nl=( |
| 285 | "start a local x-mcp browser plugin or xpzouying/xiaohongshu-mcp " |
| 286 | "service that can see your logged-in Xiaohongshu browser session; " |
| 287 | "set XIAOHONGSHU_API_BASE only when it runs on a custom host/port" |
| 288 | ), |
| 289 | fix_cli="XIAOHONGSHU_API_BASE=http://your-host:18060 # only for a custom host; leave unset to auto-probe localhost and host.docker.internal", |
| 290 | anchor="api-keys-env", |
| 291 | ), |
| 292 | )) |
| 293 | |
| 294 | |
| 295 | def lookup(source: str, failure: str) -> Optional[Prescription]: |
| 296 | """Return the registered entry for (source, failure), or None.""" |
| 297 | return REGISTRY.get((source, failure)) |
| 298 | |
| 299 | |
| 300 | # Failure names remapped on an official-only host (env.x_policy): every |
| 301 | # cookie or Grok CLI failure has an official-path counterpart, so no fix |
| 302 | # line there names cookies, the scraper, or the CLI. Official names |
| 303 | # pass through unchanged on every host. |
| 304 | _OFFICIAL_X_FAILURES: Dict[str, str] = { |
| 305 | "cookies_missing": "bearer_missing", |
| 306 | "cookies_expired": "bearer_invalid", |
| 307 | "grok_cli_missing": "bearer_missing", |
| 308 | "grok_not_authenticated": "bearer_missing", |
| 309 | } |
| 310 | |
| 311 | |
| 312 | def for_x(config: dict, failure: str) -> Prescription: |
| 313 | """Policy-aware X prescription. |
| 314 | |
| 315 | On an official-only host (``env.x_policy(config).hint_namespace == |
| 316 | "official"``) a default-namespace failure resolves to its official |
| 317 | counterpart; elsewhere today's entry is returned unchanged. Never |
| 318 | raises (falls through to ``get``'s generic fallback). |
| 319 | """ |
| 320 | if env.x_policy(config or {}).hint_namespace == "official": |
| 321 | failure = _OFFICIAL_X_FAILURES.get(failure, failure) |
| 322 | return get("x", failure) |
| 323 | |
| 324 | |
| 325 | def get(source: str, failure: str) -> Prescription: |
| 326 | """Return the registered entry, or the generic CONFIGURATION.md fallback. |
| 327 | |
| 328 | Never raises: an unregistered failure mode still yields an actionable |
| 329 | (if generic) prescription, so a report renderer cannot crash on a |
| 330 | failure class the registry has not learned yet. |
| 331 | """ |
| 332 | entry = lookup(source, failure) |
| 333 | if entry is not None: |
| 334 | return entry |
| 335 | return Prescription( |
| 336 | source=source, |
| 337 | failure=failure, |
| 338 | cause=f"{source}: {failure.replace('_', ' ')}", |
| 339 | fix_nl=GENERIC_FIX_NL, |
| 340 | fix_cli=f"{ENGINE_CLI} setup", |
| 341 | ) |
| 342 | |
| 343 | |
| 344 | # --------------------------------------------------------------------------- |
| 345 | # Composition with U1 dependency probes |
| 346 | # --------------------------------------------------------------------------- |
| 347 | |
| 348 | def _dependency_failure(probe: health.DependencyProbe) -> Optional[Tuple[str, str]]: |
| 349 | """Map a failed dependency probe onto a registered (source, failure).""" |
| 350 | if probe.name == "yt-dlp": |
| 351 | if probe.status == health.MISSING: |
| 352 | return ("youtube", "ytdlp_missing") |
| 353 | return ("youtube", "ytdlp_broken") # BROKEN and TIMEOUT: reinstall class |
| 354 | if probe.name == "digg-pp-cli": |
| 355 | # health reports off-PATH binaries as MISSING with ``off_path=True``; |
| 356 | # the distinction only picks cause/NL wording — the probe's own |
| 357 | # prescription wins the CLI form either way. |
| 358 | if probe.status == health.MISSING: |
| 359 | if probe.off_path: |
| 360 | return ("digg", "pp_cli_off_path") |
| 361 | return ("digg", "pp_cli_missing") |
| 362 | return ("digg", "pp_cli_broken") # BROKEN and TIMEOUT: reinstall class |
| 363 | return None |
| 364 | |
| 365 | |
| 366 | def for_dependency_probe(probe: health.DependencyProbe) -> Optional[Prescription]: |
| 367 | """Prescription for a failed U1 dependency probe (None when OK). |
| 368 | |
| 369 | U1's machine-aware prescription (the manager that owns the binary on |
| 370 | THIS machine, or a PATH edit for off-PATH installs) wins the CLI form; |
| 371 | the registry entry supplies the shared cause/NL/anchor vocabulary. |
| 372 | Unregistered dependencies wrap the probe so callers still get both |
| 373 | fix forms without this module restating U1's strings. |
| 374 | """ |
| 375 | if probe.ok: |
| 376 | return None |
| 377 | key = _dependency_failure(probe) |
| 378 | entry = REGISTRY.get(key) if key else None |
| 379 | if entry is None: |
| 380 | return Prescription( |
| 381 | source=probe.name, |
| 382 | failure=probe.status, |
| 383 | cause=probe.detail or f"{probe.name}: {probe.status}", |
| 384 | fix_nl=f"repair the {probe.name} install; {GENERIC_FIX_NL}", |
| 385 | fix_cli=probe.prescription or f"{ENGINE_CLI} setup", |
| 386 | ) |
| 387 | updates = {} |
| 388 | if probe.detail: |
| 389 | updates["cause"] = probe.detail |
| 390 | if probe.prescription and probe.prescription != entry.fix_cli: |
| 391 | updates["fix_cli"] = probe.prescription |
| 392 | return replace(entry, **updates) if updates else entry |
| 393 |