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.
Files changed (51) hide show
  1. monkeybot_cli/__init__.py +3 -0
  2. monkeybot_cli/chat_renderer.py +87 -0
  3. monkeybot_cli/chat_session.py +911 -0
  4. monkeybot_cli/chat_status_bar.py +205 -0
  5. monkeybot_cli/chat_theme.py +91 -0
  6. monkeybot_cli/chat_tool_display.py +334 -0
  7. monkeybot_cli/chat_tui.py +1491 -0
  8. monkeybot_cli/chat_tui_widgets.py +996 -0
  9. monkeybot_cli/commands/__init__.py +1 -0
  10. monkeybot_cli/commands/chat.py +817 -0
  11. monkeybot_cli/commands/doctor.py +293 -0
  12. monkeybot_cli/commands/loop.py +207 -0
  13. monkeybot_cli/commands/new.py +207 -0
  14. monkeybot_cli/commands/run_cmd.py +41 -0
  15. monkeybot_cli/commands/talk.py +102 -0
  16. monkeybot_cli/commands/validate.py +385 -0
  17. monkeybot_cli/compat.py +7 -0
  18. monkeybot_cli/config_resolve.py +55 -0
  19. monkeybot_cli/exit_commands.py +13 -0
  20. monkeybot_cli/extras_catalog.py +95 -0
  21. monkeybot_cli/gateway_health.py +34 -0
  22. monkeybot_cli/main.py +38 -0
  23. monkeybot_cli/opensandbox_lifecycle.py +314 -0
  24. monkeybot_cli/output.py +110 -0
  25. monkeybot_cli/providers.py +112 -0
  26. monkeybot_cli/realtime/__init__.py +13 -0
  27. monkeybot_cli/realtime/audio_io.py +147 -0
  28. monkeybot_cli/realtime/client.py +17 -0
  29. monkeybot_cli/realtime/gateway_manager.py +142 -0
  30. monkeybot_cli/realtime/push_to_talk.py +128 -0
  31. monkeybot_cli/realtime/session.py +256 -0
  32. monkeybot_cli/realtime/session_controller.py +501 -0
  33. monkeybot_cli/realtime/talk_ui.py +243 -0
  34. monkeybot_cli/realtime/wire_encode.py +39 -0
  35. monkeybot_cli/runtime_python.py +91 -0
  36. monkeybot_cli/scaffold.py +287 -0
  37. monkeybot_cli/scaffold_defaults/AGENT.md +56 -0
  38. monkeybot_cli/scaffold_defaults/__init__.py +1 -0
  39. monkeybot_cli/scaffold_defaults/command_allowlist.yaml +57 -0
  40. monkeybot_cli/scaffold_defaults/env.example +35 -0
  41. monkeybot_cli/scaffold_defaults/mcp.json +49 -0
  42. monkeybot_cli/scaffold_defaults/monkeybot.example.yaml +191 -0
  43. monkeybot_cli/scaffold_defaults/otel-collector.example.yaml +57 -0
  44. monkeybot_cli/scaffold_defaults/permissions.yaml +32 -0
  45. monkeybot_cli/scaffold_defaults/setup-workspace.sh +24 -0
  46. monkeybot_cli/session_controller.py +7 -0
  47. monkeybot_cli/terminal_markdown.py +48 -0
  48. monkeybot_cli-0.2.1.dist-info/METADATA +10 -0
  49. monkeybot_cli-0.2.1.dist-info/RECORD +51 -0
  50. monkeybot_cli-0.2.1.dist-info/WHEEL +4 -0
  51. monkeybot_cli-0.2.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,501 @@
1
+ """WebSocket session controller that emits ChatUiEvent for the shared TUI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import json
8
+ import logging
9
+ import time
10
+ from collections.abc import Callable
11
+ from typing import Any, Literal
12
+
13
+ import websockets
14
+ from monkeybot.gateway.realtime.wire import (
15
+ ClientAudioStreamEndFrame,
16
+ ClientCloseFrame,
17
+ ClientElicitationResponseFrame,
18
+ ClientInterruptFrame,
19
+ ClientTextFrame,
20
+ ClientToolConfirmationResponseFrame,
21
+ ProtocolError,
22
+ ServerConnectedFrame,
23
+ ServerElicitationFrame,
24
+ ServerErrorFrame,
25
+ ServerInterruptedFrame,
26
+ ServerSessionEndedFrame,
27
+ ServerTextDeltaFrame,
28
+ ServerToolCallFrame,
29
+ ServerToolConfirmationFrame,
30
+ ServerToolResultFrame,
31
+ ServerTurnBoundaryFrame,
32
+ ServerUsageFrame,
33
+ ServerUserTranscriptFrame,
34
+ parse_server_frame,
35
+ )
36
+ from websockets.typing import Data
37
+
38
+ from monkeybot_cli.chat_session import ChatUiEvent, HitlAnswer
39
+ from monkeybot_cli.chat_status_bar import UsageStore, parse_usage_response
40
+ from monkeybot_cli.realtime.wire_encode import encode_client_frame
41
+
42
+ logger = logging.getLogger(__name__)
43
+
44
+ EmitFn = Callable[[ChatUiEvent], None]
45
+
46
+ _MIC_ENERGY_THRESHOLD_DB = -40.0
47
+ _POST_SPEAK_MUTE_SEC = 0.4
48
+ _AUDIO_CHUNK_EMIT_EVERY = 5
49
+
50
+ VoiceState = Literal["listening", "muted", "ptt_held", "speaking"]
51
+
52
+
53
+ class RealtimeSessionController:
54
+ """Owns the realtime WebSocket loop; emits UI events; never writes stdout."""
55
+
56
+ turn_based: bool = False
57
+
58
+ def __init__(
59
+ self,
60
+ *,
61
+ gateway_url: str,
62
+ session_id: str,
63
+ emit: EmitFn | None = None,
64
+ audio_enabled: bool = False,
65
+ audio_recorder: Any | None = None,
66
+ audio_player: Any | None = None,
67
+ push_to_talk: Any | None = None,
68
+ verbose: bool = False,
69
+ ) -> None:
70
+ self.gateway_url = gateway_url.rstrip("/")
71
+ self.session_id = session_id
72
+ self._emit_fn = emit or (lambda _e: None)
73
+ self.audio_enabled = audio_enabled and audio_recorder is not None
74
+ self.audio_recorder = audio_recorder
75
+ self.audio_player = audio_player
76
+ self.push_to_talk = push_to_talk
77
+ self.verbose = verbose
78
+ self.show_usage = False
79
+ self.usage = UsageStore()
80
+ self.stream_error = False
81
+ self._stream_alive = False
82
+ self._reconnecting = False
83
+ self._closed = False
84
+ self._stop = asyncio.Event()
85
+ self._ws: Any | None = None
86
+ self._recv_task: asyncio.Task[None] | None = None
87
+ self._audio_task: asyncio.Task[None] | None = None
88
+ self._assistant_open = False
89
+ self._model_speaking = False
90
+ self._last_model_audio_at = 0.0
91
+ self._tui_ptt_held = False
92
+ self._voice_state: VoiceState | None = None
93
+ self._pending_hitl: dict[str, Any] | None = None
94
+ self._background_tasks: set[asyncio.Task[None]] = set()
95
+ self._out_chunk_n = 0
96
+ self._in_chunk_n = 0
97
+ self._last_usage_payload: dict[str, Any] | None = None
98
+
99
+ def _emit(self, kind: str, **payload: Any) -> None:
100
+ self._emit_fn(ChatUiEvent(kind=kind, payload=payload))
101
+
102
+ def set_emit(self, emit: EmitFn) -> None:
103
+ """Replace the UI event sink (used when the TUI takes ownership)."""
104
+ self._emit_fn = emit
105
+
106
+ @property
107
+ def stream_alive(self) -> bool:
108
+ return self._stream_alive and not self._closed
109
+
110
+ @property
111
+ def reconnecting(self) -> bool:
112
+ return self._reconnecting
113
+
114
+ def set_ptt_held(self, held: bool) -> None:
115
+ """In-TUI Space PTT (SSH-safe alternative to global pynput)."""
116
+ self._tui_ptt_held = bool(held)
117
+ self._emit_voice_state()
118
+
119
+ def _ptt_held(self) -> bool:
120
+ if self.push_to_talk is not None and self.push_to_talk.is_held():
121
+ return True
122
+ return self._tui_ptt_held
123
+
124
+ def _mic_open(self) -> bool:
125
+ if self._stop.is_set():
126
+ return False
127
+ if self._model_speaking:
128
+ return False
129
+ if (time.monotonic() - self._last_model_audio_at) < _POST_SPEAK_MUTE_SEC:
130
+ return False
131
+ if self.push_to_talk is not None or self._tui_ptt_held:
132
+ return self._ptt_held()
133
+ # No PTT configured: open mic when audio enabled (open-mic mode).
134
+ return self.audio_enabled
135
+
136
+ def _compute_voice_state(self, *, level_db: float | None = None) -> VoiceState:
137
+ if self._model_speaking:
138
+ return "speaking"
139
+ if self._ptt_held() and self._mic_open():
140
+ return "ptt_held"
141
+ if self._mic_open():
142
+ return "listening"
143
+ return "muted"
144
+
145
+ def _emit_voice_state(self, *, level_db: float | None = None) -> None:
146
+ state = self._compute_voice_state(level_db=level_db)
147
+ if state == self._voice_state and level_db is None:
148
+ return
149
+ self._voice_state = state
150
+ payload: dict[str, Any] = {"state": state}
151
+ if level_db is not None:
152
+ payload["level_db"] = level_db
153
+ self._emit("voice_state", **payload)
154
+
155
+ async def connect(self, resume_session_id: str | None = None) -> None:
156
+ if resume_session_id and resume_session_id.strip():
157
+ self.session_id = resume_session_id.strip()
158
+ url = f"{self.gateway_url}/sessions/{self.session_id}/realtime"
159
+ self._stop.clear()
160
+ self._closed = False
161
+ if self.push_to_talk is not None:
162
+ try:
163
+ self.push_to_talk.start()
164
+ except Exception:
165
+ logger.exception("push-to-talk start failed")
166
+ self._emit(
167
+ "device_error",
168
+ message="Push-to-talk failed to start",
169
+ hint="Check Accessibility permissions, or use in-TUI Space PTT / --text.",
170
+ )
171
+ self.push_to_talk = None
172
+ try:
173
+ self._ws = await websockets.connect(url)
174
+ await self._ws.send(
175
+ json.dumps({"kind": "connect", "session_id": self.session_id})
176
+ )
177
+ except websockets.exceptions.InvalidStatus as exc:
178
+ status = getattr(exc, "status_code", getattr(exc, "status", "unknown"))
179
+ raise RuntimeError(f"Failed to connect to {url}: HTTP {status}") from exc
180
+ except Exception as exc:
181
+ raise RuntimeError(f"Realtime connect failed: {exc}") from exc
182
+
183
+ self._stream_alive = True
184
+ self.stream_error = False
185
+ self._recv_task = asyncio.create_task(self._receive_loop(), name="rt-recv")
186
+ if self.audio_enabled and self.audio_recorder is not None:
187
+ self._audio_task = asyncio.create_task(self._send_audio(), name="rt-audio")
188
+ self._emit("connection_state", state="connected")
189
+ self._emit_voice_state()
190
+
191
+ async def _receive_loop(self) -> None:
192
+ assert self._ws is not None
193
+ try:
194
+ async for raw in self._ws:
195
+ if self._stop.is_set():
196
+ return
197
+ await self._handle_server_frame(raw)
198
+ except websockets.exceptions.ConnectionClosed:
199
+ logger.debug("realtime websocket closed")
200
+ except Exception as exc:
201
+ logger.exception("realtime receive loop failed")
202
+ self.stream_error = True
203
+ self._emit("stream_failed", message=str(exc), fatal=True)
204
+ finally:
205
+ self._stream_alive = False
206
+ if not self._stop.is_set():
207
+ self._emit("stream_ended")
208
+
209
+ async def _send_audio(self) -> None:
210
+ if self.audio_recorder is None or self._ws is None:
211
+ return
212
+ was_open = False
213
+ while not self._stop.is_set():
214
+ try:
215
+ chunk = await asyncio.to_thread(self.audio_recorder.read_chunk)
216
+ except Exception as exc:
217
+ self._emit(
218
+ "device_error",
219
+ message=f"Microphone read failed: {exc}",
220
+ hint="Check PortAudio / mic permissions, or use --text.",
221
+ )
222
+ return
223
+ if self._stop.is_set() or self._ws is None:
224
+ return
225
+ mic_open = self._mic_open()
226
+ if was_open and not mic_open:
227
+ try:
228
+ await self._ws.send(encode_client_frame(ClientAudioStreamEndFrame()))
229
+ except Exception:
230
+ logger.warning("failed to send audio_stream_end", exc_info=True)
231
+ self._emit_voice_state()
232
+ was_open = mic_open
233
+ if not mic_open:
234
+ self._emit_voice_state()
235
+ await asyncio.sleep(0.02)
236
+ continue
237
+ level: float | None = None
238
+ if hasattr(self.audio_recorder, "chunk_peak_db"):
239
+ level = float(self.audio_recorder.chunk_peak_db(chunk))
240
+ if level < _MIC_ENERGY_THRESHOLD_DB:
241
+ await asyncio.sleep(0)
242
+ continue
243
+ try:
244
+ await self._ws.send(chunk)
245
+ except Exception as exc:
246
+ logger.exception("realtime outbound audio send failed")
247
+ self.stream_error = True
248
+ self._emit("stream_failed", message=str(exc), fatal=True)
249
+ return
250
+ self._in_chunk_n += 1
251
+ if self._in_chunk_n % _AUDIO_CHUNK_EMIT_EVERY == 0:
252
+ self._emit("audio_chunk", direction="in", level_db=level)
253
+ self._emit_voice_state(level_db=level)
254
+ else:
255
+ self._emit_voice_state(level_db=level)
256
+ await asyncio.sleep(0)
257
+
258
+ async def _handle_server_frame(self, raw: Data) -> None:
259
+ if isinstance(raw, bytes):
260
+ await self._on_audio_bytes(raw)
261
+ return
262
+ try:
263
+ frame = parse_server_frame(raw)
264
+ except ProtocolError as exc:
265
+ logger.warning("ignored malformed server frame: %s", exc)
266
+ return
267
+
268
+ if isinstance(frame, ServerConnectedFrame):
269
+ self._on_connected(frame)
270
+ elif isinstance(frame, ServerUserTranscriptFrame):
271
+ self._emit(
272
+ "user_transcript",
273
+ text=frame.text,
274
+ is_final=bool(frame.is_final),
275
+ )
276
+ elif isinstance(frame, ServerTextDeltaFrame):
277
+ self._on_text_delta(frame)
278
+ elif isinstance(frame, ServerTurnBoundaryFrame):
279
+ self._on_turn_boundary(frame)
280
+ elif isinstance(frame, ServerToolCallFrame):
281
+ self._emit(
282
+ "tool_started",
283
+ tool=frame.name,
284
+ label=frame.name,
285
+ args=dict(frame.args or {}),
286
+ call_id=frame.call_id or "",
287
+ )
288
+ elif isinstance(frame, ServerToolResultFrame):
289
+ self._emit(
290
+ "tool_finished",
291
+ tool=frame.name,
292
+ error=frame.error,
293
+ result=frame.result or "",
294
+ verbose=self.verbose,
295
+ call_id=frame.call_id or "",
296
+ )
297
+ elif isinstance(frame, ServerUsageFrame):
298
+ payload = dict(frame.usage or {})
299
+ self._last_usage_payload = payload
300
+ view = parse_usage_response(payload)
301
+ self.usage.update(view)
302
+ self._emit("usage_updated", usage=view)
303
+ elif isinstance(frame, ServerToolConfirmationFrame):
304
+ self._pending_hitl = {
305
+ "hitl_kind": "confirm",
306
+ "tool_call_id": frame.tool_call_id,
307
+ "tool_name": frame.tool_name,
308
+ "prompt": frame.prompt,
309
+ "arguments": dict(frame.arguments or {}),
310
+ "timeout_sec": frame.timeout_sec,
311
+ }
312
+ self._emit("hitl_required", **self._pending_hitl)
313
+ elif isinstance(frame, ServerElicitationFrame):
314
+ self._pending_hitl = {
315
+ "hitl_kind": "elicit",
316
+ "elicitation_id": frame.elicitation_id,
317
+ "prompt": frame.prompt,
318
+ "schema": frame.schema,
319
+ "timeout_sec": frame.timeout_sec,
320
+ }
321
+ self._emit("hitl_required", **self._pending_hitl)
322
+ elif isinstance(frame, ServerInterruptedFrame):
323
+ self._model_speaking = False
324
+ self._assistant_open = False
325
+ self._emit_voice_state()
326
+ self._emit("turn_aborted", cancel_ok=True)
327
+ elif isinstance(frame, ServerErrorFrame):
328
+ self._emit("turn_error", error=frame.error)
329
+ elif isinstance(frame, ServerSessionEndedFrame):
330
+ self._emit("stream_ended")
331
+ self._stop.set()
332
+ self._stream_alive = False
333
+
334
+ async def _on_audio_bytes(self, raw: bytes) -> None:
335
+ self._model_speaking = True
336
+ self._last_model_audio_at = time.monotonic()
337
+ self._out_chunk_n += 1
338
+ if self._out_chunk_n % _AUDIO_CHUNK_EMIT_EVERY == 0:
339
+ self._emit("audio_chunk", direction="out")
340
+ self._emit_voice_state()
341
+ if self.audio_player is not None:
342
+ await asyncio.to_thread(self.audio_player.write, raw)
343
+
344
+ def _on_connected(self, frame: ServerConnectedFrame) -> None:
345
+ self.session_id = frame.session_id or self.session_id
346
+ self._emit(
347
+ "session_ready",
348
+ session_id=self.session_id,
349
+ input_format=frame.input_format,
350
+ output_format=frame.output_format,
351
+ chunk_ms=frame.chunk_ms,
352
+ )
353
+
354
+ def _on_text_delta(self, frame: ServerTextDeltaFrame) -> None:
355
+ if not self._assistant_open:
356
+ self._emit("assistant_start")
357
+ self._assistant_open = True
358
+ if frame.delta:
359
+ self._emit("assistant_delta", delta=frame.delta)
360
+ if frame.is_final:
361
+ self._assistant_open = False
362
+ self._emit("turn_complete", usage=None)
363
+
364
+ def _on_turn_boundary(self, frame: ServerTurnBoundaryFrame) -> None:
365
+ if frame.role == "assistant":
366
+ self._model_speaking = False
367
+ self._last_model_audio_at = time.monotonic()
368
+ self._emit_voice_state()
369
+ if self._assistant_open:
370
+ self._assistant_open = False
371
+ self._emit("turn_complete", usage=None)
372
+ elif frame.role == "user":
373
+ self._emit("thinking", text="listening…")
374
+
375
+ async def submit(self, message: str) -> None:
376
+ if self._ws is None or not self._stream_alive:
377
+ self._emit("error", message="Not connected")
378
+ return
379
+ text = message.strip()
380
+ if not text:
381
+ return
382
+ self._emit("turn_started", request_id="")
383
+ self._emit("thinking", text="thinking…")
384
+ try:
385
+ await self._ws.send(encode_client_frame(ClientTextFrame(text=text)))
386
+ except Exception as exc:
387
+ self._emit("error", message=f"Send failed: {exc}")
388
+
389
+ def _spawn_background(self, coro: Any, *, name: str) -> None:
390
+ """Schedule ``coro`` and retain the task so it is not GC'd mid-flight."""
391
+ try:
392
+ loop = asyncio.get_running_loop()
393
+ except RuntimeError:
394
+ logger.debug("%s called with no running event loop", name)
395
+ return
396
+ task = loop.create_task(coro, name=name)
397
+ self._background_tasks.add(task)
398
+ task.add_done_callback(self._background_tasks.discard)
399
+
400
+ def abort_turn(self) -> None:
401
+ if self._ws is None:
402
+ return
403
+
404
+ async def _interrupt() -> None:
405
+ try:
406
+ assert self._ws is not None
407
+ await self._ws.send(encode_client_frame(ClientInterruptFrame()))
408
+ except Exception:
409
+ logger.warning("failed to send interrupt frame", exc_info=True)
410
+
411
+ self._spawn_background(_interrupt(), name="rt-abort")
412
+
413
+ def provide_hitl_answer(self, answer: HitlAnswer) -> None:
414
+ if self._ws is None or self._pending_hitl is None:
415
+ return
416
+ pending = self._pending_hitl
417
+ self._pending_hitl = None
418
+
419
+ async def _send() -> None:
420
+ assert self._ws is not None
421
+ if answer.cancelled:
422
+ if pending.get("hitl_kind") == "confirm":
423
+ frame = ClientToolConfirmationResponseFrame(
424
+ tool_call_id=str(pending.get("tool_call_id") or ""),
425
+ approved=False,
426
+ reason="cancelled",
427
+ )
428
+ else:
429
+ frame = ClientElicitationResponseFrame(
430
+ elicitation_id=str(pending.get("elicitation_id") or ""),
431
+ user_data=None,
432
+ cancelled=True,
433
+ )
434
+ elif pending.get("hitl_kind") == "confirm":
435
+ frame = ClientToolConfirmationResponseFrame(
436
+ tool_call_id=str(pending.get("tool_call_id") or ""),
437
+ approved=bool(answer.approved),
438
+ reason=answer.text or "",
439
+ )
440
+ else:
441
+ frame = ClientElicitationResponseFrame(
442
+ elicitation_id=str(pending.get("elicitation_id") or ""),
443
+ user_data=answer.user_data if answer.user_data is not None else answer.text,
444
+ cancelled=False,
445
+ )
446
+ try:
447
+ await self._ws.send(encode_client_frame(frame))
448
+ except Exception:
449
+ logger.warning("failed to send HITL response frame", exc_info=True)
450
+ self._emit("error", message="Failed to send confirmation response")
451
+
452
+ self._spawn_background(_send(), name="rt-hitl")
453
+
454
+ async def restart_session(self) -> None:
455
+ self._emit("error", message="Session restart is not supported in realtime mode")
456
+
457
+ async def resume_session(self, session_id: str) -> None:
458
+ await self.close()
459
+ self.session_id = session_id
460
+ await self.connect(session_id)
461
+
462
+ async def refresh_usage(self) -> None:
463
+ if self._last_usage_payload is not None:
464
+ view = parse_usage_response(self._last_usage_payload)
465
+ self.usage.update(view)
466
+ self._emit("usage_updated", usage=view)
467
+
468
+ async def close(self) -> None:
469
+ if self._closed:
470
+ return
471
+ self._closed = True
472
+ self._stop.set()
473
+ if self.push_to_talk is not None:
474
+ try:
475
+ self.push_to_talk.stop()
476
+ except Exception:
477
+ logger.warning("push-to-talk stop failed", exc_info=True)
478
+ ws = self._ws
479
+ self._ws = None
480
+ if ws is not None:
481
+ try:
482
+ await ws.send(encode_client_frame(ClientCloseFrame(reason="client_close")))
483
+ except Exception:
484
+ logger.debug("close frame send failed during shutdown", exc_info=True)
485
+ try:
486
+ await ws.close()
487
+ except Exception:
488
+ logger.debug("websocket close failed during shutdown", exc_info=True)
489
+ for task in (self._audio_task, self._recv_task):
490
+ if task is not None:
491
+ task.cancel()
492
+ with contextlib.suppress(asyncio.CancelledError):
493
+ await task
494
+ for task in list(self._background_tasks):
495
+ task.cancel()
496
+ with contextlib.suppress(asyncio.CancelledError, Exception):
497
+ await task
498
+ self._background_tasks.clear()
499
+ self._audio_task = None
500
+ self._recv_task = None
501
+ self._stream_alive = False