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,315 @@
|
|
|
1
|
+
"""Install agent-wire into agent clients, keeping config, cache and vault inside each client's folder.
|
|
2
|
+
|
|
3
|
+
Existing configs are merged, never clobbered: other servers and keys survive, a file that isn't
|
|
4
|
+
valid JSON/TOML is refused instead of overwritten, and re-running is idempotent.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import sys
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from importlib import metadata, resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import unquote, urlsplit
|
|
16
|
+
|
|
17
|
+
SERVER = "agent_wire"
|
|
18
|
+
CLIENTS = ("claude-code", "codex", "hermes", "antigravity", "claude-desktop", "peridot", "cursor",
|
|
19
|
+
"windsurf", "vscode", "roo", "gemini") # fmt: skip
|
|
20
|
+
ASSETS = {
|
|
21
|
+
"SKILL.md": "skills/agent-wire/SKILL.md",
|
|
22
|
+
"wire-worker.md": "agents/wire-worker.md",
|
|
23
|
+
"cursor_rule.mdc": "manifests/cursor_rule.mdc",
|
|
24
|
+
"windsurf_rules.md": "manifests/windsurf_rules.md",
|
|
25
|
+
"hermes_skill.md": "manifests/hermes_skill.md",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Result:
|
|
31
|
+
client: str
|
|
32
|
+
files: list[Path] = field(default_factory=list)
|
|
33
|
+
notes: list[str] = field(default_factory=list)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Target:
|
|
38
|
+
project: Path
|
|
39
|
+
home: Path
|
|
40
|
+
profile: str
|
|
41
|
+
global_: bool
|
|
42
|
+
dry_run: bool
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def asset(name: str) -> str:
|
|
46
|
+
"""Copy bundled in the wheel, else the source checkout (editable / dev installs)."""
|
|
47
|
+
packaged = resources.files("agent_wire").joinpath("assets", name)
|
|
48
|
+
if packaged.is_file():
|
|
49
|
+
return packaged.read_text(encoding="utf-8")
|
|
50
|
+
return (Path(__file__).resolve().parents[3] / ASSETS[name]).read_text(encoding="utf-8")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _body(md: str) -> str:
|
|
54
|
+
"""Markdown without its YAML front matter."""
|
|
55
|
+
return re.sub(r"\A---\n.*?\n---\n+", "", md, flags=re.DOTALL)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def install_source() -> str | None:
|
|
59
|
+
"""Where uvx should fetch agent-wire from, mirroring how it was installed (PyPI: agent-wire-cli).
|
|
60
|
+
|
|
61
|
+
A local checkout -> its path; a git install -> `git+<url>[@rev]`; a registry install -> None.
|
|
62
|
+
"""
|
|
63
|
+
try:
|
|
64
|
+
raw = metadata.distribution("agent-wire-cli").read_text("direct_url.json")
|
|
65
|
+
except metadata.PackageNotFoundError:
|
|
66
|
+
return None
|
|
67
|
+
info = json.loads(raw) if raw else {}
|
|
68
|
+
url = info.get("url", "")
|
|
69
|
+
if "vcs_info" in info:
|
|
70
|
+
rev = info["vcs_info"].get("requested_revision")
|
|
71
|
+
return f"git+{url}" + (f"@{rev}" if rev else "")
|
|
72
|
+
if not url.startswith("file:"):
|
|
73
|
+
return None
|
|
74
|
+
path = unquote(urlsplit(url).path)
|
|
75
|
+
if re.match(r"^/[A-Za-z]:", path): # file:///E:/agent-wire on Windows
|
|
76
|
+
path = path[1:]
|
|
77
|
+
return str(Path(path))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def server_entry(profile: str, wire_home: Path) -> dict:
|
|
81
|
+
"""uvx for registry/git installs (cached env); `uv run` for a local checkout, because
|
|
82
|
+
`uvx --from <path>` rebuilds the environment on every start and blows client connect timeouts."""
|
|
83
|
+
src = install_source()
|
|
84
|
+
tail = ["--profile", "local"] if profile == "local" else []
|
|
85
|
+
env = {"WIRE_HOME": str(wire_home)}
|
|
86
|
+
if src and not src.startswith("git+"):
|
|
87
|
+
args = ["run", "--quiet", "--directory", src, "--frozen", "wire", "serve", *tail]
|
|
88
|
+
return {"command": "uv", "args": args, "env": env}
|
|
89
|
+
args = (["--from", src] if src else []) + ["agent-wire-cli", "serve", *tail]
|
|
90
|
+
return {"command": "uvx", "args": args, "env": env}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---- file helpers -------------------------------------------------------------------------------
|
|
94
|
+
def _write(path: Path, text: str, t: Target, r: Result) -> None:
|
|
95
|
+
if not t.dry_run:
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text(text, encoding="utf-8")
|
|
98
|
+
r.files.append(path)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _read(path: Path) -> str:
|
|
102
|
+
return path.read_text(encoding="utf-8") if path.exists() else ""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _merge_json(path: Path, section: str, entry: dict, t: Target, r: Result) -> None:
|
|
106
|
+
raw = _read(path).strip()
|
|
107
|
+
try:
|
|
108
|
+
data = json.loads(raw) if raw else {}
|
|
109
|
+
except json.JSONDecodeError as e:
|
|
110
|
+
raise ValueError(
|
|
111
|
+
f"{path} is not plain JSON ({e.msg}); add agent_wire by hand or fix the file"
|
|
112
|
+
) from e
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
raise ValueError(f"{path} does not hold a JSON object") # noqa: TRY004 - CLI reports ValueError
|
|
115
|
+
data.setdefault(section, {})[SERVER] = entry
|
|
116
|
+
_write(path, json.dumps(data, indent=2, ensure_ascii=False) + "\n", t, r)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _toml_block(entry: dict) -> str:
|
|
120
|
+
args = ", ".join(json.dumps(a) for a in entry["args"])
|
|
121
|
+
env = ", ".join(f"{k} = {json.dumps(v)}" for k, v in entry["env"].items())
|
|
122
|
+
return f"[mcp_servers.{SERVER}]\ncommand = {json.dumps(entry['command'])}\nargs = [{args}]\nenv = {{ {env} }}\n"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _merge_toml(path: Path, entry: dict, t: Target, r: Result) -> None:
|
|
126
|
+
text = _read(path)
|
|
127
|
+
block = _toml_block(entry)
|
|
128
|
+
existing = re.compile(rf"(?ms)^\[mcp_servers\.{SERVER}\]\n.*?(?=^\[|\Z)")
|
|
129
|
+
if existing.search(text):
|
|
130
|
+
text = existing.sub(lambda _: block + "\n", text, count=1).rstrip() + "\n"
|
|
131
|
+
else:
|
|
132
|
+
text = (text.rstrip() + "\n\n" if text.strip() else "") + block
|
|
133
|
+
try:
|
|
134
|
+
tomllib.loads(text) # never leave Codex a config it can't parse
|
|
135
|
+
except tomllib.TOMLDecodeError as e:
|
|
136
|
+
raise ValueError(
|
|
137
|
+
f"{path}: merged config would not parse ({e}); add agent_wire by hand"
|
|
138
|
+
) from e
|
|
139
|
+
_write(path, text, t, r)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _merge_hermes_yaml(path: Path, entry: dict, t: Target, r: Result) -> None:
|
|
143
|
+
text = _read(path)
|
|
144
|
+
if re.search(rf"(?m)^\s+{SERVER}:\s*$", text):
|
|
145
|
+
r.notes.append(f"{path} already registers {SERVER}; left unchanged")
|
|
146
|
+
return
|
|
147
|
+
block = (f" {SERVER}:\n command: {entry['command']}\n args: {json.dumps(entry['args'])}\n"
|
|
148
|
+
f" env:\n WIRE_HOME: {json.dumps(entry['env']['WIRE_HOME'])}\n") # fmt: skip
|
|
149
|
+
if re.search(r"(?m)^mcp_servers:\s*$", text):
|
|
150
|
+
text = re.sub(r"(?m)^mcp_servers:[ \t]*\n", lambda m: m[0] + block, text, count=1)
|
|
151
|
+
elif re.search(r"(?m)^mcp_servers:", text):
|
|
152
|
+
raise ValueError(f"{path}: mcp_servers uses inline YAML; add agent_wire by hand")
|
|
153
|
+
else:
|
|
154
|
+
text = (text.rstrip() + "\n\n" if text.strip() else "") + "mcp_servers:\n" + block
|
|
155
|
+
_write(path, text, t, r)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _desktop_dir(home: Path) -> Path:
|
|
159
|
+
if sys.platform == "win32":
|
|
160
|
+
roaming = (
|
|
161
|
+
Path(os.environ["APPDATA"])
|
|
162
|
+
if os.environ.get("APPDATA") and home == Path.home()
|
|
163
|
+
else home / "AppData" / "Roaming"
|
|
164
|
+
)
|
|
165
|
+
return roaming / "Claude"
|
|
166
|
+
if sys.platform == "darwin":
|
|
167
|
+
return home / "Library" / "Application Support" / "Claude"
|
|
168
|
+
return home / ".config" / "Claude"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---- clients ------------------------------------------------------------------------------------
|
|
172
|
+
def _claude_code(t: Target, r: Result) -> None:
|
|
173
|
+
base = (t.home if t.global_ else t.project) / ".claude"
|
|
174
|
+
entry = server_entry(t.profile, base / ".wire")
|
|
175
|
+
_merge_json(
|
|
176
|
+
t.home / ".claude.json" if t.global_ else t.project / ".mcp.json", "mcpServers", entry, t, r
|
|
177
|
+
)
|
|
178
|
+
_write(base / "skills" / "agent-wire" / "SKILL.md", asset("SKILL.md"), t, r)
|
|
179
|
+
_write(base / "agents" / "wire-worker.md", asset("wire-worker.md"), t, r)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _codex(t: Target, r: Result) -> None:
|
|
183
|
+
base = (
|
|
184
|
+
t.project / ".codex"
|
|
185
|
+
if not t.global_ and (t.project / ".codex").is_dir()
|
|
186
|
+
else t.home / ".codex"
|
|
187
|
+
)
|
|
188
|
+
_merge_toml(base / "config.toml", server_entry(t.profile, base / ".wire"), t, r)
|
|
189
|
+
_write(base / "skills" / "agent-wire" / "SKILL.md", asset("SKILL.md"), t, r)
|
|
190
|
+
if t.profile != "local":
|
|
191
|
+
r.notes.append(
|
|
192
|
+
"Codex Go plan: re-run with --profile local to cap tool output at 800 tokens"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _hermes(t: Target, r: Result) -> None:
|
|
197
|
+
base = t.home / ".hermes"
|
|
198
|
+
_merge_hermes_yaml(base / "config.yaml", server_entry(t.profile, base / ".wire"), t, r)
|
|
199
|
+
_write(base / "skills" / "agent-wire" / "SKILL.md", asset("hermes_skill.md"), t, r)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _antigravity(t: Target, r: Result) -> None:
|
|
203
|
+
base = (t.home / ".gemini" / "antigravity") if t.global_ else (t.project / ".agent")
|
|
204
|
+
entry = server_entry(t.profile, base / ".wire")
|
|
205
|
+
_merge_json(t.home / ".gemini" / "antigravity" / "mcp_config.json", "mcpServers", entry, t, r)
|
|
206
|
+
_write(base / "skills" / "agent-wire" / "SKILL.md", asset("SKILL.md"), t, r)
|
|
207
|
+
_write(base / "agents" / "wire-worker.md", asset("wire-worker.md"), t, r)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _claude_desktop(t: Target, r: Result) -> None:
|
|
211
|
+
base = _desktop_dir(t.home)
|
|
212
|
+
_merge_json(
|
|
213
|
+
base / "claude_desktop_config.json",
|
|
214
|
+
"mcpServers",
|
|
215
|
+
server_entry(t.profile, base / ".wire"),
|
|
216
|
+
t,
|
|
217
|
+
r,
|
|
218
|
+
)
|
|
219
|
+
r.notes.append("restart Claude Desktop to load the server")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _peridot(t: Target, r: Result) -> None:
|
|
223
|
+
base = (t.home if t.global_ else t.project) / ".peridot"
|
|
224
|
+
_merge_json(base / "mcp.json", "mcpServers", server_entry("local", base / ".wire"), t, r)
|
|
225
|
+
r.notes.append("peridot always uses the local (8 GB VRAM) profile")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _cursor(t: Target, r: Result) -> None:
|
|
229
|
+
base = (t.home if t.global_ else t.project) / ".cursor"
|
|
230
|
+
_merge_json(base / "mcp.json", "mcpServers", server_entry(t.profile, base / ".wire"), t, r)
|
|
231
|
+
if not t.global_:
|
|
232
|
+
_write(base / "rules" / "agent-wire.mdc", asset("cursor_rule.mdc"), t, r)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _windsurf(t: Target, r: Result) -> None:
|
|
236
|
+
base = t.home / ".codeium" / "windsurf"
|
|
237
|
+
_merge_json(
|
|
238
|
+
base / "mcp_config.json", "mcpServers", server_entry(t.profile, base / ".wire"), t, r
|
|
239
|
+
)
|
|
240
|
+
if not t.global_:
|
|
241
|
+
_write(
|
|
242
|
+
t.project / ".windsurf" / "rules" / "agent-wire.md", asset("windsurf_rules.md"), t, r
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _vscode(t: Target, r: Result) -> None:
|
|
247
|
+
base = t.project / ".vscode"
|
|
248
|
+
entry = {"type": "stdio", **server_entry(t.profile, base / ".wire")}
|
|
249
|
+
_merge_json(base / "mcp.json", "servers", entry, t, r)
|
|
250
|
+
if t.global_:
|
|
251
|
+
r.notes.append("VS Code MCP config is per-workspace; wrote .vscode/mcp.json in the project")
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _roo(t: Target, r: Result) -> None:
|
|
255
|
+
base = t.project / ".roo"
|
|
256
|
+
_merge_json(base / "mcp.json", "mcpServers", server_entry(t.profile, base / ".wire"), t, r)
|
|
257
|
+
_write(base / "rules" / "agent-wire.md", _body(asset("SKILL.md")), t, r)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _gemini(t: Target, r: Result) -> None:
|
|
261
|
+
base = t.home / ".gemini" / "extensions" / "agent-wire"
|
|
262
|
+
manifest = {
|
|
263
|
+
"name": "agent-wire",
|
|
264
|
+
"version": "0.1.0",
|
|
265
|
+
"description": "Live web and platform access: token-compressed, privacy-firewalled.",
|
|
266
|
+
"contextFileName": "GEMINI.md",
|
|
267
|
+
"mcpServers": {SERVER: server_entry(t.profile, base / ".wire")},
|
|
268
|
+
}
|
|
269
|
+
_write(base / "gemini-extension.json", json.dumps(manifest, indent=2) + "\n", t, r)
|
|
270
|
+
_write(base / "GEMINI.md", _body(asset("SKILL.md")), t, r)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
WRITERS = {
|
|
274
|
+
"claude-code": _claude_code, "codex": _codex, "hermes": _hermes, "antigravity": _antigravity,
|
|
275
|
+
"claude-desktop": _claude_desktop, "peridot": _peridot, "cursor": _cursor,
|
|
276
|
+
"windsurf": _windsurf, "vscode": _vscode, "roo": _roo, "gemini": _gemini,
|
|
277
|
+
} # fmt: skip
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def detect(project: Path, home: Path) -> list[str]:
|
|
281
|
+
markers = {
|
|
282
|
+
"claude-code": [project / ".claude", home / ".claude"],
|
|
283
|
+
"codex": [project / ".codex", home / ".codex"],
|
|
284
|
+
"hermes": [home / ".hermes"],
|
|
285
|
+
"antigravity": [project / ".agent", home / ".gemini" / "antigravity"],
|
|
286
|
+
"claude-desktop": [_desktop_dir(home)],
|
|
287
|
+
"peridot": [project / ".peridot"],
|
|
288
|
+
"cursor": [project / ".cursor", home / ".cursor"],
|
|
289
|
+
"windsurf": [project / ".windsurf", home / ".codeium" / "windsurf"],
|
|
290
|
+
"vscode": [project / ".vscode"],
|
|
291
|
+
"roo": [project / ".roo"],
|
|
292
|
+
"gemini": [home / ".gemini" / "extensions"],
|
|
293
|
+
}
|
|
294
|
+
return [c for c, paths in markers.items() if any(p.exists() for p in paths)] or ["claude-code"]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def install(client: str, *, project: Path, home: Path, profile: str = "standard",
|
|
298
|
+
global_: bool = False, dry_run: bool = False) -> list[Result]: # fmt: skip
|
|
299
|
+
if client == "all":
|
|
300
|
+
names = list(CLIENTS)
|
|
301
|
+
elif client == "auto":
|
|
302
|
+
names = detect(project, home)
|
|
303
|
+
elif client in WRITERS:
|
|
304
|
+
names = [client]
|
|
305
|
+
else:
|
|
306
|
+
raise ValueError(
|
|
307
|
+
f"unknown client {client!r}; choose auto, all, or one of: {', '.join(CLIENTS)}"
|
|
308
|
+
)
|
|
309
|
+
target = Target(project, home, profile, global_, dry_run)
|
|
310
|
+
results = []
|
|
311
|
+
for name in names:
|
|
312
|
+
r = Result(name)
|
|
313
|
+
WRITERS[name](target, r)
|
|
314
|
+
results.append(r)
|
|
315
|
+
return results
|
|
File without changes
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Optional transcription: local faster-whisper (GPU/CPU) or an OpenAI-compatible API, all in memory."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.util
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from curl_cffi import CurlMime
|
|
9
|
+
|
|
10
|
+
from agent_wire.engine.http_pool import HttpPool
|
|
11
|
+
from agent_wire.firewall.outbound_privacy import parse_json
|
|
12
|
+
from agent_wire.media.subtitle_cleaner import to_paragraphs
|
|
13
|
+
|
|
14
|
+
MAX_AUDIO_BYTES = 200 * 1024 * 1024
|
|
15
|
+
DEFAULT_API = "https://api.openai.com/v1/audio/transcriptions"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TranscriptionUnavailable(Exception):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def enabled() -> bool:
|
|
23
|
+
return os.environ.get("WIRE_TRANSCRIBE") == "1"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def backend() -> str | None:
|
|
27
|
+
if importlib.util.find_spec("faster_whisper"):
|
|
28
|
+
return "local"
|
|
29
|
+
if os.environ.get("WIRE_TRANSCRIBE_API_KEY"):
|
|
30
|
+
return "api"
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def transcribe(pool: HttpPool, audio_url: str) -> str:
|
|
35
|
+
kind = backend()
|
|
36
|
+
if not kind:
|
|
37
|
+
raise TranscriptionUnavailable(
|
|
38
|
+
"install agent-wire-cli[local-whisper] or set WIRE_TRANSCRIBE_API_KEY for an OpenAI-compatible API"
|
|
39
|
+
)
|
|
40
|
+
f = await pool.get(audio_url, impersonate=None, timeout=300)
|
|
41
|
+
if not f.ok or len(f.content) > MAX_AUDIO_BYTES:
|
|
42
|
+
raise TranscriptionUnavailable(
|
|
43
|
+
f"audio download failed or too large ({len(f.content)} bytes)"
|
|
44
|
+
)
|
|
45
|
+
if kind == "local":
|
|
46
|
+
return await asyncio.to_thread(_local, f.content)
|
|
47
|
+
return await _api(pool, f.content, audio_url.rsplit("/", 1)[-1].split("?")[0] or "audio.mp3")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _local(audio: bytes) -> str:
|
|
51
|
+
from faster_whisper import WhisperModel
|
|
52
|
+
|
|
53
|
+
model = WhisperModel(
|
|
54
|
+
os.environ.get("WIRE_WHISPER_MODEL", "small"), device="auto", compute_type="auto"
|
|
55
|
+
)
|
|
56
|
+
segments, _ = model.transcribe(io.BytesIO(audio), vad_filter=True)
|
|
57
|
+
return to_paragraphs([(s.start, s.text.strip()) for s in segments])
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
async def _api(pool: HttpPool, audio: bytes, filename: str) -> str:
|
|
61
|
+
# Opt-in only: this uploads the (public) audio to the configured provider.
|
|
62
|
+
mime = CurlMime()
|
|
63
|
+
mime.addpart(name="model", data=os.environ.get("WIRE_TRANSCRIBE_MODEL", "whisper-1").encode())
|
|
64
|
+
mime.addpart(
|
|
65
|
+
name="file", filename=filename, content_type="application/octet-stream", data=audio
|
|
66
|
+
)
|
|
67
|
+
try:
|
|
68
|
+
f = await pool.get(
|
|
69
|
+
os.environ.get("WIRE_TRANSCRIBE_URL", DEFAULT_API), method="POST", impersonate=None,
|
|
70
|
+
headers={"Authorization": f"Bearer {os.environ['WIRE_TRANSCRIBE_API_KEY']}"},
|
|
71
|
+
multipart=mime, timeout=600,
|
|
72
|
+
) # fmt: skip
|
|
73
|
+
finally:
|
|
74
|
+
mime.close()
|
|
75
|
+
if not f.ok:
|
|
76
|
+
raise TranscriptionUnavailable(f"transcription API returned HTTP {f.status}")
|
|
77
|
+
return parse_json(f.content).get("text", "")
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""VTT / SRT / JSON3 / timedtext XML -> clean paragraphs (no timestamps, no rolling duplicates)."""
|
|
2
|
+
|
|
3
|
+
import html
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
import orjson
|
|
7
|
+
|
|
8
|
+
_TIME = r"(?:\d+:)?\d{1,2}:\d{2}[.,]\d{3}"
|
|
9
|
+
_CUE = re.compile(rf"^({_TIME})\s*-->\s*({_TIME})")
|
|
10
|
+
_INLINE_TAG = re.compile(r"<[^>]+>")
|
|
11
|
+
_XML_CUE = re.compile(
|
|
12
|
+
r'<(?:text|p)\b[^>]*?\b(?:start|t)="([\d.]+)"[^>]*>(.*?)</(?:text|p)>', re.DOTALL
|
|
13
|
+
)
|
|
14
|
+
_SENTENCE_END = re.compile(r"[.!?。!?…]$")
|
|
15
|
+
CHAPTER_LINE = re.compile(r"^\s*((?:\d{1,2}:)?\d{1,2}:\d{2})\s*[-–—:]?\s+(.+?)\s*$")
|
|
16
|
+
|
|
17
|
+
GAP_BREAK_S = 2.5
|
|
18
|
+
PARAGRAPH_CHARS = 600
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _seconds(stamp: str) -> float:
|
|
22
|
+
parts = stamp.replace(",", ".").split(":")
|
|
23
|
+
return sum(float(p) * 60**i for i, p in enumerate(reversed(parts)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_captions(raw: str | bytes) -> list[tuple[float, str]]:
|
|
27
|
+
text = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else raw
|
|
28
|
+
text = text.lstrip("").strip()
|
|
29
|
+
if text.startswith("{"):
|
|
30
|
+
segs = []
|
|
31
|
+
for ev in orjson.loads(text).get("events", []):
|
|
32
|
+
line = "".join(s.get("utf8", "") for s in ev.get("segs") or [])
|
|
33
|
+
if line.strip():
|
|
34
|
+
segs.append((ev.get("tStartMs", 0) / 1000, line))
|
|
35
|
+
return _clean(segs)
|
|
36
|
+
if text.startswith("<"):
|
|
37
|
+
return _clean([(float(t) / (1000 if "." not in t and float(t) > 1e4 else 1), body)
|
|
38
|
+
for t, body in _XML_CUE.findall(text)]) # fmt: skip
|
|
39
|
+
segs, start, buf = [], None, []
|
|
40
|
+
for line in text.splitlines():
|
|
41
|
+
if m := _CUE.match(line.strip()):
|
|
42
|
+
if start is not None and buf:
|
|
43
|
+
segs.append((start, " ".join(buf)))
|
|
44
|
+
start, buf = _seconds(m[1]), []
|
|
45
|
+
elif start is not None and line.strip() and not line.strip().isdigit():
|
|
46
|
+
buf.append(line.strip())
|
|
47
|
+
elif not line.strip() and start is not None and buf:
|
|
48
|
+
segs.append((start, " ".join(buf)))
|
|
49
|
+
start, buf = None, []
|
|
50
|
+
if start is not None and buf:
|
|
51
|
+
segs.append((start, " ".join(buf)))
|
|
52
|
+
return _clean(segs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _plain(body: str) -> str:
|
|
56
|
+
# YouTube timedtext XML double-encodes entities (&#39;), so unescape twice.
|
|
57
|
+
return " ".join(html.unescape(html.unescape(_INLINE_TAG.sub("", body))).split())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _clean(segs: list[tuple[float, str]]) -> list[tuple[float, str]]:
|
|
61
|
+
"""Strip markup and drop the rolling-caption repeats auto-captions produce."""
|
|
62
|
+
out: list[tuple[float, str]] = []
|
|
63
|
+
last = ""
|
|
64
|
+
for start, body in segs:
|
|
65
|
+
line = _plain(body)
|
|
66
|
+
if not line or line == last:
|
|
67
|
+
continue
|
|
68
|
+
if last and line.startswith(last): # rolling cue: keep only the new tail
|
|
69
|
+
line = line[len(last) :].strip()
|
|
70
|
+
elif last and last.endswith(line):
|
|
71
|
+
continue
|
|
72
|
+
if line:
|
|
73
|
+
out.append((start, line))
|
|
74
|
+
last = _plain(body)
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parse_chapters(description: str) -> list[tuple[float, str]]:
|
|
79
|
+
chapters = [(_seconds(m[1] + ".000"), m[2]) for ln in description.splitlines()
|
|
80
|
+
if (m := CHAPTER_LINE.match(ln))] # fmt: skip
|
|
81
|
+
return chapters if chapters and chapters[0][0] == 0 else []
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def to_paragraphs(segs: list[tuple[float, str]], chapters: list[tuple[float, str]] = ()) -> str:
|
|
85
|
+
out, para, prev_end = [], [], None
|
|
86
|
+
pending = list(chapters)
|
|
87
|
+
for start, line in segs:
|
|
88
|
+
while pending and start >= pending[0][0]:
|
|
89
|
+
if para:
|
|
90
|
+
out.append(" ".join(para))
|
|
91
|
+
para = []
|
|
92
|
+
out.append(f"## {pending.pop(0)[1]}")
|
|
93
|
+
gap = prev_end is not None and start - prev_end > GAP_BREAK_S
|
|
94
|
+
long = sum(len(p) for p in para) > PARAGRAPH_CHARS and _SENTENCE_END.search(para[-1])
|
|
95
|
+
if para and (gap or long):
|
|
96
|
+
out.append(" ".join(para))
|
|
97
|
+
para = []
|
|
98
|
+
para.append(line)
|
|
99
|
+
prev_end = start
|
|
100
|
+
if para:
|
|
101
|
+
out.append(" ".join(para))
|
|
102
|
+
return "\n\n".join(out)
|
agent_wire/models.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Strict output schemas. Unknown keys are dropped on validation (outbound firewall layer 3)."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class _Strict(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="ignore")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class WireComment(_Strict):
|
|
13
|
+
id: str
|
|
14
|
+
body: str
|
|
15
|
+
author_handle: str | None = None
|
|
16
|
+
timestamp: datetime | None = None
|
|
17
|
+
score: int = 0
|
|
18
|
+
score_kind: str = "points" # or "replies" where a site hides comment points (HN)
|
|
19
|
+
depth: int = 0
|
|
20
|
+
is_op: bool = False
|
|
21
|
+
is_accepted: bool = False
|
|
22
|
+
replies: list["WireComment"] = []
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class WireDocument(_Strict):
|
|
26
|
+
url: str
|
|
27
|
+
channel: str
|
|
28
|
+
title: str | None = None
|
|
29
|
+
author_handle: str | None = None
|
|
30
|
+
timestamp: datetime | None = None
|
|
31
|
+
score: int | None = None
|
|
32
|
+
body: str = ""
|
|
33
|
+
comments: list[WireComment] = []
|
|
34
|
+
metadata: dict[str, str | int | float | bool | None] = {}
|
|
35
|
+
# Pipeline telemetry, never rendered into the document text.
|
|
36
|
+
stage: int = 1
|
|
37
|
+
auth: str = "none"
|
|
38
|
+
raw_bytes: int = 0
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class WireSearchHit(_Strict):
|
|
42
|
+
url: str
|
|
43
|
+
title: str
|
|
44
|
+
snippet: str = ""
|
|
45
|
+
source: str = "web"
|
|
46
|
+
rank_score: float = 0.0
|
|
47
|
+
# Filled where the platform exposes them; wire_research scores freshness and engagement.
|
|
48
|
+
timestamp: datetime | None = None
|
|
49
|
+
author: str | None = None
|
|
50
|
+
link: str | None = None # the article a post points at (HN/Reddit/Techmeme), for dedup
|
|
51
|
+
engagement: dict[str, int] = {} # score, comments, likes, reposts, views, volume ...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class WireTelemetry(_Strict):
|
|
55
|
+
channel: str
|
|
56
|
+
tokens: int
|
|
57
|
+
saved_pct: int = 0
|
|
58
|
+
auth: str = "none"
|
|
59
|
+
stage: int = 1
|
|
60
|
+
elapsed_ms: int = 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""wire_research: last-N-days multi-source research (plan -> collect -> score -> cluster -> brief)."""
|