luckiest-co 1.0.47 → 1.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (264) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/package.json +1 -1
  4. package/skills/ab-testing/CHANGELOG.md +5 -0
  5. package/skills/ab-testing/SKILL.md +12 -9
  6. package/skills/ab-testing/evals/evals.json +18 -7
  7. package/skills/ab-testing/references/sample-size-guide.md +2 -2
  8. package/skills/ads/CHANGELOG.md +5 -0
  9. package/skills/ads/SKILL.md +4 -4
  10. package/skills/ai-seo/CHANGELOG.md +7 -0
  11. package/skills/ai-seo/SKILL.md +27 -20
  12. package/skills/analytics/CHANGELOG.md +6 -0
  13. package/skills/analytics/SKILL.md +4 -3
  14. package/skills/analytics/evals/evals.json +27 -0
  15. package/skills/analytics/references/ga4-implementation.md +7 -3
  16. package/skills/analytics/references/gtm-implementation.md +8 -2
  17. package/skills/aso/CHANGELOG.md +5 -0
  18. package/skills/aso/SKILL.md +4 -4
  19. package/skills/aso/evals/evals.json +15 -2
  20. package/skills/aso/references/benchmarks.md +4 -2
  21. package/skills/aso/references/google-play-specs.md +5 -1
  22. package/skills/brainstorm/SKILL.md +59 -16
  23. package/skills/brainstorm/builder-check-prompt.md +23 -0
  24. package/skills/churn-prevention/CHANGELOG.md +5 -0
  25. package/skills/churn-prevention/SKILL.md +3 -3
  26. package/skills/churn-prevention/evals/evals.json +14 -0
  27. package/skills/churn-prevention/references/dunning-playbook.md +6 -1
  28. package/skills/coder-brainstorming/CHANGELOG.md +10 -0
  29. package/skills/coder-brainstorming/SKILL.md +62 -20
  30. package/skills/coder-brainstorming/builder-check-prompt.md +23 -0
  31. package/skills/coder-brainstorming/scripts/server.cjs +1 -1
  32. package/skills/coder-debugging/CHANGELOG.md +4 -0
  33. package/skills/coder-debugging/SKILL.md +5 -5
  34. package/skills/coder-requesting-code-review/CHANGELOG.md +4 -0
  35. package/skills/coder-requesting-code-review/SKILL.md +4 -4
  36. package/skills/coder-subagent-driven-development/CHANGELOG.md +4 -0
  37. package/skills/coder-subagent-driven-development/SKILL.md +3 -3
  38. package/skills/coder-subagent-driven-development/scripts/sdd-workspace +3 -1
  39. package/skills/cold-email/CHANGELOG.md +7 -0
  40. package/skills/cold-email/SKILL.md +27 -7
  41. package/skills/cold-email/evals/evals.json +30 -0
  42. package/skills/cold-email/references/benchmarks.md +15 -11
  43. package/skills/cold-email/references/deliverability.md +110 -0
  44. package/skills/cold-email/references/linkedin-outreach.md +91 -0
  45. package/skills/cold-email/references/multichannel-cadence.md +79 -0
  46. package/skills/cold-email/references/outbound-plays.md +88 -0
  47. package/skills/cold-email/references/personalization.md +11 -0
  48. package/skills/cold-email/references/reply-handling.md +65 -0
  49. package/skills/competitor-profiling/CHANGELOG.md +5 -0
  50. package/skills/competitor-profiling/SKILL.md +4 -4
  51. package/skills/content-strategy/CHANGELOG.md +5 -0
  52. package/skills/content-strategy/SKILL.md +5 -3
  53. package/skills/copy-editing/CHANGELOG.md +5 -0
  54. package/skills/copy-editing/SKILL.md +5 -5
  55. package/skills/copy-editing/references/content-refresh.md +33 -0
  56. package/skills/cro/CHANGELOG.md +5 -0
  57. package/skills/cro/SKILL.md +14 -3
  58. package/skills/cro/references/form.md +1 -1
  59. package/skills/directory-submissions/CHANGELOG.md +5 -0
  60. package/skills/directory-submissions/SKILL.md +13 -4
  61. package/skills/directory-submissions/evals/evals.json +15 -1
  62. package/skills/directory-submissions/references/directory-list.md +2 -4
  63. package/skills/emails/CHANGELOG.md +5 -0
  64. package/skills/emails/SKILL.md +14 -4
  65. package/skills/launch/CHANGELOG.md +7 -0
  66. package/skills/launch/SKILL.md +8 -4
  67. package/skills/launch/evals/evals.json +29 -0
  68. package/skills/launch/references/shipped-changes.md +84 -0
  69. package/skills/launch/references/site-launch-qa.md +55 -0
  70. package/skills/lead-magnets/CHANGELOG.md +5 -0
  71. package/skills/lead-magnets/SKILL.md +6 -4
  72. package/skills/lead-magnets/evals/evals.json +18 -0
  73. package/skills/lead-magnets/references/content-to-skill-corpus-design.md +89 -0
  74. package/skills/lead-magnets/references/content-to-skill-release.md +71 -0
  75. package/skills/lead-magnets/references/content-to-skill.md +108 -0
  76. package/skills/marketing-plan/CHANGELOG.md +5 -0
  77. package/skills/marketing-plan/SKILL.md +3 -3
  78. package/skills/marketing-plan/evals/evals.json +13 -0
  79. package/skills/marketing-plan/references/budget-planning.md +26 -17
  80. package/skills/pricing/CHANGELOG.md +5 -0
  81. package/skills/pricing/SKILL.md +4 -4
  82. package/skills/pricing/evals/evals.json +12 -0
  83. package/skills/pricing/references/research-methods.md +8 -4
  84. package/skills/programmatic-seo/CHANGELOG.md +5 -0
  85. package/skills/programmatic-seo/SKILL.md +8 -4
  86. package/skills/programmatic-seo/evals/evals.json +13 -0
  87. package/skills/programmatic-seo/references/implementation-platforms.md +37 -0
  88. package/skills/prospecting/CHANGELOG.md +7 -0
  89. package/skills/prospecting/SKILL.md +27 -13
  90. package/skills/prospecting/evals/evals.json +41 -0
  91. package/skills/prospecting/references/account-research.md +59 -0
  92. package/skills/prospecting/references/b2b-prospecting.md +1 -1
  93. package/skills/prospecting/references/compliance.md +44 -6
  94. package/skills/prospecting/references/data-sources.md +13 -15
  95. package/skills/prospecting/references/enrichment-playbook.md +61 -0
  96. package/skills/prospecting/references/saas-prospecting.md +1 -1
  97. package/skills/prospecting/references/signal-plays.md +99 -0
  98. package/skills/prospecting/references/sourcing-and-enrichment.md +89 -0
  99. package/skills/referrals/CHANGELOG.md +5 -0
  100. package/skills/referrals/SKILL.md +10 -4
  101. package/skills/referrals/evals/evals.json +99 -0
  102. package/skills/referrals/references/affiliate-operations.md +64 -0
  103. package/skills/referrals/references/affiliate-programs.md +2 -2
  104. package/skills/referrals/references/program-examples.md +14 -6
  105. package/skills/referrals/references/review-platform-rules.md +36 -0
  106. package/skills/referrals/references/review-program-operations.md +62 -0
  107. package/skills/referrals/references/review-programs.md +96 -0
  108. package/skills/revops/CHANGELOG.md +5 -0
  109. package/skills/revops/SKILL.md +10 -6
  110. package/skills/revops/evals/evals.json +13 -0
  111. package/skills/revops/references/automation-playbooks.md +1 -1
  112. package/skills/revops/references/inbound-routing.md +43 -0
  113. package/skills/revops/references/lifecycle-definitions.md +31 -0
  114. package/skills/sales-enablement/CHANGELOG.md +5 -0
  115. package/skills/sales-enablement/SKILL.md +6 -4
  116. package/skills/sales-enablement/evals/evals.json +14 -0
  117. package/skills/sales-enablement/references/cold-call-scripts.md +68 -0
  118. package/skills/schema/CHANGELOG.md +5 -0
  119. package/skills/schema/SKILL.md +20 -8
  120. package/skills/schema/evals/evals.json +40 -0
  121. package/skills/schema/references/schema-examples.md +9 -15
  122. package/skills/seo-audit/CHANGELOG.md +5 -0
  123. package/skills/seo-audit/SKILL.md +10 -16
  124. package/skills/seo-audit/evals/evals.json +68 -0
  125. package/skills/seo-audit/references/international-seo.md +9 -11
  126. package/skills/seo-audit/references/local-seo-pages-and-measurement.md +39 -0
  127. package/skills/seo-audit/references/local-seo-profile-and-citations.md +41 -0
  128. package/skills/seo-audit/references/local-seo.md +86 -0
  129. package/skills/seo-audit/references/rankings-push.md +62 -0
  130. package/skills/seo-audit/references/title-tags.md +89 -0
  131. package/skills/signup/CHANGELOG.md +5 -0
  132. package/skills/signup/SKILL.md +7 -3
  133. package/skills/site-architecture/CHANGELOG.md +5 -0
  134. package/skills/site-architecture/SKILL.md +5 -4
  135. package/skills/site-architecture/evals/evals.json +25 -0
  136. package/skills/site-architecture/references/platforms-and-migration.md +84 -0
  137. package/skills/social/CHANGELOG.md +5 -0
  138. package/skills/social/SKILL.md +4 -4
  139. package/skills/trends/.skillignore +3 -0
  140. package/skills/trends/CHANGELOG.md +20 -0
  141. package/skills/trends/SKILL.md +12 -8
  142. package/skills/trends/references/agent-mode.md +36 -0
  143. package/skills/trends/references/category-peers.md +38 -0
  144. package/skills/trends/references/comparison.md +227 -0
  145. package/skills/trends/references/competitors.md +29 -0
  146. package/skills/trends/references/discovery.md +90 -0
  147. package/skills/trends/references/engine-contract.md +111 -1889
  148. package/skills/trends/references/followups.md +112 -0
  149. package/skills/trends/references/grok-bot-x.md +55 -0
  150. package/skills/trends/references/hiring-signals.md +19 -0
  151. package/skills/trends/references/library-queue.md +33 -0
  152. package/skills/trends/references/recommendations.md +86 -0
  153. package/skills/trends/references/research-runbook.md +781 -0
  154. package/skills/trends/references/runtime.md +165 -0
  155. package/skills/trends/references/save-html-brief.md +39 -20
  156. package/skills/trends/references/setup-wizard.md +253 -0
  157. package/skills/trends/references/synthesis.md +289 -0
  158. package/skills/trends/scripts/box_chrome_login.py +209 -0
  159. package/skills/trends/scripts/briefing.py +9 -17
  160. package/skills/trends/scripts/last30days.py +3247 -280
  161. package/skills/trends/scripts/lib/agentcookie.py +196 -0
  162. package/skills/trends/scripts/lib/amazon.py +877 -0
  163. package/skills/trends/scripts/lib/arxiv.py +42 -8
  164. package/skills/trends/scripts/lib/backends.py +769 -0
  165. package/skills/trends/scripts/lib/bird_x.py +340 -57
  166. package/skills/trends/scripts/lib/bluesky.py +84 -10
  167. package/skills/trends/scripts/lib/bounded_get.py +156 -0
  168. package/skills/trends/scripts/lib/brightdata.py +291 -0
  169. package/skills/trends/scripts/lib/categories.py +7 -1
  170. package/skills/trends/scripts/lib/chrome_cdp.py +455 -0
  171. package/skills/trends/scripts/lib/chrome_cookies.py +132 -39
  172. package/skills/trends/scripts/lib/cluster.py +31 -54
  173. package/skills/trends/scripts/lib/competitors.py +10 -1
  174. package/skills/trends/scripts/lib/cookie_extract.py +221 -44
  175. package/skills/trends/scripts/lib/cookie_paths.py +27 -0
  176. package/skills/trends/scripts/lib/corpus.py +439 -0
  177. package/skills/trends/scripts/lib/devto.py +313 -0
  178. package/skills/trends/scripts/lib/discovery_handoff.py +988 -0
  179. package/skills/trends/scripts/lib/doctor.py +2166 -0
  180. package/skills/trends/scripts/lib/dripstack.py +203 -0
  181. package/skills/trends/scripts/lib/entity_extract.py +37 -0
  182. package/skills/trends/scripts/lib/env.py +1092 -138
  183. package/skills/trends/scripts/lib/fanout.py +11 -1
  184. package/skills/trends/scripts/lib/feed.py +61 -0
  185. package/skills/trends/scripts/lib/fetch_memo.py +135 -0
  186. package/skills/trends/scripts/lib/freshness.py +566 -0
  187. package/skills/trends/scripts/lib/fusion.py +314 -28
  188. package/skills/trends/scripts/lib/github.py +438 -24
  189. package/skills/trends/scripts/lib/grok_x.py +1237 -0
  190. package/skills/trends/scripts/lib/grounding.py +129 -11
  191. package/skills/trends/scripts/lib/hackernews.py +61 -4
  192. package/skills/trends/scripts/lib/health.py +373 -0
  193. package/skills/trends/scripts/lib/hosted.py +333 -0
  194. package/skills/trends/scripts/lib/html_publish.py +34 -0
  195. package/skills/trends/scripts/lib/html_render.py +169 -3
  196. package/skills/trends/scripts/lib/http.py +1215 -70
  197. package/skills/trends/scripts/lib/instagram.py +132 -9
  198. package/skills/trends/scripts/lib/jobs.py +4 -2
  199. package/skills/trends/scripts/lib/library.py +308 -0
  200. package/skills/trends/scripts/lib/library_index.py +477 -0
  201. package/skills/trends/scripts/lib/linkedin.py +271 -24
  202. package/skills/trends/scripts/lib/log.py +5 -2
  203. package/skills/trends/scripts/lib/meta_ads.py +982 -0
  204. package/skills/trends/scripts/lib/normalize.py +382 -28
  205. package/skills/trends/scripts/lib/parallel_mcp.py +234 -0
  206. package/skills/trends/scripts/lib/permission_preflight.py +42 -3
  207. package/skills/trends/scripts/lib/perplexity.py +1080 -468
  208. package/skills/trends/scripts/lib/pipeline.py +5079 -211
  209. package/skills/trends/scripts/lib/planner.py +396 -53
  210. package/skills/trends/scripts/lib/polymarket.py +285 -23
  211. package/skills/trends/scripts/lib/prescriptions.py +432 -0
  212. package/skills/trends/scripts/lib/private_files.py +21 -0
  213. package/skills/trends/scripts/lib/providers.py +231 -10
  214. package/skills/trends/scripts/lib/quality_nudge.py +122 -37
  215. package/skills/trends/scripts/lib/reddit.py +177 -12
  216. package/skills/trends/scripts/lib/reddit_arctic.py +146 -5
  217. package/skills/trends/scripts/lib/reddit_keyless.py +121 -60
  218. package/skills/trends/scripts/lib/reddit_listing.py +215 -14
  219. package/skills/trends/scripts/lib/reddit_public.py +12 -13
  220. package/skills/trends/scripts/lib/reddit_search.py +304 -0
  221. package/skills/trends/scripts/lib/reddit_shreddit.py +51 -7
  222. package/skills/trends/scripts/lib/registers.py +134 -0
  223. package/skills/trends/scripts/lib/relevance.py +17 -0
  224. package/skills/trends/scripts/lib/render.py +2161 -271
  225. package/skills/trends/scripts/lib/rerank.py +623 -55
  226. package/skills/trends/scripts/lib/resolve.py +50 -1
  227. package/skills/trends/scripts/lib/safari_cookies.py +42 -15
  228. package/skills/trends/scripts/lib/schema.py +782 -1
  229. package/skills/trends/scripts/lib/setup_wizard.py +779 -120
  230. package/skills/trends/scripts/lib/signals.py +206 -7
  231. package/skills/trends/scripts/lib/stocktwits.py +374 -0
  232. package/skills/trends/scripts/lib/subproc.py +307 -53
  233. package/skills/trends/scripts/lib/techmeme.py +89 -64
  234. package/skills/trends/scripts/lib/telegram.py +317 -0
  235. package/skills/trends/scripts/lib/tiktok.py +13 -9
  236. package/skills/trends/scripts/lib/topic_shape.py +340 -0
  237. package/skills/trends/scripts/lib/transcribe.py +12 -3
  238. package/skills/trends/scripts/lib/trustpilot.py +256 -5
  239. package/skills/trends/scripts/lib/truthsocial.py +9 -1
  240. package/skills/trends/scripts/lib/ui.py +6 -0
  241. package/skills/trends/scripts/lib/usage.py +132 -0
  242. package/skills/trends/scripts/lib/web_search_keyless.py +63 -4
  243. package/skills/trends/scripts/lib/windows_job.py +138 -0
  244. package/skills/trends/scripts/lib/x_api.py +824 -0
  245. package/skills/trends/scripts/lib/x_envelope.py +733 -0
  246. package/skills/trends/scripts/lib/x_judge.py +292 -0
  247. package/skills/trends/scripts/lib/xai_x.py +26 -3
  248. package/skills/trends/scripts/lib/xquik.py +89 -15
  249. package/skills/trends/scripts/lib/xurl_x.py +284 -91
  250. package/skills/trends/scripts/lib/youtube_yt.py +570 -119
  251. package/skills/trends/scripts/setup-keychain.sh +12 -4
  252. package/skills/trends/scripts/setup-pass.sh +3 -0
  253. package/skills/trends/scripts/store.py +444 -50
  254. package/skills/trends/scripts/watchlist.py +38 -11
  255. package/skills/trends/scripts/x_lane_parity.py +107 -0
  256. package/skills/video-studio/CHANGELOG.md +4 -0
  257. package/skills/video-studio/SKILL.md +4 -4
  258. package/skills/video-studio/references/styles/product-flight.md +45 -0
  259. package/skills/video-studio/references/styles.md +3 -1
  260. package/skills/video-studio-motion/ATTRIBUTION.md +1 -1
  261. package/skills/video-studio-motion/CHANGELOG.md +4 -0
  262. package/skills/video-studio-motion/SKILL.md +8 -5
  263. package/skills/coder-brainstorming/spec-document-reviewer-prompt.md +0 -49
  264. package/skills/trends/scripts/lib/reddit_rss.py +0 -224
