luckiest-co 1.0.52 → 1.0.53

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 (104) 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 +4 -0
  5. package/skills/ab-testing/SKILL.md +3 -3
  6. package/skills/ab-testing/references/sample-size-guide.md +15 -2
  7. package/skills/ad-creative/CHANGELOG.md +8 -0
  8. package/skills/ad-creative/SKILL.md +23 -13
  9. package/skills/ads/CHANGELOG.md +6 -0
  10. package/skills/ads/SKILL.md +14 -10
  11. package/skills/ads/evals/evals.json +13 -0
  12. package/skills/ads/references/operating-check.md +75 -0
  13. package/skills/ai-seo/CHANGELOG.md +11 -0
  14. package/skills/ai-seo/SKILL.md +28 -27
  15. package/skills/ai-seo/references/agent-defaults.md +79 -0
  16. package/skills/ai-seo/references/ai-attribution.md +54 -0
  17. package/skills/ai-seo/references/answer-accuracy.md +84 -0
  18. package/skills/ai-seo/references/content-patterns.md +24 -0
  19. package/skills/ai-seo/references/multilingual-and-commerce.md +55 -0
  20. package/skills/ai-seo/references/platform-ranking-factors.md +29 -11
  21. package/skills/ai-seo/references/prompt-portfolio.md +83 -0
  22. package/skills/analytics/CHANGELOG.md +8 -0
  23. package/skills/analytics/SKILL.md +74 -18
  24. package/skills/analytics/references/event-library.md +2 -2
  25. package/skills/churn-prevention/CHANGELOG.md +7 -0
  26. package/skills/churn-prevention/SKILL.md +16 -8
  27. package/skills/churn-prevention/references/dunning-playbook.md +10 -7
  28. package/skills/co-marketing/CHANGELOG.md +3 -0
  29. package/skills/co-marketing/SKILL.md +9 -9
  30. package/skills/coder/CHANGELOG.md +19 -0
  31. package/skills/coder/SKILL.md +4 -3
  32. package/skills/coder-writing-plans/CHANGELOG.md +5 -0
  33. package/skills/coder-writing-plans/SKILL.md +4 -3
  34. package/skills/content-strategy/CHANGELOG.md +3 -0
  35. package/skills/content-strategy/SKILL.md +3 -3
  36. package/skills/content-strategy/references/headless-cms.md +4 -4
  37. package/skills/copy-editing/CHANGELOG.md +4 -0
  38. package/skills/copy-editing/SKILL.md +5 -3
  39. package/skills/copy-editing/evals/evals.json +13 -0
  40. package/skills/copy-editing/references/checklist.md +24 -0
  41. package/skills/copywriting/CHANGELOG.md +6 -0
  42. package/skills/copywriting/SKILL.md +57 -4
  43. package/skills/copywriting/evals/evals.json +14 -0
  44. package/skills/emails/CHANGELOG.md +4 -0
  45. package/skills/emails/SKILL.md +5 -3
  46. package/skills/marketing-plan/CHANGELOG.md +4 -0
  47. package/skills/marketing-plan/SKILL.md +8 -3
  48. package/skills/offers/CHANGELOG.md +4 -0
  49. package/skills/offers/SKILL.md +4 -4
  50. package/skills/onboarding/CHANGELOG.md +4 -0
  51. package/skills/onboarding/SKILL.md +13 -7
  52. package/skills/pricing/CHANGELOG.md +4 -0
  53. package/skills/pricing/SKILL.md +3 -3
  54. package/skills/pricing/references/research-methods.md +12 -2
  55. package/skills/product-marketing/CHANGELOG.md +5 -0
  56. package/skills/product-marketing/SKILL.md +76 -11
  57. package/skills/product-marketing/references/brand-voice.md +103 -0
  58. package/skills/prospecting/CHANGELOG.md +3 -0
  59. package/skills/prospecting/SKILL.md +22 -22
  60. package/skills/prospecting/references/data-sources.md +0 -18
  61. package/skills/prospecting/references/saas-prospecting.md +2 -2
  62. package/skills/referrals/CHANGELOG.md +4 -0
  63. package/skills/referrals/SKILL.md +3 -3
  64. package/skills/referrals/references/program-examples.md +9 -2
  65. package/skills/research/CHANGELOG.md +5 -0
  66. package/skills/research/SKILL.md +5 -5
  67. package/skills/research/references/visual-read.md +10 -4
  68. package/skills/revops/CHANGELOG.md +4 -0
  69. package/skills/revops/SKILL.md +6 -6
  70. package/skills/revops/references/scoring-models.md +14 -13
  71. package/skills/seo-audit/CHANGELOG.md +4 -0
  72. package/skills/seo-audit/SKILL.md +7 -7
  73. package/skills/seo-audit/references/international-seo.md +3 -1
  74. package/skills/social/CHANGELOG.md +4 -0
  75. package/skills/social/SKILL.md +4 -4
  76. package/skills/social/references/listening.md +13 -0
  77. package/skills/social/references/platforms.md +35 -0
  78. package/skills/social/references/short-form-video.md +10 -0
  79. package/skills/trends/CHANGELOG.md +7 -0
  80. package/skills/trends/SKILL.md +5 -5
  81. package/skills/trends/agents/openai.yaml +8 -0
  82. package/skills/trends/references/comparison.md +5 -2
  83. package/skills/trends/references/engine-contract.md +2 -2
  84. package/skills/trends/references/synthesis.md +2 -2
  85. package/skills/trends/scripts/build-skill.sh +39 -0
  86. package/skills/trends/scripts/compare.sh +61 -0
  87. package/skills/trends/scripts/evaluate_search_quality.py +584 -0
  88. package/skills/trends/scripts/lib/arxiv.py +10 -1
  89. package/skills/trends/scripts/lib/bluesky.py +22 -2
  90. package/skills/trends/scripts/lib/devto.py +108 -18
  91. package/skills/trends/scripts/lib/digg.py +79 -17
  92. package/skills/trends/scripts/lib/github.py +9 -4
  93. package/skills/trends/scripts/lib/hackernews.py +74 -2
  94. package/skills/trends/scripts/lib/html_render.py +15 -1
  95. package/skills/trends/scripts/lib/pipeline.py +1 -1
  96. package/skills/trends/scripts/lib/planner.py +5 -0
  97. package/skills/trends/scripts/lib/providers.py +1 -1
  98. package/skills/trends/scripts/lib/reddit_shreddit.py +4 -1
  99. package/skills/trends/scripts/lib/render.py +49 -45
  100. package/skills/trends/scripts/lib/youtube_yt.py +10 -7
  101. package/skills/trends/scripts/test-v1-vs-v2.sh +220 -0
  102. package/skills/trends/scripts/test_device_auth.py +128 -0
  103. package/skills/trends/scripts/verify_v3.py +200 -0
  104. package/skills/video/CHANGELOG.md +1 -1
