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,227 @@
1
+ """Render a ResearchResult as a cited Markdown brief, JSON, or a standalone HTML page."""
2
+
3
+ import html
4
+ import json
5
+ import re
6
+ from datetime import UTC, datetime
7
+
8
+ from agent_wire.compression.dam_formatter import relative_age
9
+ from agent_wire.research.engine import ResearchResult
10
+
11
+ MAX_CLUSTERS = {"quick": 8, "default": 12, "deep": 20}
12
+ _ENG_LABEL = (("score", "▲"), ("likes", "♥"), ("reposts", "↻"), ("comments", "💬"), ("stars", "★"),
13
+ ("views", "views "), ("volume24h", "$24h ")) # fmt: skip
14
+
15
+
16
+ def _num(n: int) -> str:
17
+ for size, suffix in ((1_000_000_000, "B"), (1_000_000, "M"), (1_000, "k")):
18
+ if n >= size:
19
+ return f"{n / size:.1f}".rstrip("0").rstrip(".") + suffix
20
+ return str(n)
21
+
22
+
23
+ def engagement_line(eng: dict[str, int]) -> str:
24
+ return " ".join(f"{label}{_num(eng[k])}" for k, label in _ENG_LABEL if eng.get(k))
25
+
26
+
27
+ def title_of(r: ResearchResult) -> str:
28
+ p = r.plan
29
+ if p.mode == "discover":
30
+ return f"What's trending · last {p.days} days"
31
+ if p.mode == "person":
32
+ return f"{p.github_user} on GitHub · last {p.days} days"
33
+ return f"{p.topic} · last {p.days} days"
34
+
35
+
36
+ def headline(r: ResearchResult) -> str:
37
+ if r.topics:
38
+ return ", ".join(t["topic"] for t in r.topics[:3])
39
+ return r.clusters[0].title if r.clusters else "no stories"
40
+
41
+
42
+ def _take_line(t) -> str:
43
+ who = f"@{t.comment.author_handle}" if t.comment.author_handle else "someone"
44
+ unit = "replies" if t.comment.score_kind == "replies" else "pts"
45
+ score = f" ({t.comment.score} {unit})" if t.comment.score else ""
46
+ return f" > {who}{score}: {' '.join(t.comment.body.split())[:240]}"
47
+
48
+
49
+ def to_markdown(r: ResearchResult, max_clusters: int | None = None) -> str:
50
+ p = r.plan
51
+ now = p.until
52
+ n = max_clusters or MAX_CLUSTERS[p.depth]
53
+ out = [f"# {title_of(r)}", ""]
54
+ srcs = ", ".join(f"{s} {c}" for s, c in sorted(r.counts.items(), key=lambda kv: -kv[1]))
55
+ if p.mode == "person":
56
+ out.append(f"Window {p.since.date()} → {p.until.date()}")
57
+ else:
58
+ out.append(f"Window {p.since.date()} → {p.until.date()} · {len(r.items)} items in "
59
+ f"{len(r.clusters)} stories · {srcs or 'no sources answered'}") # fmt: skip
60
+
61
+ if r.topics:
62
+ out += ["", "## Trending topics"]
63
+ for i, t in enumerate(r.topics, 1):
64
+ ex = " · ".join(f"[{e['title'][:70]}]({e['url']})" for e in t["examples"])
65
+ out.append(f"{i}. **{t['topic']}** · {t['momentum']} · {t['mentions']} stories on "
66
+ f"{', '.join(t['sources'])}\n {ex}") # fmt: skip
67
+
68
+ if r.subjects:
69
+ out += ["", "## Head to head", "", "| | items | heat | sources | top story |",
70
+ "|---|---|---|---|---|"] # fmt: skip
71
+ for s in r.subjects:
72
+ top = f"[{s['top']['title'][:60]}]({s['top']['url']})" if s["top"] else "-"
73
+ out.append(f"| **{s['subject']}** | {s['items']} | {_num(s['heat'])} | "
74
+ f"{', '.join(s['sources']) or '-'} | {top} |") # fmt: skip
75
+ if both := max((s["head_to_head"] for s in r.subjects), default=0):
76
+ out.append(f"\n{both} items discuss them side by side.")
77
+
78
+ if r.person:
79
+ out += ["", "## GitHub activity", r.person]
80
+
81
+ takes: dict[int, list] = {}
82
+ for t in r.takes:
83
+ takes.setdefault(t.cluster, []).append(t)
84
+ if r.clusters and p.mode != "person":
85
+ out += ["", "## Biggest stories" if p.mode == "discover" else "## Top stories"]
86
+ for idx, c in enumerate(r.clusters[:n]):
87
+ when = relative_age(c.first_seen, now) if c.first_seen else "undated"
88
+ bits = (", ".join(c.sources), engagement_line(c.engagement), when, c.momentum(now))
89
+ out.append(
90
+ f"{idx + 1}. **[{c.title}]({c.lead.hit.url})** · "
91
+ + " · ".join(b for b in bits if b)
92
+ )
93
+ seen = {c.lead.hit.url}
94
+ cites = []
95
+ for i in sorted(c.items, key=lambda i: -i.score):
96
+ if i.hit.url not in seen and len(cites) < 4:
97
+ seen.add(i.hit.url)
98
+ cites.append(f"[{i.source}]({i.hit.url})")
99
+ if c.link and c.link not in seen:
100
+ cites.append(f"[article]({c.link})")
101
+ if cites:
102
+ out.append(" also: " + " · ".join(cites))
103
+ # the platform's own blurb beats the start of a page (a repo's file list, a nav bar)
104
+ if snippet := (c.lead.hit.snippet or r.excerpts.get(idx) or "").strip():
105
+ out.append(" " + " ".join(snippet.split())[:280])
106
+ out += [_take_line(t) for t in takes.get(idx, [])]
107
+
108
+ markets = r.markets
109
+ if markets:
110
+ out += ["", "## Where the money is" if p.mode == "discover" else "## What the markets say"]
111
+ out += [f"- [{i.hit.title}]({i.hit.url}) · {i.hit.snippet} · {engagement_line(i.hit.engagement)}"
112
+ for i in markets[:4]] # fmt: skip
113
+
114
+ if r.library:
115
+ out += ["", "## From your library"]
116
+ out += [f"- {x['created_at'][:10]} · run #{x['id']} · {x['topic']}: {x['headline']}"
117
+ for x in r.library] # fmt: skip
118
+
119
+ if r.gaps or p.notes:
120
+ out += ["", "## Not covered"]
121
+ out += [f"- {s}: {why}" for s, why in sorted(r.gaps.items())]
122
+ out += [f"- {note}" for note in p.notes]
123
+ return "\n".join(out).strip()
124
+
125
+
126
+ def to_dict(r: ResearchResult) -> dict:
127
+ p = r.plan
128
+
129
+ def item(i):
130
+ h = i.hit
131
+ return {"url": h.url, "title": h.title, "source": h.source, "author": h.author, "link": h.link,
132
+ "timestamp": h.timestamp.isoformat() if h.timestamp else None,
133
+ "engagement": h.engagement, "score": i.score, "relevance": round(i.relevance, 3),
134
+ "freshness": round(i.freshness, 3), "snippet": h.snippet[:300]} # fmt: skip
135
+
136
+ return {
137
+ "topic": p.topic, "mode": p.mode, "depth": p.depth, "days": p.days,
138
+ "window": {"since": p.since.isoformat(), "until": p.until.isoformat()},
139
+ "created_at": r.created_at.isoformat(), "elapsed_ms": r.elapsed_ms, "run_id": r.run_id,
140
+ "counts": r.counts, "gaps": r.gaps, "subjects": r.subjects, "topics": r.topics,
141
+ "person": r.person, "library": r.library, "headline": headline(r),
142
+ "markets": [item(i) for i in r.markets],
143
+ "clusters": [{"title": c.title, "score": c.score, "momentum": c.momentum(p.until),
144
+ "sources": c.sources, "engagement": c.engagement, "link": c.link,
145
+ "excerpt": r.excerpts.get(idx),
146
+ "takes": [{"author": t.comment.author_handle, "score": t.comment.score,
147
+ "body": t.comment.body[:600], "url": t.url}
148
+ for t in r.takes if t.cluster == idx],
149
+ "items": [item(i) for i in c.items]}
150
+ for idx, c in enumerate(r.clusters)],
151
+ } # fmt: skip
152
+
153
+
154
+ def to_json(r: ResearchResult) -> str:
155
+ return json.dumps(to_dict(r), ensure_ascii=False, indent=2)
156
+
157
+
158
+ PAGE_CSS = """
159
+ :root{--bg:#fbfaf7;--fg:#1c1b19;--muted:#6b6862;--line:#e4e1da;--accent:#0b6e4f}
160
+ @media (prefers-color-scheme:dark){:root{--bg:#141413;--fg:#ecebe6;--muted:#9c998f;--line:#2c2b28;--accent:#4fc39a}}
161
+ body{background:var(--bg);color:var(--fg);font:16px/1.55 system-ui,sans-serif;margin:0 auto;max-width:860px;padding:24px 16px}
162
+ a{color:var(--accent)}h1{font-size:1.6rem;margin:.2em 0}h2{font-size:1.15rem;margin-top:2em;border-bottom:1px solid var(--line)}
163
+ blockquote{margin:.3em 0 .3em 1em;padding-left:.8em;border-left:3px solid var(--line);color:var(--muted)}
164
+ .meta{color:var(--muted);font-size:.9rem}table{border-collapse:collapse;width:100%;display:block;overflow-x:auto}
165
+ td,th{border-bottom:1px solid var(--line);padding:4px 6px;text-align:left}
166
+ """
167
+ _BOLD = re.compile(r"\*\*(.+?)\*\*")
168
+ _LINK = re.compile(r"\[([^\]]+)\]\((https?://[^)\s]+)\)")
169
+
170
+
171
+ def _inline(text: str) -> str:
172
+ text = html.escape(text, quote=False)
173
+ text = _BOLD.sub(r"<strong>\1</strong>", text)
174
+ return _LINK.sub(
175
+ lambda m: f'<a href="{html.escape(m[2])}" rel="noopener nofollow">{m[1]}</a>', text
176
+ )
177
+
178
+
179
+ def md_to_html(md: str) -> str:
180
+ """Renderer for the brief's own Markdown subset (headings, quotes, links, bold, tables)."""
181
+ out, in_table = [], False
182
+ for line in md.splitlines():
183
+ s = line.strip()
184
+ if s.startswith("|"):
185
+ if set(s) <= set("|-: "):
186
+ continue
187
+ if not in_table:
188
+ out.append("<table>")
189
+ in_table = True
190
+ out.append(
191
+ "<tr>"
192
+ + "".join(f"<td>{_inline(c.strip())}</td>" for c in s.strip("|").split("|"))
193
+ + "</tr>"
194
+ )
195
+ continue
196
+ if in_table:
197
+ out.append("</table>")
198
+ in_table = False
199
+ if s.startswith("## "):
200
+ out.append(f"<h2>{_inline(s[3:])}</h2>")
201
+ elif s.startswith("# "):
202
+ out.append(f"<h1>{_inline(s[2:])}</h1>")
203
+ elif s.startswith("> "):
204
+ out.append(f"<blockquote>{_inline(s[2:])}</blockquote>")
205
+ elif s:
206
+ out.append(f"<p>{_inline(s)}</p>")
207
+ if in_table:
208
+ out.append("</table>")
209
+ return "\n".join(out)
210
+
211
+
212
+ def page(markdown: str, title: str) -> str:
213
+ stamp = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
214
+ return (f"<!doctype html><html lang=en><head><meta charset=utf-8>"
215
+ f"<meta name=viewport content='width=device-width,initial-scale=1'>"
216
+ f"<title>{html.escape(title)}</title><style>{PAGE_CSS}</style></head><body>"
217
+ f"{md_to_html(markdown)}<p class=meta>Generated by agent-wire · {stamp}</p></body></html>") # fmt: skip
218
+
219
+
220
+ def render(r: ResearchResult, fmt: str = "md") -> str:
221
+ if fmt == "json":
222
+ return to_json(r)
223
+ if fmt == "html":
224
+ return page(to_markdown(r), title_of(r))
225
+ if fmt != "md":
226
+ raise ValueError("emit must be md, json or html")
227
+ return to_markdown(r)
@@ -0,0 +1,41 @@
1
+ """Daily / weekly digest across every watched topic, plus what is trending overall."""
2
+
3
+ from datetime import UTC, datetime
4
+
5
+ from agent_wire.research.brief import engagement_line
6
+
7
+ PERIODS = {"daily": 1, "weekly": 7}
8
+ TOP_PER_TOPIC = 5
9
+
10
+
11
+ async def digest(wire, period: str = "daily", *, trending: bool = True, save: bool = True) -> str:
12
+ if period not in PERIODS:
13
+ raise ValueError("period must be daily or weekly")
14
+ days = PERIODS[period]
15
+ stamp = datetime.now(UTC)
16
+ out = [f"# {period.title()} briefing · {stamp.date()}"]
17
+ watches = wire.store.watches()
18
+ if not watches and not trending:
19
+ return "no watched topics (wire watch add <topic>)"
20
+ for w in watches:
21
+ r, _, _ = await wire.research_result(w["topic"], days=days, platforms=w.get("sources"),
22
+ depth="quick", store=True) # fmt: skip
23
+ out += ["", f"## {w['topic']}"]
24
+ if not r.clusters:
25
+ out.append("- nothing in this window")
26
+ for c in r.clusters[:TOP_PER_TOPIC]:
27
+ out.append(f"- [{c.title}]({c.lead.hit.url}) · {', '.join(c.sources)} · "
28
+ f"{engagement_line(c.engagement)} · {c.momentum(r.plan.until)}") # fmt: skip
29
+ if trending:
30
+ r, _, _ = await wire.research_result("", days=days, depth="quick", store=False)
31
+ out += ["", "## Trending everywhere"]
32
+ out += [f"- **{t['topic']}** · {t['momentum']} · {', '.join(t['sources'])}"
33
+ for t in r.topics[:8]] # fmt: skip
34
+ text = "\n".join(out)
35
+ if save:
36
+ folder = wire.library.dir / "briefings"
37
+ folder.mkdir(parents=True, exist_ok=True)
38
+ path = folder / f"{stamp.date()}-{period}.md"
39
+ path.write_text(text, encoding="utf-8")
40
+ text += f"\n\n_saved to {path}_"
41
+ return text
@@ -0,0 +1,128 @@
1
+ """Cross-source dedup: one story told on HN, Reddit and Techmeme becomes one cluster."""
2
+
3
+ import re
4
+ from dataclasses import dataclass, field
5
+ from datetime import UTC, datetime, timedelta
6
+
7
+ from agent_wire.channels.tier1_global.search import url_key
8
+ from agent_wire.research.scoring import Item, raw_engagement
9
+
10
+ TITLE_JACCARD = 0.6
11
+ _WORD = re.compile(r"[a-z0-9]+")
12
+ _NOISE = frozenset(
13
+ [
14
+ "show",
15
+ "ask",
16
+ "hn",
17
+ "tell",
18
+ "the",
19
+ "a",
20
+ "an",
21
+ "of",
22
+ "to",
23
+ "in",
24
+ "on",
25
+ "for",
26
+ "and",
27
+ "is",
28
+ "are",
29
+ "with",
30
+ "by",
31
+ "at",
32
+ "from",
33
+ ]
34
+ )
35
+
36
+
37
+ @dataclass
38
+ class Cluster:
39
+ items: list[Item] = field(default_factory=list)
40
+
41
+ @property
42
+ def lead(self) -> Item:
43
+ return max(self.items, key=lambda i: i.score)
44
+
45
+ @property
46
+ def title(self) -> str:
47
+ return self.lead.hit.title
48
+
49
+ @property
50
+ def sources(self) -> list[str]:
51
+ return sorted({i.source for i in self.items})
52
+
53
+ @property
54
+ def score(self) -> float:
55
+ # agreement across platforms is the strongest signal there is
56
+ return round(self.lead.score * (1 + 0.25 * (len(self.sources) - 1)), 4)
57
+
58
+ @property
59
+ def engagement(self) -> dict[str, int]:
60
+ total: dict[str, int] = {}
61
+ for i in self.items:
62
+ for k, v in i.hit.engagement.items():
63
+ total[k] = total.get(k, 0) + v
64
+ return total
65
+
66
+ @property
67
+ def first_seen(self) -> datetime | None:
68
+ dates = [i.hit.timestamp for i in self.items if i.hit.timestamp]
69
+ return min(dates) if dates else None
70
+
71
+ @property
72
+ def link(self) -> str | None:
73
+ return next((i.hit.link for i in self.items if i.hit.link), None)
74
+
75
+ def momentum(self, now: datetime | None = None) -> str:
76
+ now = now or datetime.now(UTC)
77
+ first = self.first_seen
78
+ heat = sum(raw_engagement(i.hit) for i in self.items)
79
+ if first and now - first < timedelta(days=3):
80
+ return "breaking" if heat > 200 or len(self.sources) > 1 else "new"
81
+ if len(self.sources) >= 3 or heat > 2000:
82
+ return "hot"
83
+ return "steady"
84
+
85
+
86
+ def _title_words(title: str) -> frozenset[str]:
87
+ return frozenset(w for w in _WORD.findall(title.lower()) if w not in _NOISE)
88
+
89
+
90
+ def _jaccard(a: frozenset, b: frozenset) -> float:
91
+ return len(a & b) / len(a | b) if a and b else 0.0
92
+
93
+
94
+ def cluster(items: list[Item]) -> list[Cluster]:
95
+ """Union-find over shared URL, shared outbound link, or near-identical titles."""
96
+ parent = list(range(len(items)))
97
+
98
+ def find(i):
99
+ while parent[i] != i:
100
+ parent[i] = parent[parent[i]]
101
+ i = parent[i]
102
+ return i
103
+
104
+ def union(a, b):
105
+ parent[find(a)] = find(b)
106
+
107
+ by_key: dict[str, int] = {}
108
+ for idx, it in enumerate(items):
109
+ for u in (it.hit.url, it.hit.link):
110
+ if not u:
111
+ continue
112
+ key = url_key(u)
113
+ if key in by_key:
114
+ union(idx, by_key[key])
115
+ else:
116
+ by_key[key] = idx
117
+ words = [_title_words(i.hit.title) for i in items]
118
+ for a in range(len(items)):
119
+ if len(words[a]) < 4:
120
+ continue # short titles ("@user", "ZED and python") collide by accident
121
+ for b in range(a):
122
+ if len(words[b]) >= 4 and _jaccard(words[a], words[b]) >= TITLE_JACCARD:
123
+ union(a, b)
124
+
125
+ groups: dict[int, Cluster] = {}
126
+ for idx, it in enumerate(items):
127
+ groups.setdefault(find(idx), Cluster()).items.append(it)
128
+ return sorted(groups.values(), key=lambda c: -c.score)