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
@@ -12,8 +12,8 @@ import sys
12
12
  import time
13
13
  from pathlib import Path
14
14
 
15
- from . import http, log, subproc
16
- from datetime import datetime
15
+ from . import env, health, http, log, subproc
16
+ from datetime import date, datetime, timedelta
17
17
  from typing import Any, Dict, List, Optional, Tuple
18
18
 
19
19
  from .relevance import token_overlap_relevance as _compute_relevance
@@ -54,6 +54,29 @@ DEPTH_CONFIG = {
54
54
  # Module-level credentials injected from .env config
55
55
  _credentials: Dict[str, str] = {}
56
56
 
57
+ # The vendored bird-search client reads exactly this env surface, and the
58
+ # node subprocess it spawns needs the node-runtime env (platform, locale,
59
+ # TLS/proxy config) to run in every environment it runs in today. Ambient
60
+ # BIRD_* vars pass through as well. Everything else in os.environ - unrelated
61
+ # API keys, tokens, .env contents - must not reach the scan-excluded vendored
62
+ # client (issue #1063). Mirrors the platform-var surface grok_x keeps for its
63
+ # node child (grok_x._subprocess_env).
64
+ _SUBPROCESS_ENV_ALLOWLIST = (
65
+ # Runtime / platform vars the node subprocess needs (mirrors grok_x)
66
+ "PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SystemRoot",
67
+ "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "SystemDrive", "COMSPEC",
68
+ "PATHEXT", "TEMP", "TMP",
69
+ # Node TLS / proxy / CA config for custom-CA and proxied environments
70
+ "NODE_ENV", "NODE_OPTIONS", "NODE_EXTRA_CA_CERTS",
71
+ "NODE_TLS_REJECT_UNAUTHORIZED", "NODE_USE_ENV_PROXY",
72
+ "SSL_CERT_FILE", "SSL_CERT_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
73
+ "http_proxy", "https_proxy", "no_proxy",
74
+ # X session cookies the vendored client reads from the environment
75
+ "AUTH_TOKEN", "CT0", "TWITTER_AUTH_TOKEN", "TWITTER_CT0",
76
+ # Browser-cookie disable flag the client reads (cookies.js envFlagEnabled)
77
+ "LAST30DAYS_DISABLE_BROWSER_COOKIES",
78
+ )
79
+
57
80
 
58
81
  def set_credentials(auth_token: Optional[str], ct0: Optional[str]):
59
82
  """Inject AUTH_TOKEN/CT0 from .env config so Node subprocesses can use them."""
@@ -70,12 +93,26 @@ def _has_injected_credentials() -> bool:
70
93
 
71
94
  def _has_process_credentials() -> bool:
72
95
  """Return True when AUTH_TOKEN/CT0 are present in process env."""
73
- return bool(os.environ.get("AUTH_TOKEN") and os.environ.get("CT0"))
96
+ return bool(env.read_secret_env("AUTH_TOKEN") and env.read_secret_env("CT0"))
74
97
 
75
98
 
76
99
  def _subprocess_env() -> Dict[str, str]:
77
- """Build env dict for Node subprocesses, merging injected credentials."""
78
- env = os.environ.copy()
100
+ """Build env dict for Node subprocesses, merging injected credentials.
101
+
102
+ The child env is limited to the vendored client's env surface (see
103
+ ``_SUBPROCESS_ENV_ALLOWLIST``) plus injected credentials, so unrelated
104
+ ambient secrets never reach scan-excluded vendored code (issue #1063).
105
+ The ambient-credential lane behaves exactly as before.
106
+ """
107
+ env = {
108
+ name: os.environ[name]
109
+ for name in _SUBPROCESS_ENV_ALLOWLIST
110
+ if name in os.environ
111
+ }
112
+ env.update({
113
+ key: value for key, value in os.environ.items()
114
+ if key.startswith("BIRD_")
115
+ })
79
116
  env.update(_credentials)
80
117
  # Hard-disable browser-cookie fallback so normal pipeline runs never hit
81
118
  # Safari/Chrome Keychain prompts during source detection or search.
@@ -87,6 +124,38 @@ def _log(msg: str):
87
124
  log.source_log("Bird", msg, tty_only=False)
88
125
 
89
126
 
127
+ def _scrub_credentials(text: str) -> str:
128
+ """Redact X session cookie values from subprocess output.
129
+
130
+ A failure reason built from bird-search's stderr reaches the run's
131
+ ``source_status`` detail, which is rendered in the report and returned by
132
+ ``--emit=json``. The vendored client receives AUTH_TOKEN/CT0 in its
133
+ environment, so an error message that echoes a rejected cookie would
134
+ otherwise carry it into user-facing output. Log lines get the same
135
+ treatment because stderr is captured in agent harnesses.
136
+ """
137
+ scrubbed = text
138
+ for name in ("AUTH_TOKEN", "CT0", "TWITTER_AUTH_TOKEN", "TWITTER_CT0"):
139
+ value = _credentials.get(name) or os.environ.get(name)
140
+ # Short values would match too much ordinary text to be worth it.
141
+ if value and len(value) >= 8:
142
+ scrubbed = scrubbed.replace(value, "<redacted>")
143
+ return scrubbed
144
+
145
+
146
+ def classify_run_failure(detail: str) -> str:
147
+ """Map Bird's subprocess-only failure shapes to run outcome states."""
148
+ text = detail.lower()
149
+ if any(marker in text for marker in ("interstitial", "non-json", "invalid json")):
150
+ return health.SCHEMA_DRIFT
151
+ if any(
152
+ marker in text
153
+ for marker in ("cookie expired", "expired cookie", "unauthorized", "forbidden", "login required")
154
+ ):
155
+ return health.AUTH_FAILED
156
+ return http.classify_failure(message=detail)
157
+
158
+
90
159
  def _extract_core_subject(topic: str) -> str:
91
160
  """Extract core subject from verbose query for X search.
92
161
 
@@ -98,6 +167,68 @@ def _extract_core_subject(topic: str) -> str:
98
167
  return extract_core_subject(topic, max_words=5, strip_suffixes=True)
99
168
 
100
169
 
170
+ def _plain_query_tokens(text: str) -> list[str]:
171
+ """Return lexical tokens without Bird query grouping syntax.
172
+
173
+ Strips phrase quotes as well as grouping characters. Used where a flat
174
+ token list is wanted; use ``build_topic_query`` for the provider query,
175
+ which preserves quoted phrases.
176
+ """
177
+ separators = str.maketrans({char: " " for char in '\"“”()[]{}'})
178
+ return [
179
+ clean
180
+ for token in text.translate(separators).split()
181
+ if (clean := token.strip("'‘’"))
182
+ ]
183
+
184
+
185
+ # Bird/X grouping syntax that carries no lexical meaning. Double quotes are
186
+ # deliberately absent: X advanced search treats "..." as a phrase match, which
187
+ # is exactly what the planner intended when it quoted a proper noun.
188
+ _GROUPING_CHARS = "“”()[]{}"
189
+
190
+
191
+ def _date_filters(from_date: str, to_date: Optional[str] = None) -> str:
192
+ filters = f"since:{from_date}"
193
+ if to_date is not None:
194
+ end = date.fromisoformat(to_date)
195
+ if end == date.max:
196
+ # No supported post date lies beyond this inclusive upper bound.
197
+ return filters
198
+ # The research window includes to_date; X's until bound is exclusive.
199
+ until = end + timedelta(days=1)
200
+ filters += f" until:{until.isoformat()}"
201
+ return filters
202
+
203
+
204
+ def build_topic_query(
205
+ topic: str, from_date: str, to_date: Optional[str] = None
206
+ ) -> str:
207
+ """Build the X topic query, preserving quoted proper-noun phrases.
208
+
209
+ Previously the topic went through ``_plain_query_tokens``, which stripped
210
+ the quotes the planner had added, so an intended phrase match for
211
+ '"Peter Steinberger"' degraded into `peter AND steinberger` -- narrower and
212
+ noisier at once. X supports phrase queries natively, so the quotes are
213
+ passed through.
214
+ """
215
+ separators = str.maketrans({char: " " for char in _GROUPING_CHARS})
216
+ cleaned = topic.translate(separators)
217
+ # An unbalanced quote is worse than no quote: X reads the orphan as an
218
+ # unterminated phrase and matches nothing. Upstream trimming (core-subject
219
+ # extraction, retry shortening) can cut a topic mid-phrase, so verify the
220
+ # quotes pair up and fall back to bare tokens when they do not.
221
+ if cleaned.count('"') % 2:
222
+ cleaned = cleaned.replace('"', " ")
223
+ tokens = [
224
+ clean
225
+ for token in cleaned.split()
226
+ if (clean := token.strip("'‘’"))
227
+ ]
228
+ core = " ".join(tokens).strip()
229
+ return " ".join(part for part in (core, _date_filters(from_date, to_date)) if part)
230
+
231
+
101
232
  def is_bird_installed() -> bool:
102
233
  """Check if vendored Bird search module is available.
103
234
 
@@ -214,39 +345,43 @@ def _invoke_bird_subprocess(query: str, count: int, timeout: int):
214
345
  "--json",
215
346
  ]