@@ -4,16 +4,21 @@ Uses the public Forem API at dev.to (no key):
4
4
 
5
5
  - ``/api/articles?tag=<tag>&top=<days>`` returns the most-engaged articles for
6
6
  a tag in the window. Precise, but only as good as the tag.
7
- - ``/api/articles/search?q=<term>&top=<days>`` is free-text search sorted by
8
- recency. Single distinctive terms match well; multi-word queries match
9
- loosely, so every search hit must pass the engine's token-overlap relevance
10
- check before it is kept.
7
+ - ``/api/articles/search?q=<term>&top=<days>`` is free-text search sorted
8
+ roughly by recency. Single distinctive terms match well; multi-word queries
9
+ match loosely, so every search hit must pass the engine's token-overlap
10
+ relevance check before it is kept.
11
11
  - ``/api/comments?a_id=<id>`` returns the comment tree for an article.
12
12
 
13
13
  The source is off by default. Host-inferred tags (``--devto-tags``) turn it
14
14
  on for a run; ``INCLUDE_SOURCES=devto`` or ``--search devto`` turn it on
15
15
  without tags, in which case only the keyword search runs. An unknown tag
16
16
  returns ``[]`` with HTTP 200, so a wrong tag stays quiet instead of failing.
17
+
18
+ Both endpoints count ``top`` back from now and accept no end date, sort, or
19
+ date-range parameter. A window that ends before today therefore pages
20
+ through everything published since its start and filters locally, within
21
+ ``HISTORICAL_MAX_PAGES`` and ``HISTORICAL_BUDGET_SECONDS``.
17
22
  """
18
23
 
19
24
  from __future__ import annotations
@@ -48,6 +53,11 @@ SEARCH_RELEVANCE_FLOOR = 0.25
48
53
  # Total wall-clock cap per request. The socket timeout alone bounds each
49
54
  # read, so a response trickling in small chunks could otherwise run on.
50
55
  REQUEST_DEADLINE_SECONDS = 20.0
56
+ # Forem's per_page ceiling. A busy term ("kubernetes") has ~4,000 search hits
57
+ # over 78 days, about 2 MB and 4-5 s per full page.
58
+ HISTORICAL_PER_PAGE = 1000
59
+ HISTORICAL_MAX_PAGES = 10
60
+ HISTORICAL_BUDGET_SECONDS = 45.0
51
61
  ENRICH_WAIT_SECONDS = 25.0
52
62
 
53
63
  _TAG_RE = re.compile(r"[^a-z0-9]")
@@ -76,11 +86,22 @@ def resolve_tags(tags: list[str] | None, depth: str = "default") -> list[str]:
76
86
  return resolved[: TAG_LIMITS.get(depth, TAG_LIMITS["default"])]
77
87
 
78
88
 
89
+ def _today() -> datetime.date:
90
+ return datetime.datetime.now(datetime.timezone.utc).date()
91
+
92
+
93
+ def _is_historical(to_date: str | None) -> bool:
94
+ return bool(to_date) and str(to_date) < _today().isoformat()
95
+
96
+
79
97
  def _window_days(from_date: str | None, to_date: str | None) -> int:
80
98
  try:
81
99
  start = datetime.date.fromisoformat(from_date or "")
82
- end = datetime.date.fromisoformat(to_date or "") if to_date else datetime.date.today()
83
- return max(1, (end - start).days)
100
+ # Forem counts top=N back from now, including for historical windows,
101
+ # so the reach always runs to today and _in_window enforces to_date.
102
+ # The extra day keeps all of from_date: a now-anchored cutoff of
103
+ # exactly N days lands partway through it.
104
+ return max(1, (_today() - start).days + 1)
84
105
  except ValueError:
85
106
  return 30
86
107
 
@@ -98,7 +119,9 @@ def clear_cache() -> None:
98
119
  _URL_LOCKS.clear()
99
120
 
100
121
 
101
- def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str, Any]]:
122
+ def _get_list(
123
+ url: str, headers: dict[str, str] | None = None, deadline: float | None = None,
124
+ ) -> list[dict[str, Any]]:
102
125
  # Concurrent subquery streams can request the same URL; the per-URL lock
103
126
  # makes the second caller wait for the first fetch instead of repeating it.
104
127
  with _LOCKS_GUARD:
@@ -107,7 +130,10 @@ def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str,
107
130
  if url not in _RESPONSE_CACHE:
108
131
  data = http.request(
109
132
  "GET", url, headers=headers, timeout=15, retries=2,
110
- deadline_monotonic=time.monotonic() + REQUEST_DEADLINE_SECONDS,
133
+ deadline_monotonic=min(
134
+ time.monotonic() + REQUEST_DEADLINE_SECONDS,
135
+ deadline if deadline is not None else float("inf"),
136
+ ),
111
137
  owned_get=True,
112
138
  )
113
139
  if not isinstance(data, list):
@@ -118,8 +144,58 @@ def _get_list(url: str, headers: dict[str, str] | None = None) -> list[dict[str,
118
144
  return [dict(row) for row in _RESPONSE_CACHE[url]]
119
145
 
120
146
 
147
+ def _published(article: dict[str, Any]) -> str:
148
+ return str(article.get("published_at") or "")[:10]
149
+
150
+
151
+ def _fetch_rows(
152
+ base_url: str,
153
+ params: dict[str, Any],
154
+ to_date: str | None,
155
+ *,
156
+ label: str,
157
+ deadline: float,
158
+ headers: dict[str, str] | None = None,
159
+ ) -> list[dict[str, Any]]:
160
+ """Fetch one page for a current window, or page through a past one.
161
+
162
+ Every page after the first stops at ``deadline``, which the caller shares
163
+ across all endpoints so a deep run's tags cannot each spend the budget.
164
+ """
165
+ if not _is_historical(to_date):
166
+ return _get_list(f"{base_url}?{urllib.parse.urlencode(params)}", headers=headers)
167
+ rows: list[dict[str, Any]] = []
168
+ for page in range(1, HISTORICAL_MAX_PAGES + 1):
169
+ if page > 1 and time.monotonic() >= deadline:
170
+ _log(f"{label}: stopped after {page - 1} pages at the time budget; older in-window articles may be missing")
171
+ return rows
172
+ page_params = {**params, "per_page": HISTORICAL_PER_PAGE, "page": page}
173
+ url = f"{base_url}?{urllib.parse.urlencode(page_params)}"
174
+ try:
175
+ batch = _get_list(url, headers=headers, deadline=deadline if page > 1 else None)
176
+ except Exception as exc:
177
+ if page == 1:
178
+ raise
179
+ _log(f"{label}: page {page} failed ({exc}); keeping the {page - 1} pages fetched")
180
+ return rows
181
+ rows.extend(batch)
182
+ # top=N bounds the result set at from_date, so a short page is the end.
183
+ if len(batch) < HISTORICAL_PER_PAGE:
184
+ return rows
185
+ _log(f"{label}: stopped at {HISTORICAL_MAX_PAGES} pages; older in-window articles may be missing")
186
+ return rows
187
+
188
+
189
+ def _top_engaged(rows: list[dict[str, Any]], limit: int) -> list[dict[str, Any]]:
190
+ return sorted(
191
+ rows,
192
+ key=lambda row: int(row.get("public_reactions_count") or row.get("positive_reactions_count") or 0),
193
+ reverse=True,
194
+ )[:limit]
195
+
196
+
121
197
  def _in_window(article: dict[str, Any], from_date: str | None, to_date: str | None) -> bool:
122
- published = str(article.get("published_at") or "")[:10]
198
+ published = _published(article)
123
199
  if not published:
124
200
  return True
125
201
  if from_date and published < from_date:
@@ -151,24 +227,28 @@ def search_devto(
151
227
  endpoint is recorded in ``errors`` and the other endpoint still counts.
152
228
  """
