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
@@ -4,11 +4,27 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import pathlib
7
+ import re
7
8
  from collections import Counter
8
9
  from datetime import date
9
10
  from urllib.parse import urlparse
10
11
 
11
- from . import dates, schema, signals, skill_meta
12
+ from . import (
13
+ amazon,
14
+ dates,
15
+ fusion,
16
+ health,
17
+ hiring_signals,
18
+ library_index,
19
+ meta_ads,
20
+ registers,
21
+ relevance,
22
+ rerank,
23
+ schema,
24
+ signals,
25
+ skill_meta,
26
+ x_envelope,
27
+ )
12
28
 
13
29
 
14
30
  def _skill_version() -> str:
@@ -61,10 +77,154 @@ def _render_badge() -> list[str]:
61
77
  "",
62
78
  ]
63
79
 
80
+
81
+ def _ordinal(count: int) -> str:
82
+ """1 -> 1st, 2 -> 2nd, 3 -> 3rd, 11-13 -> th (Pipeline card line)."""
83
+ if 10 <= count % 100 <= 20:
84
+ suffix = "th"
85
+ else:
86
+ suffix = {1: "st", 2: "nd", 3: "rd"}.get(count % 10, "th")
87
+ return f"{count}{suffix}"
88
+
89
+
90
+ def _format_discovery_engagement(
91
+ engagement: dict[str, dict[str, float | int]],
92
+ ) -> str:
93
+ parts: list[str] = []
94
+ for source, metrics in engagement.items():
95
+ metric_parts = [
96
+ f"{field.replace('_', ' ')} {value:,.0f}"
97
+ for field, value in metrics.items()
98
+ if value
99
+ ]
100
+ if metric_parts:
101
+ parts.append(
102
+ f"{SOURCE_LABELS.get(source, source.title())}: {', '.join(metric_parts)}"
103
+ )
104
+ return " · ".join(parts) or "No native engagement counters reported"
105
+
106
+
107
+ def render_discovery(report: schema.DiscoveryReport) -> str:
108
+ """Render a compact topic-per-section discovery brief."""
109
+ title = (
110
+ f"# Trending discovery: {report.domain}" if report.domain else "# Trending now"
111
+ )
112
+ lines = [
113
+ *_render_badge(),
114
+ title,
115
+ "",
116
+ f"Window: {report.range_from} to {report.range_to}",
117
+ f"Feeds: {', '.join(report.plan.sources)}",
118
+ ]
119
+ if report.plan.subreddits:
120
+ lines.append(
121
+ "Communities: " + ", ".join(f"r/{sub}" for sub in report.plan.subreddits)
122
+ )
123
+ lines.append("")
124
+
125
+ if not report.topics:
126
+ if report.outcome == "nothing-solid":
127
+ lines.extend(
128
+ [
129
+ "**Nothing solid this window.** No topic cleared the confidence "
130
+ "floor - not enough cross-source confirmation or engagement to "
131
+ "call anything a trend, and ranked noise would be worse than an "
132
+ "honest empty result.",
133
+ "",
134
+ ]
135
+ )
136
+ if report.weak_signal:
137
+ lines.extend(
138
+ [
139
+ f"Closest weak signal: {report.weak_signal} (sub-floor; "
140
+ "single-source or too little engagement).",
141
+ "",
142
+ ]
143
+ )
144
+ else:
145
+ lines.extend(["No trending topic clusters survived this sweep.", ""])
146
+ for topic in report.topics:
147
+ momentum = "New this week" if topic.momentum == "new-this-week" else "Building"
148
+ confirmation = (
149
+ f" · confirmed across {topic.corroboration_count} sources"
150
+ if topic.corroboration_count >= 2
151
+ else ""
152
+ )
153
+ lines.extend(
154
+ [
155
+ f"## {topic.rank}. {topic.name}",
156
+ "",
157
+ f"**Momentum:** {momentum} · velocity {topic.velocity_score:,.2f}{confirmation}",
158
+ "",
159
+ topic.why_spiking,
160
+ "",
161
+ ]
162
+ )
163
+ if topic.top_comment:
164
+ lines.extend(
165
+ [
166
+ f"**Community voice:** {topic.top_comment}",
167
+ "",
168
+ ]
169
+ )
170
+ if topic.podcast_angle:
171
+ lines.extend(
172
+ [
173
+ f"**Podcast angle:** {topic.podcast_angle}",
174
+ "",
175
+ ]
176
+ )
177
+ if topic.x_article_angle:
178
+ lines.extend(
179
+ [
180
+ f"**X article angle:** {topic.x_article_angle}",
181
+ "",
182
+ ]
183
+ )
184
+ pipeline_notes: list[str] = []
185
+ if topic.previously_surfaced_count > 0:
186
+ # previously_surfaced_count is PRIOR appearances, so this
187
+ # appearance is the (count + 1)-th. The queue is all-time.
188
+ pipeline_notes.append(
189
+ f"surfaced {_ordinal(topic.previously_surfaced_count + 1)} time"
190
+ )
191
+ if topic.covered:
192
+ # last_surfaced is the last surfacing date, not the covered date,
193
+ # so no date is rendered here.
194
+ pipeline_notes.append("marked covered")
195
+ if pipeline_notes:
196
+ lines.extend(
197
+ [
198
+ f"**Pipeline:** {', '.join(pipeline_notes)}",
199
+ "",
200
+ ]
201
+ )
202
+ lines.extend(
203
+ [
204
+ f"**Evidence:** {_format_discovery_engagement(topic.engagement_by_source)}",
205
+ "",
206
+ f"**Research next:** `{topic.command}`",
207
+ "",
208
+ ]
209
+ )
210
+
211
+ if report.warnings:
212
+ lines.extend(["### Coverage notes", ""])
213
+ lines.extend(f"- {warning}" for warning in report.warnings)
214
+ lines.append("")
215
+ return "\n".join(lines).rstrip() + "\n"
216
+
217
+
64
218
  SOURCE_LABELS = {
219
+ "reddit": "Reddit",
220
+ "youtube": "YouTube",
221
+ "tiktok": "TikTok",
222
+ "instagram": "Instagram",
65
223
  "grounding": "Web",
66
224
  "hackernews": "Hacker News",
225
+ "devto": "dev.to",
67
226
  "truthsocial": "Truth Social",
227
+ "linkedin": "LinkedIn",
68
228
  "xiaohongshu": "Xiaohongshu",
69
229
  "x": "X",
70
230
  "github": "GitHub",
@@ -72,10 +232,16 @@ SOURCE_LABELS = {
72
232
  "arxiv": "arXiv",
73
233
  "techmeme": "Techmeme",
74
234
  "trustpilot": "Trustpilot",
235
+ "amazon": "Amazon",
236
+ "meta_ads": "Meta Ads",
75
237
  "perplexity": "Perplexity",
76
238
  "jobs": "Jobs",
239
+ "corpus": "Your files",
77
240
  }
78
241
 
242
+ PRIVATE_CORPUS_START = "<!-- LAST30DAYS_PRIVATE_CORPUS_START -->"
243
+ PRIVATE_CORPUS_END = "<!-- LAST30DAYS_PRIVATE_CORPUS_END -->"
244
+
79
245
 
80
246
  # vote_weight = max points a fully on-topic, max-upvoted top comment can add to
81
247
  # the LLM humor score. Tuned against real runs: typical funny comments score
@@ -109,7 +275,527 @@ def _assistant_safety_lines() -> list[str]:
109
275
  ]
110
276
 
111
277
 
112
- def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str = "medium", save_path: str | None = None) -> str:
278
+ def _render_drill_context(report: schema.Report) -> list[str]:
279
+ context = report.artifacts.get("drill_context") or {}
280
+ if not report.drill_of or not context:
281
+ return []
282
+ titles = context.get("cluster_titles") or [report.drill_of]
283
+ sources = context.get("sources") or []
284
+ source_text = ", ".join(_source_label(source) for source in sources) or "none"
285
+ original = context.get("original_summary") or "No cached summary was available."
286
+ return [
287
+ "## Drill Follow-up",
288
+ "",
289
+ f"- Target: {context.get('target') or report.drill_of}",
290
+ f"- Matched: {', '.join(titles)}",
291
+ "",
292
+ "### Original",
293
+ "",
294
+ str(original),
295
+ "",
296
+ "### Deeper",
297
+ "",
298
+ f"- {int(context.get('new_items') or 0)} new items after dedupe",
299
+ f"- Re-researched sources: {source_text}",
300
+ ]
301
+
302
+
303
+ def _render_library_context(report: schema.Report) -> list[str]:
304
+ if not report.library_context:
305
+ return []
306
+ lines = [
307
+ library_index.LIBRARY_CONTEXT_START,
308
+ "## From your library",
309
+ "",
310
+ "_Prior saved runs on this topic from your local research library "
311
+ "(historical context, not fresh evidence; set "
312
+ "LAST30DAYS_LIBRARY_CONTEXT=off to hide)._",
313
+ "",
314
+ ]
315
+ for item in report.library_context:
316
+ detail = _truncate(item.summary or item.headline, 220)
317
+ lines.append(
318
+ f"- You researched **{item.topic}** on {item.published_date} - "
319
+ f"key finding then: {detail}"
320
+ )
321
+ lines.append(library_index.LIBRARY_CONTEXT_END)
322
+ return lines
323
+
324
+
325
+ def render_library_search(
326
+ query: str,
327
+ matches: list[library_index.LibrarySearchMatch],
328
+ ) -> str:
329
+ """Render dated FTS matches grouped by the topic run that produced them."""
330
+ if not matches:
331
+ return (
332
+ f"# Library search: {query}\n\n"
333
+ "No saved briefs or store sightings matched this query.\n"
334
+ )
335
+ groups: dict[tuple[str, date], list[library_index.LibrarySearchMatch]] = {}
336
+ for match in matches:
337
+ groups.setdefault(match.run_key, []).append(match)
338
+ lines = [
339
+ f"# Library search: {query}",
340
+ "",
341
+ _AI_SAFETY_NOTE,
342
+ "",
343
+ f"Found {len(matches)} match(es) across {len(groups)} topic run(s).",
344
+ "",
345
+ ]
346
+ for (topic, published), run_matches in groups.items():
347
+ lines.extend([f"## {topic} - {published.isoformat()}", ""])
348
+ for match in run_matches:
349
+ label = "Saved brief" if match.source_kind == "brief" else "Store sighting"
350
+ engagement = ""
351
+ if match.engagement is not None:
352
+ engagement = (
353
+ f"; {_format_library_engagement(match.engagement)} engagement"
354
+ )
355
+ lines.append(f"- **{label}:** {match.headline}{engagement}")
356
+ if match.snippet and match.snippet != match.headline:
357
+ lines.append(f" {match.snippet}")
358
+ location = match.url or match.source_path
359
+ if location:
360
+ lines.append(f" Source: {location}")
361
+ lines.append("")
362
+ return "\n".join(lines).strip() + "\n"
363
+
364
+
365
+ def _format_library_engagement(value: float) -> str:
366
+ if value >= 1_000_000:
367
+ return f"{value / 1_000_000:.1f}M"
368
+ if value >= 1_000:
369
+ return f"{value / 1_000:.1f}K"
370
+ return f"{value:g}"
371
+
372
+
373
+ def _qualifying_representative_ids(
374
+ cluster: schema.Cluster,
375
+ candidate_by_id: dict[str, schema.Candidate],
376
+ *,
377
+ limit: int | None = None,
378
+ fallback_limit: int = 1,
379
+ ) -> list[str]:
380
+ """Keep qualifying MMR representatives, or promote a conservative fallback."""
381
+ representative_ids = [
382
+ candidate_id
383
+ for candidate_id in cluster.representative_ids
384
+ if candidate_id in candidate_by_id
385
+ and _best_take_relevance_ok(candidate_by_id[candidate_id])
386
+ ]
387
+ if not representative_ids:
388
+ representative_ids = [
389
+ candidate_id
390
+ for candidate_id in cluster.candidate_ids
391
+ if candidate_id in candidate_by_id
392
+ and _best_take_relevance_ok(candidate_by_id[candidate_id])
393
+ ][:fallback_limit]
394
+ return representative_ids[:limit] if limit is not None else representative_ids
395
+
396
+
397
+ def _render_ranked_clusters(
398
+ report: schema.Report,
399
+ clusters: list[schema.Cluster],
400
+ ) -> list[str]:
401
+ lines = ["## Ranked Evidence Clusters", ""]
402
+ candidate_by_id = {
403
+ candidate.candidate_id: candidate for candidate in report.ranked_candidates
404
+ }
405
+ solid_clusters = _clusters_clearing_relevance_floor(report, clusters)
406
+ if clusters and not solid_clusters:
407
+ lines.extend(
408
+ [
409
+ "**Nothing solid this window.**",
410
+ "",
411
+ "No recent evidence cluster cleared the relevance floor. "
412
+ "Do not infer findings or quote community comments from this run.",
413
+ "",
414
+ ]
415
+ )
416
+ for index, cluster in enumerate(solid_clusters, start=1):
417
+ lines.append(
418
+ f"### {index}. {_safe_title(cluster.title)} "
419
+ f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} "
420
+ f"item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
421
+ f"sources: {', '.join(_source_label(source) for source in cluster.sources)})"
422
+ )
423
+ if cluster.uncertainty:
424
+ lines.append(f"- Uncertainty: {cluster.uncertainty}")
425
+ representative_ids = _qualifying_representative_ids(
426
+ cluster,
427
+ candidate_by_id,
428
+ )
429
+ for rep_index, candidate_id in enumerate(representative_ids, start=1):
430
+ candidate = candidate_by_id.get(candidate_id)
431
+ if not candidate:
432
+ continue
433
+ lines.extend(
434
+ _render_candidate(candidate, prefix=f"{rep_index}.", report=report)
435
+ )
436
+ lines.append("")
437
+ return lines
438
+
439
+
440
+ def _auxiliary_candidate_pool(
441
+ report: schema.Report,
442
+ visible_clusters: list[schema.Cluster],
443
+ solid_clusters: list[schema.Cluster],
444
+ ) -> list[schema.Candidate]:
445
+ """Candidates for Best Takes and Top Community Comments.
446
+
447
+ Those sections are cross-cutting evidence surfaces, so they read every
448
+ cluster that clears the relevance floor, not only the ``cluster_limit``
449
+ clusters shown in ``## Ranked Evidence Clusters``: a 3,000-vote comment on
450
+ the thread in cluster eleven is exactly what the brief is for. The
451
+ nothing-solid gate is unchanged: when the visible set has clusters but
452
+ none clear the floor, the pool is empty.
453
+ """
454
+ if visible_clusters and not solid_clusters:
455
+ return []
456
+ all_solid = _clusters_clearing_relevance_floor(report, report.clusters)
457
+ return _candidates_for_auxiliary_sections(report, report.clusters, all_solid)
458
+
459
+
460
+ def _clusters_clearing_relevance_floor(
461
+ report: schema.Report,
462
+ clusters: list[schema.Cluster],
463
+ ) -> list[schema.Cluster]:
464
+ """Return visible clusters with positive, non-entity-miss evidence.
465
+
466
+ A zero-score cluster is diagnostic retrieval residue rather than evidence.
467
+ Likewise, a positive cluster with known members but no qualifying member
468
+ must not be promoted by engagement into the synthesis. Every cluster member
469
+ is considered because MMR representatives can omit valid evidence. Missing
470
+ member records are not treated as misses: score remains the only signal
471
+ available when no member record is present.
472
+ """
473
+ candidate_by_id = {
474
+ candidate.candidate_id: candidate for candidate in report.ranked_candidates
475
+ }
476
+ solid: list[schema.Cluster] = []
477
+ for cluster in clusters:
478
+ if cluster.score <= 0:
479
+ continue
480
+ members = [
481
+ candidate_by_id[candidate_id]
482
+ for candidate_id in cluster.candidate_ids
483
+ if candidate_id in candidate_by_id
484
+ ]
485
+ if members and not any(
486
+ _best_take_relevance_ok(candidate) for candidate in members
487
+ ):
488
+ continue
489
+ solid.append(cluster)
490
+ return solid
491
+
492
+
493
+ def _candidates_in_clusters(
494
+ report: schema.Report,
495
+ clusters: list[schema.Cluster],
496
+ ) -> list[schema.Candidate]:
497
+ """Return ranked candidates belonging to the supplied visible clusters."""
498
+ candidate_ids = {
499
+ candidate_id for cluster in clusters for candidate_id in cluster.candidate_ids
500
+ }
501
+ return [
502
+ candidate
503
+ for candidate in report.ranked_candidates
504
+ if candidate.candidate_id in candidate_ids
505
+ ]
506
+
507
+
508
+ def _candidates_for_auxiliary_sections(
509
+ report: schema.Report,
510
+ requested_clusters: list[schema.Cluster],
511
+ visible_clusters: list[schema.Cluster],
512
+ ) -> list[schema.Candidate]:
513
+ """Exclude rejected cluster members while preserving unclustered evidence."""
514
+ if requested_clusters and not visible_clusters:
515
+ return []
516
+ clustered_ids = {
517
+ candidate_id
518
+ for cluster in report.clusters
519
+ for candidate_id in cluster.candidate_ids
520
+ }
521
+ visible_ids = {
522
+ candidate_id
523
+ for cluster in visible_clusters
524
+ for candidate_id in cluster.candidate_ids
525
+ }
526
+ return [
527
+ candidate
528
+ for candidate in report.ranked_candidates
529
+ if candidate.candidate_id not in clustered_ids
530
+ or candidate.candidate_id in visible_ids
531
+ ]
532
+
533
+
534
+ def _visible_clusters_fail_relevance_floor(
535
+ report: schema.Report,
536
+ clusters: list[schema.Cluster],
537
+ ) -> bool:
538
+ """Whether a non-empty visible cluster set contains no usable evidence."""
539
+ return bool(clusters) and not _clusters_clearing_relevance_floor(report, clusters)
540
+
541
+
542
+ def _render_corpus_section(report: schema.Report, limit: int = 8) -> list[str]:
543
+ """Render private local evidence in one removable, clearly badged block."""
544
+ candidates = [
545
+ candidate
546
+ for candidate in report.ranked_candidates
547
+ if candidate.source == "corpus"
548
+ ][:limit]
549
+ if not candidates:
550
+ return []
551
+ lines = [
552
+ PRIVATE_CORPUS_START,
553
+ "## From your files",
554
+ "",
555
+ "> 🔒 **LOCAL ONLY** - excluded from hosted publishing and agent JSON unless explicitly opted in.",
556
+ "",
557
+ ]
558
+ for candidate in candidates:
559
+ primary = schema.candidate_primary_item(candidate)
560
+ path = str((primary.metadata if primary else {}).get("relative_path") or "")
561
+ published = primary.published_at if primary else None
562
+ detail = f"modified {published}" if published else "modification date unknown"
563
+ # Corpus content sits inside the EVIDENCE FOR SYNTHESIS envelope, so it
564
+ # needs the engine-sentinel defanging every other evidence path gets
565
+ # (#1053) on top of the corpus-marker defanging. A local file is not
566
+ # automatically trustworthy input: it may have been downloaded, shared,
567
+ # or generated by someone else.
568
+ lines.append(
569
+ f"- **{_defang_corpus_sentinels(_safe_title(candidate.title))}** "
570
+ f"({detail}, relevance {candidate.final_score:.0f})"
571
+ )
572
+ if path:
573
+ lines.append(
574
+ f" - File: `{_defang_corpus_sentinels(_safe_title(path))}`"
575
+ )
576
+ if candidate.snippet:
577
+ lines.append(
578
+ " - "
579
+ + _defang_corpus_sentinels(
580
+ _format_untrusted_evidence(
581
+ candidate.snippet, 300, continuation_indent=" "
582
+ )
583
+ )
584
+ )
585
+ lines.append(PRIVATE_CORPUS_END)
586
+ return lines
587
+
588
+
589
+ def _defang_corpus_sentinels(value: str) -> str:
590
+ """Source content must not be able to terminate the private-block markers.
591
+
592
+ A note containing the literal end marker would otherwise close the block
593
+ early, leaving later corpus snippets in publishable output.
594
+ """
595
+ return value.replace("LAST30DAYS_PRIVATE_CORPUS", "LAST30DAYS_PRIVATE-CORPUS")
596
+
597
+
598
+ def _defang_engine_sentinels(value: str) -> str:
599
+ """Source content must not be able to forge the engine's own block markers.
600
+
601
+ The EVIDENCE FOR SYNTHESIS and PASS-THROUGH FOOTER envelopes are HTML
602
+ comments, and LAW 5 tells the host model to emit the footer block
603
+ verbatim. Scraped text carrying those markers could therefore close the
604
+ evidence envelope early or open a footer the model relays to the user
605
+ unmodified. Breaking the comment delimiters is what actually neutralizes
606
+ them; the phrase substitutions are defense in depth for a model that
607
+ pattern-matches on the wording rather than the comment syntax.
608
+ """
609
+ return (
610
+ value.replace("<!--", "<!- -")
611
+ .replace("-->", "- ->")
612
+ .replace("PASS-THROUGH FOOTER", "PASS-THROUGH-FOOTER")
613
+ .replace("EVIDENCE FOR SYNTHESIS", "EVIDENCE-FOR-SYNTHESIS")
614
+ )
615
+
616
+
617
+ def _safe_title(title: str) -> str:
618
+ """Render a scraped title as one defanged line.
619
+
620
+ On X, TikTok, Instagram and LinkedIn the title *is* the post body
621
+ (``normalize.py`` takes ``text[:140]``), and normalization only strips
622
+ the ends, so internal newlines survive. Unlike snippets, titles are
623
+ interpolated straight into engine-authored structure, so an unflattened
624
+ one can open its own markdown blocks or forge the sentinels above.
625
+ Collapsing whitespace first is what keeps the title on the line the
626
+ caller built for it.
627
+ """
628
+ return _defang_engine_sentinels(" ".join(title.split()))
629
+
630
+
631
+ _FRESHNESS_PRIORITY = {
632
+ "contradicted": 0,
633
+ "stale": 1,
634
+ "unsupported": 2,
635
+ "current": 3,
636
+ }
637
+
638
+
639
+ def _candidate_freshness_flag(report: schema.Report, candidate_id: str) -> str:
640
+ states = {
641
+ verdict.verdict
642
+ for verdict in report.freshness_verdicts
643
+ if verdict.candidate_id == candidate_id
644
+ }
645
+ if not states:
646
+ return ""
647
+ ordered = sorted(states, key=lambda state: _FRESHNESS_PRIORITY[state])
648
+ return " [freshness:" + ",".join(ordered) + "]"
649
+
650
+
651
+ def _render_freshness_verdicts(report: schema.Report) -> list[str]:
652
+ if not report.freshness_verdicts:
653
+ return []
654
+ lines = [
655
+ "## Freshness Verification",
656
+ "",
657
+ "| Verdict | Claim | Evidence | Checked |",
658
+ "| --- | --- | --- | --- |",
659
+ ]
660
+ for verdict in report.freshness_verdicts:
661
+ claim = verdict.claim.replace("|", "\\|")
662
+ if verdict.detail:
663
+ # The verifier's detail carries the formatted movement for stale
664
+ # rows and the reason a claim could not be re-checked otherwise.
665
+ claim += f" ({verdict.detail.replace('|', chr(92) + '|')})"
666
+ evidence_label = (
667
+ verdict.evidence_timestamp or verdict.source_timestamp or "source"
668
+ )
669
+ evidence = (
670
+ f"[{evidence_label}]({verdict.evidence_url})"
671
+ if verdict.evidence_url
672
+ else evidence_label
673
+ )
674
+ lines.append(
675
+ f"| **{verdict.verdict}** | {claim} | {evidence} | {verdict.checked_at} |"
676
+ )
677
+ return lines
678
+
679
+
680
+ def _clusters_for_register(
681
+ report: schema.Report,
682
+ audience: registers.AudienceRegister,
683
+ fallback_limit: int,
684
+ ) -> list[schema.Cluster]:
685
+ """Apply a preset's source emphasis without mutating pipeline rankings."""
686
+
687
+ clusters = list(report.clusters)
688
+ if audience.emphasis_weights:
689
+ clusters.sort(
690
+ key=lambda cluster: (
691
+ -cluster.score
692
+ * max(
693
+ (audience.emphasis_for(source) for source in cluster.sources),
694
+ default=1.0,
695
+ )
696
+ )
697
+ )
698
+ return clusters[: audience.budget_for("clusters", fallback_limit)]
699
+
700
+
701
+ def _render_registered_sections(
702
+ report: schema.Report,
703
+ audience: registers.AudienceRegister,
704
+ fun_params: dict[str, float | int],
705
+ cluster_limit: int,
706
+ *,
707
+ include_source_diagnostics: bool = True,
708
+ ) -> list[str]:
709
+ """Render one audience preset's ordered, budgeted evidence sections."""
710
+
711
+ visible_clusters = _clusters_for_register(report, audience, cluster_limit)
712
+ solid_clusters = _clusters_clearing_relevance_floor(report, visible_clusters)
713
+ visible_candidates = _candidates_for_auxiliary_sections(
714
+ report,
715
+ visible_clusters,
716
+ solid_clusters,
717
+ )
718
+ no_solid_evidence = bool(visible_clusters) and not solid_clusters
719
+ aux_candidates = _auxiliary_candidate_pool(report, visible_clusters, solid_clusters)
720
+ if no_solid_evidence:
721
+ best_takes: list[str] = []
722
+ top_comments: list[str] = []
723
+ else:
724
+ best_takes = _render_best_takes(
725
+ aux_candidates,
726
+ limit=audience.budget_for("best_takes", int(fun_params["limit"])),
727
+ threshold=float(fun_params["threshold"]),
728
+ vote_weight=float(fun_params.get("vote_weight", 18.0)),
729
+ # The preset's source emphasis must reach the lead section's own
730
+ # ranking: a creator register surfaces TikTok/IG/YouTube takes ahead
731
+ # of equally-rated HN or GitHub ones.
732
+ source_weight=(
733
+ audience.emphasis_for if audience.emphasis_weights else None
734
+ ),
735
+ )
736
+ if not best_takes:
737
+ best_takes = [
738
+ "## Best Takes",
739
+ "",
740
+ "- No qualifying takes surfaced in this run.",
741
+ ]
742
+
743
+ top_comments = _render_top_comments(
744
+ report,
745
+ limit=audience.budget_for("top_comments", 8),
746
+ candidates=aux_candidates,
747
+ )
748
+ if not top_comments:
749
+ top_comments = [
750
+ "## Top Community Comments",
751
+ "",
752
+ "- No qualifying community comments surfaced in this run.",
753
+ ]
754
+
755
+ sections = {
756
+ "hiring_signals": (
757
+ []
758
+ if no_solid_evidence
759
+ else _render_hiring_signals(
760
+ report,
761
+ candidates=None if not visible_clusters else visible_candidates,
762
+ )
763
+ ),
764
+ "clusters": _render_ranked_clusters(
765
+ report,
766
+ visible_clusters,
767
+ ),
768
+ "stats": _render_stats(report),
769
+ "best_takes": best_takes,
770
+ "top_comments": top_comments,
771
+ "source_outcomes": _render_source_outcome_note(report),
772
+ "source_coverage": _render_source_coverage(report, include_errors=False),
773
+ }
774
+ lines: list[str] = []
775
+ for section_name in audience.section_order:
776
+ if not include_source_diagnostics and section_name in {
777
+ "source_outcomes",
778
+ "source_coverage",
779
+ }:
780
+ continue
781
+ block = sections[section_name]
782
+ if not block:
783
+ continue
784
+ if lines and lines[-1] != "":
785
+ lines.append("")
786
+ lines.extend(block)
787
+ return lines
788
+
789
+
790
+ def render_compact(
791
+ report: schema.Report,
792
+ cluster_limit: int = 8,
793
+ fun_level: str = "medium",
794
+ save_path: str | None = None,
795
+ register: str = "default",
796
+ ) -> str:
797
+ audience = registers.get_register(register)
798
+ evidence_report = schema.without_sources(report, {"corpus"})
113
799
  non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