216
347
 
217
- pid_holder: list[int] = []
218
-
219
- def _register(pid: int) -> None:
220
- pid_holder.append(pid)
221
- try:
222
- from last30days import register_child_pid
223
- register_child_pid(pid)
224
- except ImportError:
225
- pass
226
-
227
348
  try:
228
349
  result = subproc.run_with_timeout(
229
350
  cmd,
230
351
  timeout=timeout,
231
352
  env=_subprocess_env(),
232
- on_pid=_register,
233
353
  )
234
354
  except subproc.SubprocTimeout:
235
355
  return None, {"error": f"Search timed out after {timeout}s", "items": []}
236
356
  except Exception as e:
237
357
  return None, {"error": str(e), "items": []}
238
- finally:
239
- if pid_holder:
240
- try:
241
- from last30days import unregister_child_pid
242
- unregister_child_pid(pid_holder[0])
243
- except Exception:
244
- pass
245
358
 
246
359
  return result, None
247
360
 
248
361
 
249
- def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
362
+ def _invalid_json_error(
363
+ attempts: int,
364
+ decode_error: Optional[str],
365
+ budget_exhausted: bool = False,
366
+ ) -> Dict[str, Any]:
367
+ noun = "attempt" if attempts == 1 else "attempts"
368
+ skipped = ", retry skipped: chain budget exhausted" if budget_exhausted else ""
369
+ return {
370
+ "error": (
371
+ f"Invalid JSON response after {attempts} {noun}{skipped} "
372
+ f"(likely Twitter anti-bot interstitial): {decode_error}"
373
+ ),
374
+ "items": [],
375
+ }
376
+
377
+
378
+ def _run_bird_search(
379
+ query: str,
380
+ count: int,
381
+ timeout: int,
382
+ deadline: Optional[float] = None,
383
+ cancel: Any = None,
384
+ ) -> Dict[str, Any]:
250
385
  """Run a search using the vendored bird-search.mjs module.
251
386
 
252
387
  Retries the subprocess on JSON-decode failure (typically a Twitter
@@ -259,22 +394,42 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
259
394
  query: Full search query string (including since: filter)
260
395
  count: Number of results to request
261
396
  timeout: Timeout in seconds (per attempt)
397
+ deadline: Optional shared ``time.monotonic()`` deadline. A decode
398
+ retry runs only when the delay plus at least one second of
399
+ subprocess time still fits; its timeout is re-clamped after the
400
+ delay.
262
401
 
263
402
  Returns:
264
403
  Raw Bird JSON response or error dict.
265
404
  """
