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.
- agent_wire/__init__.py +1 -0
- agent_wire/__main__.py +3 -0
- agent_wire/assets/SKILL.md +26 -0
- agent_wire/assets/cursor_rule.mdc +13 -0
- agent_wire/assets/hermes_skill.md +28 -0
- agent_wire/assets/windsurf_rules.md +14 -0
- agent_wire/assets/wire-worker.md +20 -0
- agent_wire/auth/__init__.py +0 -0
- agent_wire/auth/browser_paths.py +143 -0
- agent_wire/auth/cookie_vault.py +181 -0
- agent_wire/auth/rate_limiter.py +26 -0
- agent_wire/cache/__init__.py +0 -0
- agent_wire/cache/ring_buffer.py +94 -0
- agent_wire/cache/sqlite_ledger.py +132 -0
- agent_wire/channels/__init__.py +41 -0
- agent_wire/channels/base.py +249 -0
- agent_wire/channels/tier1_global/__init__.py +0 -0
- agent_wire/channels/tier1_global/github.py +371 -0
- agent_wire/channels/tier1_global/hackernews.py +135 -0
- agent_wire/channels/tier1_global/reddit.py +371 -0
- agent_wire/channels/tier1_global/rss.py +129 -0
- agent_wire/channels/tier1_global/search.py +160 -0
- agent_wire/channels/tier1_global/stackoverflow.py +94 -0
- agent_wire/channels/tier1_global/web.py +98 -0
- agent_wire/channels/tier1_global/wikipedia.py +91 -0
- agent_wire/channels/tier1_global/x_twitter.py +380 -0
- agent_wire/channels/tier1_global/youtube.py +223 -0
- agent_wire/channels/tier2_pro/__init__.py +0 -0
- agent_wire/channels/tier2_pro/archive_bypass.py +66 -0
- agent_wire/channels/tier2_pro/arxiv_papers.py +220 -0
- agent_wire/channels/tier2_pro/devto.py +114 -0
- agent_wire/channels/tier2_pro/fediverse.py +181 -0
- agent_wire/channels/tier2_pro/hubs.py +161 -0
- agent_wire/channels/tier2_pro/linkedin.py +146 -0
- agent_wire/channels/tier2_pro/markets.py +207 -0
- agent_wire/channels/tier2_pro/meta_social.py +104 -0
- agent_wire/channels/tier2_pro/news.py +173 -0
- agent_wire/channels/tier2_pro/polymarket.py +105 -0
- agent_wire/channels/tier2_pro/publishing.py +84 -0
- agent_wire/channels/tier2_pro/stocktwits.py +79 -0
- agent_wire/channels/tier3_regional/bilibili.py +171 -0
- agent_wire/channels/tier3_regional/boss_zhipin.py +35 -0
- agent_wire/channels/tier3_regional/douyin_tiktok.py +85 -0
- agent_wire/channels/tier3_regional/v2ex.py +70 -0
- agent_wire/channels/tier3_regional/wechat.py +67 -0
- agent_wire/channels/tier3_regional/xiaohongshu.py +58 -0
- agent_wire/channels/tier3_regional/xiaoyuzhou.py +70 -0
- agent_wire/channels/tier3_regional/xueqiu.py +77 -0
- agent_wire/channels/tier3_regional/zhihu.py +57 -0
- agent_wire/cli.py +403 -0
- agent_wire/compression/__init__.py +0 -0
- agent_wire/compression/anchor_slicer.py +45 -0
- agent_wire/compression/bm25_slicer.py +151 -0
- agent_wire/compression/dam_formatter.py +74 -0
- agent_wire/compression/url_compactor.py +45 -0
- agent_wire/config.py +52 -0
- agent_wire/engine/__init__.py +0 -0
- agent_wire/engine/challenge.py +58 -0
- agent_wire/engine/http_pool.py +136 -0
- agent_wire/engine/stealth_browser.py +90 -0
- agent_wire/engine/waterfall.py +159 -0
- agent_wire/firewall/__init__.py +0 -0
- agent_wire/firewall/inbound_shield.py +116 -0
- agent_wire/firewall/outbound_privacy.py +120 -0
- agent_wire/installers/__init__.py +0 -0
- agent_wire/installers/client_installer.py +315 -0
- agent_wire/media/__init__.py +0 -0
- agent_wire/media/audio_transcriber.py +77 -0
- agent_wire/media/subtitle_cleaner.py +102 -0
- agent_wire/models.py +60 -0
- agent_wire/research/__init__.py +1 -0
- agent_wire/research/brief.py +227 -0
- agent_wire/research/briefing.py +41 -0
- agent_wire/research/cluster.py +128 -0
- agent_wire/research/engine.py +632 -0
- agent_wire/research/library.py +74 -0
- agent_wire/research/planner.py +232 -0
- agent_wire/research/scoring.py +89 -0
- agent_wire/research/store.py +149 -0
- agent_wire/research/watch.py +70 -0
- agent_wire/router.py +107 -0
- agent_wire/server.py +468 -0
- agent_wire_cli-0.2.1.dist-info/METADATA +258 -0
- agent_wire_cli-0.2.1.dist-info/RECORD +87 -0
- agent_wire_cli-0.2.1.dist-info/WHEEL +4 -0
- agent_wire_cli-0.2.1.dist-info/entry_points.txt +4 -0
- agent_wire_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Xiaoyuzhou (小宇宙) podcasts: episode show notes, audio URL, optional transcript; podcast feeds."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from agent_wire.channels.base import (
|
|
7
|
+
Channel,
|
|
8
|
+
WireUnsupported,
|
|
9
|
+
html_to_markdown,
|
|
10
|
+
listing,
|
|
11
|
+
script_json,
|
|
12
|
+
ts,
|
|
13
|
+
)
|
|
14
|
+
from agent_wire.media import audio_transcriber
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def page_props(html: str) -> dict:
|
|
18
|
+
data = script_json(html, "script#__NEXT_DATA__") or {}
|
|
19
|
+
return (data.get("props") or {}).get("pageProps") or {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_episode(props: dict) -> dict:
|
|
23
|
+
ep = props.get("episode") or {}
|
|
24
|
+
if not ep:
|
|
25
|
+
raise WireUnsupported("xiaoyuzhou episode data missing")
|
|
26
|
+
notes = ep.get("shownotes") or ep.get("description") or ""
|
|
27
|
+
audio = (ep.get("enclosure") or {}).get("url") or (ep.get("media") or {}).get("source", {}).get(
|
|
28
|
+
"url"
|
|
29
|
+
)
|
|
30
|
+
return {"title": ep.get("title"), "author_handle": (ep.get("podcast") or {}).get("title"),
|
|
31
|
+
"timestamp": ts(ep.get("pubDate")), "body": html_to_markdown(notes) if "<" in notes else notes,
|
|
32
|
+
"metadata": {"length": f"{(ep.get('duration') or 0) // 60}m", "plays": ep.get("playCount"),
|
|
33
|
+
"comments": ep.get("commentCount"), "audio": audio}} # fmt: skip
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class XiaoyuzhouChannel(Channel):
|
|
37
|
+
name = "xiaoyuzhou"
|
|
38
|
+
|
|
39
|
+
async def read(self, url, *, query=None, section=None, include_comments=True):
|
|
40
|
+
if re.search(r"/podcast/", urlsplit(url).path):
|
|
41
|
+
return await self.feed(url)
|
|
42
|
+
f = await self.fetch(url, archive=False)
|
|
43
|
+
info = parse_episode(page_props(f.text))
|
|
44
|
+
audio = info["metadata"].get("audio")
|
|
45
|
+
if audio and audio_transcriber.enabled():
|
|
46
|
+
try:
|
|
47
|
+
transcript = await audio_transcriber.transcribe(self.ctx.pool, audio)
|
|
48
|
+
info["body"] += f"\n\n## Transcript\n{transcript}"
|
|
49
|
+
except audio_transcriber.TranscriptionUnavailable as e:
|
|
50
|
+
info["body"] += f"\n\n_Transcript unavailable: {e}_"
|
|
51
|
+
return self.doc(f, url, **info)
|
|
52
|
+
|
|
53
|
+
async def feed(self, target, *, sort="hot", limit=10):
|
|
54
|
+
url = (
|
|
55
|
+
target
|
|
56
|
+
if target.startswith("http")
|
|
57
|
+
else f"https://www.xiaoyuzhoufm.com/podcast/{target}"
|
|
58
|
+
)
|
|
59
|
+
f = await self.fetch(url, archive=False)
|
|
60
|
+
props = page_props(f.text)
|
|
61
|
+
pod = props.get("podcast") or {}
|
|
62
|
+
episodes = pod.get("episodes") or props.get("episodes") or []
|
|
63
|
+
items = [{"title": e.get("title"), "url": f"https://www.xiaoyuzhoufm.com/episode/{e.get('eid')}",
|
|
64
|
+
"meta": [(e.get("pubDate") or "")[:10], f"{(e.get('duration') or 0) // 60}m"],
|
|
65
|
+
"snippet": e.get("description") or ""} for e in episodes[:limit]] # fmt: skip
|
|
66
|
+
return self.doc(f, url, title=pod.get("title") or "Xiaoyuzhou podcast", author_handle=pod.get("author"),
|
|
67
|
+
body=(pod.get("brief") or "") + "\n\n" + listing(items)) # fmt: skip
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
CHANNEL = XiaoyuzhouChannel
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Xueqiu (雪球): stock quotes and hot discussions, after priming the anonymous xq_a_token cookie."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import quote, urlsplit
|
|
5
|
+
|
|
6
|
+
from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, ts
|
|
7
|
+
from agent_wire.engine.http_pool import Fetched
|
|
8
|
+
from agent_wire.engine.waterfall import WireFetchError
|
|
9
|
+
from agent_wire.firewall.outbound_privacy import parse_json
|
|
10
|
+
from agent_wire.models import WireComment
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_quote(data: dict) -> dict:
|
|
14
|
+
q = (data.get("data") or {}).get("quote") or {}
|
|
15
|
+
if not q:
|
|
16
|
+
raise WireUnsupported("xueqiu returned no quote (unknown symbol?)")
|
|
17
|
+
lines = [f"Price: {q.get('current')} {q.get('currency') or ''} ({q.get('percent'):+}%)" if q.get("percent") is not None
|
|
18
|
+
else f"Price: {q.get('current')}",
|
|
19
|
+
f"52W: {q.get('high52w')} / {q.get('low52w')} · P/E (TTM): {q.get('pe_ttm')} · Market cap: {q.get('market_capital')}"] # fmt: skip
|
|
20
|
+
return {"title": f"{q.get('name')} ({q.get('symbol')})", "body": "\n".join(lines),
|
|
21
|
+
"metadata": {"exchange": q.get("exchange")}} # fmt: skip
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def parse_statuses(data: dict) -> list[WireComment]:
|
|
25
|
+
return [WireComment(id=str(s.get("id")), body=html_to_markdown(s.get("text") or s.get("description") or ""),
|
|
26
|
+
author_handle=(s.get("user") or {}).get("screen_name"), score=s.get("like_count") or 0,
|
|
27
|
+
timestamp=ts(s.get("created_at")))
|
|
28
|
+
for s in data.get("list", [])] # fmt: skip
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class XueqiuChannel(Channel):
|
|
32
|
+
name = "xueqiu"
|
|
33
|
+
|
|
34
|
+
def __init__(self, ctx):
|
|
35
|
+
super().__init__(ctx)
|
|
36
|
+
self._primed = False
|
|
37
|
+
|
|
38
|
+
async def _get(self, url: str) -> Fetched:
|
|
39
|
+
if not self._primed: # anonymous visitor cookie, kept in the shared chrome session
|
|
40
|
+
await self.ctx.pool.get("https://xueqiu.com/", impersonate="chrome")
|
|
41
|
+
self._primed = True
|
|
42
|
+
f = await self.ctx.pool.get(
|
|
43
|
+
url, impersonate="chrome", headers={"Referer": "https://xueqiu.com/"}
|
|
44
|
+
)
|
|
45
|
+
if not f.ok:
|
|
46
|
+
raise WireFetchError(
|
|
47
|
+
f"xueqiu: HTTP {f.status}; its WAF needs a JS challenge: install the stealth extra "
|
|
48
|
+
"or run `wire auth login xueqiu.com`"
|
|
49
|
+
)
|
|
50
|
+
return f
|
|
51
|
+
|
|
52
|
+
async def _stock(self, url: str, symbol: str, include_comments: bool):
|
|
53
|
+
f = await self._get(
|
|
54
|
+
f"https://stock.xueqiu.com/v5/stock/quote.json?symbol={quote(symbol)}&extend=detail"
|
|
55
|
+
)
|
|
56
|
+
info = parse_quote(parse_json(f.content))
|
|
57
|
+
if include_comments:
|
|
58
|
+
try:
|
|
59
|
+
s = await self._get("https://xueqiu.com/query/v1/symbol/search/status.json?count=15&comment=0"
|
|
60
|
+
f"&symbol={quote(symbol)}&hl=0&source=all&sort=alpha&page=1") # fmt: skip
|
|
61
|
+
info["comments"] = parse_statuses(parse_json(s.content))
|
|
62
|
+
except (WireFetchError, ValueError):
|
|
63
|
+
pass
|
|
64
|
+
return self.doc(f, url, **info)
|
|
65
|
+
|
|
66
|
+
async def read(self, url, *, query=None, section=None, include_comments=True):
|
|
67
|
+
m = re.search(r"/S/([\w.]+)", urlsplit(url).path)
|
|
68
|
+
if not m:
|
|
69
|
+
return await self.ctx.channel("web").read(url, query=query, section=section)
|
|
70
|
+
return await self._stock(url, m[1].upper(), include_comments)
|
|
71
|
+
|
|
72
|
+
async def feed(self, target, *, sort="hot", limit=10):
|
|
73
|
+
sym = target.strip().upper()
|
|
74
|
+
return await self._stock(f"https://xueqiu.com/S/{sym}", sym, True)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
CHANNEL = XueqiuChannel
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Zhihu: questions with top answers, single answers and zhuanlan articles from js-initialData."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import urlsplit
|
|
5
|
+
|
|
6
|
+
from agent_wire.channels.base import Channel, WireUnsupported, html_to_markdown, script_json, ts
|
|
7
|
+
from agent_wire.models import WireComment
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def parse_initial(state: dict, kind: str, ident: str, answer_id: str | None = None) -> dict:
|
|
11
|
+
entities = (state.get("initialState") or {}).get("entities") or {}
|
|
12
|
+
if kind == "article":
|
|
13
|
+
a = (entities.get("articles") or {}).get(ident)
|
|
14
|
+
if not a:
|
|
15
|
+
raise WireUnsupported("zhihu article not in page state (login wall?)")
|
|
16
|
+
return {"title": a.get("title"), "author_handle": (a.get("author") or {}).get("name"),
|
|
17
|
+
"timestamp": ts(a.get("created")), "score": a.get("voteupCount"),
|
|
18
|
+
"body": html_to_markdown(a.get("content") or "")} # fmt: skip
|
|
19
|
+
q = (entities.get("questions") or {}).get(ident) or {}
|
|
20
|
+
answers = list((entities.get("answers") or {}).values())
|
|
21
|
+
if answer_id:
|
|
22
|
+
answers = [a for a in answers if str(a.get("id")) == answer_id] or answers
|
|
23
|
+
if not q and not answers:
|
|
24
|
+
raise WireUnsupported("zhihu question not in page state (login wall?)")
|
|
25
|
+
comments = [
|
|
26
|
+
WireComment(id=str(a.get("id")), body=html_to_markdown(a.get("content") or a.get("excerpt") or ""),
|
|
27
|
+
author_handle=(a.get("author") or {}).get("name"), score=a.get("voteupCount") or 0,
|
|
28
|
+
timestamp=ts(a.get("createdTime")))
|
|
29
|
+
for a in answers
|
|
30
|
+
] # fmt: skip
|
|
31
|
+
return {"title": q.get("title"), "body": html_to_markdown(q.get("detail") or ""), "comments": comments,
|
|
32
|
+
"metadata": {"answers": q.get("answerCount"), "followers": q.get("followerCount"),
|
|
33
|
+
"views": q.get("visitCount")}} # fmt: skip
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class ZhihuChannel(Channel):
|
|
37
|
+
name = "zhihu"
|
|
38
|
+
|
|
39
|
+
async def read(self, url, *, query=None, section=None, include_comments=True):
|
|
40
|
+
p = urlsplit(url)
|
|
41
|
+
if (p.hostname or "").startswith("zhuanlan") and (m := re.search(r"/p/(\d+)", p.path)):
|
|
42
|
+
kind, ident, answer = "article", m[1], None
|
|
43
|
+
elif m := re.search(r"/question/(\d+)(?:/answer/(\d+))?", p.path):
|
|
44
|
+
kind, ident, answer = "question", m[1], m[2]
|
|
45
|
+
else:
|
|
46
|
+
return await self.ctx.channel("web").read(url, query=query, section=section)
|
|
47
|
+
f = await self.fetch(url, archive=False)
|
|
48
|
+
state = script_json(f.text, "script#js-initialData") or {}
|
|
49
|
+
return self.doc(f, url, **parse_initial(state, kind, ident, answer))
|
|
50
|
+
|
|
51
|
+
async def search(self, query, *, limit=5, sort="relevance"):
|
|
52
|
+
raise WireUnsupported(
|
|
53
|
+
"zhihu search needs its signed API; use platforms=['web'] with 'site:zhihu.com'"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
CHANNEL = ZhihuChannel
|
agent_wire/cli.py
ADDED
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""`wire` CLI: serve the MCP server, run the tools from a shell, install into clients, manage logins."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from agent_wire import __version__
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
no_args_is_help=True,
|
|
15
|
+
add_completion=False,
|
|
16
|
+
help="agent-wire: agents wired to the live internet.",
|
|
17
|
+
)
|
|
18
|
+
auth_app = typer.Typer(no_args_is_help=True, help="Local logins for wire_me and login-only sites.")
|
|
19
|
+
app.add_typer(auth_app, name="auth")
|
|
20
|
+
watch_app = typer.Typer(no_args_is_help=True, help="Watched topics: re-run and report what's new.")
|
|
21
|
+
app.add_typer(watch_app, name="watch")
|
|
22
|
+
library_app = typer.Typer(no_args_is_help=True, help="Saved research briefs: search, list, site.")
|
|
23
|
+
app.add_typer(library_app, name="library")
|
|
24
|
+
|
|
25
|
+
Profile = Annotated[str, typer.Option("--profile", help="standard, or local for small-VRAM models")]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utf8() -> None:
|
|
29
|
+
# Windows consoles default to a legacy code page; output contains ★ ♥ · and CJK text.
|
|
30
|
+
for stream in (sys.stdout, sys.stderr):
|
|
31
|
+
if hasattr(stream, "reconfigure"):
|
|
32
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run(profile: str, fn) -> None:
|
|
36
|
+
from agent_wire.server import Wire
|
|
37
|
+
|
|
38
|
+
_utf8()
|
|
39
|
+
|
|
40
|
+
async def go():
|
|
41
|
+
wire = Wire(profile)
|
|
42
|
+
try:
|
|
43
|
+
return await fn(wire)
|
|
44
|
+
finally:
|
|
45
|
+
await wire.close()
|
|
46
|
+
|
|
47
|
+
typer.echo(asyncio.run(go()))
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command()
|
|
51
|
+
def serve(
|
|
52
|
+
profile: Profile = "standard",
|
|
53
|
+
transport: Annotated[str, typer.Option(help="stdio, http or sse")] = "stdio",
|
|
54
|
+
port: int = 8765,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Run the MCP server (stdio by default)."""
|
|
57
|
+
from agent_wire.server import create_server
|
|
58
|
+
|
|
59
|
+
server = create_server(profile)
|
|
60
|
+
if transport == "stdio":
|
|
61
|
+
server.run(show_banner=False)
|
|
62
|
+
else:
|
|
63
|
+
server.run(transport=transport, port=port, show_banner=False)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.command()
|
|
67
|
+
def read(
|
|
68
|
+
urls: list[str],
|
|
69
|
+
query: Annotated[str | None, typer.Option("--query", "-q")] = None,
|
|
70
|
+
section: str | None = None,
|
|
71
|
+
no_comments: bool = False,
|
|
72
|
+
max_tokens: int | None = None,
|
|
73
|
+
force: bool = False,
|
|
74
|
+
profile: Profile = "standard",
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Read one or more URLs."""
|
|
77
|
+
_run(profile, lambda w: w.read(urls, query, section, not no_comments, max_tokens, force))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@app.command()
|
|
81
|
+
def search(
|
|
82
|
+
query: str,
|
|
83
|
+
platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
|
|
84
|
+
limit: int = 5,
|
|
85
|
+
sort: str = "relevance",
|
|
86
|
+
expand: Annotated[int | None, typer.Option(help="top hits to auto-read")] = None,
|
|
87
|
+
max_tokens: int | None = None,
|
|
88
|
+
profile: Profile = "standard",
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Search the web or platforms (reads the top hits too)."""
|
|
91
|
+
_run(profile, lambda w: w.search(query, platform or ["web"], limit, sort, expand, max_tokens))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@app.command()
|
|
95
|
+
def feed(
|
|
96
|
+
platform: str, target: str, sort: str = "hot", limit: int = 10, profile: Profile = "standard"
|
|
97
|
+
) -> None:
|
|
98
|
+
"""A subreddit, @user timeline, trending repos, market watchlist or RSS feed."""
|
|
99
|
+
_run(profile, lambda w: w.feed(platform, target, sort, limit))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command()
|
|
103
|
+
def me(
|
|
104
|
+
platform: str, target: str = "latest_posts", limit: int = 5, profile: Profile = "standard"
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Your own activity: latest_posts, likes, bookmarks, notifications, stats."""
|
|
107
|
+
_run(profile, lambda w: w.me(platform, target, limit))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def status(refresh: bool = False, profile: Profile = "standard") -> None:
|
|
112
|
+
"""Channel health, sessions and route cache."""
|
|
113
|
+
_run(profile, lambda w: w.status(refresh))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def install(
|
|
118
|
+
client: Annotated[str, typer.Option("--client", "-c")] = "auto",
|
|
119
|
+
profile: Profile = "standard",
|
|
120
|
+
global_: Annotated[
|
|
121
|
+
bool, typer.Option("--global", help="user-wide config instead of this project")
|
|
122
|
+
] = False,
|
|
123
|
+
project: Annotated[Path, typer.Option(help="project root for project-scoped clients")] = Path(
|
|
124
|
+
"."
|
|
125
|
+
),
|
|
126
|
+
dry_run: bool = False,
|
|
127
|
+
yes: Annotated[bool, typer.Option("--yes", "-y", help="don't ask before writing")] = False,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Install into agent clients (auto, all, claude-code, codex, cursor, ...)."""
|
|
130
|
+
from agent_wire.installers.client_installer import detect
|
|
131
|
+
from agent_wire.installers.client_installer import install as do_install
|
|
132
|
+
|
|
133
|
+
_utf8()
|
|
134
|
+
if client == "auto" and not dry_run and not yes:
|
|
135
|
+
found = detect(project.resolve(), Path.home())
|
|
136
|
+
typer.echo(f"detected clients: {', '.join(found)}")
|
|
137
|
+
if not typer.confirm("write agent-wire into all of them?", default=True):
|
|
138
|
+
typer.echo("nothing written; pick one with --client <name>")
|
|
139
|
+
raise typer.Exit(1)
|
|
140
|
+
try:
|
|
141
|
+
results = do_install(client, project=project.resolve(), home=Path.home(), profile=profile,
|
|
142
|
+
global_=global_, dry_run=dry_run) # fmt: skip
|
|
143
|
+
except ValueError as e:
|
|
144
|
+
typer.echo(f"error: {e}", err=True)
|
|
145
|
+
raise typer.Exit(2) from e
|
|
146
|
+
for r in results:
|
|
147
|
+
typer.echo(f"{r.client}:")
|
|
148
|
+
for f in r.files:
|
|
149
|
+
typer.echo(f" {'would write' if dry_run else 'wrote'} {f}")
|
|
150
|
+
for note in r.notes:
|
|
151
|
+
typer.echo(f" note: {note}")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@app.command()
|
|
155
|
+
def research(
|
|
156
|
+
topic: Annotated[
|
|
157
|
+
str, typer.Argument(help="topic, 'A vs B', or empty for what's trending")
|
|
158
|
+
] = "",
|
|
159
|
+
days: int = 30,
|
|
160
|
+
platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
|
|
161
|
+
mode: Annotated[str, typer.Option(help="auto, topic, compare, discover, person")] = "auto",
|
|
162
|
+
depth: Annotated[str, typer.Option(help="quick, default, deep")] = "default",
|
|
163
|
+
github_user: Annotated[str | None, typer.Option(help="add this GitHub user's activity")] = None,
|
|
164
|
+
as_of: Annotated[str | None, typer.Option(help="end of the window, YYYY-MM-DD")] = None,
|
|
165
|
+
emit: Annotated[str, typer.Option(help="md, json or html")] = "md",
|
|
166
|
+
output: Annotated[
|
|
167
|
+
Path | None, typer.Option("--output", "-o", help="write to this file")
|
|
168
|
+
] = None,
|
|
169
|
+
save_dir: Annotated[
|
|
170
|
+
Path | None, typer.Option(help="write <slug>.<ext> into this folder")
|
|
171
|
+
] = None,
|
|
172
|
+
store: Annotated[bool, typer.Option(help="save the run to the local library")] = False,
|
|
173
|
+
preflight: Annotated[bool, typer.Option(help="show the plan; no network")] = False,
|
|
174
|
+
max_tokens: int | None = None,
|
|
175
|
+
profile: Profile = "standard",
|
|
176
|
+
) -> None:
|
|
177
|
+
"""What people said about a topic in the last N days, across sites, as one cited brief."""
|
|
178
|
+
from agent_wire.server import Wire
|
|
179
|
+
|
|
180
|
+
_utf8()
|
|
181
|
+
|
|
182
|
+
async def go():
|
|
183
|
+
wire = Wire(profile)
|
|
184
|
+
try:
|
|
185
|
+
return await wire.research(topic, days, platform, mode, depth, as_of, github_user, emit,
|
|
186
|
+
store, preflight, max_tokens or 100_000) # fmt: skip
|
|
187
|
+
finally:
|
|
188
|
+
await wire.close()
|
|
189
|
+
|
|
190
|
+
text = asyncio.run(go())
|
|
191
|
+
target = output
|
|
192
|
+
if save_dir and not target:
|
|
193
|
+
slug = re.sub(r"[^a-z0-9]+", "-", (topic or "trending").lower()).strip("-")[:60] or "brief"
|
|
194
|
+
target = save_dir / f"{slug}-{days}d.{emit}"
|
|
195
|
+
if target:
|
|
196
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
target.write_text(text, encoding="utf-8")
|
|
198
|
+
typer.echo(f"wrote {target}")
|
|
199
|
+
else:
|
|
200
|
+
typer.echo(text)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _wire_call(profile: str, fn):
|
|
204
|
+
"""Run fn(wire) with a Wire that is always closed; print the returned text."""
|
|
205
|
+
from agent_wire.server import Wire
|
|
206
|
+
|
|
207
|
+
_utf8()
|
|
208
|
+
|
|
209
|
+
async def go():
|
|
210
|
+
wire = Wire(profile)
|
|
211
|
+
try:
|
|
212
|
+
return await fn(wire)
|
|
213
|
+
finally:
|
|
214
|
+
await wire.close()
|
|
215
|
+
|
|
216
|
+
typer.echo(asyncio.run(go()))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@watch_app.command("add")
|
|
220
|
+
def watch_add(
|
|
221
|
+
topic: str,
|
|
222
|
+
days: int = 7,
|
|
223
|
+
platform: Annotated[list[str] | None, typer.Option("--platform", "-p")] = None,
|
|
224
|
+
webhook: Annotated[
|
|
225
|
+
str | None, typer.Option(help="POST new findings (JSON) to this URL")
|
|
226
|
+
] = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Watch a topic; `wire watch run` reports what appeared since the last run."""
|
|
229
|
+
from agent_wire.research.planner import normalize_sources
|
|
230
|
+
from agent_wire.research.watch import check_webhook
|
|
231
|
+
|
|
232
|
+
try:
|
|
233
|
+
sources = normalize_sources(platform)
|
|
234
|
+
check_webhook(webhook)
|
|
235
|
+
except ValueError as e:
|
|
236
|
+
typer.echo(f"error: {e}", err=True)
|
|
237
|
+
raise typer.Exit(2) from e
|
|
238
|
+
|
|
239
|
+
async def add(w):
|
|
240
|
+
w.store.add_watch(topic, days, ",".join(sources) if sources else None, webhook)
|
|
241
|
+
return (
|
|
242
|
+
f"watching {topic!r} (last {days} days"
|
|
243
|
+
+ (f", webhook {webhook}" if webhook else "")
|
|
244
|
+
+ ")"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
_wire_call("standard", add)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@watch_app.command("list")
|
|
251
|
+
def watch_list() -> None:
|
|
252
|
+
"""Watched topics."""
|
|
253
|
+
|
|
254
|
+
async def ls(w):
|
|
255
|
+
rows = w.store.watches()
|
|
256
|
+
return (
|
|
257
|
+
"\n".join(
|
|
258
|
+
f"{r['topic']} · {r['days']}d · {r['sources'] or 'default sources'}"
|
|
259
|
+
+ (f" · webhook {r['webhook']}" if r["webhook"] else "")
|
|
260
|
+
+ (f" · last run #{r['last_run_id']}" if r["last_run_id"] else "")
|
|
261
|
+
for r in rows
|
|
262
|
+
)
|
|
263
|
+
or "no watched topics"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
_wire_call("standard", ls)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@watch_app.command("remove")
|
|
270
|
+
def watch_remove(topic: str) -> None:
|
|
271
|
+
"""Stop watching a topic."""
|
|
272
|
+
|
|
273
|
+
async def rm(w):
|
|
274
|
+
return f"removed {topic!r}" if w.store.remove_watch(topic) else f"not watching {topic!r}"
|
|
275
|
+
|
|
276
|
+
_wire_call("standard", rm)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
@watch_app.command("run")
|
|
280
|
+
def watch_run(
|
|
281
|
+
topic: Annotated[str | None, typer.Argument(help="one topic, or all")] = None,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Re-run watched topics and show only new findings (schedule this with cron / Task Scheduler)."""
|
|
284
|
+
from agent_wire.research.watch import run_watches
|
|
285
|
+
|
|
286
|
+
_wire_call("standard", lambda w: run_watches(w, topic))
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@app.command()
|
|
290
|
+
def brief(
|
|
291
|
+
period: Annotated[str, typer.Argument(help="daily or weekly")] = "daily",
|
|
292
|
+
no_trending: bool = False,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Digest across every watched topic, plus what's trending; saved under the library."""
|
|
295
|
+
from agent_wire.research.briefing import digest
|
|
296
|
+
|
|
297
|
+
_wire_call("standard", lambda w: digest(w, period, trending=not no_trending))
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@library_app.command("search")
|
|
301
|
+
def library_search(query: str, limit: int = 10) -> None:
|
|
302
|
+
"""Offline search over saved briefs."""
|
|
303
|
+
|
|
304
|
+
async def s(w):
|
|
305
|
+
return w.library.search_markdown(query, limit)
|
|
306
|
+
|
|
307
|
+
_wire_call("standard", s)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@library_app.command("list")
|
|
311
|
+
def library_list(limit: int = 20) -> None:
|
|
312
|
+
"""Saved runs, newest first."""
|
|
313
|
+
|
|
314
|
+
async def ls(w):
|
|
315
|
+
rows = w.store.runs(limit)
|
|
316
|
+
return "\n".join(f"#{r['id']} · {r['created_at'][:16]} · {r['topic']} ({r['mode']}, {r['days']}d)"
|
|
317
|
+
f" · {r['headline']}" for r in rows) or "library is empty (research --store)" # fmt: skip
|
|
318
|
+
|
|
319
|
+
_wire_call("standard", ls)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@library_app.command("show")
|
|
323
|
+
def library_show(run_id: int) -> None:
|
|
324
|
+
"""Print a saved brief."""
|
|
325
|
+
|
|
326
|
+
async def show(w):
|
|
327
|
+
run = w.store.run(run_id)
|
|
328
|
+
return run["markdown"] if run else f"no run #{run_id}"
|
|
329
|
+
|
|
330
|
+
_wire_call("standard", show)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@library_app.command("feed")
|
|
334
|
+
def library_feed() -> None:
|
|
335
|
+
"""Build WIRE_HOME/library/index.html, feed.xml (Atom) and one page per run."""
|
|
336
|
+
|
|
337
|
+
async def build(w):
|
|
338
|
+
out = w.library.build()
|
|
339
|
+
return f"wrote {out / 'index.html'} and {out / 'feed.xml'}"
|
|
340
|
+
|
|
341
|
+
_wire_call("standard", build)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@app.command()
|
|
345
|
+
def version() -> None:
|
|
346
|
+
"""Print the version."""
|
|
347
|
+
typer.echo(__version__)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@auth_app.command("login")
|
|
351
|
+
def auth_login(
|
|
352
|
+
site: str,
|
|
353
|
+
from_browser: Annotated[
|
|
354
|
+
bool, typer.Option("--from-browser", help="copy this site's cookies from a local browser")
|
|
355
|
+
] = False,
|
|
356
|
+
) -> None:
|
|
357
|
+
"""Save a session for one site in the encrypted vault (the only cookies agent-wire uses).
|
|
358
|
+
|
|
359
|
+
Opens a login window (stealth extra), or with --from-browser copies the site's cookies from
|
|
360
|
+
a browser you're already logged in to.
|
|
361
|
+
"""
|
|
362
|
+
from agent_wire.auth.cookie_vault import CookieVault
|
|
363
|
+
from agent_wire.config import init_wire_home
|
|
364
|
+
|
|
365
|
+
_utf8()
|
|
366
|
+
vault = CookieVault(init_wire_home())
|
|
367
|
+
if from_browser:
|
|
368
|
+
n = asyncio.run(vault.import_from_browser(site))
|
|
369
|
+
typer.echo(
|
|
370
|
+
f"saved {n} cookies for {site}" if n else f"no {site} cookies found in local browsers"
|
|
371
|
+
)
|
|
372
|
+
return
|
|
373
|
+
try:
|
|
374
|
+
n = asyncio.run(vault.interactive_login(site))
|
|
375
|
+
except ImportError as e:
|
|
376
|
+
typer.echo("wire auth login needs the stealth extra: uv tool install 'agent-wire-cli[stealth]' "
|
|
377
|
+
"&& playwright install chromium", err=True) # fmt: skip
|
|
378
|
+
raise typer.Exit(1) from e
|
|
379
|
+
typer.echo(f"saved {n} cookies for {site}" if n else f"no cookies captured for {site}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@auth_app.command("list")
|
|
383
|
+
def auth_list() -> None:
|
|
384
|
+
"""Sites with a saved session (names only, never values)."""
|
|
385
|
+
from agent_wire.auth.cookie_vault import CookieVault
|
|
386
|
+
from agent_wire.config import init_wire_home
|
|
387
|
+
|
|
388
|
+
domains = CookieVault(init_wire_home()).domains()
|
|
389
|
+
typer.echo("\n".join(domains) if domains else "no saved sessions")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@auth_app.command("logout")
|
|
393
|
+
def auth_logout(site: str) -> None:
|
|
394
|
+
"""Forget a saved session."""
|
|
395
|
+
from agent_wire.auth.cookie_vault import CookieVault
|
|
396
|
+
from agent_wire.config import init_wire_home
|
|
397
|
+
|
|
398
|
+
removed = CookieVault(init_wire_home()).remove(site)
|
|
399
|
+
typer.echo(f"removed {site}" if removed else f"no saved session for {site}")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
if __name__ == "__main__":
|
|
403
|
+
app()
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""#L45-L120 line slicing for code and #heading section extraction for Markdown."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
CONTEXT_LINES = 5
|
|
6
|
+
|
|
7
|
+
_LINE_ANCHOR = re.compile(r"#L(\d+)(?:-L?(\d+))?$")
|
|
8
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_line_anchor(url: str) -> tuple[int, int] | None:
|
|
12
|
+
if not (m := _LINE_ANCHOR.search(url)):
|
|
13
|
+
return None
|
|
14
|
+
a, b = int(m[1]), int(m[2] or m[1])
|
|
15
|
+
return min(a, b), max(a, b)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def slice_lines(text: str, start: int, end: int, context: int = CONTEXT_LINES) -> str:
|
|
19
|
+
lines = text.splitlines()
|
|
20
|
+
lo, hi = max(1, start - context), min(len(lines), end + context)
|
|
21
|
+
width = len(str(hi))
|
|
22
|
+
return "\n".join(f"{n:>{width}}| {lines[n - 1]}" for n in range(lo, hi + 1))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def slugify(title: str) -> str:
|
|
26
|
+
"""GitHub-style heading anchor."""
|
|
27
|
+
s = re.sub(r"[^\w\s-]", "", title.strip().lower())
|
|
28
|
+
return s.replace(" ", "-")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def slice_section(markdown: str, section: str) -> str | None:
|
|
32
|
+
"""Heading named/slugged `section` through the next heading of equal or higher level."""
|
|
33
|
+
target = slugify(section.lstrip("#"))
|
|
34
|
+
out, level, in_fence = None, 0, False
|
|
35
|
+
for line in markdown.splitlines():
|
|
36
|
+
if line.lstrip().startswith(("```", "~~~")):
|
|
37
|
+
in_fence = not in_fence
|
|
38
|
+
m = None if in_fence else _HEADING.match(line)
|
|
39
|
+
if out is not None:
|
|
40
|
+
if m and len(m[1]) <= level:
|
|
41
|
+
break
|
|
42
|
+
out.append(line)
|
|
43
|
+
elif m and slugify(m[2]) == target:
|
|
44
|
+
out, level = [line], len(m[1])
|
|
45
|
+
return "\n".join(out) if out else None
|