153
229
  days = _window_days(from_date, to_date)
230
+ historical = _is_historical(to_date)
231
+ paging_deadline = time.monotonic() + HISTORICAL_BUDGET_SECONDS
154
232
  resolved_tags = resolve_tags(tags, depth)
155
233
  articles: dict[int, dict[str, Any]] = {}
156
234
  errors: list[str] = []
157
235
 
158
236
  for tag in resolved_tags:
159
- url = f"{ARTICLES_URL}?{urllib.parse.urlencode({'tag': tag, 'top': days, 'per_page': TAG_PER_PAGE})}"
237
+ params = {"tag": tag, "top": days, "per_page": TAG_PER_PAGE}
160
238
  try:
161
- rows = _get_list(url)
239
+ rows = _fetch_rows(ARTICLES_URL, params, to_date, label=f"tag '{tag}'", deadline=paging_deadline)
162
240
  except Exception as exc:
163
241
  errors.append(f"tag {tag}: {exc}")
164
242
  _log(f"tag feed failed for '{tag}': {exc}")
165
243
  continue
244
+ rows = [row for row in rows if row.get("id") is not None and _in_window(row, from_date, to_date)]
245
+ if historical:
246
+ rows = _top_engaged(rows, TAG_PER_PAGE)
166
247
  kept = 0
