luckiest-co 1.0.47 → 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 (264) 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 +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  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-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,292 @@
1
+ """X corpus judging for retrieve-judge-retry.
2
+
3
+ No I/O: judges items already retrieved. Reuses relevance.token_overlap_relevance.
4
+
5
+ The judge detects off-topic floods and determines which extracted handles should
6
+ be promoted to the FROM lane based on on-topic post ratio, not frequency.
7
+ """
8
+
9
+ from collections import Counter
10
+ from typing import Any, Dict, List, Optional, Set, Tuple
11
+
12
+ from . import relevance
13
+
14
+ # Minimum on-topic ratio for the overall corpus. Below this, the engine should
15
+ # retry with a wider keyword query. Rome measured ~0.2 (8/40 on-topic).
16
+ CORPUS_ON_TOPIC_FLOOR = 0.4
17
+
18
+ # Minimum on-topic ratio for a handle's posts to qualify for FROM promotion.
19
+ HANDLE_ON_TOPIC_FLOOR = 0.5
20
+
21
+ # Minimum on-topic keyword hits before a handle can be promoted to FROM lane.
22
+ # Prevents promoting handles that appeared in thin phrase hits with only 1 match.
23
+ MIN_ON_TOPIC_HITS = 2
24
+
25
+
26
+ # Ambiguous short tokens that require case-sensitive matching to avoid
27
+ # pronoun/acronym collisions. E.g., "US" (country) vs "us" (pronoun).
28
+ # For these tokens, require the text to contain the uppercase form (acronym)
29
+ # rather than just the lowercase form (common word).
30
+ _CASE_SENSITIVE_ACRONYMS = frozenset({'us'})
31
+
32
+
33
+ def _compute_relevance(query: str, text: str) -> float:
34
+ """Compute relevance score for a post against the topic query.
35
+
36
+ Returns 0.0 for empty/stopword-only queries to avoid treating all items
37
+ as equally relevant (the shared relevance module returns 0.5 for empty
38
+ queries as a neutral fallback, but x_judge needs strict filtering).
39
+
40
+ Uses case-sensitive matching for ambiguous short tokens like 'us' to
41
+ distinguish country acronym 'US' from pronoun 'us'.
42
+ """
43
+ import re
44
+ if not query or not text:
45
+ return 0.0
46
+
47
+ q_tokens = relevance.tokenize(query)
48
+ if not q_tokens:
49
+ return 0.0 # All query tokens were stopwords
50
+
51
+ # Check for ambiguous acronyms that need case-sensitive handling
52
+ t_tokens = relevance.tokenize(text)
53
+ filtered_q_tokens = set(q_tokens)
54
+ for acronym in _CASE_SENSITIVE_ACRONYMS:
55
+ if acronym in q_tokens and acronym in t_tokens:
56
+ # Text has the token, but we need to check if it's the acronym (US)
57
+ # or the common word (us). If text only has lowercase, don't count it.
58
+ has_uppercase = bool(re.search(rf'\b{acronym.upper()}\b', text))
59
+ has_lowercase = bool(re.search(rf'\b{acronym}\b', text))
60
+ if has_lowercase and not has_uppercase:
61
+ # Text only has lowercase version (pronoun) - don't count this
62
+ # token in query overlap. This effectively removes "us" from
63
+ # contributing to the score when text has only the pronoun.
64
+ t_tokens = t_tokens - {acronym}
65
+ # Also remove from query for this calculation to avoid
66
+ # penalizing the overall coverage ratio
67
+ filtered_q_tokens = filtered_q_tokens - {acronym}
68
+
69
+ # If filtering removed all query tokens, fall back to 0
70
+ if not filtered_q_tokens:
71
+ return 0.0
72
+
73
+ overlap_tokens = filtered_q_tokens & t_tokens
74
+ if not overlap_tokens:
75
+ return 0.0
76
+
77
+ # Compute simplified relevance: coverage ratio
78
+ # This is a simpler version of token_overlap_relevance that uses
79
+ # the filtered tokens rather than re-tokenizing the original text
80
+ coverage = len(overlap_tokens) / len(filtered_q_tokens)
81
+ return coverage
82
+
83
+
84
+ def judge_x_corpus(
85
+ items: List[Dict[str, Any]],
86
+ topic: str,
87
+ *,
88
+ ranking_query: str = "",
89
+ ) -> Dict[str, Any]:
90
+ """Judge the retrieved X corpus for on-topic ratio.
91
+
92
+ Args:
93
+ items: List of X items with 'author_handle' and 'text' fields
94
+ topic: The user topic (e.g., "Rome")
95
+ ranking_query: Optional ranking query for better relevance scoring
96
+
97
+ Returns:
98
+ Dict with:
99
+ - on_topic_ratio: float, fraction of posts that are on-topic
100
+ - is_off_topic_flood: bool, True if corpus fails the on-topic floor
101
+ - on_topic_items: list, items that passed relevance check
102
+ - off_topic_items: list, items that failed relevance check
103
+ - handle_stats: dict, per-handle on-topic counts and totals
104
+ """
105
+ if not items:
106
+ return {
107
+ "on_topic_ratio": 1.0,
108
+ "is_off_topic_flood": False,
109
+ "on_topic_items": [],
110
+ "off_topic_items": [],
111
+ "handle_stats": {},
112
+ }
113
+
114
+ # Use ranking_query if provided, otherwise topic
115
+ query = ranking_query or topic
116
+
117
+ on_topic_items = []
118
+ off_topic_items = []
119
+ handle_stats: Dict[str, Dict[str, int]] = {}
120
+
121
+ for item in items:
122
+ handle = (item.get("author_handle") or "").lower()
123
+ text = item.get("text") or ""
124
+ score = _compute_relevance(query, text)
125
+
126
+ # On-topic threshold: relevance.RELEVANCE_FLOOR is 0.1
127
+ is_on_topic = score >= relevance.RELEVANCE_FLOOR
128
+
129
+ if is_on_topic:
130
+ on_topic_items.append(item)
131
+ else:
132
+ off_topic_items.append(item)
133
+
134
+ if handle:
135
+ if handle not in handle_stats:
136
+ handle_stats[handle] = {"on_topic": 0, "total": 0}
137
+ handle_stats[handle]["total"] += 1
138
+ if is_on_topic:
139
+ handle_stats[handle]["on_topic"] += 1
140
+
141
+ on_topic_ratio = len(on_topic_items) / len(items) if items else 1.0
142
+
143
+ # Check if top-3 frequency authors have poor on-topic ratio
144
+ top_handles = sorted(
145
+ handle_stats.items(),
146
+ key=lambda x: x[1]["total"],
147
+ reverse=True,
148
+ )[:3]
149
+ top_authors_off_topic = all(
150
+ stats["on_topic"] / stats["total"] < HANDLE_ON_TOPIC_FLOOR
151
+ for _, stats in top_handles
152
+ if stats["total"] > 0
153
+ ) if top_handles else False
154
+
155
+ is_off_topic_flood = (
156
+ on_topic_ratio < CORPUS_ON_TOPIC_FLOOR
157
+ or (top_authors_off_topic and len(on_topic_items) < MIN_ON_TOPIC_HITS)
158
+ )
159
+
160
+ return {
161
+ "on_topic_ratio": on_topic_ratio,
162
+ "is_off_topic_flood": is_off_topic_flood,
163
+ "on_topic_items": on_topic_items,
164
+ "off_topic_items": off_topic_items,
165
+ "handle_stats": handle_stats,
166
+ }
167
+
168
+
169
+ def promotable_handles(
170
+ items: List[Dict[str, Any]],
171
+ topic: str,
172
+ extracted_handles: List[str],
173
+ *,
174
+ explicit_handles: Optional[List[str]] = None,
175
+ ranking_query: str = "",
176
+ ) -> Tuple[List[str], List[str]]:
177
+ """Determine which handles should be promoted to the FROM lane.
178
+
179
+ Split FROM promotion:
180
+ - Explicit handles (--x-handle/--x-related): always promoted, no AND topic
181
+ - Extracted handles: promoted only if:
182
+ - ≥MIN_ON_TOPIC_HITS on-topic keyword hits AND
183
+ - author on-topic ratio ≥ HANDLE_ON_TOPIC_FLOOR
184
+ - These pulls AND the topic (from:handle Rome)
185
+
186
+ Args:
187
+ items: List of X items with 'author_handle' and 'text' fields
188
+ topic: The user topic
189
+ extracted_handles: Handles extracted from entity_extract
190
+ explicit_handles: Explicit --x-handle/--x-related handles
191
+ ranking_query: Optional ranking query for relevance scoring
192
+
193
+ Returns:
194
+ Tuple of (explicit_promotable, extracted_promotable):
195
+ - explicit_promotable: handles that get FROM without AND topic
196
+ - extracted_promotable: handles that get FROM with AND topic
197
+ """
198
+ explicit_set = {
199
+ h.lower().lstrip("@")
200
+ for h in (explicit_handles or [])
201
+ if h and h.strip()
202
+ }
203
+
204
+ # Judge corpus to get handle stats
205
+ judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
206
+ handle_stats = judgment["handle_stats"]
207
+
208
+ explicit_promotable = []
209
+ extracted_promotable = []
210
+
211
+ for handle in extracted_handles:
212
+ handle_lower = handle.lower().lstrip("@")
213
+
214
+ # Explicit handles always promoted (no AND topic)
215
+ if handle_lower in explicit_set:
216
+ explicit_promotable.append(handle)
217
+ continue
218
+
219
+ # Check if handle qualifies for extracted promotion
220
+ stats = handle_stats.get(handle_lower, {"on_topic": 0, "total": 0})
221
+
222
+ # Need ≥MIN_ON_TOPIC_HITS on-topic posts
223
+ if stats["on_topic"] < MIN_ON_TOPIC_HITS:
224
+ continue
225
+
226
+ # Need ≥HANDLE_ON_TOPIC_FLOOR ratio
227
+ if stats["total"] > 0:
228
+ ratio = stats["on_topic"] / stats["total"]
229
+ if ratio >= HANDLE_ON_TOPIC_FLOOR:
230
+ extracted_promotable.append(handle)
231
+
232
+ # Also check explicit handles not in extracted list
233
+ for handle in (explicit_handles or []):
234
+ handle_lower = handle.lower().lstrip("@")
235
+ if handle_lower not in [h.lower() for h in explicit_promotable]:
236
+ if handle_lower not in [h.lower() for h in extracted_handles]:
237
+ explicit_promotable.append(handle)
238
+
239
+ return explicit_promotable, extracted_promotable
240
+
241
+
242
+ def should_retry_x_search(
243
+ items: List[Dict[str, Any]],
244
+ topic: str,
245
+ *,
246
+ ranking_query: str = "",
247
+ depth: str = "default",
248
+ ) -> bool:
249
+ """Determine if X search should retry with wider keyword query.
250
+
251
+ Skip retry on quick/mock (same as Phase 2).
252
+
253
+ Args:
254
+ items: Retrieved X items
255
+ topic: The user topic
256
+ ranking_query: Optional ranking query
257
+ depth: Search depth ("quick", "default", "deep")
258
+
259
+ Returns:
260
+ True if retry is warranted
261
+ """
262
+ if depth == "quick":
263
+ return False
264
+
265
+ if not items:
266
+ return False # Nothing to judge, no retry
267
+
268
+ judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
269
+ return judgment["is_off_topic_flood"]
270
+
271
+
272
+ def prune_off_topic_items(
273
+ items: List[Dict[str, Any]],
274
+ topic: str,
275
+ *,
276
+ ranking_query: str = "",
277
+ ) -> List[Dict[str, Any]]:
278
+ """Prune off-topic items before the pool.
279
+
280
+ Eight on-topic items with 32 pruned → ok with 8.
281
+ Zero on-topic → no-results, not ok with 40 junk.
282
+
283
+ Args:
284
+ items: Retrieved X items
285
+ topic: The user topic
286
+ ranking_query: Optional ranking query
287
+
288
+ Returns:
289
+ Only on-topic items
290
+ """
291
+ judgment = judge_x_corpus(items, topic, ranking_query=ranking_query)
292
+ return judgment["on_topic_items"]
@@ -5,7 +5,7 @@ import re
5
5
  import sys
