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
@@ -21,6 +21,8 @@ import tempfile
21
21
  from pathlib import Path
22
22
  from typing import Optional
23
23
 
24
+ from .cookie_paths import path_exists, path_is_dir
25
+
24
26
  logger = logging.getLogger(__name__)
25
27
 
26
28
 
@@ -204,6 +206,7 @@ def _extract_chromium_cookies_macos(
204
206
  keychain_service: str,
205
207
  domain: str,
206
208
  cookie_names: list[str],
209
+ key_cache: Optional[dict[str, Optional[bytes]]] = None,
207
210
  ) -> Optional[dict[str, str]]:
208
211
  """Extract cookies from any Chromium-based browser on macOS.
209
212
 
@@ -220,7 +223,11 @@ def _extract_chromium_cookies_macos(
220
223
  Dict mapping cookie name to decrypted value, or None on failure.
221
224
  Only includes cookies that were successfully found and decrypted.
222
225
  """
223
- if not db_path.exists():
226
+ try:
227
+ db_path.stat()
228
+ except PermissionError:
229
+ raise
230
+ except OSError:
224
231
  logger.info("%s cookies database not found at %s", keychain_service, db_path)
225
232
  return None
226
233
 
@@ -229,15 +236,24 @@ def _extract_chromium_cookies_macos(
229
236
  tmp_path = None
230
237
  try:
231
238
  tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite")
232
- shutil.copy2(str(db_path), tmp_path)
239
+ # mkstemp creates the file 0600. copy2 would copy the source DB's
240
+ # permission bits onto the temp file before the chmod below runs,
241
+ # briefly exposing live cookies when the source DB is looser.
242
+ shutil.copyfile(str(db_path), tmp_path)
233
243
  _lock_temp_cookie_copy(tmp_path)
234
244
  except Exception as e:
235
- logger.info("Failed to copy %s cookies database: %s", keychain_service, e)
245
+ browser_read_denied = isinstance(e, PermissionError) and e.filename == str(db_path)
246
+ if browser_read_denied:
247
+ logger.info("Permission denied reading %s cookies database", keychain_service)
248
+ else:
249
+ logger.info("Failed to copy %s cookies database: %s", keychain_service, e)
236
250
  if tmp_path:
237
251
  try:
238
252
  Path(tmp_path).unlink(missing_ok=True)
239
253
  except Exception:
240
254
  pass
255
+ if browser_read_denied:
256
+ raise
241
257
  return None
242
258
  finally:
243
259
  if tmp_fd is not None:
@@ -274,8 +290,13 @@ def _extract_chromium_cookies_macos(
274
290
  # Keychain prompt for browsers that don't hold the requested
275
291
  # cookie, which matters for FROM_BROWSER=auto across several
276
292
  # installed Chromium browsers.
277
- passphrase = _get_chromium_encryption_key(keychain_service)
278
- aes_key = _derive_aes_key(passphrase) if passphrase else None
293
+ if key_cache is not None and keychain_service in key_cache:
294
+ aes_key = key_cache[keychain_service]
295
+ else:
296
+ passphrase = _get_chromium_encryption_key(keychain_service)
297
+ aes_key = _derive_aes_key(passphrase) if passphrase else None
298
+ if key_cache is not None:
299
+ key_cache[keychain_service] = aes_key
279
300
  key_fetched = True
280
301
  if aes_key is None:
281
302
  logger.debug("Skipping encrypted cookie %s — no Keychain access", name)
@@ -316,16 +337,14 @@ def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Option
316
337
  same modern ``Default/Network/Cookies`` (Chromium >= 96) and legacy
317
338
  ``Default/Cookies`` probing as the rest of the Chromium family.
318
339
  """
319
- db_path = _find_chromium_cookies_db(CHROME_BASE_DIR)
320
- if db_path is None:
321
- logger.info("Chrome cookies database not found under %s", CHROME_BASE_DIR)
322
- return None
323
- return _extract_chromium_cookies_macos(
324
- db_path, "Chrome Safe Storage", domain, cookie_names
340
+ return _extract_chromium_cookies_any_profile(
341
+ CHROME_BASE_DIR, "Chrome Safe Storage", domain, cookie_names
325
342
  )
326
343
 
327
344
 
328
- def _profile_cookie_db(profile_dir: Path) -> Optional[Path]:
345
+ def _profile_cookie_db(
346
+ profile_dir: Path, denials: Optional[list[PermissionError]] = None
347
+ ) -> Optional[Path]:
329
348
  """Return the Cookies DB inside a profile dir, or None.
330
349
 
331
350
  Prefers the modern ``Network/Cookies`` location (Chromium >= 96 moved the
@@ -333,10 +352,20 @@ def _profile_cookie_db(profile_dir: Path) -> Optional[Path]:
333
352
  to the legacy flat ``Cookies`` file. Different browsers and versions use
334
353
  different layouts, so both are probed.
335
354
  """
355
+ denied: list[PermissionError] = []
336
356
  for rel in ("Network/Cookies", "Cookies"):
337
357
  candidate = profile_dir / rel
338
- if candidate.exists():
339
- return candidate
358
+ try:
359
+ if path_exists(candidate):
360
+ if denials is not None:
361
+ denials.extend(denied)
362
+ return candidate
363
+ except PermissionError as exc:
364
+ denied.append(exc)
365
+ if denials is not None:
366
+ denials.extend(denied)
367
+ elif denied:
368
+ raise denied[0]
340
369
  return None
341
370
 
342
371
 
@@ -351,28 +380,96 @@ def _find_chromium_cookies_db(base_dir: Path) -> Optional[Path]:
351
380
  recently used one is the likeliest to hold current cookies. Lexicographic
352
381
  sort would visit "Profile 10" before "Profile 2", which can return the
353
382
  wrong profile, so we sort by mtime.
383
+
384
+ Kept for backward compatibility; new code should use
385
+ _find_all_chromium_cookies_dbs() to search across all profiles.
354
386
  """
355
- found = _profile_cookie_db(base_dir / "Default")
356
- if found:
357
- return found
387
+ dbs = _find_all_chromium_cookies_dbs(base_dir)
388
+ return dbs[0] if dbs else None
389
+
358
390
 
359
- found = _profile_cookie_db(base_dir)
360
- if found:
361
- return found
391
+ def _find_all_chromium_cookies_dbs(
392
+ base_dir: Path, denials: Optional[list[PermissionError]] = None
393
+ ) -> list[Path]:
394
+ """Return ALL candidate Cookies DBs under base_dir, best-guess order first.
362
395
 
396
+ Order: Default, the base dir itself (Opera's flat layout), then numbered
397
+ "Profile N" dirs by most-recently-modified. Unlike _find_chromium_cookies_db
398
+ (which returns the first DB that merely EXISTS), this returns every profile
399
+ so the caller can pick the one that actually holds the target domain's
400
+ cookies. Needed because a logged-in session often lives in a non-Default
401
+ profile while Default still has a (guest-only) cookie DB.
402
+ """
403
+ paths: list[Path] = []
404
+ seen: set[Path] = set()
405
+ denied: list[PermissionError] = []
406
+
407
+ def add(p: Optional[Path]) -> None:
408
+ if p is not None and p not in seen:
409
+ seen.add(p)
410
+ paths.append(p)
411
+
412
+ add(_profile_cookie_db(base_dir / "Default", denied))
413
+ add(_profile_cookie_db(base_dir, denied))
363
414
  try:
364
- candidates = [
365
- child for child in base_dir.iterdir()
366
- if child.is_dir() and child.name.startswith("Profile ")
367
- ]
368
- for child in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
369
- found = _profile_cookie_db(child)
370
- if found:
371
- return found
415
+ candidates = []
416
+ for child in base_dir.iterdir():
417
+ if not child.name.startswith("Profile "):
418
+ continue
419
+ try:
420
+ if path_is_dir(child):
421
+ candidates.append((child.stat().st_mtime, child))
422
+ except PermissionError as exc:
423
+ denied.append(exc)
424
+ for _, child in sorted(candidates, reverse=True):
425
+ add(_profile_cookie_db(child, denied))
426
+ except PermissionError as exc:
427
+ denied.append(exc)
372
428
  except OSError:
373
429
  pass
430
+ if denials is not None:
431
+ denials.extend(denied)
432
+ elif denied and not paths:
433
+ raise denied[0]
434
+ return paths
374
435
 
375
- return None
436
+
437
+ def _extract_chromium_cookies_any_profile(
438
+ base_dir: Path, keychain_service: str, domain: str, cookie_names: list[str]
439
+ ) -> Optional[dict[str, str]]:
440
+ """Try every profile under base_dir and return the best cookie match.
441
+
442
+ Returns the first profile that yields ALL requested cookie_names. If no
443
+ profile has the complete set, returns the first partial match found, or
444
+ None if no profile yielded any. This fixes the single-profile limitation
445
+ where a guest-only Default profile shadowed a logged-in "Profile N".
446
+ """
447
+ denials: list[PermissionError] = []
448
+ db_paths = _find_all_chromium_cookies_dbs(base_dir, denials)
449
+ if not db_paths:
450
+ if denials:
451
+ raise denials[0]
452
+ logger.info("%s cookies database not found under %s", keychain_service, base_dir)
453
+ return None
454
+ best: Optional[dict[str, str]] = None
455
+ key_cache: dict[str, Optional[bytes]] = {}
456
+ for db_path in db_paths:
457
+ try:
458
+ got = _extract_chromium_cookies_macos(
459
+ db_path, keychain_service, domain, cookie_names, key_cache=key_cache
460
+ )
461
+ except PermissionError as exc:
462
+ denials.append(exc)
463
+ continue
464
+ if got:
465
+ if all(name in got for name in cookie_names):
466
+ logger.debug("Found complete cookie set for %s in %s", domain, db_path)
467
+ return got
468
+ if best is None:
469
+ best = got
470
+ if denials:
471
+ raise denials[0]
472
+ return best
376
473
 
377
474
 
378
475
  def _find_brave_cookies_db() -> Optional[Path]:
@@ -386,11 +483,9 @@ def extract_brave_cookies_macos(domain: str, cookie_names: list[str]) -> Optiona
386
483
  Brave uses the same v10 AES-128-CBC encryption as Chrome; only the DB
387
484
  path and Keychain service name differ.
388
485
  """
389
- db_path = _find_brave_cookies_db()
390
- if db_path is None:
391
- logger.info("Brave cookies database not found under %s", BRAVE_BASE_DIR)
392
- return None
393
- return _extract_chromium_cookies_macos(db_path, "Brave Safe Storage", domain, cookie_names)
486
+ return _extract_chromium_cookies_any_profile(
487
+ BRAVE_BASE_DIR, "Brave Safe Storage", domain, cookie_names
488
+ )
394
489
 
395
490
 
396
491
  def extract_chromium_browser_cookies_macos(
@@ -407,8 +502,6 @@ def extract_chromium_browser_cookies_macos(
407
502
  logger.debug("Unknown Chromium browser: %s", browser)
408
503
  return None
409
504
  base_dir, keychain_service = spec
410
- db_path = _find_chromium_cookies_db(base_dir)
411
- if db_path is None:
412
- logger.info("%s cookies database not found under %s", keychain_service, base_dir)
413
- return None
414
- return _extract_chromium_cookies_macos(db_path, keychain_service, domain, cookie_names)
505
+ return _extract_chromium_cookies_any_profile(
506
+ base_dir, keychain_service, domain, cookie_names
507
+ )
@@ -2,59 +2,25 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import re
5
+ from . import dedupe, entity_extract, schema
6
6
 
7
- from . import dedupe, schema
8
7
 
9
- CLUSTERABLE_INTENTS = {"breaking_news", "opinion", "comparison", "prediction"}
8
+ def _cluster_sort_key(candidate: schema.Candidate) -> tuple:
9
+ """Sort key that partitions stale candidates below fresh ones.
10
10
 
11
- # Words too common to signal shared topic between clusters.
12
- _ENTITY_STOPWORDS = frozenset({
13
- "the", "a", "an", "to", "for", "how", "is", "in", "of", "on", "and",
14
- "with", "from", "by", "at", "this", "that", "it", "what", "are", "do",
15
- "can", "his", "her", "he", "she", "its", "was", "has", "new", "just",
16
- "says", "said", "will", "about", "after", "now", "all", "been", "here",
17
- "not", "out", "up", "more", "also", "but", "who", "year", "first",
18
- "make", "being", "making", "over", "into", "than", "they", "their",
19
- "would", "could", "get", "got", "some", "like", "back", "going",
20
- "breaking", "https", "http", "www", "com",
21
- })
11
+ Stale items (all dated source_items outside the window) must never lead
12
+ cluster representatives or render as the cluster title.
13
+ """
14
+ return (
15
+ 1 if schema.candidate_out_of_window(candidate) else 0,
16
+ -candidate.final_score,
17
+ )
22
18
 
19
+ CLUSTERABLE_INTENTS = {"breaking_news", "opinion", "comparison", "prediction"}
23
20
 
24
21
  def _candidate_text(candidate: schema.Candidate) -> str:
25
22
  return " ".join(part for part in [candidate.title, candidate.snippet] if part).strip()
26
23
 
27
-
28
- def _extract_entities(text: str) -> set[str]:
29
- """Extract significant words (proper nouns, numbers, capitalized words) from text.
30
-
31
- Used for cross-source cluster merging where phrasing differs but entities overlap.
32
- """
33
- # Normalize but preserve word boundaries
34
- words = re.sub(r"[^\w\s]", " ", text).split()
35
- entities = set()
36
- for word in words:
37
- lower = word.lower()
38
- if lower in _ENTITY_STOPWORDS or len(word) <= 2:
39
- continue
40
- # Keep words that are: capitalized, ALL CAPS, contain digits, or 4+ chars
41
- if word[0].isupper() or word.isupper() or any(c.isdigit() for c in word) or len(word) >= 4:
42
- entities.add(lower)
43
- return entities
44
-
45
-
46
- def _entity_overlap(entities_a: set[str], entities_b: set[str]) -> float:
47
- """Jaccard-style overlap on extracted entities."""
48
- if not entities_a or not entities_b:
49
- return 0.0
50
- intersection = entities_a & entities_b
51
- smaller = min(len(entities_a), len(entities_b))
52
- # Use overlap coefficient (intersection / min) instead of Jaccard,
53
- # because a short tweet about the same event as a long Reddit post
54
- # will have fewer total entities but high overlap with the larger set.
55
- return len(intersection) / smaller if smaller > 0 else 0.0
56
-
57
-
58
24
  def _mmr_representatives(
59
25
  candidates: list[schema.Candidate],
60
26
  text_cache: dict[str, dedupe._PreparedText],
@@ -66,7 +32,7 @@ def _mmr_representatives(
66
32
  remaining = list(candidates)
67
33
  while remaining and len(selected) < limit:
68
34
  if not selected:
69
- best = max(remaining, key=lambda candidate: candidate.final_score)
35
+ best = min(remaining, key=_cluster_sort_key)
70
36
  selected.append(best)
71
37
  remaining_set.discard(best.candidate_id)
72
38
  remaining = [c for c in remaining if c.candidate_id in remaining_set]
@@ -74,12 +40,16 @@ def _mmr_representatives(
74
40
 
75
41
  selected_preps = [text_cache[c.candidate_id] for c in selected]
76
42
 
77
- def score(candidate: schema.Candidate) -> float:
43
+ def score(candidate: schema.Candidate) -> tuple:
78
44
  prep = text_cache[candidate.candidate_id]
79
45
  diversity_penalty = max(
80
46
  dedupe.prepared_similarity(prep, sp) for sp in selected_preps
81
47
  )
82
- return (diversity_lambda * candidate.final_score) - ((1 - diversity_lambda) * diversity_penalty * 100)
48
+ base_score = (diversity_lambda * candidate.final_score) - ((1 - diversity_lambda) * diversity_penalty * 100)
49
+ return (
50
+ 0 if schema.candidate_out_of_window(candidate) else 1,
51
+ base_score,
52
+ )
83
53
 
84
54
  best = max(remaining, key=score)
85
55
  selected.append(best)
@@ -135,7 +105,7 @@ def cluster_candidates(
135
105
 
136
106
  clusters: list[schema.Cluster] = []
137
107
  for index, group in enumerate(groups, start=1):
138
- group.sort(key=lambda candidate: candidate.final_score, reverse=True)
108
+ group.sort(key=_cluster_sort_key)
139
109
  cluster_id = f"cluster-{index}"
140
110
  representatives = _mmr_representatives(group, text_cache)
141
111
  for candidate in group:
@@ -153,7 +123,11 @@ def cluster_candidates(
153
123
  )
154
124
 
155
125
  # Second pass: merge small clusters that share entities across sources.
156
- clusters = _merge_entity_clusters(clusters, candidates)
126
+ clusters = _merge_entity_clusters(
127
+ clusters,
128
+ candidates,
129
+ min_shared_entities=2 if "discover-mode" in plan.notes else 1,
130
+ )
157
131
 
158
132
  return sorted(clusters, key=lambda cluster: cluster.score, reverse=True)
159
133
 
@@ -161,6 +135,8 @@ def cluster_candidates(
161
135
  def _merge_entity_clusters(
162
136
  clusters: list[schema.Cluster],
163
137
  all_candidates: list[schema.Candidate],
138
+ *,
139
+ min_shared_entities: int = 1,
164
140
  ) -> list[schema.Cluster]:
165
141
  """Merge small clusters that cover the same story across different sources.
166
142
 
@@ -182,7 +158,7 @@ def _merge_entity_clusters(
182
158
  for cid in cl.candidate_ids:
183
159
  cand = candidate_map.get(cid)
184
160
  if cand:
185
- entities |= _extract_entities(_candidate_text(cand))
161
+ entities |= entity_extract.extract_text_entities(_candidate_text(cand))
186
162
  cluster_entities.append(entities)
187
163
 
188
164
  # Only merge clusters with <= 3 items (don't merge already-large clusters)
@@ -207,8 +183,9 @@ def _merge_entity_clusters(
207
183
  if poly_i != poly_j:
208
184
  continue
209
185
 
210
- overlap = _entity_overlap(cluster_entities[i], cluster_entities[j])
211
- if overlap >= 0.45:
186
+ shared_entities = cluster_entities[i] & cluster_entities[j]
187
+ overlap = entity_extract.entity_overlap(cluster_entities[i], cluster_entities[j])
188
+ if len(shared_entities) >= min_shared_entities and overlap >= 0.45:
212
189
  merged_into[j] = i
213
190
 
214
191
  if not merged_into:
@@ -236,7 +213,7 @@ def _merge_entity_clusters(
236
213
 
237
214
  # Pick representatives from combined pool
238
215
  combined_candidates = [candidate_map[cid] for cid in combined_cids if cid in candidate_map]
239
- combined_candidates.sort(key=lambda c: c.final_score, reverse=True)
216
+ combined_candidates.sort(key=_cluster_sort_key)
240
217
  merge_text_cache = {
241
218
  c.candidate_id: dedupe._PreparedText(_candidate_text(c))
242
219
  for c in combined_candidates
@@ -19,6 +19,15 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
19
19
  from . import dates, grounding, log
20
20
  from .resolve import _has_backend
21
21
 
22
+ # Peer cap vs total vs-entity cap (main + peers).
23
+ COMPETITORS_MIN = 1
24
+ COMPETITORS_MAX = 6
25
+ COMPETITORS_DEFAULT = 2
26
+ COMPARISON_ENTITY_MAX = COMPETITORS_MAX + 1
27
+ # Discovery SERP fan-out is small (3 queries today) but still needs a ceiling
28
+ # so a future query expansion cannot open one worker per query unbounded.
29
+ MAX_DISCOVERY_WORKERS = 3
30
+
22
31
  # A "brand-shaped" token starts with uppercase OR is camelCase with an
23
32
  # uppercase letter later. Catches "Anthropic", "OpenAI", "xAI", "iPhone",
24
33
  # "eBay", "Hugging", "Face".
@@ -172,7 +181,7 @@ def discover_competitors(
172
181
  items, _artifact = grounding.web_search(query, date_range, config)
173
182
  return label, items
174
183
 
175
- with ThreadPoolExecutor(max_workers=len(queries)) as executor:
184
+ with ThreadPoolExecutor(max_workers=min(len(queries), MAX_DISCOVERY_WORKERS)) as executor:
176
185
  futures = {
177
186
  executor.submit(_search, label, q): label
178
187
  for label, q in queries.items()