167
248
  for row in rows:
168
- if row.get("id") is not None and _in_window(row, from_date, to_date):
169
- row.setdefault("_devto_match", f"tag:{tag}")
170
- articles.setdefault(row["id"], row)
171
- kept += 1
249
+ row.setdefault("_devto_match", f"tag:{tag}")
250
+ articles.setdefault(row["id"], row)
251
+ kept += 1
172
252
  _log(f"tag '{tag}': {kept} articles in window")
173
253
 
174
254
  # Planner queries quote phrases for engines with phrase syntax; Forem
@@ -176,21 +256,31 @@ def search_devto(
176
256
  query = (search_query or topic or "").replace('"', " ").strip()
177
257
  query = " ".join(query.split())
178
258
  if query:
179
- params = {"q": query, "top": days, "per_page": SEARCH_PER_PAGE.get(depth, SEARCH_PER_PAGE["default"])}
180
- url = f"{SEARCH_URL}?{urllib.parse.urlencode(params)}"
259
+ per_page = SEARCH_PER_PAGE.get(depth, SEARCH_PER_PAGE["default"])
260
+ params = {"q": query, "top": days, "per_page": per_page}
181
261
  try:
182
- rows = _get_list(url, headers=_SEARCH_HEADERS)
262
+ rows = _fetch_rows(
263
+ SEARCH_URL, params, to_date, label=f"search '{query}'",
264
+ deadline=paging_deadline, headers=_SEARCH_HEADERS,
265
+ )
183
266
  except Exception as exc:
184
267
  errors.append(f"search: {exc}")
185
268
  _log(f"search failed for '{query}': {exc}")
186
269
  rows = []
187
270
  kept = dropped = 0
271
+ relevant = []
188
272
  for row in rows:
189
273
  if row.get("id") is None or not _in_window(row, from_date, to_date):
190
274
  continue
191
275
  if token_overlap_relevance(topic or query, _article_text(row)) < SEARCH_RELEVANCE_FLOOR:
192
276
  dropped += 1
193
277
  continue
278
+ relevant.append(row)
279
+ if historical:
280
+ # A current window keeps the newest page; a past one keeps the
281
+ # most-engaged in-window hits so the item count stays comparable.
282
+ relevant = _top_engaged(relevant, per_page)
283
+ for row in relevant:
194
284
  row.setdefault("_devto_match", "search")
195
285
  articles.setdefault(row["id"], row)
196
286
  kept += 1
@@ -19,9 +19,10 @@ top-ranked X posts to each cluster's ``posts`` field.
19
19
  from __future__ import annotations
20
20
 
21
21
  import json
22
+ import re
22
23
  import shutil
23
24
  from datetime import datetime, timedelta, timezone
24
- from typing import Any, Dict, List, Optional
25
+ from typing import Any, Dict, List, Optional, Tuple
25
26
  from urllib.parse import urlparse
26
27
 
27
28
  from . import log, subproc
@@ -133,47 +134,104 @@ def _build_posts_args(cluster_url_id: str, posts_per: int) -> List[str]:
133
134
  ]
134
135
 
135
136
 