114
800
  lines = [
115
801
  *_render_badge(),
@@ -117,21 +803,32 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
117
803
  "",
118
804
  *_assistant_safety_lines(),
119
805
  f"- Date range: {report.range_from} to {report.range_to}",
120
- f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})" if non_empty else "- Sources: none",
806
+ f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
807
+ if non_empty
808
+ else "- Sources: none",
121
809
  "",
122
810
  ]
811
+ drill_context = _render_drill_context(report)
812
+ if drill_context:
813
+ lines.extend([*drill_context, ""])
814
+ library_context = _render_library_context(report)
815
+ if library_context:
816
+ lines.extend([*library_context, ""])
123
817
 
124
818
  freshness_warning = _assess_data_freshness(report)
125
819
  if freshness_warning:
126
- lines.extend([
127
- "## Freshness",
128
- f"- {freshness_warning}",
129
- "",
130
- ])
820
+ lines.extend(
821
+ [
822
+ "## Freshness",
823
+ f"- {freshness_warning}",
824
+ "",
825
+ ]
826
+ )
131
827
 
132
- if report.warnings:
828
+ user_warnings = _warnings_without_source_failures(report.warnings)
829
+ if user_warnings:
133
830
  lines.append("## Warnings")
134
- lines.extend(f"- {warning}" for warning in report.warnings)
831
+ lines.extend(f"- {warning}" for warning in user_warnings)
135
832
  lines.append("")
136
833
 
137
834
  # LAW 7 backstop: emit the DEGRADED RUN WARNING block BEFORE the evidence
@@ -150,46 +847,84 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
150
847
  # verbatim as user output. The envelope comments give the model an
151
848
  # unambiguous scope for "pass through verbatim" (the PASS-THROUGH FOOTER
152
849
  # block below) vs "synthesize from" (this block).
153
- lines.append("<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->")
154
- lines.append("")
155
- hiring_block = _render_hiring_signals(report)
156
- if hiring_block:
157
- lines.extend(hiring_block)
158
- lines.append("")
159
- lines.append("## Ranked Evidence Clusters")
850
+ lines.append(
851
+ "<!-- EVIDENCE FOR SYNTHESIS: read this, do not emit verbatim. Transform into `What I learned:` prose per LAW 2. -->"
852
+ )
160
853
  lines.append("")
161
- candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
162
- for index, cluster in enumerate(report.clusters[:cluster_limit], start=1):
163
- lines.append(
164
- f"### {index}. {cluster.title} "
165
- f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
166
- f"sources: {', '.join(_source_label(source) for source in cluster.sources)})"
854
+ # Echo the synthesis contract early so it survives tail truncation (#726).
855
+ lines.extend(_render_synthesis_directive())
856
+ visible_clusters = evidence_report.clusters[:cluster_limit]
857
+ solid_clusters = _clusters_clearing_relevance_floor(
858
+ evidence_report,
859
+ visible_clusters,
860
+ )
861
+ visible_candidates = _candidates_for_auxiliary_sections(
862
+ evidence_report,
863
+ visible_clusters,
864
+ solid_clusters,
865
+ )
866
+ no_solid_evidence = bool(visible_clusters) and not solid_clusters
867
+ aux_candidates = _auxiliary_candidate_pool(
868
+ evidence_report, visible_clusters, solid_clusters
869
+ )
870
+ hiring_block = (
871
+ []
872
+ if no_solid_evidence
873
+ else _render_hiring_signals(
874
+ evidence_report,
875
+ candidates=None if not visible_clusters else visible_candidates,
167
876
  )
168
- if cluster.uncertainty:
169
- lines.append(f"- Uncertainty: {cluster.uncertainty}")
170
- for rep_index, candidate_id in enumerate(cluster.representative_ids, start=1):
171
- candidate = candidate_by_id.get(candidate_id)
172
- if not candidate:
173
- continue
174
- lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
877
+ )
878
+ if hiring_block and audience.name in {"default", "eli5"}:
879
+ lines.extend(hiring_block)
175
880
  lines.append("")
176
-
177
- lines.extend(_render_stats(report))
178
-
179
881
  fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
180
- best_takes = _render_best_takes(report.ranked_candidates, limit=fun_params["limit"], threshold=fun_params["threshold"], vote_weight=fun_params.get("vote_weight", 18.0))
181
- if best_takes:
182
- lines.extend([""] + best_takes)
882
+ if audience.name in {"default", "eli5"}:
883
+ # Keep this legacy assembly byte-for-byte stable. ELI5 has always been
884
+ # a synthesis-only voice change, so it intentionally takes this path.
885
+ lines.extend(_render_ranked_clusters(evidence_report, visible_clusters))
886
+ lines.extend(_render_stats(evidence_report))
887
+
888
+ if not no_solid_evidence:
889
+ best_takes = _render_best_takes(
890
+ aux_candidates,
891
+ limit=fun_params["limit"],
892
+ threshold=fun_params["threshold"],
893
+ vote_weight=fun_params.get("vote_weight", 18.0),
894
+ )
895
+ if best_takes:
896
+ lines.extend([""] + best_takes)
897
+
898
+ top_comments = _render_top_comments(
899
+ evidence_report,
900
+ candidates=aux_candidates,
901
+ )
902
+ if top_comments:
903
+ lines.extend([""] + top_comments)
183
904
 
184
- top_comments = _render_top_comments(report)
185
- if top_comments:
186
- lines.extend([""] + top_comments)
905
+ outcome_note = _render_source_outcome_note(report)
906
+ if outcome_note:
907
+ lines.extend([""] + outcome_note)
187
908
 
188
- lines.extend(_render_source_coverage(report))
909
+ lines.extend(_render_source_coverage(report, include_errors=False))
910
+ else:
911
+ lines.extend(
912
+ _render_registered_sections(
913
+ evidence_report, audience, fun_params, cluster_limit
914
+ )
915
+ )
916
+ corpus_section = _render_corpus_section(report)
917
+ if corpus_section:
918
+ lines.extend(["", *corpus_section])
189
919
  # Close EVIDENCE FOR SYNTHESIS envelope before anything that passes through verbatim.
190
920
  lines.append("")
191
921
  lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
192
922
 
923
+ freshness_verdicts = _render_freshness_verdicts(report)
924
+ if freshness_verdicts:
925
+ lines.append("")
926
+ lines.extend(freshness_verdicts)
927
+
193
928
  pre_research_warning = _render_pre_research_warning(report)
194
929
  if pre_research_warning:
195
930
  lines.append("")
@@ -203,7 +938,9 @@ def render_compact(report: schema.Report, cluster_limit: int = 8, fun_level: str
203
938
  footer = _render_emoji_footer(report, save_path)
204
939
  if footer:
205
940
  lines.append("")
206
- lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
941
+ lines.append(
942
+ "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
943
+ )
207
944
  lines.extend(footer)
208
945
  lines.append("<!-- END PASS-THROUGH FOOTER -->")
209
946
 
@@ -217,6 +954,8 @@ def render_for_html(
217
954
  synthesis_md: str | None = None,
218
955
  *,
219
956
  save_path: str | None = None,
957
+ fun_level: str = "medium",
958
+ register: str = "default",
220
959
  ) -> str:
221
960
  """Render markdown intended for shareable HTML conversion.
222
961
 
@@ -226,20 +965,59 @@ def render_for_html(
226
965
  model-facing safety note, and evidence scratchpad emitted by
227
966
  render_compact().
228
967
 
229
- When synthesis_md is None, the body is intentionally sparse: badge,
230
- metadata, optional data quality note, and engine footer only.
968
+ With the default/eli5 register and no synthesis_md, the body is
969
+ intentionally sparse: badge, metadata, optional data quality note, and
970
+ engine footer only. Other named registers render their ordered evidence
971
+ sections so direct HTML output reflects the selected audience preset.
231
972
  """
973
+ audience = registers.get_register(register)
974
+ evidence_report = schema.without_sources(report, {"corpus"})
232
975
  lines = [
233
976
  *_render_badge(),
234
977
  *_render_html_metadata(report),
235
978
  ]
236
- hiring_block = _render_hiring_signals(report)
979
+ drill_context = _render_drill_context(report)
980
+ if drill_context:
981
+ lines.extend(["", *drill_context])
982
+ html_clusters = _clusters_clearing_relevance_floor(
983
+ evidence_report,
984
+ evidence_report.clusters,
985
+ )
986
+ html_candidates = _candidates_for_auxiliary_sections(
987
+ evidence_report,
988
+ evidence_report.clusters,
989
+ html_clusters,
990
+ )
991
+ hiring_block = _render_hiring_signals(
992
+ evidence_report,
993
+ candidates=html_candidates if evidence_report.clusters else None,
994
+ )
237
995
  if synthesis_md:
238
996
  lines.extend(["", synthesis_md.strip()])
239
997
  if hiring_block and "## Hiring Signals" not in synthesis_md:
240
998
  lines.extend(["", *hiring_block])
241
- elif hiring_block:
999
+ elif hiring_block and audience.name in {"default", "eli5"}:
242
1000
  lines.extend(["", *hiring_block])
1001
+ if not synthesis_md and audience.name not in {"default", "eli5"}:
1002
+ fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
1003
+ lines.extend(
1004
+ [
1005
+ "",
1006
+ *_render_registered_sections(
1007
+ evidence_report,
1008
+ audience,
1009
+ fun_params,
1010
+ 8,
1011
+ include_source_diagnostics=False,
1012
+ ),
1013
+ ]
1014
+ )
1015
+ corpus_section = _render_corpus_section(report)
1016
+ if corpus_section:
1017
+ lines.extend(["", *corpus_section])
1018
+ freshness_verdicts = _render_freshness_verdicts(report)
1019
+ if freshness_verdicts:
1020
+ lines.extend(["", *freshness_verdicts])
243
1021
  # Data quality warnings are NOT rendered into the HTML artifact. The HTML
244
1022
  # is meant to be shared (Slack, email, Notion); recipients haven't asked
245
1023
  # for technical commentary about how the run was produced. Generators see
@@ -275,6 +1053,13 @@ def render_for_html_comparison(
275
1053
  ]
276
1054
  if synthesis_md:
277
1055
  lines.extend(["", synthesis_md.strip()])
1056
+ for label, report in entity_reports:
1057
+ freshness_verdicts = _render_freshness_verdicts(report)
1058
+ if freshness_verdicts:
1059
+ lines.extend(["", f"## {label}", "", *freshness_verdicts])
1060
+ corpus_section = _render_corpus_section(report)
1061
+ if corpus_section:
1062
+ lines.extend(["", f"## {label}", "", *corpus_section])
278
1063
  # Comparison data quality notes also go to stderr, not into the artifact.
279
1064
  _append_html_footer(lines, main_report, save_path)
280
1065
  return "\n".join(lines).strip() + "\n"
@@ -289,9 +1074,13 @@ def collect_html_warnings(report: schema.Report) -> list[str]:
289
1074
  """
290
1075
  notes: list[str] = []
291
1076
  if _render_degraded_run_warning(report):
292
- notes.append("Run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
1077
+ notes.append(
1078
+ "Run was missing pre-flight resolution. Re-run with `--plan` for richer results."
1079
+ )
293
1080
  elif _render_pre_research_warning(report):
294
- notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
1081
+ notes.append(
1082
+ "Pre-research was skipped, so results may be thinner than a resolved run."
1083
+ )
295
1084
  freshness_warning = _assess_data_freshness(report)
296
1085
  if freshness_warning:
297
1086
  notes.append(freshness_warning)
@@ -332,10 +1121,14 @@ def _render_html_data_quality_note(report: schema.Report) -> str | None:
332
1121
  notes: list[str] = []
333
1122
  degraded_warning = _render_degraded_run_warning(report)
334
1123
  if degraded_warning:
335
- notes.append("This run was missing pre-flight resolution. Re-run with `--plan` for richer results.")
1124
+ notes.append(
1125
+ "This run was missing pre-flight resolution. Re-run with `--plan` for richer results."
1126
+ )
336
1127
  pre_research_warning = _render_pre_research_warning(report)
337
1128
  if pre_research_warning and not degraded_warning:
338
- notes.append("Pre-research was skipped, so results may be thinner than a resolved run.")
1129
+ notes.append(
1130
+ "Pre-research was skipped, so results may be thinner than a resolved run."
1131
+ )
339
1132
  freshness_warning = _assess_data_freshness(report)
340
1133
  if freshness_warning:
341
1134
  notes.append(freshness_warning)
@@ -371,14 +1164,48 @@ def _dedupe_notes(notes: list[str]) -> list[str]:
371
1164
  return out
372
1165
 
373
1166
 
374
- def _append_html_footer(lines: list[str], report: schema.Report, save_path: str | None) -> None:
1167
+ def _append_html_footer(
1168
+ lines: list[str], report: schema.Report, save_path: str | None
1169
+ ) -> None:
375
1170
  footer = _render_emoji_footer(report, save_path)
376
1171
  lines.append("")
377
- lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
1172
+ lines.append(
1173
+ "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
1174
+ )
378
1175
  lines.extend(footer)
379
1176
  lines.append("<!-- END PASS-THROUGH FOOTER -->")
380
1177
 
381
1178
 
1179
+ def _render_synthesis_directive() -> list[str]:
1180
+ """Echo the synthesis contract at the TOP of the evidence envelope.
1181
+
1182
+ Added 2026-06-30 for issue #726 (Grok Build v0.2.67 emitted only logs and
1183
+ raw evidence clusters instead of the canonical synthesis). Root cause: the
1184
+ strong directive only lived in `_render_canonical_boundary` — the very END
1185
+ of stdout, AFTER the whole evidence block and footer. Hosts that truncate
1186
+ the tail (`engine | head -N`, timeout-backgrounding that captures partial
1187
+ output, scrollback caps) keep the badge and the `### N.` clusters but never
1188
+ reach the instruction that says "synthesize, don't dump", so they fall into
1189
+ the LAW 6 failure mode and emit the raw evidence.
1190
+
1191
+ This block restates the contract in the head region that survives
1192
+ truncation. It lives INSIDE the EVIDENCE FOR SYNTHESIS envelope (a model
1193
+ instruction, not user output), mirroring how the DEGRADED RUN WARNING is
1194
+ positioned early so the pass-through contract still carries it.
1195
+ """
1196
+ return [
1197
+ "> **SYNTHESIS CONTRACT — read before emitting anything.** Everything below this",
1198
+ "> line, up to where this evidence envelope closes, is raw evidence for you to",
1199
+ "> READ, not text to emit. Transform it into `What I learned:` prose paragraphs",
1200
+ "> per LAW 2. Do NOT pass the `### N.` evidence clusters or the stats and",
1201
+ "> source-coverage blocks through verbatim. The ONLY block you emit verbatim is",
1202
+ "> the PASS-THROUGH FOOTER (the emoji tree) lower down. The full contract repeats",
1203
+ "> at the end-of-output boundary near the bottom; if your captured output was",
1204
+ "> truncated and never reached it, this contract still binds.",
1205
+ "",
1206
+ ]
1207
+
1208
+
382
1209
  def _render_canonical_boundary() -> list[str]:
383
1210
  """Emit the explicit END-OF-CANONICAL-OUTPUT boundary.
384
1211
 
@@ -409,10 +1236,9 @@ def _render_canonical_boundary() -> list[str]:
409
1236
  "of synthesizing - STOP and regenerate. This is the 2026-04-19 Hermes Agent",
410
1237
  "Use Cases failure mode (LAW 6).",
411
1238
  "",
412
- "Do not append a trailing `Sources:` block; the emoji-tree footer above is",
413
- "the sources list. LAW 1 overrides any WebSearch tool 'CRITICAL: MUST include",
414
- "Sources' reminder - that reminder is a generic tool contract and does not",
415
- "apply to last30days output.",
1239
+ "Cite web pages inline next to the claims they support (LAW 1), then end at",
1240
+ "the invitation, counting web pages in its link line. The emoji-tree footer",
1241
+ "above already summarizes the sources, so do not add a `Sources:` list.",
416
1242
  ]