266
405
  last_decode_error: Optional[str] = None
406
+ attempt_timeout: Optional[int] = timeout
267
407
 
268
408
  for attempt in range(MAX_JSON_DECODE_RETRIES):
269
- result, terminal_error = _invoke_bird_subprocess(query, count, timeout)
409
+ if cancel is not None and cancel.is_set():
410
+ return {"error": "bird: research cancelled", "items": []}
411
+ if attempt > 0:
412
+ attempt_timeout = _clamped_bird_timeout(timeout, deadline)
413
+ if attempt_timeout is None:
414
+ return _invalid_json_error(
415
+ attempt, last_decode_error, budget_exhausted=True,
416
+ )
417
+ result, terminal_error = _invoke_bird_subprocess(query, count, attempt_timeout)
270
418
  if terminal_error is not None:
271
419
  return terminal_error
272
420
 
421
+ output = result.stdout.strip()
273
422
  if result.returncode != 0:
274
- error = result.stderr.strip() or "Bird search failed"
275
- return {"error": error, "items": []}
423
+ if not output:
424
+ error = result.stderr.strip() or "Bird search failed"
425
+ return {"error": error, "items": []}
426
+ # Windows/Node 24: the vendored Bird CLI uses native fetch (undici),
427
+ # and calling process.exit() while keep-alive sockets are still
428
+ # closing trips a libuv assertion -> non-zero exit code AFTER it has
429
+ # already written a complete, valid JSON result to stdout. Trust
430
+ # stdout when it has content; only treat a non-zero exit as a real
431
+ # failure when stdout is empty.
276
432
 