136
- def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
137
- """Invoke digg-pp-cli and parse the JSON envelope.
137
+ _LOCAL_FALLBACK_MARKER = "falling back to local FTS5:"
138
+ _FALLBACK_REASON_MAX = 200
139
+
140
+
141
+ def _local_fallback_reason(stderr: str) -> str:
142
+ """Extract the live-search failure from digg-pp-cli's fallback stderr.
138
143
 
139
- Returns ``{"results": [...]}`` on success, ``{"results": [], "error": "..."}``
140
- on failure. Never raises; the pipeline relies on shape consistency.
144
+ The CLI prints ``falling back to local FTS5: <error>`` and the error can
145
+ embed the full HTML body of a failed response, so the reason is cut at
146
+ the HTTP status when present and length-bounded otherwise.
147
+ """
148
+ for line in (stderr or "").splitlines():
149
+ _, marker, rest = line.partition(_LOCAL_FALLBACK_MARKER)
150
+ if not marker:
151
+ continue
152
+ rest = rest.strip()
153
+ status = re.match(r".*?\bHTTP \d{3}\b", rest)
154
+ if status:
155
+ return status.group(0)
156
+ return rest[:_FALLBACK_REASON_MAX].rstrip()
157
+ return ""
158
+
159
+
160
+ def _empty_local_fallback_error(data: Dict[str, Any], stderr: str = "") -> Optional[str]:
161
+ """Return an error when the CLI answered from an empty local store.
162
+
163
+ ``digg-pp-cli search`` is live by default, but it falls back to its FTS5
164
+ store on a network error and exits 0 with ``meta.source == "local"``.
165
+ That store only holds rows for users who ran ``digg-pp-cli sync``
166
+ themselves, which last30days never does - so a broken live search
167
+ reaches the user as ``Digg: 0 items (no results)`` instead of an error.
168
+ A non-empty local store is real data and is left alone.
169
+
170
+ The CLI reports the live failure only on stderr; ``meta.reason`` is
171
+ preferred if a future CLI release adds it to the envelope.
172
+ """
173
+ meta = data.get("meta")
174
+ if not isinstance(meta, dict) or meta.get("source") != "local":
175
+ return None
176
+ if data.get("results"):
177
+ return None
178
+ reason = str(meta.get("reason") or "").strip()[:_FALLBACK_REASON_MAX]
179
+ detail = (
180
+ reason
181
+ or _local_fallback_reason(stderr)
182
+ or "live search fell back to an empty local store"
183
+ )
184
+ return f"live-search-unavailable: {detail}"
185
+
186
+
187
+ def _invoke_cli(cmd: List[str], timeout: int) -> Tuple[Dict[str, Any], str]:
188
+ """Invoke digg-pp-cli and return the parsed envelope with its stderr.
189
+
190
+ The envelope is ``{"results": [...]}`` on success and
191
+ ``{"results": [], "error": "..."}`` on failure. Never raises; the
192
+ pipeline relies on shape consistency.
141
193
  """
142
194
  if not _is_available():
143
- return {"results": [], "error": f"{CLI_BIN} not on PATH"}
195
+ return {"results": [], "error": f"{CLI_BIN} not on PATH"}, ""
144
196
  try:
145
197
  result = subproc.run_with_timeout(cmd, timeout=timeout)
146
198
  except subproc.SubprocTimeout as exc:
147
199
  _log(f"Timeout: {exc}")
148
- return {"results": [], "error": str(exc)}
200
+ return {"results": [], "error": str(exc)}, ""
149
201
  except FileNotFoundError as exc:
150
202
  _log(f"Binary missing: {exc}")
151
- return {"results": [], "error": str(exc)}
203
+ return {"results": [], "error": str(exc)}, ""
152
204
  except OSError as exc:
153
205
  _log(f"Spawn failed: {exc}")
154
- return {"results": [], "error": str(exc)}
206
+ return {"results": [], "error": str(exc)}, ""
155
207
 
208
+ stderr = result.stderr or ""
156
209
  if result.returncode != 0:
157
- snippet = (result.stderr or "").strip().splitlines()[:1]
210
+ snippet = stderr.strip().splitlines()[:1]
158
211
  first = snippet[0] if snippet else f"exit {result.returncode}"
159
212
  _log(f"CLI exit {result.returncode}: {first}")
160
- return {"results": [], "error": first}
213
+ return {"results": [], "error": first}, stderr
161
214
 
162
215
  stdout = result.stdout or ""
163
216
  if not stdout.strip():
164
- return {"results": []}
217
+ return {"results": []}, stderr
165
218
  try:
166
219
  data = json.loads(stdout)
167
220
  except json.JSONDecodeError as exc:
168
221
  _log(f"JSON decode failed: {exc}")
169
- return {"results": [], "error": f"json decode: {exc}"}
222
+ return {"results": [], "error": f"json decode: {exc}"}, stderr
170
223
 
171
224
  if not isinstance(data, dict):
172
- return {"results": []}
225
+ return {"results": []}, stderr
173
226
  results = data.get("results")
174
227
  if not isinstance(results, list):
175
- return {"results": []}
176
- return data
228
+ return {"results": []}, stderr
229
+ return data, stderr
230
+
231
+
232
+ def _run_cli(cmd: List[str], timeout: int) -> Dict[str, Any]:
233
+ """Invoke digg-pp-cli and return only the parsed envelope."""
234
+ return _invoke_cli(cmd, timeout)[0]
177
235
 
178
236
 
179
237
  def search_digg(
@@ -199,7 +257,11 @@ def search_digg(
199
257
  return {"results": []}
200
258
  cmd = _build_search_args(topic, limit)
201
259
  _log(f"search '{topic}' (limit={limit}, since=30d)")
202
- response = _run_cli(cmd, timeout=SEARCH_TIMEOUT)
260
+ response, stderr = _invoke_cli(cmd, timeout=SEARCH_TIMEOUT)
261
+ fallback_error = _empty_local_fallback_error(response, stderr)
262
+ if fallback_error:
263
+ _log(fallback_error)
264
+ return {"results": [], "error": fallback_error}
203
265
  n = len(response.get("results") or [])
204
266
  _log(f"found {n} clusters")
205
267
  return response
@@ -179,7 +179,7 @@ def _compute_relevance(
179
179
  # the qualifier so no stray `()`/`""` residue reaches the query (issue #952).
180
180
  # The plain regex's boundary also accepts wrapper openers so a qualifier with
181
181
  # a missing closer ("(created:>2025-03-20") is still stripped rather than
182
- # leaking into the query; only the stray opener survives, harmlessly.
182
+ # leaking into the query; qualifier-only wrapper residue is discarded below.
183
183
  QUALIFIER_KEYS = frozenset({
184
184
  "archived", "assignee", "author", "base", "closed", "comments", "commenter",
185
185
  "created", "fork", "forks", "head", "in", "interactions", "involves", "is",
@@ -220,6 +220,11 @@ def strip_search_qualifiers(text: str) -> str:
220
220
  """