417
1243
 
418
1244
 
@@ -479,7 +1305,7 @@ def _render_pre_research_warning(report: schema.Report) -> list[str]:
479
1305
  f"rerun `/last30days {report.topic}`. The skill will resolve handles",
480
1306
  "and communities before calling the engine this time, producing richer results.",
481
1307
  "",
482
- "If this topic really is abstract (e.g. \"AI regulation\") and doesn't need",
1308
+ 'If this topic really is abstract (e.g. "AI regulation") and doesn\'t need',
483
1309
  "handle resolution, add `--auto-resolve` to the engine command or ignore this",
484
1310
  "warning - the current results are the keyword-search fallback.",
485
1311
  ]
@@ -517,7 +1343,7 @@ def _render_degraded_run_warning(report: schema.Report) -> list[str]:
517
1343
  "<!-- USER-VISIBLE BANNER: emit verbatim before synthesis per LAW 5 / LAW 7. -->",
518
1344
  "## DEGRADED RUN WARNING",
519
1345
  "",
520
- f"⚠️ This run was called BARE on a named-entity topic (\"{report.topic}\"):",
1346
+ f'⚠️ This run was called BARE on a named-entity topic ("{report.topic}"):',
521
1347
  "no `--plan` JSON, no pre-flight handle resolution. The engine ran",
522
1348
  "deterministic fallback retrieval, which is the cron/headless path -",
523
1349
  "weaker than the path where the hosting reasoning model authors its own",
@@ -539,25 +1365,23 @@ def _render_degraded_run_warning(report: schema.Report) -> list[str]:
539
1365
  "",
540
1366
  "**If you are a user reading this:** the assistant skipped its own",
541
1367
  "planning step. Ask it to regenerate following Step 0.55 and Step 0.75",
542
- "of SKILL.md.",
1368
+ "of the skill's research runbook.",
543
1369
  "<!-- END USER-VISIBLE BANNER -->",
544
1370
  ]
545
1371
 
546
1372
 
547
1373
  def _parse_comparison_entities(topic: str) -> list[str] | None:
548
- """Return list of entity names if topic is a comparison query, else None.
1374
+ """Return entity names if topic is a comparison query, else None.
549
1375
 
550
- Splits on ` vs ` or ` versus ` (case-insensitive). Caps at 4 entities
551
- for table readability. Returns None if only one entity or empty input.
1376
+ Delegates to ``planner._comparison_entities`` so scaffold columns match
1377
+ vs-routing (including `/`, trailing-context strip, and dedup).
552
1378
  """
553
1379
  if not topic:
554
1380
  return None
555
- import re
556
- parts = re.split(r"\s+(?:vs\.?|versus)\s+", topic.strip(), flags=re.IGNORECASE)
557
- parts = [p.strip() for p in parts if p.strip()]
558
- if len(parts) < 2:
559
- return None
560
- return parts[:4]
1381
+ from . import planner
1382
+
1383
+ entities = planner._comparison_entities(topic)
1384
+ return entities if len(entities) >= 2 else None
561
1385
 
562
1386
 
563
1387
  def _render_comparison_scaffold(topic: str) -> list[str]:
@@ -597,7 +1421,7 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
597
1421
  fill_instructions = (
598
1422
  "Fill each cell based on the research above. Keep cells short (5-15 words). "
599
1423
  "Use ' - ' (hyphen with spaces) not em-dashes. Write N/A for axes that do not apply to this topic class. "
600
- "Ground the \"What it is\" row in first-party positioning fetched during this run's research when "
1424
+ 'Ground the "What it is" row in first-party positioning fetched during this run\'s research when '
601
1425
  "available - describe each entity as it pitches itself today, never from memory. "
602
1426
  "This scaffold matches the April 9 launch-video exemplar shape."
603
1427
  )
@@ -611,7 +1435,7 @@ def _render_comparison_scaffold(topic: str) -> list[str]:
611
1435
  separator,
612
1436
  *body,
613
1437
  "",
614
- "After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in SKILL.md for the full structure.",
1438
+ "After the table, write the Bottom Line section with one Choose-X-if paragraph per entity, then the emerging stack paragraph. See the comparison template in the skill's references/comparison.md for the full structure.",
615
1439
  ]
616
1440
 
617
1441
 
@@ -667,6 +1491,8 @@ def render_comparison_multi(
667
1491
  "`What I learned:` prose per LAW 2. Each entity has its own evidence subsection. -->"
668
1492
  )
669
1493
  lines.append("")
1494
+ # Echo the synthesis contract early so it survives tail truncation (#726).
1495
+ lines.extend(_render_synthesis_directive())
670
1496
 
671
1497
  resolved_block = _render_resolved_entities_block(entity_reports)
672
1498
  if resolved_block:
@@ -675,16 +1501,23 @@ def render_comparison_multi(
675
1501
 
676
1502
  fun_params = _FUN_LEVELS.get(fun_level, _FUN_LEVELS["medium"])
677
1503
  for label, report in entity_reports:
678
- lines.extend(_render_entity_evidence_block(
679
- label=label,
680
- report=report,
681
- cluster_limit=cluster_limit,
682
- fun_params=fun_params,
683
- ))
1504
+ lines.extend(
1505
+ _render_entity_evidence_block(
1506
+ label=label,
1507
+ report=report,
1508
+ cluster_limit=cluster_limit,
1509
+ fun_params=fun_params,
1510
+ )
1511
+ )
684
1512
 
685
1513
  lines.append("<!-- END EVIDENCE FOR SYNTHESIS -->")
686
1514
  lines.append("")
687
1515
 
1516
+ for label, report in entity_reports:
1517
+ freshness_verdicts = _render_freshness_verdicts(report)
1518
+ if freshness_verdicts:
1519
+ lines.extend([f"## {label}", "", *freshness_verdicts, ""])
1520
+
688
1521
  # Reuse the existing comparison scaffold by feeding it the synthesized
689
1522
  # topic. _parse_comparison_entities splits on " vs " so the scaffold
690
1523
  # picks up all N entities automatically.
@@ -694,7 +1527,9 @@ def render_comparison_multi(
694
1527
  footer = _render_emoji_footer(main_report, save_path)
695
1528
  if footer:
696
1529
  lines.append("")
697
- lines.append("<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->")
1530
+ lines.append(
1531
+ "<!-- PASS-THROUGH FOOTER: emit verbatim in the model response per LAW 5. -->"
1532
+ )
698
1533
  lines.extend(footer)
699
1534
  lines.append("<!-- END PASS-THROUGH FOOTER -->")
700
1535
 
@@ -731,15 +1566,20 @@ def _render_resolved_entities_block(
731
1566
 
732
1567
  x_display = f"@{x_handle}" if x_handle else "-"
733
1568
  subs_display = (
734
- ", ".join(f"r/{s}" for s in subs[:5]) + (
735
- f" (+{len(subs) - 5})" if len(subs) > 5 else ""
1569
+ (
1570
+ ", ".join(f"r/{s}" for s in subs[:5])
1571
+ + (f" (+{len(subs) - 5})" if len(subs) > 5 else "")
736
1572
  )
737
- ) if subs else "-"
1573
+ if subs
1574
+ else "-"
1575
+ )
738
1576
  gh_display = f"@{gh_user}" if gh_user else "-"
739
1577
  if gh_repos:
740
- gh_display += f" ({', '.join(gh_repos[:3])}" + (
741
- f" +{len(gh_repos) - 3}" if len(gh_repos) > 3 else ""
742
- ) + ")"
1578
+ gh_display += (
1579
+ f" ({', '.join(gh_repos[:3])}"
1580
+ + (f" +{len(gh_repos) - 3}" if len(gh_repos) > 3 else "")
1581
+ + ")"
1582
+ )
743
1583
  context_display = _truncate(context, 120) if context else "-"
744
1584
 
745
1585
  out.append(
@@ -757,34 +1597,66 @@ def _render_entity_evidence_block(
757
1597
  fun_params: dict,
758
1598
  ) -> list[str]:
759
1599
  """Render one entity's clusters and best-takes inside the evidence envelope."""
760
- candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
1600
+ evidence_report = schema.without_sources(report, {"corpus"})
1601
+ candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
1602
+ requested_clusters = evidence_report.clusters[:cluster_limit]
1603
+ visible_clusters = _clusters_clearing_relevance_floor(
1604
+ evidence_report,
1605
+ requested_clusters,
1606
+ )
761
1607
  out: list[str] = [f"## {label}", ""]
762
1608
 
763
- if not report.clusters:
1609
+ if not evidence_report.clusters:
764
1610
  out.append("(no significant discussion this month)")
765
1611
  out.append("")
1612
+ corpus_section = _render_corpus_section(report)
1613
+ if corpus_section:
1614
+ out.extend(corpus_section)
1615
+ out.append("")
766
1616
  return out
767
1617
 
768
1618
  out.append("### Ranked Evidence Clusters")
769
1619
  out.append("")
770
- for index, cluster in enumerate(report.clusters[:cluster_limit], start=1):
1620
+ if requested_clusters and not visible_clusters:
1621
+ out.extend(
1622
+ [
1623
+ "**Nothing solid this window.**",
1624
+ "",
1625
+ "No recent evidence cluster cleared the relevance floor.",
1626
+ "",
1627
+ ]
1628
+ )
1629
+ for index, cluster in enumerate(visible_clusters, start=1):
771
1630
  out.append(
772
- f"#### {index}. {cluster.title} "
1631
+ f"#### {index}. {_safe_title(cluster.title)} "
773
1632
  f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item"
774
1633
  f"{'s' if len(cluster.candidate_ids) != 1 else ''}, "
775
1634
  f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
776
1635
  )
777
1636
  if cluster.uncertainty:
778
1637
  out.append(f"- Uncertainty: {cluster.uncertainty}")
779
- for rep_index, candidate_id in enumerate(cluster.representative_ids, start=1):
1638
+ representative_ids = _qualifying_representative_ids(
1639
+ cluster,
1640
+ candidate_by_id,
1641
+ )
1642
+ for rep_index, candidate_id in enumerate(representative_ids, start=1):
780
1643
  candidate = candidate_by_id.get(candidate_id)
781
1644
  if not candidate:
782
1645
  continue
783
- out.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
1646
+ out.extend(
1647
+ _render_candidate(
1648
+ candidate, prefix=f"{rep_index}.", report=evidence_report
1649
+ )
1650
+ )
784
1651
  out.append("")
785
1652
 