@@ -0,0 +1,333 @@
1
+ """Remote API client for last30days (optional hosted-backend mode).
2
+
3
+ When both LAST30DAYS_API_KEY and LAST30DAYS_API_BASE are set, the engine
4
+ submits the topic to the configured remote API, polls until the run reaches a
5
+ terminal status, streams narration progress to stderr, and renders the
6
+ server's report. No local provider keys are required in this mode. The
7
+ endpoint comes only from LAST30DAYS_API_BASE - there is no built-in default.
8
+
9
+ Contract (API v1):
10
+ POST {base}/search Authorization: Bearer <key>
11
+ {"query": ..., "depth": "quick"|"default"|"deep",
12
+ "register"?: "exec"|"dev"|"creator"|"eli5"}
13
+ -> 200 {"search_id": "<uuid>", "status": "running"}
14
+ -> 200 clarify payload {"needs_clarification": true, ...}
15
+ -> 401 {"error"} / 402 {"error","requires_credits",
16
+ "balance","needed"} / 429 {"error"}
17
+ GET {base}/search?id=<uuid> same auth header; poll until status is
18
+ terminal ("complete" | "error"). Running rows carry
19
+ "stderr" (narration + engine lines) and "eta_ms";
20
+ terminal complete rows carry "synthesis_text" and
21
+ "raw_markdown" (stderr stripped).
22
+
23
+ This module carries ZERO pricing, rate-card, cost, or billing logic.
24
+ Balance/credit numbers are only ever displayed verbatim from API responses.
25
+ The API key is never printed, logged, or persisted by this module.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import re
33
+ import sys
34
+ import time
35
+
36
+ from . import env, http, providers, usage
37
+ from .log import source_log
38
+
39
+ # Distinct exit code for the clarify gate so the invoking model can tell
40
+ # "re-run with a chosen angle" apart from a plain failure (1).
41
+ EXIT_CLARIFY = 3
42
+
43
+ POLL_INITIAL_DELAY = 3.0
44
+ POLL_MAX_DELAY = 10.0
45
+ POLL_TIMEOUT_SECONDS = 15 * 60
46
+ # GET is idempotent: retry a few times across network blips before giving up.
47
+ POLL_NETWORK_RETRIES = 3
48
+ # Cadence for the compact elapsed/eta progress line (seconds).
49
+ PROGRESS_LINE_INTERVAL = 15.0
50
+
51
+ NARRATE_PREFIX = "[narrate] step="
52
+ TERMINAL_STATUSES = {"complete", "error"}
53
+
54
+
55
+ def _err(msg: str) -> None:
56
+ source_log("hosted", msg, tty_only=False)
57
+
58
+
59
+ def _api_base() -> str:
60
+ # Endpoint comes only from the environment - no built-in default. Hosted
61
+ # mode is gated on this being set (see last30days.py), so by the time this
62
+ # is called it is populated; an empty value means "not configured".
63
+ base = (os.environ.get("LAST30DAYS_API_BASE") or "").rstrip("/")
64
+ if not providers.allowed_base_url_override(base):
65
+ raise ValueError(
66
+ "LAST30DAYS_API_BASE must use https:// "
67
+ "(http:// is allowed only for loopback hosts)."
68
+ )
69
+ return base
70
+
71
+
72
+ def _billing_url() -> str:
73
+ """Derive a billing link from the configured base, so no URL is hardcoded.
74
+ Convention: the base is the API-version root (e.g. ends in /api/v1); drop
75
+ that segment and point at the account's billing page."""
76
+ base = _api_base()
77
+ root = re.sub(r"/api/v\d+$", "", base)
78
+ return f"{root}/dashboard/billing"
79
+
80
+
81
+ def _auth_headers() -> dict[str, str]:
82
+ # Key is read at call time and placed only in the request header;
83
+ # it must never be interpolated into any log or output line.
84
+ key = env.read_secret_env("LAST30DAYS_API_KEY") or ""
85
+ return {"Authorization": f"Bearer {key}"}
86
+
87
+
88
+ def submit(query: str, depth: str, register: str = "default") -> dict:
89
+ """POST the search. retries=1: a blind POST retry could double-submit."""
90
+ payload = {"query": query, "depth": depth}
91
+ if register != "default":
92
+ payload["register"] = register
93
+ usage.begin("hosted")
94
+ return http.post(
95
+ f"{_api_base()}/search",
96
+ json_data=payload,
97
+ headers=_auth_headers(),
98
+ retries=1,
99
+ )
100
+
101
+
102
+ def poll(search_id: str) -> dict:
103
+ """GET the search row once. Callers own the retry loop (GET is idempotent)."""
104
+ return http.get(
105
+ f"{_api_base()}/search",
106
+ headers=_auth_headers(),
107
+ params={"id": search_id},
108
+ retries=1,
109
+ )
110
+
111
+
112
+ def _parse_error_body(exc: http.HTTPError) -> dict:
113
+ if not exc.body:
114
+ return {}
115
+ try:
116
+ parsed = json.loads(exc.body)
117
+ except (json.JSONDecodeError, TypeError):
118
+ return {}
119
+ return parsed if isinstance(parsed, dict) else {}
120
+
121
+
122
+ def _handle_http_error(exc: http.HTTPError) -> int:
123
+ body = _parse_error_body(exc)
124
+ if exc.status_code == 401:
125
+ _err(
126
+ "API key rejected: invalid or revoked. Check "
127
+ "LAST30DAYS_API_KEY (and LAST30DAYS_API_BASE), or unset them "
128
+ "to fall back to local sources."
129
+ )
130
+ return 1
131
+ if exc.status_code == 402:
132
+ _err(f"API: {body.get('error') or 'insufficient credits.'}")
133
+ if body.get("balance") is not None or body.get("needed") is not None:
134
+ _err(
135
+ f"Balance: {body.get('balance')} credits. "
136
+ f"Needed for this search: {body.get('needed')} credits."
137
+ )
138
+ _err(f"Add credits at {_billing_url()}")
139
+ return 1
140
+ if exc.status_code == 429:
141
+ _err(
142
+ f"API rate limit hit: "
143
+ f"{body.get('error') or 'too many requests.'} "
144
+ "Wait a minute and re-run."
145
+ )
146
+ return 1
147
+ _err(f"API request failed: {exc}")
148
+ return 1
149
+
150
+
151
+ def _handle_clarify(resp: dict) -> int:
152
+ question = resp.get("question") or "The API needs a clarification before searching."
153
+ options = resp.get("options") or []
154
+ _err(f"Clarification needed before this search runs: {question}")
155
+ for index, option in enumerate(options, 1):
156
+ label = option if isinstance(option, str) else json.dumps(option)
157
+ sys.stderr.write(f" {index}. {label}\n")
158
+ sys.stderr.flush()
159
+ _err(
160
+ "No search was started. Re-run last30days with the chosen angle "
161
+ "folded into the topic text."
162
+ )
163
+ return EXIT_CLARIFY
164
+
165
+
166
+ def _print_new_narration(stderr_blob: str, seen: set[str]) -> bool:
167
+ """Print each '[narrate] step=' line once, verbatim. Returns True if any new."""
168
+ printed = False
169
+ for line in stderr_blob.splitlines():
170
+ if line.startswith(NARRATE_PREFIX) and line not in seen:
171
+ seen.add(line)
172
+ sys.stderr.write(f"{line}\n")
173
+ printed = True
174
+ if printed:
175
+ sys.stderr.flush()
176
+ return printed
177
+
178
+
179
+ def _print_progress_line(elapsed: float, eta_ms) -> None:
180
+ line = f"elapsed {int(elapsed)}s"
181
+ if isinstance(eta_ms, (int, float)) and eta_ms > 0:
182
+ line += f", eta ~{int(eta_ms / 1000)}s"
183
+ _err(line)
184
+
185
+
186
+ def _poll_with_retry(search_id: str) -> dict | None:
187
+ """Poll once, retrying transient network failures. None means give up
188
+ (a user-facing message has already been printed)."""
189
+ last_error: http.HTTPError | None = None
190
+ for attempt in range(POLL_NETWORK_RETRIES):
191
+ try:
192
+ return poll(search_id)
193
+ except http.HTTPError as exc:
194
+ if exc.status_code is not None and 400 <= exc.status_code < 500 and exc.status_code != 429:
195
+ _handle_http_error(exc)
196
+ return None
197
+ # Network blip / timeout / 5xx / 429: GET is idempotent, retry.
198
+ last_error = exc
199
+ if attempt < POLL_NETWORK_RETRIES - 1:
200
+ time.sleep(POLL_INITIAL_DELAY)
201
+ _err(
202
+ f"API unreachable while polling search {search_id} "
203
+ f"after {POLL_NETWORK_RETRIES} attempts: {last_error}"
204
+ )
205
+ return None
206
+
207
+
208
+ def _slugify(value: str) -> str:
209
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
210
+ return slug or "last30days"
211
+
212
+
213
+ def _save_output(topic: str, content: str, emit: str, save_dir: str, suffix: str):
214
+ """Mirror local save_output() naming: <slug>-raw[-suffix].<ext>."""
215
+ from datetime import datetime
216
+ from pathlib import Path
217
+
218
+ path = Path(save_dir).expanduser().resolve()
219
+ path.mkdir(parents=True, exist_ok=True)
220
+ slug = _slugify(topic)
221
+ extension = "json" if emit == "json" else "md"
222
+ suffix_part = f"-{suffix}" if suffix else ""
223
+ base = path / f"{slug}-raw{suffix_part}.{extension}"
224
+ date_str = datetime.now().strftime('%Y-%m-%d')
225
+ candidates = [base]
226
+ candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}.{extension}")
227
+ for i in range(1, 100):
228
+ candidates.append(path / f"{slug}-raw{suffix_part}-{date_str}-{i}.{extension}")
229
+ encoded = content.encode("utf-8")
230
+ for candidate in candidates:
231
+ try:
232
+ fd = os.open(candidate, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
233
+ except FileExistsError:
234
+ continue
235
+ with os.fdopen(fd, "wb") as f:
236
+ f.write(encoded)
237
+ return candidate
238
+ # Fallback: all 101 candidates existed (extremely unlikely).
239
+ raise RuntimeError(
240
+ f"_save_output: could not find a unique filename after 101 attempts in {path}"
241
+ )
242
+
243
+
244
+ def _render_complete(row: dict, topic: str, emit: str, save_dir, save_suffix: str) -> int:
245
+ synthesis = row.get("synthesis_text") or ""
246
+ raw_markdown = row.get("raw_markdown") or ""
247
+ if emit == "json":
248
+ payload = {
249
+ key: row.get(key)
250
+ for key in ("id", "status", "synthesis_text", "raw_markdown")
251
+ if key in row
252
+ }
253
+ rendered = json.dumps(payload, indent=2, sort_keys=True)
254
+ save_content = rendered
255
+ else:
256
+ # The server report is the content source; it already synthesized.
257
+ # All markdown-ish emit modes print the synthesis text as-is.
258
+ rendered = synthesis or raw_markdown
259
+ save_content = raw_markdown or synthesis
260
+ if save_dir:
261
+ out_path = _save_output(topic, save_content, emit, save_dir, save_suffix)
262
+ sys.stderr.write(f"[last30days] Saved output to {out_path}\n")
263
+ sys.stderr.flush()
264
+ print(rendered)
265
+ return 0
266
+
267
+
268
+ def run_hosted(
269
+ topic: str,
270
+ depth: str,
271
+ *,
272
+ emit: str = "compact",
273
+ save_dir=None,
274
+ save_suffix: str = "",
275
+ register: str = "default",
276
+ ) -> int:
277
+ """Submit topic to the remote API, poll to terminal status, render report."""
278
+ try:
279
+ base = _api_base()
280
+ except ValueError as exc:
281
+ _err(str(exc))
282
+ return 1
283
+ _err(f"Running via last30days API ({base}), depth={depth}")
284
+ try:
285
+ resp = submit(topic, depth, register=register)
286
+ except http.HTTPError as exc:
287
+ return _handle_http_error(exc)
288
+
289
+ if resp.get("needs_clarification"):
290
+ return _handle_clarify(resp)
291
+
292
+ search_id = resp.get("search_id")
293
+ if not search_id:
294
+ _err(f"Unexpected API response (no search_id): {json.dumps(resp)[:200]}")
295
+ return 1
296
+ _err(f"Search submitted (id: {search_id}). Polling for results...")
297
+
298
+ started = time.monotonic()
299
+ delay = POLL_INITIAL_DELAY
300
+ seen_narration: set[str] = set()
301
+ last_progress_line = 0.0
302
+ while True:
303
+ elapsed = time.monotonic() - started
304
+ if elapsed > POLL_TIMEOUT_SECONDS:
305
+ _err(
306
+ f"Search did not finish within "
307
+ f"{POLL_TIMEOUT_SECONDS // 60} minutes (id: {search_id}). "
308
+ "It may still complete server-side; check the dashboard."
309
+ )
310
+ return 1
311
+ time.sleep(delay)
312
+ delay = min(delay * 2, POLL_MAX_DELAY)
313
+
314
+ row = _poll_with_retry(search_id)
315
+ if row is None:
316
+ return 1
317
+
318
+ status = row.get("status")
319
+ narrated = _print_new_narration(row.get("stderr") or "", seen_narration)
320
+ elapsed = time.monotonic() - started
321
+ if status not in TERMINAL_STATUSES and (
322
+ narrated or elapsed - last_progress_line >= PROGRESS_LINE_INTERVAL or last_progress_line == 0.0
323
+ ):
324
+ _print_progress_line(elapsed, row.get("eta_ms"))
325
+ last_progress_line = elapsed
326
+
327
+ if status == "error":
328
+ _err(f"Search failed: {row.get('error') or 'unknown server error'}")
329
+ return 1
330
+ if status == "complete":
331
+ _err(f"Search complete in {int(elapsed)}s.")
332
+ return _render_complete(row, topic, emit, save_dir, save_suffix)
333
+ # pending | running -> keep polling
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ from collections.abc import Mapping
6
7
  from typing import Any, Callable
7
8
  from urllib.error import HTTPError, URLError
8
9
  from urllib.request import Request, urlopen
@@ -15,6 +16,14 @@ class HtmlPublishError(RuntimeError):
15
16
  """Raised when the hosted HTML publish endpoint rejects the artifact."""
16
17
 
17
18
 
19
+ class HtmlPublishBatchResult(dict[str, dict[str, Any]]):
20
+ """Successful document publishes plus an optional later failure."""
21
+
22
+ def __init__(self) -> None:
23
+ super().__init__()
24
+ self.error: HtmlPublishError | None = None
25
+
26
+
18
27
  def publish_html(
19
28
  html_content: str,
20
29
  *,
@@ -62,6 +71,31 @@ def publish_html(
62
71
  return result
63
72
 
64
73
 
74
+ def publish_html_documents(
75
+ documents: Mapping[str, str],
76
+ *,
77
+ password: str | None = None,
78
+ endpoint: str = DEFAULT_ENDPOINT,
79
+ opener: Callable[..., Any] | None = None,
80
+ timeout: int = 30,
81
+ ) -> HtmlPublishBatchResult:
82
+ """Publish a named set of documents, preserving caller order in results."""
83
+ results = HtmlPublishBatchResult()
84
+ for name, content in documents.items():
85
+ try:
86
+ results[name] = publish_html(
87
+ content,
88
+ password=password,
89
+ endpoint=endpoint,
90
+ opener=opener,
91
+ timeout=timeout,
92
+ )
93
+ except HtmlPublishError as exc:
94
+ results.error = exc
95
+ break
96
+ return results
97
+
98
+
65
99
  def _error_message(status: int, detail: str) -> str:
66
100
  try:
67
101
  payload = json.loads(detail)
@@ -4,9 +4,12 @@ from __future__ import annotations
4
4
 
5
5
  import html
6
6
  import re
7
+ from collections import OrderedDict
8
+ from collections.abc import Mapping, Sequence
7
9
  from datetime import date
8
10
 
9
- from . import render, schema
11
+ from . import registers, render, schema
12
+ from .library import LibraryEntry
10
13
 
11
14
 
12
15
  PROSE_LABELS = [
@@ -274,6 +277,47 @@ td:first-child { color: var(--fg); font-weight: 500; }
274
277
  font-size: 0.95em;
275
278
  }
276
279
 
280
+ .library-hero {
281
+ padding: 1.5rem 0 2.5rem;
282
+ border-bottom: 1px solid var(--border);
283
+ }
284
+
285
+ .library-hero h1 { margin-bottom: 0.65rem; }
286
+ .library-hero p { max-width: 42rem; color: var(--fg-muted); }
287
+ .library-hero .subscribe { font-weight: 700; }
288
+
289
+ .library-topic { margin-top: 3rem; }
290
+
291
+ .library-topic-heading {
292
+ display: flex;
293
+ align-items: baseline;
294
+ justify-content: space-between;
295
+ gap: 1rem;
296
+ border-bottom: 1px solid var(--border);
297
+ }
298
+
299
+ .library-topic-heading h2 { margin-bottom: 0.65rem; }
300
+ .library-topic-heading a { font-size: 0.85rem; }
301
+
302
+ .library-entry {
303
+ display: grid;
304
+ grid-template-columns: 7rem minmax(0, 1fr);
305
+ column-gap: 1.25rem;
306
+ padding: 1.35rem 0;
307
+ border-bottom: 1px solid var(--border);
308
+ }
309
+
310
+ .library-entry time {
311
+ grid-row: 1 / span 2;
312
+ color: var(--fg-subtle);
313
+ font-family: 'JetBrains Mono', ui-monospace, 'SF Mono', Menlo, monospace;
314
+ font-size: 0.8rem;
315
+ }
316
+
317
+ .library-entry h3 { margin: 0 0 0.35rem; font-size: 1.1rem; }
318
+ .library-entry p { margin: 0; color: var(--fg-muted); }
319
+ .library-empty { padding: 3rem 0; }
320
+
277
321
  @media print {
278
322
  :root {
279
323
  --bg: #ffffff;
@@ -319,6 +363,8 @@ td:first-child { color: var(--fg); font-weight: 500; }
319
363
  h1 { font-size: 25px; }
320
364
  .badge { font-size: 12px; }
321
365
  th, td { padding: 0.65rem 0.5rem; }
366
+ .library-entry { grid-template-columns: 1fr; }
367
+ .library-entry time { grid-row: auto; margin-bottom: 0.5rem; }
322
368
  }
323
369
  """.strip()
324
370
 
@@ -346,9 +392,15 @@ def render_html(
346
392
  fun_level: str = "medium",
347
393
  save_path: str | None = None,
348
394
  synthesis_md: str | None = None,
395
+ register: str = "default",
349
396
  ) -> str:
350
- _ = fun_level
351
- md = render.render_for_html(report, synthesis_md=synthesis_md, save_path=save_path)
397
+ md = render.render_for_html(
398
+ report,
399
+ synthesis_md=synthesis_md,
400
+ save_path=save_path,
401
+ fun_level=fun_level,
402
+ register=register,
403
+ )
352
404
  md = _strip_evidence_block(md)
353
405
  md = _strip_invitation(md)
354
406
  md = _strip_canonical_boundary(md)
@@ -383,6 +435,120 @@ def render_html_comparison(
383
435
  return _wrap_in_template(body, colophon, topic)
384
436
 
385
437
 
438
+ LIBRARY_BRIEF_MARKER = "<!-- generated by last30days library feed -->"
439
+
440
+
441
+ def render_library_brief(entry: LibraryEntry, *, include_private: bool = True) -> str:
442
+ """Render a scanned Markdown or JSON briefing as a safe standalone page."""
443
+ md = _strip_invitation(entry.content)
444
+ md = _strip_canonical_boundary(md)
445
+ if not include_private:
446
+ md = _strip_private_corpus(md)
447
+ body = _markdown_to_html(md)
448
+ body = _wrap_engine_footer(body)
449
+ colophon = (
450
+ '<footer class="colophon">'
451
+ f"Saved research · {html.escape(entry.published_date.isoformat())} · "
452
+ f"{html.escape(entry.topic)}"
453
+ "</footer>"
454
+ )
455
+ rendered = scrub_publishable_digit_runs(
456
+ _wrap_in_template(body, colophon, entry.headline)
457
+ )
458
+ # Ownership marker consumed by the library-feed prune: a generated-looking
459
+ # filename alone must never be grounds for deletion.
460
+ return rendered.replace("</body>", f"{LIBRARY_BRIEF_MARKER}\n</body>", 1)
461
+
462
+
463
+ _PRIVATE_CORPUS_BLOCK = re.compile(
464
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_START -->.*?"
465
+ r"<!-- LAST30DAYS_PRIVATE_CORPUS_END -->\s*",
466
+ re.DOTALL,
467
+ )
468
+
469
+
470
+ def _strip_private_corpus(markdown: str) -> str:
471
+ """Remove the renderer-marked local corpus section before publication."""
472
+ return _PRIVATE_CORPUS_BLOCK.sub("", markdown)
473
+
474
+
475
+ def render_library_index(
476
+ entries: Sequence[LibraryEntry],
477
+ *,
478
+ entry_urls: Mapping[str, str] | None = None,
479
+ feed_url: str | None = "feed.xml",
480
+ ) -> str:
481
+ """Render the reverse-chronological, topic-grouped research index."""
482
+ urls = entry_urls or {}
483
+ grouped: OrderedDict[str, list[LibraryEntry]] = OrderedDict()
484
+ for entry in entries:
485
+ grouped.setdefault(entry.topic, []).append(entry)
486
+
487
+ parts = [
488
+ '<header class="library-hero">',
489
+ '<span class="badge">RESEARCH LIBRARY</span>',
490
+ '<h1>What the community is learning</h1>',
491
+ ]
492
+ if feed_url is None:
493
+ parts.append('<p>Saved last30days briefs, newest first.</p>')
494
+ else:
495
+ parts.extend([
496
+ '<p>Saved last30days briefs, newest first. Follow the Atom feed to keep up.</p>',
497
+ f'<p><a class="subscribe" href="{html.escape(feed_url, quote=True)}">Subscribe via Atom</a></p>',
498
+ ])
499
+ parts.append('</header>')
500
+ if not entries:
501
+ parts.append('<section class="library-empty"><h2>No saved briefs yet</h2><p>Run last30days research and this library will fill itself.</p></section>')
502
+ for topic, topic_entries in grouped.items():
503
+ latest = topic_entries[0]
504
+ latest_url = urls.get(latest.entry_id, f"briefs/{latest.output_name}")
505
+ parts.extend([
506
+ '<section class="library-topic">',
507
+ '<div class="library-topic-heading">',
508
+ f'<h2>{html.escape(topic)}</h2>',
509
+ f'<a href="{html.escape(latest_url, quote=True)}">Latest</a>',
510
+ '</div>',
511
+ ])
512
+ for entry in topic_entries:
513
+ url = urls.get(entry.entry_id, f"briefs/{entry.output_name}")
514
+ parts.extend([
515
+ '<article class="library-entry">',
516
+ f'<time datetime="{entry.published_date.isoformat()}">{entry.published_date.isoformat()}</time>',
517
+ f'<h3><a href="{html.escape(url, quote=True)}">{html.escape(entry.headline)}</a></h3>',
518
+ f'<p>{html.escape(entry.summary)}</p>',
519
+ '</article>',
520
+ ])
521
+ parts.append('</section>')
522
+ colophon = '<footer class="colophon">Generated locally by <strong>last30days</strong>.</footer>'
523
+ rendered = _wrap_in_template("\n".join(parts), colophon, "Research library")
524
+ return scrub_publishable_digit_runs(rendered)
525
+
526
+
527
+ _HREF_PATTERN = re.compile(r'(?P<prefix>\bhref\s*=\s*)(?P<quote>["\'])(?P<url>.*?)(?P=quote)', re.IGNORECASE)
528
+ _LONG_DIGIT_RUN = re.compile(r"\d{13,19}")
529
+
530
+
531
+ def scrub_publishable_digit_runs(html_content: str) -> str:
532
+ """Defuse payment-card-shaped digit runs before hosted publishing.
533
+
534
+ ht-ml.app rejects pages containing 13-19 digit runs during its safety scan.
535
+ Social post IDs commonly have that shape. Link targets are percent-encoded
536
+ so they still resolve; visible occurrences are shortened for readability.
537
+ """
538
+ def scrub_href(match: re.Match[str]) -> str:
539
+ url = _LONG_DIGIT_RUN.sub(
540
+ lambda digits: "".join(f"%{ord(char):02X}" for char in digits.group(0)),
541
+ match.group("url"),
542
+ )
543
+ return f'{match.group("prefix")}{match.group("quote")}{url}{match.group("quote")}'
544
+
545
+ with_safe_hrefs = _HREF_PATTERN.sub(scrub_href, html_content)
546
+ return _LONG_DIGIT_RUN.sub(
547
+ lambda digits: f"{digits.group(0)[:6]}…{digits.group(0)[-4:]}",
548
+ with_safe_hrefs,
549
+ )
550
+
551
+
386
552
  def _strip_evidence_block(md: str) -> str:
387
553
  return EVIDENCE_BLOCK_PATTERN.sub("", md)
388
554