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,17 @@
|
|
|
1
|
+
"""Deprecated shim — use ``encode_client_frame`` and ``is_exit_command``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from monkeybot_cli.exit_commands import is_exit_command
|
|
6
|
+
from monkeybot_cli.realtime.wire_encode import encode_client_frame
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RealtimeClientError(RuntimeError):
|
|
10
|
+
"""Deprecated alias; prefer ``RuntimeError``."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RealtimeClientError",
|
|
15
|
+
"encode_client_frame",
|
|
16
|
+
"is_exit_command",
|
|
17
|
+
]
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Helpers for starting the combined (SSE + realtime) gateway from the CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import urllib.parse
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from monkeybot_cli.runtime_python import (
|
|
14
|
+
COMBINED_GATEWAY_MODULE,
|
|
15
|
+
gateway_argv,
|
|
16
|
+
resolve_runtime_python,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _find_workspace_dir(start: Path | None = None) -> Path | None:
|
|
23
|
+
"""Find the directory containing ``monkeybot_config/monkeybot.yaml``."""
|
|
24
|
+
start = start or Path.cwd()
|
|
25
|
+
for path in [start, *start.parents]:
|
|
26
|
+
if (path / "monkeybot_config" / "monkeybot.yaml").exists():
|
|
27
|
+
return path
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _load_dotenv(workspace_dir: Path) -> None:
|
|
32
|
+
"""Load workspace ``.env`` into the current process environment."""
|
|
33
|
+
env_file = workspace_dir / ".env"
|
|
34
|
+
if not env_file.exists():
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
from dotenv import load_dotenv
|
|
38
|
+
|
|
39
|
+
load_dotenv(env_file, override=False)
|
|
40
|
+
except Exception:
|
|
41
|
+
logger.warning("Failed to load %s", env_file, exc_info=True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _http_base_from_ws_url(url: str) -> str:
|
|
45
|
+
parsed = urllib.parse.urlparse(url)
|
|
46
|
+
host = parsed.hostname or "localhost"
|
|
47
|
+
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
|
48
|
+
scheme = "https" if parsed.scheme == "wss" else "http"
|
|
49
|
+
return f"{scheme}://{host}:{port}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def _is_gateway_healthy(url: str, timeout: float = 2.0) -> bool:
|
|
53
|
+
"""Return True if ``GET {http_base}/health`` succeeds."""
|
|
54
|
+
base = _http_base_from_ws_url(url)
|
|
55
|
+
try:
|
|
56
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
57
|
+
resp = await client.get(f"{base}/health")
|
|
58
|
+
return resp.status_code == 200
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
logger.debug("Gateway health check failed at %s: %s", base, exc)
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def _wait_for_gateway(url: str, max_wait: float = 30.0) -> bool:
|
|
65
|
+
"""Poll health until ready or timeout."""
|
|
66
|
+
deadline = asyncio.get_event_loop().time() + max_wait
|
|
67
|
+
while asyncio.get_event_loop().time() < deadline:
|
|
68
|
+
if await _is_gateway_healthy(url):
|
|
69
|
+
return True
|
|
70
|
+
await asyncio.sleep(0.5)
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _url_is_local(url: str) -> bool:
|
|
75
|
+
"""Return True if the URL points to localhost/127.0.0.1."""
|
|
76
|
+
parsed = urllib.parse.urlparse(url)
|
|
77
|
+
host = (parsed.hostname or "").lower()
|
|
78
|
+
return host in {"localhost", "127.0.0.1", "::1"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def start_gateway_if_needed(
|
|
82
|
+
url: str,
|
|
83
|
+
*,
|
|
84
|
+
start: bool = True,
|
|
85
|
+
) -> asyncio.subprocess.Process | None:
|
|
86
|
+
"""Start a local combined gateway if requested and not already healthy."""
|
|
87
|
+
if not start:
|
|
88
|
+
return None
|
|
89
|
+
if not _url_is_local(url):
|
|
90
|
+
return None
|
|
91
|
+
if await _is_gateway_healthy(url):
|
|
92
|
+
logger.info("Gateway already reachable at %s", url)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
workspace = _find_workspace_dir()
|
|
96
|
+
if workspace is None:
|
|
97
|
+
raise RuntimeError(
|
|
98
|
+
"Could not find monkeybot_config/monkeybot.yaml to start the gateway. "
|
|
99
|
+
"Run the command from a MonkeyBot workspace or start the gateway manually."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
_load_dotenv(workspace)
|
|
103
|
+
parsed = urllib.parse.urlparse(url)
|
|
104
|
+
port = parsed.port or (443 if parsed.scheme == "wss" else 80)
|
|
105
|
+
|
|
106
|
+
env = {**os.environ, "PORT": str(port)}
|
|
107
|
+
env.setdefault("LOG_LEVEL", "error")
|
|
108
|
+
runtime = resolve_runtime_python(workspace)
|
|
109
|
+
cmd = gateway_argv(runtime, module=COMBINED_GATEWAY_MODULE)
|
|
110
|
+
|
|
111
|
+
logger.info("Starting combined gateway on port %s from %s", port, workspace)
|
|
112
|
+
proc = await asyncio.create_subprocess_exec(
|
|
113
|
+
*cmd,
|
|
114
|
+
cwd=str(workspace),
|
|
115
|
+
env=env,
|
|
116
|
+
stdout=None,
|
|
117
|
+
stderr=None,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if not await _wait_for_gateway(url):
|
|
121
|
+
proc.terminate()
|
|
122
|
+
try:
|
|
123
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
124
|
+
except asyncio.TimeoutError:
|
|
125
|
+
proc.kill()
|
|
126
|
+
await proc.wait()
|
|
127
|
+
raise RuntimeError(f"Gateway failed to become reachable at {url}")
|
|
128
|
+
|
|
129
|
+
logger.info("Gateway ready at %s", url)
|
|
130
|
+
return proc
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def stop_gateway(proc: asyncio.subprocess.Process | None) -> None:
|
|
134
|
+
"""Terminate a gateway subprocess started by :func:`start_gateway_if_needed`."""
|
|
135
|
+
if proc is None or proc.returncode is not None:
|
|
136
|
+
return
|
|
137
|
+
proc.terminate()
|
|
138
|
+
try:
|
|
139
|
+
await asyncio.wait_for(proc.wait(), timeout=5.0)
|
|
140
|
+
except asyncio.TimeoutError:
|
|
141
|
+
proc.kill()
|
|
142
|
+
await proc.wait()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Push-to-talk key gate for the realtime CLI.
|
|
2
|
+
|
|
3
|
+
Hold the configured modifier (Command on macOS by default) to unmute the
|
|
4
|
+
microphone. Requires ``pynput`` (installed via ``monkeybot[cli-realtime]``).
|
|
5
|
+
On macOS, grant Accessibility permission to the terminal app if key events
|
|
6
|
+
are not detected.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import threading
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
try:
|
|
18
|
+
from pynput import keyboard
|
|
19
|
+
|
|
20
|
+
_HAS_PYNPUT = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
keyboard = None
|
|
23
|
+
_HAS_PYNPUT = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PushToTalkError(Exception):
|
|
27
|
+
"""Push-to-talk setup failed."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PushToTalkGate:
|
|
31
|
+
"""Tracks whether the push-to-talk key is currently held."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, key_name: str = "cmd") -> None:
|
|
34
|
+
if not _HAS_PYNPUT:
|
|
35
|
+
raise PushToTalkError(
|
|
36
|
+
"pynput is required for push-to-talk. Install with: uv sync --extra cli-realtime"
|
|
37
|
+
)
|
|
38
|
+
self._key_name = key_name.lower().strip()
|
|
39
|
+
self._held = False
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._listener: Any | None = None
|
|
42
|
+
self._target_keys = self._resolve_keys(self._key_name)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def _resolve_keys(key_name: str) -> set[Any]:
|
|
46
|
+
assert keyboard is not None
|
|
47
|
+
aliases: dict[str, set[Any]] = {
|
|
48
|
+
"cmd": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
49
|
+
"command": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
50
|
+
"meta": {keyboard.Key.cmd, keyboard.Key.cmd_l, keyboard.Key.cmd_r},
|
|
51
|
+
"alt": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
|
|
52
|
+
"option": {keyboard.Key.alt, keyboard.Key.alt_l, keyboard.Key.alt_r},
|
|
53
|
+
"ctrl": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
|
54
|
+
"control": {keyboard.Key.ctrl, keyboard.Key.ctrl_l, keyboard.Key.ctrl_r},
|
|
55
|
+
"space": {keyboard.Key.space},
|
|
56
|
+
}
|
|
57
|
+
keys = aliases.get(key_name)
|
|
58
|
+
if keys is None:
|
|
59
|
+
raise PushToTalkError(
|
|
60
|
+
f"Unsupported push-to-talk key '{key_name}'. "
|
|
61
|
+
"Use one of: cmd, alt, ctrl, space"
|
|
62
|
+
)
|
|
63
|
+
return keys
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def key_label(self) -> str:
|
|
67
|
+
labels = {
|
|
68
|
+
"cmd": "⌘ Command",
|
|
69
|
+
"command": "⌘ Command",
|
|
70
|
+
"meta": "⌘ Command",
|
|
71
|
+
"alt": "⌥ Option",
|
|
72
|
+
"option": "⌥ Option",
|
|
73
|
+
"ctrl": "⌃ Control",
|
|
74
|
+
"control": "⌃ Control",
|
|
75
|
+
"space": "Space",
|
|
76
|
+
}
|
|
77
|
+
return labels.get(self._key_name, self._key_name)
|
|
78
|
+
|
|
79
|
+
def is_held(self) -> bool:
|
|
80
|
+
with self._lock:
|
|
81
|
+
return self._held
|
|
82
|
+
|
|
83
|
+
def _on_press(self, key: Any) -> None:
|
|
84
|
+
if key in self._target_keys:
|
|
85
|
+
with self._lock:
|
|
86
|
+
was_held = self._held
|
|
87
|
+
self._held = True
|
|
88
|
+
if not was_held:
|
|
89
|
+
logger.debug("push-to-talk pressed (%s)", self._key_name)
|
|
90
|
+
|
|
91
|
+
def _on_release(self, key: Any) -> None:
|
|
92
|
+
if key in self._target_keys:
|
|
93
|
+
with self._lock:
|
|
94
|
+
was_held = self._held
|
|
95
|
+
self._held = False
|
|
96
|
+
if was_held:
|
|
97
|
+
logger.debug("push-to-talk released (%s)", self._key_name)
|
|
98
|
+
|
|
99
|
+
def start(self) -> None:
|
|
100
|
+
assert keyboard is not None
|
|
101
|
+
if self._listener is not None:
|
|
102
|
+
return
|
|
103
|
+
self._listener = keyboard.Listener(
|
|
104
|
+
on_press=self._on_press,
|
|
105
|
+
on_release=self._on_release,
|
|
106
|
+
)
|
|
107
|
+
self._listener.daemon = True
|
|
108
|
+
try:
|
|
109
|
+
self._listener.start()
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
self._listener = None
|
|
112
|
+
raise PushToTalkError(
|
|
113
|
+
f"Failed to start push-to-talk listener: {exc}. "
|
|
114
|
+
"On macOS, grant Accessibility permission to your terminal app "
|
|
115
|
+
"(System Settings → Privacy & Security → Accessibility)."
|
|
116
|
+
) from exc
|
|
117
|
+
logger.info("push-to-talk ready: hold %s to speak", self.key_label)
|
|
118
|
+
|
|
119
|
+
def stop(self) -> None:
|
|
120
|
+
listener = self._listener
|
|
121
|
+
self._listener = None
|
|
122
|
+
if listener is not None:
|
|
123
|
+
try:
|
|
124
|
+
listener.stop()
|
|
125
|
+
except Exception:
|
|
126
|
+
logger.exception("push-to-talk listener stop failed")
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._held = False
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Talk session orchestration (audio + text entrypoints)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from monkeybot.core.config.realtime_config import get_realtime_config
|
|
13
|
+
from monkeybot.core.logging_utils import normalize_log_level
|
|
14
|
+
|
|
15
|
+
from monkeybot_cli.realtime.audio_io import AudioIOError, AudioPlayer, AudioRecorder
|
|
16
|
+
from monkeybot_cli.realtime.push_to_talk import PushToTalkError, PushToTalkGate
|
|
17
|
+
from monkeybot_cli.runtime_python import DEFAULT_PORT
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(help="MonkeyBot realtime CLI helpers (prefer the monkeybot-cli package)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _setup_logging(*, verbose: bool = False) -> None:
|
|
23
|
+
level = logging.DEBUG if verbose else normalize_log_level(os.getenv("LOG_LEVEL"))
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=level,
|
|
26
|
+
format="%(levelname)s:%(name)s:%(message)s",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _generate_session_id() -> str:
|
|
31
|
+
return uuid.uuid4().hex
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_sample_rate(fmt: str) -> int:
|
|
35
|
+
parts = fmt.lower().split("_")
|
|
36
|
+
for p in parts:
|
|
37
|
+
if p.endswith("khz"):
|
|
38
|
+
try:
|
|
39
|
+
return int(p.replace("khz", "")) * 1000
|
|
40
|
+
except ValueError:
|
|
41
|
+
pass
|
|
42
|
+
return 24000
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def default_gateway_ws_url() -> str:
|
|
46
|
+
"""Default WebSocket URL from env or ``runtime.port`` (DEFAULT_PORT)."""
|
|
47
|
+
env = os.getenv("MONKEYBOT_GATEWAY_URL")
|
|
48
|
+
if env:
|
|
49
|
+
return env.rstrip("/")
|
|
50
|
+
return f"ws://127.0.0.1:{DEFAULT_PORT}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _setup_audio_devices(
|
|
54
|
+
*,
|
|
55
|
+
text: bool,
|
|
56
|
+
input_format: str,
|
|
57
|
+
chunk_ms: int,
|
|
58
|
+
ptt_key: str,
|
|
59
|
+
) -> tuple[bool, AudioRecorder | None, AudioPlayer | None, PushToTalkGate | None, int]:
|
|
60
|
+
"""Open player/recorder/PTT for talk. Returns (audio_enabled, recorder, player, ptt, exit_code).
|
|
61
|
+
|
|
62
|
+
``exit_code`` is non-zero when setup failed fatally (caller should return it).
|
|
63
|
+
"""
|
|
64
|
+
audio_input_enabled = not text
|
|
65
|
+
recorder: AudioRecorder | None = None
|
|
66
|
+
player: AudioPlayer | None = None
|
|
67
|
+
ptt: PushToTalkGate | None = None
|
|
68
|
+
device_hints: list[str] = []
|
|
69
|
+
|
|
70
|
+
if text:
|
|
71
|
+
return False, None, None, None, 0
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
player = AudioPlayer(
|
|
75
|
+
sample_rate=_parse_sample_rate(input_format),
|
|
76
|
+
channels=1,
|
|
77
|
+
format_name=input_format,
|
|
78
|
+
)
|
|
79
|
+
except AudioIOError as exc:
|
|
80
|
+
device_hints.append(f"Audio output unavailable: {exc}")
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
recorder = AudioRecorder(
|
|
84
|
+
sample_rate=_parse_sample_rate(input_format),
|
|
85
|
+
channels=1,
|
|
86
|
+
chunk_ms=chunk_ms,
|
|
87
|
+
format_name=input_format,
|
|
88
|
+
)
|
|
89
|
+
except AudioIOError as exc:
|
|
90
|
+
if player is None:
|
|
91
|
+
print(f"Audio setup failed: {exc}", file=sys.stderr)
|
|
92
|
+
print(
|
|
93
|
+
"Tip: install PortAudio (brew install portaudio) and sync with "
|
|
94
|
+
"'uv sync --extra cli-realtime', or use --text for text-only mode.",
|
|
95
|
+
file=sys.stderr,
|
|
96
|
+
)
|
|
97
|
+
return False, None, None, None, 1
|
|
98
|
+
device_hints.append(f"Microphone unavailable: {exc} Continuing with text input.")
|
|
99
|
+
audio_input_enabled = False
|
|
100
|
+
|
|
101
|
+
if audio_input_enabled:
|
|
102
|
+
try:
|
|
103
|
+
ptt = PushToTalkGate(key_name=ptt_key)
|
|
104
|
+
except PushToTalkError as exc:
|
|
105
|
+
print(f"Push-to-talk unavailable: {exc}", file=sys.stderr)
|
|
106
|
+
print(
|
|
107
|
+
"Tip: sync with 'uv sync --extra cli-realtime'. "
|
|
108
|
+
"On macOS, grant Accessibility permission to your terminal app. "
|
|
109
|
+
"Space in an empty composer toggles in-TUI PTT.",
|
|
110
|
+
file=sys.stderr,
|
|
111
|
+
)
|
|
112
|
+
# Continue without global PTT — in-TUI Space still works when TTY.
|
|
113
|
+
ptt = None
|
|
114
|
+
|
|
115
|
+
for hint in device_hints:
|
|
116
|
+
print(hint, file=sys.stderr)
|
|
117
|
+
|
|
118
|
+
return audio_input_enabled and recorder is not None, recorder, player, ptt, 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def run_talk_session(
|
|
122
|
+
*,
|
|
123
|
+
gateway_url: str | None = None,
|
|
124
|
+
session_id: str | None = None,
|
|
125
|
+
text: bool = False,
|
|
126
|
+
ptt_key: str = "cmd",
|
|
127
|
+
start_gateway: bool = True,
|
|
128
|
+
input_format: str = "pcm_s16le_24khz_mono",
|
|
129
|
+
chunk_ms: int = 200,
|
|
130
|
+
verbose: bool = False,
|
|
131
|
+
) -> int:
|
|
132
|
+
"""Run a realtime talk session via ``RealtimeSessionController``. Returns exit code."""
|
|
133
|
+
_setup_logging(verbose=verbose)
|
|
134
|
+
get_realtime_config()
|
|
135
|
+
if not session_id:
|
|
136
|
+
session_id = _generate_session_id()
|
|
137
|
+
if not gateway_url:
|
|
138
|
+
gateway_url = default_gateway_ws_url()
|
|
139
|
+
|
|
140
|
+
from monkeybot_cli.realtime.talk_ui import run_talk_ui_session
|
|
141
|
+
|
|
142
|
+
audio_enabled, recorder, player, ptt, setup_code = _setup_audio_devices(
|
|
143
|
+
text=text,
|
|
144
|
+
input_format=input_format,
|
|
145
|
+
chunk_ms=chunk_ms,
|
|
146
|
+
ptt_key=ptt_key,
|
|
147
|
+
)
|
|
148
|
+
if setup_code != 0:
|
|
149
|
+
return setup_code
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
return run_talk_ui_session(
|
|
153
|
+
gateway_url=gateway_url,
|
|
154
|
+
session_id=session_id,
|
|
155
|
+
start_gateway=start_gateway,
|
|
156
|
+
verbose=verbose,
|
|
157
|
+
audio_enabled=audio_enabled,
|
|
158
|
+
audio_recorder=recorder,
|
|
159
|
+
audio_player=player,
|
|
160
|
+
push_to_talk=ptt,
|
|
161
|
+
)
|
|
162
|
+
finally:
|
|
163
|
+
if recorder is not None:
|
|
164
|
+
recorder.close()
|
|
165
|
+
if player is not None:
|
|
166
|
+
player.close()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@app.command("talk")
|
|
170
|
+
def talk(
|
|
171
|
+
gateway_url: Annotated[
|
|
172
|
+
str,
|
|
173
|
+
typer.Option(
|
|
174
|
+
"--gateway-url",
|
|
175
|
+
help="Base URL of the MonkeyBot realtime gateway, e.g. ws://127.0.0.1:8080",
|
|
176
|
+
envvar="MONKEYBOT_GATEWAY_URL",
|
|
177
|
+
),
|
|
178
|
+
] = "",
|
|
179
|
+
session_id: Annotated[
|
|
180
|
+
str | None,
|
|
181
|
+
typer.Option(
|
|
182
|
+
"--session-id",
|
|
183
|
+
help="Session ID to connect to; generated if not provided",
|
|
184
|
+
envvar="MONKEYBOT_SESSION_ID",
|
|
185
|
+
),
|
|
186
|
+
] = None,
|
|
187
|
+
text: Annotated[
|
|
188
|
+
bool,
|
|
189
|
+
typer.Option(
|
|
190
|
+
"--text/--no-text",
|
|
191
|
+
help="Text-only input (no microphone). Audio output is still used when available.",
|
|
192
|
+
),
|
|
193
|
+
] = False,
|
|
194
|
+
ptt_key: Annotated[
|
|
195
|
+
str,
|
|
196
|
+
typer.Option(
|
|
197
|
+
"--ptt-key",
|
|
198
|
+
help="Push-to-talk key to hold while speaking: cmd, alt, ctrl, or space",
|
|
199
|
+
),
|
|
200
|
+
] = "cmd",
|
|
201
|
+
start_gateway: Annotated[
|
|
202
|
+
bool,
|
|
203
|
+
typer.Option(
|
|
204
|
+
"--start-gateway/--no-start-gateway",
|
|
205
|
+
help="Start a local realtime gateway if one is not already reachable",
|
|
206
|
+
),
|
|
207
|
+
] = True,
|
|
208
|
+
input_format: Annotated[
|
|
209
|
+
str,
|
|
210
|
+
typer.Option(
|
|
211
|
+
"--input-format",
|
|
212
|
+
help="Audio format, e.g. pcm_s16le_24khz_mono",
|
|
213
|
+
),
|
|
214
|
+
] = "pcm_s16le_24khz_mono",
|
|
215
|
+
chunk_ms: Annotated[
|
|
216
|
+
int,
|
|
217
|
+
typer.Option("--chunk-ms", help="Audio chunk size in milliseconds"),
|
|
218
|
+
] = 200,
|
|
219
|
+
verbose: Annotated[
|
|
220
|
+
bool,
|
|
221
|
+
typer.Option(
|
|
222
|
+
"--verbose/--no-verbose",
|
|
223
|
+
help="Enable debug logging for audio chunks and gateway events",
|
|
224
|
+
),
|
|
225
|
+
] = False,
|
|
226
|
+
) -> None:
|
|
227
|
+
"""Talk with a MonkeyBot realtime agent."""
|
|
228
|
+
code = run_talk_session(
|
|
229
|
+
gateway_url=gateway_url or None,
|
|
230
|
+
session_id=session_id,
|
|
231
|
+
text=text,
|
|
232
|
+
ptt_key=ptt_key,
|
|
233
|
+
start_gateway=start_gateway,
|
|
234
|
+
input_format=input_format,
|
|
235
|
+
chunk_ms=chunk_ms,
|
|
236
|
+
verbose=verbose,
|
|
237
|
+
)
|
|
238
|
+
if code != 0:
|
|
239
|
+
raise typer.Exit(code)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.command()
|
|
243
|
+
def version() -> None:
|
|
244
|
+
"""Show MonkeyBot version."""
|
|
245
|
+
from importlib.metadata import version as get_version
|
|
246
|
+
|
|
247
|
+
typer.echo(get_version("monkeybot"))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def main() -> None:
|
|
251
|
+
"""Console entrypoint for realtime helpers (prefer ``monkeybot`` from monkeybot-cli)."""
|
|
252
|
+
app()
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
main()
|