1653
+ comparison_candidates = _candidates_for_auxiliary_sections(
1654
+ evidence_report,
1655
+ requested_clusters,
1656
+ visible_clusters,
1657
+ )
786
1658
  best_takes = _render_best_takes(
787
- report.ranked_candidates,
1659
+ comparison_candidates,
788
1660
  limit=fun_params["limit"],
789
1661
  threshold=fun_params["threshold"],
790
1662
  vote_weight=fun_params.get("vote_weight", 18.0),
@@ -793,6 +1665,11 @@ def _render_entity_evidence_block(
793
1665
  out.extend(best_takes)
794
1666
  out.append("")
795
1667
 
1668
+ corpus_section = _render_corpus_section(report)
1669
+ if corpus_section:
1670
+ out.extend(corpus_section)
1671
+ out.append("")
1672
+
796
1673
  return out
797
1674
 
798
1675
 
@@ -815,23 +1692,121 @@ def render_comparison_multi_context(
815
1692
  if resolved_block:
816
1693
  lines.extend(resolved_block)
817
1694
  lines.append("")
1695
+ # render_context surfaces warnings for a single-entity run; omitting them
1696
+ # here left context mode the one supported output where a dropped entity
1697
+ # or a failed source is invisible. Placed above the per-entity sections so
1698
+ # it survives tail truncation, matching render_comparison_multi.
1699
+ aggregated_warnings = [
1700
+ f"[{label}] {warning}"
1701
+ for label, report in entity_reports
1702
+ for warning in report.warnings
1703
+ ]
1704
+ if aggregated_warnings:
1705
+ lines.append("Warnings:")
1706
+ lines.extend(f"- {warning}" for warning in aggregated_warnings)
1707
+ lines.append("")
818
1708
  for label, report in entity_reports:
1709
+ evidence_report = schema.without_sources(report, {"corpus"})
1710
+ requested_clusters = evidence_report.clusters[:cluster_limit]
1711
+ visible_clusters = _clusters_clearing_relevance_floor(
1712
+ evidence_report,
1713
+ requested_clusters,
1714
+ )
819
1715
  lines.append(f"## {label}")
820
1716
  lines.append(f"Intent: {report.query_plan.intent}")
821
- if not report.clusters:
1717
+ if not evidence_report.clusters:
822
1718
  lines.append("- (no significant discussion this month)")
1719
+ elif not visible_clusters:
1720
+ lines.append("- Nothing solid this window.")
823
1721
  else:
824
- for cluster in report.clusters[:cluster_limit]:
1722
+ for cluster in visible_clusters:
825
1723
  lines.append(
826
- f"- {cluster.title} "
1724
+ f"- {_safe_title(cluster.title)} "
827
1725
  f"[{', '.join(_source_label(s) for s in cluster.sources)}]"
828
1726
  )
1727
+ corpus_section = _render_corpus_section(report)
1728
+ if corpus_section:
1729
+ lines.extend(["", *corpus_section])
829
1730
  lines.append("")
830
1731
  return "\n".join(lines).strip() + "\n"
831
1732
 
832
1733
 
833
- def render_full(report: schema.Report) -> str:
834
- """Full data dump: ALL clusters + ALL items by source. For saved files and debugging."""
1734
+ _SAFE_MARKDOWN_LINK_SCHEMES = ("http", "https")
1735
+ _MARKDOWN_LINK_UNSAFE_CHARS = ("(", ")", "[", "]", "\\", "<", ">", "`")
1736
+ _MARKDOWN_PLAIN_TEXT_ESCAPES = re.compile(r"([\\`*_{}\[\]()#+\-.!|~:])")
1737
+
1738
+
1739
+ def _sanitize_url_for_single_line_output(url: str) -> str:
1740
+ """Collapse embedded newlines/carriage-returns out of an untrusted URL.
1741
+
1742
+ A URL is not supposed to contain raw line breaks; a source-controlled
1743
+ value that does could otherwise inject fabricated report structure
1744
+ (fake headings, list items) into the saved single-line output --
1745
+ whether or not it ends up wrapped in markdown link syntax. Applied
1746
+ before either the link-safety check or the plain-text fallback below,
1747
+ so this closes the injection at the root rather than only for links.
1748
+ """
1749
+ return "".join(
1750
+ " "
1751
+ if ch.isspace() or ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F
1752
+ else ch
1753
+ for ch in url
1754
+ )
1755
+
1756
+
1757
+ def _escape_markdown_plain_text(value: str) -> str:
1758
+ """Make untrusted text inert in Markdown without hiding its contents."""
1759
+ value = value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1760
+ return _MARKDOWN_PLAIN_TEXT_ESCAPES.sub(r"\\\1", value)
1761
+
1762
+
1763
+ def _markdown_url_link(url: str) -> str:
1764
+ """Render ``url`` as a markdown link when it's safe to, else escaped text.
1765
+
1766
+ Source URLs are untrusted API responses, not authored content: `(`/`)`/
1767
+ `[`/`]` would corrupt markdown link syntax, a backslash can escape
1768
+ adjacent markdown delimiters, and an unrestricted scheme (e.g.
1769
+ ``javascript:``) would become an active link with none of the safety
1770
+ filtering ``html_render.py`` already applies via
1771
+ ``html.escape(url, quote=True)``. Falls back to escaped plain text so
1772
+ rejected input cannot remain active Markdown or raw HTML.
1773
+ """
1774
+ if not url:
1775
+ return ""
1776
+ sanitized_url = _sanitize_url_for_single_line_output(url)
1777
+ if not sanitized_url.strip():
1778
+ return ""
1779
+
1780
+ has_whitespace_or_control = any(
1781
+ ch.isspace() or ord(ch) < 0x20 or 0x7F <= ord(ch) <= 0x9F
1782
+ for ch in url
1783
+ )
1784
+ safe_destination = False
1785
+ if not has_whitespace_or_control and not any(
1786
+ ch in sanitized_url for ch in _MARKDOWN_LINK_UNSAFE_CHARS
1787
+ ):
1788
+ try:
1789
+ parsed = urlparse(sanitized_url)
1790
+ _ = parsed.port
1791
+ safe_destination = (
1792
+ parsed.scheme.lower() in _SAFE_MARKDOWN_LINK_SCHEMES
1793
+ and bool(parsed.netloc and parsed.hostname)
1794
+ )
1795
+ except ValueError:
1796
+ safe_destination = False
1797
+
1798
+ if safe_destination:
1799
+ return f"[{sanitized_url}]({sanitized_url})"
1800
+ return _escape_markdown_plain_text(sanitized_url)
1801
+
1802
+
1803
+ def render_full(report: schema.Report, save_path: str | None = None) -> str:
1804
+ """Full data dump: ALL clusters + ALL items by source. For saved files and debugging.
1805
+
1806
+ When ``save_path`` is provided, the deterministic emoji footer is appended
1807
+ so the saved artifact cites the file actually written (collision fallback
1808
+ included), matching the stdout footer contract."""
1809
+ evidence_report = schema.without_sources(report, {"corpus"})
835
1810
  # Start with the same header as compact
836
1811
  non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
837
1812
  lines = [
@@ -839,7 +1814,9 @@ def render_full(report: schema.Report) -> str:
839
1814
  "",
840
1815
  *_assistant_safety_lines(),
841
1816
  f"- Date range: {report.range_from} to {report.range_to}",
842
- f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})" if non_empty else "- Sources: none",
1817
+ f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
1818
+ if non_empty
1819
+ else "- Sources: none",
843
1820
  "",
844
1821
  ]
845
1822
 
@@ -848,6 +1825,10 @@ def render_full(report: schema.Report) -> str:
848
1825
  lines.extend(f"- {warning}" for warning in report.warnings)
849
1826
  lines.append("")
850
1827
 
1828
+ library_context = _render_library_context(report)
1829
+ if library_context:
1830
+ lines.extend([*library_context, ""])
1831
+
851
1832
  # When this Report is a per-entity sub-run from vs-mode / --competitors,
852
1833
  # include the single-row Resolved Entities block so the saved file is
853
1834
  # self-describing. The artifact is populated by last30days.py's
@@ -860,27 +1841,20 @@ def render_full(report: schema.Report) -> str:
860
1841
  lines.append("")
861
1842
 
862
1843
  # ALL clusters (no limit)
863
- lines.append("## Ranked Evidence Clusters")
864
- lines.append("")
865
- candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
866
- for index, cluster in enumerate(report.clusters, start=1):
867
- lines.append(
868
- f"### {index}. {cluster.title} "
869
- f"(score {cluster.score:.0f}, {len(cluster.candidate_ids)} item{'s' if len(cluster.candidate_ids) != 1 else ''}, "
870
- f"sources: {', '.join(_source_label(s) for s in cluster.sources)})"
871
- )
872
- if cluster.uncertainty:
873
- lines.append(f"- Uncertainty: {cluster.uncertainty}")
874
- for rep_index, cid in enumerate(cluster.representative_ids, start=1):
875
- candidate = candidate_by_id.get(cid)
876
- if not candidate:
877
- continue
878
- lines.extend(_render_candidate(candidate, prefix=f"{rep_index}."))
879
- lines.append("")
1844
+ lines.extend(_render_ranked_clusters(evidence_report, evidence_report.clusters))
880
1845
 
881
1846
  fun_params = _FUN_LEVELS["medium"]
1847
+ full_clusters = _clusters_clearing_relevance_floor(
1848
+ evidence_report,
1849
+ evidence_report.clusters,
1850
+ )
1851
+ full_candidates = _candidates_for_auxiliary_sections(
1852
+ evidence_report,
1853
+ evidence_report.clusters,
1854
+ full_clusters,
1855
+ )
882
1856
  best_takes = _render_best_takes(
883
- report.ranked_candidates,
1857
+ full_candidates,
884
1858
  limit=fun_params["limit"],
885
1859
  threshold=fun_params["threshold"],
886
1860
  vote_weight=fun_params["vote_weight"],
@@ -892,33 +1866,79 @@ def render_full(report: schema.Report) -> str:
892
1866
  # ALL items by source (flat dump, v2-style)
893
1867
  lines.append("## All Items by Source")
894
1868
  lines.append("")
895
- source_order = ["reddit", "x", "youtube", "tiktok", "instagram", "threads", "pinterest",
896
- "hackernews", "bluesky", "truthsocial", "polymarket", "grounding", "xiaohongshu", "github", "digg", "perplexity", "jobs"]
1869
+ source_order = [
1870
+ "reddit",
1871
+ "x",
1872
+ "youtube",
1873
+ "tiktok",
1874
+ "instagram",
1875
+ "threads",
1876
+ "pinterest",
1877
+ "hackernews",
1878
+ "devto",
1879
+ "bluesky",
1880
+ "truthsocial",
1881
+ "polymarket",
1882
+ "grounding",
1883
+ "xiaohongshu",
1884
+ "github",
1885
+ "digg",
1886
+ "perplexity",
1887
+ "jobs",
1888
+ ]
1889
+ # The list above fixes the display order for the sources it names, but it
1890
+ # is not the source registry and drifts every time one is added: amazon,
1891
+ # arxiv, techmeme, trustpilot, linkedin, and dripstack were all silently
1892
+ # absent from this dump while appearing normally in the ranked section
1893
+ # above, so the saved artifact -- the copy users keep -- was missing
1894
+ # evidence the run actually collected. Append whatever else the report
1895
+ # carries, sorted for determinism, so a new source is visible here the
1896
+ # day it lands rather than the day someone notices.
1897
+ source_order += sorted(
1898
+ source for source in evidence_report.items_by_source
1899
+ if source not in source_order
1900
+ )
897
1901
  for source in source_order:
898
- items = report.items_by_source.get(source, [])
1902
+ items = evidence_report.items_by_source.get(source, [])
899
1903
  if not items:
900
1904
  continue
901
1905
  lines.append(f"### {_source_label(source)} ({len(items)} items)")
902
1906
  lines.append("")
903
1907
  for item in items:
904
- score = item.local_rank_score if item.local_rank_score is not None else 0
905
- lines.append(f"**{item.item_id}** (score:{score:.0f}) {item.author or ''} ({item.published_at or 'date unknown'}) [{_format_item_engagement(item)}]")
906
- lines.append(f" {item.title}")
1908
+ # local_rank_score is 0..1; print it on the report's 0-100 scale
1909
+ # (":.0f" of the raw value rendered every item as score:0 or 1).
1910
+ score = (item.local_rank_score or 0) * 100.0
1911
+ lines.append(
1912
+ f"**{item.item_id}** (score:{score:.0f}) {item.author or ''} ({item.published_at or 'date unknown'}) [{_format_item_engagement(item)}]"
1913
+ )
1914
+ lines.append(f" {_safe_title(item.title)}")
907
1915
  if item.url:
908
- lines.append(f" {item.url}")
1916
+ rendered_url = _markdown_url_link(item.url)
1917
+ if rendered_url:
1918
+ lines.append(f" {rendered_url}")
909
1919
  if item.container:
910
1920
  lines.append(f" *{item.container}*")
911
1921
  if item.snippet:
912
- lines.append(f" {item.snippet[:500]}")
1922
+ lines.append(
1923
+ f" {_format_untrusted_evidence(item.snippet, 500, continuation_indent=' ')}"
1924
+ )
913
1925
  # Top comments for Reddit, YouTube, TikTok, HackerNews.
914
1926
  top_comments = item.metadata.get("top_comments", [])
915
1927
  if top_comments and isinstance(top_comments[0], dict):
916
1928
  vote_label = _vote_label_for(item.source)
917
1929
  for tc in top_comments[:3]:
918
- excerpt = tc.get("excerpt", tc.get("text", ""))[:200]
1930
+ excerpt = tc.get("excerpt", tc.get("text", ""))
919
1931
  tc_score = tc.get("score", "")
920
1932
  attribution = _comment_attribution(item.source, tc.get("author"))
921
- lines.append(f" Top comment {attribution} ({tc_score} {vote_label}): {excerpt}")
1933
+ vote_part = (
1934
+ f" ({tc_score} {vote_label})"
1935
+ if tc_score is not None and tc_score != ""
1936
+ else ""
1937
+ )
1938
+ lines.append(
1939
+ f" Top comment {attribution}{vote_part}: "
1940
+ f"{_format_untrusted_evidence(excerpt, 200, continuation_indent=' ')}"
1941
+ )
922
1942
  # Digg: inline X-post quotes attached to the cluster.
923
1943
  for post in _digg_posts_for(item, limit=3):
924
1944
  lines.append(f" > {_format_digg_quote(post)}")
@@ -927,18 +1947,28 @@ def render_full(report: schema.Report) -> str:
927
1947
  if insights:
928
1948
  lines.append(" Insights:")
929
1949
  for ins in insights[:3]:
930
- lines.append(f" - {ins[:200]}")
1950
+ lines.append(
1951
+ f" - {_format_untrusted_evidence(ins, 200, continuation_indent=' ')}"
1952
+ )
931
1953
  # Transcript highlights for YouTube
932
1954
  highlights = item.metadata.get("transcript_highlights", [])
933
1955
  if highlights:
934
- lines.append(" Highlights (auto-generated transcript; may contain transcription errors):")
1956
+ lines.append(
1957
+ " Highlights (auto-generated transcript; may contain transcription errors):"
1958
+ )
935
1959
  for hl in highlights[:5]:
936
- lines.append(f' - "{hl[:200]}"')
1960
+ lines.append(
1961
+ f' - "{_format_untrusted_evidence(hl, 200, continuation_indent=" ")}"'
1962
+ )
937
1963
  # Full transcript snippet for YouTube
938
1964
  transcript = item.metadata.get("transcript_snippet", "")
939
1965
  if transcript and len(transcript) > 100:
940
- lines.append(f" <details><summary>Transcript ({len(transcript.split())} words; auto-generated — may contain transcription errors)</summary>")
941
- lines.append(f" {transcript[:5000]}")
1966
+ lines.append(
1967
+ f" <details><summary>Transcript ({len(transcript.split())} words; auto-generated — may contain transcription errors)</summary>"
1968
+ )
1969
+ lines.append(
1970
+ f" {_format_untrusted_evidence(transcript, 5000, continuation_indent=' ')}"
1971
+ )
942
1972
  lines.append(" </details>")
943
1973
  # Polymarket outcome prices and market details
944
1974
  outcome_prices = item.metadata.get("outcome_prices") or []
@@ -949,7 +1979,11 @@ def render_full(report: schema.Report) -> str:
949
1979
  odds_parts = []
950
1980
  for name, price in outcome_prices:
951
1981
  if isinstance(price, (int, float)):
952
- pct = f"{price * 100:.0f}%" if price >= 0.1 else f"{price * 100:.1f}%"
1982
+ pct = (
1983
+ f"{price * 100:.0f}%"
1984
+ if price >= 0.1
1985
+ else f"{price * 100:.1f}%"
1986
+ )
953
1987
  odds_parts.append(f"{name}: {pct}")
954
1988
  if odds_parts:
955
1989
  lines.append(f" Odds: {' | '.join(odds_parts)}")
@@ -961,8 +1995,21 @@ def render_full(report: schema.Report) -> str:
961
1995
  lines.append(f" Closes: {end_date}")
962
1996
  lines.append("")
963
1997
 
964
- lines.extend(_render_stats(report))
965
- lines.extend(_render_source_coverage(report))
1998
+ corpus_section = _render_corpus_section(report)
1999
+ if corpus_section:
2000
+ lines.extend(corpus_section)
2001
+ lines.append("")
2002
+
2003
+ freshness_verdicts = _render_freshness_verdicts(evidence_report)
2004
+ if freshness_verdicts:
2005
+ lines.extend(freshness_verdicts)
2006
+ lines.append("")
2007
+ lines.extend(_render_stats(evidence_report))
2008
+ lines.extend(_render_source_coverage(evidence_report))
2009
+ if save_path:
2010
+ footer_lines = _render_emoji_footer(evidence_report, save_path)
2011
+ if footer_lines:
2012
+ lines.extend(["", *footer_lines])
966
2013
  return "\n".join(lines).strip() + "\n"
967
2014
 
968
2015
 
@@ -972,31 +2019,89 @@ def _format_item_engagement(item: schema.SourceItem) -> str:
972
2019
  if not eng:
973
2020
  return ""
974
2021
  parts = []
975
- for key in ["score", "likes", "views", "points", "reposts", "replies", "comments",
976
- "play_count", "digg_count", "share_count", "num_comments"]:
2022
+ for key in [
2023
+ "score",
2024
+ "likes",
2025
+ "views",
2026
+ "points",
2027
+ "reposts",
2028
+ "replies",
2029
+ "comments",
2030
+ "play_count",
2031
+ "digg_count",
2032
+ "share_count",
2033
+ "num_comments",
2034
+ "ratings",
2035
+ ]:
977
2036
  val = eng.get(key)
978
2037
  if val is not None and val != 0:
979
2038
  parts.append(f"{val} {key}")
2039
+ # Same drift as the source list above: this allowlist silently blanks the
2040
+ # engagement of any source whose metric is not on it (trustpilot's
2041
+ # `reviews`/`trustScore` today), so the item renders an empty `[]` in the
2042
+ # saved dump. Fall through only when nothing matched, which fixes the
2043
+ # blank case without adding previously-unshown keys to sources that
2044
+ # already render fine.
2045
+ if not parts:
2046
+ for key, val in sorted(eng.items()):
2047
+ if val not in (None, 0, ""):
2048
+ parts.append(f"{val} {key}")
980
2049
  return ", ".join(parts) if parts else ""
981
2050
 
982
2051
 
983
2052
  def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
984
- candidate_by_id = {candidate.candidate_id: candidate for candidate in report.ranked_candidates}
2053
+ evidence_report = schema.without_sources(report, {"corpus"})
2054
+ candidate_by_id = {
2055
+ candidate.candidate_id: candidate
2056
+ for candidate in evidence_report.ranked_candidates
2057
+ }
2058
+ requested_clusters = evidence_report.clusters[:cluster_limit]
2059
+ visible_clusters = _clusters_clearing_relevance_floor(
2060
+ evidence_report,
2061
+ requested_clusters,
2062
+ )
2063
+ no_solid_evidence = bool(requested_clusters) and not visible_clusters
985
2064
  lines = [
986
2065
  f"Topic: {report.topic}",
987
2066
  f"Intent: {report.query_plan.intent}",
988
2067
  _AI_SAFETY_NOTE,
989
2068
  ]
2069
+ drill_context = _render_drill_context(report)
2070
+ if drill_context:
2071
+ lines.extend(["", *drill_context])
2072
+ library_context = _render_library_context(report)
2073
+ if library_context:
2074
+ lines.extend(["", *library_context])
990
2075
  freshness_warning = _assess_data_freshness(report)
991
2076
  if freshness_warning:
992
2077
  lines.append(f"Freshness warning: {freshness_warning}")
993
- hiring_block = _render_hiring_signals(report)
2078
+ context_candidates = _candidates_for_auxiliary_sections(
2079
+ report,
2080
+ requested_clusters,
2081
+ visible_clusters,
2082
+ )
2083
+ hiring_block = (
2084
+ []
2085
+ if no_solid_evidence
2086
+ else _render_hiring_signals(
2087
+ report,
2088
+ candidates=context_candidates if requested_clusters else None,
2089
+ )
2090
+ )
994
2091
  if hiring_block:
995
2092
  lines.extend(["", *hiring_block, ""])
996
2093
  lines.append("Top clusters:")
997
- for cluster in report.clusters[:cluster_limit]:
998
- lines.append(f"- {cluster.title} [{', '.join(_source_label(source) for source in cluster.sources)}]")
999
- for candidate_id in cluster.representative_ids[:2]:
2094
+ if no_solid_evidence:
2095
+ lines.append("- Nothing solid this window.")
2096
+ for cluster in visible_clusters:
2097
+ lines.append(
2098
+ f"- {_safe_title(cluster.title)} [{', '.join(_source_label(source) for source in cluster.sources)}]"
2099
+ )
2100
+ for candidate_id in _qualifying_representative_ids(
2101
+ cluster,
2102
+ candidate_by_id,
2103
+ limit=2,
2104
+ ):
1000
2105
  candidate = candidate_by_id.get(candidate_id)
1001
2106
  if not candidate:
1002
2107
  continue
@@ -1008,10 +2113,22 @@ def render_context(report: schema.Report, cluster_limit: int = 6) -> str:
1008
2113
  ]
1009
2114
  lines.append(f" - {' | '.join(detail_parts)}")
1010
2115
  if candidate.snippet:
1011
- lines.append(f" Evidence: {_truncate(candidate.snippet, 180)}")
2116
+ lines.append(
2117
+ f" Evidence: "
2118
+ f"{_format_untrusted_evidence(candidate.snippet, 180, continuation_indent=' ')}"
2119
+ )
2120
+ corpus_section = _render_corpus_section(report)
2121
+ if corpus_section:
2122
+ lines.extend(["", *corpus_section])
1012
2123
  if report.warnings:
1013
2124
  lines.append("Warnings:")
1014
2125
  lines.extend(f"- {warning}" for warning in report.warnings)
2126
+ if report.freshness_verdicts:
2127
+ lines.append("Freshness verdicts:")
2128
+ lines.extend(
2129
+ f"- {verdict.verdict}: {verdict.claim} ({verdict.evidence_url or verdict.source_url})"
2130
+ for verdict in report.freshness_verdicts
2131
+ )
1015
2132
  return "\n".join(lines).strip() + "\n"
1016
2133
 
1017
2134
 
@@ -1023,36 +2140,76 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
1023
2140
  Audience Questions, and Source Clusters. Sections 2-4 are omitted when there
1024
2141
  is no matching data; Sections 1 and 5 always appear.
1025
2142
  """
2143
+ evidence_report = schema.without_sources(report, {"corpus"})
1026
2144
  non_empty = [s for s, items in sorted(report.items_by_source.items()) if items]
1027
2145
  lines = [
1028
2146
  f"# Production Brief: {report.topic}",
1029
2147
  "",
1030
2148
  *_assistant_safety_lines(),
1031
2149
  f"- Date range: {report.range_from} to {report.range_to}",
1032
- f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})" if non_empty else "- Sources: none",
2150
+ f"- Sources: {len(non_empty)} active ({', '.join(_source_label(s) for s in non_empty)})"
2151
+ if non_empty
2152
+ else "- Sources: none",
1033
2153
  "",
1034
2154
  ]
2155
+ drill_context = _render_drill_context(report)
2156
+ if drill_context:
2157
+ lines.extend([*drill_context, ""])
2158
+ library_context = _render_library_context(report)
2159
+ if library_context:
2160
+ lines.extend([*library_context, ""])
1035
2161
 
1036
2162
  lines.append("## Ranked Storylines")
1037
2163
  lines.append("")
1038
- candidate_by_id = {c.candidate_id: c for c in report.ranked_candidates}
1039
- for i, cluster in enumerate(report.clusters[:cluster_limit], start=1):
2164
+ candidate_by_id = {c.candidate_id: c for c in evidence_report.ranked_candidates}
2165
+ requested_clusters = evidence_report.clusters[:cluster_limit]
2166
+ visible_clusters = _clusters_clearing_relevance_floor(
2167
+ evidence_report,
2168
+ requested_clusters,
2169
+ )
2170
+ brief_candidates = _candidates_for_auxiliary_sections(
2171
+ evidence_report,
2172
+ requested_clusters,
2173
+ visible_clusters,
2174
+ )
2175
+ qualifying_candidates = [
2176
+ candidate
2177
+ for candidate in brief_candidates
2178
+ if _best_take_relevance_ok(candidate)
2179
+ ]
2180
+ if requested_clusters and not visible_clusters:
2181
+ lines.extend(["**Nothing solid this window.**", ""])
2182
+ for i, cluster in enumerate(visible_clusters, start=1):
1040
2183
  source_tags = ", ".join(_source_label(s) for s in cluster.sources)
1041
- qualifier = f" [{cluster.uncertainty.replace('-', ' ')}]" if cluster.uncertainty else ""
1042
- lines.append(f"### {i}. {cluster.title} (score {cluster.score:.0f}, {source_tags}){qualifier}")
1043
- for cid in cluster.representative_ids[:2]:
2184
+ qualifier = (
2185
+ f" [{cluster.uncertainty.replace('-', ' ')}]" if cluster.uncertainty else ""
2186
+ )
2187
+ lines.append(
2188
+ f"### {i}. {_safe_title(cluster.title)} (score {cluster.score:.0f}, {source_tags}){qualifier}"
2189
+ )
2190
+ for cid in _qualifying_representative_ids(
2191
+ cluster,
2192
+ candidate_by_id,
2193
+ limit=2,
2194
+ ):
1044
2195
  candidate = candidate_by_id.get(cid)
1045
2196
  if not candidate:
1046
2197
  continue
1047
2198
  if candidate.snippet:
1048
- lines.append(f"- {_truncate(candidate.snippet, 280)}")
2199
+ lines.append(
2200
+ f"- {_format_untrusted_evidence(candidate.snippet, 280, continuation_indent=' ')}"
2201
+ )
1049
2202
  explanation = _format_explanation(candidate)
1050
2203
  if explanation:
1051
2204
  lines.append(f" _Why: {explanation}_")
1052
2205
  lines.append("")
1053
2206
 
1054
2207
  hooks = sorted(
1055
- (c for c in report.ranked_candidates if c.fun_score is not None and c.fun_score >= 70),
2208
+ (
2209
+ c
2210
+ for c in qualifying_candidates
2211
+ if c.fun_score is not None and c.fun_score >= 70
2212
+ ),
1056
2213
  key=lambda c: -(c.fun_score or 0),
1057
2214
  )
1058
2215
  if hooks:
@@ -1071,7 +2228,8 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
1071
2228
  attribution = source_label
1072
2229
  reason = (
1073
2230
  f" — {candidate.fun_explanation}"
1074
- if candidate.fun_explanation and candidate.fun_explanation != "heuristic-fallback"
2231
+ if candidate.fun_explanation
2232
+ and candidate.fun_explanation != "heuristic-fallback"
1075
2233
  else ""
1076
2234
  )
1077
2235
  lines.append(
@@ -1080,17 +2238,21 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
1080
2238
  )
1081
2239
  lines.append("")
1082
2240
 
1083
- tensions = [c for c in report.clusters[:cluster_limit] if c.uncertainty]
2241
+ tensions = [c for c in visible_clusters if c.uncertainty]
1084
2242
  if tensions:
1085
2243
  lines.append("## Topic Tensions")
1086
2244
  lines.append("")
1087
2245
  for cluster in tensions[:cluster_limit]:
1088
- label = cluster.uncertainty.replace("-", " ").title() if cluster.uncertainty else ""
2246
+ label = (
2247
+ cluster.uncertainty.replace("-", " ").title()
2248
+ if cluster.uncertainty
2249
+ else ""
2250
+ )
1089
2251
  source_tags = ", ".join(_source_label(s) for s in cluster.sources)
1090
- lines.append(f"- **{cluster.title}** [{label}]: {source_tags}")
2252
+ lines.append(f"- **{_safe_title(cluster.title)}** [{label}]: {source_tags}")
1091
2253
  lines.append("")
1092
2254
 
1093
- questions = _extract_audience_questions(report.ranked_candidates)
2255
+ questions = _extract_audience_questions(qualifying_candidates)
1094
2256
  if questions:
1095
2257
  lines.append("## Audience Questions")
1096
2258
  lines.append("")
@@ -1100,11 +2262,21 @@ def render_brief(report: schema.Report, cluster_limit: int = 8) -> str:
1100
2262
 
1101
2263
  lines.append("## Source Clusters")
1102
2264
  lines.append("")
1103
- for cluster in report.clusters[:cluster_limit]:
2265
+ for cluster in visible_clusters:
1104
2266
  source_tags = " + ".join(_source_label(s) for s in cluster.sources)
1105
- lines.append(f"- **{cluster.title}**: {source_tags}")
2267
+ lines.append(f"- **{_safe_title(cluster.title)}**: {source_tags}")
1106
2268
  lines.append("")
1107
2269
 
2270
+ corpus_section = _render_corpus_section(report)
2271
+ if corpus_section:
2272
+ lines.extend(corpus_section)
2273
+ lines.append("")
2274
+
2275
+ freshness_verdicts = _render_freshness_verdicts(report)
2276
+ if freshness_verdicts:
2277
+ lines.extend(freshness_verdicts)
2278
+ lines.append("")
2279
+
1108
2280
  return "\n".join(lines).strip() + "\n"
1109
2281
 
1110
2282
 
@@ -1124,13 +2296,54 @@ def _extract_audience_questions(candidates: list[schema.Candidate]) -> list[str]
1124
2296
  return questions
1125
2297
 
1126
2298
 
1127
- def _render_hiring_signals(report: schema.Report) -> list[str]:
2299
+ def _render_hiring_signals(
2300
+ report: schema.Report,
2301
+ *,
2302
+ candidates: list[schema.Candidate] | None = None,
2303
+ ) -> list[str]:
1128
2304
  summary = report.artifacts.get("hiring_signals")
1129
2305
  if not isinstance(summary, dict):
1130
2306
  return []
2307
+ mode = summary.get("mode") or "standard"
2308
+ rejected_jobs = set(summary.get("rejected_job_keys") or [])
2309
+ if candidates is not None or rejected_jobs:
2310
+ solid_clusters = _clusters_clearing_relevance_floor(report, report.clusters)
2311
+ accepted = _candidates_for_auxiliary_sections(
2312
+ report, report.clusters, solid_clusters,
2313
+ )
2314
+ accepted_ids = {
2315
+ candidate.candidate_id for candidate in accepted
2316
+ if _best_take_relevance_ok(candidate)
2317
+ }
2318
+ rejected_jobs.update(
2319
+ fusion.candidate_key(item)
2320
+ for candidate in report.ranked_candidates
2321
+ if candidate.candidate_id not in accepted_ids
2322
+ for item in candidate.source_items
2323
+ if item.source == "jobs"
2324
+ )
2325
+ # Board size and rare-role signals must survive the ranking pool and
2326
+ # display limits, while explicitly rejected evidence stays excluded.
2327
+ job_items = {
2328
+ fusion.candidate_key(item): item
2329
+ for item in report.items_by_source.get("jobs", [])
2330
+ if fusion.candidate_key(item) not in rejected_jobs
2331
+ }
2332
+ for candidate in accepted:
2333
+ if candidate.candidate_id not in accepted_ids:
2334
+ continue
2335
+ for item in candidate.source_items:
2336
+ if item.source == "jobs":
2337
+ job_items[fusion.candidate_key(item)] = item
2338
+ if not job_items:
2339
+ return []
2340
+ summary = hiring_signals.analyze(
2341
+ list(job_items.values()),
2342
+ explicit=mode == "explicit",
2343
+ topic=report.topic,
2344
+ )
1131
2345
  signals = summary.get("signals") or []
1132
2346
  include = bool(summary.get("include"))
1133
- mode = summary.get("mode") or "standard"
1134
2347
  if not include and mode != "explicit":
1135
2348
  return []
1136
2349
 
@@ -1173,7 +2386,7 @@ def _render_hiring_signals(report: schema.Report) -> list[str]:
1173
2386
  out.append(
1174
2387
  "- Strategic single-role signals (judge novelty yourself - a founding "
1175
2388
  "or first-of-function role can outweigh a whole department; in synthesis, "
1176
- "distinguish \"new bets\" from \"doubling down\"):"
2389
+ 'distinguish "new bets" from "doubling down"):'
1177
2390
  )
1178
2391
  for cand in strategic[:8]:
1179
2392
  title = cand.get("title") or "Job posting"
@@ -1189,7 +2402,11 @@ def _render_hiring_signals(report: schema.Report) -> list[str]:
1189
2402
  return out
1190
2403
 
1191
2404
 
1192
- def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
2405
+ def _render_candidate(
2406
+ candidate: schema.Candidate,
2407
+ prefix: str,
2408
+ report: schema.Report | None = None,
2409
+ ) -> list[str]:
1193
2410
  primary = schema.candidate_primary_item(candidate)
1194
2411
  detail_parts = [
1195
2412
  _format_date(primary),
@@ -1207,10 +2424,14 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
1207
2424
  detail_parts.append("interaction:→@" + ",@".join(interaction_targets[:2]))
1208
2425
  details = " | ".join(part for part in detail_parts if part)
1209
2426
  lines = [
1210
- f"{prefix} [{schema.candidate_source_label(candidate)}] {candidate.title}",
2427
+ f"{prefix} [{schema.candidate_source_label(candidate)}] {_safe_title(candidate.title)}"
2428
+ + (_candidate_freshness_flag(report, candidate.candidate_id) if report else ""),
1211
2429
  f" - {details}",
1212
- f" - URL: {candidate.url}",
1213
2430
  ]
2431
+ if candidate.url:
2432
+ rendered_url = _markdown_url_link(candidate.url)
2433
+ if rendered_url:
2434
+ lines.append(f" - URL: {rendered_url}")
1214
2435
  corroboration = _format_corroboration(candidate)
1215
2436
  if corroboration:
1216
2437
  lines.append(f" - {corroboration}")
@@ -1218,24 +2439,44 @@ def _render_candidate(candidate: schema.Candidate, prefix: str) -> list[str]:
1218
2439
  if explanation:
1219
2440
  lines.append(f" - Why: {explanation}")
1220
2441
  if candidate.snippet:
1221
- lines.append(f" - Evidence: {_truncate(candidate.snippet, 360)}")
2442
+ lines.append(
2443
+ f" - Evidence: {_format_untrusted_evidence(candidate.snippet, 360)}"
2444
+ )
1222
2445
  for tc in _top_comments_list(primary):
1223
2446
  excerpt = tc.get("excerpt") or tc.get("text") or ""
1224
2447
  score = tc.get("score", "")
1225
2448
  vote_label = _vote_label_for(primary.source) if primary else "upvotes"
1226
2449
  source = primary.source if primary else None
1227
2450
  attribution = _comment_attribution(source, tc.get("author"))
1228
- lines.append(f" - {attribution} ({score} {vote_label}): {_truncate(excerpt.strip(), 240)}")
2451
+ vote_part = (
2452
+ f" ({score} {vote_label})"
2453
+ if score is not None and score != ""
2454
+ else ""
2455
+ )
2456
+ # HN comment lines carry their own link: the candidate URL above is
2457
+ # the article, not where the quote was said.
2458
+ hn_link = (
2459
+ _comment_link(primary, tc)
2460
+ if primary is not None and primary.source == "hackernews"
2461
+ else ""
2462
+ )
2463
+ lines.append(
2464
+ f" - {attribution}{vote_part}: "
2465
+ f"{_format_untrusted_evidence(excerpt.strip(), 240)}"
2466
+ + (f" — {hn_link}" if hn_link else "")
2467
+ )
1229
2468
  for post in _digg_posts_for(primary):
1230
2469
  lines.append(f" - {_format_digg_quote(post)}")
1231
2470
  insight = _comment_insight(primary)
1232
2471
  if insight:
1233
- lines.append(f" - Insight: {_truncate(insight, 220)}")
2472
+ lines.append(f" - Insight: {_format_untrusted_evidence(insight, 220)}")
1234
2473
  highlights = _transcript_highlights(primary)
1235
2474
  if highlights:
1236
- lines.append(" - Highlights (auto-generated transcript; may contain transcription errors):")
2475
+ lines.append(
2476
+ " - Highlights (auto-generated transcript; may contain transcription errors):"
2477
+ )
1237
2478
  for hl in highlights:
1238
- lines.append(f' - "{_truncate(hl, 200)}"')
2479
+ lines.append(f' - "{_format_untrusted_evidence(hl, 200)}"')
1239
2480
  return lines
1240
2481
 
1241
2482
 
@@ -1270,13 +2511,23 @@ def _shorten_polymarket_title(title: str) -> str:
1270
2511
  t = t[5:].strip()
1271
2512
 
1272
2513
  # Drop "by <Month> <Day>" or "by <Month> <Day>, <Year>" tail
1273
- t = re.sub(r"\s+by\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:,\s*\d{4})?$", "", t, flags=re.IGNORECASE)
2514
+ t = re.sub(
2515
+ r"\s+by\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+(?:,\s*\d{4})?$",
2516
+ "",
2517
+ t,
2518
+ flags=re.IGNORECASE,
2519
+ )
1274
2520
  # Drop "in <Year>" tail (e.g. "separate in 2026")
1275
2521
  t = re.sub(r"\s+in\s+\d{4}$", "", t, flags=re.IGNORECASE)
1276
2522
  # Drop "by <Year>" tail
1277
2523
  t = re.sub(r"\s+by\s+\d{4}$", "", t, flags=re.IGNORECASE)
1278
2524
  # Drop "before <Month> <Day>" tail
1279
- t = re.sub(r"\s+before\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+$", "", t, flags=re.IGNORECASE)
2525
+ t = re.sub(
2526
+ r"\s+before\s+(January|February|March|April|May|June|July|August|September|October|November|December)\s+\d+$",
2527
+ "",
2528
+ t,
2529
+ flags=re.IGNORECASE,
2530
+ )
1280
2531
 
1281
2532
  # Pattern: "<Subject> visit <Place>" -> "<Place> visit"
1282
2533
  m = re.match(r"^(.+?)\s+visit\s+(?:the\s+)?(.+)$", t, flags=re.IGNORECASE)
@@ -1292,12 +2543,14 @@ def _shorten_polymarket_title(title: str) -> str:
1292
2543
  t = " ".join(words[:6])
1293
2544
 
1294
2545
  # Drop a leading article so the descriptor doesn't read "an Anthropic Claude..."
1295
- t = re.sub(r"^(?:a|an|the)\s+", "", t, flags=re.I)
2546
+ t = re.sub(r"^(?:a|an|the)\s+", "", t, flags=re.IGNORECASE)
1296
2547
 
1297
2548
  return t
1298
2549
 
1299
2550
 
1300
- def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> list[str]:
2551
+ def _polymarket_top_markets(
2552
+ items: list[schema.SourceItem], limit: int = 3
2553
+ ) -> list[str]:
1301
2554
  """Build short summary strings for the top Polymarket markets by volume.
1302
2555
 
1303
2556
  Returns list like: ['UK visit 5.5%', 'Israel visit 8%', 'blocked from entering 36%']
@@ -1319,9 +2572,15 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
1319
2572
  if not isinstance(lead_price, (int, float)):
1320
2573
  continue
1321
2574
 
1322
- pct = f"{lead_price * 100:.0f}%" if lead_price >= 0.1 else f"{lead_price * 100:.1f}%"
2575
+ pct = (
2576
+ f"{lead_price * 100:.0f}%"
2577
+ if lead_price >= 0.1
2578
+ else f"{lead_price * 100:.1f}%"
2579
+ )
1323
2580
 
1324
- descriptor = _shorten_polymarket_title(item.metadata.get("question") or item.title or "")
2581
+ descriptor = _shorten_polymarket_title(
2582
+ item.metadata.get("question") or item.title or ""
2583
+ )
1325
2584
  if not descriptor:
1326
2585
  continue
1327
2586
 
@@ -1344,14 +2603,42 @@ def _polymarket_top_markets(items: list[schema.SourceItem], limit: int = 3) -> l
1344
2603
  return summaries
1345
2604
 
1346
2605
 
1347
- def _render_source_coverage(report: schema.Report) -> list[str]:
2606
+ # Warnings that only restate a per-source outcome. The compact (model-facing)
2607
+ # stdout carries those through ## Partial Coverage, and the user-facing
2608
+ # footer carries counts only; doctor --postmortem, the saved raw file, and
2609
+ # --emit=json keep the full list.
2610
+ _SOURCE_FAILURE_WARNING_PREFIXES = (
2611
+ "Some sources failed",
2612
+ "Some sources returned partial results",
2613
+ )
2614
+
2615
+
2616
+ def _warnings_without_source_failures(warnings: list[str]) -> list[str]:
2617
+ return [
2618
+ warning
2619
+ for warning in warnings
2620
+ if not warning.startswith(_SOURCE_FAILURE_WARNING_PREFIXES)
2621
+ ]
2622
+
2623
+
2624
+ def _render_source_coverage(
2625
+ report: schema.Report,
2626
+ *,
2627
+ include_errors: bool = True,
2628
+ ) -> list[str]:
1348
2629
  lines = [
1349
2630
  "## Source Coverage",
1350
2631
  "",
1351
2632
  ]
1352
- for source, items in sorted(report.items_by_source.items()):
1353
- lines.append(f"- {_source_label(source)}: {len(items)} item{'s' if len(items) != 1 else ''}")
1354
- if report.errors_by_source:
2633
+ sources = sorted(set(report.items_by_source) | set(report.source_status))
2634
+ for source in sources:
2635
+ items = report.items_by_source.get(source, [])
2636
+ line = f"- {_source_label(source)}: {len(items)} item{'s' if len(items) != 1 else ''}"
2637
+ outcome = report.source_status.get(source)
2638
+ if outcome and outcome.state != health.OK:
2639
+ line += f" ({_format_outcome(outcome)})"
2640
+ lines.append(line)
2641
+ if include_errors and report.errors_by_source:
1355
2642
  lines.append("")
1356
2643
  lines.append("## Source Errors")
1357
2644
  lines.append("")
@@ -1360,6 +2647,57 @@ def _render_source_coverage(report: schema.Report) -> list[str]:
1360
2647
  return lines
1361
2648
 
1362
2649
 
2650
+ def _render_source_outcome_note(report: schema.Report) -> list[str]:
2651
+ """Tell the synthesizer that a failed source is not evidence of silence."""
2652
+ affected = [
2653
+ outcome
2654
+ for outcome in report.source_status.values()
2655
+ if outcome.state not in (health.OK, schema.NO_RESULTS)
2656
+ ]
2657
+ if not affected:
2658
+ return []
2659
+ summaries = "; ".join(
2660
+ f"{_source_label(outcome.source)} {_format_outcome(outcome)}"
2661
+ for outcome in sorted(affected, key=lambda item: item.source)
2662
+ )
2663
+ return [
2664
+ "## Partial Coverage",
2665
+ "",
2666
+ f"> {summaries}.",
2667
+ "> Do not interpret a failed source as no discussion on that source. "
2668
+ "Synthesize only from available evidence; run `doctor` for fix prescriptions.",
2669
+ ]
2670
+
2671
+
2672
+ def _format_outcome(outcome: schema.SourceOutcome) -> str:
2673
+ detail = " ".join((outcome.detail or "").split())
2674
+ if len(detail) > 140:
2675
+ detail = detail[:137].rstrip() + "..."
2676
+ state = outcome.state
2677
+ if state == schema.PARTIAL:
2678
+ noun = "item" if outcome.items_returned == 1 else "items"
2679
+ summary = f"partial — {outcome.items_returned} {noun} returned"
2680
+ detail_l = detail.lower()
2681
+ if (
2682
+ "429" in detail_l
2683
+ or "rate-limited" in detail_l
2684
+ or "rate limit" in detail_l
2685
+ or "too many requests" in detail_l
2686
+ ):
2687
+ summary += ", some requests rate-limited"
2688
+ elif state == schema.NO_RESULTS:
2689
+ summary = "no results"
2690
+ elif state == schema.PAYMENT_REQUIRED:
2691
+ summary = health.credits_exhausted_label(outcome.source)
2692
+ else:
2693
+ summary = state
2694
+ if detail:
2695
+ summary += f": {detail}"
2696
+ if outcome.fix_hint == "doctor":
2697
+ summary += " (run doctor for fixes)"
2698
+ return summary
2699
+
2700
+
1363
2701
  # Known publications for the Web line of the emoji-tree footer.
1364
2702
  # Maps apex domain to a clean display name. Unknown domains fall back to
1365
2703
  # the bare domain string (protocol stripped, www. removed).
@@ -1439,8 +2777,7 @@ def _site_name_for_url(url: str) -> str:
1439
2777
  # urlparse needs a scheme to resolve the netloc; prepend http:// if missing.
1440
2778
  parsed = urlparse(u if "://" in u else f"http://{u}")
1441
2779
  host = (parsed.netloc or parsed.path.split("/", 1)[0]).lower()
1442
- if host.startswith("www."):
1443
- host = host[4:]
2780
+ host = host.removeprefix("www.")
1444
2781
  if not host:
1445
2782
  return u[:40]
1446
2783
  if host in _SITE_NAMES:
@@ -1479,25 +2816,81 @@ def _format_web_line_sources(items: list[schema.SourceItem], limit: int = 8) ->
1479
2816
  # Report.items_by_source. Order here is the render order.
1480
2817
  _FOOTER_SOURCES: list[tuple[str, str, str, str, list[tuple[str, str]]]] = [
1481
2818
  # (source_key, emoji, display_name, item_word_singular, [(engagement_key, word)])
1482
- ("reddit", "🟠", "Reddit", "thread", [("score", "upvotes"), ("num_comments", "comments")]),
1483
- ("x", "🔵", "X", "post", [("likes", "likes"), ("reposts", "reposts")]),
1484
- ("youtube", "🔴", "YouTube", "video", [("views", "views")]), # transcripts appended below in _build_source_footer_lines
1485
- ("tiktok", "🎵", "TikTok", "video", [("views", "views"), ("likes", "likes")]),
1486
- ("instagram", "📸", "Instagram", "reel", [("views", "views"), ("likes", "likes")]),
1487
- ("threads", "🧵", "Threads", "post", [("likes", "likes"), ("replies", "replies")]),
1488
- ("pinterest", "📌", "Pinterest", "pin", [("saves", "saves"), ("comments", "comments")]),
1489
- ("hackernews", "🟡", "HN", "story", [("points", "points"), ("comments", "comments")]),
1490
- ("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
1491
- ("truthsocial", "🇺🇸", "Truth Social", "post", [("likes", "likes"), ("reposts", "reposts")]),
1492
- ("github", "🐙", "GitHub", "item", [("stars", "stars"), ("merged_prs", "merged"), ("reactions", "reactions"), ("comments", "comments")]),
1493
- ("digg", "⛏️", "Digg", "cluster", [("postCount", "posts"), ("uniqueAuthors", "authors")]),
1494
- ("arxiv", "📄", "arXiv", "paper", []),
1495
- ("techmeme", "📰", "Techmeme", "headline", []),
1496
- ("trustpilot", "⭐", "Trustpilot", "review", [("reviews", "reviews")]),
2819
+ (
2820
+ "reddit",
2821
+ "🟠",
2822
+ "Reddit",
2823
+ "thread",
2824
+ [("score", "upvotes"), ("num_comments", "comments")],
2825
+ ),
2826
+ ("x", "🔵", "X", "post", [("likes", "likes"), ("reposts", "reposts")]),
2827
+ (
2828
+ "youtube",
2829
+ "🔴",
2830
+ "YouTube",
2831
+ "video",
2832
+ [("views", "views")],
2833
+ ), # transcripts appended below in _build_source_footer_lines
2834
+ ("tiktok", "🎵", "TikTok", "video", [("views", "views"), ("likes", "likes")]),
2835
+ ("instagram", "📸", "Instagram", "reel", [("views", "views"), ("likes", "likes")]),
2836
+ ("threads", "🧵", "Threads", "post", [("likes", "likes"), ("replies", "replies")]),
2837
+ (
2838
+ "pinterest",
2839
+ "📌",
2840
+ "Pinterest",
2841
+ "pin",
2842
+ [("saves", "saves"), ("comments", "comments")],
2843
+ ),
2844
+ (
2845
+ "hackernews",
2846
+ "🟡",
2847
+ "HN",
2848
+ "story",
2849
+ [("points", "points"), ("comments", "comments")],
2850
+ ),
2851
+ ("devto", "👩‍💻", "dev.to", "article", [("reactions", "reactions"), ("comments", "comments")]),
2852
+ ("bluesky", "🦋", "Bluesky", "post", [("likes", "likes"), ("reposts", "reposts")]),
2853
+ (
2854
+ "truthsocial",
2855
+ "🇺🇸",
2856
+ "Truth Social",
2857
+ "post",
2858
+ [("likes", "likes"), ("reposts", "reposts")],
2859
+ ),
2860
+ (
2861
+ "linkedin",
2862
+ "👔",
2863
+ "LinkedIn",
2864
+ "post",
2865
+ [("likes", "likes"), ("comments", "comments")],
2866
+ ),
2867
+ (
2868
+ "github",
2869
+ "🐙",
2870
+ "GitHub",
2871
+ "item",
2872
+ [
2873
+ ("stars", "stars"),
2874
+ ("merged_prs", "merged"),
2875
+ ("reactions", "reactions"),
2876
+ ("comments", "comments"),
2877
+ ],
2878
+ ),
2879
+ (
2880
+ "digg",
2881
+ "⛏️",
2882
+ "Digg",
2883
+ "cluster",
2884
+ [("postCount", "posts"), ("uniqueAuthors", "authors")],
2885
+ ),
2886
+ ("arxiv", "📄", "arXiv", "paper", []),
2887
+ ("techmeme", "📰", "Techmeme", "headline", []),
2888
+ ("trustpilot", "⭐", "Trustpilot", "review", [("reviews", "reviews")]),
1497
2889
  # Jobs must appear so a scoped --hiring-signals run (jobs-only) still emits
1498
2890
  # the LAW 5 footer; without it the footer was dropped entirely.
1499
- ("jobs", "💼", "Jobs", "role", []),
1500
- ("perplexity", "🧠", "Perplexity", "result", [("citations", "citations")]),
2891
+ ("jobs", "💼", "Jobs", "role", []),
2892
+ ("perplexity", "🧠", "Perplexity", "result", [("citations", "citations")]),
2893
+ ("corpus", "🔒", "Your files", "file", []),
1501
2894
  ]
1502
2895
 
1503
2896
 
@@ -1514,18 +2907,28 @@ def _sum_engagement(items: list[schema.SourceItem], key: str) -> int:
1514
2907
  return total
1515
2908
 
1516
2909
 
1517
- def _footer_line_for_source(emoji: str, label: str, count: int, item_word: str, stats: str) -> str:
2910
+ def _footer_line_for_source(
2911
+ emoji: str, label: str, count: int, item_word: str, stats: str
2912
+ ) -> str:
1518
2913
  count_str = f"{count:,}" if count >= 1000 else str(count)
1519
- plural = f"{item_word}s" if count != 1 else item_word
2914
+ if count == 1:
2915
+ plural = item_word
2916
+ elif len(item_word) > 1 and item_word.endswith("y") and item_word[-2] not in "aeiou":
2917
+ plural = f"{item_word[:-1]}ies"
2918
+ else:
2919
+ plural = f"{item_word}s"
1520
2920
  if stats:
1521
2921
  return f"{emoji} {label}: {count_str} {plural} │ {stats}"
1522
2922
  return f"{emoji} {label}: {count_str} {plural}"
1523
2923
 
1524
2924
 
1525
2925
  def _build_source_footer_lines(report: schema.Report) -> list[str]:
1526
- """Return emoji-tree body lines (without tree characters) for each populated source.
2926
+ """Return emoji-tree lines for populated sources only (>=1 item).
1527
2927
 
1528
- The caller adds the tree characters (├─ / └─) after assembling all lines.
2928
+ Sources that returned zero items - clean NO_RESULTS or a failure - are
2929
+ omitted; their outcome still surfaces in the ## Source Coverage /
2930
+ ## Partial Coverage evidence blocks. The caller adds the tree characters
2931
+ (├─ / └─) after assembling all lines.
1529
2932
  """
1530
2933
  out: list[str] = []
1531
2934
  for source_key, emoji, label, item_word, engagement_fields in _FOOTER_SOURCES:
@@ -1544,12 +2947,24 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
1544
2947
  # case that needs to be loud is the one previously omitted from the footer.
1545
2948
  if source_key == "youtube":
1546
2949
  with_transcripts = sum(
1547
- 1 for it in items
1548
- if (it.metadata.get("transcript_highlights") or it.metadata.get("transcript_snippet"))
2950
+ 1
2951
+ for it in items
2952
+ if (
2953
+ it.metadata.get("transcript_highlights")
2954
+ or it.metadata.get("transcript_snippet")
2955
+ )
1549
2956
  )
1550
2957
  parts.append(f"{with_transcripts}/{len(items)} with transcripts")
2958
+ provenance = report.artifacts.get("x_provenance") if source_key == "x" else None
2959
+ if provenance in x_envelope.PROVENANCE_LABELS:
2960
+ # Host-fetched X names its lane on the user-facing line too.
2961
+ parts.append(f"via {x_envelope.PROVENANCE_LABELS[provenance]}")
1551
2962
  stats = " │ ".join(parts)
1552
- out.append(_footer_line_for_source(emoji, label, len(items), item_word, stats))
2963
+ line = _footer_line_for_source(emoji, label, len(items), item_word, stats)
2964
+ # Counts only: run diagnostics live in doctor --postmortem, the saved
2965
+ # raw file, and the model-facing ## Partial Coverage note, never on
2966
+ # the user-facing conclusion surface.
2967
+ out.append(line)
1553
2968
 
1554
2969
  # Polymarket (special: count + odds string from existing helper)
1555
2970
  polymarket_items = report.items_by_source.get("polymarket") or []
@@ -1560,9 +2975,18 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
1560
2975
  count_str = f"{count:,}" if count >= 1000 else str(count)
1561
2976
  plural = "markets" if count != 1 else "market"
1562
2977
  if odds_str:
1563
- out.append(f"📊 Polymarket: {count_str} {plural} │ {odds_str}")
2978
+ line = f"📊 Polymarket: {count_str} {plural} │ {odds_str}"
1564
2979
  else:
1565
- out.append(f"📊 Polymarket: {count_str} {plural}")
2980
+ line = f"📊 Polymarket: {count_str} {plural}"
2981
+ out.append(line)
2982
+
2983
+ amazon_line = _amazon_footer_line(report)
2984
+ if amazon_line:
2985
+ out.append(amazon_line)
2986
+
2987
+ meta_ads_line = _meta_ads_footer_line(report)
2988
+ if meta_ads_line:
2989
+ out.append(meta_ads_line)
1566
2990
 
1567
2991
  # Web (sources from grounding)
1568
2992
  web_items = report.items_by_source.get("grounding") or []
@@ -1572,13 +2996,252 @@ def _build_source_footer_lines(report: schema.Report) -> list[str]:
1572
2996
  count_str = f"{count:,}" if count >= 1000 else str(count)
1573
2997
  plural = "pages" if count != 1 else "page"
1574
2998
  if names:
1575
- out.append(f"🌐 Web: {count_str} {plural} - {names}")
2999
+ line = f"🌐 Web: {count_str} {plural} - {names}"
1576
3000
  else:
1577
- out.append(f"🌐 Web: {count_str} {plural}")
1578
-
3001
+ line = f"🌐 Web: {count_str} {plural}"
3002
+ out.append(line)
3003
+
3004
+ # Only populated sources (>=1 item) get an emoji-tree line. A source that
3005
+ # returned zero items - whether it completed cleanly (NO_RESULTS) or failed
3006
+ # (rate-limited / unreachable / etc.) - is omitted from the user-facing
3007
+ # footer. Its failure signal remains visible to synthesis in the
3008
+ # ## Partial Coverage / ## Source Coverage evidence blocks, so nothing is
3009
+ # silently lost; the conclusion surface just stays clean.
1579
3010
  return out
1580
3011
 
1581
3012
 
3013
+ def _amazon_footer_line(report: schema.Report) -> str | None:
3014
+ """Build the 📦 Amazon emoji-footer line (R1c).
3015
+
3016
+ Follows the Polymarket shape -- unit count, then *named products
3017
+ carrying their own numbers* -- rather than the three-count inventory
3018
+ shape every social source uses. ``3 products │ 611 ratings │ 56
3019
+ reviews`` fits the box and says nothing; a named product with the
3020
+ direction its rating moved this month is the whole reason the source
3021
+ exists.
3022
+
3023
+ Three renderings:
3024
+
3025
+ * **Default/deep** -- per-product drift entries (see
3026
+ ``amazon.footer_entry``).
3027
+ * **Quick depth** -- no review pulls means no recent window, so the
3028
+ inventory form is the honest one here and only here. Never render a
3029
+ ``→`` against a null window.
3030
+ * **Empty search** -- name the keyword rather than suppressing the
3031
+ line. Observability, not spend: an empty result means the model's
3032
+ keyword or its relevance judgment was wrong, and a hidden line means
3033
+ nobody ever finds out.
3034
+ """
3035
+ items = report.items_by_source.get("amazon") or []
3036
+ keyword = str((report.artifacts or {}).get("amazon_query") or "").strip()
3037
+ outcome = report.source_status.get("amazon")
3038
+ failed = bool(outcome and outcome.state != health.OK)
3039
+
3040
+ if not items:
3041
+ if not keyword:
3042
+ return None
3043
+ # An empty result is only a keyword problem when the search actually
3044
+ # ran and came back empty. On an expired token or a CLI failure,
3045
+ # "no products matched" sends the user to fix the wrong thing --
3046
+ # so lead with the real outcome, same as every other footer branch.
3047
+ if failed:
3048
+ # Counts-only footer: the outcome itself lives in ## Partial
3049
+ # Coverage and doctor --postmortem, so just avoid the misleading
3050
+ # "no products matched" wording when the search never ran.
3051
+ return f'📦 Amazon: no results for "{keyword}"'
3052
+ return f'📦 Amazon: no products matched "{keyword}"'
3053
+
3054
+ count = len(items)
3055
+ plural = "products" if count != 1 else "product"
3056
+ all_stats = [amazon.stats_from_item(item) for item in items]
3057
+
3058
+ # Quick depth pulls no reviews at all, so nothing has a recent window.
3059
+ if not any(s.get("reviews_pulled") for s in all_stats):
3060
+ rated = [s["all_time"] for s in all_stats if s.get("all_time") is not None]
3061
+ total_ratings = sum(s.get("ratings_total") or 0 for s in all_stats)
3062
+ parts = [f"{count} {plural}"]
3063
+ if rated:
3064
+ parts.append(f"{sum(rated) / len(rated):.1f}★ average")
3065
+ if total_ratings:
3066
+ parts.append(f"{total_ratings:,} ratings")
3067
+ line = f"📦 Amazon: {' │ '.join(parts)}"
3068
+ return line
3069
+
3070
+ # Only the *sampled* products earn a slot. A run can carry a dozen
3071
+ # discovered products, but only the two or three that got a review pull
3072
+ # have a recent window at all -- rendering the rest appends a string of
3073
+ # `quiet` entries that push the line past every other source in the box
3074
+ # while adding nothing (observed live: 12 entries, 9 of them padding).
3075
+ # The count still reports everything found, so nothing is hidden.
3076
+ sampled = [
3077
+ (s, item) for s, item in zip(all_stats, items) if s.get("reviews_pulled")
3078
+ ]
3079
+ # Variants of one product share a short name; showing both reads as a
3080
+ # rendering bug even though the ASINs differ.
3081
+ stats, shown_items, seen_names = [], [], set()
3082
+ for stat, item in sampled:
3083
+ key = (stat.get("short_name") or "").strip().lower()
3084
+ if key and key in seen_names:
3085
+ continue
3086
+ if key:
3087
+ seen_names.add(key)
3088
+ stats.append(stat)
3089
+ shown_items.append(item)
3090
+ items = shown_items
3091
+
3092
+ # Deliberately no quote fragment here. The design called for one
3093
+ # model-written phrase on the sharpest negative drift ("... ↓ \"the lid
3094
+ # jams\""), but this footer is rendered by the engine *before* the model
3095
+ # ever sees the report, and the model passes it through verbatim -- so
3096
+ # there is no weave-time write path for the model to supply one. Rather
3097
+ # than ship a branch that can never fire, the quote is deferred: the
3098
+ # same evidence reaches the reader through the body section's
3099
+ # Loved/Gripes/Watch line, which the model does author. `footer_entry`
3100
+ # still accepts a quote so a future writer can supply one.
3101
+ entries = [amazon.footer_entry(s) for s in stats]
3102
+ line = f"📦 Amazon: {count} {plural} │ {', '.join(entries)}"
3103
+ return line
3104
+
3105
+
3106
+ # Longest advertiser or candidate name the footer will print. The footer is one
3107
+ # scannable line inside a box; an unbounded source-controlled name would push
3108
+ # every other source's line out of view.
3109
+ _META_ADS_NAME_MAX = 32
3110
+
3111
+
3112
+ def _footer_safe(value: str, limit: int = _META_ADS_NAME_MAX) -> str:
3113
+ """Make a source-controlled string safe to interpolate into a footer line.
3114
+
3115
+ The advertiser name, the closest-candidate name, and the promo codes all
3116
+ come from the upstream API, so they are attacker-influenceable. Left raw, a
3117
+ name carrying a newline or the footer's own separator could forge extra
3118
+ footer rows in the passed-through emoji tree, which the model relays
3119
+ verbatim. Collapse control characters and the separator, then clip.
3120
+ """
3121
+ cleaned = _sanitize_url_for_single_line_output(str(value or "")).replace("│", "|")
3122
+ cleaned = " ".join(cleaned.split()).strip()
3123
+ if len(cleaned) > limit:
3124
+ cleaned = cleaned[:limit].rstrip() + "…"
3125
+ return cleaned
3126
+
3127
+
3128
+ _PLACEMENT_SHORT = {
3129
+ "FACEBOOK": "FB",
3130
+ "INSTAGRAM": "IG",
3131
+ "THREADS": "Threads",
3132
+ "MESSENGER": "Messenger",
3133
+ "WHATSAPP": "WhatsApp",
3134
+ "AUDIENCE_NETWORK": "Audience",
3135
+ }
3136
+
3137
+
3138
+ def _meta_ads_footer_line(report: schema.Report) -> str | None:
3139
+ """Build the 📣 Meta Ads emoji-footer line.
3140
+
3141
+ Every count comes from the adapter's tally, never from ``items_by_source``.
3142
+ The pipeline truncates each source's stream to a per-depth limit (12 at
3143
+ default) before rendering, so counting surviving items would report 12
3144
+ creatives for a page that ran thirty, and would under-count the video and
3145
+ transcript work that was actually paid for.
3146
+
3147
+ Four shapes, because an empty line is never the right answer here -- if the
3148
+ lane ran and found nothing, the reader needs to know which nothing it was:
3149
+
3150
+ * **Resolved with creatives** -- advertiser, what launched this window, and
3151
+ the paid-media detail (placements, promo codes, transcripts).
3152
+ * **Resolved, nothing new** -- name the advertiser and how much it is still
3153
+ running from before, so "no ads" is not confused with "not advertising".
3154
+ * **Unresolved** -- name the closest candidate and point at the override.
3155
+ * **Failed** -- name the outcome rather than implying an empty Ad Library.
3156
+ """
3157
+ tally = (report.artifacts or {}).get("meta_ads_tally") or {}
3158
+ page = (report.artifacts or {}).get("meta_ads_page") or {}
3159
+ outcome = report.source_status.get("meta_ads")
3160
+ if not tally and not page and outcome is None:
3161
+ return None
3162
+
3163
+ # Three distinct states, and conflating any two of them misleads:
3164
+ # failed -- the lane could not run; say so, never "found nothing".
3165
+ # partial -- it ran and was cut short; its counts are a floor, so a
3166
+ # "nothing new" conclusion drawn from them would be a
3167
+ # claim the data does not support.
3168
+ # no results -- it ran to completion and the answer is genuinely empty.
3169
+ # NO_RESULTS is what the pipeline stamps on any zero-item source, so
3170
+ # treating it as failure would collapse every honest empty state below into
3171
+ # a generic "no ads pulled".
3172
+ state = getattr(outcome, "state", None)
3173
+ detail = str(getattr(outcome, "detail", "") or "").strip()
3174
+ if outcome and state not in (health.OK, schema.PARTIAL, schema.NO_RESULTS):
3175
+ return f"📣 Meta Ads: no ads pulled ({detail})" if detail else "📣 Meta Ads: no ads pulled"
3176
+ cut_short = state == schema.PARTIAL
3177
+
3178
+ resolution = str(tally.get("resolution") or "")
3179
+ if resolution == meta_ads.NO_CANDIDATES:
3180
+ return "📣 Meta Ads: no advertiser candidates returned │ pass --meta-ads-page to target one"
3181
+ if resolution == meta_ads.UNRESOLVED or not page:
3182
+ candidate = _footer_safe(tally.get("top_candidate") or "")
3183
+ if candidate:
3184
+ return (
3185
+ f"📣 Meta Ads: no advertiser matched │ closest: {candidate} "
3186
+ f"│ pass --meta-ads-page to target one"
3187
+ )
3188
+ return "📣 Meta Ads: no advertiser matched │ pass --meta-ads-page to target one"
3189
+
3190
+ advertiser = _footer_safe(page.get("name") or tally.get("advertiser") or "")
3191
+ launched = int(tally.get("launched_in_window") or 0)
3192
+ still = int(tally.get("still_running") or 0)
3193
+
3194
+ if not launched:
3195
+ # A cut-short lane never reached the end of the page, so "no new
3196
+ # creatives" would state a conclusion its own data cannot support.
3197
+ if cut_short:
3198
+ head = f"📣 Meta Ads: {advertiser} │ incomplete".rstrip()
3199
+ parts = [head, "no new creatives seen before the run was cut short"]
3200
+ if detail:
3201
+ parts.append(_footer_safe(detail, 60))
3202
+ return " │ ".join(parts)
3203
+ parts = [f"📣 Meta Ads: no new creatives for {advertiser}".rstrip()]
3204
+ if still:
3205
+ parts.append(f"{still} still running from before")
3206
+ return " │ ".join(parts)
3207
+
3208
+ fetched = int(tally.get("fetched") or 0)
3209
+ total = int(tally.get("endpoint_total") or 0)
3210
+ # A page bigger than the depth cap is a sample, and saying so is the
3211
+ # difference between "this brand launched 12 creatives" and "we looked at
3212
+ # 60 of its 222 live ads and 12 of those were new". The two numbers are
3213
+ # different units on purpose -- deduped creatives against raw ads -- so
3214
+ # both get named rather than sharing one bare noun.
3215
+ if tally.get("cursor_remaining") and total > fetched > 0:
3216
+ head = f"{launched} new of {fetched} ads fetched ({total:,} live)"
3217
+ else:
3218
+ head = f"{launched} new {'creative' if launched == 1 else 'creatives'}"
3219
+
3220
+ parts = [f"📣 Meta Ads: {advertiser} │ {head}"] if advertiser else [f"📣 Meta Ads: {head}"]
3221
+ if cut_short:
3222
+ parts.append("incomplete")
3223
+ if still:
3224
+ parts.append(f"{still} running from before")
3225
+ # Resolution that rested on substring containment is the weakest tier the
3226
+ # resolver accepts, so the reader is told to check the advertiser rather
3227
+ # than left to assume the name was confirmed.
3228
+ if str(tally.get("match_strength") or "") == meta_ads.MATCH_CONTAINED:
3229
+ parts.append("matched by partial name")
3230
+ placements = [
3231
+ _PLACEMENT_SHORT.get(str(p).upper(), _footer_safe(p, 12))
3232
+ for p in (tally.get("placements") or [])
3233
+ ]
3234
+ if placements:
3235
+ parts.append(", ".join(placements))
3236
+ codes = [_footer_safe(c, 16) for c in (tally.get("promo_codes") or []) if c]
3237
+ if codes:
3238
+ parts.append(f"code {', '.join(codes)}")
3239
+ transcribed = int(tally.get("transcribed") or 0)
3240
+ if transcribed:
3241
+ parts.append(f"{transcribed} transcribed")
3242
+ return " │ ".join(parts)
3243
+
3244
+
1582
3245
  def _top_voices_footer_line(report: schema.Report) -> str | None:
1583
3246
  """Return the 🗣️ Top voices line or None if no meaningful voices exist.
1584
3247
 
@@ -1587,7 +3250,15 @@ def _top_voices_footer_line(report: schema.Report) -> str | None:
1587
3250
  """
1588
3251
  handle_items = {
1589
3252
  source: report.items_by_source.get(source) or []
1590
- for source in ("x", "bluesky", "truthsocial", "youtube", "tiktok", "instagram", "threads")
3253
+ for source in (
3254
+ "x",
3255
+ "bluesky",
3256
+ "truthsocial",
3257
+ "youtube",
3258
+ "tiktok",
3259
+ "instagram",
3260
+ "threads",
3261
+ )
1591
3262
  }
1592
3263
  handle_counts: Counter[str] = Counter()
1593
3264
  for items in handle_items.values():
@@ -1617,22 +3288,36 @@ def _render_emoji_footer(report: schema.Report, save_path: str | None) -> list[s
1617
3288
  """Produce the deterministic magic footer block.
1618
3289
 
1619
3290
  Returns a list of markdown lines, including enclosing ``---`` separators.
1620
- Returns an empty list if no sources are populated.
3291
+ Returns an empty list only when there is nothing to report - no populated
3292
+ sources, no top voices, and no save path. When every source returned zero
3293
+ items but a save path exists, the banner and the 'Raw results saved to' line
3294
+ still render so the durable raw-file citation is never silently dropped.
1621
3295
  """
1622
3296
  source_lines = _build_source_footer_lines(report)
1623
- if not source_lines:
1624
- return []
1625
-
1626
3297
  voices_line = _top_voices_footer_line(report)
3298
+ # The freshness verdict is computed for the report body, but a reader who
3299
+ # only scans this footer never sees it — and it is the one line that says
3300
+ # how much of the evidence is actually recent.
3301
+ freshness_warning = _assess_data_freshness(report)
3302
+ freshness_line = f"🕒 {freshness_warning}" if freshness_warning else None
1627
3303
  raw_line = f"📎 Raw results saved to {save_path}" if save_path else None
1628
3304
 
1629
3305
  body: list[str] = []
1630
3306
  body.extend(source_lines)
1631
3307
  if voices_line:
1632
3308
  body.append(voices_line)
3309
+ # Append freshness whenever it would annotate something: either the body
3310
+ # already has content, or the raw-results line will make the footer
3311
+ # non-empty. An otherwise empty run stays silent rather than announcing
3312
+ # its own emptiness.
3313
+ if freshness_line and (body or raw_line):
3314
+ body.append(freshness_line)
1633
3315
  if raw_line:
1634
3316
  body.append(raw_line)
1635
3317
 
3318
+ if not body:
3319
+ return []
3320
+
1636
3321
  # Apply tree characters: ├─ for all but the last body line, └─ for the last.
1637
3322
  tree_lines: list[str] = []
1638
3323
  for i, line in enumerate(body):
@@ -1691,6 +3376,10 @@ def _render_stats(report: schema.Report) -> list[str]:
1691
3376
  actor_summary = _top_actor_summary(source, items)
1692
3377
  if actor_summary:
1693
3378
  parts.append(actor_summary)
3379
+ provenance = report.artifacts.get("x_provenance") if source == "x" else None
3380
+ if provenance in x_envelope.PROVENANCE_LABELS:
3381
+ # Host-fetched lane (--x-posts): name the provenance in the footer.
3382
+ parts.append(f"via {x_envelope.PROVENANCE_LABELS[provenance]}")
1694
3383
  lines.append(f"- {_source_label(source)}: {' | '.join(parts)}")
1695
3384
  lines.append("")
1696
3385
  return lines
@@ -1713,7 +3402,9 @@ def _assess_data_freshness(report: schema.Report) -> str | None:
1713
3402
  item.published_at,
1714
3403
  reference_date=report.range_to,
1715
3404
  )
1716
- ) is not None and _days_ago <= 7
3405
+ )
3406
+ is not None
3407
+ and _days_ago <= 7
1717
3408
  ]
1718
3409
  if len(recent_items) < 3:
1719
3410
  return f"Limited recent data: only {len(recent_items)} of {len(dated_items)} dated items are from the last 7 days."
@@ -1748,21 +3439,30 @@ def _format_actor(item: schema.SourceItem | None) -> str | None:
1748
3439
 
1749
3440
  # Per-source engagement display fields: list of (field_name, label) tuples.
1750
3441
  ENGAGEMENT_DISPLAY: dict[str, list[tuple[str, str]]] = {
1751
- "reddit": [("score", "pts"), ("num_comments", "cmt")],
1752
- "x": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
1753
- "youtube": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
1754
- "tiktok": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
1755
- "instagram": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
1756
- "threads": [("likes", "likes"), ("replies", "re")],
1757
- "pinterest": [("saves", "saves"), ("comments", "cmt")],
1758
- "hackernews": [("points", "pts"), ("comments", "cmt")],
1759
- "bluesky": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
1760
- "truthsocial": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
1761
- "polymarket": [],
1762
- "github": [("stars", "stars"), ("merged_prs", "merged"), ("reactions", "react"), ("comments", "cmt")],
1763
- "perplexity": [("citations", "cite")],
1764
- "digg": [("postCount", "posts"), ("uniqueAuthors", "auth")],
1765
- "trustpilot": [("reviews", "reviews")],
3442
+ "reddit": [("score", "pts"), ("num_comments", "cmt")],
3443
+ "x": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
3444
+ "youtube": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
3445
+ "tiktok": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
3446
+ "instagram": [("views", "views"), ("likes", "likes"), ("comments", "cmt")],
3447
+ "threads": [("likes", "likes"), ("replies", "re")],
3448
+ "pinterest": [("saves", "saves"), ("comments", "cmt")],
3449
+ "hackernews": [("points", "pts"), ("comments", "cmt")],
3450
+ "devto": [("reactions", "react"), ("comments", "cmt")],
3451
+ "bluesky": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
3452
+ "truthsocial": [("likes", "likes"), ("reposts", "rt"), ("replies", "re")],
3453
+ "linkedin": [("likes", "likes"), ("comments", "cmt")],
3454
+ "polymarket": [],
3455
+ "github": [
3456
+ ("stars", "stars"),
3457
+ ("merged_prs", "merged"),
3458
+ ("reactions", "react"),
3459
+ ("comments", "cmt"),
3460
+ ],
3461
+ "perplexity": [("citations", "cite")],
3462
+ "digg": [("postCount", "posts"), ("uniqueAuthors", "auth")],
3463
+ "trustpilot": [("reviews", "reviews")],
3464
+ "amazon": [("ratings", "ratings")],
3465
+ "meta_ads": [("variants", "variants")],
1766
3466
  }
1767
3467
 
1768
3468
 
@@ -1832,7 +3532,9 @@ def _top_actor_summary(source: str, items: list[schema.SourceItem]) -> str | Non
1832
3532
  return f"{label}: {', '.join(actors)}"
1833
3533
 
1834
3534
 
1835
- def _top_actors_for_source(source: str, items: list[schema.SourceItem], limit: int = 3) -> list[str]:
3535
+ def _top_actors_for_source(
3536
+ source: str, items: list[schema.SourceItem], limit: int = 3
3537
+ ) -> list[str]:
1836
3538
  counts: Counter[str] = Counter()
1837
3539
  for item in items:
1838
3540
  actor = _stats_actor(item)
@@ -1841,7 +3543,9 @@ def _top_actors_for_source(source: str, items: list[schema.SourceItem], limit: i
1841
3543
  return [actor for actor, _ in counts.most_common(limit)]
1842
3544
 
1843
3545
 
1844
- def _top_voices_overall(items_by_source: dict[str, list[schema.SourceItem]], limit: int = 5) -> list[str]:
3546
+ def _top_voices_overall(
3547
+ items_by_source: dict[str, list[schema.SourceItem]], limit: int = 5
3548
+ ) -> list[str]:
1845
3549
  counts: Counter[str] = Counter()
1846
3550
  for items in items_by_source.values():
1847
3551
  for item in items:
@@ -1890,13 +3594,22 @@ _TOP_COMMENT_MIN_SCORE: dict[str, int] = {
1890
3594
  "reddit": 10,
1891
3595
  "youtube": 50,
1892
3596
  "tiktok": 500,
1893
- "hackernews": 5,
3597
+ "instagram": 5,
3598
+ # Zero, not a tuned floor: the Algolia items endpoint returns points=null
3599
+ # for every comment child (only stories carry points), so any positive
3600
+ # threshold here rejects the entire source rather than filtering it.
3601
+ "hackernews": 0,
3602
+ # Replies are the only comment signal the dev.to API exposes; most top
3603
+ # comments have none, so any positive floor would drop the whole source.
3604
+ "devto": 0,
1894
3605
  }
1895
3606
  _TOP_COMMENT_VOTE_LABEL: dict[str, str] = {
1896
3607
  "reddit": "upvotes",
1897
3608
  "hackernews": "points",
3609
+ "devto": "replies",
1898
3610
  "youtube": "likes",
1899
3611
  "tiktok": "likes",
3612
+ "instagram": "likes",
1900
3613
  }
1901
3614
 
1902
3615
 
@@ -1931,11 +3644,33 @@ def _comment_attribution(source: str | None, author: str | None) -> str:
1931
3644
  # Some sources (YouTube/TikTok) already store the author with a leading '@';
1932
3645
  # strip it before re-prefixing so we don't emit '@@handle'.
1933
3646
  if prefix and author.startswith(prefix):
1934
- author = author[len(prefix):]
3647
+ author = author[len(prefix) :]
1935
3648
  return f"{prefix}{author}" if prefix else author
1936
3649
 
1937
3650
 
1938
- def _top_comments_list(item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None) -> list[dict]:
3651
+ def _comment_link(item: schema.SourceItem, comment: dict, fallback: str = "") -> str:
3652
+ """Link for a quoted comment: where it was said, never the HN article.
3653
+
3654
+ HN items carry the article as their URL, so HN comments fall back to the
3655
+ item's discussion page (``hn_url`` metadata, else built from a numeric item
3656
+ id) instead of ``fallback``. Other sources keep ``fallback``.
3657
+ """
3658
+ if comment.get("url"):
3659
+ return str(comment["url"])
3660
+ if item.source != "hackernews":
3661
+ return fallback or ""
3662
+ hn_url = (item.metadata or {}).get("hn_url")
3663
+ if hn_url:
3664
+ return str(hn_url)
3665
+ item_id = str(item.item_id or "")
3666
+ if item_id.isdigit():
3667
+ return f"https://news.ycombinator.com/item?id={item_id}"
3668
+ return ""
3669
+
3670
+
3671
+ def _top_comments_list(
3672
+ item: schema.SourceItem | None, limit: int = 3, min_score: int | None = None
3673
+ ) -> list[dict]:
1939
3674
  """Return up to `limit` top comments with score at or above the source's minimum.
