luckiest-co 1.0.47 → 1.0.49

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 (274) 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/model-router/CHANGELOG.md +6 -0
  81. package/skills/model-router/SKILL.md +3 -3
  82. package/skills/model-router/scripts/route.py +1 -1
  83. package/skills/model-router/scripts/test_route.py +5 -0
  84. package/skills/pricing/CHANGELOG.md +5 -0
  85. package/skills/pricing/SKILL.md +4 -4
  86. package/skills/pricing/evals/evals.json +12 -0
  87. package/skills/pricing/references/research-methods.md +8 -4
  88. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  89. package/skills/programmatic-seo/SKILL.md +8 -4
  90. package/skills/programmatic-seo/evals/evals.json +13 -0
  91. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  92. package/skills/prospecting/CHANGELOG.md +7 -0
  93. package/skills/prospecting/SKILL.md +27 -13
  94. package/skills/prospecting/evals/evals.json +41 -0
  95. package/skills/prospecting/references/account-research.md +59 -0
  96. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  97. package/skills/prospecting/references/compliance.md +44 -6
  98. package/skills/prospecting/references/data-sources.md +13 -15
  99. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  100. package/skills/prospecting/references/saas-prospecting.md +1 -1
  101. package/skills/prospecting/references/signal-plays.md +99 -0
  102. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  103. package/skills/referrals/CHANGELOG.md +5 -0
  104. package/skills/referrals/SKILL.md +10 -4
  105. package/skills/referrals/evals/evals.json +99 -0
  106. package/skills/referrals/references/affiliate-operations.md +64 -0
  107. package/skills/referrals/references/affiliate-programs.md +2 -2
  108. package/skills/referrals/references/program-examples.md +14 -6
  109. package/skills/referrals/references/review-platform-rules.md +36 -0
  110. package/skills/referrals/references/review-program-operations.md +62 -0
  111. package/skills/referrals/references/review-programs.md +96 -0
  112. package/skills/revops/CHANGELOG.md +5 -0
  113. package/skills/revops/SKILL.md +10 -6
  114. package/skills/revops/evals/evals.json +13 -0
  115. package/skills/revops/references/automation-playbooks.md +1 -1
  116. package/skills/revops/references/inbound-routing.md +43 -0
  117. package/skills/revops/references/lifecycle-definitions.md +31 -0
  118. package/skills/sales-enablement/CHANGELOG.md +5 -0
  119. package/skills/sales-enablement/SKILL.md +6 -4
  120. package/skills/sales-enablement/evals/evals.json +14 -0
  121. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  122. package/skills/schema/CHANGELOG.md +5 -0
  123. package/skills/schema/SKILL.md +20 -8
  124. package/skills/schema/evals/evals.json +40 -0
  125. package/skills/schema/references/schema-examples.md +9 -15
  126. package/skills/seo-audit/CHANGELOG.md +5 -0
  127. package/skills/seo-audit/SKILL.md +10 -16
  128. package/skills/seo-audit/evals/evals.json +68 -0
  129. package/skills/seo-audit/references/international-seo.md +9 -11
  130. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  131. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  132. package/skills/seo-audit/references/local-seo.md +86 -0
  133. package/skills/seo-audit/references/rankings-push.md +62 -0
  134. package/skills/seo-audit/references/title-tags.md +89 -0
  135. package/skills/signup/CHANGELOG.md +5 -0
  136. package/skills/signup/SKILL.md +7 -3
  137. package/skills/site-architecture/CHANGELOG.md +5 -0
  138. package/skills/site-architecture/SKILL.md +5 -4
  139. package/skills/site-architecture/evals/evals.json +25 -0
  140. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  141. package/skills/social/CHANGELOG.md +5 -0
  142. package/skills/social/SKILL.md +4 -4
  143. package/skills/trends/.skillignore +3 -0
  144. package/skills/trends/CHANGELOG.md +20 -0
  145. package/skills/trends/SKILL.md +12 -8
  146. package/skills/trends/references/agent-mode.md +36 -0
  147. package/skills/trends/references/category-peers.md +38 -0
  148. package/skills/trends/references/comparison.md +227 -0
  149. package/skills/trends/references/competitors.md +29 -0
  150. package/skills/trends/references/discovery.md +90 -0
  151. package/skills/trends/references/engine-contract.md +111 -1889
  152. package/skills/trends/references/followups.md +112 -0
  153. package/skills/trends/references/grok-bot-x.md +55 -0
  154. package/skills/trends/references/hiring-signals.md +19 -0
  155. package/skills/trends/references/library-queue.md +33 -0
  156. package/skills/trends/references/recommendations.md +86 -0
  157. package/skills/trends/references/research-runbook.md +781 -0
  158. package/skills/trends/references/runtime.md +165 -0
  159. package/skills/trends/references/save-html-brief.md +39 -20
  160. package/skills/trends/references/setup-wizard.md +253 -0
  161. package/skills/trends/references/synthesis.md +289 -0
  162. package/skills/trends/scripts/box_chrome_login.py +209 -0
  163. package/skills/trends/scripts/briefing.py +9 -17
  164. package/skills/trends/scripts/last30days.py +3247 -280
  165. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  166. package/skills/trends/scripts/lib/amazon.py +877 -0
  167. package/skills/trends/scripts/lib/arxiv.py +42 -8
  168. package/skills/trends/scripts/lib/backends.py +769 -0
  169. package/skills/trends/scripts/lib/bird_x.py +340 -57
  170. package/skills/trends/scripts/lib/bluesky.py +84 -10
  171. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  172. package/skills/trends/scripts/lib/brightdata.py +291 -0
  173. package/skills/trends/scripts/lib/categories.py +7 -1
  174. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  175. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  176. package/skills/trends/scripts/lib/cluster.py +31 -54
  177. package/skills/trends/scripts/lib/competitors.py +10 -1
  178. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  179. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  180. package/skills/trends/scripts/lib/corpus.py +439 -0
  181. package/skills/trends/scripts/lib/devto.py +313 -0
  182. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  183. package/skills/trends/scripts/lib/doctor.py +2166 -0
  184. package/skills/trends/scripts/lib/dripstack.py +203 -0
  185. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  186. package/skills/trends/scripts/lib/env.py +1092 -138
  187. package/skills/trends/scripts/lib/fanout.py +11 -1
  188. package/skills/trends/scripts/lib/feed.py +61 -0
  189. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  190. package/skills/trends/scripts/lib/freshness.py +566 -0
  191. package/skills/trends/scripts/lib/fusion.py +314 -28
  192. package/skills/trends/scripts/lib/github.py +438 -24
  193. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  194. package/skills/trends/scripts/lib/grounding.py +129 -11
  195. package/skills/trends/scripts/lib/hackernews.py +61 -4
  196. package/skills/trends/scripts/lib/health.py +373 -0
  197. package/skills/trends/scripts/lib/hosted.py +333 -0
  198. package/skills/trends/scripts/lib/html_publish.py +34 -0
  199. package/skills/trends/scripts/lib/html_render.py +169 -3
  200. package/skills/trends/scripts/lib/http.py +1215 -70
  201. package/skills/trends/scripts/lib/instagram.py +132 -9
  202. package/skills/trends/scripts/lib/jobs.py +4 -2
  203. package/skills/trends/scripts/lib/library.py +308 -0
  204. package/skills/trends/scripts/lib/library_index.py +477 -0
  205. package/skills/trends/scripts/lib/linkedin.py +271 -24
  206. package/skills/trends/scripts/lib/log.py +5 -2
  207. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  208. package/skills/trends/scripts/lib/normalize.py +382 -28
  209. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  210. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  211. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  212. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  213. package/skills/trends/scripts/lib/planner.py +396 -53
  214. package/skills/trends/scripts/lib/polymarket.py +285 -23
  215. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  216. package/skills/trends/scripts/lib/private_files.py +21 -0
  217. package/skills/trends/scripts/lib/providers.py +231 -10
  218. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  219. package/skills/trends/scripts/lib/reddit.py +177 -12
  220. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  221. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  222. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  223. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  224. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  225. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  226. package/skills/trends/scripts/lib/registers.py +134 -0
  227. package/skills/trends/scripts/lib/relevance.py +17 -0
  228. package/skills/trends/scripts/lib/render.py +2161 -271
  229. package/skills/trends/scripts/lib/rerank.py +623 -55
  230. package/skills/trends/scripts/lib/resolve.py +50 -1
  231. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  232. package/skills/trends/scripts/lib/schema.py +782 -1
  233. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  234. package/skills/trends/scripts/lib/signals.py +206 -7
  235. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  236. package/skills/trends/scripts/lib/subproc.py +307 -53
  237. package/skills/trends/scripts/lib/techmeme.py +89 -64
  238. package/skills/trends/scripts/lib/telegram.py +317 -0
  239. package/skills/trends/scripts/lib/tiktok.py +13 -9
  240. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  241. package/skills/trends/scripts/lib/transcribe.py +12 -3
  242. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  243. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  244. package/skills/trends/scripts/lib/ui.py +6 -0
  245. package/skills/trends/scripts/lib/usage.py +132 -0
  246. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  247. package/skills/trends/scripts/lib/windows_job.py +138 -0
  248. package/skills/trends/scripts/lib/x_api.py +824 -0
  249. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  250. package/skills/trends/scripts/lib/x_judge.py +292 -0
  251. package/skills/trends/scripts/lib/xai_x.py +26 -3
  252. package/skills/trends/scripts/lib/xquik.py +89 -15
  253. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  254. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  255. package/skills/trends/scripts/setup-keychain.sh +12 -4
  256. package/skills/trends/scripts/setup-pass.sh +3 -0
  257. package/skills/trends/scripts/store.py +444 -50
  258. package/skills/trends/scripts/watchlist.py +38 -11
  259. package/skills/trends/scripts/x_lane_parity.py +107 -0
  260. package/skills/video-studio/CHANGELOG.md +4 -0
  261. package/skills/video-studio/SKILL.md +4 -4
  262. package/skills/video-studio/references/styles/product-flight.md +45 -0
  263. package/skills/video-studio/references/styles.md +3 -1
  264. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  265. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  266. package/skills/video-studio-motion/SKILL.md +8 -5
  267. package/skills/youtube-planner/ATTRIBUTION.md +25 -0
  268. package/skills/youtube-planner/CHANGELOG.md +12 -0
  269. package/skills/youtube-planner/SKILL.md +191 -0
  270. package/skills/youtube-planner/references/plan-template.md +57 -0
  271. package/skills/youtube-planner/references/stages.md +119 -0
  272. package/skills/youtube-planner/scripts/floor_ceiling.py +132 -0
  273. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  274. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -2,12 +2,45 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import re
5
6
  import sys
6
7
  import urllib.parse
7
- from datetime import datetime
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timedelta, timezone
8
10
  from urllib.parse import urlparse
9
11
 
10
- from . import dates, env, http, web_search_keyless
12
+ from . import dates, env, http, parallel_mcp, schema, web_search_keyless
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class GroundedClaimText:
17
+ """Candidate text with its exact primary evidence item."""
18
+
19
+ candidate_id: str
20
+ title: str
21
+ summary: str
22
+ item: schema.SourceItem
23
+
24
+
25
+ def claim_source_map(report: schema.Report) -> dict[str, GroundedClaimText]:
26
+ """Expose only candidate claims that have a clean primary-item trace.
27
+
28
+ Freshness verification deliberately starts here instead of scanning all
29
+ report prose. A candidate without a primary ``SourceItem`` cannot produce
30
+ an auditable per-claim verdict.
31
+ """
32
+ grounded: dict[str, GroundedClaimText] = {}
33
+ for candidate in report.ranked_candidates:
34
+ item = schema.candidate_primary_item(candidate)
35
+ if item is None:
36
+ continue
37
+ grounded[candidate.candidate_id] = GroundedClaimText(
38
+ candidate_id=candidate.candidate_id,
39
+ title=candidate.title,
40
+ summary=candidate.snippet or item.snippet or item.body,
41
+ item=item,
42
+ )
43
+ return grounded
11
44
 
12
45
 
13
46
  # ---------------------------------------------------------------------------
@@ -32,7 +65,7 @@ def brave_search(
32
65
  for i, r in enumerate((data.get("web", {}).get("results", []))[:count]):
33
66
  raw_date = r.get("page_age") or ""
34
67
  pub_date = _normalize_date(raw_date[:10]) if raw_date else None
35
- if not _in_date_range(pub_date, date_range):
68
+ if _known_date_out_of_range(pub_date, date_range):
36
69
  continue
37
70
  items.append({
38
71
  "id": f"WB{i + 1}",
@@ -43,6 +76,7 @@ def brave_search(
43
76
  "date": pub_date,
44
77
  "relevance": 0.8,
45
78
  "why_relevant": "Brave web search",
79
+ "metadata": {"date_window_basis": "server_bounds"},
46
80
  })
47
81
  artifact = {"label": "brave", "webSearchQueries": [query], "resultCount": len(items)}
48
82
  return items, artifact
@@ -77,7 +111,7 @@ def exa_search(
77
111
  continue
78
112
  raw_date = r.get("publishedDate") or ""
79
113
  pub_date = _normalize_date(raw_date.split("T")[0] if "T" in raw_date else raw_date[:10]) if raw_date else None
80
- if not _in_date_range(pub_date, date_range):
114
+ if _known_date_out_of_range(pub_date, date_range):
81
115
  continue
82
116
  items.append({
83
117
  "id": f"WE{i + 1}",
@@ -88,6 +122,7 @@ def exa_search(
88
122
  "date": pub_date,
89
123
  "relevance": 0.8,
90
124
  "why_relevant": "Exa web search",
125
+ "metadata": {"date_window_basis": "server_bounds"},
91
126
  })
92
127
  artifact = {"label": "exa", "webSearchQueries": [query], "resultCount": len(items)}
93
128
  return items, artifact
@@ -114,7 +149,9 @@ def serper_search(
114
149
  for i, r in enumerate((data.get("organic", []))[:count]):
115
150
  raw_date = r.get("date") or ""
116
151
  pub_date = _parse_serper_date(raw_date)
117
- if not _in_date_range(pub_date, date_range):
152
+ if pub_date is None and _SERPER_RELATIVE_RE.match(raw_date.strip()):
153
+ continue
154
+ if _known_date_out_of_range(pub_date, date_range):
118
155
  continue
119
156
  items.append({
120
157
  "id": f"WS{i + 1}",
@@ -125,6 +162,7 @@ def serper_search(
125
162
  "date": pub_date,
126
163
  "relevance": 0.8,
127
164
  "why_relevant": "Serper web search",
165
+ "metadata": {"date_window_basis": "server_bounds"},
128
166
  })
129
167
  artifact = {"label": "serper", "webSearchQueries": [query], "resultCount": len(items)}
130
168
  return items, artifact
@@ -137,12 +175,16 @@ def serper_search(
137
175
  def parallel_search(
138
176
  query: str, date_range: tuple[str, str], api_key: str, count: int = 5,
139
177
  ) -> tuple[list[dict], dict]:
178
+ current_window = date_range[1] >= datetime.now(timezone.utc).date().isoformat()
140
179
  data = http.request(
141
180
  "POST", "https://api.parallel.ai/v1/search",
142
- headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
181
+ headers={"x-api-key": api_key, "Content-Type": "application/json"},
143
182
  json_data={
144
183
  "search_queries": [query],
145
- "advanced_settings": {"max_results": count},
184
+ "advanced_settings": {
185
+ "max_results": count,
186
+ "source_policy": {"after_date": date_range[0]},
187
+ },
146
188
  },
147
189
  timeout=15,
148
190
  )
@@ -155,7 +197,9 @@ def parallel_search(
155
197
  continue
156
198
  raw_date = r.get("publish_date") or ""
157
199
  pub_date = _normalize_date(raw_date[:10]) if raw_date else None
158
- if not _in_date_range(pub_date, date_range):
200
+ if _known_date_out_of_range(pub_date, date_range):
201
+ continue
202
+ if pub_date is None and not current_window:
159
203
  continue
160
204
  items.append({
161
205
  "id": f"WP{i + 1}",
@@ -166,12 +210,71 @@ def parallel_search(
166
210
  "date": pub_date,
167
211
  "relevance": 0.8,
168
212
  "why_relevant": "Parallel AI web search",
213
+ "metadata": {
214
+ "date_window_basis": "server_start_current" if current_window else "server_start"
215
+ },
169
216
  })
170
217
  artifact = {"label": "parallel", "webSearchQueries": [query], "resultCount": len(items)}
171
218
  return items, artifact
172
219
 
173
220
 
174
- def _parse_serper_date(raw: str) -> str | None:
221
+ _SERPER_RELATIVE_RE = re.compile(
222
+ r"^(?:about\s+)?(\d+)\s+(minute|min|hour|hr|day|week|month|year)s?\s+ago$", re.I
223
+ )
224
+ _SERPER_RELATIVE_DAYS = {"day": 1, "week": 7, "month": 30, "year": 365}
225
+ _SERPER_RELATIVE_WORDS = {"just now": 0, "today": 0, "yesterday": 1}
226
+
227
+
228
+ def _now() -> datetime:
229
+ """Query-time clock. Isolated so tests can pin it."""
230
+ return datetime.now()
231
+
232
+
233
+ def _parse_serper_relative(raw: str, now: datetime | None = None) -> str | None:
234
+ """Resolve Serper's relative dates ("2 hours ago") to an ISO date.
235
+
236
+ Google renders a relative date for recent results, so the fresher a result
237
+ is the more likely it arrives in this form. Left unparsed it becomes None
238
+ and `_in_date_range` drops the item, which silently empties the web lane on
239
+ short research windows.
240
+
241
+ The label states the result's age **at query time**, so it resolves against
242
+ the clock rather than against the end of the research window. The two differ
243
+ whenever `--as-of` names a past window: a result inside a window ending two
244
+ weeks ago is still labelled with its age as of today, and anchoring to the
245
+ window end would place it a further two weeks back and discard it.
246
+
247
+ Sub-day units ("N minutes/hours ago") are subtracted from the clock rather
248
+ than rounded up to the current date, which would name the wrong day across
249
+ midnight: at 00:30 "23 hours ago" is yesterday. Residual slack comes only
250
+ from the timezone `_now()` reads against Google's.
251
+
252
+ An age large enough to overflow `timedelta` or run past the end of `date`
253
+ reads as unparseable, like any other junk label, so one malformed value
254
+ cannot abort the whole result loop in `serper_search`.
255
+ """
256
+ anchor = now or _now()
257
+ text = raw.strip().lower()
258
+ if text in _SERPER_RELATIVE_WORDS:
259
+ return (anchor.date() - timedelta(days=_SERPER_RELATIVE_WORDS[text])).isoformat()
260
+ m = _SERPER_RELATIVE_RE.match(text)
261
+ if not m:
262
+ return None
263
+ unit = {"min": "minute", "hr": "hour"}.get(m.group(2), m.group(2))
264
+ try:
265
+ amount = int(m.group(1).lstrip("0") or "0")
266
+ if unit == "minute":
267
+ return (anchor - timedelta(minutes=amount)).date().isoformat()
268
+ if unit == "hour":
269
+ return (anchor - timedelta(hours=amount)).date().isoformat()
270
+ return (
271
+ anchor.date() - timedelta(days=amount * _SERPER_RELATIVE_DAYS[unit])
272
+ ).isoformat()
273
+ except (OverflowError, ValueError):
274
+ return None
275
+
276
+
277
+ def _parse_serper_date(raw: str, now: datetime | None = None) -> str | None:
175
278
  if not raw:
176
279
  return None
177
280
  normalized = _normalize_date(raw)
@@ -182,7 +285,7 @@ def _parse_serper_date(raw: str) -> str | None:
182
285
  return datetime.strptime(raw.strip(), fmt).date().isoformat()
183
286
  except ValueError:
184
287
  continue
185
- return None
288
+ return _parse_serper_relative(raw, now)
186
289
 
187
290
 
188
291
 
@@ -236,6 +339,10 @@ def web_search(
236
339
  if not key:
237
340
  raise RuntimeError("PARALLEL_API_KEY is required when web_backend='parallel'")
238
341
  items, artifact = parallel_search(query, date_range, key)
342
+ elif backend == "parallel-mcp":
343
+ items, artifact = parallel_mcp.search(
344
+ query, date_range, config.get("PARALLEL_API_KEY")
345
+ )
239
346
  elif backend == "keyless":
240
347
  items, artifact = web_search_keyless.keyless_search(query, date_range, config)
241
348
  elif backend != "none":
@@ -243,7 +350,13 @@ def web_search(
243
350
  else:
244
351
  return [], {}
245
352
  if items and not _reddit_excluded(config):
246
- items = _enrich_reddit_items(items)
353
+ # Reddit enrichment is a best-effort secondary fetch on already-retrieved
354
+ # web results. Isolate its HTTP failures in a throwaway capture sink so a
355
+ # reddit.com fetch failure (e.g. a 403 on a datacenter IP) is not
356
+ # attributed to the web/grounding source itself — which would otherwise
357
+ # discard the successfully retrieved results and report the source failed.
358
+ with http.capture_failures():
359
+ items = _enrich_reddit_items(items)
247
360
  return items, artifact
248
361
 
249
362
 
@@ -327,5 +440,10 @@ def _in_date_range(pub_date: str | None, date_range: tuple[str, str]) -> bool:
327
440
  return date_range[0] <= pub_date <= date_range[1]
328
441
 
329
442
 
443
+ def _known_date_out_of_range(pub_date: str | None, date_range: tuple[str, str]) -> bool:
444
+ """True when a parseable date falls outside the window. Unknown dates stay."""
445
+ return pub_date is not None and not _in_date_range(pub_date, date_range)
446
+
447
+
330
448
  def _domain(url: str) -> str:
331
449
  return urlparse(url).netloc.strip().lower()
@@ -40,6 +40,8 @@ ENRICH_LIMITS = {
40
40
  "deep": 10,
41
41
  }
42
42
 
43
+ DISCOVERY_LIMITS = {"quick": 20, "default": 40, "deep": 60}
44
+
43
45
 
44
46
  def _log(msg: str):
45
47
  log.source_log("HN", msg, tty_only=False)
@@ -145,6 +147,47 @@ def search_hackernews(
145
147
  return response
146
148
 
147
149
 
150
+ def fetch_discovery_listings(
151
+ from_date: str,
152
+ to_date: str,
153
+ depth: str = "default",
154
+ ) -> Dict[str, Any]:
155
+ """Fetch topic-less HN front-page and best-in-window story listings."""
156
+ limit = DISCOVERY_LIMITS.get(depth, DISCOVERY_LIMITS["default"])
157
+ from_ts = _date_to_unix(from_date)
158
+ to_ts = _date_to_unix(to_date) + 86400
159
+ from urllib.parse import urlencode
160
+
161
+ urls = [
162
+ f"{ALGOLIA_SEARCH_URL}?{urlencode({'tags': 'front_page', 'hitsPerPage': str(limit)})}",
163
+ f"{ALGOLIA_SEARCH_URL}?{urlencode({
164
+ 'tags': 'story',
165
+ 'numericFilters': f'created_at_i>{from_ts},created_at_i<{to_ts}',
166
+ 'hitsPerPage': str(limit),
167
+ })}",
168
+ ]
169
+ hits: list[dict[str, Any]] = []
170
+ errors: list[str] = []
171
+ for url in urls:
172
+ try:
173
+ response = http.request("GET", url, timeout=30)
174
+ hits.extend(response.get("hits") or [])
175
+ except Exception as exc:
176
+ errors.append(str(exc))
177
+
178
+ seen: set[str] = set()
179
+ unique_hits: list[dict[str, Any]] = []
180
+ for hit in hits:
181
+ object_id = str(hit.get("objectID") or "")
182
+ if not object_id or object_id in seen:
183
+ continue
184
+ seen.add(object_id)
185
+ unique_hits.append(hit)
186
+
187
+ items = parse_hackernews_response({"hits": unique_hits}, query="")
188
+ return {"items": items, "errors": errors}
189
+
190
+
148
191
  _WORD_BOUNDARY_RE_CACHE: Dict[str, "re.Pattern[str]"] = {}
149
192
 
150
193
 
@@ -232,7 +275,7 @@ def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List
232
275
 
233
276
  # Article URL vs HN discussion URL
234
277
  article_url = hit.get("url") or ""
235
- hn_url = f"https://news.ycombinator.com/item?id={object_id}"
278
+ hn_url = _hn_item_url(object_id)
236
279
 
237
280
  # Relevance: blend Algolia rank with token-overlap content matching
238
281
  rank_score = max(0.3, 1.0 - (i * 0.02)) # 1.0 -> 0.3 over 35 items
@@ -261,6 +304,11 @@ def parse_hackernews_response(response: Dict[str, Any], query: str = "") -> List
261
304
  return items
262
305
 
263
306
 
307
+ def _hn_item_url(item_id: Any) -> str:
308
+ """HN discussion/comment permalink for an item id."""
309
+ return f"https://news.ycombinator.com/item?id={item_id}"
310
+
311
+
264
312
  def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any]:
265
313
  """Fetch top-level comments for a story from Algolia items endpoint.
266
314
 
@@ -290,13 +338,18 @@ def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any
290
338
 
291
339
  comments = []
292
340
  insights = []
341
+ # A quote links to where it was said: the comment's own permalink, else the
342
+ # story's discussion page -- never the article URL.
343
+ discussion_url = _hn_item_url(object_id)
293
344
  for c in real_comments[:max_comments]:
294
345
  text = _strip_html(c.get("text", ""))
295
346
  excerpt = text[:300] + "..." if len(text) > 300 else text
347
+ comment_id = c.get("id")
296
348
  comments.append({
297
349
  "author": c.get("author", ""),
298
350
  "text": excerpt,
299
- "points": c.get("points") or 0,
351
+ "points": c.get("points"),
352
+ "url": _hn_item_url(comment_id) if comment_id else discussion_url,
300
353
  })
301
354
  # First sentence as insight
302
355
  first_sentence = text.split(". ")[0].split("\n")[0][:200]
@@ -327,7 +380,7 @@ def enrich_top_stories(
327
380
  # Sort by points to enrich the most popular stories
328
381
  by_points = sorted(
329
382
  range(len(items)),
330
- key=lambda i: items[i].get("engagement", {}).get("points", 0),
383
+ key=lambda i: items[i].get("engagement", {}).get("points") or 0,
331
384
  reverse=True,
332
385
  )
333
386
  to_enrich = by_points[:limit]
@@ -335,8 +388,12 @@ def enrich_top_stories(
335
388
  _log(f"Enriching top {len(to_enrich)} stories with comments")
336
389
 
337
390
  with ThreadPoolExecutor(max_workers=5) as executor:
391
+ # submit_with_context, not executor.submit: a plain submit drops the
392
+ # pipeline's capture_failures() sink, so failed or rate-limited item
393
+ # fetches would vanish and the HN source would look fully enriched.
338
394
  futures = {
339
- executor.submit(
395
+ http.submit_with_context(
396
+ executor,
340
397
  _fetch_item_comments,
341
398
  items[idx]["id"],
342
399
  ): idx