221
221
  stripped = _WRAPPED_QUALIFIER_RE.sub(" ", text)
222
222
  stripped = _QUALIFIER_RE.sub(" ", stripped)
223
+ # Malformed/nested wrappers can leave only punctuation after a qualifier
224
+ # is removed (#969). There is no subject to search in that case. Keep
225
+ # wrappers around actual topic text, including unbalanced ones like "ai (foo".
226
+ if stripped != text and re.fullmatch(r"[\s()\[\]\"']*", stripped):
227
+ return ""
223
228
  while True:
224
229
  cleaned = _EMPTY_WRAPPER_RE.sub("", stripped)
225
230
  if cleaned == stripped:
@@ -750,8 +755,8 @@ def search_github_person(
750
755
  _log(f"Person-mode search for @{username} (since {from_date})")
751
756
 
752
757
  # Phase 1: PR velocity via search API
753
- total_q = urllib.parse.quote(f"author:{username} type:pr created:>{from_date}")
754
- merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:>{from_date}")
758
+ total_q = urllib.parse.quote(f"author:{username} type:pr created:{from_date}..{to_date}")
759
+ merged_q = urllib.parse.quote(f"author:{username} type:pr is:merged created:{from_date}..{to_date}")
755
760
 
756
761
  total_url = f"{SEARCH_URL}?q={total_q}&per_page=1"
757
762
  merged_url = f"{SEARCH_URL}?q={merged_q}&sort=reactions&order=desc&per_page=100"
@@ -823,7 +828,7 @@ def search_github_person(
823
828
  num_repos = len(repo_pr_counts)
824
829
  velocity_text = (
825
830
  f"GitHub Person Profile: @{username}\n\n"
826
- f"CONTRIBUTION VELOCITY (last {(to_date > from_date) and 30 or 30} days)\n"
831
+ f"CONTRIBUTION VELOCITY (last {dates.days_ago(from_date, to_date)} days)\n"
827
832
  f"- {merged_count} PRs merged across {num_repos} repos ({merge_rate}% merge rate)\n"
828
833
  f"- {total_prs} total PRs submitted, {open_prs} still open\n"
829
834
  )
@@ -10,6 +10,7 @@ import math
10
10
  import sys
11
11
  import time
12
12
  from concurrent.futures import ThreadPoolExecutor, as_completed
13
+ from http.client import HTTPException
13
14
  from typing import Any, Dict, List, Optional
14
15
 
15
16
  import re
@@ -24,6 +25,9 @@ _HN_PREFIXES = re.compile(r"^(Tell HN|Show HN|Ask HN|Launch HN)\s*:\s*", re.IGNO
24
25
  ALGOLIA_SEARCH_URL = "https://hn.algolia.com/api/v1/search"
25
26
  ALGOLIA_SEARCH_BY_DATE_URL = "https://hn.algolia.com/api/v1/search_by_date"
26
27
  ALGOLIA_ITEM_URL = "https://hn.algolia.com/api/v1/items"
28
+ # Official HN Firebase item API — `kids` is in the order the thread displays
29
+ # top-level comments (Algolia's /items children do not carry useful points).
30
+ FIREBASE_ITEM_URL = "https://hacker-news.firebaseio.com/v0/item"
27
31
 
28
32
  DEPTH_CONFIG = {
29
33
  "quick": 15,
@@ -309,6 +313,68 @@ def _hn_item_url(item_id: Any) -> str:
309
313
  return f"https://news.ycombinator.com/item?id={item_id}"
310
314
 
311
315
 
316
+ # Ordering is optional, so a slow Firebase must not hold up the report.
317
+ FIREBASE_KIDS_TIMEOUT = 5
318
+ FIREBASE_KIDS_RETRIES = 2
319
+ FIREBASE_KIDS_BUDGET = 8.0
320
+
321
+
322
+ def _fetch_hn_kids(object_id: str) -> Optional[List[Any]]:
323
+ """Return HN's display order for a story's top-level comments, or None on failure.
324
+
325
+ Algolia's `/items/<id>` children list is effectively id-order with `points`
326
+ always null, so sorting by points is a no-op (#1285). The Firebase item
327
+ endpoint returns `kids` in the same order the HN thread UI shows.
328
+ """
329
+ url = f"{FIREBASE_ITEM_URL}/{object_id}.json"
330
+ # Comment order is a refinement over comments Algolia already returned, so a
331
+ # Firebase failure must not reach the pipeline's sink and mark HN degraded.
332
+ try:
333
+ with http.capture_failures():
334
+ data = http.request(
335
+ "GET",
336
+ url,
337
+ timeout=FIREBASE_KIDS_TIMEOUT,
338
+ retries=FIREBASE_KIDS_RETRIES,
339
+ deadline_monotonic=time.monotonic() + FIREBASE_KIDS_BUDGET,
340
+ )
341
+ # http.request wraps HTTP and socket errors, but a truncated or non-UTF-8
342
+ # body escapes as IncompleteRead or UnicodeDecodeError.
343
+ except (http.HTTPError, HTTPException, UnicodeDecodeError) as e:
344
+ _log(f"Failed to fetch HN kids for {object_id}: {e}")
345
+ return None
346
+ if not isinstance(data, dict):
347
+ return None
348
+ kids = data.get("kids")
349
+ if not isinstance(kids, list):
350
+ return None
351
+ return kids
352
+
353
+
354
+ def _order_comments_by_kids(
355
+ comments: List[Dict[str, Any]],
356
+ kids: List[Any],
357
+ ) -> List[Dict[str, Any]]:
358
+ """Reorder Algolia children to match HN `kids` order; unknowns keep relative order."""
359
+ by_id: Dict[Any, Dict[str, Any]] = {}
360
+ for comment in comments:
361
+ cid = comment.get("id")
362
+ if cid is not None and cid not in by_id:
363
+ by_id[cid] = comment
364
+ ordered: List[Dict[str, Any]] = []
365
+ seen = set()
366
+ for kid in kids:
367
+ comment = by_id.get(kid)
368
+ if comment is not None:
369
+ ordered.append(comment)
370
+ seen.add(kid)
371
+ for comment in comments:
372
+ cid = comment.get("id")
373
+ if cid not in seen:
374
+ ordered.append(comment)
375
+ return ordered
376
+
377
+
312
378
  def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any]:
313
379
  """Fetch top-level comments for a story from Algolia items endpoint.
314
380
 
@@ -329,12 +395,18 @@ def _fetch_item_comments(object_id: str, max_comments: int = 5) -> Dict[str, Any
329
395
 
330
396
  children = data.get("children", [])
331
397
 
332
- # Sort by points (highest first), filter to actual comments
398
+ # Filter to actual comments. Prefer HN's Firebase `kids` order (#1285);
399
+ # Algolia children have null points, so a points sort is a no-op and leaves
400
+ # earliest-posted replies first. Fall back to points only when kids is unavailable.
333
401
  real_comments = [
334
402
  c for c in children
335
403
  if c.get("text") and c.get("author")
336
404
  ]
337
- real_comments.sort(key=lambda c: c.get("points") or 0, reverse=True)
405
+ kids = _fetch_hn_kids(object_id)
406
+ if kids is not None:
407
+ real_comments = _order_comments_by_kids(real_comments, kids)
408
+ else:
409
+ real_comments.sort(key=lambda c: c.get("points") or 0, reverse=True)
338
410
 
339
411
  comments = []
340
412
  insights = []
@@ -20,6 +20,18 @@ PROSE_LABELS = [
20
20
  INVITATION_PATTERN = re.compile(r"^---\nI'm now an expert.*?Just ask\.$", re.MULTILINE | re.DOTALL)
21
21
  EVIDENCE_BLOCK_PATTERN = re.compile(r"<!-- EVIDENCE FOR SYNTHESIS.*?<!-- END EVIDENCE FOR SYNTHESIS -->", re.DOTALL)
22
22
  PASS_THROUGH_FOOTER_PATTERN = re.compile(r"<!-- PASS-THROUGH FOOTER.*?-->\n(.*?)<!-- END PASS-THROUGH FOOTER -->", re.DOTALL)
23
+ # Line-by-line on purpose: no row inside the match may itself open or close a
24
+ # fence, so an earlier ordinary ```text block cannot be stitched to the real
25
+ # footer with the prose between them swallowed into the footer box.
26
+ _FOOTER_ROWS = r"(?:(?!```)[^\n]*\n)*?"
27
+ FENCED_ENGINE_FOOTER_PATTERN = re.compile(
28
+ r"^```text\n(---\n"
29
+ + _FOOTER_ROWS
30
+ + r"[^\n]*All agents reported back![^\n]*\n"
31
+ + _FOOTER_ROWS
32
+ + r"---)\n```$",
33
+ re.MULTILINE,
34
+ )
23
35
  CANONICAL_BOUNDARY_PATTERN = re.compile(r"\n?---\n# END OF last30days CANONICAL OUTPUT.*$", re.DOTALL)
24
36
  # render_for_html emits metadata as <!-- META: ... --> so it survives the
25
37
  # markdown converter (which escapes raw HTML inside paragraphs). Promoted to
@@ -713,7 +725,9 @@ def _protect_engine_footers(md: str) -> tuple[str, dict[str, str]]:
713
725
  footers[token] = match.group(1).strip("\n")
714
726
  return f"\n{token}\n"
715
727
 
716
- return PASS_THROUGH_FOOTER_PATTERN.sub(replace, md), footers
728
+ md = PASS_THROUGH_FOOTER_PATTERN.sub(replace, md)
729
+ md = FENCED_ENGINE_FOOTER_PATTERN.sub(replace, md)
730
+ return md, footers
717
731
 
718
732
 
719
733
  def _wrap_engine_footer(body: str) -> str:
@@ -6116,7 +6116,7 @@ def _retrieve_stream_impl(
6116
6116
  # search_query, so off-topic narrowing does not let weak matches through.
6117
6117
  relevance_topic = raw_topic or topic or subquery.search_query
6118
6118
  return (
6119
- arxiv.parse_arxiv_response(result, query=relevance_topic),
6119
+ arxiv.parse_arxiv_response(result, query=relevance_topic, to_date=to_date),
6120
6120
  _result_outcome_artifact(source, result),
6121
6121
  )
6122
6122
  if source == "techmeme":
@@ -621,6 +621,11 @@ def _trim_subqueries_for_depth(
621
621
  # Operator-supplied --plan is a contract: keep per-subquery sources.
622
622
  if depth != "quick" and not honor_plan_sources:
623
623
  expanded_sources = _default_sources_for_intent(intent, available_sources)
624
+ # Explicit --search sources are a user override: intent routing must not drop them.
625
+ expanded_sources += [
626
+ source for source in available_sources
627
+ if source in (requested_sources or []) and source not in expanded_sources
628
+ ]
624
629
  return [
625
630
  schema.SubQuery(
626
631
  label=subquery.label,
@@ -15,7 +15,7 @@ from . import env, http, schema
15
15
  GEMINI_FLASH_LITE = "gemini-3.1-flash-lite"
16
16
  GEMINI_PRO = "gemini-3.1-pro-preview"
17
17
  OPENAI_DEFAULT = "gpt-5.4-nano"
18
- XAI_DEFAULT = "grok-4-1-fast"
18
+ XAI_DEFAULT = "grok-4.20-0309-non-reasoning"
19
19
 
20
20
  GEMINI_URL = "https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent?key={api_key}"
21
21
  OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses"
@@ -98,7 +98,10 @@ def _svc_url(subreddit: str, post_id: str) -> str:
98
98
 
99
99
 
100
100
  def _attr(tag: str, name: str) -> str:
101
- m = re.search(rf'\b{name}="([^"]*)"', tag)
101
+ # HTML attribute names are case-insensitive: the server partial emits
102
+ # ``thingId``, a DOM round-trip serializes it as ``thingid``. Match the name
103
+ # either way; the value keeps its case.
104
+ m = re.search(rf'\b{name}="([^"]*)"', tag, re.IGNORECASE)
102
105
  return _html.unescape(m.group(1)) if m else ""
103
106
 
104
107