277
- output = result.stdout.strip()
278
433
  if not output:
279
434
  return {"items": []}
280
435
 
@@ -293,21 +448,29 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
293
448
  )
294
449
  last_decode_error = str(e)
295
450
  if attempt_num < MAX_JSON_DECODE_RETRIES:
451
+ if deadline is not None and (
452
+ deadline - time.monotonic() < JSON_DECODE_RETRY_DELAY + 1
453
+ ):
454
+ log.source_log(
455
+ "X/bird",
456
+ f"{log_msg}; retry skipped, chain budget exhausted",
457
+ tty_only=False,
458
+ )
459
+ return _invalid_json_error(
460
+ attempt_num, last_decode_error, budget_exhausted=True,
461
+ )
296
462
  log.source_log(
297
463
  "X/bird",
298
464
  f"{log_msg}; retrying in {JSON_DECODE_RETRY_DELAY:.0f}s",
299
465
  tty_only=False,
300
466
  )
301
- time.sleep(JSON_DECODE_RETRY_DELAY)
467
+ if cancel is not None:
468
+ cancel.wait(JSON_DECODE_RETRY_DELAY)
469
+ else:
470
+ time.sleep(JSON_DECODE_RETRY_DELAY)
302
471
  continue
303
472
  log.source_log("X/bird", log_msg, tty_only=False)
304
- return {
305
- "error": (
306
- f"Invalid JSON response after {MAX_JSON_DECODE_RETRIES} attempts "
307
- f"(likely Twitter anti-bot interstitial): {e}"
308
- ),
309
- "items": [],
310
- }
473
+ return _invalid_json_error(attempt_num, last_decode_error)
311
474
 
312
475
  if isinstance(parsed, list):
313
476
  return {"items": parsed}
@@ -320,38 +483,84 @@ def _run_bird_search(query: str, count: int, timeout: int) -> Dict[str, Any]:
320
483
  }
321
484
 
322
485
 
486
+ def _clamped_bird_timeout(base: int, deadline: Optional[float]) -> Optional[int]:
487
+ """Per-attempt timeout clamped to the chain's remaining budget.
488
+
489
+ Returns None when the deadline already passed so the caller skips the
490
+ subprocess instead of starting a call guaranteed to overrun the chain.
491
+ """
492
+ if deadline is None:
493
+ return base
494
+ remaining = deadline - time.monotonic()
495
+ if remaining < 1:
496
+ return None
497
+ return max(1, min(base, int(remaining)))
498
+
499
+
500
+ def _budget_stop(
501
+ response: Dict[str, Any],
502
+ last_clean_response: Optional[Dict[str, Any]],
503
+ ) -> Dict[str, Any]:
504
+ """Outcome when an optional zero-result retry no longer fits the budget.
505
+
506
+ A clean empty response stays a no-results outcome with a coverage warning;
507
+ otherwise the earlier search's own error (interstitial, timeout) is more
508
+ diagnostic than a generic budget message.
509
+ """
510
+ _log("chain budget exhausted; skipping remaining zero-result retries")
511
+ if last_clean_response is not None:
512
+ return {
513
+ **last_clean_response,
514
+ "warning": "Partial coverage: optional zero-result retries skipped because the search budget was exhausted.",
515
+ }
516
+ return response
517
+
518
+
323
519
  def search_x(
324
520
  topic: str,
325
521
  from_date: str,
326
522
  to_date: str,
327
523
  depth: str = "default",
524
+ deadline: Optional[float] = None,
525
+ cancel: Any = None,
328
526
  ) -> Dict[str, Any]:
329
527
  """Search X using Bird CLI with automatic retry on 0 results.
330
528
 
331
529
  Args:
332
530
  topic: Search topic
333
531
  from_date: Start date (YYYY-MM-DD)
334
- to_date: End date (YYYY-MM-DD) - unused but kept for API compatibility
532
+ to_date: Inclusive end date (YYYY-MM-DD)
335
533
  depth: Research depth - "quick", "default", or "deep"
534
+ deadline: Optional shared wall-clock deadline (``time.monotonic()``
535
+ instant) from the X backend chain. Each of the up-to-four
536
+ sequential searches clamps its per-attempt timeout to the time
537
+ left; searches past the deadline never start.
336
538
 
337
539
  Returns:
338
540
  Raw Bird JSON response or error dict.
339
541
  """
340
542
  count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
341
- timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
543
+ cancel_kwargs = {"cancel": cancel} if cancel is not None else {}
544
+ base_timeout = 30 if depth == "quick" else 45 if depth == "default" else 60
545
+ timeout = _clamped_bird_timeout(base_timeout, deadline)
546
+ if timeout is None:
547
+ return {"error": "bird: chain budget exhausted", "items": []}
342
548
 
343
549
  # Extract core subject - X search is literal, not semantic
344
- core_topic = _extract_core_subject(topic)
345
- query = f"{core_topic} since:{from_date}"
550
+ core_subject = _extract_core_subject(topic)
551
+ core_words = _plain_query_tokens(core_subject)
552
+ core_topic = " ".join(core_words)
553
+ query = build_topic_query(core_subject, from_date, to_date)
554
+ date_filters = _date_filters(from_date, to_date)
346
555
 
347
556
  _log(f"Searching: {query}")
348
- response = _run_bird_search(query, count, timeout)
557
+ response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
558
+ last_clean_response = response if not response.get("error") else None
349
559
 
350
560
  # Check if we got results
351
561
  items = parse_bird_response(response, query=core_topic)
352
562
 
353
563
  # Retry with OR groups for multi-word queries (X supports OR operator)
354
- core_words = core_topic.split()
355
564
  if not items and len(core_words) >= 2:
356
565
  from .query import extract_compound_terms
357
566
  compounds = extract_compound_terms(topic)