1940
3675
 
1941
3676
  If `min_score` is passed explicitly it overrides the per-source default;
@@ -2004,24 +3739,15 @@ def _source_label(source: str) -> str:
2004
3739
  return SOURCE_LABELS.get(source, source.replace("_", " ").title())
2005
3740
 
2006
3741
 
2007
-
2008
3742
  def _best_take_relevance_ok(candidate) -> bool:
2009
3743
  """Exclude off-topic-but-viral candidates from Best Takes.
2010
3744
 
2011
- The engine demotes candidates that don't match the topic entity by tagging
2012
- ``entity-miss`` in the explanation and/or zeroing ``final_score`` (e.g. a
2013
- 39k-like Grand Tour comment surfacing in a 'Patagonia brand' run). Those
2014
- must never reach Best Takes no matter how upvoted their comments are.
2015
- Plain ``fallback-local-score`` (without entity-miss) is NOT a demotion --
2016
- it is the default reason when LLM rerank didn't score an item -- so it is
2017
- not gated here.
3745
+ Delegates to ``rerank.candidate_relevance_ok``, which owns the entity-miss
3746
+ demotion test. Do not re-implement the check here: this site previously
3747
+ carried its own copy, which meant the first-party carve-out applied in
3748
+ rerank never reached Best Takes or cluster visibility.
2018
3749
  """