6
6
  from typing import Any, Dict, List, Optional
7
7
 
8
- from . import http, log
8
+ from . import http, log, providers
9
9
 
10
10
 
11
11
  def _safe_text(val) -> str:
@@ -74,6 +74,8 @@ def search_x(
74
74
  to_date: str,
75
75
  depth: str = "default",
76
76
  mock_response: Optional[Dict] = None,
77
+ deadline_monotonic: Optional[float] = None,
78
+ cancel: Any = None,
77
79
  ) -> Dict[str, Any]:
78
80
  """Search X for relevant posts using xAI API with live search.
79
81
 
@@ -85,6 +87,14 @@ def search_x(
85
87
  to_date: End date (YYYY-MM-DD)
86
88
  depth: Research depth - "quick", "default", or "deep"
87
89
  mock_response: Mock response for testing
90
+ deadline_monotonic: Optional shared wall-clock deadline (``time.monotonic()``
91
+ instant) from the X backend chain. Bounds the whole call including
92
+ retries; without one this call owns its own timeout only.
93
+
94
+ The transport runs with ``retries=1`` (a global default change from the
95
+ inherited ``retries=5``): one 90-180s live-search call must not become a
96
+ 10-15 minute stall. DNS-resolution failures are the exception: the
97
+ transport widens those to three attempts.
88
98
 
89
99
  Returns:
90
100
  Raw API response
@@ -122,7 +132,20 @@ def search_x(
122
132
  ],
123
133
  }
124
134
 
125
- return http.post(XAI_RESPONSES_URL, payload, headers=headers, timeout=timeout)
135
+ # Honor XAI_BASE_URL like the planner/rerank client does (providers.py), so
136
+ # an override redirects every request that carries the xAI key, not only some.
137
+ url = providers.resolve_endpoint("XAI_BASE_URL", XAI_RESPONSES_URL)
138
+ # A single non-DNS attempt: the per-call timeout already spans the model's
139
+ # full live-search latency, and the inherited http default (retries=5)
140
+ # would turn one 90-180s call into a 10-15 minute stall. The shared chain
141
+ # deadline bounds the wait wall-clock; failover to the next X backend
142
+ # covers the miss.
143
+ return http.post(
144
+ url, payload, headers=headers, timeout=timeout,
145
+ retries=1, deadline_monotonic=deadline_monotonic,
146
+ cancel=cancel,
147
+ bypass_proxy=providers.is_loopback_http_endpoint(url),
148
+ )
126
149
 
127
150
 
128
151
  def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
@@ -141,7 +164,7 @@ def parse_x_response(response: Dict[str, Any]) -> List[Dict[str, Any]]:
141
164
  error = response["error"]
142
165
  err_msg = error.get("message", str(error)) if isinstance(error, dict) else str(error)
143
166
  _log_error(f"xAI API error: {err_msg}")
144
- if http.DEBUG:
167
+ if log.is_debug():
145
168
  _log_error(f"Full error response: {json.dumps(response, indent=2)[:1000]}")
146
169
  return items
147
170
 
@@ -7,11 +7,13 @@ bookmarks). Requires an API key from xquik.com.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import time
10
11
  from datetime import datetime
11
12
  from typing import Any, Dict, List, Optional
12
13
 
13
14
  from . import http, log
14
15
  from .relevance import token_overlap_relevance as _compute_relevance
16
+ from .x_api import is_own_post
15
17
 
16
18
  # Per-process probe cache: (state, reason). state is "unset" until probed, then
17
19
  # True (funded/working) | False (auth/payment failure) | None (inconclusive).
@@ -78,6 +80,8 @@ def search_xquik(
78
80
  to_date: str,
79
81
  depth: str = "default",
80
82
  token: str = "",
83
+ deadline: float | None = None,
84
+ cancel: Any = None,
81
85
  ) -> Dict[str, Any]:
82
86
  """Search X via Xquik REST API.