@@ -359,16 +568,26 @@ def search_x(
359
568
  # Build OR-group query: ("multi-agent" OR "agent simulation") since:DATE
360
569
  or_parts = ' OR '.join(f'"{t}"' for t in compounds[:3])
361
570
  _log(f"0 results for '{core_topic}', retrying with OR groups: {or_parts}")
362
- query = f"({or_parts}) since:{from_date}"
363
- response = _run_bird_search(query, count, timeout)
571
+ query = f"({or_parts}) {date_filters}"
572
+ timeout = _clamped_bird_timeout(base_timeout, deadline)
573
+ if timeout is None:
574
+ return _budget_stop(response, last_clean_response)
575
+ response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
576
+ if not response.get("error"):
577
+ last_clean_response = response
364
578
  items = parse_bird_response(response, query=core_topic)
365
579
 
366
580
  # Retry with fewer keywords if still 0 results and query has 3+ words
367
581
  if not items and len(core_words) > 2:
368
582
  shorter = ' '.join(core_words[:2])
369
583
  _log(f"0 results for '{core_topic}', retrying with '{shorter}'")
370
- query = f"{shorter} since:{from_date}"
371
- response = _run_bird_search(query, count, timeout)
584
+ query = f"{shorter} {date_filters}"
585
+ timeout = _clamped_bird_timeout(base_timeout, deadline)
586
+ if timeout is None:
587
+ return _budget_stop(response, last_clean_response)
588
+ response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
589
+ if not response.get("error"):
590
+ last_clean_response = response
372
591
  items = parse_bird_response(response, query=core_topic)
373
592
 
374
593
  # Last-chance retry: use strongest remaining token (often the product name)
@@ -390,9 +609,19 @@ def search_x(
390
609
  strongest = max(candidates, key=len)
391
610
  retry_terms = anchor if strongest == anchor else f"{anchor} {strongest}"
392
611
  _log(f"0 results for '{core_topic}', retrying anchored on '{retry_terms}'")
393
- query = f"{retry_terms} since:{from_date}"
394
- response = _run_bird_search(query, count, timeout)
395
-
612
+ query = f"{retry_terms} {date_filters}"
613
+ timeout = _clamped_bird_timeout(base_timeout, deadline)
614
+ if timeout is None:
615
+ return _budget_stop(response, last_clean_response)
616
+ response = _run_bird_search(query, count, timeout, deadline=deadline, **cancel_kwargs)
617
+ if not response.get("error"):
618
+ last_clean_response = response
619
+
620
+ if cancel is not None and cancel.is_set():
621
+ return {**response, "error": "bird: research cancelled"}
622
+ if response.get("error") and last_clean_response is not None:
623
+ _log("Optional retry failed after a clean empty response; preserving no-results outcome")
624
+ return last_clean_response
396
625
  return response
397
626
 
398
627
 
@@ -401,6 +630,11 @@ def search_handles(
401
630
  topic: Optional[str],
402
631
  from_date: str,
403
632
  count_per: int = 5,
633
+ failure_out: Optional[List[str]] = None,
634
+ *,
635
+ to_date: Optional[str] = None,
636
+ deadline: Optional[float] = None,
637
+ cancel: Any = None,
404
638
  ) -> List[Dict[str, Any]]:
405
639
  """Search specific X handles for topic-related content.
406
640
 
@@ -415,16 +649,29 @@ def search_handles(
415
649
  topic: Search topic — used for relevance ranking only, not the query
416
650
  from_date: Start date (YYYY-MM-DD)
417
651
  count_per: Results to request per handle
652
+ failure_out: When provided, a short reason is appended for every
653
+ per-handle failure branch, so the caller can distinguish a
654
+ transport failure from a handle that genuinely posted nothing.
655
+ to_date: Inclusive end date (YYYY-MM-DD), when supplied
418
656
 
419
657
  Returns:
420
658
  List of raw item dicts (same format as parse_bird_response output).
421
659
  """
422
660
  core_topic = _extract_core_subject(topic) if topic else None
661
+ date_filters = _date_filters(from_date, to_date)
662
+
663
+ def _note(msg: str) -> None:
664
+ if failure_out is not None:
665
+ failure_out.append(msg)
423
666
 
424
667
  def _search_one_handle(handle: str) -> List[Dict[str, Any]]:
668
+ timeout = _clamped_bird_timeout(15, deadline)
669
+ if timeout is None or (cancel is not None and cancel.is_set()):
670
+ _note("bird handle research cancelled or timed out")
671
+ return []
425
672
  handle = handle.lstrip("@")
426
673
  # Always unfiltered: pull the timeline, rank by topic relevance below.
427
- query = f"from:{handle} since:{from_date}"
674
+ query = f"from:{handle} {date_filters}"
428
675
 
429
676
  cmd = [
430
677
  "node", str(_BIRD_SEARCH_MJS),
@@ -434,19 +681,28 @@ def search_handles(
434
681
  ]
435
682
 
436
683
  try:
437
- result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env())
684
+ result = subproc.run_with_timeout(cmd, timeout=timeout, env=_subprocess_env())
438
685
  except subproc.SubprocTimeout:
439
686
  _log(f"Handle search timed out for @{handle}")
687
+ _note(f"@{handle}: bird-search timed out after 15s")
440
688
  return []
441
689
  except OSError as e:
442
690
  _log(f"Handle search error for @{handle}: {e}")
691
+ _note(f"@{handle}: could not spawn bird-search ({e})")
443
692
  return []
444
693
 
694
+ output = result.stdout.strip()
445
695
  if result.returncode != 0:
446
- _log(f"Handle search failed for @{handle}: {result.stderr.strip()}")
447
- return []
696
+ if not output:
697
+ _log(f"Handle search failed for @{handle}: {_scrub_credentials(result.stderr.strip())}")
698
+ _note(
699
+ f"@{handle}: bird-search exited {result.returncode} "
700
+ f"({_scrub_credentials(result.stderr.strip())[:160] or 'no stderr'})"
701
+ )
702
+ return []
703
+ # Windows/Node 24: benign libuv assertion can cause non-zero exit
704
+ # AFTER valid JSON is written to stdout. Trust stdout content.
448
705
 
449
- output = result.stdout.strip()
450
706
  if not output:
451
707
  return []
452
708
 
@@ -454,6 +710,7 @@ def search_handles(
454
710
  response = json.loads(output)
455
711
  except json.JSONDecodeError:
456
712
  _log(f"Invalid JSON from handle search for @{handle}")
713
+ _note(f"@{handle}: bird-search returned invalid JSON")
457
714
  return []
458
715
  items = parse_bird_response(response, query=core_topic)
459
716
  # Log on success/empty too (not only on failure): a silent handle search
@@ -476,6 +733,11 @@ def search_mentions(
476
733
  handles: List[str],
477
734
  from_date: str,
478
735
  count_per: int = 5,
736
+ failure_out: Optional[List[str]] = None,
737
+ *,
738
+ to_date: Optional[str] = None,
739
+ deadline: Optional[float] = None,
740
+ cancel: Any = None,
479
741
  ) -> List[Dict[str, Any]]:
480
742
  """Search for tweets ABOUT/TO each handle — the mention lane.
481
743
 
@@ -488,13 +750,27 @@ def search_mentions(
488
750
  handles: List of X handles (without @)
489
751
  from_date: Start date (YYYY-MM-DD)
490
752
  count_per: Results to request per handle
753
+ failure_out: When provided, a short reason is appended for every
754
+ per-handle failure branch, so the caller can distinguish a
755
+ transport failure from a handle nobody mentioned.
756
+ to_date: Inclusive end date (YYYY-MM-DD), when supplied
491
757
 
492
758
  Returns:
493
759
  List of raw item dicts (same format as parse_bird_response output).
494
760
  """
761
+ date_filters = _date_filters(from_date, to_date)
762
+
763
+ def _note(msg: str) -> None:
764
+ if failure_out is not None:
765
+ failure_out.append(msg)
766
+
495
767
  def _search_one(handle: str) -> List[Dict[str, Any]]:
768
+ timeout = _clamped_bird_timeout(15, deadline)
769
+ if timeout is None or (cancel is not None and cancel.is_set()):
770
+ _note("bird mention research cancelled or timed out")
771
+ return []
496
772
  handle = handle.lstrip("@")
497
- query = f"@{handle} since:{from_date}"
773
+ query = f"@{handle} {date_filters}"
498
774
  cmd = [
499
775
  "node", str(_BIRD_SEARCH_MJS),
500
776
  query,
@@ -502,15 +778,21 @@ def search_mentions(
502
778
  "--json",
503
779
  ]
504
780
  try:
505
- result = subproc.run_with_timeout(cmd, timeout=15, env=_subprocess_env())
781
+ result = subproc.run_with_timeout(cmd, timeout=timeout, env=_subprocess_env())
506
782
  except subproc.SubprocTimeout:
507
783
  _log(f"Mention search timed out for @{handle}")
784
+ _note(f"@{handle}: bird-search timed out after 15s")
508
785
  return []
509
786
  except OSError as e:
510
787
  _log(f"Mention search error for @{handle}: {e}")
788
+ _note(f"@{handle}: could not spawn bird-search ({e})")
511
789
  return []
512
790
  if result.returncode != 0:
513
- _log(f"Mention search failed for @{handle}: {result.stderr.strip()}")
791
+ _log(f"Mention search failed for @{handle}: {_scrub_credentials(result.stderr.strip())}")
792
+ _note(
793
+ f"@{handle}: bird-search exited {result.returncode} "
794
+ f"({_scrub_credentials(result.stderr.strip())[:160] or 'no stderr'})"
795
+ )
514
796
  return []
515
797
  output = result.stdout.strip()
516
798
  if not output:
@@ -519,6 +801,7 @@ def search_mentions(
519
801
  response = json.loads(output)
520
802
  except json.JSONDecodeError:
521
803
  _log(f"Invalid JSON from mention search for @{handle}")
804
+ _note(f"@{handle}: bird-search returned invalid JSON")
522
805
  return []
523
806
  items = parse_bird_response(response, query=None)
524
807
  # ABOUT lane = OTHERS mentioning the handle. Drop the handle's own tweets