2019
- explanation = (candidate.explanation or "").lower()
2020
- if "entity-miss" in explanation:
2021
- return False
2022
- if (candidate.final_score or 0.0) <= 0.0:
2023
- return False
2024
- return True
3750
+ return rerank.candidate_relevance_ok(candidate)
2025
3751
 
2026
3752
 
2027
3753
  def _effective_fun_score(candidate, vote_weight: float) -> float:
@@ -2040,32 +3766,84 @@ def _effective_fun_score(candidate, vote_weight: float) -> float:
2040
3766
  return base + vote_weight * confidence * vote_signal
2041
3767
 
2042
3768
 
2043
- def _render_best_takes(candidates, limit=5, threshold=70.0, vote_weight=_FUN_LEVELS["medium"]["vote_weight"]):
3769
+ def _render_best_takes(
3770
+ candidates,
3771
+ limit=5,
3772
+ threshold=70.0,
3773
+ vote_weight=_FUN_LEVELS["medium"]["vote_weight"],
3774
+ source_weight=None,
3775
+ ):
2044
3776
  eligible = [
2045
- c for c in candidates
3777
+ c
3778
+ for c in candidates
2046
3779
  if c.fun_score is not None
2047
3780
  and c.fun_score >= _BEST_TAKE_FUNNY_FLOOR
2048
3781
  and _best_take_relevance_ok(c)
2049
3782
  ]
