agent-wire-cli 0.2.1__py3-none-any.whl

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 (87) hide show
  1. agent_wire/__init__.py +1 -0
  2. agent_wire/__main__.py +3 -0
  3. agent_wire/assets/SKILL.md +26 -0
  4. agent_wire/assets/cursor_rule.mdc +13 -0
  5. agent_wire/assets/hermes_skill.md +28 -0
  6. agent_wire/assets/windsurf_rules.md +14 -0
  7. agent_wire/assets/wire-worker.md +20 -0
  8. agent_wire/auth/__init__.py +0 -0
  9. agent_wire/auth/browser_paths.py +143 -0
  10. agent_wire/auth/cookie_vault.py +181 -0
  11. agent_wire/auth/rate_limiter.py +26 -0
  12. agent_wire/cache/__init__.py +0 -0
  13. agent_wire/cache/ring_buffer.py +94 -0
  14. agent_wire/cache/sqlite_ledger.py +132 -0
  15. agent_wire/channels/__init__.py +41 -0
  16. agent_wire/channels/base.py +249 -0
  17. agent_wire/channels/tier1_global/__init__.py +0 -0
  18. agent_wire/channels/tier1_global/github.py +371 -0
  19. agent_wire/channels/tier1_global/hackernews.py +135 -0
  20. agent_wire/channels/tier1_global/reddit.py +371 -0
  21. agent_wire/channels/tier1_global/rss.py +129 -0
  22. agent_wire/channels/tier1_global/search.py +160 -0
  23. agent_wire/channels/tier1_global/stackoverflow.py +94 -0
  24. agent_wire/channels/tier1_global/web.py +98 -0
  25. agent_wire/channels/tier1_global/wikipedia.py +91 -0
  26. agent_wire/channels/tier1_global/x_twitter.py +380 -0
  27. agent_wire/channels/tier1_global/youtube.py +223 -0
  28. agent_wire/channels/tier2_pro/__init__.py +0 -0
  29. agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
  30. agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
  31. agent_wire/channels/tier2_pro/devto.py +114 -0
  32. agent_wire/channels/tier2_pro/fediverse.py +181 -0
  33. agent_wire/channels/tier2_pro/hubs.py +161 -0
  34. agent_wire/channels/tier2_pro/linkedin.py +146 -0
  35. agent_wire/channels/tier2_pro/markets.py +207 -0
  36. agent_wire/channels/tier2_pro/meta_social.py +104 -0
  37. agent_wire/channels/tier2_pro/news.py +173 -0
  38. agent_wire/channels/tier2_pro/polymarket.py +105 -0
  39. agent_wire/channels/tier2_pro/publishing.py +84 -0
  40. agent_wire/channels/tier2_pro/stocktwits.py +79 -0
  41. agent_wire/channels/tier3_regional/bilibili.py +171 -0
  42. agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
  43. agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
  44. agent_wire/channels/tier3_regional/v2ex.py +70 -0
  45. agent_wire/channels/tier3_regional/wechat.py +67 -0
  46. agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
  47. agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
  48. agent_wire/channels/tier3_regional/xueqiu.py +77 -0
  49. agent_wire/channels/tier3_regional/zhihu.py +57 -0
  50. agent_wire/cli.py +403 -0
  51. agent_wire/compression/__init__.py +0 -0
  52. agent_wire/compression/anchor_slicer.py +45 -0
  53. agent_wire/compression/bm25_slicer.py +151 -0
  54. agent_wire/compression/dam_formatter.py +74 -0
  55. agent_wire/compression/url_compactor.py +45 -0
  56. agent_wire/config.py +52 -0
  57. agent_wire/engine/__init__.py +0 -0
  58. agent_wire/engine/challenge.py +58 -0
  59. agent_wire/engine/http_pool.py +136 -0
  60. agent_wire/engine/stealth_browser.py +90 -0
  61. agent_wire/engine/waterfall.py +159 -0
  62. agent_wire/firewall/__init__.py +0 -0
  63. agent_wire/firewall/inbound_shield.py +116 -0
  64. agent_wire/firewall/outbound_privacy.py +120 -0
  65. agent_wire/installers/__init__.py +0 -0
  66. agent_wire/installers/client_installer.py +315 -0
  67. agent_wire/media/__init__.py +0 -0
  68. agent_wire/media/audio_transcriber.py +77 -0
  69. agent_wire/media/subtitle_cleaner.py +102 -0
  70. agent_wire/models.py +60 -0
  71. agent_wire/research/__init__.py +1 -0
  72. agent_wire/research/brief.py +227 -0
  73. agent_wire/research/briefing.py +41 -0
  74. agent_wire/research/cluster.py +128 -0
  75. agent_wire/research/engine.py +632 -0
  76. agent_wire/research/library.py +74 -0
  77. agent_wire/research/planner.py +232 -0
  78. agent_wire/research/scoring.py +89 -0
  79. agent_wire/research/store.py +149 -0
  80. agent_wire/research/watch.py +70 -0
  81. agent_wire/router.py +107 -0
  82. agent_wire/server.py +468 -0
  83. agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
  84. agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
  85. agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
  86. agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
  87. agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,632 @@
