luckiest-co 1.0.46 → 1.0.48

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.
Files changed (266) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +8 -0
  257. package/skills/video-studio/SKILL.md +6 -5
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio/scripts/qa.mjs +64 -2
  261. package/skills/video-studio/scripts/qa.test.mjs +29 -1
  262. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  263. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  264. package/skills/video-studio-motion/SKILL.md +8 -5
  265. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  266. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -2,11 +2,13 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import ipaddress
5
6
  import json
6
7
  import os
7
8
  import re
8
9
  import sys
9
10
  from typing import Any
11
+ from urllib.parse import urlsplit, urlunsplit
10
12
 
11
13
  from . import env, http, schema
12
14
 
@@ -24,6 +26,93 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
24
26
  # constant is suffix-free. If GEMINI_FLASH_LITE moves to a non-preview stable ID,
25
27
  # double-check that OpenRouter's slug still maps to the same upstream model.
26
28
  OPENROUTER_DEFAULT = "google/gemini-3.1-flash-lite-preview"
29
+ PROVIDER_BASE_URL_KEYS = frozenset({"OPENAI_BASE_URL", "XAI_BASE_URL", "OPENROUTER_BASE_URL"})
30
+
31
+
32
+ def _is_loopback(host: str) -> bool:
33
+ """True for hosts that never leave the machine."""
34
+ if host == "localhost":
35
+ return True
36
+ try:
37
+ return ipaddress.ip_address(host).is_loopback
38
+ except ValueError:
39
+ return False
40
+
41
+
42
+ def allowed_base_url_override(value: str) -> bool:
43
+ try:
44
+ parts = urlsplit(value)
45
+ host = parts.hostname
46
+ except ValueError:
47
+ return False
48
+ return bool(host) and (
49
+ parts.scheme == "https" or (parts.scheme == "http" and _is_loopback(host))
50
+ )
51
+
52
+
53
+ def is_loopback_http_endpoint(url: str) -> bool:
54
+ parts = urlsplit(url)
55
+ return parts.scheme == "http" and _is_loopback(parts.hostname or "")
56
+
57
+
58
+ def base_url_override(key: str, default: str) -> str:
59
+ """Resolve a provider base-URL override, refusing cleartext remote hosts.
60
+
61
+ Every provider resolves its endpoint through here so one guard covers both
62
+ ways a value arrives: the process environment, and
63
+ ``_propagate_config_to_environ`` pushing a `.env` value into os.environ.
64
+
65
+ A base-URL override redirects the request that carries the provider's bearer
66
+ token, so an `http://` override to a remote host would put the API key on
67
+ the wire in cleartext. That is refused and the built-in vendor endpoint is
68
+ used instead - failing closed protects the credential, and the warning makes
69
+ the drop visible instead of leaving the user to wonder why their gateway is
70
+ being bypassed. Loopback is the one legitimate `http://` case (a local
71
+ LiteLLM/Ollama gateway or an SSH tunnel never leaves the machine), so it is
72
+ allowed.
73
+ """
74
+ raw = (os.environ.get(key) or "").strip()
75
+ if not raw:
76
+ return default
77
+ if allowed_base_url_override(raw):
78
+ return raw
79
+ sys.stderr.write(
80
+ f"[last30days] WARNING: ignoring {key} - a provider endpoint override "
81
+ "must be https:// (http:// is allowed only on localhost), otherwise the API "
82
+ f"key would be sent in cleartext. Using {default} instead.\n"
83
+ )
84
+ sys.stderr.flush()
85
+ return default
86
+
87
+
88
+ _ENDPOINT_PATHS = {
89
+ OPENAI_RESPONSES_URL: "/responses",
90
+ XAI_RESPONSES_URL: "/responses",
91
+ OPENROUTER_URL: "/chat/completions",
92
+ }
93
+
94
+
95
+ def resolve_endpoint(env_var: str, default_url: str) -> str:
96
+ """Resolve a ``*_BASE_URL`` override into a full endpoint URL.
97
+
98
+ By the convention every OpenAI-compatible provider documents, ``*_BASE_URL``
99
+ names the API root (``https://host/v1``) and the client appends the endpoint
100
+ path. This module historically required the full endpoint URL instead, so a
101
+ value copied from a provider's setup guide POSTed to the API root and failed.
102
+
103
+ Accept both forms: a host or versioned API root gets the endpoint path
104
+ appended, and a complete gateway route is used unchanged. Query strings
105
+ stay after the path in either form.
106
+ """
107
+ override = base_url_override(env_var, default_url)
108
+ if override == default_url:
109
+ return default_url
110
+ parts = urlsplit(override)
111
+ root_path = parts.path.rstrip("/")
112
+ last_segment = root_path.rsplit("/", 1)[-1]
113
+ if not root_path or re.fullmatch(r"v\d+(?:beta\d*)?", last_segment):
114
+ return urlunsplit(parts._replace(path=root_path + _ENDPOINT_PATHS[default_url]))
115
+ return override
27
116
 
