返回 last30days-skill
doctor.py
根目录 / skills / last30days / scripts / lib / doctor.py
1 """Unified `doctor` health surface: aggregate, tier rollup, render (U4).
2
3 One command answers "what's broken, what's serving, and what do I run to
4 fix it" by composing the existing health layers instead of replacing them:
5
6 - U1 ``lib/health.py`` dependency probes (ok/missing/broken/timeout)
7 - U2 ``lib/backends.py`` chain descriptors + "will use" prediction
8 - U3 ``lib/prescriptions.py`` the single remediation vocabulary
9 - ``lib/pipeline.diagnose`` + ``lib/permission_preflight`` for the
10 engine-level availability and permission summary
11
12 The legacy ``--diagnose`` / ``--preflight`` flags keep their frozen JSON
13 shapes (see ``tests/test_diagnose_compat.py``); anything new appears ONLY
14 in ``doctor --json``.
15
16 Tier rollup (the R1 machine contract). Per source, ``tier`` is the
17 four-value rollup and ``status`` preserves the most specific state:
18
19 | condition | status | tier |
20 |------------------------------------------------------|---------------|-------|
21 | probes pass, credentials (if any) present | ok | ok |
22 | usable but degraded (fallback serving, partial) | degraded | warn |
23 | opt-in not enabled / key-gated unconfigured | opt-in / | off |
24 | | unconfigured | |
25 | configured but missing / broken / timeout / error | that status | error |
26
27 Semantics and guarantees:
28
29 - ``active_backend`` is a PREDICTION ("will use"), never an observation
30 (KTD 4). Reddit is conditional mode: honest wording, no single winner.
31 - On a native-search host with no web keys, engine-side web search is
32 intentionally off — doctor reports tier ``off`` with a host-native note,
33 never a false-alarm error. Web search has NO env pin, only the
34 ``--web-backend`` flag; the record says so.
35 - No cookie reads (plan-only, like ``--diagnose``); no secret values
36 anywhere — key presence is booleans only.
37 - Per-source exception isolation: one failing probe becomes that source's
38 ``error`` record; it can never blank the report.
39 - Reporting problems is a successful run: the exit code is always 0.
40 """
41
42 from __future__ import annotations
43
44 import concurrent.futures
45 import datetime
46 import hashlib
47 import json
48 import os
49 import shutil
50 import sys
51 import urllib.error
52 import urllib.request
53 from pathlib import Path
54 from typing import Any, Callable, Dict, List, Optional
55
56 from . import backends, env, health, http, prescriptions
57 from .backends import TIER_ERROR, TIER_OK, TIER_WARN
58
59 # Rollup tiers (R1). ok/warn/error are U2's; only "off" is doctor's own.
60 TIER_OFF = "off"
61
62 # Specific statuses and the rollup row each maps to. The doctor-local
63 # statuses "opt-in" and "unconfigured" have no health constant.
64 TIER_BY_STATUS: Dict[str, str] = {
65 health.OK: TIER_OK,
66 health.DEGRADED: TIER_WARN,
67 "opt-in": TIER_OFF,
68 "unconfigured": TIER_OFF,
69 health.MISSING: TIER_ERROR,
70 health.BROKEN: TIER_ERROR,
71 health.TIMEOUT: TIER_ERROR,
72 health.ERROR: TIER_ERROR,
73 }
74
75 # Tier -> glyph, still used by the cached-report shape validator to confirm a
76 # record carries a known tier. The user-facing render uses AUDIT_GLYPHS below.
77 GLYPHS = {TIER_OK: "✓", TIER_WARN: "!", TIER_OFF: "○", TIER_ERROR: "✗"}
78
79 # Four-state audit (R1). A presentation layer derived from the tier rollup +
80 # last-run evidence + optional live probe - it augments the per-source records,
81 # it does not replace them (the tier/status fields stay in the record and JSON).
82 AUDIT_WORKING = "working"
83 AUDIT_UNVERIFIED = "unverified"
84 AUDIT_NOT_WORKING = "not-working"
85 AUDIT_COULD_BE_ON = "could-be-on"
86
87 AUDIT_GLYPHS = {
88 AUDIT_WORKING: "●", # ●
89 AUDIT_UNVERIFIED: "◐", # ◐
90 AUDIT_NOT_WORKING: "✕", # ✕
91 AUDIT_COULD_BE_ON: "○", # ○
92 }
93
94 AUDIT_GROUPS = (
95 (AUDIT_WORKING, "WORKING"),
96 (AUDIT_UNVERIFIED, "TURNED ON - UNVERIFIED"),
97 (AUDIT_NOT_WORKING, "NOT WORKING"),
98 (AUDIT_COULD_BE_ON, "COULD BE ON"),
99 )
100
101 # Sources that need neither credentials nor a CLI: they always serve, so with
102 # no run evidence and no probe they are WORKING, not UNVERIFIED.
103 KEYLESS_ALWAYS_ON = frozenset(
104 {"reddit", "hackernews", "polymarket", "github", "library"}
105 )
106
107 # Fresh-run outcome states -> audit bucket for a tier-ok source. Anything not
108 # listed here (error / timeout / rate-limited / auth-failed / unreachable /
109 # schema-drift) means the source ran and failed -> NOT WORKING.
110 _RUN_WORKING_STATES = frozenset({health.OK, health.NO_RESULTS})
111 _RUN_UNVERIFIED_STATES = frozenset({health.PARTIAL, health.SKIPPED_UNCONFIGURED})
112
113
114 def audit_state(
115 name: str,
116 record: Dict[str, Any],
117 run_outcome: Optional[Dict[str, Any]] = None,
118 probe_result: Optional[Dict[str, Any]] = None,
119 ) -> str:
120 """Map a source's (tier, run evidence, probe) to one of four audit states.
121
122 Precedence: a failing/degraded tier is NOT WORKING regardless of history;
123 an off tier (opt-in / unconfigured) is COULD BE ON. For a tier-ok source,
124 fresh run evidence wins (ok/no-results -> WORKING; partial/skipped ->
125 UNVERIFIED; any error/timeout/rate-limit/etc -> NOT WORKING), then a live
126 probe, then the keyless-always-on fallback, else UNVERIFIED.
127 """
128 tier = record.get("tier")
129 if tier in (TIER_ERROR, TIER_WARN):
130 return AUDIT_NOT_WORKING
131 if tier == TIER_OFF:
132 return AUDIT_COULD_BE_ON
133 # tier ok
134 if run_outcome:
135 state = run_outcome.get("state")
136 if state in _RUN_WORKING_STATES:
137 return AUDIT_WORKING
138 if state in _RUN_UNVERIFIED_STATES:
139 return AUDIT_UNVERIFIED
140 return AUDIT_NOT_WORKING
141 if probe_result is not None:
142 return AUDIT_WORKING if probe_result.get("ok") else AUDIT_NOT_WORKING
143 if name in KEYLESS_ALWAYS_ON:
144 return AUDIT_WORKING
145 return AUDIT_UNVERIFIED
146
147 # Report order: chained sources first, then free, then key-gated/opt-in.
148 SOURCE_ORDER = (
149 "reddit",
150 "x",
151 "youtube",
152 "web",
153 "hackernews",
154 "polymarket",
155 "github",
156 "digg",
157 "techmeme",
158 "arxiv",
159 "trustpilot",
160 "tiktok",
161 "instagram",
162 "threads",
163 "bluesky",
164 "truthsocial",
165 "perplexity",
166 "linkedin",
167 "pinterest",
168 "xiaohongshu",
169 "jobs",
170 "library",
171 )
172
173 # Sources whose availability depends on a downloaded CLI binary. doctor probes
174 # each (installed AND functional, via health.probe_dependency) and surfaces a
175 # per-source marker plus a dedicated CLI-health block (R2). Everything not
176 # listed is keyless - it needs no CLI. gh is OPTIONAL for GitHub (the REST tier
177 # works without it), so its absence is a note, never a failure.
178 CLI_DEPENDENCIES = {
179 "youtube": "yt-dlp",
180 "digg": "digg-pp-cli",
181 "techmeme": "techmeme-pp-cli",
182 "arxiv": "arxiv-pp-cli",
183 "trustpilot": "trustpilot-pp-cli",
184 "github": "gh",
185 }
186 _OPTIONAL_CLI_SOURCES = frozenset({"github"})
187
188 # Key-presence booleans for the setup block. NEVER values.
189 KEY_PRESENCE_VARS = (
190 "SCRAPECREATORS_API_KEY",
191 "XAI_API_KEY",
192 "XQUIK_API_KEY",
193 "BRAVE_API_KEY",
194 "EXA_API_KEY",
195 "SERPER_API_KEY",
196 "PARALLEL_API_KEY",
197 "GROQ_API_KEY",
198 "OPENAI_API_KEY",
199 "GOOGLE_API_KEY",
200 "GEMINI_API_KEY",
201 "OPENROUTER_API_KEY",
202 "PERPLEXITY_API_KEY",
203 "GITHUB_TOKEN",
204 "TRUTHSOCIAL_TOKEN",
205 "BSKY_APP_PASSWORD",
206 )
207
208 # Failing statuses ranked most-specific-first for chained rollups: a broken
209 # shim outranks a timeout outranks a generic error when naming the source's
210 # status (all three roll up to tier error regardless).
211 _SPECIFIC_FAILURES = (health.BROKEN, health.TIMEOUT, health.ERROR)
212
213
214 def _fix_text(entry: prescriptions.Prescription) -> str:
215 """Render a registry entry as one actionable fix line (NL + CLI forms)."""
216 if entry.fix_cli and entry.fix_cli not in entry.fix_nl:
217 return f"{entry.fix_nl} (cli: {entry.fix_cli})"
218 return entry.fix_nl
219
220
221 def _record(
222 *,
223 status: str,
224 mode: str = "single",
225 backends_list: Optional[List[Dict[str, Any]]] = None,
226 active_backend: Optional[str] = None,
227 fix: str = "",
228 requires: str = "",
229 note: str = "",
230 detail: str = "",
231 pin_var: Optional[str] = None,
232 pin_flag: Optional[str] = None,
233 pinned: bool = False,
234 ) -> Dict[str, Any]:
235 return {
236 "tier": TIER_BY_STATUS[status],
237 "status": status,
238 "mode": mode,
239 "backends": backends_list,
240 "active_backend": active_backend,
241 "fix": fix,
242 "requires": requires,
243 "note": note,
244 "detail": detail,
245 "pin_var": pin_var,
246 "pin_flag": pin_flag,
247 "pinned": pinned,
248 }
249
250
251 # ---------------------------------------------------------------------------
252 # Chained sources (via U2 descriptors)
253 # ---------------------------------------------------------------------------
254
255 def _finding_json(finding: backends.BackendFinding) -> Dict[str, Any]:
256 return {
257 "name": finding.name,
258 "status": finding.status,
259 "detail": finding.detail,
260 "requires": finding.requires,
261 "fix": finding.prescription,
262 }
263
264
265 def _host_native_web_note(config: Dict[str, Any]) -> str:
266 """Doctor-local note when the host's own web search serves this run.
267
268 Keys on LAST30DAYS_NATIVE_SEARCH (via env.is_native_search) AND on
269 CLAUDECODE as a host signal - Claude Code always exposes a web-search tool,
270 but `doctor` run in a plain shell never sees the LAST30DAYS_NATIVE_SEARCH
271 the engine exports only for its own run, so without the CLAUDECODE signal it
272 would mislabel a fine setup as "degraded/keyless". Messaging only: it does
273 not change env.is_native_search or the engine's keyless-floor behavior. The
274 note names the signal actually detected so it never cites an env var the
275 user did not set.
276 """
277 if env.is_native_search(config):
278 return (
279 "host-native search active (LAST30DAYS_NATIVE_SEARCH): the host's "
280 "own web search serves this run; set a web key only if you want "
281 "engine-side web search"
282 )
283 if config.get("CLAUDECODE") or os.environ.get("CLAUDECODE"):
284 return (
285 "host-native web search active (Claude Code): the host's own web "
286 "search serves this run; set a web key only if you want "
287 "engine-side web search"
288 )
289 return ""
290
291
292 def _chained_record(source: str, config: Dict[str, Any]) -> Dict[str, Any]:
293 descriptor = backends.get_descriptor(source)
294 res = backends.resolve(source, config)
295 findings_json = [_finding_json(f) for f in res.findings]
296 common = dict(
297 mode=res.mode,
298 backends_list=findings_json,
299 active_backend=res.active_backend,
300 pin_var=descriptor.pin_var,
301 pin_flag=descriptor.pin_flag,
302 pinned=res.pinned,
303 )
304
305 if res.mode == backends.MODE_CONDITIONAL:
306 # Reddit: honest conditional wording (U2, verbatim), never a winner.
307 return _record(status=health.OK, note=res.conditional,
308 requires=res.findings[0].requires if res.findings else "",
309 **common)
310
311 by_name = {f.name: f for f in res.findings}
312 if res.tier == backends.TIER_OK:
313 active = by_name.get(res.active_backend)
314 return _record(status=health.OK, note=res.summary,
315 requires=active.requires if active else "", **common)
316
317 # Doctor-local (KTD-3): on a host that brings its own web search, the
318 # engine's web lanes (keyless floor or nothing configured) are intentionally
319 # dormant - report that, not an alarming "degraded/keyless". This must run
320 # before the WARN branch, because the keyless floor resolves to WARN and
321 # would otherwise return first. Messaging only; it never touches
322 # env.is_native_search or the engine's keyless-floor runtime behavior.
323 if source == "web":
324 host_note = _host_native_web_note(config)
325 if host_note:
326 return _record(
327 status="unconfigured",
328 note=host_note,
329 requires=res.findings[0].requires if res.findings else "",
330 **common,
331 )
332
333 if res.tier == backends.TIER_WARN:
334 active = by_name.get(res.active_backend)
335 return _record(status=health.DEGRADED, note=res.summary,
336 detail=active.detail if active else "",
337 fix=res.prescription,
338 requires=active.requires if active else "", **common)
339
340 # res.tier == error: separate "nothing configured" (tier off) from
341 # "configured but broken" (tier error).
342 if res.findings and all(f.status == health.MISSING for f in res.findings):
343 return _record(
344 status="unconfigured",
345 fix=res.prescription,
346 requires=res.findings[0].requires if res.findings else "",
347 note=f"no backend configured (chain: {' -> '.join(res.chain)})",
348 **common,
349 )
350 # Something IS configured/installed but won't serve: name the most
351 # specific failure in chain order.
352 status = health.ERROR
353 fix = res.prescription
354 detail = ""
355 failed: Optional[backends.BackendFinding] = None
356 for wanted in _SPECIFIC_FAILURES:
357 failed = next((f for f in res.findings if f.status == wanted), None)
358 if failed is not None:
359 status = wanted
360 fix = failed.prescription or res.prescription
361 detail = failed.detail
362 break
363 # Mirror the OK/WARN branches: the requirement named is the FAILED
364 # backend's, not chain[0]'s (which may be a different, merely-missing
365 # backend when the failure came from later in the chain).
366 return _record(status=status, fix=fix, detail=detail,
367 requires=failed.requires if failed
368 else (res.findings[0].requires if res.findings else ""),
369 **common)
370
371
372 # ---------------------------------------------------------------------------
373 # Single-backend sources
374 # ---------------------------------------------------------------------------
375
376 def _sc_fix() -> str:
377 return _fix_text(prescriptions.get("scrapecreators", "key_missing"))
378
379
380 def _sc_gated_record(config: Dict[str, Any], purpose: str) -> Dict[str, Any]:
381 if config.get("SCRAPECREATORS_API_KEY"):
382 return _record(status=health.OK, requires="SCRAPECREATORS_API_KEY",
383 detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
384 return _record(status="unconfigured", requires="SCRAPECREATORS_API_KEY",
385 fix=_sc_fix())
386
387
388 def _sc_optin_record(config: Dict[str, Any], source: str, purpose: str) -> Dict[str, Any]:
389 """SC-gated source that ALSO requires an INCLUDE_SOURCES opt-in to run.
390
391 Unlike ``_sc_gated_record`` (used by the on-by-default TikTok/Instagram),
392 a key alone is not enough here: the pipeline only fires this source when it
393 is in INCLUDE_SOURCES. Reporting a bare key as Ready is the Threads
394 false-Ready bug - this mirrors ``_linkedin_record``'s correct gating so
395 doctor and the pipeline cannot disagree.
396 """
397 requires = f"SCRAPECREATORS_API_KEY + INCLUDE_SOURCES={source}"
398 if not config.get("SCRAPECREATORS_API_KEY"):
399 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
400 if source in env.include_sources(config):
401 return _record(status=health.OK, requires=requires,
402 detail=f"SCRAPECREATORS_API_KEY present ({purpose})")
403 return _record(
404 status="opt-in", requires=requires,
405 fix=f"add {source} to INCLUDE_SOURCES (or request it via --search {source})",
406 note="key present; opt-in, never auto-activates",
407 )
408
409
410 def _reddit_record(config):
411 return _chained_record("reddit", config)
412
413
414 def _x_record(config):
415 record = _chained_record("x", config)
416 # Diagnose/doctor load config in plan_only mode, so browser cookies are not
417 # extracted and every X backend reads as statically missing -> unconfigured.
418 # But if bird is installed and FROM_BROWSER will authenticate X at run time,
419 # a normal run serves X fine (this is how the reporting user pulled 29 posts
420 # while doctor said "Off"). Reuse the existing shared predicate so doctor and
421 # diagnose cannot drift. It reads no cookie *values*, so it confirms a run
422 # will *attempt* browser auth, not that the session is currently valid -
423 # keep the note honest and point at the verified key-backed path.
424 if record["status"] == "unconfigured" and env.x_pending_browser_auth(
425 config, local_only=True
426 ):
427 record["status"] = health.OK
428 record["tier"] = TIER_BY_STATUS[health.OK]
429 record["note"] = (
430 "will use: bird (browser cookies; session not verified until a run "
431 "- add XAI_API_KEY for a verified, cookie-free path)"
432 )
433 record["fix"] = ""
434 return record
435
436
437 def _youtube_record(config):
438 record = _chained_record("youtube", config)
439 if record["status"] != health.OK:
440 return record
441 notes: List[str] = []
442 # yt-dlp already provides search + transcripts. A transcription key only
443 # backfills captions for the occasional caption-free video - an enhancement,
444 # not a sign YouTube is broken.
445 if not env.transcription_providers(config):
446 entry = prescriptions.get("youtube", "transcription_key_missing")
447 notes.append(
448 "search + transcripts work; a transcription key only adds "
449 "captions for caption-free videos"
450 )
451 record["fix"] = _fix_text(entry)
452 # Comment *text* is free via yt-dlp, so this caveat only fires when yt-dlp
453 # is absent and the legacy ScrapeCreators path is the only one left. Never
454 # prescribe a paid key for something the installed toolchain already does.
455 if not env.is_youtube_comments_available(config):
456 notes.append(
457 "comment text needs yt-dlp (free) or a ScrapeCreators key "
458 "+ youtube_comments opt-in"
459 )
460 # Actionable fix, matching the transcription branch. The transcription
461 # fix takes precedence when both caveats fire (one fix line per record).
462 if not record["fix"]:
463 if not config.get("SCRAPECREATORS_API_KEY"):
464 record["fix"] = _sc_fix()
465 else:
466 record["fix"] = (
467 "add youtube_comments to INCLUDE_SOURCES in "
468 "~/.config/last30days/.env to enable YouTube comment text"
469 )
470 if notes:
471 joined = "; ".join(notes)
472 record["note"] = (record["note"] + "; " + joined) if record["note"] else joined
473 return record
474
475
476 def _web_record(config):
477 return _chained_record("web", config)
478
479
480 def _hackernews_record(config):
481 return _record(status=health.OK, requires="none (free Algolia API)")
482
483
484 def _polymarket_record(config):
485 return _record(status=health.OK, requires="none (public API)")
486
487
488 def _github_record(config):
489 authed = bool(config.get("GITHUB_TOKEN") or env.read_secret_env("GITHUB_TOKEN") or shutil.which("gh"))
490 detail = (
491 "authenticated tier (GITHUB_TOKEN or gh CLI)"
492 if authed
493 else "unauthenticated REST tier (lower rate limits; GITHUB_TOKEN or gh raises them)"
494 )
495 return _record(status=health.OK, detail=detail,
496 requires="none (GITHUB_TOKEN or gh CLI optional)")
497
498
499 def _digg_record(config):
500 probe = health.probe_dependency("digg-pp-cli")
501 requires = "digg-pp-cli on the agent-subprocess PATH"
502 if probe.ok:
503 return _record(status=health.OK, detail=probe.detail, requires=requires)
504 entry = prescriptions.for_dependency_probe(probe)
505 fix = _fix_text(entry) if entry else probe.prescription
506 if probe.status == health.MISSING and not probe.off_path:
507 # Never installed: an optional source that simply isn't enabled.
508 return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
509 # Installed but off-PATH, broken, or timing out: configured-but-broken.
510 return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
511
512
513 def _cli_gated_record(config, cli_name: str, purpose: str):
514 """A source gated purely on a keyless downloaded CLI (mirrors _digg_record).
515
516 ok -> installed and functional; opt-in -> never installed (an optional
517 source simply not enabled); its failing status -> installed off-PATH,
518 broken, or timing out (configured-but-broken).
519 """
520 probe = health.probe_dependency(cli_name)
521 requires = f"{cli_name} on the agent-subprocess PATH"
522 if probe.ok:
523 return _record(status=health.OK, detail=probe.detail, requires=requires)
524 entry = prescriptions.for_dependency_probe(probe)
525 fix = _fix_text(entry) if entry else probe.prescription
526 if probe.status == health.MISSING and not probe.off_path:
527 return _record(status="opt-in", fix=fix, detail=probe.detail, requires=requires)
528 return _record(status=probe.status, fix=fix, detail=probe.detail, requires=requires)
529
530
531 def _techmeme_record(config):
532 return _cli_gated_record(config, "techmeme-pp-cli", "techmeme")
533
534
535 def _arxiv_record(config):
536 return _cli_gated_record(config, "arxiv-pp-cli", "arxiv")
537
538
539 def _trustpilot_record(config):
540 return _cli_gated_record(config, "trustpilot-pp-cli", "trustpilot")
541
542
543 def _tiktok_record(config):
544 return _sc_gated_record(config, "tiktok")
545
546
547 def _instagram_record(config):
548 return _sc_gated_record(config, "instagram")
549
550
551 def _threads_record(config):
552 # Threads needs the key AND an INCLUDE_SOURCES=threads opt-in to run, so it
553 # is opt-in-gated (not on-by-default like TikTok/Instagram).
554 return _sc_optin_record(config, "threads", "threads")
555
556
557 def _bluesky_record(config):
558 if env.is_bluesky_available(config):
559 return _record(status=health.OK, requires="BSKY_HANDLE + BSKY_APP_PASSWORD")
560 return _record(
561 status="unconfigured",
562 requires="BSKY_HANDLE + BSKY_APP_PASSWORD",
563 fix=_fix_text(prescriptions.get("bluesky", "app_password_missing")),
564 )
565
566
567 def _truthsocial_record(config):
568 if env.is_truthsocial_available(config):
569 return _record(status=health.OK, requires="TRUTHSOCIAL_TOKEN")
570 return _record(
571 status="unconfigured",
572 requires="TRUTHSOCIAL_TOKEN",
573 fix=_fix_text(prescriptions.get("truthsocial", "token_missing")),
574 )
575
576
577 def _perplexity_record(config):
578 requires = "PERPLEXITY_API_KEY or OPENROUTER_API_KEY + INCLUDE_SOURCES=perplexity"
579 has_key = bool(config.get("PERPLEXITY_API_KEY") or config.get("OPENROUTER_API_KEY"))
580 include = env.include_sources(config)
581 if not has_key:
582 return _record(
583 status="unconfigured", requires=requires,
584 fix=(
585 "set PERPLEXITY_API_KEY or OPENROUTER_API_KEY in "
586 "~/.config/last30days/.env, then add perplexity to INCLUDE_SOURCES"
587 ),
588 )
589 if "perplexity" in include:
590 return _record(status=health.OK, requires=requires)
591 return _record(
592 status="opt-in", requires=requires,
593 fix="add perplexity to INCLUDE_SOURCES (or request it via --search perplexity)",
594 note="key present; source runs only when opted in",
595 )
596
597
598 def _linkedin_record(config):
599 requires = "SCRAPECREATORS_API_KEY + INCLUDE_SOURCES=linkedin"
600 if not config.get("SCRAPECREATORS_API_KEY"):
601 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
602 if "linkedin" in env.include_sources(config):
603 return _record(status=health.OK, requires=requires)
604 return _record(
605 status="opt-in", requires=requires,
606 fix="add linkedin to INCLUDE_SOURCES (or request it via --search linkedin)",
607 note="key present; power-user opt-in, never auto-activates",
608 )
609
610
611 def _pinterest_record(config):
612 requires = "SCRAPECREATORS_API_KEY; requested-only (--search pinterest)"
613 if not config.get("SCRAPECREATORS_API_KEY"):
614 return _record(status="unconfigured", requires=requires, fix=_sc_fix())
615 return _record(
616 status="opt-in", requires=requires,
617 fix="request it explicitly via --search pinterest (or INCLUDE_SOURCES)",
618 note="key present; runs only when requested",
619 )
620
621
622 def _xiaohongshu_record(config):
623 requires = (
624 "logged-in Xiaohongshu browser-session service; requested-only "
625 "(--search xhs)"
626 )
627 entry = prescriptions.get("xiaohongshu", "service_unreachable")
628 if config.get("XIAOHONGSHU_API_BASE"):
629 return _record(
630 status=health.OK, requires=requires,
631 note=(
632 "XIAOHONGSHU_API_BASE configured; service reachability is not "
633 "probed (doctor makes no network calls)"
634 ),
635 )
636 return _record(
637 status="opt-in",
638 requires=requires,
639 fix=_fix_text(entry),
640 note=(
641 "auto-probes http://localhost:18060 first, then "
642 "http://host.docker.internal:18060"
643 ),
644 )
645
646
647 def _jobs_record(config):
648 return _record(
649 status="opt-in",
650 requires="none; activates for company topics or --hiring-signals",
651 note="on-demand source: no configuration needed",
652 )
653
654
655 def _count_saved_briefs(memory_dir) -> int:
656 """Cheap count of saved research briefs (directory listing, no file parse).
657
658 Globs the ``*-raw*.md`` artifacts the engine writes, deliberately avoiding
659 library.scan_library's read_text+parse of every file - a count does not
660 need the parsed content, and the full scan adds real latency to every
661 `doctor` run on a large library.
662 """
663 path = Path(memory_dir).expanduser()
664 return sum(1 for _ in path.glob("*-raw*.md"))
665
666
667 def _library_record(config):
668 """Local research library that feeds the report's 'From your library' block.
669
670 This is not a network source - it reports how many saved briefs are indexed
671 so the 'From your library' block's presence is explained on the health
672 surface. Read-only and never fails the run: an empty store, a missing store,
673 or a SQLite build without FTS5 all resolve to an informational OK line.
674 """
675 from . import library, library_index
676
677 if not library_index.fts5_available():
678 return _record(
679 status=health.OK,
680 requires="none (local SQLite)",
681 note=(
682 "search index unavailable (this SQLite build lacks FTS5); "
683 "saved briefs still render, `library search` is disabled"
684 ),
685 )
686 try:
687 count = _count_saved_briefs(
688 config.get("LAST30DAYS_MEMORY_DIR") or library.DEFAULT_MEMORY_DIR
689 )
690 except Exception:
691 return _record(
692 status=health.OK,
693 requires="none (local SQLite)",
694 note="local research library (powers the 'From your library' block)",
695 )
696 if count == 0:
697 note = "no saved briefs yet - runs you save build this over time"
698 else:
699 plural = "brief" if count == 1 else "briefs"
700 note = (
701 f"{count} saved {plural}; powers the 'From your library' block "
702 "(LAST30DAYS_LIBRARY_CONTEXT=off to hide)"
703 )
704 return _record(status=health.OK, requires="none (local SQLite)", note=note)
705
706
707 _SOURCE_BUILDERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
708 "reddit": _reddit_record,
709 "x": _x_record,
710 "youtube": _youtube_record,
711 "web": _web_record,
712 "hackernews": _hackernews_record,
713 "polymarket": _polymarket_record,
714 "github": _github_record,
715 "digg": _digg_record,
716 "techmeme": _techmeme_record,
717 "arxiv": _arxiv_record,
718 "trustpilot": _trustpilot_record,
719 "tiktok": _tiktok_record,
720 "instagram": _instagram_record,
721 "threads": _threads_record,
722 "bluesky": _bluesky_record,
723 "truthsocial": _truthsocial_record,
724 "perplexity": _perplexity_record,
725 "linkedin": _linkedin_record,
726 "pinterest": _pinterest_record,
727 "xiaohongshu": _xiaohongshu_record,
728 "jobs": _jobs_record,
729 "library": _library_record,
730 }
731
732
733 # ---------------------------------------------------------------------------
734 # Run-evidence overlay (U1): read the engine's last-report.json
735 #
736 # doctor predicts config health; a research run records what ACTUALLY happened
737 # per source in Report.source_status. Reading the last run lets doctor tell
738 # "configured" from "working" (the four-state audit) and powers --postmortem.
739 # This is a read-only reuse of the engine's existing report cache - no new
740 # writer. The schema stamp + filename mirror REPORT_CACHE_VERSION /
741 # _last_report_cache_path() in last30days.py (the same mirror pattern the
742 # doctor-cache block below already uses for its own schema stamp).
743 # ---------------------------------------------------------------------------
744
745 REPORT_CACHE_SCHEMA_VERSION = "last30days-report-cache/v1"
746 REPORT_CACHE_FILENAME = "last-report.json"
747 DEFAULT_REPORT_CACHE_TTL_SECONDS = 3600
748
749
750 def _last_report_path() -> Optional[Path]:
751 """The engine's last-report.json, beside the doctor cache (None in clean mode)."""
752 if env.CONFIG_DIR is None:
753 return None
754 return env.CONFIG_DIR / REPORT_CACHE_FILENAME
755
756
757 def load_run_evidence(
758 config: Dict[str, Any], ttl_seconds: int = DEFAULT_REPORT_CACHE_TTL_SECONDS
759 ) -> Dict[str, Any]:
760 """Return the last research run's per-source outcomes, read-only.
761
762 Shape: ``{"outcomes": {source: {state, items_returned, detail, fix_hint,
763 at}}, "topic": str|None, "at": str|None, "fresh": bool, "present": bool}``.
764
765 Any failure mode - absent file, unreadable, invalid JSON, schema mismatch,
766 wrong shape - yields the empty, not-present result and never raises
767 (doctor's exit-0 contract is absolute). ``fresh`` reflects the report TTL:
768 ``--postmortem`` reads regardless of freshness (labeling the age), while the
769 plain-``doctor`` overlay consumes only fresh evidence so a week-old run
770 cannot mislabel a source as WORKING today.
771 """
772 empty = {"outcomes": {}, "topic": None, "at": None, "fresh": False, "present": False}
773 path = _last_report_path()
774 if path is None or not path.exists():
775 return empty
776 try:
777 payload = json.loads(path.read_text(encoding="utf-8"))
778 except Exception:
779 return empty
780 if not isinstance(payload, dict):
781 return empty
782 if payload.get("schema") != REPORT_CACHE_SCHEMA_VERSION:
783 return empty
784 reports = payload.get("reports") or []
785 if not reports or not isinstance(reports[0], dict):
786 return empty
787 report = reports[0].get("report")
788 if not isinstance(report, dict):
789 return empty
790 raw_status = report.get("source_status") or {}
791 outcomes: Dict[str, Any] = {}
792 if isinstance(raw_status, dict):
793 for source, outcome in raw_status.items():
794 if not isinstance(outcome, dict):
795 continue
796 state = outcome.get("state")
797 if not isinstance(state, str):
798 continue
799 outcomes[source] = {
800 "state": state,
801 "items_returned": int(outcome.get("items_returned") or 0),
802 "detail": outcome.get("detail"),
803 "fix_hint": outcome.get("fix_hint"),
804 "at": outcome.get("at"),
805 }
806 timestamp = payload.get("timestamp")
807 return {
808 "outcomes": outcomes,
809 "topic": payload.get("topic"),
810 "at": timestamp or report.get("generated_at"),
811 "fresh": bool(env.is_timestamp_fresh(timestamp, ttl_seconds)),
812 "present": True,
813 }
814
815
816 # ---------------------------------------------------------------------------
817 # Backup + comment sub-lanes (U7 / R8, R9)
818 #
819 # Backups (Reddit's SC backfill, YouTube's SC transcript/search backstop, X's
820 # cookie-vs-key dual path) and comment lanes (youtube/tiktok/instagram) are not
821 # independent sources - they are capabilities of their parent. doctor surfaces
822 # them as indented sub-lines so "is a backup armed when yt-dlp is rate-limited?"
823 # is answerable at a glance without inventing fake sources.
824 # ---------------------------------------------------------------------------
825
826 def _sub_lanes_for(source: str, config: Dict[str, Any]):
827 """Return (backups, comments) metadata for a source, or ([], None)."""
828 backups: List[Dict[str, Any]] = []
829 comments: Optional[Dict[str, Any]] = None
830 has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
831 if source == "reddit":
832 backups.append({
833 "name": "ScrapeCreators backfill", "armed": has_sc,
834 "note": "fills in when the free public path returns nothing",
835 })
836 elif source == "youtube":
837 backups.append({
838 "name": "ScrapeCreators transcript/search backstop", "armed": has_sc,
839 "note": "used when yt-dlp is rate-limited or bot-gated",
840 })
841 comments = {"enabled": bool(env.is_youtube_comments_available(config))}
842 elif source == "x":
843 has_key = bool(config.get("XAI_API_KEY") or config.get("XQUIK_API_KEY"))
844 cookie = bool(env.x_pending_browser_auth(config, local_only=True))
845 if has_key:
846 note = "XAI_API_KEY key-backed path (verified, cookie-free)"
847 elif cookie:
848 note = (
849 "browser-cookie path primary; add XAI_API_KEY for a verified "
850 "cookie-free backup"
851 )
852 else:
853 note = "no auth path armed"
854 backups.append({"name": "X auth path", "armed": has_key or cookie, "note": note})
855 elif source == "tiktok":
856 comments = {"enabled": bool(env.is_tiktok_comments_available(config))}
857 elif source == "instagram":
858 comments = {"enabled": bool(env.is_instagram_comments_available(config))}
859 return backups, comments
860
861
862 # ---------------------------------------------------------------------------
863 # Aggregation
864 # ---------------------------------------------------------------------------
865
866 def _engine_version() -> str:
867 try:
868 from . import render
869
870 version = render._skill_version()
871 except Exception:
872 version = None
873 # render's parent-walking helper falls back to "?"; doctor says "unknown".
874 if not version or version == "?":
875 return "unknown"
876 return version
877
878
879 def _setup_block(config: Dict[str, Any]) -> Dict[str, Any]:
880 keys_present = {var: bool(config.get(var)) for var in KEY_PRESENCE_VARS}
881 keys_present["x_browser_cookies"] = bool(
882 config.get("AUTH_TOKEN") and config.get("CT0")
883 )
884 keys_present["bluesky_app_password"] = bool(
885 config.get("BSKY_HANDLE") and config.get("BSKY_APP_PASSWORD")
886 )
887 return {
888 "setup_complete": env.is_setup_complete(config),
889 "keys_present": keys_present,
890 }
891
892
893 def _permissions_block(config: Dict[str, Any]) -> Dict[str, Any]:
894 """Secret-free permission summary via the existing preflight provider."""
895 from . import pipeline
896
897 diag = pipeline.diagnose(config, None, safe=True)
898 return diag["permission_preflight"]
899
900
901 def build_report(config: Dict[str, Any]) -> Dict[str, Any]:
902 """Aggregate every health provider into one report dict.
903
904 Per-source exceptions are isolated: a failing builder yields an
905 ``error`` record for that source and the rest of the report survives.
906 """
907 def _build_one(name: str) -> Dict[str, Any]:
908 try:
909 return _SOURCE_BUILDERS[name](config)
910 except Exception as exc: # one bad probe must not blank the report
911 return _record(
912 status=health.ERROR,
913 detail=f"probe failed: {type(exc).__name__}: {exc}",
914 fix=_fix_text(prescriptions.get(name, "probe_error")),
915 )
916
917 # Builders are independent probes (subprocess/filesystem bound), so run
918 # them concurrently. ``pool.map`` preserves SOURCE_ORDER, keeping the
919 # sources dict insertion order — and render grouping — deterministic.
920 with concurrent.futures.ThreadPoolExecutor(
921 max_workers=min(8, len(SOURCE_ORDER))
922 ) as pool:
923 sources: Dict[str, Dict[str, Any]] = dict(
924 zip(SOURCE_ORDER, pool.map(_build_one, SOURCE_ORDER))
925 )
926
927 # U1: overlay the last research run's per-source outcome onto each record
928 # so the audit layer can tell "configured" from "actually working".
929 # U2: derive each record's four-state audit bucket (probe evidence, when a
930 # live probe runs, is layered on in run() before render).
931 evidence = load_run_evidence(config)
932 for source, record in sources.items():
933 record["run_outcome"] = evidence["outcomes"].get(source) if evidence["fresh"] else None
934 record["audit_state"] = audit_state(source, record, record["run_outcome"])
935
936 # U3: annotate each CLI-dependent source with its binary's health so the
937 # per-source marker and the dedicated CLI-health block can render (R2).
938 for source, cli_name in CLI_DEPENDENCIES.items():
939 record = sources.get(source)
940 if record is None:
941 continue
942 try:
943 probe = health.probe_dependency(cli_name)
944 except Exception:
945 continue
946 record["cli"] = {
947 "name": cli_name,
948 "status": probe.status,
949 "off_path": bool(getattr(probe, "off_path", False)),
950 "detail": probe.detail,
951 "optional": source in _OPTIONAL_CLI_SOURCES,
952 }
953
954 # U7: attach backup + comment sub-lanes to their parent source.
955 for source, record in sources.items():
956 backups, comments = _sub_lanes_for(source, config)
957 if backups:
958 record["backups"] = backups
959 if comments is not None:
960 record["comments"] = comments
961
962 # Sequential on purpose: the permission preflight composes pipeline
963 # diagnostics and must not race the source builders.
964 try:
965 permissions = _permissions_block(config)
966 except Exception as exc:
967 permissions = {"status": "unavailable", "error": f"{type(exc).__name__}: {exc}"}
968
969 return {
970 "engine_version": _engine_version(),
971 "config": {
972 "global_env": str(env.CONFIG_FILE) if env.CONFIG_FILE else None,
973 "config_source": config.get("_CONFIG_SOURCE"),
974 },
975 "setup": _setup_block(config),
976 "permissions": permissions,
977 "sources": sources,
978 "mode": "config",
979 "run_evidence": {
980 "present": evidence["present"],
981 "fresh": evidence["fresh"],
982 "topic": evidence["topic"],
983 "at": evidence["at"],
984 },
985 }
986
987
988 # ---------------------------------------------------------------------------
989 # Renderers
990 # ---------------------------------------------------------------------------
991
992 def render_json(report: Dict[str, Any]) -> str:
993 return json.dumps(report, indent=2, sort_keys=True)
994
995
996 def _cli_marker(record: Dict[str, Any]) -> str:
997 """Inline `[CLI: name ✓]` / `[keyless]` marker (populated by U3)."""
998 cli = record.get("cli")
999 if not cli:
1000 return ""
1001 name = cli.get("name")
1002 if cli.get("status") == health.OK:
1003 return f" [CLI: {name} ✓]"
1004 if cli.get("off_path"):
1005 return f" [CLI: {name} ✗ off-PATH]"
1006 return f" [CLI: {name} ✗ {cli.get('status')}]"
1007
1008
1009 def _run_evidence_suffix(record: Dict[str, Any], state: str) -> str:
1010 """Last-run outcome tail for a source line (R4)."""
1011 ro = record.get("run_outcome")
1012 if not ro:
1013 if state == AUDIT_UNVERIFIED:
1014 return " [no recent run]"
1015 return ""
1016 st = ro.get("state")
1017 count = ro.get("items_returned") or 0
1018 detail = ro.get("detail")
1019 if st in _RUN_WORKING_STATES:
1020 if count:
1021 return f" [✓ {count} items last run]"
1022 return " [✓ ran clean, 0 matches last run]"
1023 if st == health.PARTIAL:
1024 tail = f" ({detail})" if detail else ""
1025 return f" [⚠ partial last run{tail}]"
1026 tail = detail or st
1027 return f" [✕ {tail} last run]"
1028
1029
1030 def _audit_source_line(name: str, record: Dict[str, Any], state: str) -> str:
1031 glyph = AUDIT_GLYPHS.get(state, "?")
1032 parts = [f" {glyph} {name}{_cli_marker(record)}"]
1033 descriptors: List[str] = []
1034 if record.get("status") not in (health.OK,):
1035 descriptors.append(record["status"])
1036 if record.get("note"):
1037 descriptors.append(record["note"])
1038 elif record.get("detail") and record.get("tier") != TIER_OK:
1039 descriptors.append(record["detail"])
1040 if descriptors:
1041 parts.append(" — " + "; ".join(descriptors))
1042 evidence = _run_evidence_suffix(record, state)
1043 if evidence:
1044 parts.append(evidence)
1045 # fix is only ever populated when there is something actionable, so
1046 # render it whenever present — an ok-tier record can carry one (the
1047 # youtube transcription-key note) and must not lose it in text mode.
1048 if record.get("fix"):
1049 parts.append(f"; fix: {record['fix']}")
1050 # Backup / comment sub-lanes render on their own indented lines (U7),
1051 # after the primary line (with its fix) is complete.
1052 for sub in _sub_lane_lines(record):
1053 parts.append("\n" + sub)
1054 return "".join(parts)
1055
1056
1057 def _sub_lane_lines(record: Dict[str, Any]) -> List[str]:
1058 """Indented backup/comment sub-lane lines under a source (R8, R9)."""
1059 lines: List[str] = []
1060 for backup in record.get("backups") or []:
1061 state = "armed" if backup.get("armed") else "off"
1062 note = f" - {backup['note']}" if backup.get("note") else ""
1063 lines.append(f" backup: {backup['name']} — {state}{note}")
1064 comments = record.get("comments")
1065 if comments is not None:
1066 state = "on" if comments.get("enabled") else "off"
1067 lines.append(f" comments: {state}")
1068 return lines
1069
1070
1071 def _cli_health_lines(report: Dict[str, Any]) -> List[str]:
1072 """Dedicated CLI-health block (R2): one row per CLI-dependent source,
1073 plus a note naming the keyless sources that need no CLI at all.
1074 """
1075 sources = report.get("sources") or {}
1076 rows: List[str] = []
1077 for source in SOURCE_ORDER:
1078 cli = (sources.get(source) or {}).get("cli")
1079 if not cli:
1080 continue
1081 ok = cli.get("status") == health.OK
1082 glyph = "✓" if ok else "✗"
1083 detail = cli.get("detail") or cli.get("status")
1084 tail = ""
1085 if not ok:
1086 if cli.get("off_path"):
1087 tail = " (installed off-PATH)"
1088 elif cli.get("optional"):
1089 tail = " (optional)"
1090 rows.append(f" {glyph} {cli['name']} — {source}{tail}: {detail}")
1091 if not rows:
1092 return []
1093 return (
1094 ["CLI health (downloaded binaries):"]
1095 + rows
1096 + [" · Reddit, Hacker News, Polymarket need no CLI (keyless)"]
1097 )
1098
1099
1100 def render_text(report: Dict[str, Any]) -> str:
1101 lines: List[str] = [f"last30days doctor — engine v{report['engine_version']}"]
1102 config_block = report.get("config") or {}
1103 if config_block.get("global_env"):
1104 line = f"config: {config_block['global_env']}"
1105 if config_block.get("config_source"):
1106 line += f" (source: {config_block['config_source']})"
1107 lines.append(line)
1108
1109 setup = report.get("setup") or {}
1110 present = sorted(
1111 name for name, is_set in (setup.get("keys_present") or {}).items() if is_set
1112 )
1113 setup_state = "complete" if setup.get("setup_complete") else "not recorded"
1114 lines.append(
1115 f"setup: {setup_state}; credentials present: "
1116 + (", ".join(present) if present else "none")
1117 + " (values never shown)"
1118 )
1119
1120 permissions = report.get("permissions") or {}
1121 if permissions.get("status"):
1122 browser = ((permissions.get("local_reads") or {}).get("browser_cookies") or {})
1123 lines.append(
1124 f"permissions: {permissions['status']}"
1125 + (f"; browser cookies: {browser.get('status')}" if browser else "")
1126 )
1127
1128 run_ev = report.get("run_evidence") or {}
1129 if run_ev.get("present") and run_ev.get("fresh"):
1130 topic = run_ev.get("topic") or "last run"
1131 lines.append(
1132 f"last run: {topic} - overlaying actual source outcomes below"
1133 )
1134 elif run_ev.get("present"):
1135 lines.append(
1136 "last run: found but stale - run `doctor --postmortem` to inspect it"
1137 )
1138
1139 grouped: Dict[str, List[str]] = {state: [] for state, _ in AUDIT_GROUPS}
1140 for name, record in (report.get("sources") or {}).items():
1141 state = record.get("audit_state") or audit_state(
1142 name, record, record.get("run_outcome")
1143 )
1144 grouped.setdefault(state, []).append(_audit_source_line(name, record, state))
1145
1146 for state, header in AUDIT_GROUPS:
1147 entries = grouped.get(state) or []
1148 lines.append("")
1149 lines.append(f"{AUDIT_GLYPHS.get(state, '')} {header}:")
1150 if entries:
1151 lines.extend(entries)
1152 else:
1153 lines.append(" (none)")
1154
1155 cli_block = _cli_health_lines(report)
1156 if cli_block:
1157 lines.append("")
1158 lines.extend(cli_block)
1159
1160 lines.append("")
1161 lines.append(
1162 "doctor reports problems without failing; run the printed fixes, "
1163 "then re-run doctor"
1164 )
1165 return "\n".join(lines) + "\n"
1166
1167
1168 # ---------------------------------------------------------------------------
1169 # Post-mortem (U4 / R5): what actually happened on the last run
1170 #
1171 # Unlike plain doctor (config prediction), --postmortem reads the last run's
1172 # per-source SourceOutcome and reports what broke, at any age (labeled). It is
1173 # a reader of the same last-report.json the overlay uses - no new persistence.
1174 # ---------------------------------------------------------------------------
1175
1176 def _age_label(iso: Any) -> str:
1177 if not iso:
1178 return ""
1179 try:
1180 ts = datetime.datetime.fromisoformat(iso)
1181 except (TypeError, ValueError):
1182 return ""
1183 if ts.tzinfo is None:
1184 ts = ts.replace(tzinfo=datetime.timezone.utc)
1185 secs = int(
1186 (datetime.datetime.now(datetime.timezone.utc) - ts).total_seconds()
1187 )
1188 if secs < 0:
1189 return ""
1190 if secs < 3600:
1191 return f"{secs // 60}m ago"
1192 if secs < 86400:
1193 return f"{secs // 3600}h ago"
1194 return f"{secs // 86400}d ago"
1195
1196
1197 def build_postmortem(config: Dict[str, Any]) -> Dict[str, Any]:
1198 """Assemble the last run's per-source outcomes (any age) for --postmortem."""
1199 evidence = load_run_evidence(config)
1200 return {
1201 "engine_version": _engine_version(),
1202 "mode": "postmortem",
1203 "present": evidence["present"],
1204 "topic": evidence["topic"],
1205 "at": evidence["at"],
1206 "outcomes": evidence["outcomes"],
1207 }
1208
1209
1210 def render_postmortem_text(pm: Dict[str, Any]) -> str:
1211 lines = [f"last30days post-mortem — engine v{pm['engine_version']}"]
1212 if not pm.get("present"):
1213 lines.append("")
1214 lines.append(
1215 "No saved run found - run `/last30days <topic>` first, or "
1216 "`doctor --probe` for a live check."
1217 )
1218 return "\n".join(lines) + "\n"
1219 topic = pm.get("topic") or "last run"
1220 age = _age_label(pm.get("at"))
1221 lines.append(f"last run: {topic}" + (f" ({age})" if age else ""))
1222
1223 failed, partial, succeeded, skipped = [], [], [], []
1224 for source, outcome in (pm.get("outcomes") or {}).items():
1225 state = outcome.get("state")
1226 if state in _RUN_WORKING_STATES:
1227 succeeded.append((source, outcome))
1228 elif state == health.PARTIAL:
1229 partial.append((source, outcome))
1230 elif state == health.SKIPPED_UNCONFIGURED:
1231 skipped.append((source, outcome))
1232 else:
1233 failed.append((source, outcome))
1234
1235 if failed:
1236 lines.append("")
1237 lines.append("Failed:")
1238 for source, outcome in failed:
1239 detail = outcome.get("detail") or outcome.get("state")
1240 lines.append(f" ✕ {source} — {outcome.get('state')}: {detail}")
1241 if outcome.get("fix_hint"):
1242 lines.append(f" fix: {outcome['fix_hint']}")
1243 if partial:
1244 lines.append("")
1245 lines.append("Partial:")
1246 for source, outcome in partial:
1247 count = outcome.get("items_returned") or 0
1248 detail = outcome.get("detail")
1249 tail = f" — {detail}" if detail else ""
1250 lines.append(f" ⚠ {source} ({count} items){tail}")
1251 if outcome.get("fix_hint"):
1252 lines.append(f" fix: {outcome['fix_hint']}")
1253 if succeeded:
1254 lines.append("")
1255 names = ", ".join(
1256 f"{s} ({o.get('items_returned') or 0})" for s, o in succeeded
1257 )
1258 lines.append(f"Succeeded: {names}")
1259 if skipped:
1260 lines.append("")
1261 lines.append(
1262 "Skipped (not configured): " + ", ".join(s for s, _ in skipped)
1263 )
1264 if not failed and not partial:
1265 lines.append("")
1266 lines.append("No failures on the last run.")
1267 return "\n".join(lines) + "\n"
1268
1269
1270 # ---------------------------------------------------------------------------
1271 # Cross-invocation cache (U5 / R5, KTD 8)
1272 #
1273 # Doctor writes its JSON result beside the existing ``last-run.json``
1274 # convention (env.CONFIG_DIR) so the SKILL.md standing rule's pre-research
1275 # check costs one file read on the healthy path instead of a dozen probe
1276 # subprocesses. ``--cached`` serves the stored report within the TTL and
1277 # falls through to a live run (rewriting the cache) when the file is stale,
1278 # absent, or corrupt — corruption is treated as absence, never a crash.
1279 # An explicit ``doctor`` (no ``--cached``) always runs live and refreshes.
1280 #
1281 # The payload carries a schema stamp (mirrors REPORT_CACHE_VERSION in
1282 # last30days.py) and a config fingerprint — a sha256 over the same
1283 # non-secret signals doctor already reports (key-presence booleans, backend
1284 # pin values, INCLUDE_SOURCES). A schema or fingerprint mismatch is treated
1285 # as stale, so a credential or pin change can never serve yesterday's
1286 # conclusions. Served reports carry ``from_cache`` + ``generated_at`` so
1287 # consumers can see staleness instead of inferring it.
1288 # ---------------------------------------------------------------------------
1289
1290 CACHE_FILENAME = "doctor-cache.json"
1291
1292 # Bump when the cached payload/report shape changes incompatibly; a
1293 # mismatched (or absent) stamp is treated as an absent cache.
1294 DOCTOR_CACHE_SCHEMA_VERSION = "last30days-doctor-cache/v1"
1295
1296 # TTL in SECONDS (env-tunable via LAST30DAYS_DOCTOR_TTL; registered in
1297 # lib/env.py's get_config key list so a .env-set value is not swallowed).
1298 DEFAULT_CACHE_TTL_SECONDS = 900
1299
1300 # Config vars whose values must never land in the cache file. Doctor output
1301 # carries no secrets by design (key presence is booleans only); this belt-and-
1302 # suspenders check refuses to persist the cache if a seeded value ever leaks.
1303 _SECRET_CONFIG_VARS = KEY_PRESENCE_VARS + (
1304 "AUTH_TOKEN", "CT0", "APIFY_API_TOKEN", "GOOGLE_GENAI_API_KEY",
1305 )
1306
1307 # Backend pin vars folded into the config fingerprint. Pin values are
1308 # backend names (e.g. "bird"), never secrets.
1309 _FINGERPRINT_PIN_VARS = (env.X_BACKEND_PIN_VAR, env.REDDIT_BACKEND_PIN_VAR)
1310
1311 # Top-level report keys the renderers read unguarded; a cached report
1312 # missing any of them is treated as corrupt (absent), never rendered.
1313 _REQUIRED_REPORT_KEYS = ("engine_version", "config", "setup", "permissions", "sources")
1314
1315
1316 def cache_path() -> Optional[Path]:
1317 """The doctor cache file, beside last-run.json (None in clean mode)."""
1318 if env.CONFIG_DIR is None:
1319 return None
1320 return env.CONFIG_DIR / CACHE_FILENAME
1321
1322
1323 def cache_ttl_seconds(config: Dict[str, Any]) -> int:
1324 """LAST30DAYS_DOCTOR_TTL in seconds; process env > config; default 900."""
1325 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_TTL")
1326 if raw is None:
1327 raw = (config or {}).get("LAST30DAYS_DOCTOR_TTL")
1328 if raw is None or raw == "":
1329 return DEFAULT_CACHE_TTL_SECONDS
1330 try:
1331 return max(0, int(raw))
1332 except (TypeError, ValueError):
1333 return DEFAULT_CACHE_TTL_SECONDS
1334
1335
1336 def _is_fresh(timestamp: Any, ttl_seconds: int) -> bool:
1337 return env.is_timestamp_fresh(timestamp, ttl_seconds)
1338
1339
1340 def _config_fingerprint(config: Dict[str, Any]) -> str:
1341 """sha256 over the non-secret config signals doctor already reports.
1342
1343 Inputs are key-presence BOOLEANS (never credential values — the same
1344 ``keys_present`` set the setup block renders), backend pin values
1345 (backend names, not secrets), and INCLUDE_SOURCES (not a secret).
1346 Adding or removing a credential, changing a pin, or toggling an opt-in
1347 source yields a new fingerprint, so ``read_cached_report`` treats the
1348 old cache as stale instead of serving pre-change conclusions.
1349 """
1350 config = config or {}
1351 signals = {
1352 "keys_present": _setup_block(config)["keys_present"],
1353 "pins": {var: str(config.get(var) or "") for var in _FINGERPRINT_PIN_VARS},
1354 "include_sources": str(config.get("INCLUDE_SOURCES") or ""),
1355 }
1356 canonical = json.dumps(signals, sort_keys=True, separators=(",", ":"))
1357 return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
1358
1359
1360 def _report_shape_ok(report: Any) -> bool:
1361 """True when a cached report satisfies the render contract.
1362
1363 Validates everything the renderers read unguarded: the required
1364 top-level keys exist (dict-valued where render calls ``.get`` on them),
1365 and every sources record is a dict carrying a known tier and a str
1366 status. Anything else is corrupt — treated as absent, never rendered.
1367 """
1368 if not isinstance(report, dict):
1369 return False
1370 if any(key not in report for key in _REQUIRED_REPORT_KEYS):
1371 return False
1372 if any(
1373 not isinstance(report[key], dict)
1374 for key in ("config", "setup", "permissions", "sources")
1375 ):
1376 return False
1377 sources = report["sources"]
1378 if not sources:
1379 return False
1380 for record in sources.values():
1381 if not isinstance(record, dict):
1382 return False
1383 if record.get("tier") not in GLYPHS:
1384 return False
1385 if not isinstance(record.get("status"), str):
1386 return False
1387 return True
1388
1389
1390 def read_cached_report(config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
1391 """Return the cached report when present, well-formed, and within TTL.
1392
1393 Any failure mode — unreadable file, invalid JSON, schema mismatch,
1394 config-fingerprint mismatch, wrong shape, bad or stale timestamp —
1395 returns None (cache treated as absent, never a crash).
1396
1397 A served report is stamped with ``from_cache: True`` and
1398 ``generated_at`` (the cache write time) so consumers see staleness.
1399 """
1400 path = cache_path()
1401 if path is None:
1402 return None
1403 try:
1404 payload = json.loads(path.read_text(encoding="utf-8"))
1405 except Exception:
1406 return None
1407 if not isinstance(payload, dict):
1408 return None
1409 if payload.get("schema") != DOCTOR_CACHE_SCHEMA_VERSION:
1410 return None # absent or mismatched schema stamp: treat as absent
1411 if payload.get("fingerprint") != _config_fingerprint(config):
1412 return None # credentials/pins/opt-ins changed: cache is stale
1413 report = payload.get("report")
1414 if not _report_shape_ok(report):
1415 return None
1416 if not _is_fresh(payload.get("timestamp"), cache_ttl_seconds(config)):
1417 return None
1418 report["generated_at"] = payload.get("timestamp")
1419 report["from_cache"] = True
1420 return report
1421
1422
1423 def _write_cache(report: Dict[str, Any], config: Dict[str, Any]) -> bool:
1424 """Best-effort cache write; refuses to persist any secret value.
1425
1426 Never fatal: any failure returns False after a one-line stderr warning
1427 (doctor's exit-0 contract is unaffected; only ``--cached`` reuse is).
1428 """
1429 try:
1430 path = cache_path()
1431 if path is None:
1432 return False
1433 payload = {
1434 "schema": DOCTOR_CACHE_SCHEMA_VERSION,
1435 "fingerprint": _config_fingerprint(config),
1436 "timestamp": report.get("generated_at")
1437 or datetime.datetime.now(datetime.timezone.utc).isoformat(),
1438 "report": report,
1439 }
1440 raw = json.dumps(payload, indent=2, sort_keys=True)
1441 for var in _SECRET_CONFIG_VARS:
1442 value = (config or {}).get(var)
1443 if isinstance(value, str) and value and value in raw:
1444 return False # never write a cache containing a secret
1445 path.parent.mkdir(parents=True, exist_ok=True)
1446 path.write_text(raw, encoding="utf-8")
1447 return True
1448 except Exception as exc:
1449 sys.stderr.write(
1450 f"[last30days] WARNING: could not write doctor cache: "
1451 f"{type(exc).__name__}: {exc}\n"
1452 )
1453 sys.stderr.flush()
1454 return False
1455
1456
1457 # ---------------------------------------------------------------------------
1458 # Live probe (U5 / R6)
1459 #
1460 # When there is no fresh run to learn from (or on explicit --probe), doctor
1461 # runs a BOUNDED live test so WORKING is verified, not guessed. Scope is
1462 # deliberate: free HTTP endpoints + keyless CLIs only. Credit-gated /
1463 # session-gated sources (x, tiktok, instagram, threads, ...) are NOT
1464 # live-probed - a health check must never spend ScrapeCreators credits or trip
1465 # auth rate limits; they stay UNVERIFIED with that noted. Every probe is capped
1466 # by a per-source deadline so a single slow source (YouTube's 120s search) can
1467 # never hang doctor.
1468 # ---------------------------------------------------------------------------
1469
1470 # Free, keyless liveness endpoints (reachability check, tiny payload).
1471 _HTTP_PROBE_URLS = {
1472 # The keyless engine's real discovery endpoint (reddit_rss._build_urls).
1473 # /r/all/hot.json is permanently 403 keyless (see the reddit_keyless module
1474 # docstring) and no lane requests it any more, so probing it measured an
1475 # endpoint the engine had already abandoned.
1476 "reddit": "https://www.reddit.com/search.rss?q=test&sort=relevance&t=month",
1477 "hackernews": "https://hn.algolia.com/api/v1/search?query=test&hitsPerPage=1",
1478 "polymarket": "https://gamma-api.polymarket.com/events?limit=1",
1479 "github": "https://api.github.com/rate_limit",
1480 }
1481
1482 # Per-source exception to "a 4xx still means the endpoint responded". The
1483 # keyless Reddit lanes send no credentials, so a 403/429 there is the host
1484 # refusing this client — the exact failure the engine hits — not reachability.
1485 _PROBE_BLOCKED_STATUSES = {"reddit": frozenset({403, 429})}
1486
1487 # Probe with the identity the lane sends, or the probe measures the User-Agent
1488 # rather than the endpoint (get_text sends http.BROWSER_USER_AGENT).
1489 _PROBE_HEADERS = {
1490 "reddit": {
1491 "User-Agent": http.BROWSER_USER_AGENT,
1492 "Accept": "application/atom+xml",
1493 },
1494 }
1495
1496 DEFAULT_PROBE_TIMEOUT_SECONDS = 10
1497
1498
1499 def probe_timeout_seconds(config: Dict[str, Any]) -> int:
1500 """Per-source probe deadline; process env > config > default 10s."""
1501 raw: Any = os.environ.get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1502 if raw is None:
1503 raw = (config or {}).get("LAST30DAYS_DOCTOR_PROBE_TIMEOUT")
1504 if raw is None or raw == "":
1505 return DEFAULT_PROBE_TIMEOUT_SECONDS
1506 try:
1507 return max(1, int(raw))
1508 except (TypeError, ValueError):
1509 return DEFAULT_PROBE_TIMEOUT_SECONDS
1510
1511
1512 def _probeable_sources() -> tuple:
1513 """Sources doctor will live-probe: free HTTP endpoints + keyless CLIs.
1514
1515 github is HTTP-probed (its REST tier works without gh), so it is excluded
1516 from the CLI-probe path even though gh is in CLI_DEPENDENCIES.
1517 """
1518 cli_only = [s for s in CLI_DEPENDENCIES if s not in _HTTP_PROBE_URLS]
1519 return tuple(dict.fromkeys(list(_HTTP_PROBE_URLS) + cli_only))
1520
1521
1522 def _http_ok(
1523 url: str,
1524 timeout: float,
1525 *,
1526 blocked_statuses: frozenset = frozenset(),
1527 headers: Optional[Dict[str, str]] = None,
1528 ) -> tuple:
1529 """Reachability check: a 4xx still means the endpoint responded; 5xx or a
1530 connection/timeout error means it did not.
1531
1532 ``blocked_statuses`` names the per-source codes that mean "responded, but
1533 refused us" (Reddit's keyless 403/429) — those are a failure, not
1534 reachability. ``headers`` overrides the probe identity so a source can be
1535 probed with the same User-Agent its lane sends.
1536 """
1537 def _verdict(code: int) -> tuple:
1538 return code < 500 and code not in blocked_statuses, f"HTTP {code}"
1539
1540 try:
1541 req = urllib.request.Request(
1542 url, headers=headers or {"User-Agent": "last30days-doctor"}
1543 )
1544 with urllib.request.urlopen(req, timeout=timeout) as resp:
1545 return _verdict(getattr(resp, "status", 200) or 200)
1546 except urllib.error.HTTPError as exc:
1547 return _verdict(exc.code)
1548 except Exception as exc:
1549 return False, f"{type(exc).__name__}: {exc}"
1550
1551
1552 def _probe_source(name: str, config: Dict[str, Any], timeout: float) -> Optional[Dict[str, Any]]:
1553 url = _HTTP_PROBE_URLS.get(name)
1554 if url:
1555 ok, detail = _http_ok(
1556 url,
1557 timeout,
1558 blocked_statuses=_PROBE_BLOCKED_STATUSES.get(name, frozenset()),
1559 headers=_PROBE_HEADERS.get(name),
1560 )
1561 return {"ok": ok, "detail": detail, "probed": True}
1562 cli = CLI_DEPENDENCIES.get(name)
1563 if cli:
1564 try:
1565 probe = health.probe_dependency(cli)
1566 except Exception as exc:
1567 return {"ok": False, "detail": f"{type(exc).__name__}: {exc}", "probed": True}
1568 return {"ok": bool(probe.ok), "detail": probe.detail, "probed": True}
1569 return None
1570
1571
1572 def _probe_sources(config: Dict[str, Any], timeout: int) -> Dict[str, Dict[str, Any]]:
1573 """Probe the probeable sources concurrently, each capped at ``timeout``.
1574
1575 A source that blows its deadline resolves to a probe-failure for that
1576 source only (never a hung command); other probes are unaffected.
1577 """
1578 names = _probeable_sources()
1579 results: Dict[str, Dict[str, Any]] = {}
1580 with concurrent.futures.ThreadPoolExecutor(
1581 max_workers=min(8, len(names) or 1)
1582 ) as pool:
1583 futures = {
1584 name: pool.submit(_probe_source, name, config, timeout) for name in names
1585 }
1586 for name, fut in futures.items():
1587 try:
1588 res = fut.result(timeout=timeout + 1)
1589 except concurrent.futures.TimeoutError:
1590 res = {"ok": False, "detail": "probe exceeded deadline", "probed": True}
1591 except Exception as exc:
1592 res = {
1593 "ok": False,
1594 "detail": f"{type(exc).__name__}: {exc}",
1595 "probed": True,
1596 }
1597 if res is not None:
1598 results[name] = res
1599 return results
1600
1601
1602 def _apply_probe(report: Dict[str, Any], probe_results: Dict[str, Dict[str, Any]]) -> None:
1603 """Attach probe results and re-derive audit_state for probed sources."""
1604 for name, res in probe_results.items():
1605 record = (report.get("sources") or {}).get(name)
1606 if record is None:
1607 continue
1608 record["probe"] = res
1609 record["audit_state"] = audit_state(
1610 name, record, record.get("run_outcome"), res
1611 )
1612
1613
1614 def run(
1615 config: Dict[str, Any],
1616 *,
1617 emit_json: bool = False,
1618 cached: bool = False,
1619 postmortem: bool = False,
1620 probe: bool = False,
1621 ) -> int:
1622 """Build (or serve the cached) doctor report and print it. Always exits 0
1623 (reporting problems is a successful run).
1624
1625 ``postmortem=True`` reads the last run's per-source outcomes (any age) and
1626 reports what broke, instead of predicting config health.
1627 ``probe=True`` (or no fresh run) runs a bounded live probe (U5) so WORKING
1628 is verified, not guessed.
1629 ``cached=True`` serves the stored report within the TTL; stale, absent,
1630 corrupt, schema-mismatched, or fingerprint-mismatched caches fall
1631 through to a live run that rewrites the cache — as does ANY exception
1632 raised while serving the cache (never-crash contract, KTD 8).
1633 ``cached=False`` (explicit ``doctor``) always runs live and refreshes.
1634 """
1635 if postmortem:
1636 pm = build_postmortem(config)
1637 if emit_json:
1638 print(json.dumps(pm, indent=2, sort_keys=True))
1639 else:
1640 print(render_postmortem_text(pm), end="")
1641 return 0
1642
1643 def _emit(report: Dict[str, Any]) -> None:
1644 if emit_json:
1645 # generated_at/from_cache ride the report dict, so they appear
1646 # at the JSON top level for free.
1647 print(render_json(report))
1648 else:
1649 # The cache-status line is printed here (run() owns this print)
1650 # because render_text's header belongs to the render layer, not
1651 # the cache layer.
1652 origin = "cached" if report.get("from_cache") else "live"
1653 print(render_text(report), end="")
1654 print(f"generated: {report.get('generated_at')} ({origin})")
1655
1656 if cached:
1657 try:
1658 cached_report = read_cached_report(config)
1659 if cached_report is not None:
1660 _emit(cached_report)
1661 return 0
1662 except Exception:
1663 # Belt-and-suspenders for shapes the validator misses: any
1664 # failure serving the cache falls through to a live run.
1665 pass
1666 report = build_report(config)
1667 report["generated_at"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
1668 report["from_cache"] = False
1669
1670 # U5: verify WORKING with a bounded live probe when asked (--probe) or when
1671 # there is no fresh run to learn from ("if no recent runs, run a live
1672 # test"). Scoped to free/CLI sources; credit-gated sources stay UNVERIFIED.
1673 fresh_run = bool((report.get("run_evidence") or {}).get("fresh"))
1674 if probe or not fresh_run:
1675 timeout = probe_timeout_seconds(config)
1676 probeable = _probeable_sources()
1677 sys.stderr.write(
1678 f"[last30days] doctor live probe: checking {len(probeable)} free/CLI "
1679 f"sources ({timeout}s each; no credit-gated sources - x/tiktok/"
1680 f"instagram/threads stay unverified)\n"
1681 )
1682 sys.stderr.flush()
1683 try:
1684 probe_results = _probe_sources(config, timeout)
1685 except Exception:
1686 probe_results = {}
1687 _apply_probe(report, probe_results)
1688 report["mode"] = "probe"
1689 report["probe"] = {"ran": True, "timeout": timeout, "sources": list(probeable)}
1690
1691 _write_cache(report, config)
1692 _emit(report)
1693 return 0
1694
1694 lines PYTHON