83
87
 
@@ -87,6 +91,10 @@ def search_xquik(
87
91
  to_date: End date (YYYY-MM-DD)
88
92
  depth: Research depth - "quick", "default", or "deep"
89
93
  token: Xquik API key
94
+ deadline: Optional shared wall-clock deadline (``time.monotonic()``
95
+ instant) from the X backend chain. Queries past the deadline are
96
+ never started; the per-request timeout/retry pair still bounds
97
+ each call (mirrors ``x_api``'s TIMEOUT_SECONDS/RETRIES pattern).
90
98
 
91
99
  Returns:
92
100
  Dict with "items" list and optional "error" string.
@@ -100,12 +108,17 @@ def search_xquik(
100
108
  seen_ids: set[str] = set()
101
109
 
102
110
  for query_text in queries:
111
+ if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
112
+ _log("chain deadline reached; skipping remaining queries")
113
+ return {"items": all_items, "error": "Xquik research cancelled or timed out"}
103
114
  q = f"{query_text} since:{from_date} until:{to_date}"
104
115
  items, auth_error = _execute_search(
105
116
  q, cfg["limit"], token,
106
117
  label=query_text, id_prefix="XQ",
107
118
  seen_ids=seen_ids, relevance_query=query_text,
108
119
  index_offset=len(all_items),
120
+ deadline_monotonic=deadline,
121
+ cancel=cancel,
109
122
  )
110
123
  if auth_error:
111
124
  # Auth/payment failure is fatal for the whole source (e.g. 401/403,
@@ -114,6 +127,8 @@ def search_xquik(
114
127
  return {"items": [], "error": auth_error}
115
128
  all_items.extend(items)
116
129
 
130
+ if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
131
+ return {"items": all_items, "error": "Xquik research cancelled or timed out"}
117
132
  return {"items": all_items}
118
133
 
119
134
 
@@ -127,38 +142,64 @@ def _execute_search(
127
142
  seen_ids: set[str],
128
143
  relevance_query: str,
129
144
  index_offset: int = 0,
145
+ deadline_monotonic: float | None = None,
146
+ failure_out: Optional[List[str]] = None,
147
+ cancel: Any = None,
130
148
  ) -> tuple[List[Dict[str, Any]], str | None]:
131
149
  """Run one Xquik search call and parse its tweets.
132
150
 
133
151
  Returns ``(items, auth_error)``. ``auth_error`` is a non-empty string only
134
152
  on a fatal auth/payment failure (401/403); transient/HTTP errors log and
135
153
  return ``([], None)`` so one bad lane never discards another's results.
154
+
155
+ Non-fatal failures (429, 5xx, network error, unparseable payload) append a
156
+ reason to ``failure_out`` instead. They stay out of the return value
157
+ because the caller must keep going to the next handle, but a caller that
158
+ stopped there would report the lane as empty rather than as failed.
136
159
  ``relevance_query`` (the topic) is what items are scored against — for the
137
160
  handle lanes that differs from the search query (``from:handle``).
138
161
  ``index_offset`` keeps item ids unique across multiple calls that share an
139
162
  accumulator (multi-query topic search, per-handle lanes).
163
+ ``deadline_monotonic`` bounds the request including retries at the
164
+ transport (mirrors ``x_api._get``).
140
165
  """
141
166
  full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit={limit}"
142
167
  _log(f"Searching: {label}")
143
168
  try:
144
- response = http.get(full_url, headers={"X-Api-Key": token}, timeout=30, retries=2)
169
+ request_headers = {"X-Api-Key": token}
170
+ response = http.get(
171
+ full_url, headers=request_headers, timeout=30, retries=2,
172
+ deadline_monotonic=deadline_monotonic,
173
+ cancel=cancel,
174
+ )
145
175
  except http.HTTPError as exc:
146
176
  status = getattr(exc, "status_code", None)
147
177
  if status == 402:
148
178
  # Unpaid key — fatal for the source, and surfaced on the real search
149
179
  # path (not just --diagnose) so a live run reports it instead of
150
- # settling silently empty.
151
- return [], "Xquik key unpaid (402)"
180
+ # settling silently empty. The X retrieval branch classifies by
181
+ # message text only, so the detail carries the "payment required"
182
+ # marker that http.classify_failure maps to PAYMENT_REQUIRED.
183
+ return [], "Xquik key unpaid: payment required (402)"
152
184
  if status in (401, 403):
153
185
  return [], f"Xquik auth failed ({status})"
154
186
  _log(f"HTTP error for '{label}': {exc}")
187
+ if failure_out is not None:
188
+ failure_out.append(f"Xquik HTTP error for '{label}': {exc}")
155
189
  return [], None
156
190
  except Exception as exc:
157
191
  _log(f"Error for '{label}': {exc}")
192
+ if failure_out is not None:
193
+ failure_out.append(f"Xquik request failed for '{label}': {exc}")
158
194
  return [], None
159
195
 
160
196
  tweets = response.get("tweets", [])
161
197
  if not isinstance(tweets, list):
198
+ if failure_out is not None:
199
+ failure_out.append(
200
+ f"Xquik returned no tweets array for '{label}' "
201
+ f"(got {type(tweets).__name__})"
202
+ )
162
203
  return [], None
163
204
  items: List[Dict[str, Any]] = []
164
205
  for tweet in tweets:
@@ -174,15 +215,10 @@ def _execute_search(
174
215
  return items, None
175
216
 
176
217
 
177
- def _is_own(url: str, handle: str) -> bool:
178
- """True when a tweet URL is authored by ``handle`` (their own post).
179
-
180
- Used by the ABOUT lane to drop the subject's own tweets so only mentions
181
- *by others* remain. Handles both x.com and twitter.com permalinks.
182
- """
183
- u = (url or "").lower()
184
- h = handle.lower().lstrip("@").strip()
185
- return bool(h) and (f"x.com/{h}/status" in u or f"twitter.com/{h}/status" in u)
218
+ # True when a tweet URL is authored by ``handle`` (their own post). Used by
219
+ # the ABOUT lane to drop the subject's own tweets so only mentions *by
220
+ # others* remain; the implementation is ``x_api.is_own_post``.
221
+ _is_own = is_own_post
186
222
 
187
223
 
188
224
  def search_handles(
@@ -193,18 +229,30 @@ def search_handles(
193
229
  *,
194
230
  count_per: int = 8,
195
231
  token: str = "",
232
+ failure_out: Optional[List[str]] = None,
233
+ deadline: float | None = None,
234
+ cancel: Any = None,
196
235
  ) -> List[Dict[str, Any]]:
197
236
  """FROM lane: tweets authored BY each handle (their own timeline).
198
237
 
199
238
  The topic is NOT AND'd into the query (that was the from:-AND bug, #610) —
200
239
  we pull the raw timeline and use ``topic`` for relevance ranking only.
201
240
  Returns a flat list of item dicts (mirrors ``bird_x.search_handles``).
241
+
242
+ When ``failure_out`` is provided, any failure reason is appended — the
243
+ fatal auth/payment one that stops the lane, and the per-handle transient
244
+ ones (429, 5xx, network, unparseable payload) that do not — so the caller
245
+ can distinguish a failed request from a handle that posted nothing.
202
246
  """
203
247
  if not token or not handles:
204
248
  return []
205
249
  items: List[Dict[str, Any]] = []
206
250
  seen_ids: set[str] = set()
207
251
  for raw in handles:
252
+ if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
253
+ if failure_out is not None:
254
+ failure_out.append("Xquik research cancelled or timed out")
255
+ break
208
256
  handle = str(raw).lstrip("@").strip()
209
257
  if not handle:
210
258
  continue
@@ -214,9 +262,17 @@ def search_handles(
214
262
  label=f"from:{handle}", id_prefix="XF",
215
263
  seen_ids=seen_ids, relevance_query=topic,
216
264
  index_offset=len(items),
265
+ failure_out=failure_out,
266
+ deadline_monotonic=deadline,
267
+ cancel=cancel,
217
268
  )
218
269
  if auth_error:
219
- break # fatal auth/payment failure — stop, keep what we have
270
+ # Fatal auth/payment failure — stop, keep what we have. Surface the
271
+ # reason: an empty FROM lane is otherwise indistinguishable from a
272
+ # subject who simply did not post.
273
+ if failure_out is not None:
274
+ failure_out.append(auth_error)
275
+ break
220
276
  items.extend(got)
221
277
  return items
222
278
 
@@ -229,17 +285,29 @@ def search_mentions(
229
285
  topic: str = "",
230
286
  count_per: int = 5,
231
287
  token: str = "",
288
+ failure_out: Optional[List[str]] = None,
289
+ deadline: float | None = None,
290
+ cancel: Any = None,
232
291
  ) -> List[Dict[str, Any]]:
233
292
  """ABOUT lane: tweets mentioning each handle, authored by OTHERS.
234
293
 
235
294
  Queries ``@handle`` then drops the handle's own tweets (``_is_own``) so only
236
295
  third-party mentions remain. Returns a flat list of item dicts.
296
+
297
+ When ``failure_out`` is provided, any failure reason is appended — the
298
+ fatal auth/payment one that stops the lane, and the per-handle transient
299
+ ones (429, 5xx, network, unparseable payload) that do not — so the caller
300
+ can distinguish a failed request from a handle nobody mentioned.
237
301
  """
238
302
  if not token or not handles:
239
303
  return []
240
304
  items: List[Dict[str, Any]] = []
241
305
  seen_ids: set[str] = set()
242
306
  for raw in handles:
307
+ if (cancel is not None and cancel.is_set()) or (deadline is not None and time.monotonic() >= deadline):
308
+ if failure_out is not None:
309
+ failure_out.append("Xquik research cancelled or timed out")
310
+ break
243
311
  handle = str(raw).lstrip("@").strip()
244
312
  if not handle:
245
313
  continue
@@ -249,8 +317,13 @@ def search_mentions(
249
317
  label=f"@{handle}", id_prefix="XA",
250
318
  seen_ids=seen_ids, relevance_query=topic,
251
319
  index_offset=len(items),
320
+ failure_out=failure_out,
321
+ deadline_monotonic=deadline,
322
+ cancel=cancel,
252
323
  )
253
324
  if auth_error:
325
+ if failure_out is not None:
326
+ failure_out.append(auth_error)
254
327
  break
255
328
  items.extend(it for it in got if not _is_own(it.get("url", ""), handle))
256
329
  return items
@@ -280,11 +353,12 @@ def probe_works(token: str, timeout: int = 8) -> Optional[bool]:
280
353
  q = f"from:x since:{since}"
281
354
  full_url = f"{_BASE_URL}/x/tweets/search?q={_url_encode(q)}&queryType=Top&limit=1"
282
355
  try:
283
- http.get(full_url, headers={"X-Api-Key": token}, timeout=timeout, retries=0)
356
+ request_headers = {"X-Api-Key": token}
357
+ http.get(full_url, headers=request_headers, timeout=timeout, retries=0)
284
358
  except http.HTTPError as exc:
285
359
  status = getattr(exc, "status_code", None)
286
360
  if status == 402:
287
- _probe_cache = (False, "xquik key unpaid (402)")
361
+ _probe_cache = (False, "xquik key unpaid: payment required (402)")
288
362
  elif status in (401, 403):
289
363
  _probe_cache = (False, f"xquik auth failed ({status})")
290
364
  else: