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,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 (&amp;#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)."""