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
@@ -1,8 +1,7 @@
1
- """Subprocess helpers: safe timeout + process-group cleanup.
1
+ """Subprocess helpers: safe timeout + process-tree cleanup.
2
2
 
3
- Used by bird_x.py (Node.js Bird search) and youtube_yt.py (yt-dlp search
4
- and transcript download). Both need the same os.setsid/killpg cleanup
5
- dance on timeout to avoid orphaning child processes.
3
+ Used by Bird/X search and other external-source clients. POSIX children use
4
+ their own process group; Windows children run inside a kill-on-close job.
6
5
  """
7
6
 
8
7
  from __future__ import annotations
@@ -10,6 +9,10 @@ from __future__ import annotations
10
9
  import os
11
10
  import signal
12
11
  import subprocess
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from contextlib import ExitStack
13
16
  from dataclasses import dataclass
14
17
  from typing import Optional, Sequence
15
18
 
@@ -17,6 +20,138 @@ from typing import Optional, Sequence
17
20
  class SubprocTimeout(Exception):
18
21
  """Raised when a subprocess exceeds its timeout and is killed."""
19
22
 
23
+ def __init__(self, message: str = "", *, started: bool = True):
24
+ super().__init__(message)
25
+ self.started = started
26
+
27
+
28
+ # Live run_with_timeout children, process-wide. Each child is a session
29
+ # leader (os.setsid), so a group kill aimed at the engine never reaches it;
30
+ # the engine's SIGTERM handler and atexit hook drain this set instead. It
31
+ # lives here, not in last30days.py, because the entrypoint runs as
32
+ # __main__: importing it by name from lib/ executes a second module copy
33
+ # with its own empty registry, and on a worker thread that copy cannot
34
+ # install its signal handler either. RLock because cleanup_children runs
35
+ # inside a signal handler on the main thread, which may already hold the
36
+ # lock in register_child_pid.
37
+ _child_pids: set[int] = set()
38
+ _child_pids_lock = threading.RLock()
39
+ _child_jobs: dict[int, object] = {}
40
+ # Set once cleanup_children starts. Worker threads keep running while the
41
+ # handler sleeps through the grace, so a source can spawn a child after the
42
+ # snapshot; register_child_pid kills such late children itself.
43
+ _shutting_down = False
44
+ _WINDOWS = os.name == "nt"
45
+
46
+
47
+ def register_child_pid(pid: int) -> None:
48
+ with _child_pids_lock:
49
+ _child_pids.add(pid)
50
+ late = _shutting_down
51
+ if late:
52
+ _kill_child_group(pid, getattr(signal, "SIGKILL", signal.SIGTERM))
53
+
54
+
55
+ def unregister_child_pid(pid: int) -> None:
56
+ with _child_pids_lock:
57
+ _child_pids.discard(pid)
58
+ job = _child_jobs.pop(pid, None)
59
+ if job is not None:
60
+ job.close()
61
+
62
+
63
+ # Upper bound on how long cleanup_children waits for SIGTERMed groups before
64
+ # SIGKILLing them. It runs inside the engine's SIGTERM handler, so it must
65
+ # finish well inside the MCP server's termGracePeriod
66
+ # (mcp/internal/engine/run.go), after which the engine group is SIGKILLed and
67
+ # the handler never reaches the escalation. Pinned by tests/test_subproc.py.
68
+ CLEANUP_TERM_GRACE_SECONDS = 0.8
69
+ _CLEANUP_POLL_SECONDS = 0.05
70
+
71
+
72
+ def _signal_group(pgid: int, sig: int) -> bool:
73
+ """Signal a child's process group; False once the group has no members."""
74
+ try:
75
+ os.killpg(pgid, sig)
76
+ except (ProcessLookupError, PermissionError, OSError):
77
+ return False
78
+ return True
79
+
80
+
81
+ def _taskkill_pid(pid: int, timeout: float = 2) -> bool:
82
+ try:
83
+ result = subprocess.run(
84
+ ["taskkill", "/F", "/T", "/PID", str(pid)],
85
+ stdin=subprocess.DEVNULL,
86
+ stdout=subprocess.DEVNULL,
87
+ stderr=subprocess.DEVNULL,
88
+ timeout=timeout,
89
+ check=False,
90
+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
91
+ )
92
+ except (OSError, subprocess.TimeoutExpired):
93
+ return False
94
+ return result.returncode == 0
95
+
96
+
97
+ def _kill_windows_tree(pid: int) -> bool:
98
+ return _taskkill_pid(pid)
99
+
100
+
101
+ def _kill_windows_managed_tree(pid: int) -> bool:
102
+ with _child_pids_lock:
103
+ job = _child_jobs.get(pid)
104
+ if job is not None and job.terminate():
105
+ return True
106
+ return _kill_windows_tree(pid)
107
+
108
+
109
+ def _kill_child_group(pid: int, sig: int) -> None:
110
+ if hasattr(os, "setsid") and hasattr(os, "killpg"):
111
+ _signal_group(pid, sig)
112
+ return
113
+ if _WINDOWS and _kill_windows_managed_tree(pid):
114
+ return
115
+ try:
116
+ os.kill(pid, sig)
117
+ except (ProcessLookupError, PermissionError, OSError):
118
+ pass
119
+
120
+
121
+ def cleanup_children(grace: float = CLEANUP_TERM_GRACE_SECONDS) -> None:
122
+ """Terminate the process group of every registered child.
123
+
124
+ Windows jobs terminate immediately. On POSIX, SIGTERM every group, wait
125
+ up to ``grace`` seconds for the groups to empty, then SIGKILL survivors.
126
+ run_with_timeout starts each POSIX child with os.setsid, so the child's
127
+ pid is its pgid; signalling the pgid directly still reaches grandchildren
128
+ after the leader has been reaped,
129
+ and the kernel does not reuse a pid while a group of that id has
130
+ members. A group whose only member is an unreaped zombie still reads as
131
+ live, which at worst costs the full grace and a harmless SIGKILL.
132
+ Children registered after this call starts are SIGKILLed as they
133
+ register, since the caller is about to exit.
134
+ """
135
+ global _shutting_down
136
+ with _child_pids_lock:
137
+ _shutting_down = True
138
+ pids = list(_child_pids)
139
+ if not pids:
140
+ return
141
+ if not (hasattr(os, "setsid") and hasattr(os, "killpg")):
142
+ for pid in pids:
143
+ with _child_pids_lock:
144
+ if pid in _child_pids:
145
+ _kill_child_group(pid, signal.SIGTERM)
146
+ return
147
+ live = [pgid for pgid in pids if _signal_group(pgid, signal.SIGTERM)]
148
+ deadline = time.monotonic() + grace
149
+ while live and time.monotonic() < deadline:
150
+ time.sleep(_CLEANUP_POLL_SECONDS)
151
+ live = [pgid for pgid in live if _signal_group(pgid, 0)]
152
+ for pgid in live:
153
+ _signal_group(pgid, signal.SIGKILL)
154
+
20
155
 
21
156
  @dataclass
22
157
  class SubprocResult:
@@ -30,84 +165,203 @@ class SubprocResult:
30
165
  def run_with_timeout(
31
166
  cmd: Sequence[str],
32
167
  *,
33
- timeout: int,
168
+ timeout: float,
34
169
  env: Optional[dict] = None,
35
170
  on_pid: Optional[callable] = None,
171
+ input_text: Optional[str] = None,
172
+ deadline_monotonic: Optional[float] = None,
173
+ cancel: Optional[threading.Event] = None,
174
+ cleanup_grace: float = 5.0,
175
+ capture_limit_bytes: Optional[int] = None,
36
176
  ) -> SubprocResult:
37
177
  """Run a subprocess with process-group cleanup on timeout.
38
178
 
39
- Spawns ``cmd`` inside its own process group via ``os.setsid`` where
40
- available. If ``communicate(timeout=...)`` raises ``TimeoutExpired``,
41
- signals ``SIGTERM`` to the entire group, falls back to ``proc.kill()``
42
- if the signal fails, then waits up to 5 seconds for cleanup, and
43
- raises ``SubprocTimeout``.
179
+ Spawns ``cmd`` inside its own process group via ``start_new_session`` where
180
+ available. On Windows, the child enters a kill-on-close job before it runs,
181
+ so cleanup reaches descendants that inherit the job after launchers exit.
182
+ Closing the job on normal return also stops detached descendants. Job setup
183
+ failure aborts the suspended child. POSIX signals the group with SIGTERM,
184
+ then escalates to SIGKILL if needed.
44
185
 
45
186
  Args:
46
187
  cmd: Command and arguments to spawn.
47
188
  timeout: Timeout in seconds passed to ``communicate()``.
48
189
  env: Optional environment dict. If None, inherits parent env.
49
190
  on_pid: Optional callable invoked with the child PID right after
50
- spawn. Used by bird_x.py to register child PIDs for cleanup
51
- tracking. Exceptions raised by the callback are suppressed.
191
+ spawn. Exceptions raised by the callback are suppressed. The
192
+ child is registered for cleanup_children() regardless.
193
+ input_text: Optional UTF-8 text supplied on stdin, never argv.
194
+ deadline_monotonic: Absolute operation deadline, including startup.
195
+ cancel: Optional event checked while waiting for the command.
196
+ cleanup_grace: Maximum TERM wait and subsequent KILL/reap wait, each.
197
+ capture_limit_bytes: Capture to temporary files instead of pipes,
198
+ reading at most this many bytes from each stream after exit.
52
199
 
53
200
  Returns:
54
201
  SubprocResult with returncode, stdout, and stderr as strings.
55
202
 
56
203
  Raises:
57
- SubprocTimeout: If the process exceeded ``timeout``.
204
+ SubprocTimeout: If the process timed out, was cancelled, or its
205
+ absolute deadline elapsed.
58
206
  FileNotFoundError: If the executable is not found.
59
207
  OSError: For other spawn failures.
60
208
  """
61
- preexec = os.setsid if hasattr(os, "setsid") else None
62
-
63
- proc = subprocess.Popen(
64
- list(cmd),
65
- stdout=subprocess.PIPE,
66
- stderr=subprocess.PIPE,
67
- text=True,
68
- encoding="utf-8",
69
- errors="replace",
70
- preexec_fn=preexec,
71
- env=env,
72
- )
73
-
74
- if on_pid is not None:
75
- try:
76
- on_pid(proc.pid)
77
- except Exception:
78
- pass
209
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
210
+ raise SubprocTimeout("Command deadline exceeded before spawn", started=False)
211
+ if cancel is not None and cancel.is_set():
212
+ raise SubprocTimeout("Command cancelled before spawn", started=False)
213
+ if capture_limit_bytes is not None and capture_limit_bytes < 0:
214
+ raise ValueError("capture limit must be nonnegative")
215
+ own_group = hasattr(os, "setsid") and hasattr(os, "killpg")
216
+ native_windows = _WINDOWS
217
+ capture = ExitStack()
218
+ job = None
219
+ try:
220
+ stdout_file = capture.enter_context(tempfile.TemporaryFile()) if capture_limit_bytes is not None else None
221
+ stderr_file = capture.enter_context(tempfile.TemporaryFile()) if capture_limit_bytes is not None else None
222
+ if native_windows:
223
+ from .windows_job import CREATE_SUSPENDED, WindowsJob
79
224
 
225
+ job = WindowsJob()
226
+ proc = subprocess.Popen(
227
+ list(cmd),
228
+ stdout=stdout_file if stdout_file is not None else subprocess.PIPE,
229
+ stderr=stderr_file if stderr_file is not None else subprocess.PIPE,
230
+ stdin=subprocess.PIPE if input_text is not None else None,
231
+ text=True,
232
+ encoding="utf-8",
233
+ errors="replace",
234
+ start_new_session=own_group,
235
+ env=env,
236
+ **({"creationflags": CREATE_SUSPENDED} if native_windows else {}),
237
+ )
238
+ except BaseException as exc:
239
+ if isinstance(exc, OSError):
240
+ exc._last30days_subproc_launch_failed = True
241
+ if job is not None:
242
+ job.close()
243
+ capture.close()
244
+ raise
80
245
  try:
81
- stdout, stderr = proc.communicate(timeout=timeout)
82
- except subprocess.TimeoutExpired:
83
- try:
84
- if hasattr(os, "killpg") and hasattr(os, "getpgid"):
85
- os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
86
- else:
87
- proc.kill()
88
- except (ProcessLookupError, PermissionError, OSError, AttributeError):
89
- proc.kill()
246
+ if native_windows:
247
+ from .windows_job import resume_suspended_process
248
+
249
+ try:
250
+ job.assign(proc._handle)
251
+ with _child_pids_lock:
252
+ _child_pids.add(proc.pid)
253
+ _child_jobs[proc.pid] = job
254
+ late = _shutting_down
255
+ if late:
256
+ if not job.terminate():
257
+ proc.kill()
258
+ else:
259
+ resume_suspended_process(proc.pid)
260
+ if late:
261
+ raise SubprocTimeout("Command cancelled during shutdown")
262
+ except BaseException as exc:
263
+ if isinstance(exc, OSError):
264
+ exc._last30days_subproc_launch_failed = True
265
+ if proc.poll() is None:
266
+ try:
267
+ proc.kill()
268
+ except OSError:
269
+ pass
270
+ try:
271
+ proc.wait(timeout=min(cleanup_grace, 2))
272
+ except subprocess.TimeoutExpired:
273
+ pass
274
+ raise
275
+ else:
276
+ register_child_pid(proc.pid)
277
+ if on_pid is not None:
278
+ try:
279
+ on_pid(proc.pid)
280
+ except Exception:
281
+ pass
90
282
  try:
91
- proc.wait(timeout=5)
283
+ deadline = time.monotonic() + timeout
284
+ if deadline_monotonic is not None:
285
+ deadline = min(deadline, deadline_monotonic)
286
+ first = True
287
+ while True:
288
+ remaining = deadline - time.monotonic()
289
+ if remaining <= 0 or (cancel is not None and cancel.is_set()):
290
+ raise subprocess.TimeoutExpired(cmd, timeout)
291
+ try:
292
+ stdout, stderr = proc.communicate(
293
+ input=input_text if first else None,
294
+ timeout=min(remaining, 0.05) if cancel is not None else remaining,
295
+ )
296
+ if deadline_monotonic is not None and time.monotonic() >= deadline_monotonic:
297
+ raise subprocess.TimeoutExpired(cmd, timeout)
298
+ break
299
+ except subprocess.TimeoutExpired:
300
+ if cancel is None or time.monotonic() >= deadline or cancel.is_set():
301
+ raise
302
+ finally:
303
+ first = False
92
304
  except subprocess.TimeoutExpired:
93
- # Child ignored SIGTERM (or our killpg lost the race); escalate.
94
- # Guard killpg/getpgid the same way the SIGTERM path above does:
95
- # they are POSIX-only and raise AttributeError on Windows. The
96
- # primary path was hardened in #552; this mirrors that guard on the
97
- # escalation path (added later in #433) so the same crash can't
98
- # re-surface here (#588).
305
+ term_deadline = time.monotonic() + cleanup_grace
306
+ pgid = None
99
307
  try:
100
- if hasattr(os, "killpg") and hasattr(os, "getpgid"):
101
- os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
102
- else:
308
+ if own_group:
309
+ pgid = proc.pid
310
+ os.killpg(pgid, signal.SIGTERM)
311
+ elif not (_WINDOWS and _kill_windows_managed_tree(proc.pid)):
103
312
  proc.kill()
104
313
  except (ProcessLookupError, PermissionError, OSError, AttributeError):
314
+ pgid = None
105
315
  proc.kill()
106
316
  try:
107
- proc.wait(timeout=5)
317
+ proc.wait(timeout=cleanup_grace)
318
+ except subprocess.TimeoutExpired:
319
+ # Child ignored SIGTERM (or our killpg lost the race); escalate.
320
+ try:
321
+ if pgid is not None:
322
+ os.killpg(pgid, signal.SIGKILL)
323
+ elif not (_WINDOWS and _kill_windows_managed_tree(proc.pid)):
324
+ proc.kill()
325
+ except (ProcessLookupError, PermissionError, OSError, AttributeError):
326
+ proc.kill()
327
+ try:
328
+ proc.wait(timeout=cleanup_grace)
329
+ except subprocess.TimeoutExpired:
330
+ pass # process unkillable (e.g. D-state); leave as zombie
331
+ else:
332
+ if pgid is not None:
333
+ # Reaping the leader does not end its process group. Keep
334
+ # the remaining members inside the original TERM grace.
335
+ while _signal_group(pgid, 0):
336
+ remaining = term_deadline - time.monotonic()
337
+ if remaining <= 0:
338
+ _signal_group(pgid, signal.SIGKILL)
339
+ break
340
+ time.sleep(min(_CLEANUP_POLL_SECONDS, remaining))
341
+ raise SubprocTimeout(f"Command {cmd[0]} timed out after {timeout}s")
342
+ if capture_limit_bytes is not None:
343
+ stdout_file.seek(0)
344
+ stderr_file.seek(0)
345
+ stdout = stdout_file.read(capture_limit_bytes).decode("utf-8", errors="replace")
346
+ stderr = stderr_file.read(capture_limit_bytes).decode("utf-8", errors="replace")
347
+ except SubprocTimeout:
348
+ raise
349
+ except BaseException:
350
+ if proc.poll() is None:
351
+ _kill_child_group(proc.pid, getattr(signal, "SIGKILL", signal.SIGTERM))
352
+ try:
353
+ proc.wait(timeout=cleanup_grace)
108
354
  except subprocess.TimeoutExpired:
109
- pass # process unkillable (e.g. D-state); leave as zombie
110
- raise SubprocTimeout(f"Command {cmd[0]} timed out after {timeout}s")
355
+ pass
356
+ raise
357
+ finally:
358
+ unregister_child_pid(proc.pid)
359
+ if job is not None:
360
+ job.close()
361
+ for pipe in (proc.stdin, proc.stdout, proc.stderr):
362
+ if pipe is not None:
363
+ pipe.close()
364
+ capture.close()
111
365
 
112
366
  return SubprocResult(
113
367
  returncode=proc.returncode,
@@ -1,8 +1,8 @@
1
1
  """Techmeme tech-news source for last30days.
2
2
 
3
- Shells out to ``techmeme-pp-cli`` (no auth) to surface current tech-news
4
- headlines relevant to a topic. Techmeme caches headlines locally, so the
5
- adapter ensures a one-time sync per run, then searches the cache.
3
+ Shells out to ``techmeme-pp-cli`` (no auth). The CLI's ``search`` command hits
4
+ Techmeme's live archive search endpoint (results back to ~2005) -- it never
5
+ reads the locally synced headline cache, so the adapter performs no ``sync``.
6
6
 
7
7
  Activation gate: only available when ``techmeme-pp-cli`` is on PATH.
8
8
  ``pipeline.available_sources`` checks ``shutil.which`` before including
@@ -11,23 +11,31 @@ Activation gate: only available when ``techmeme-pp-cli`` is on PATH.
11
11
  Surface choice: ``search "<topic>" --json`` (NOT ``--agent``). ``--agent``
12
12
  implies ``--compact``, which on older binaries stripped headline records to
13
13
  ``{}`` (fixed upstream in printing-press-library PR #1383); ``--json`` without
14
- ``--compact`` returns the populated ``{num, source, headline, link}`` shape on
15
- every binary version, so the adapter is robust regardless of the installed
16
- build.
17
-
18
- The search shape carries no per-item date or velocity, so headlines are dated
19
- to the sync time (Techmeme's cache is the current news cycle) and ranked on
20
- topic relevance plus source quality. Publication-name header rows (very short
21
- ``headline`` values) are dropped.
14
+ ``--compact`` returns the populated record shape on every binary version, so
15
+ the adapter is robust regardless of the installed build.
16
+
17
+ Dates: current binaries emit ``{num, source, headline, link, date}`` where
18
+ ``date`` is ISO ``YYYY-MM-DD`` (or ``""`` when Techmeme's markup was
19
+ unparseable). The adapter windows records to the research range
20
+ (``from_date <= date <= to_date``) so archive hits from years past never
21
+ masquerade as current news. Records with no usable date -- old binaries emit
22
+ no ``date`` key at all -- are kept but flow downstream with no date, so
23
+ ``normalize._normalize_techmeme`` assigns ``date_confidence: low``. Headlines
24
+ are never stamped with today's date. (This deliberately diverges from
25
+ ``lib/arxiv.py``, which drops entries with unparseable dates -- arXiv's feed
26
+ reliably carries dates, so an unparseable one is anomalous; Techmeme's old
27
+ binaries emit no ``date`` key at all, so dropping would zero out the source
28
+ for every user on an old binary.) Old binaries also print prose
29
+ (``No results for "q"``) to stdout on zero hits; that parses as an empty
30
+ result set, not a decode failure. Publication-name header rows (very short
31
+ ``headline`` values) are dropped; ranking is topic relevance plus rank decay.
22
32
  """
23
33
 
24
34
  from __future__ import annotations
25
35
 
26
36
  import json
37
+ import re
27
38
  import shutil
28
- import threading
29
- import time
30
- from datetime import datetime, timezone
31
39
  from typing import Any, Dict, List
32
40
 
33
41
  from . import log, subproc
@@ -48,19 +56,12 @@ DEPTH_CONFIG = {
48
56
  MIN_HEADLINE_WORDS = 4
49
57
 
50
58
  SEARCH_TIMEOUT = 30
51
- SYNC_TIMEOUT = 40
52
59
 
53
- # Refresh the local cache at most once per TTL window, guarded by a lock so
54
- # concurrent fan-out threads do not double-sync (two processes writing the same
55
- # cache file). A monotonic timestamp -- not a bare bool -- so a long-lived host
56
- # process or multi-report fan-out re-syncs instead of serving a stale cache
57
- # indefinitely (headlines are dated to the sync time, so staleness would
58
- # misrepresent old news as current).
59
- _SYNC_LOCK = threading.Lock()
60
- _SYNC_TTL = 600.0 # seconds
61
- _LAST_SYNC = 0.0
62
- # Sentinel used by tests to force a sync regardless of the TTL window.
63
- _NEVER_SYNCED = float("-inf")
60
+ # Old binaries print this prose to stdout (exit 0) on zero hits, even in JSON
61
+ # mode. It is a zero-result response, not malformed output.
62
+ _NO_RESULTS_PREFIX = "No results"
63
+
64
+ _ISO_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
64
65
 
65
66
 
66
67
  def _log(msg: str) -> None:
@@ -72,33 +73,10 @@ def _is_available() -> bool:
72
73
  return shutil.which(CLI_BIN) is not None
73
74
 
74
75
 
75
- def _today_iso() -> str:
76
- return datetime.now(timezone.utc).date().isoformat()
77
-
78
-
79
- def _ensure_synced() -> None:
80
- """Refresh the local headline cache at most once per TTL window.
81
-
82
- Lock-guarded so concurrent fan-out threads do not double-sync. Best-effort:
83
- a sync failure is logged and ignored (search can still run on an existing
84
- cache), and the timestamp is stamped before the call so a failing sync does
85
- not retry on every subquery within the window."""
86
- global _LAST_SYNC
87
- with _SYNC_LOCK:
88
- if time.monotonic() - _LAST_SYNC < _SYNC_TTL:
89
- return
90
- _LAST_SYNC = time.monotonic() # stamp first: a failure does not retry
91
- try:
92
- subproc.run_with_timeout([CLI_BIN, "sync", "--agent"], timeout=SYNC_TIMEOUT)
93
- _log("synced headline cache")
94
- except (subproc.SubprocTimeout, FileNotFoundError, OSError) as exc:
95
- _log(f"sync skipped: {exc}")
96
-
97
-
98
76
  def _build_search_args(topic: str) -> List[str]:
99
77
  # --json (not --agent) avoids --compact, which blanks headline records on
100
78
  # pre-PR-1383 binaries. Techmeme's `search` has no result-limit flag, so the
101
- # depth cap is applied client-side after parsing.
79
+ # depth cap is applied client-side after windowing.
102
80
  return [CLI_BIN, "search", topic, "--json"]
103
81
 
104
82
 
@@ -114,6 +92,18 @@ def _coerce_list(data: Any) -> List[Dict[str, Any]]:
114
92
  return []
115
93
 
116
94
 
95
+ def _record_iso_date(rec: Dict[str, Any]) -> str | None:
96
+ """The record's ``date`` as a valid ISO YYYY-MM-DD string, else None.
97
+
98
+ Old binaries emit no ``date`` key; current binaries emit ``""`` when
99
+ Techmeme's markup was unparseable. Anything that isn't a clean ISO date is
100
+ treated as absent."""
101
+ value = rec.get("date")
102
+ if isinstance(value, str) and _ISO_DATE_RE.match(value.strip()):
103
+ return value.strip()
104
+ return None
105
+
106
+
117
107
  def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
118
108
  """Invoke techmeme-pp-cli and return ``{"results": [...records...]}``.
119
109
  Never raises."""
@@ -140,6 +130,10 @@ def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
140
130
  stdout = result.stdout or ""
141
131
  if not stdout.strip():
142
132
  return {"results": []}
133
+ # Old binaries print `No results for "q"` prose (exit 0) even in JSON
134
+ # mode: a legitimate zero-hit response, not a decode failure.
135
+ if stdout.strip().startswith(_NO_RESULTS_PREFIX):
136
+ return {"results": []}
143
137
  try:
144
138
  data = json.loads(stdout)
145
139
  except json.JSONDecodeError as exc:
@@ -155,24 +149,55 @@ def search_techmeme(
155
149
  to_date: str,
156
150
  depth: str = "default",
157
151
  ) -> Dict[str, Any]:
158
- """Search Techmeme headlines via techmeme-pp-cli.
159
-
160
- Ensures a one-time cache sync, then searches. Returns a dict with a
161
- ``results`` list of raw records. On failure, ``results`` is empty.
152
+ """Search Techmeme's live archive via techmeme-pp-cli.
153
+
154
+ Windows records to ``from_date..to_date`` on each record's own ISO date
155
+ (lexicographic compare is exact for ISO strings). Records with no usable
156
+ date are kept -- their recency is resolved downstream as low confidence.
157
+ There is deliberately no keep-all fallback when nothing is in-window:
158
+ Techmeme's archive reaches back decades, so zero in-window records means
159
+ zero results, not "serve stale news". Returns a dict with a ``results``
160
+ list of raw records; on failure ``results`` is empty.
162
161
  """
163
162
  if not topic or not topic.strip():
164
163
  return {"results": []}
165
164
  if not _is_available():
166
165
  return {"results": [], "error": f"{CLI_BIN} not on PATH"}
167
- _ensure_synced()
168
166
  limit = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"])
169
167
  cmd = _build_search_args(topic)
170
168
  _log(f"search '{topic}' (cap={limit})")
171
169
  response = _run_cli(cmd, timeout=SEARCH_TIMEOUT)
172
- # Techmeme returns all matches; apply the depth cap client-side.
173
170
  records = response.get("results") or []
174
- if isinstance(records, list) and len(records) > limit:
175
- response["results"] = records[:limit]
171
+ if isinstance(records, list):
172
+ # Hard date window: drop records whose date falls outside the research
173
+ # range; keep undated records (old binaries / unparseable markup).
174
+ dated_in_window = []
175
+ undated = []
176
+ dropped = 0
177
+ for rec in records:
178
+ iso = _record_iso_date(rec)
179
+ if iso is None:
180
+ undated.append(rec)
181
+ elif from_date <= iso <= to_date:
182
+ dated_in_window.append(rec)
183
+ else:
184
+ dropped += 1
185
+ if dropped:
186
+ _log(f"dropped {dropped} records outside {from_date}..{to_date}")
187
+ if records and not dated_in_window and not dropped:
188
+ # Every record lacks a usable date: old techmeme-pp-cli (no date
189
+ # key) or a Techmeme markup change upstream. Windowing is inactive.
190
+ _log(
191
+ "no records carry usable dates; date windowing inactive "
192
+ "(old techmeme-pp-cli binary or upstream markup change; upgrade "
193
+ "via `npx -y @mvanhorn/printing-press-library install techmeme "
194
+ "--cli-only`)"
195
+ )
196
+ # Techmeme returns all matches; apply the depth cap after windowing.
197
+ # Dated in-window records take cap slots first so undated archive
198
+ # hits can never evict confirmed-fresh stories; undated records fill
199
+ # whatever slots remain.
200
+ response["results"] = (dated_in_window + undated)[:limit]
176
201
  _log(f"found {len(response.get('results') or [])} records")
177
202
  return response
178
203
 
@@ -195,16 +220,16 @@ def parse_techmeme_response(
195
220
  ) -> List[Dict[str, Any]]:
196
221
  """Parse a Techmeme search envelope into normalized item dicts.
197
222
 
198
- Drops publication-name header rows and records missing a link. Dates each
199
- headline to today (the cache is the current news cycle) and computes a
200
- token-overlap relevance hint. Returns dicts ready for
201
- ``normalize._normalize_techmeme``.
223
+ Drops publication-name header rows and records missing a link. Each item
224
+ carries the record's own ISO date, or None when the record has no usable
225
+ date (never today's date -- undated items get ``date_confidence: low``
226
+ downstream). Computes a token-overlap relevance hint. Returns dicts ready
227
+ for ``normalize._normalize_techmeme``.
202
228
  """
203
229
  raw = response.get("results") if isinstance(response, dict) else None
204
230
  if not isinstance(raw, list):
205
231
  return []
206
232
 
207
- today = _today_iso()
208
233
  items: List[Dict[str, Any]] = []
209
234
  for i, rec in enumerate(raw):
210
235
  if not isinstance(rec, dict):
@@ -227,7 +252,7 @@ def parse_techmeme_response(
227
252
  "title": headline,
228
253
  "url": link,
229
254
  "source_name": source_name,
230
- "date": today,
255
+ "date": _record_iso_date(rec),
231
256
  "engagement": {},
232
257
  "relevance": round(relevance, 2),
233
258
  "why_relevant": (