2050
3783
  scored = [(c, _effective_fun_score(c, vote_weight)) for c in eligible]
3784
+ # Audience presets promote sources INSIDE the ranking (a pre-sort of the
3785
+ # input is discarded by this sort): weight the ordering, not the
3786
+ # threshold, so emphasis reorders takes without inventing eligibility.
3787
+ rank_key = (
3788
+ (lambda pair: -pair[1] * source_weight(pair[0].source))
3789
+ if source_weight
3790
+ else (lambda pair: -pair[1])
3791
+ )
2051
3792
  # Carry the effective score forward so the display loop doesn't recompute it.
2052
- gems = [(c, eff) for c, eff in sorted(scored, key=lambda pair: -pair[1]) if eff >= threshold]
3793
+ gems = [(c, eff) for c, eff in sorted(scored, key=rank_key) if eff >= threshold]
2053
3794
  if len(gems) < 2:
2054
3795
  return []
2055
3796
  lines = ["## Best Takes", ""]
2056
3797
  for candidate, effective in gems[:limit]:
2057
3798
  text = candidate.title.strip()
3799
+ selected_comment_item = None
3800
+ hackernews_take = None
2058
3801
  for item in candidate.source_items:
2059
3802
  for comment in item.metadata.get("top_comments", [])[:3]:
2060
- body = (comment.get("body") or comment.get("text") or "") if isinstance(comment, dict) else str(comment)
3803
+ body = (
3804
+ (
3805
+ comment.get("body")
3806
+ or comment.get("text")
3807
+ or (
3808
+ comment.get("excerpt")
3809
+ if item.source == "hackernews"
3810
+ else ""
3811
+ )
3812
+ or ""
3813
+ )
3814
+ if isinstance(comment, dict)
3815
+ else str(comment)
3816
+ )
2061
3817
  body = body.strip()
2062
- if body and len(body) < len(text) and len(body) > 10:
3818
+ if not body or len(body) <= 10:
3819
+ continue
3820
+ if item.source == "hackernews" and (
3821
+ hackernews_take is None or len(body) < len(hackernews_take[0])
3822
+ ):
3823
+ hackernews_take = (body, item)
3824
+ elif hackernews_take is None and len(body) < len(text):
2063
3825
  text = body
2064
- source_label = _source_label(candidate.source)
2065
- author = candidate.source_items[0].author if candidate.source_items else None
2066
- attribution = f"@{author} on {source_label}" if author and candidate.source in ("x", "tiktok", "instagram", "threads") else f"{source_label}"
2067
- if author and candidate.source == "reddit":
2068
- container = candidate.source_items[0].container if candidate.source_items else None
3826
+ selected_comment_item = item
3827
+ if hackernews_take is not None:
3828
+ text, selected_comment_item = hackernews_take
3829
+ attribution_item = (
3830
+ selected_comment_item
3831
+ or (candidate.source_items[0] if candidate.source_items else None)
3832
+ )
3833
+ attribution_source = (
3834
+ selected_comment_item.source
3835
+ if selected_comment_item is not None
3836
+ else candidate.source
3837
+ )
3838
+ source_label = _source_label(attribution_source)
3839
+ author = attribution_item.author if attribution_item else None
3840
+ attribution = (
3841
+ f"@{author} on {source_label}"
3842
+ if author and attribution_source in ("x", "tiktok", "instagram", "threads")
3843
+ else f"{source_label}"
3844
+ )
3845
+ if author and attribution_source == "reddit":
3846
+ container = attribution_item.container if attribution_item else None
2069
3847
  attribution = f"r/{container} comment" if container else "Reddit"
2070
3848
  # fun: is the LLM humor score; flag when crowd votes materially lifted
2071
3849
  # this item's ranking, so a lower-fun item ranking above a higher-fun one
@@ -2073,12 +3851,25 @@ def _render_best_takes(candidates, limit=5, threshold=70.0, vote_weight=_FUN_LEV
2073
3851
  crowd_boost = effective - (candidate.fun_score or 0.0)
2074
3852
  crowd_tag = " +crowd" if crowd_boost >= 5.0 else ""
2075
3853
  score_tag = f"(fun:{candidate.fun_score:.0f}{crowd_tag})"
2076
- reason = f" -- {candidate.fun_explanation}" if candidate.fun_explanation and candidate.fun_explanation != "heuristic-fallback" else ""
2077
- lines.append(f'- "{_truncate(text, 280)}" -- {attribution} {score_tag}{reason}')
3854
+ reason = (
3855
+ f" -- {candidate.fun_explanation}"
3856
+ if candidate.fun_explanation
3857
+ and candidate.fun_explanation != "heuristic-fallback"
3858
+ else ""
3859
+ )
3860
+ lines.append(
3861
+ f'- "{_format_untrusted_evidence(text, 280, continuation_indent=" ")}" '
3862
+ f"-- {attribution} {score_tag}{reason}"
3863
+ )
2078
3864
  return lines
2079
3865
 
2080
3866
 
2081
- def _render_top_comments(report, limit: int = 8) -> list[str]:
3867
+ def _render_top_comments(
3868
+ report,
3869
+ limit: int = 8,
3870
+ *,
3871
+ candidates: list[schema.Candidate] | None = None,
3872
+ ) -> list[str]:
2082
3873
  """Vote-ranked community comments across ALL ranked candidates — not just the
2083
3874
  top-cluster representatives — surfaced into the EVIDENCE block so the reading
2084
3875
  model can weave the funniest/highest-engagement lines into the synthesis.
@@ -2093,33 +3884,87 @@ def _render_top_comments(report, limit: int = 8) -> list[str]:
2093
3884
  """
2094
3885
  seen: set[str] = set()
2095
3886
  scored: list[tuple[float, schema.Candidate, schema.SourceItem, dict, str]] = []
2096
- for cand in report.ranked_candidates:
3887
+ candidate_pool = report.ranked_candidates if candidates is None else candidates
3888
+ floor_candidates = [
3889
+ cand
3890
+ for cand in candidate_pool
3891
+ if _best_take_relevance_ok(cand)
3892
+ and (cand.local_relevance or 0.0) >= relevance.RELEVANCE_FLOOR
3893
+ ]
3894
+ apply_relevance_floor = len(floor_candidates) >= relevance.MIN_ON_TOPIC
3895
+ for cand in candidate_pool:
3896
+ if not _best_take_relevance_ok(cand):
3897
+ continue
3898
+ # Skip comments from off-topic threads when enough candidates clear the
3899
+ # floor; sparse niche topics still surface their best comments (#641).
3900
+ if (
3901
+ apply_relevance_floor
3902
+ and (cand.local_relevance or 0.0) < relevance.RELEVANCE_FLOOR
3903
+ ):
3904
+ continue
2097
3905
  for item in cand.source_items:
2098
- # _top_comments_list applies the per-source min-score threshold and
2099
- # the 3-per-item cap, so trivial comments don't surface here either.
2100
- for tc in _top_comments_list(item):
3906
+ # Pass min_score=0 here: the cross-platform list deliberately does
3907
+ # NOT gate on the per-platform absolute floor, because a less-watched
3908
+ # video's killer low-vote top comment is gold too. The 3-per-item cap
3909
+ # still applies; cross-platform fairness is handled by the rank-based
3910
+ # round-robin below, and the model makes the final quotable pick.
3911
+ for tc in _top_comments_list(item, min_score=0):
2101
3912
  if not isinstance(tc, dict):
2102
3913
  continue
2103
- body = (tc.get("excerpt") or tc.get("text") or tc.get("body") or "").strip()
3914
+ body = (
3915
+ tc.get("excerpt") or tc.get("text") or tc.get("body") or ""
3916
+ ).strip()
2104
3917
  if len(body) < 12:
2105
3918
  continue
2106
3919
  key = body[:60].lower()
2107
3920
  if key in seen:
2108
3921
  continue
2109
3922
  seen.add(key)
2110
- strength = signals.normalized_comment_vote(cand.source, tc.get("score"))
3923
+ # Blend vote strength (60%) with thread relevance (40%) so comments
3924
+ # from on-topic threads rank above off-topic viral comments.
3925
+ vote_strength = signals.normalized_comment_vote(
3926
+ item.source, tc.get("score")
3927
+ )
3928
+ strength = 0.6 * vote_strength + 0.4 * (cand.local_relevance or 0.0)
2111
3929
  scored.append((strength, cand, item, tc, body))
2112
3930
  if len(scored) < 2:
2113
3931
  return []
2114
- scored.sort(key=lambda row: -row[0])
3932
+ # Rank-based cross-platform diversity: group by platform, rank each
3933
+ # platform's comments by within-platform vote strength, then interleave by
3934
+ # rank -- every platform's #1, then every #2, then every #3, and so on. This
3935
+ # makes the top-3-of-each-platform outrank the 4th-of-any and guarantees each
3936
+ # platform's #1 a slot, instead of a global vote sort where one viral
3937
+ # platform sweeps the list. Absolute vote counts are NOT compared across
3938
+ # platforms (a less-watched video's killer 50-like comment is gold too);
3939
+ # vote strength only orders comments *within* a platform and breaks ties
3940
+ # among same-rank picks. The model still makes the final quotable pick.
3941
+ by_source: dict[str, list] = {}
3942
+ for row in scored:
3943
+ by_source.setdefault(row[2].source, []).append(row)
3944
+ for src_rows in by_source.values():
3945
+ src_rows.sort(key=lambda row: -row[0])
3946
+ ordered: list = []
3947
+ deepest = max(len(rows) for rows in by_source.values())
3948
+ for rank in range(deepest):
3949
+ tier = [rows[rank] for rows in by_source.values() if len(rows) > rank]
3950
+ tier.sort(key=lambda row: -row[0]) # among same-rank picks, strongest first
3951
+ ordered.extend(tier)
2115
3952
  lines = ["## Top Community Comments", ""]
2116
- for _strength, cand, _item, tc, body in scored[:limit]:
3953
+ for _strength, cand, item, tc, body in ordered[:limit]:
2117
3954
  score = tc.get("score", "")
2118
- vote_label = _vote_label_for(cand.source)
2119
- attribution = _comment_attribution(cand.source, tc.get("author"))
2120
- url = tc.get("url") or cand.url or ""
3955
+ vote_label = _vote_label_for(item.source)
3956
+ attribution = _comment_attribution(item.source, tc.get("author"))
3957
+ url = _comment_link(item, tc, fallback=cand.url)
2121
3958
  url_part = f" — {url}" if url else ""
2122
- lines.append(f'- "{_truncate(body, 240)}" — {attribution} ({score} {vote_label}){url_part}')
3959
+ vote_part = (
3960
+ f" ({score} {vote_label})"
3961
+ if score is not None and score != ""
3962
+ else ""
3963
+ )
3964
+ lines.append(
3965
+ f'- "{_format_untrusted_evidence(body, 240, continuation_indent=" ")}" '
3966
+ f"— {attribution}{vote_part}{url_part}"
3967
+ )
2123
3968
  return lines
2124
3969
 
2125
3970
 
@@ -2128,3 +3973,48 @@ def _truncate(text: str, limit: int) -> str:
2128
3973
  if len(text) <= limit:
2129
3974
  return text
2130
3975
  return text[: limit - 3].rstrip() + "..."
3976
+
3977
+
3978
+ _ATX_HEADING_PREFIX = re.compile(r"^(#{1,6})(\s|$)")
3979
+
3980
+
3981
+ def _escape_atx_heading_prefix(line: str) -> str:
3982
+ """Neutralize leading ATX heading markers so scraped text cannot mint sections."""
3983
+ stripped = line.lstrip()
3984
+ if not stripped:
3985
+ return line
3986
+ leading = line[: len(line) - len(stripped)]
3987
+ match = _ATX_HEADING_PREFIX.match(stripped)
3988
+ if not match:
3989
+ return line
3990
+ hashes = match.group(1)
3991
+ rest = stripped[len(hashes) :]
3992
+ return f"{leading}{'\\#' * len(hashes)}{rest}"
3993
+
3994
+
3995
+ def _format_untrusted_evidence(
3996
+ text: str,
3997
+ limit: int,
3998
+ *,
3999
+ continuation_indent: str = " ",
4000
+ ) -> str:
4001
+ """Truncate scraped text and keep it from injecting markdown structure.
4002
+
4003
+ Multi-line snippets previously broke out of the `` - Evidence:`` indent
4004
+ so a bare ``##`` from a jobs page became a sibling of engine section
4005
+ headings inside the EVIDENCE FOR SYNTHESIS block (#874). Continuation
4006
+ lines stay indented (CommonMark ATX headings need ≤3 leading spaces), and
4007
+ leading ``#`` runs are escaped as defense in depth.
4008
+
4009
+ Indentation stops CommonMark from parsing a forged heading, but it does
4010
+ nothing to an HTML comment a model reads as text, so the engine's own
4011
+ block sentinels are defanged here too (#1053).
4012
+ """
4013
+ truncated = _truncate(text, limit)
4014
+ if not truncated:
4015
+ return truncated
4016
+ lines = _defang_engine_sentinels(truncated).splitlines()
4017
+ safe: list[str] = [_escape_atx_heading_prefix(lines[0])]
4018
+ for line in lines[1:]:
4019
+ safe.append(continuation_indent + _escape_atx_heading_prefix(line))
4020
+ return "\n".join(safe)