1
+ """wire_research: fan out across key-free sources inside a time window, score, cluster, brief."""
2
+
3
+ import asyncio
4
+ import inspect
5
+ import re
6
+ import time
7
+ from collections import Counter
8
+ from dataclasses import dataclass, field
9
+ from datetime import UTC, datetime
10
+ from itertools import pairwise
11
+
12
+ from curl_cffi.requests.exceptions import RequestException
13
+
14
+ from agent_wire.channels.base import WireUnsupported, relative_ts, ts
15
+ from agent_wire.compression.bm25_slicer import rank_comments
16
+ from agent_wire.engine.waterfall import WireFetchError
17
+ from agent_wire.firewall.inbound_shield import shield_comments
18
+ from agent_wire.firewall.outbound_privacy import WirePrivacyBlockError, scrub
19
+ from agent_wire.models import WireComment, WireSearchHit
20
+ from agent_wire.research.cluster import Cluster, cluster
21
+ from agent_wire.research.planner import Plan, make_plan, terms
22
+ from agent_wire.research.scoring import RELEVANCE_FLOOR, Item, raw_engagement, score_items
23
+
24
+ SOURCE_CHANNEL = {"reddit": "reddit", "hackernews": "hackernews", "youtube": "youtube",
25
+ "github": "github", "news": "news", "bluesky": "fediverse",
26
+ "mastodon": "fediverse", "polymarket": "polymarket", "web": "search",
27
+ "devto": "devto", "stackoverflow": "stackoverflow", "arxiv": "arxiv",
28
+ "stocktwits": "stocktwits", "x": "x", "lobsters": "hackernews"} # fmt: skip
29
+ SOURCE_TIMEOUT_S = {"reddit": 60, "web": 30} # Arctic Shift and engine fan-out are slower
30
+ DEFAULT_TIMEOUT_S = 25
31
+ EXPECTED = (WireFetchError, WireUnsupported, WirePrivacyBlockError, RequestException, ValueError,
32
+ KeyError, TimeoutError, TypeError) # fmt: skip
33
+ READABLE = frozenset({"hackernews", "reddit", "youtube", "github", "devto", "bluesky", "mastodon",
34
+ "stackoverflow", "lobsters", "polymarket"}) # fmt: skip
35
+ ALGOLIA = "https://hn.algolia.com/api/v1"
36
+ COMPARE_FLOOR = 0.2 # "A vs B" items only need to be on-topic for one side
37
+
38
+
39
+ @dataclass
40
+ class Take:
41
+ cluster: int
42
+ url: str
43
+ comment: WireComment
44
+
45
+
46
+ @dataclass
47
+ class ResearchResult:
48
+ plan: Plan
49
+ clusters: list[Cluster] = field(default_factory=list)
50
+ counts: dict[str, int] = field(default_factory=dict) # source -> items kept
51
+ gaps: dict[str, str] = field(default_factory=dict) # source -> why it returned nothing
52
+ takes: list[Take] = field(default_factory=list)
53
+ excerpts: dict[int, str] = field(default_factory=dict) # cluster -> deep-read excerpt
54
+ subjects: list[dict] = field(default_factory=list) # compare mode
55
+ topics: list[dict] = field(default_factory=list) # discover mode
56
+ person: str | None = None
57
+ library: list[dict] = field(default_factory=list)
58
+ markets: list[Item] = field(default_factory=list) # Polymarket odds, shown on their own
59
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
60
+ elapsed_ms: int = 0
61
+ run_id: int | None = None
62
+
63
+ @property
64
+ def items(self) -> list[Item]:
65
+ return [i for c in self.clusters for i in c.items]
66
+
67
+
68
+ def _aware(hit: WireSearchHit) -> WireSearchHit:
69
+ if hit.timestamp and hit.timestamp.tzinfo is None:
70
+ return hit.model_copy(update={"timestamp": hit.timestamp.replace(tzinfo=UTC)})
71
+ return hit
72
+
73
+
74
+ _MONTHS = {m: i for i, m in enumerate(
75
+ ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"), 1)} # fmt: skip
76
+ _DMY = re.compile(r"^\s*(\d{1,2}) ([A-Za-z]{3})[a-z]* (\d{4})")
77
+ _MDY = re.compile(r"^\s*([A-Za-z]{3})[a-z]* (\d{1,2}), (\d{4})")
78
+
79
+
80
+ def snippet_date(snippet: str) -> datetime | None:
81
+ """Search engines prefix snippets with '2 days ago -' or '16 April 2026 -' / 'Apr 16, 2026 -'."""
82
+ head = snippet[:40]
83
+ if " ago" in head:
84
+ return relative_ts(head)
85
+ for rx, order in ((_DMY, (0, 1, 2)), (_MDY, (1, 0, 2))):
86
+ if m := rx.match(head):
87
+ day, mon, year = (m.groups()[i] for i in order)
88
+ if month := _MONTHS.get(mon.lower()):
89
+ try:
90
+ return datetime(int(year), month, int(day), tzinfo=UTC)
91
+ except ValueError:
92
+ return None
93
+ return None
94
+
95
+
96
+ def _accepts_since(fn) -> bool:
97
+ try:
98
+ return "since" in inspect.signature(fn).parameters
99
+ except (TypeError, ValueError):
100
+ return False
101
+
102
+
103
+ def mentions(item: Item, subjects: list[str]) -> list[str]:
104
+ text = f"{item.hit.title} {item.hit.snippet}".lower()
105
+ return [s for s in subjects if all(t in text for t in terms(s))]
106
+
107
+
108
+ class Researcher:
109
+ def __init__(self, wire):
110
+ self.wire = wire
111
+ self.ctx = wire.ctx
112
+
113
+ # ---- collection --------------------------------------------------------------------------
114
+ async def _search(self, source: str, query: str, plan: Plan, limit: int) -> list[WireSearchHit]:
115
+ ch = self.ctx.channel(SOURCE_CHANNEL[source])
116
+ if source == "mastodon":
117
+ words = terms(query)
118
+ return await ch.mastodon_tag(max(words, key=len), limit=limit) if words else []
119
+ if source == "lobsters":
120
+ return await self._lobsters(query, limit)
121
+ kw = {"limit": limit}
122
+ if _accepts_since(ch.search):
123
+ kw["since"] = plan.since
124
+ return await ch.search(query, **kw)
125
+
126
+ async def _lobsters(self, query: str, limit: int) -> list[WireSearchHit]:
127
+ """Lobsters search is bot-walled; its hottest JSON is open, so filter that."""
128
+ data, _ = await self.ctx.channel("hackernews").fetch_json("https://lobste.rs/hottest.json")
129
+ want = set(terms(query))
130
+ hits = []
131
+ for s in data:
132
+ text = f"{s.get('title', '')} {' '.join(s.get('tags') or [])}".lower()
133
+ if want and not any(w in text for w in want):
134
+ continue
135
+ hits.append(WireSearchHit(
136
+ url=s.get("short_id_url") or s.get("comments_url") or "", title=s.get("title") or "",
137
+ snippet=", ".join(s.get("tags") or []), source="lobsters", link=s.get("url") or None,
138
+ timestamp=ts(s.get("created_at")), rank_score=float(s.get("score") or 0),
139
+ engagement={"score": s.get("score") or 0, "comments": s.get("comment_count") or 0},
140
+ )) # fmt: skip
141
+ return hits[:limit]
142
+
143
+ async def _collect(self, plan: Plan, limit: int, result: ResearchResult) -> list[Item]:
144
+ async def one(source: str, q: str):
145
+ timeout = SOURCE_TIMEOUT_S.get(source, DEFAULT_TIMEOUT_S)
146
+ try:
147
+ hits = await asyncio.wait_for(self._search(source, q, plan, limit), timeout)
148
+ return source, q, hits, None
149
+ except EXPECTED as e:
150
+ return source, q, [], f"{type(e).__name__}: {scrub(str(e))[:160]}"
151
+
152
+ jobs = [one(s, q) for s in plan.sources for q in plan.queries.get(s) or []]
153
+ items, ok = [], set()
154
+ for source, q, hits, err in await asyncio.gather(*jobs):
155
+ if err:
156
+ result.gaps.setdefault(source, err)
157
+ continue
158
+ ok.add(source)
159
+ for h in hits:
160
+ h = _aware(h)
161
+ if h.timestamp is None and source == "web":
162
+ h = h.model_copy(update={"timestamp": snippet_date(h.snippet)})
163
+ if h.source != source and source not in ("news", "arxiv"):
164
+ h = h.model_copy(update={"source": source}) # web engines, bsky -> source name
165
+ items.append(Item(hit=h, query=q, origin=source))
166
+ for s in ok:
167
+ result.gaps.pop(s, None)
168
+ return items
169
+
170
+ # ---- modes -------------------------------------------------------------------------------
171
+ async def run(self, topic: str = "", *, days: int = 30, sources=None, mode: str = "auto",
172
+ depth: str = "default", as_of=None, github_user: str | None = None,
173
+ library=None) -> ResearchResult: # fmt: skip
174
+ t0 = time.perf_counter()
175
+ has_x = bool(self.ctx.vault and self.ctx.vault.get("x.com"))
176
+ plan = make_plan(topic, days=days, sources=sources, mode=mode, depth=depth, as_of=as_of,
177
+ github_user=github_user, has_x_session=has_x) # fmt: skip
178
+ result = ResearchResult(plan=plan)
179
+ if plan.mode == "discover":
180
+ await self._discover(plan, result)
181
+ elif plan.mode == "person":
182
+ await self._person(plan, result)
183
+ else:
184
+ await self._topic(plan, result)
185
+ if plan.github_user:
186
+ await self._person(plan, result)
187
+ if library is not None and plan.topic:
188
+ result.library = library.related(plan.topic, limit=3)
189
+ result.elapsed_ms = int((time.perf_counter() - t0) * 1000)
190
+ return result
191
+
192
+ async def _topic(self, plan: Plan, result: ResearchResult) -> None:
193
+ items = await self._collect(plan, plan.per_source, result)
194
+ compare = plan.mode == "compare"
195
+ if compare:
196
+ result.subjects = self._compare(plan, items)
197
+ floor = COMPARE_FLOOR if compare else None
198
+ scored = score_items(items, plan.topic, plan.since, plan.until, plan.days, floor=floor)
199
+ if compare: # keep items that are clearly about at least one side
200
+ scored = [
201
+ i for i in scored if i.relevance >= RELEVANCE_FLOOR or mentions(i, plan.subjects)
202
+ ]
203
+ result.counts = dict(Counter(i.source for i in scored))
204
+ answered = {i.origin for i in scored}
205
+ for s in plan.sources:
206
+ if s not in answered and s not in result.gaps:
207
+ result.gaps[s] = "answered, but nothing on-topic inside the window"
208
+ result.markets = [i for i in scored if i.source == "polymarket"]
209
+ result.clusters = cluster([i for i in scored if i.source != "polymarket"])
210
+ await self._deep_read(plan, result)
211
+
212
+ def _compare(self, plan: Plan, items: list[Item]) -> list[dict]:
213
+ out = []
214
+ for subj in plan.subjects:
215
+ pool = [Item(hit=i.hit, query=subj) for i in items
216
+ if i.query == subj or mentions(i, [subj])] # fmt: skip
217
+ mine = score_items(pool, subj, plan.since, plan.until, plan.days)
218
+ eng: Counter = Counter()
219
+ for i in mine:
220
+ eng.update(i.hit.engagement)
221
+ top = mine[0].hit if mine else None
222
+ out.append({
223
+ "subject": subj, "items": len(mine), "sources": sorted({i.source for i in mine}),
224
+ "engagement": dict(eng), "heat": round(sum(raw_engagement(i.hit) for i in mine)),
225
+ "top": {"title": top.title, "url": top.url, "source": top.source} if top else None,
226
+ "head_to_head": sum(1 for i in mine if len(mentions(i, plan.subjects)) > 1),
227
+ }) # fmt: skip
228
+ return out
229
+
230
+ async def _deep_read(self, plan: Plan, result: ResearchResult) -> None:
231
+ """'Best takes': read the top clusters and keep their strongest on-topic comments."""
232
+ picks = []
233
+ for idx, c in enumerate(result.clusters):
234
+ if len(picks) >= plan.deep_reads:
235
+ break
236
+ ranked = sorted(c.items, key=lambda i: -i.score)
237
+ lead = next((i for i in ranked if i.source in READABLE), None)
238
+ if lead:
239
+ picks.append((idx, lead.hit.url))
240
+ if not picks:
241
+ return
242
+ reads = await asyncio.gather(
243
+ *(
244
+ asyncio.wait_for(self.wire._read_one(u, plan.topic, None, True), 40)
245
+ for _, u in picks
246
+ ),
247
+ return_exceptions=True,
248
+ )
249
+ for (idx, url), r in zip(picks, reads, strict=True):
250
+ if isinstance(r, BaseException):
251
+ if not isinstance(r, EXPECTED):
252
+ raise r
253
+ continue
254
+ doc, _ = r
255
+ if body := " ".join(scrub(doc.body).split()):
256
+ result.excerpts[idx] = body[:400]
257
+ best = rank_comments(shield_comments(doc.comments), plan.topic, 400)
258
+ for c in sorted(best, key=lambda c: -c.score)[:2]:
259
+ flat = c.model_copy(update={"replies": [], "body": scrub(c.body)})
260
+ result.takes.append(Take(idx, url, flat))
261
+
262
+ async def _person(self, plan: Plan, result: ResearchResult) -> None:
263
+ try:
264
+ doc = await self.ctx.channel("github").person(plan.github_user, since=plan.since)
265
+ result.person = scrub(doc.body)
266
+ except EXPECTED as e:
267
+ result.gaps["github-person"] = f"{type(e).__name__}: {scrub(str(e))[:160]}"
268
+
269
+ async def _discover(self, plan: Plan, result: ResearchResult) -> None:
270
+ """Topic-less: what is rising across sources in the window, with momentum labels."""
271
+ hn, gh, news = (self.ctx.channel(n) for n in ("hackernews", "github", "news"))
272
+ day, since_s = plan.since.date().isoformat(), int(plan.since.timestamp())
273
+
274
+ async def hn_top():
275
+ data, _ = await hn.fetch_json(f"{ALGOLIA}/search?tags=story&hitsPerPage=40"
276
+ f"&numericFilters=created_at_i>{since_s},points>50") # fmt: skip
277
+ return [WireSearchHit(url=f"https://news.ycombinator.com/item?id={h['objectID']}",
278
+ title=h.get("title") or "", source="hackernews", link=h.get("url"),
279
+ timestamp=ts(h.get("created_at_i")),
280
+ engagement={"score": h.get("points") or 0,
281
+ "comments": h.get("num_comments") or 0})
282
+ for h in data.get("hits", [])] # fmt: skip
283
+
284
+ async def mastodon_trends():
285
+ data, _ = await hn.fetch_json("https://mastodon.social/api/v1/trends/tags?limit=10")
286
+ return [WireSearchHit(url=t.get("url") or "", title=f"#{t.get('name')}", source="mastodon",
287
+ engagement={"comments": sum(int(d.get("uses", 0))
288
+ for d in t.get("history") or [])})
289
+ for t in data] # fmt: skip
290
+
291
+ makers = {
292
+ "hackernews": hn_top,
293
+ "github": lambda: gh.search(f"created:>{day} stars:>20", limit=15, sort="stars"),
294
+ "news": lambda: news.techmeme(since=plan.since),
295
+ "polymarket": lambda: self.ctx.channel("polymarket").trending_hits(10),
296
+ "mastodon": mastodon_trends,
297
+ "lobsters": lambda: self._lobsters("", 25),
298
+ }
299
+ for s in plan.sources:
300
+ if s not in makers:
301
+ result.gaps[s] = "not used in discover mode"
302
+ names = [s for s in plan.sources if s in makers]
303
+ done = await asyncio.gather(*(asyncio.wait_for(makers[s](), DEFAULT_TIMEOUT_S) for s in names),
304
+ return_exceptions=True) # fmt: skip
305
+ items = []
306
+ for source, r in zip(names, done, strict=True):
307
+ if isinstance(r, BaseException):
308
+ if not isinstance(r, EXPECTED):
309
+ raise r
310
+ result.gaps[source] = f"{type(r).__name__}: {scrub(str(r))[:160]}"
311
+ continue
312
+ items += [Item(hit=_aware(h).model_copy(update={"source": source})) for h in r]
313
+ scored = score_items(items, "", plan.since, plan.until, plan.days, floor=0.0)
314
+ result.counts = dict(Counter(i.source for i in scored))
315
+ result.markets = [i for i in scored if i.source == "polymarket"]
316
+ result.clusters = cluster([i for i in scored if i.source != "polymarket"])
317
+ result.topics = rising_topics(result.clusters, plan.until)
318
+
319
+
320
+ _TOPIC_STOP = frozenset(
321
+ ["the", "a", "an", "and", "or", "of", "for", "to", "in", "on", "with", "by", "at", "from", "is", "are", "was", "be", "it", "its", "this", "that", "how", "why", "what", "show", "ask", "hn", "new", "you", "your", "we", "our", "my", "i", "can", "will", "not", "now", "just", "over", "after", "into", "about", "more", "than", "first", "one", "two", "us", "vs", "via", "using", "use", "get", "has", "have", "make", "made", "says", "said", "year", "years", "day", "days", "week", "launches", "launched", "introducing", "announces", "released", "release", "open", "source"]
322
+ ) # fmt: skip
323
+
324
+
325
+ _COMMON = frozenset(
326
+ [
327
+ "about",
328
+ "above",
329
+ "across",
330
+ "after",
331
+ "again",
332
+ "against",
333
+ "all",
334
+ "almost",
335
+ "also",
336
+ "always",
337
+ "among",
338
+ "another",
339
+ "any",
340
+ "anyone",
341
+ "anything",
342
+ "around",
343
+ "away",
344
+ "back",
345
+ "bad",
346
+ "because",
347
+ "been",
348
+ "before",
349
+ "behind",
350
+ "being",
351
+ "best",
352
+ "better",
353
+ "between",
354
+ "big",
355
+ "both",
356
+ "but",
357
+ "came",
358
+ "cannot",
359
+ "case",
360
+ "change",
361
+ "come",
362
+ "could",
363
+ "did",
364
+ "does",
365
+ "doing",
366
+ "done",
367
+ "down",
368
+ "during",
369
+ "each",
370
+ "early",
371
+ "even",
372
+ "ever",
373
+ "every",
374
+ "few",
375
+ "find",
376
+ "for",
377
+ "found",
378
+ "free",
379
+ "from",
380
+ "full",
381
+ "gets",
382
+ "give",
383
+ "given",
384
+ "going",
385
+ "gone",
386
+ "good",
387
+ "great",
388
+ "had",
389
+ "half",
390
+ "hand",
391
+ "hard",
392
+ "has",
393
+ "have",
394
+ "help",
395
+ "her",
396
+ "here",
397
+ "high",
398
+ "him",
399
+ "his",
400
+ "home",
401
+ "how",
402
+ "however",
403
+ "into",
404
+ "its",
405
+ "itself",
406
+ "just",
407
+ "keep",
408
+ "kind",
409
+ "know",
410
+ "large",
411
+ "last",
412
+ "later",
413
+ "least",
414
+ "less",
415
+ "let",
416
+ "life",
417
+ "like",
418
+ "little",
419
+ "long",
420
+ "look",
421
+ "lost",
422
+ "made",
423
+ "make",
424
+ "making",
425
+ "many",
426
+ "may",
427
+ "maybe",
428
+ "might",
429
+ "more",
430
+ "most",
431
+ "much",
432
+ "must",
433
+ "need",
434
+ "never",
435
+ "next",
436
+ "nice",
437
+ "nothing",
438
+ "now",
439
+ "off",
440
+ "often",
441
+ "old",
442
+ "once",
443
+ "only",
444
+ "other",
445
+ "our",
446
+ "out",
447
+ "over",
448
+ "own",
449
+ "part",
450
+ "people",
451
+ "place",
452
+ "point",
453
+ "put",
454
+ "real",
455
+ "really",
456
+ "right",
457
+ "run",
458
+ "said",
459
+ "same",
460
+ "say",
461
+ "see",
462
+ "seems",
463
+ "should",
464
+ "show",
465
+ "side",
466
+ "since",
467
+ "small",
468
+ "some",
469
+ "something",
470
+ "still",
471
+ "such",
472
+ "sure",
473
+ "take",
474
+ "than",
475
+ "that",
476
+ "the",
477
+ "their",
478
+ "them",
479
+ "then",
480
+ "there",
481
+ "these",
482
+ "they",
483
+ "thing",
484
+ "things",
485
+ "think",
486
+ "this",
487
+ "those",
488
+ "though",
489
+ "through",
490
+ "time",
491
+ "today",
492
+ "together",
493
+ "too",
494
+ "took",
495
+ "top",
496
+ "toward",
497
+ "try",
498
+ "two",
499
+ "under",
500
+ "until",
501
+ "upon",
502
+ "use",
503
+ "used",
504
+ "very",
505
+ "want",
506
+ "was",
507
+ "way",
508
+ "ways",
509
+ "well",
510
+ "went",
511
+ "were",
512
+ "what",
513
+ "when",
514
+ "where",
515
+ "whether",
516
+ "which",
517
+ "while",
518
+ "who",
519
+ "whole",
520
+ "why",
521
+ "will",
522
+ "with",
523
+ "within",
524
+ "without",
525
+ "work",
526
+ "works",
527
+ "world",
528
+ "would",
529
+ "wrong",
530
+ "year",
531
+ "years",
532
+ "yet",
533
+ "you",
534
+ "your",
535
+ "why",
536
+ "what's",
537
+ "it's",
538
+ "i'm",
539
+ "don't",
540
+ "doesn't",
541
+ "can't",
542
+ "won't",
543
+ "isn't",
544
+ "vs",
545
+ "via",
546
+ "new",
547
+ "now",
548
+ "how",
549
+ "ask",
550
+ "show",
551
+ "tell",
552
+ "hn",
553
+ "launch",
554
+ "launches",
555
+ "launched",
556
+ "introducing",
557
+ "announces",
558
+ "released",
559
+ "release",
560
+ "open",
561
+ "source",
562
+ "state",
563
+ "data",
564
+ "says",
565
+ "first",
566
+ "one",
567
+ "report",
568
+ "reports",
569
+ "amid",
570
+ "series",
571
+ ]
572
+ )
573
+
574
+
575
+ def entities(title: str) -> set[str]:
576
+ """Named things in a headline: Capitalised / MixedCase / ACRONYM / v2-style tokens and pairs."""
577
+ tokens = re.findall(r"[A-Za-z0-9][A-Za-z0-9+#.'\-]*[A-Za-z0-9+#]|[A-Za-z0-9]", title)
578
+ named = []
579
+ for i, tok in enumerate(tokens):
580
+ low = tok.lower()
581
+ if low in _COMMON or low in _TOPIC_STOP or len(tok) < 3 or not any(map(str.isalpha, tok)):
582
+ named.append(None)
583
+ continue
584
+ is_named = tok[0].isupper() and (i > 0 or not tok[1:].islower() or len(tokens) > 1)
585
+ is_named = is_named or any(ch.isdigit() for ch in tok) or tok.isupper()
586
+ named.append(low if is_named else None)
587
+ out = {t for t in named if t}
588
+ out |= {f"{a} {b}" for a, b in pairwise(named) if a and b}
589
+ return out
590
+
591
+
592
+ def rising_topics(clusters: list[Cluster], now: datetime, n: int = 10) -> list[dict]:
593
+ """Rank recurring title words/phrases by cross-source spread and engagement."""
594
+ weight: Counter = Counter()
595
+ where: dict[str, set] = {}
596
+ examples: dict[str, list[Cluster]] = {}
597
+ for c in clusters:
598
+ if c.sources == ["polymarket"]:
599
+ continue # trending markets are mostly tonight's games; they get their own list
600
+ for g in entities(c.title):
601
+ weight[g] += c.score
602
+ where.setdefault(g, set()).update(c.sources)
603
+ examples.setdefault(g, []).append(c)
604
+
605
+ def rank(g: str) -> float:
606
+ return weight[g] * (1 + 0.5 * (len(where[g]) - 1)) * (1.2 if " " in g else 1.0)
607
+
608
+ out, used, stories = [], [], []
609
+ for g in sorted(weight, key=lambda g: -rank(g)):
610
+ if len(examples[g]) < 2:
611
+ continue # a phrase from one story is that story (already in "Biggest stories")
612
+ if any(g in u or u in g for u in used):
613
+ continue
614
+ mine = {id(c) for c in examples[g]}
615
+ same = next((i for i, s in enumerate(stories) if len(mine & s) * 3 >= len(mine) * 2), None)
616
+ if same is not None:
617
+ if out[same]["topic"].count(" / ") < 2: # "gemini" + "argon": the same stories
618
+ out[same]["topic"] += f" / {g}"
619
+ used.append(g)
620
+ continue
621
+ used.append(g)
622
+ stories.append(mine)
623
+ ex = sorted(examples[g], key=lambda c: -c.score)[:3]
624
+ fresh = sum(1 for c in examples[g] if c.momentum(now) in ("new", "breaking"))
625
+ label = ("rising" if fresh * 2 >= len(examples[g])
626
+ else "hot" if len(where[g]) >= 3 else "steady") # fmt: skip
627
+ out.append({"topic": g, "mentions": len(examples[g]), "sources": sorted(where[g]),
628
+ "momentum": label,
629
+ "examples": [{"title": c.title, "url": c.lead.hit.url} for c in ex]}) # fmt: skip
630
+ if len(out) >= n:
631
+ break
632
+ return out