monkeybot-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.
- monkeybot_cli/__init__.py +3 -0
- monkeybot_cli/chat_renderer.py +87 -0
- monkeybot_cli/chat_session.py +911 -0
- monkeybot_cli/chat_status_bar.py +205 -0
- monkeybot_cli/chat_theme.py +91 -0
- monkeybot_cli/chat_tool_display.py +334 -0
- monkeybot_cli/chat_tui.py +1491 -0
- monkeybot_cli/chat_tui_widgets.py +996 -0
- monkeybot_cli/commands/__init__.py +1 -0
- monkeybot_cli/commands/chat.py +817 -0
- monkeybot_cli/commands/doctor.py +293 -0
- monkeybot_cli/commands/loop.py +207 -0
- monkeybot_cli/commands/new.py +207 -0
- monkeybot_cli/commands/run_cmd.py +41 -0
- monkeybot_cli/commands/talk.py +102 -0
- monkeybot_cli/commands/validate.py +385 -0
- monkeybot_cli/compat.py +7 -0
- monkeybot_cli/config_resolve.py +55 -0
- monkeybot_cli/exit_commands.py +13 -0
- monkeybot_cli/extras_catalog.py +95 -0
- monkeybot_cli/gateway_health.py +34 -0
- monkeybot_cli/main.py +38 -0
- monkeybot_cli/opensandbox_lifecycle.py +314 -0
- monkeybot_cli/output.py +110 -0
- monkeybot_cli/providers.py +112 -0
- monkeybot_cli/realtime/__init__.py +13 -0
- monkeybot_cli/realtime/audio_io.py +147 -0
- monkeybot_cli/realtime/client.py +17 -0
- monkeybot_cli/realtime/gateway_manager.py +142 -0
- monkeybot_cli/realtime/push_to_talk.py +128 -0
- monkeybot_cli/realtime/session.py +256 -0
- monkeybot_cli/realtime/session_controller.py +501 -0
- monkeybot_cli/realtime/talk_ui.py +243 -0
- monkeybot_cli/realtime/wire_encode.py +39 -0
- monkeybot_cli/runtime_python.py +91 -0
- monkeybot_cli/scaffold.py +287 -0
- monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
- monkeybot_cli/scaffold_defaults/__init__.py +1 -0
- monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
- monkeybot_cli/scaffold_defaults/env.example +35 -0
- monkeybot_cli/scaffold_defaults/mcp.json +49 -0
- monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
- monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
- monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
- monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
- monkeybot_cli/session_controller.py +7 -0
- monkeybot_cli/terminal_markdown.py +48 -0
- monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
- monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
- monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
- monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Start OpenSandbox server for ``monkeybot chat`` when sandbox is enabled in config."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
_DEFAULT_SERVER_IMAGE = "opensandbox/server:latest"
|
|
15
|
+
_DEFAULT_CONTAINER = "monkeybot-opensandbox"
|
|
16
|
+
# Docker Desktop / first image pull can take well over a few seconds.
|
|
17
|
+
_DEFAULT_DOCKER_WAIT_SECS = 60.0
|
|
18
|
+
_DEFAULT_HEALTH_WAIT_SECS = 60.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def sandbox_section(doc: dict) -> dict:
|
|
22
|
+
sec = doc.get("sandbox")
|
|
23
|
+
return sec if isinstance(sec, dict) else {}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def is_sandbox_enabled(doc: dict) -> bool:
|
|
27
|
+
sec = sandbox_section(doc)
|
|
28
|
+
enabled = sec.get("enabled")
|
|
29
|
+
if isinstance(enabled, bool):
|
|
30
|
+
return enabled
|
|
31
|
+
raw = os.environ.get("SANDBOX_ENABLED", "").strip().lower()
|
|
32
|
+
return raw in {"true", "1", "on", "yes"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def server_url_from_config(doc: dict) -> str:
|
|
36
|
+
sec = sandbox_section(doc)
|
|
37
|
+
url = sec.get("server_url")
|
|
38
|
+
if isinstance(url, str) and url.strip():
|
|
39
|
+
return url.strip()
|
|
40
|
+
env_url = os.environ.get("SANDBOX_SERVER_URL", "").strip()
|
|
41
|
+
if env_url:
|
|
42
|
+
return env_url
|
|
43
|
+
return "http://localhost:18080"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def host_port_from_server_url(server_url: str, *, default: int = 18080) -> int:
|
|
47
|
+
parsed = urlparse(server_url)
|
|
48
|
+
if parsed.port is not None:
|
|
49
|
+
return parsed.port
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _config_sha256(config_path: Path) -> str:
|
|
54
|
+
if not config_path.is_file():
|
|
55
|
+
return ""
|
|
56
|
+
digest = hashlib.sha256(config_path.read_bytes()).hexdigest()
|
|
57
|
+
return digest
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _health_ok(host_port: int) -> bool:
|
|
61
|
+
url = f"http://127.0.0.1:{host_port}/health"
|
|
62
|
+
try:
|
|
63
|
+
resp = httpx.get(url, timeout=2.0)
|
|
64
|
+
body = resp.text
|
|
65
|
+
except httpx.HTTPError:
|
|
66
|
+
return False
|
|
67
|
+
return '"status"' in body and "healthy" in body
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _server_image() -> str:
|
|
71
|
+
"""OpenSandbox *control plane* image (not the session/worker ``SANDBOX_IMAGE``).
|
|
72
|
+
|
|
73
|
+
``sandbox.image`` / ``SANDBOX_IMAGE`` is the container image for agent
|
|
74
|
+
``run_command`` sessions. Reusing it here starts a worker that exits
|
|
75
|
+
immediately and then burns the full health-wait timeout.
|
|
76
|
+
"""
|
|
77
|
+
for key in ("SANDBOX_SERVER_IMAGE", "OPENSANDBOX_SERVER_IMAGE"):
|
|
78
|
+
raw = os.environ.get(key, "").strip()
|
|
79
|
+
if raw:
|
|
80
|
+
return raw
|
|
81
|
+
return _DEFAULT_SERVER_IMAGE
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _wait_healthy(
|
|
85
|
+
host_port: int,
|
|
86
|
+
*,
|
|
87
|
+
timeout_secs: float,
|
|
88
|
+
container: str | None = None,
|
|
89
|
+
) -> bool:
|
|
90
|
+
deadline = time.monotonic() + timeout_secs
|
|
91
|
+
while time.monotonic() < deadline:
|
|
92
|
+
if _health_ok(host_port):
|
|
93
|
+
return True
|
|
94
|
+
# Dead container will never become healthy — don't burn the full wait.
|
|
95
|
+
if container and _container_exists(container) and not _container_running(container):
|
|
96
|
+
return False
|
|
97
|
+
time.sleep(0.5)
|
|
98
|
+
return False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _docker(*args: str, check: bool = False) -> subprocess.CompletedProcess[str]:
|
|
102
|
+
return subprocess.run(
|
|
103
|
+
["docker", *args],
|
|
104
|
+
check=check,
|
|
105
|
+
capture_output=True,
|
|
106
|
+
text=True,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _docker_available() -> bool:
|
|
111
|
+
try:
|
|
112
|
+
proc = _docker("info")
|
|
113
|
+
except OSError:
|
|
114
|
+
return False
|
|
115
|
+
return proc.returncode == 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _wait_docker_available(*, timeout_secs: float) -> bool:
|
|
119
|
+
"""Retry ``docker info`` until the daemon is up (Docker Desktop cold start)."""
|
|
120
|
+
if timeout_secs <= 0:
|
|
121
|
+
return _docker_available()
|
|
122
|
+
if _docker_available():
|
|
123
|
+
return True
|
|
124
|
+
print(
|
|
125
|
+
f"monkeybot chat: waiting up to {timeout_secs:.0f}s for Docker…",
|
|
126
|
+
flush=True,
|
|
127
|
+
)
|
|
128
|
+
deadline = time.monotonic() + timeout_secs
|
|
129
|
+
while time.monotonic() < deadline:
|
|
130
|
+
time.sleep(1.0)
|
|
131
|
+
if _docker_available():
|
|
132
|
+
return True
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _container_running(name: str) -> bool:
|
|
137
|
+
proc = _docker("container", "inspect", "-f", "{{.State.Running}}", name)
|
|
138
|
+
return proc.returncode == 0 and proc.stdout.strip() == "true"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _container_exists(name: str) -> bool:
|
|
142
|
+
proc = _docker("container", "inspect", name)
|
|
143
|
+
return proc.returncode == 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _published_port(name: str) -> str:
|
|
147
|
+
proc = _docker("port", name, "8080/tcp")
|
|
148
|
+
if proc.returncode != 0:
|
|
149
|
+
return ""
|
|
150
|
+
return proc.stdout.strip()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _config_mount_ok(name: str) -> bool:
|
|
154
|
+
proc = _docker(
|
|
155
|
+
"container",
|
|
156
|
+
"inspect",
|
|
157
|
+
name,
|
|
158
|
+
"--format",
|
|
159
|
+
"{{range .Mounts}}{{.Destination}};{{end}}",
|
|
160
|
+
)
|
|
161
|
+
if proc.returncode != 0:
|
|
162
|
+
return False
|
|
163
|
+
return "/etc/opensandbox/config.toml" in proc.stdout
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _container_config_label(name: str) -> str:
|
|
167
|
+
proc = _docker(
|
|
168
|
+
"container",
|
|
169
|
+
"inspect",
|
|
170
|
+
name,
|
|
171
|
+
"--format",
|
|
172
|
+
'{{index .Config.Labels "mb.opensandbox.config_sha256"}}',
|
|
173
|
+
)
|
|
174
|
+
if proc.returncode != 0:
|
|
175
|
+
return ""
|
|
176
|
+
return proc.stdout.strip()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _remove_container(name: str) -> None:
|
|
180
|
+
_docker("rm", "-f", name)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _start_existing(name: str) -> None:
|
|
184
|
+
_docker("start", name)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _run_container(
|
|
188
|
+
*,
|
|
189
|
+
name: str,
|
|
190
|
+
host_port: int,
|
|
191
|
+
config_path: Path,
|
|
192
|
+
image: str,
|
|
193
|
+
cfg_hash: str,
|
|
194
|
+
) -> bool:
|
|
195
|
+
proc = _docker(
|
|
196
|
+
"run",
|
|
197
|
+
"-d",
|
|
198
|
+
"--name",
|
|
199
|
+
name,
|
|
200
|
+
"--label",
|
|
201
|
+
f"mb.opensandbox.config_sha256={cfg_hash}",
|
|
202
|
+
"--add-host=host.docker.internal:host-gateway",
|
|
203
|
+
"-p",
|
|
204
|
+
f"{host_port}:8080",
|
|
205
|
+
"-e",
|
|
206
|
+
"OPENSANDBOX_INSECURE_SERVER=YES",
|
|
207
|
+
"-v",
|
|
208
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
209
|
+
"-v",
|
|
210
|
+
f"{config_path}:/etc/opensandbox/config.toml:ro",
|
|
211
|
+
image,
|
|
212
|
+
)
|
|
213
|
+
return proc.returncode == 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def resolve_opensandbox_config(agent_root: Path) -> Path:
|
|
217
|
+
return (agent_root / "monkeybot_config" / "opensandbox.docker.toml").resolve()
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def ensure_opensandbox_for_agent(
|
|
221
|
+
agent_root: Path,
|
|
222
|
+
*,
|
|
223
|
+
server_url: str,
|
|
224
|
+
skip: bool | None = None,
|
|
225
|
+
) -> bool:
|
|
226
|
+
"""Ensure OpenSandbox server is reachable; start docker container when needed.
|
|
227
|
+
|
|
228
|
+
Returns True when health check passes or sandbox is skipped/disabled upstream.
|
|
229
|
+
"""
|
|
230
|
+
if skip is None:
|
|
231
|
+
skip = os.environ.get("SKIP_OPENSANDBOX", "").strip() == "1"
|
|
232
|
+
if skip:
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
host_port = host_port_from_server_url(server_url)
|
|
236
|
+
if _health_ok(host_port):
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
docker_wait_secs = float(
|
|
240
|
+
os.environ.get("SANDBOX_DOCKER_WAIT_SECS", str(_DEFAULT_DOCKER_WAIT_SECS))
|
|
241
|
+
)
|
|
242
|
+
if not _wait_docker_available(timeout_secs=docker_wait_secs):
|
|
243
|
+
print(
|
|
244
|
+
"monkeybot chat: Docker not available; run_command sandbox will fail "
|
|
245
|
+
"(set sandbox.enabled: false or start Docker).",
|
|
246
|
+
flush=True,
|
|
247
|
+
)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
config_path = resolve_opensandbox_config(agent_root)
|
|
251
|
+
if not config_path.is_file():
|
|
252
|
+
print(
|
|
253
|
+
f"monkeybot chat: missing {config_path}; cannot start OpenSandbox.",
|
|
254
|
+
flush=True,
|
|
255
|
+
)
|
|
256
|
+
return False
|
|
257
|
+
|
|
258
|
+
container = os.environ.get("SANDBOX_CONTAINER", _DEFAULT_CONTAINER).strip() or _DEFAULT_CONTAINER
|
|
259
|
+
image = _server_image()
|
|
260
|
+
wait_secs = float(os.environ.get("SANDBOX_HEALTH_WAIT_SECS", str(_DEFAULT_HEALTH_WAIT_SECS)))
|
|
261
|
+
want_hash = _config_sha256(config_path)
|
|
262
|
+
|
|
263
|
+
if _container_exists(container):
|
|
264
|
+
published = _published_port(container)
|
|
265
|
+
port_ok = f":{host_port}" in published
|
|
266
|
+
if not _config_mount_ok(container) or not port_ok:
|
|
267
|
+
print(f"monkeybot chat: recreating OpenSandbox container {container}", flush=True)
|
|
268
|
+
_remove_container(container)
|
|
269
|
+
elif want_hash and _container_config_label(container) != want_hash:
|
|
270
|
+
print(f"monkeybot chat: recreating OpenSandbox (config changed)", flush=True)
|
|
271
|
+
_remove_container(container)
|
|
272
|
+
elif not _container_running(container):
|
|
273
|
+
print(f"monkeybot chat: starting OpenSandbox container {container}", flush=True)
|
|
274
|
+
_start_existing(container)
|
|
275
|
+
|
|
276
|
+
if _container_exists(container):
|
|
277
|
+
if _wait_healthy(host_port, timeout_secs=wait_secs, container=container):
|
|
278
|
+
return True
|
|
279
|
+
print("monkeybot chat: OpenSandbox unhealthy; recreating container", flush=True)
|
|
280
|
+
_remove_container(container)
|
|
281
|
+
|
|
282
|
+
print(
|
|
283
|
+
f"monkeybot chat: starting OpenSandbox ({image}, port {host_port})…",
|
|
284
|
+
flush=True,
|
|
285
|
+
)
|
|
286
|
+
if not _run_container(
|
|
287
|
+
name=container,
|
|
288
|
+
host_port=host_port,
|
|
289
|
+
config_path=config_path,
|
|
290
|
+
image=image,
|
|
291
|
+
cfg_hash=want_hash,
|
|
292
|
+
):
|
|
293
|
+
print(
|
|
294
|
+
f"monkeybot chat: docker run failed (is port {host_port} in use?)",
|
|
295
|
+
flush=True,
|
|
296
|
+
)
|
|
297
|
+
return False
|
|
298
|
+
|
|
299
|
+
if _wait_healthy(host_port, timeout_secs=wait_secs, container=container):
|
|
300
|
+
print(f"monkeybot chat: OpenSandbox ready (127.0.0.1:{host_port})", flush=True)
|
|
301
|
+
return True
|
|
302
|
+
|
|
303
|
+
if _container_exists(container) and not _container_running(container):
|
|
304
|
+
print(
|
|
305
|
+
"monkeybot chat: OpenSandbox container exited before becoming healthy "
|
|
306
|
+
f"(image={image}).",
|
|
307
|
+
flush=True,
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
print(
|
|
311
|
+
f"monkeybot chat: OpenSandbox did not become healthy within {wait_secs:.0f}s.",
|
|
312
|
+
flush=True,
|
|
313
|
+
)
|
|
314
|
+
return False
|
monkeybot_cli/output.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Structured JSON output for validate/doctor commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
Severity = Literal["error", "warning"]
|
|
10
|
+
Status = Literal["pass", "fail", "skip"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class CheckResult:
|
|
15
|
+
id: str
|
|
16
|
+
category: str
|
|
17
|
+
severity: Severity
|
|
18
|
+
status: Status
|
|
19
|
+
message: str = ""
|
|
20
|
+
field: str | None = None
|
|
21
|
+
value: Any = None
|
|
22
|
+
expected: list[str] | None = None
|
|
23
|
+
remediation: str | None = None
|
|
24
|
+
docs: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class CommandReport:
|
|
29
|
+
command: str
|
|
30
|
+
ok: bool
|
|
31
|
+
config_path: str | None
|
|
32
|
+
checks: list[CheckResult] = field(default_factory=list)
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def summary(self) -> dict[str, int]:
|
|
36
|
+
passed = warnings = failed = skipped = 0
|
|
37
|
+
for c in self.checks:
|
|
38
|
+
if c.status == "pass":
|
|
39
|
+
passed += 1
|
|
40
|
+
elif c.status == "fail":
|
|
41
|
+
if c.severity == "error":
|
|
42
|
+
failed += 1
|
|
43
|
+
else:
|
|
44
|
+
warnings += 1
|
|
45
|
+
elif c.status == "skip":
|
|
46
|
+
skipped += 1
|
|
47
|
+
return {"passed": passed, "warnings": warnings, "failed": failed, "skipped": skipped}
|
|
48
|
+
|
|
49
|
+
def compute_ok(self) -> None:
|
|
50
|
+
self.ok = not any(c.severity == "error" and c.status == "fail" for c in self.checks)
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict[str, Any]:
|
|
53
|
+
self.compute_ok()
|
|
54
|
+
return {
|
|
55
|
+
"command": self.command,
|
|
56
|
+
"ok": self.ok,
|
|
57
|
+
"config_path": self.config_path,
|
|
58
|
+
"checks": [asdict(c) for c in self.checks],
|
|
59
|
+
"summary": self.summary,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
def print_human(self) -> None:
|
|
63
|
+
self.compute_ok()
|
|
64
|
+
status = "OK" if self.ok else "FAILED"
|
|
65
|
+
print(f"{self.command}: {status}")
|
|
66
|
+
if self.config_path:
|
|
67
|
+
print(f" config: {self.config_path}")
|
|
68
|
+
for c in self.checks:
|
|
69
|
+
if c.status == "skip":
|
|
70
|
+
continue
|
|
71
|
+
icon = "✓" if c.status == "pass" else "!"
|
|
72
|
+
print(f" [{icon}] {c.id}: {c.message or c.status}")
|
|
73
|
+
if c.remediation and c.status == "fail":
|
|
74
|
+
print(f" → {c.remediation}")
|
|
75
|
+
s = self.summary
|
|
76
|
+
print(f" summary: {s['passed']} passed, {s['warnings']} warnings, {s['failed']} failed, {s['skipped']} skipped")
|
|
77
|
+
|
|
78
|
+
def emit(self, *, as_json: bool) -> int:
|
|
79
|
+
self.compute_ok()
|
|
80
|
+
if as_json:
|
|
81
|
+
print(json.dumps(self.to_dict(), indent=2))
|
|
82
|
+
else:
|
|
83
|
+
self.print_human()
|
|
84
|
+
if self.ok:
|
|
85
|
+
return 0
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def check(
|
|
90
|
+
report: CommandReport,
|
|
91
|
+
*,
|
|
92
|
+
id: str,
|
|
93
|
+
category: str,
|
|
94
|
+
severity: Severity,
|
|
95
|
+
passed: bool,
|
|
96
|
+
message: str = "",
|
|
97
|
+
skip: bool = False,
|
|
98
|
+
**extra: Any,
|
|
99
|
+
) -> None:
|
|
100
|
+
status: Status = "skip" if skip else ("pass" if passed else "fail")
|
|
101
|
+
report.checks.append(
|
|
102
|
+
CheckResult(
|
|
103
|
+
id=id,
|
|
104
|
+
category=category,
|
|
105
|
+
severity=severity,
|
|
106
|
+
status=status,
|
|
107
|
+
message=message,
|
|
108
|
+
**{k: v for k, v in extra.items() if k in CheckResult.__dataclass_fields__},
|
|
109
|
+
)
|
|
110
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Provider → extra → credential mapping (owned by CLI)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from monkeybot.core.config import normalize_model_provider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class ProviderSpec:
|
|
14
|
+
yaml_aliases: tuple[str, ...]
|
|
15
|
+
extra: str | None
|
|
16
|
+
credential_env_vars: tuple[str, ...]
|
|
17
|
+
gcp_adc: bool = False
|
|
18
|
+
# True when the provider needs no API key (e.g. a local server reachable
|
|
19
|
+
# without auth, such as Ollama). Skips the credentials check in `doctor`.
|
|
20
|
+
credentials_optional: bool = False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
PROVIDER_SPECS: dict[str, ProviderSpec] = {
|
|
24
|
+
"google_vertexai": ProviderSpec(
|
|
25
|
+
("gemini", "vertex", "google_vertexai"),
|
|
26
|
+
"gemini",
|
|
27
|
+
(
|
|
28
|
+
"GEMINI_API_KEY",
|
|
29
|
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
|
30
|
+
"GCP_PROJECT_ID",
|
|
31
|
+
"GOOGLE_CLOUD_PROJECT",
|
|
32
|
+
),
|
|
33
|
+
gcp_adc=True,
|
|
34
|
+
),
|
|
35
|
+
"openai": ProviderSpec(("openai",), "openai", ("OPENAI_API_KEY",)),
|
|
36
|
+
"anthropic": ProviderSpec(("anthropic",), "claude", ("ANTHROPIC_API_KEY",)),
|
|
37
|
+
"vertex_anthropic": ProviderSpec(
|
|
38
|
+
("vertex-claude", "vertex_claude", "vertex_anthropic"),
|
|
39
|
+
"vertex-claude",
|
|
40
|
+
("GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "ANTHROPIC_VERTEX_PROJECT_ID"),
|
|
41
|
+
gcp_adc=True,
|
|
42
|
+
),
|
|
43
|
+
"aws_bedrock": ProviderSpec(
|
|
44
|
+
("aws_bedrock",),
|
|
45
|
+
"bedrock",
|
|
46
|
+
("AWS_ACCESS_KEY_ID", "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION"),
|
|
47
|
+
),
|
|
48
|
+
"huggingface": ProviderSpec(
|
|
49
|
+
("huggingface",), "huggingface", ("HF_TOKEN", "HUGGINGFACE_API_KEY")
|
|
50
|
+
),
|
|
51
|
+
"nvidia": ProviderSpec(("nvidia",), "nvidia", ("NVIDIA_API_KEY",)),
|
|
52
|
+
"ollama": ProviderSpec(
|
|
53
|
+
# credential_env_vars is empty: credentials_optional=True short-circuits
|
|
54
|
+
# credentials_present() before these are ever read, and OLLAMA_BASE_URL
|
|
55
|
+
# isn't a credential anyway (it's just the server address).
|
|
56
|
+
("ollama",),
|
|
57
|
+
"ollama",
|
|
58
|
+
(),
|
|
59
|
+
credentials_optional=True,
|
|
60
|
+
),
|
|
61
|
+
"fake": ProviderSpec(("fake",), None, (), credentials_optional=True),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def canonical_provider(yaml_provider: str) -> str:
|
|
66
|
+
return normalize_model_provider(yaml_provider.strip().lower())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def spec_for_provider(yaml_provider: str) -> ProviderSpec | None:
|
|
70
|
+
key = canonical_provider(yaml_provider)
|
|
71
|
+
return PROVIDER_SPECS.get(key)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extra_module(extra: str) -> str:
|
|
75
|
+
"""Return the importable module name used to detect ``extra``."""
|
|
76
|
+
mapping = {
|
|
77
|
+
"gemini": "google.genai",
|
|
78
|
+
"vertex": "google.auth",
|
|
79
|
+
"claude": "anthropic",
|
|
80
|
+
"vertex-claude": "anthropic",
|
|
81
|
+
"openai": "openai",
|
|
82
|
+
"bedrock": "boto3",
|
|
83
|
+
"huggingface": "openai",
|
|
84
|
+
"ollama": "openai",
|
|
85
|
+
"nvidia": "openai",
|
|
86
|
+
}
|
|
87
|
+
return mapping.get(extra, extra)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def extra_installed(extra: str) -> bool:
|
|
91
|
+
"""Check ``extra`` in the *current* process (the CLI env).
|
|
92
|
+
|
|
93
|
+
Prefer running the check in the interpreter that will host the gateway
|
|
94
|
+
(see ``monkeybot_cli.runtime_python``) so provider/storage extras declared
|
|
95
|
+
on the agent project are detected, not the CLI's globals.
|
|
96
|
+
"""
|
|
97
|
+
return importlib.util.find_spec(extra_module(extra)) is not None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def credentials_present(spec: ProviderSpec) -> bool:
|
|
101
|
+
if spec.credentials_optional:
|
|
102
|
+
return True
|
|
103
|
+
if spec.gcp_adc:
|
|
104
|
+
if os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip():
|
|
105
|
+
return True
|
|
106
|
+
for var in ("GCP_PROJECT_ID", "GOOGLE_CLOUD_PROJECT", "VERTEX_AI_PROJECT_ID"):
|
|
107
|
+
if os.environ.get(var, "").strip():
|
|
108
|
+
return True
|
|
109
|
+
if os.environ.get("GEMINI_API_KEY", "").strip():
|
|
110
|
+
return True
|
|
111
|
+
return False
|
|
112
|
+
return any(os.environ.get(v, "").strip() for v in spec.credential_env_vars)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Realtime talk session controller (WebSocket gateway)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.realtime.session import run_talk_session
|
|
6
|
+
from monkeybot_cli.realtime.session_controller import RealtimeSessionController
|
|
7
|
+
from monkeybot_cli.realtime.wire_encode import encode_client_frame
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"RealtimeSessionController",
|
|
11
|
+
"encode_client_frame",
|
|
12
|
+
"run_talk_session",
|
|
13
|
+
]
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Audio input/output helpers for the realtime CLI.
|
|
2
|
+
|
|
3
|
+
PyAudio is an optional dependency. If it is not installed, the CLI falls back to
|
|
4
|
+
text-only mode. The `pyaudio` package requires PortAudio system libraries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
from typing import cast
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
import pyaudio
|
|
18
|
+
|
|
19
|
+
_HAS_PYAUDIO = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
pyaudio = None
|
|
22
|
+
_HAS_PYAUDIO = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class AudioIOError(Exception):
|
|
26
|
+
"""Audio hardware or PyAudio configuration problem."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AudioRecorder:
|
|
30
|
+
"""Blocking microphone recorder. Run in a thread for async use."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
sample_rate: int = 24000,
|
|
35
|
+
channels: int = 1,
|
|
36
|
+
chunk_ms: int = 200,
|
|
37
|
+
format_name: str = "pcm_s16le",
|
|
38
|
+
) -> None:
|
|
39
|
+
if not _HAS_PYAUDIO:
|
|
40
|
+
raise AudioIOError("PyAudio is not installed. Install 'monkeybot[cli-realtime]'.")
|
|
41
|
+
self.sample_rate = sample_rate
|
|
42
|
+
self.channels = channels
|
|
43
|
+
self.chunk_ms = chunk_ms
|
|
44
|
+
self.format_name = format_name
|
|
45
|
+
self._format = pyaudio.paInt16 if "s16" in format_name else pyaudio.paInt8
|
|
46
|
+
self._bytes_per_sample = 2 if "s16" in format_name else 1
|
|
47
|
+
self._chunk_frames = int(sample_rate * chunk_ms / 1000)
|
|
48
|
+
self._chunk_bytes = self._chunk_frames * self._bytes_per_sample * channels
|
|
49
|
+
try:
|
|
50
|
+
self._pyaudio = pyaudio.PyAudio()
|
|
51
|
+
self._stream = self._pyaudio.open(
|
|
52
|
+
format=self._format,
|
|
53
|
+
channels=channels,
|
|
54
|
+
rate=sample_rate,
|
|
55
|
+
input=True,
|
|
56
|
+
frames_per_buffer=self._chunk_frames,
|
|
57
|
+
)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise AudioIOError(f"Failed to open microphone: {exc}") from exc
|
|
60
|
+
logger.info(
|
|
61
|
+
"audio recorder opened: rate=%s channels=%s chunk_ms=%s",
|
|
62
|
+
sample_rate,
|
|
63
|
+
channels,
|
|
64
|
+
chunk_ms,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def read_chunk(self) -> bytes:
|
|
68
|
+
try:
|
|
69
|
+
return cast(bytes, self._stream.read(self._chunk_frames, exception_on_overflow=False))
|
|
70
|
+
except Exception as exc:
|
|
71
|
+
raise AudioIOError(f"Microphone read failed: {exc}") from exc
|
|
72
|
+
|
|
73
|
+
def chunk_peak_db(self, chunk: bytes) -> float:
|
|
74
|
+
"""Return approximate peak level of a PCM s16le chunk in dBFS."""
|
|
75
|
+
import math
|
|
76
|
+
import struct
|
|
77
|
+
|
|
78
|
+
if not chunk or self._bytes_per_sample != 2:
|
|
79
|
+
return -96.0
|
|
80
|
+
count = len(chunk) // 2
|
|
81
|
+
if count == 0:
|
|
82
|
+
return -96.0
|
|
83
|
+
peak = 0
|
|
84
|
+
# Unpack every 16-bit sample and find max absolute value.
|
|
85
|
+
for i in range(0, len(chunk) - 1, 2):
|
|
86
|
+
sample = struct.unpack("<h", chunk[i : i + 2])[0]
|
|
87
|
+
abs_sample = abs(sample)
|
|
88
|
+
if abs_sample > peak:
|
|
89
|
+
peak = abs_sample
|
|
90
|
+
if peak == 0:
|
|
91
|
+
return -96.0
|
|
92
|
+
return 20.0 * math.log10(peak / 32768.0)
|
|
93
|
+
|
|
94
|
+
def iter_chunks(self) -> Iterator[bytes]:
|
|
95
|
+
while True:
|
|
96
|
+
yield self.read_chunk()
|
|
97
|
+
|
|
98
|
+
def close(self) -> None:
|
|
99
|
+
try:
|
|
100
|
+
self._stream.stop_stream()
|
|
101
|
+
self._stream.close()
|
|
102
|
+
except Exception:
|
|
103
|
+
logger.exception("audio recorder close failed")
|
|
104
|
+
finally:
|
|
105
|
+
self._pyaudio.terminate()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class AudioPlayer:
|
|
109
|
+
"""Blocking speaker output. Run in a thread for async use."""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
sample_rate: int = 24000,
|
|
114
|
+
channels: int = 1,
|
|
115
|
+
format_name: str = "pcm_s16le",
|
|
116
|
+
) -> None:
|
|
117
|
+
if not _HAS_PYAUDIO:
|
|
118
|
+
raise AudioIOError("PyAudio is not installed. Install 'monkeybot[cli-realtime]'.")
|
|
119
|
+
self.sample_rate = sample_rate
|
|
120
|
+
self.channels = channels
|
|
121
|
+
self._format = pyaudio.paInt16 if "s16" in format_name else pyaudio.paInt8
|
|
122
|
+
try:
|
|
123
|
+
self._pyaudio = pyaudio.PyAudio()
|
|
124
|
+
self._stream = self._pyaudio.open(
|
|
125
|
+
format=self._format,
|
|
126
|
+
channels=channels,
|
|
127
|
+
rate=sample_rate,
|
|
128
|
+
output=True,
|
|
129
|
+
)
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
raise AudioIOError(f"Failed to open speaker: {exc}") from exc
|
|
132
|
+
logger.info("audio player opened: rate=%s channels=%s", sample_rate, channels)
|
|
133
|
+
|
|
134
|
+
def write(self, chunk: bytes) -> None:
|
|
135
|
+
try:
|
|
136
|
+
self._stream.write(chunk)
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
raise AudioIOError(f"Speaker write failed: {exc}") from exc
|
|
139
|
+
|
|
140
|
+
def close(self) -> None:
|
|
141
|
+
try:
|
|
142
|
+
self._stream.stop_stream()
|
|
143
|
+
self._stream.close()
|
|
144
|
+
except Exception:
|
|
145
|
+
logger.exception("audio player close failed")
|
|
146
|
+
finally:
|
|
147
|
+
self._pyaudio.terminate()
|