28
117
 
29
118
  class ReasoningClient:
@@ -31,6 +120,95 @@ class ReasoningClient:
31
120
 
32
121
  name: str
33
122
 
123
+ def __init__(self) -> None:
124
+ self._usage_calls = 0
125
+ self._usage_prompt_tokens = 0
126
+ self._usage_completion_tokens = 0
127
+ self._usage_complete = True
128
+
129
+ @staticmethod
130
+ def _valid_token_count(value: Any) -> bool:
131
+ return isinstance(value, int) and not isinstance(value, bool) and value >= 0
132
+
133
+ def record_usage(
134
+ self,
135
+ prompt_tokens: Any,
136
+ completion_tokens: Any,
137
+ total_tokens: Any = None,
138
+ ) -> None:
139
+ self._usage_calls += 1
140
+ if not self._valid_token_count(prompt_tokens):
141
+ self._usage_complete = False
142
+ return
143
+ if total_tokens is not None:
144
+ if (
145
+ not self._valid_token_count(total_tokens)
146
+ or total_tokens < prompt_tokens
147
+ or (
148
+ completion_tokens is not None
149
+ and (
150
+ not self._valid_token_count(completion_tokens)
151
+ or total_tokens < prompt_tokens + completion_tokens
152
+ )
153
+ )
154
+ ):
155
+ self._usage_complete = False
156
+ return
157
+ # Reported totals can include reasoning tokens absent from completion counts.
158
+ completion_tokens = total_tokens - prompt_tokens
159
+ elif not self._valid_token_count(completion_tokens):
160
+ self._usage_complete = False
161
+ return
162
+ self._usage_prompt_tokens += prompt_tokens
163
+ self._usage_completion_tokens += completion_tokens
164
+
165
+ @property
166
+ def total_usage(self) -> dict[str, int] | None:
167
+ if not self._usage_calls or not self._usage_complete:
168
+ return None
169
+ return {
170
+ "calls": self._usage_calls,
171
+ "promptTokens": self._usage_prompt_tokens,
172
+ "completionTokens": self._usage_completion_tokens,
173
+ "totalTokens": self._usage_prompt_tokens + self._usage_completion_tokens,
174
+ }
175
+
176
+ def _mark_usage_incomplete(self) -> None:
177
+ self._usage_complete = False
178
+
179
+ def _post(self, url: str, payload: dict[str, Any], **kwargs: Any) -> dict[str, Any]:
180
+ try:
181
+ return http.post(url, payload, on_retry=self._mark_usage_incomplete, **kwargs)
182
+ except Exception:
183
+ self._mark_usage_incomplete()
184
+ raise
185
+
186
+ def _record_response_usage(
187
+ self,
188
+ response: dict[str, Any],
189
+ *,
190
+ metadata_key: str,
191
+ prompt_key: str,
192
+ completion_key: str,
193
+ total_key: str,
194
+ prompt_fallback_key: str | None = None,
195
+ completion_fallback_key: str | None = None,
196
+ ) -> None:
197
+ usage = response.get(metadata_key)
198
+ if not isinstance(usage, dict):
199
+ usage = {}
200
+ prompt_tokens = usage.get(prompt_key)
201
+ completion_tokens = usage.get(completion_key)
202
+ if prompt_tokens is None and prompt_fallback_key:
203
+ prompt_tokens = usage.get(prompt_fallback_key)
204
+ if completion_tokens is None and completion_fallback_key:
205
+ completion_tokens = usage.get(completion_fallback_key)
206
+ self.record_usage(
207
+ prompt_tokens,
208
+ completion_tokens,
209
+ usage.get(total_key),
210
+ )
211
+
34
212
  def generate_text(
35
213
  self,
36
214
  model: str,
@@ -56,6 +234,7 @@ class GeminiClient(ReasoningClient):
56
234
  name = "gemini"
57
235
 
58
236
  def __init__(self, api_key: str):
237
+ super().__init__()
59
238
  self.api_key = api_key
60
239
 
61
240
  def _generate_content(
@@ -74,7 +253,7 @@ class GeminiClient(ReasoningClient):
74
253
  body["generationConfig"]["responseMimeType"] = response_mime_type
75
254
  if tools:
76
255
  body["tools"] = tools
77
- return http.post(
256
+ return self._post(
78
257
  GEMINI_URL.format(model=model, api_key=self.api_key),
79
258
  body,
80
259
  headers={"Content-Type": "application/json"},
@@ -95,12 +274,20 @@ class GeminiClient(ReasoningClient):
95
274
  tools=tools,
96
275
  response_mime_type=response_mime_type,
97
276
  )
277
+ self._record_response_usage(
278
+ payload,
279
+ metadata_key="usageMetadata",
280
+ prompt_key="promptTokenCount",
281
+ completion_key="candidatesTokenCount",
282
+ total_key="totalTokenCount",
283
+ )
98
284
  return extract_gemini_text(payload)
99
285
 
100
286
  class OpenAIClient(ReasoningClient):
101
287
  name = "openai"
102
288
 
103
289
  def __init__(self, token: str):
290
+ super().__init__()
104
291
  self.token = token
105
292
 
106
293
  def generate_text(
@@ -118,14 +305,23 @@ class OpenAIClient(ReasoningClient):
118
305
  "input": prompt,
119
306
  "temperature": 0,
120
307
  }
121
- response = http.post(
122
- os.environ.get("OPENAI_BASE_URL", OPENAI_RESPONSES_URL),
308
+ endpoint = resolve_endpoint("OPENAI_BASE_URL", OPENAI_RESPONSES_URL)
309
+ response = self._post(
310
+ endpoint,
123
311
  payload,
124
312
  headers={
125
313
  "Authorization": f"Bearer {self.token}",
126
314
  "Content-Type": "application/json",
127
315
  },
128
316
  timeout=90,
317
+ bypass_proxy=is_loopback_http_endpoint(endpoint),
318
+ )
319
+ self._record_response_usage(
320
+ response,
321
+ metadata_key="usage",
322
+ prompt_key="input_tokens",
323
+ completion_key="output_tokens",
324
+ total_key="total_tokens",
129
325
  )
130
326
  return extract_openai_text(response)
131
327
 
@@ -134,6 +330,7 @@ class XAIClient(ReasoningClient):
134
330
  name = "xai"
135
331
 
136
332
  def __init__(self, api_key: str):
333
+ super().__init__()
137
334
  self.api_key = api_key
138
335
 
139
336
  def generate_text(
@@ -149,14 +346,25 @@ class XAIClient(ReasoningClient):
149
346
  "model": model,
150
347
  "input": [{"role": "user", "content": prompt}],
151
348
  }
152
- response = http.post(
153
- os.environ.get("XAI_BASE_URL", XAI_RESPONSES_URL),
349
+ endpoint = resolve_endpoint("XAI_BASE_URL", XAI_RESPONSES_URL)
350
+ response = self._post(
351
+ endpoint,
154
352
  payload,
155
353
  headers={
156
354
  "Authorization": f"Bearer {self.api_key}",
157
355
  "Content-Type": "application/json",
158
356
  },
159
357
  timeout=90,
358
+ bypass_proxy=is_loopback_http_endpoint(endpoint),
359
+ )
360
+ self._record_response_usage(
361
+ response,
362
+ metadata_key="usage",
363
+ prompt_key="input_tokens",
364
+ completion_key="output_tokens",
365
+ total_key="total_tokens",
366
+ prompt_fallback_key="prompt_tokens",
367
+ completion_fallback_key="completion_tokens",
160
368
  )
161
369
  return extract_openai_text(response)
162
370
 
@@ -165,6 +373,7 @@ class OpenRouterClient(ReasoningClient):
165
373
  name = "openrouter"
166
374
 
167
375
  def __init__(self, api_key: str):
376
+ super().__init__()
168
377
  self.api_key = api_key
169
378
 
170
379
  def generate_text(
@@ -181,14 +390,23 @@ class OpenRouterClient(ReasoningClient):
181
390
  "messages": [{"role": "user", "content": prompt}],
182
391
  "temperature": 0,
183
392
  }
184
- response = http.post(
185
- OPENROUTER_URL,
393
+ endpoint = resolve_endpoint("OPENROUTER_BASE_URL", OPENROUTER_URL)
394
+ response = self._post(
395
+ endpoint,
186
396
  payload,
187
397
  headers={
188
398
  "Authorization": f"Bearer {self.api_key}",
189
399
  "Content-Type": "application/json",
190
400
  },
191
401
  timeout=90,
402
+ bypass_proxy=is_loopback_http_endpoint(endpoint),
403
+ )
404
+ self._record_response_usage(
405
+ response,
406
+ metadata_key="usage",
407
+ prompt_key="prompt_tokens",
408
+ completion_key="completion_tokens",
409
+ total_key="total_tokens",
192
410
  )
193
411
  return extract_openai_text(response)
194
412
 
@@ -315,9 +533,12 @@ def resolve_runtime(config: dict[str, Any], depth: str) -> tuple[schema.Provider
315
533
 
316
534
 
317
535
  def _resolve_x_backend(config: dict[str, Any]) -> str | None:
318
- preferred = (config.get("LAST30DAYS_X_BACKEND") or "").lower()
319
- if preferred in {"xai", "bird"}:
320
- return preferred
536
+ """Resolve the X backend for runtime fetch.
537
+
538
+ Delegates to env.get_x_source which handles:
539
+ - Any known pin (X_BACKEND_KNOWN) exclusively: returns pin if available, None otherwise
540
+ - Unpinned: walks auto-chain (X_BACKEND_ORDER) only, never auto-selects opt-in backends
541
+ """
321
542
  return env.get_x_source(config)
322
543
 
323
544
 
@@ -1,13 +1,21 @@
1
1
  """Post-research quality score and upgrade nudge.
2
2
 
3
- Computes a quality score based on 5 core sources and builds
3
+ Computes a quality score based on the non-blocking core sources and builds
4
4
  a nudge message describing what the user missed and how to fix it.
5
+
6
+ Fix text comes from ``lib.prescriptions`` (the single remediation
7
+ vocabulary shared with the doctor command, KTD 7); only the trigger
8
+ logic and the message framing live here.
5
9
  """
6
10
 
7
- from typing import List
11
+ from typing import List, Optional
12
+
13
+ from . import env, health, http, prescriptions, x_envelope
8
14
 
9
15
 
10
- # The 5 core sources
16
+ # Sources whose absence can justify a post-run quality repair. X remains a
17
+ # supported source, but it is optional: declining cookie access must not turn a
18
+ # successful multi-source run into a setup prompt or a lower quality grade.
11
19
  CORE_SOURCES = ["hn", "polymarket", "x", "youtube", "reddit"]
12
20
 
13
21
  # Labels for display
@@ -22,6 +30,8 @@ SOURCE_LABELS = {
22
30
 
23
31
  def _is_x_active(config: dict, research_results: dict) -> bool:
24
32
  """Check if X source is active (has credentials AND didn't error)."""
33
+ if "x" in (research_results.get("active_sources") or []):
34
+ return not bool(research_results.get("x_error"))
25
35
  has_creds = _has_x_credentials(config)
26
36
  if not has_creds:
27
37
  return False
@@ -32,12 +42,60 @@ def _is_x_active(config: dict, research_results: dict) -> bool:
32
42
 
33
43
 
34
44
  def _has_x_credentials(config: dict) -> bool:
35
- """Return True when any X/Twitter source credential is configured."""
36
- return bool(
45
+ """Return True when any X/Twitter source credential is configured.
46
+
47
+ ``X_BEARER_TOKEN`` counts where the xapi backend can actually run: an
48
+ official-only host (it heads the auto chain there) or an explicit xapi
49
+ pin. An ambient bearer on any other host is not a configured X source,
50
+ so nothing changes there.
51
+ """
52
+ if (
37
53
  config.get("AUTH_TOKEN")
38
54
  or config.get("XAI_API_KEY")
39
55
  or config.get("XQUIK_API_KEY")
40
- )
56
+ ):
57
+ return True
58
+ if config.get("X_BEARER_TOKEN"):
59
+ return "xapi" in env.x_auto_chain(config) or env.x_backend_pin(config) == "xapi"
60
+ return False
61
+
62
+
63
+ def _x_error_prescription(config: dict, research_results: dict) -> prescriptions.Prescription:
64
+ """The fix for a configured X that errored, routed through the X policy.
65
+
66
+ The pipeline stamps the failed backend into ``x_error`` or
67
+ ``x_degraded_error``, sometimes behind a simplified-query retry label.
68
+ Use that runtime provenance before the host policy.
69
+ """
70
+ message = str(research_results.get("x_error") or research_results.get("x_degraded_error") or "")
71
+ if message.startswith(x_envelope.DETAIL_NOT_PASSED):
72
+ # The model declared the X connector lane and passed no envelope:
73
+ # the fix is the connector, on any host.
74
+ return prescriptions.for_x(config, "connector_missing")
75
+ backend_error = message.removeprefix("Simplified-query retry failed: ")
76
+ backend_error = backend_error.removeprefix("All X backends failed — ")
77
+ if backend_error.startswith("X served via "):
78
+ served_backend, separator, origin_error = backend_error.removeprefix("X served via ").partition(" after ")
79
+ if separator:
80
+ backend_error = origin_error.split(f"; {served_backend}", 1)[0]
81
+ if backend_error.startswith("xai:"):
82
+ state = http.classify_failure(message=backend_error)
83
+ if state == health.PAYMENT_REQUIRED:
84
+ failure = "xai_payment_required"
85
+ elif state == health.RATE_LIMITED:
86
+ failure = "xai_rate_limited"
87
+ elif state == health.TIMEOUT:
88
+ failure = "xai_timeout"
89
+ elif state == health.AUTH_FAILED or "model" in backend_error.lower():
90
+ failure = "xai_error"
91
+ else:
92
+ failure = "xai_unavailable"
93
+ return prescriptions.for_x(config, failure)
94
+ failure = "cookies_expired"
95
+ if env.x_policy(config).hint_namespace == "official":
96
+ if http.classify_failure(message=message) == health.PAYMENT_REQUIRED:
97
+ failure = "payment_required"
98
+ return prescriptions.for_x(config, failure)
41
99
 
42
100
 
43
101
  def _has_ytdlp() -> bool:
@@ -143,7 +201,7 @@ def _is_instagram_silent_failure(config: dict, research_results: dict) -> bool:
143
201
 
144
202
 
145
203
  def compute_quality_score(config: dict, research_results: dict) -> dict:
146
- """Compute research quality score based on 5 core sources.
204
+ """Compute research quality score from the non-blocking core sources.
147
205
 
148
206
  Args:
149
207
  config: Configuration dict from env.get_config()
@@ -153,13 +211,14 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
153
211
  degraded-YouTube detection (transcript-fetch ratio below threshold,
154
212
  or fallback/provider data returned without local yt-dlp).
155
213
  Optional key ``instagram_items_count`` enables silent-failure
156
- detection for the bonus Instagram source.
214
+ detection for the bonus Instagram source. ``x_degraded_error``
215
+ carries a failed xAI lane when another backend served X items.
157
216
 
158
217
  Returns:
159
218
  {
160
- "score_pct": 40-100,
219
+ "score_pct": 0-100,
161
220
  "core_active": ["hn", "polymarket", ...],
162
- "core_missing": ["x", "youtube"],
221
+ "core_missing": ["youtube"],
163
222
  "core_errored": [], # configured but errored at top level
164
223
  "core_degraded": [], # configured and returned items but quality below threshold
165
224
  "bonus_errored": [], # bonus sources (Instagram, etc.) configured but silent
@@ -177,14 +236,22 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
177
236
  core_active.append("polymarket")
178
237
  core_active.append("reddit")
179
238
 
180
- # X
181
- has_x_creds = _has_x_credentials(config)
239
+ # X splits three ways. Active counts normally. Configured-but-errored is a
240
+ # real outage: it still docks the score and surfaces a repair, never an
241
+ # "optional omission". Only unconfigured/declined X leaves the denominator.
242
+ optional_omitted: List[str] = []
243
+ x_configured = _has_x_credentials(config) or (
244
+ "x" in (research_results.get("active_sources") or [])
245
+ )
182
246
  if _is_x_active(config, research_results):
183
247
  core_active.append("x")
184
- else:
248
+ if research_results.get("x_degraded_error"):
249
+ core_degraded.append("x")
250
+ elif x_configured and research_results.get("x_error"):
185
251
  core_missing.append("x")
186
- if has_x_creds and research_results.get("x_error"):
187
- core_errored.append("x")
252
+ core_errored.append("x")
253
+ else:
254
+ optional_omitted.append("x")
188
255
 
189
256
  # YouTube
190
257
  has_ytdlp = _has_ytdlp()
@@ -219,10 +286,12 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
219
286
  if _is_instagram_silent_failure(config, research_results):
220
287
  bonus_errored.append("instagram")
221
288
 
222
- score_pct = int(len(core_active) / 5 * 100)
289
+ scored_source_count = len(CORE_SOURCES) - len(optional_omitted)
290
+ score_pct = int(len(core_active) / scored_source_count * 100)
223
291
 
224
292
  has_sc = bool(config.get("SCRAPECREATORS_API_KEY"))
225
293
  active_sources = research_results.get("active_sources") or []
294
+ x_fix = _x_error_prescription(config, research_results) if "x" in core_errored or "x" in core_degraded else None
226
295
  nudge_text = _build_nudge_text(
227
296
  core_missing,
228
297
  core_errored,
@@ -232,6 +301,8 @@ def compute_quality_score(config: dict, research_results: dict) -> dict:
232
301
  active_sources=active_sources,
233
302
  bonus_errored=bonus_errored,
234
303
  has_ytdlp=has_ytdlp,
304
+ core_total=scored_source_count,
305
+ x_fix=x_fix,
235
306
  ) if (core_missing or core_degraded or bonus_errored) else None
236
307
 
237
308
  return {
@@ -254,6 +325,8 @@ def _build_nudge_text(
254
325
  active_sources: list = None,
255
326
  bonus_errored: List[str] = None,
256
327
  has_ytdlp: bool = False,
328
+ core_total: int | None = None,
329
+ x_fix: Optional[prescriptions.Prescription] = None,
257
330
  ) -> str:
258
331
  """Build human-readable nudge text describing what was missed or degraded.
259
332
 
@@ -274,8 +347,9 @@ def _build_nudge_text(
274
347
  else:
275
348
  missed_parts.append(label)
276
349
 
277
- active_count = 5 - len(core_missing)
278
- lines.append(f"Research quality: {active_count}/5 core sources.")
350
+ effective_total = core_total if core_total is not None else len(CORE_SOURCES)
351
+ active_count = effective_total - len(core_missing)
352
+ lines.append(f"Research quality: {active_count}/{effective_total} core sources.")
279
353
  if missed_parts:
280
354
  lines.append(f"Missing: {', '.join(missed_parts)}.")
281
355
  if core_degraded:
@@ -289,29 +363,30 @@ def _build_nudge_text(
289
363
  # Free suggestions
290
364
  free_suggestions: List[str] = []
291
365
 
292
- if "x" in core_missing:
293
- if "x" in core_errored:
294
- free_suggestions.append(
295
- "X/Twitter errored - log into x.com in your browser, then re-run."
296
- )
297
- else:
298
- free_suggestions.append(
299
- "X/Twitter: real-time posts with likes and reposts - the fastest "
300
- "signal for breaking topics. Three options: log into x.com in your "
301
- "browser and re-run (cookies detected automatically), or add "
302
- "XAI_API_KEY to your .env (get key at api.x.ai), or add "
303
- "XQUIK_API_KEY to your .env (get key at xquik.com)."
304
- )
366
+ # A configured X that errored is the only X entry that can reach
367
+ # core_missing: unconfigured/declined X is an optional omission and never
368
+ # lands here. Surface the repair instead of hiding the outage.
369
+ if "x" in core_missing and "x" in core_errored:
370
+ if x_fix is None:
371
+ x_fix = prescriptions.get("x", "cookies_expired")
372
+ free_suggestions.append(f"X/Twitter errored - {x_fix.fix_nl}.")
373
+
374
+ if "x" in core_degraded:
375
+ if x_fix is None:
376
+ x_fix = prescriptions.get("x", "cookies_expired")
377
+ free_suggestions.append(f"X/Twitter used a backup - {x_fix.fix_nl}.")
305
378
 
306
379
  if "youtube" in core_missing:
307
380
  if "youtube" in core_errored:
381
+ yt_fix = prescriptions.get("youtube", "ytdlp_stale")
308
382
  free_suggestions.append(
309
- "YouTube errored - update yt-dlp: brew upgrade yt-dlp"
383
+ f"YouTube errored - update yt-dlp: {yt_fix.fix_cli}"
310
384
  )
311
385
  else:
386
+ yt_fix = prescriptions.get("youtube", "ytdlp_missing")
312
387
  free_suggestions.append(
313
388
  "YouTube: video transcripts with key moments - often the deepest "
314
- "explanations on any topic. Install yt-dlp: brew install yt-dlp (free)"
389
+ f"explanations on any topic. Install yt-dlp: {yt_fix.fix_cli} (free)"
315
390
  )
316
391
 
317
392
  if "youtube" in core_degraded:
@@ -319,12 +394,18 @@ def _build_nudge_text(
319
394
  transcripts = int(research_results.get("youtube_transcripts_count") or 0)
320
395
  captions_disabled = int(research_results.get("youtube_captions_disabled_count") or 0)
321
396
  if not has_ytdlp and _youtube_returned_data(research_results):
397
+ install = prescriptions.get("youtube", "ytdlp_missing")
398
+ # Tolerant lookup: alt_cli makes no arity promise, so an entry
399
+ # gaining/losing a platform alternate must degrade the wording,
400
+ # never crash the nudge path.
401
+ scoop_install = install.alt_cli[0] if len(install.alt_cli) > 0 else install.fix_cli
402
+ pip_install = install.alt_cli[1] if len(install.alt_cli) > 1 else scoop_install
322
403
  free_suggestions.append(
323
404
  f"YouTube returned {videos} videos and {transcripts} transcripts "
324
405
  "through a fallback/provider path, but local yt-dlp is not "
325
406
  "installed. Install yt-dlp to enable the free local YouTube lane "
326
- "and reduce reliance on fallback providers: brew install yt-dlp "
327
- "(macOS), scoop install yt-dlp (Windows), or pip install -U yt-dlp."
407
+ f"and reduce reliance on fallback providers: {install.fix_cli} "
408
+ f"(macOS), {scoop_install} (Windows), or {pip_install}."
328
409
  )
329
410
  else:
330
411
  captions_note = ""
@@ -333,13 +414,17 @@ def _build_nudge_text(
333
414
  f" ({captions_disabled} of those had captions disabled by the "
334
415
  "uploader, which is a separate cause and not fixable on your end)"
335
416
  )
417
+ update = prescriptions.get("youtube", "ytdlp_stale")
418
+ # Same tolerant lookup as the install branch above.
419
+ scoop_update = update.alt_cli[0] if len(update.alt_cli) > 0 else update.fix_cli
420
+ pip_update = update.alt_cli[1] if len(update.alt_cli) > 1 else scoop_update
336
421
  free_suggestions.append(
337
422
  f"YouTube returned {videos} videos but only {transcripts} transcripts "
338
423
  f"captured{captions_note}. The most common remaining cause is a stale "
339
424
  "yt-dlp binary - YouTube's caption format changes frequently and old "
340
425
  "binaries silently fail every transcript. Update via your package "
341
- "manager: scoop update yt-dlp (Windows), brew upgrade yt-dlp (macOS), "
342
- "or pip install -U yt-dlp."
426
+ f"manager: {scoop_update} (Windows), {update.fix_cli} (macOS), "
427
+ f"or {pip_update}."
343
428
  )
344
429
 
345
430
  if "instagram" in bonus_errored: