agent-cli 0.70.5__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 (196) hide show
  1. agent_cli/__init__.py +5 -0
  2. agent_cli/__main__.py +6 -0
  3. agent_cli/_extras.json +14 -0
  4. agent_cli/_requirements/.gitkeep +0 -0
  5. agent_cli/_requirements/audio.txt +79 -0
  6. agent_cli/_requirements/faster-whisper.txt +215 -0
  7. agent_cli/_requirements/kokoro.txt +425 -0
  8. agent_cli/_requirements/llm.txt +183 -0
  9. agent_cli/_requirements/memory.txt +355 -0
  10. agent_cli/_requirements/mlx-whisper.txt +222 -0
  11. agent_cli/_requirements/piper.txt +176 -0
  12. agent_cli/_requirements/rag.txt +402 -0
  13. agent_cli/_requirements/server.txt +154 -0
  14. agent_cli/_requirements/speed.txt +77 -0
  15. agent_cli/_requirements/vad.txt +155 -0
  16. agent_cli/_requirements/wyoming.txt +71 -0
  17. agent_cli/_tools.py +368 -0
  18. agent_cli/agents/__init__.py +23 -0
  19. agent_cli/agents/_voice_agent_common.py +136 -0
  20. agent_cli/agents/assistant.py +383 -0
  21. agent_cli/agents/autocorrect.py +284 -0
  22. agent_cli/agents/chat.py +496 -0
  23. agent_cli/agents/memory/__init__.py +31 -0
  24. agent_cli/agents/memory/add.py +190 -0
  25. agent_cli/agents/memory/proxy.py +160 -0
  26. agent_cli/agents/rag_proxy.py +128 -0
  27. agent_cli/agents/speak.py +209 -0
  28. agent_cli/agents/transcribe.py +671 -0
  29. agent_cli/agents/transcribe_daemon.py +499 -0
  30. agent_cli/agents/voice_edit.py +291 -0
  31. agent_cli/api.py +22 -0
  32. agent_cli/cli.py +106 -0
  33. agent_cli/config.py +503 -0
  34. agent_cli/config_cmd.py +307 -0
  35. agent_cli/constants.py +27 -0
  36. agent_cli/core/__init__.py +1 -0
  37. agent_cli/core/audio.py +461 -0
  38. agent_cli/core/audio_format.py +299 -0
  39. agent_cli/core/chroma.py +88 -0
  40. agent_cli/core/deps.py +191 -0
  41. agent_cli/core/openai_proxy.py +139 -0
  42. agent_cli/core/process.py +195 -0
  43. agent_cli/core/reranker.py +120 -0
  44. agent_cli/core/sse.py +87 -0
  45. agent_cli/core/transcription_logger.py +70 -0
  46. agent_cli/core/utils.py +526 -0
  47. agent_cli/core/vad.py +175 -0
  48. agent_cli/core/watch.py +65 -0
  49. agent_cli/dev/__init__.py +14 -0
  50. agent_cli/dev/cli.py +1588 -0
  51. agent_cli/dev/coding_agents/__init__.py +19 -0
  52. agent_cli/dev/coding_agents/aider.py +24 -0
  53. agent_cli/dev/coding_agents/base.py +167 -0
  54. agent_cli/dev/coding_agents/claude.py +39 -0
  55. agent_cli/dev/coding_agents/codex.py +24 -0
  56. agent_cli/dev/coding_agents/continue_dev.py +15 -0
  57. agent_cli/dev/coding_agents/copilot.py +24 -0
  58. agent_cli/dev/coding_agents/cursor_agent.py +48 -0
  59. agent_cli/dev/coding_agents/gemini.py +28 -0
  60. agent_cli/dev/coding_agents/opencode.py +15 -0
  61. agent_cli/dev/coding_agents/registry.py +49 -0
  62. agent_cli/dev/editors/__init__.py +19 -0
  63. agent_cli/dev/editors/base.py +89 -0
  64. agent_cli/dev/editors/cursor.py +15 -0
  65. agent_cli/dev/editors/emacs.py +46 -0
  66. agent_cli/dev/editors/jetbrains.py +56 -0
  67. agent_cli/dev/editors/nano.py +31 -0
  68. agent_cli/dev/editors/neovim.py +33 -0
  69. agent_cli/dev/editors/registry.py +59 -0
  70. agent_cli/dev/editors/sublime.py +20 -0
  71. agent_cli/dev/editors/vim.py +42 -0
  72. agent_cli/dev/editors/vscode.py +15 -0
  73. agent_cli/dev/editors/zed.py +20 -0
  74. agent_cli/dev/project.py +568 -0
  75. agent_cli/dev/registry.py +52 -0
  76. agent_cli/dev/skill/SKILL.md +141 -0
  77. agent_cli/dev/skill/examples.md +571 -0
  78. agent_cli/dev/terminals/__init__.py +19 -0
  79. agent_cli/dev/terminals/apple_terminal.py +82 -0
  80. agent_cli/dev/terminals/base.py +56 -0
  81. agent_cli/dev/terminals/gnome.py +51 -0
  82. agent_cli/dev/terminals/iterm2.py +84 -0
  83. agent_cli/dev/terminals/kitty.py +77 -0
  84. agent_cli/dev/terminals/registry.py +48 -0
  85. agent_cli/dev/terminals/tmux.py +58 -0
  86. agent_cli/dev/terminals/warp.py +132 -0
  87. agent_cli/dev/terminals/zellij.py +78 -0
  88. agent_cli/dev/worktree.py +856 -0
  89. agent_cli/docs_gen.py +417 -0
  90. agent_cli/example-config.toml +185 -0
  91. agent_cli/install/__init__.py +5 -0
  92. agent_cli/install/common.py +89 -0
  93. agent_cli/install/extras.py +174 -0
  94. agent_cli/install/hotkeys.py +48 -0
  95. agent_cli/install/services.py +87 -0
  96. agent_cli/memory/__init__.py +7 -0
  97. agent_cli/memory/_files.py +250 -0
  98. agent_cli/memory/_filters.py +63 -0
  99. agent_cli/memory/_git.py +157 -0
  100. agent_cli/memory/_indexer.py +142 -0
  101. agent_cli/memory/_ingest.py +408 -0
  102. agent_cli/memory/_persistence.py +182 -0
  103. agent_cli/memory/_prompt.py +91 -0
  104. agent_cli/memory/_retrieval.py +294 -0
  105. agent_cli/memory/_store.py +169 -0
  106. agent_cli/memory/_streaming.py +44 -0
  107. agent_cli/memory/_tasks.py +48 -0
  108. agent_cli/memory/api.py +113 -0
  109. agent_cli/memory/client.py +272 -0
  110. agent_cli/memory/engine.py +361 -0
  111. agent_cli/memory/entities.py +43 -0
  112. agent_cli/memory/models.py +112 -0
  113. agent_cli/opts.py +433 -0
  114. agent_cli/py.typed +0 -0
  115. agent_cli/rag/__init__.py +3 -0
  116. agent_cli/rag/_indexer.py +67 -0
  117. agent_cli/rag/_indexing.py +226 -0
  118. agent_cli/rag/_prompt.py +30 -0
  119. agent_cli/rag/_retriever.py +156 -0
  120. agent_cli/rag/_store.py +48 -0
  121. agent_cli/rag/_utils.py +218 -0
  122. agent_cli/rag/api.py +175 -0
  123. agent_cli/rag/client.py +299 -0
  124. agent_cli/rag/engine.py +302 -0
  125. agent_cli/rag/models.py +55 -0
  126. agent_cli/scripts/.runtime/.gitkeep +0 -0
  127. agent_cli/scripts/__init__.py +1 -0
  128. agent_cli/scripts/check_plugin_skill_sync.py +50 -0
  129. agent_cli/scripts/linux-hotkeys/README.md +63 -0
  130. agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
  131. agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
  132. agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
  133. agent_cli/scripts/macos-hotkeys/README.md +45 -0
  134. agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
  135. agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
  136. agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
  137. agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
  138. agent_cli/scripts/nvidia-asr-server/README.md +99 -0
  139. agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
  140. agent_cli/scripts/nvidia-asr-server/server.py +255 -0
  141. agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
  142. agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
  143. agent_cli/scripts/run-openwakeword.sh +11 -0
  144. agent_cli/scripts/run-piper-windows.ps1 +30 -0
  145. agent_cli/scripts/run-piper.sh +24 -0
  146. agent_cli/scripts/run-whisper-linux.sh +40 -0
  147. agent_cli/scripts/run-whisper-macos.sh +6 -0
  148. agent_cli/scripts/run-whisper-windows.ps1 +51 -0
  149. agent_cli/scripts/run-whisper.sh +9 -0
  150. agent_cli/scripts/run_faster_whisper_server.py +136 -0
  151. agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
  152. agent_cli/scripts/setup-linux.sh +108 -0
  153. agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
  154. agent_cli/scripts/setup-macos.sh +76 -0
  155. agent_cli/scripts/setup-windows.ps1 +63 -0
  156. agent_cli/scripts/start-all-services-windows.ps1 +53 -0
  157. agent_cli/scripts/start-all-services.sh +178 -0
  158. agent_cli/scripts/sync_extras.py +138 -0
  159. agent_cli/server/__init__.py +3 -0
  160. agent_cli/server/cli.py +721 -0
  161. agent_cli/server/common.py +222 -0
  162. agent_cli/server/model_manager.py +288 -0
  163. agent_cli/server/model_registry.py +225 -0
  164. agent_cli/server/proxy/__init__.py +3 -0
  165. agent_cli/server/proxy/api.py +444 -0
  166. agent_cli/server/streaming.py +67 -0
  167. agent_cli/server/tts/__init__.py +3 -0
  168. agent_cli/server/tts/api.py +335 -0
  169. agent_cli/server/tts/backends/__init__.py +82 -0
  170. agent_cli/server/tts/backends/base.py +139 -0
  171. agent_cli/server/tts/backends/kokoro.py +403 -0
  172. agent_cli/server/tts/backends/piper.py +253 -0
  173. agent_cli/server/tts/model_manager.py +201 -0
  174. agent_cli/server/tts/model_registry.py +28 -0
  175. agent_cli/server/tts/wyoming_handler.py +249 -0
  176. agent_cli/server/whisper/__init__.py +3 -0
  177. agent_cli/server/whisper/api.py +413 -0
  178. agent_cli/server/whisper/backends/__init__.py +89 -0
  179. agent_cli/server/whisper/backends/base.py +97 -0
  180. agent_cli/server/whisper/backends/faster_whisper.py +225 -0
  181. agent_cli/server/whisper/backends/mlx.py +270 -0
  182. agent_cli/server/whisper/languages.py +116 -0
  183. agent_cli/server/whisper/model_manager.py +157 -0
  184. agent_cli/server/whisper/model_registry.py +28 -0
  185. agent_cli/server/whisper/wyoming_handler.py +203 -0
  186. agent_cli/services/__init__.py +343 -0
  187. agent_cli/services/_wyoming_utils.py +64 -0
  188. agent_cli/services/asr.py +506 -0
  189. agent_cli/services/llm.py +228 -0
  190. agent_cli/services/tts.py +450 -0
  191. agent_cli/services/wake_word.py +142 -0
  192. agent_cli-0.70.5.dist-info/METADATA +2118 -0
  193. agent_cli-0.70.5.dist-info/RECORD +196 -0
  194. agent_cli-0.70.5.dist-info/WHEEL +4 -0
  195. agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
  196. agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,526 @@
1
+ """Utility functions for agent CLI operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import signal
10
+ import sys
11
+ import time
12
+ from contextlib import (
13
+ AbstractContextManager,
14
+ asynccontextmanager,
15
+ contextmanager,
16
+ nullcontext,
17
+ suppress,
18
+ )
19
+ from typing import TYPE_CHECKING, Any
20
+
21
+ from rich.console import Console
22
+ from rich.live import Live
23
+ from rich.panel import Panel
24
+ from rich.spinner import Spinner
25
+ from rich.status import Status
26
+ from rich.table import Table
27
+ from rich.text import Text
28
+
29
+ from . import process
30
+
31
+ SECONDS_PER_MINUTE = 60
32
+ MINUTES_PER_HOUR = 60
33
+ HOURS_PER_DAY = 24
34
+
35
+ if TYPE_CHECKING:
36
+ from collections.abc import AsyncGenerator, Coroutine, Generator, Iterator
37
+ from datetime import timedelta
38
+ from logging import Handler
39
+ from pathlib import Path
40
+
41
+ console = Console()
42
+ err_console = Console(stderr=True)
43
+
44
+
45
+ def enable_json_mode() -> None:
46
+ """Silence Rich console output for JSON mode.
47
+
48
+ Call this early in a command when --json flag is set.
49
+ All subsequent console.print() calls will be silenced.
50
+ """
51
+ console.quiet = True
52
+
53
+
54
+ class InteractiveStopEvent:
55
+ """A stop event with reset capability for chat agents."""
56
+
57
+ def __init__(self, process_name: str | None = None) -> None:
58
+ """Initialize the chat stop event."""
59
+ self._event = asyncio.Event()
60
+ self._sigint_count = 0
61
+ self._ctrl_c_pressed = False
62
+ self._process_name = process_name
63
+
64
+ def is_set(self) -> bool:
65
+ """Check if the stop event is set or stop file exists (Windows)."""
66
+ if self._event.is_set():
67
+ return True
68
+ # On Windows, also check for stop file (cross-process signaling)
69
+ if self._process_name is not None and process.check_stop_file(self._process_name):
70
+ self._event.set() # Set the event so subsequent checks are fast
71
+ return True
72
+ return False
73
+
74
+ def set(self) -> None:
75
+ """Set the stop event."""
76
+ self._event.set()
77
+
78
+ def clear(self) -> None:
79
+ """Clear the stop event and reset interrupt count for next iteration."""
80
+ self._event.clear()
81
+ self._sigint_count = 0
82
+ self._ctrl_c_pressed = False
83
+
84
+ def increment_sigint_count(self) -> int:
85
+ """Increment and return the SIGINT count."""
86
+ self._sigint_count += 1
87
+ self._ctrl_c_pressed = True
88
+ return self._sigint_count
89
+
90
+ @property
91
+ def ctrl_c_pressed(self) -> bool:
92
+ """Check if Ctrl+C was pressed."""
93
+ return self._ctrl_c_pressed
94
+
95
+
96
+ def atomic_write_text(path: Path, content: str, encoding: str = "utf-8") -> None:
97
+ """Write text to a file atomically using a temporary file and rename."""
98
+ # Create a temp file in the same directory to ensure atomic rename works
99
+ temp_file = path.with_suffix(f"{path.suffix}.tmp")
100
+ try:
101
+ temp_file.write_text(content, encoding=encoding)
102
+ temp_file.replace(path)
103
+ except Exception:
104
+ if temp_file.exists():
105
+ temp_file.unlink()
106
+ raise
107
+
108
+
109
+ def format_timedelta_to_ago(td: timedelta) -> str:
110
+ """Format a timedelta into a human-readable 'ago' string."""
111
+ seconds = int(td.total_seconds())
112
+ minutes, seconds = divmod(seconds, 60)
113
+ hours, minutes = divmod(minutes, 60)
114
+ days, hours = divmod(hours, 24)
115
+
116
+ if days > 0:
117
+ return f"{days} day{'s' if days != 1 else ''} ago"
118
+ if hours > 0:
119
+ return f"{hours} hour{'s' if hours != 1 else ''} ago"
120
+ if minutes > 0:
121
+ return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
122
+ return f"{seconds} second{'s' if seconds != 1 else ''} ago"
123
+
124
+
125
+ def format_short_timedelta(delta: timedelta) -> str:
126
+ """Format a timedelta into a compact 'Xm Ys' string."""
127
+ total_seconds = max(0, int(delta.total_seconds()))
128
+ if total_seconds < SECONDS_PER_MINUTE:
129
+ return f"{total_seconds}s"
130
+ minutes, seconds = divmod(total_seconds, SECONDS_PER_MINUTE)
131
+ if minutes < MINUTES_PER_HOUR:
132
+ return f"{minutes}m {seconds}s" if seconds else f"{minutes}m"
133
+ hours, minutes = divmod(minutes, MINUTES_PER_HOUR)
134
+ if hours < HOURS_PER_DAY:
135
+ return f"{hours}h {minutes}m"
136
+ days, hours = divmod(hours, HOURS_PER_DAY)
137
+ return f"{days}d {hours}h"
138
+
139
+
140
+ def iter_lines_from_file_end(path: Path, chunk_size: int) -> Iterator[str]:
141
+ """Yield lines from the end of a file in reverse order."""
142
+ if chunk_size <= 0:
143
+ msg = "chunk_size must be positive"
144
+ raise ValueError(msg)
145
+
146
+ with path.open("rb") as file:
147
+ file.seek(0, os.SEEK_END)
148
+ position = file.tell()
149
+ buffer = b""
150
+
151
+ while position > 0:
152
+ read_size = min(chunk_size, position)
153
+ position -= read_size
154
+ file.seek(position)
155
+ chunk = file.read(read_size)
156
+ buffer = chunk + buffer
157
+
158
+ while True:
159
+ newline_idx = buffer.rfind(b"\n")
160
+ if newline_idx == -1:
161
+ break
162
+ line_bytes = buffer[newline_idx + 1 :].strip()
163
+ buffer = buffer[:newline_idx]
164
+ if line_bytes:
165
+ yield line_bytes.decode("utf-8", errors="ignore")
166
+
167
+ if position == 0:
168
+ final_line = buffer.strip()
169
+ if final_line:
170
+ yield final_line.decode("utf-8", errors="ignore")
171
+ buffer = b""
172
+
173
+
174
+ def parse_json_line(line: str) -> dict[str, Any] | None:
175
+ """Parse a JSON line and return a dictionary, or None if invalid."""
176
+ try:
177
+ return json.loads(line)
178
+ except json.JSONDecodeError:
179
+ return None
180
+
181
+
182
+ def _create_spinner(text: str, style: str) -> Spinner:
183
+ """Creates a default spinner."""
184
+ return Spinner("dots", text=Text(text, style=style))
185
+
186
+
187
+ def create_status(text: str, style: str = "bold yellow") -> Status:
188
+ """Creates a default status with spinner."""
189
+ spinner_text = Text(text, style=style)
190
+ return Status(spinner_text, console=console, spinner="dots")
191
+
192
+
193
+ def print_input_panel(
194
+ text: str,
195
+ title: str = "Input",
196
+ subtitle: str = "",
197
+ style: str = "bold blue",
198
+ ) -> None:
199
+ """Prints a panel with the input text."""
200
+ console.print(Panel(text, title=title, subtitle=subtitle, border_style=style))
201
+
202
+
203
+ def print_output_panel(
204
+ text: str,
205
+ title: str = "Output",
206
+ subtitle: str = "",
207
+ style: str = "bold green",
208
+ ) -> None:
209
+ """Prints a panel with the output text."""
210
+ console.print(Panel(text, title=title, subtitle=subtitle, border_style=style))
211
+
212
+
213
+ def print_error_message(message: str, suggestion: str | None = None) -> None:
214
+ """Prints an error message in a panel with rich markup support."""
215
+ error_text = Text.from_markup(message)
216
+ if suggestion:
217
+ error_text.append("\n\n")
218
+ error_text.append(suggestion)
219
+ console.print(Panel(error_text, title="Error", border_style="bold red"))
220
+
221
+
222
+ def print_with_style(message: str, style: str = "bold green") -> None:
223
+ """Prints a status message."""
224
+ console.print(f"[{style}]{message}[/{style}]")
225
+
226
+
227
+ def print_device_index(input_device_index: int | None, input_device_name: str | None) -> None:
228
+ """Prints the device index."""
229
+ if input_device_index is not None:
230
+ name = input_device_name or "Unknown Device"
231
+ print_with_style(f"Using {name} device with index {input_device_index}")
232
+
233
+
234
+ def get_clipboard_text(*, quiet: bool = False) -> str | None:
235
+ """Get text from clipboard, with an optional status message."""
236
+ import pyperclip # noqa: PLC0415
237
+
238
+ text = pyperclip.paste()
239
+ if not text:
240
+ if not quiet:
241
+ print_with_style("Clipboard is empty.", style="yellow")
242
+ return None
243
+ return text
244
+
245
+
246
+ @contextmanager
247
+ def signal_handling_context(
248
+ logger: logging.Logger,
249
+ quiet: bool = False,
250
+ process_name: str | None = None,
251
+ ) -> Generator[InteractiveStopEvent, None, None]:
252
+ """Context manager for graceful signal handling with double Ctrl+C support.
253
+
254
+ Sets up handlers for SIGINT (Ctrl+C) and SIGTERM (kill command):
255
+ - First Ctrl+C: Graceful shutdown with warning message
256
+ - Second Ctrl+C: Force exit with code 130
257
+ - SIGTERM: Immediate graceful shutdown
258
+
259
+ On Windows, also monitors for a stop file (cross-process signaling).
260
+
261
+ Args:
262
+ logger: Logger instance for recording events
263
+ quiet: Whether to suppress console output
264
+ process_name: Optional process name for stop file monitoring (Windows)
265
+
266
+ Yields:
267
+ stop_event: InteractiveStopEvent that gets set when shutdown is requested
268
+
269
+ """
270
+ stop_event = InteractiveStopEvent(process_name=process_name)
271
+
272
+ def _sigint_handler() -> None:
273
+ sigint_count = stop_event.increment_sigint_count()
274
+
275
+ if sigint_count == 1:
276
+ logger.info("First Ctrl+C received. Processing transcription.")
277
+ # The Ctrl+C message will be shown by the ASR function
278
+ stop_event.set()
279
+ else:
280
+ logger.info("Second Ctrl+C received. Force exiting.")
281
+ if not quiet:
282
+ console.print("\n[red]Force exit![/red]")
283
+ sys.exit(130) # Standard exit code for Ctrl+C
284
+
285
+ def _sigterm_handler() -> None:
286
+ logger.info("SIGTERM received. Stopping process.")
287
+ stop_event.set()
288
+
289
+ loop = asyncio.get_running_loop()
290
+ restore_handlers: dict[signal.Signals, Any] = {}
291
+
292
+ def _register_async_handlers() -> None:
293
+ """Register signal handlers using asyncio loop (Unix)."""
294
+ loop.add_signal_handler(signal.SIGINT, _sigint_handler)
295
+ loop.add_signal_handler(signal.SIGTERM, _sigterm_handler)
296
+
297
+ def _register_sync_handlers() -> None:
298
+ """Register signal handlers using standard signal module (Windows)."""
299
+ logger.debug("Using sync signal handlers (Windows platform).")
300
+
301
+ def register(signum: signal.Signals, handler: Any) -> None:
302
+ restore_handlers[signum] = signal.getsignal(signum)
303
+ signal.signal(signum, handler)
304
+
305
+ register(signal.SIGINT, lambda *_: _sigint_handler())
306
+ register(signal.SIGTERM, lambda *_: _sigterm_handler())
307
+
308
+ if sys.platform == "win32":
309
+ _register_sync_handlers()
310
+ else:
311
+ _register_async_handlers()
312
+
313
+ try:
314
+ yield stop_event
315
+ finally:
316
+ for signum, previous in restore_handlers.items():
317
+ signal.signal(signum, previous)
318
+
319
+
320
+ def stop_or_status_or_toggle(
321
+ process_name: str,
322
+ which: str,
323
+ stop: bool,
324
+ status: bool,
325
+ toggle: bool,
326
+ *,
327
+ quiet: bool = False,
328
+ ) -> bool:
329
+ """Handle process control for a given process name."""
330
+ if stop:
331
+ if process.kill_process(process_name):
332
+ if not quiet:
333
+ print_with_style(f"✅ {which.capitalize()} stopped.")
334
+ elif not quiet:
335
+ print_with_style(f"⚠️ No {which} is running.", style="yellow")
336
+ return True
337
+
338
+ if status:
339
+ if process.is_process_running(process_name):
340
+ pid = process.read_pid_file(process_name)
341
+ if not quiet:
342
+ print_with_style(f"✅ {which.capitalize()} is running (PID: {pid}).")
343
+ elif not quiet:
344
+ print_with_style(f"⚠️ {which.capitalize()} is not running.", style="yellow")
345
+ return True
346
+
347
+ if toggle:
348
+ if process.is_process_running(process_name):
349
+ if process.kill_process(process_name) and not quiet:
350
+ print_with_style(f"✅ {which.capitalize()} stopped.")
351
+ return True
352
+ if not quiet:
353
+ print_with_style(f"⚠️ {which.capitalize()} is not running.", style="yellow")
354
+
355
+ return False
356
+
357
+
358
+ def maybe_live(use_live: bool) -> AbstractContextManager[Live | None]:
359
+ """Create a live context manager if use_live is True."""
360
+ if use_live:
361
+ return Live(_create_spinner("Initializing", "blue"), console=console, transient=True)
362
+ return nullcontext()
363
+
364
+
365
+ @asynccontextmanager
366
+ async def live_timer(
367
+ live: Live,
368
+ base_message: str,
369
+ *,
370
+ quiet: bool = False,
371
+ style: str = "blue",
372
+ stop_event: InteractiveStopEvent | None = None,
373
+ ) -> AsyncGenerator[None, None]:
374
+ """Async context manager that automatically manages a timer for a Live display.
375
+
376
+ Args:
377
+ live: Live instance to update (or None to do nothing)
378
+ base_message: Base message to display
379
+ style: Rich style for the text
380
+ quiet: If True, don't show any display
381
+ stop_event: Optional stop event to check for Ctrl+C
382
+
383
+ Usage:
384
+ async with live_timer(live, "🤖 Processing", style="bold yellow"):
385
+ # Do your work here, timer updates automatically
386
+ await some_operation()
387
+
388
+ """
389
+ if quiet:
390
+ yield
391
+ return
392
+
393
+ # Start the timer task
394
+ start_time = time.monotonic()
395
+
396
+ async def update_timer() -> None:
397
+ """Update the timer display."""
398
+ while True:
399
+ elapsed = time.monotonic() - start_time
400
+
401
+ # Check if Ctrl+C was pressed
402
+ if stop_event and stop_event.ctrl_c_pressed:
403
+ ctrl_c_text = Text(
404
+ "Ctrl+C pressed. Processing transcription... (Press Ctrl+C again to force exit)",
405
+ style="yellow",
406
+ )
407
+ live.update(ctrl_c_text)
408
+ else:
409
+ spinner = _create_spinner(f"{base_message}... ({elapsed:.1f}s)", style)
410
+ live.update(spinner)
411
+
412
+ await asyncio.sleep(0.1)
413
+
414
+ timer_task = asyncio.create_task(update_timer())
415
+
416
+ try:
417
+ yield
418
+ finally:
419
+ # Clean up timer task automatically
420
+ timer_task.cancel()
421
+ with suppress(asyncio.CancelledError):
422
+ await timer_task
423
+ if not quiet:
424
+ live.update("")
425
+
426
+
427
+ def setup_logging(log_level: str, log_file: str | None, *, quiet: bool) -> None:
428
+ """Sets up logging based on parsed arguments."""
429
+ handlers: list[Handler] = []
430
+ if not quiet:
431
+ handlers.append(logging.StreamHandler())
432
+ if log_file:
433
+ handlers.append(logging.FileHandler(log_file, mode="w"))
434
+
435
+ logging.basicConfig(
436
+ level=log_level.upper(),
437
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
438
+ handlers=handlers,
439
+ )
440
+
441
+
442
+ async def manage_send_receive_tasks(
443
+ send_task_coro: Coroutine,
444
+ receive_task_coro: Coroutine,
445
+ *,
446
+ return_when: str = asyncio.ALL_COMPLETED,
447
+ ) -> tuple[asyncio.Task, asyncio.Task]:
448
+ """Manage send and receive tasks with proper cancellation.
449
+
450
+ Args:
451
+ send_task_coro: Send task coroutine
452
+ receive_task_coro: Receive task coroutine
453
+ return_when: When to return (e.g., asyncio.ALL_COMPLETED)
454
+
455
+ Returns:
456
+ Tuple of (send_task, receive_task) - both completed or cancelled
457
+
458
+ """
459
+ send_task = asyncio.create_task(send_task_coro)
460
+ recv_task = asyncio.create_task(receive_task_coro)
461
+
462
+ _done, pending = await asyncio.wait(
463
+ [send_task, recv_task],
464
+ return_when=return_when,
465
+ )
466
+
467
+ # Cancel any pending tasks
468
+ for task in pending:
469
+ task.cancel()
470
+
471
+ return send_task, recv_task
472
+
473
+
474
+ def print_command_line_args(
475
+ args: dict[str, str | int | bool | None],
476
+ ) -> None:
477
+ """Print command line arguments in a formatted way."""
478
+ from agent_cli import opts # noqa: PLC0415
479
+
480
+ table = Table(title="Command Line Arguments", show_header=True, header_style="bold magenta")
481
+ table.add_column("Parameter", style="cyan", no_wrap=True)
482
+ table.add_column("Value", style="green")
483
+ table.add_column("Type", style="dim")
484
+
485
+ sorted_args = sorted(args.items())
486
+ categories: dict[str, list[tuple[str, str | int | bool | None]]] = {}
487
+
488
+ for key, value in sorted_args:
489
+ if key == "ctx":
490
+ continue
491
+ try:
492
+ category = getattr(opts, key.upper()).rich_help_panel
493
+ except AttributeError:
494
+ category = "Other"
495
+
496
+ if category not in categories:
497
+ categories[category] = []
498
+ categories[category].append((key, value))
499
+
500
+ sorted_categories = sorted(categories.items())
501
+ for category, items in sorted_categories:
502
+ if not items:
503
+ continue
504
+ # Add a separator row for the category
505
+ table.add_row(f"[bold yellow]── {category} ──[/bold yellow]", "", "")
506
+
507
+ for key, value in items:
508
+ if value is None:
509
+ formatted_value = "[dim]None[/dim]"
510
+ elif isinstance(value, bool):
511
+ formatted_value = "[green]✓[/green]" if value else "[red]✗[/red]"
512
+ elif isinstance(value, str) and not value:
513
+ formatted_value = "[dim]<empty>[/dim]"
514
+ else:
515
+ formatted_value = str(value)
516
+
517
+ type_name = type(value).__name__
518
+ if value is None:
519
+ type_name = "NoneType"
520
+
521
+ table.add_row(key, formatted_value, f"[dim]{type_name}[/dim]")
522
+
523
+ # Print the table
524
+ console.print()
525
+ console.print(table)
526
+ console.print()
agent_cli/core/vad.py ADDED
@@ -0,0 +1,175 @@
1
+ """Voice Activity Detection using Silero VAD for speech segmentation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import urllib.request
7
+ from collections import deque
8
+ from pathlib import Path
9
+
10
+ from agent_cli import constants
11
+
12
+ try:
13
+ import numpy as np
14
+ import torch
15
+ except ImportError as e:
16
+ msg = (
17
+ "silero-vad is required for the transcribe-daemon command. "
18
+ "Install it with: `pip install agent-cli[vad]` or `uv sync --extra vad`."
19
+ )
20
+ raise ImportError(msg) from e
21
+
22
+ LOGGER = logging.getLogger(__name__)
23
+
24
+ _SILERO_VAD_ONNX_URL = (
25
+ "https://github.com/snakers4/silero-vad/raw/master/src/silero_vad/data/silero_vad.onnx"
26
+ )
27
+
28
+
29
+ def _get_model_path() -> Path:
30
+ """Get the path to the Silero VAD ONNX model, downloading if needed."""
31
+ cache_dir = Path.home() / ".cache" / "silero-vad"
32
+ cache_dir.mkdir(parents=True, exist_ok=True)
33
+ model_path = cache_dir / "silero_vad.onnx"
34
+ if not model_path.exists():
35
+ urllib.request.urlretrieve(_SILERO_VAD_ONNX_URL, model_path) # noqa: S310
36
+ return model_path
37
+
38
+
39
+ class VoiceActivityDetector:
40
+ """Silero VAD-based voice activity detection for audio segmentation.
41
+
42
+ Processes audio chunks and emits complete speech segments when silence
43
+ is detected after speech.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ sample_rate: int = constants.AUDIO_RATE,
49
+ threshold: float = 0.3,
50
+ silence_threshold_ms: int = 1000,
51
+ min_speech_duration_ms: int = 250,
52
+ pre_speech_buffer_ms: int = 300,
53
+ ) -> None:
54
+ """Initialize VAD with configurable thresholds."""
55
+ if sample_rate not in (8000, 16000):
56
+ msg = f"Sample rate must be 8000 or 16000, got {sample_rate}"
57
+ raise ValueError(msg)
58
+
59
+ from silero_vad.utils_vad import OnnxWrapper # noqa: PLC0415
60
+
61
+ self.sample_rate = sample_rate
62
+ self.threshold = threshold
63
+ self.silence_threshold_ms = silence_threshold_ms
64
+ self.min_speech_duration_ms = min_speech_duration_ms
65
+
66
+ # Window size: 512 samples @ 16kHz, 256 @ 8kHz (Silero requirement)
67
+ self.window_size_samples = 512 if sample_rate == 16000 else 256 # noqa: PLR2004
68
+ self.window_size_bytes = self.window_size_samples * 2 # 16-bit audio
69
+
70
+ # Pre-speech buffer size in windows
71
+ pre_speech_windows = max(
72
+ 1,
73
+ (pre_speech_buffer_ms * sample_rate // 1000) // self.window_size_samples,
74
+ )
75
+
76
+ # Model and state
77
+ self._model = OnnxWrapper(str(_get_model_path()))
78
+ self._pre_speech_buffer: deque[bytes] = deque(maxlen=pre_speech_windows)
79
+ self._pending = bytearray()
80
+ self._audio_buffer = bytearray()
81
+ self._is_speaking = False
82
+ self._silence_samples = 0
83
+ self._speech_samples = 0
84
+
85
+ @property
86
+ def _silence_threshold_samples(self) -> int:
87
+ return self.silence_threshold_ms * self.sample_rate // 1000
88
+
89
+ @property
90
+ def _min_speech_samples(self) -> int:
91
+ return self.min_speech_duration_ms * self.sample_rate // 1000
92
+
93
+ def reset(self) -> None:
94
+ """Reset VAD state for a new recording session."""
95
+ self._model.reset_states()
96
+ self._pre_speech_buffer.clear()
97
+ self._pending.clear()
98
+ self._audio_buffer.clear()
99
+ self._is_speaking = False
100
+ self._silence_samples = 0
101
+ self._speech_samples = 0
102
+
103
+ def _is_speech(self, window: bytes) -> bool:
104
+ """Check if audio window contains speech."""
105
+ audio = np.frombuffer(window, dtype=np.int16).astype(np.float32) / 32768.0
106
+ prob = float(self._model(torch.from_numpy(audio), self.sample_rate).item())
107
+ LOGGER.debug("Speech prob: %.3f, threshold: %.2f", prob, self.threshold)
108
+ return prob >= self.threshold
109
+
110
+ def process_chunk(self, chunk: bytes) -> tuple[bool, bytes | None]:
111
+ """Process audio chunk and detect speech segments.
112
+
113
+ Returns (is_speaking, completed_segment_or_none).
114
+ """
115
+ self._pending.extend(chunk)
116
+ completed_segment: bytes | None = None
117
+ ws = self.window_size_bytes
118
+
119
+ # Process complete windows
120
+ while len(self._pending) >= ws:
121
+ window = bytes(self._pending[:ws])
122
+ del self._pending[:ws]
123
+
124
+ if self._is_speech(window):
125
+ if not self._is_speaking:
126
+ # Speech just started - prepend pre-speech buffer
127
+ self._is_speaking = True
128
+ self._audio_buffer.clear()
129
+ for pre in self._pre_speech_buffer:
130
+ self._audio_buffer.extend(pre)
131
+ self._pre_speech_buffer.clear()
132
+ self._silence_samples = 0
133
+ self._speech_samples = 0
134
+
135
+ self._audio_buffer.extend(window)
136
+ self._silence_samples = 0
137
+ self._speech_samples += self.window_size_samples
138
+
139
+ elif self._is_speaking:
140
+ # Silence during speech
141
+ self._audio_buffer.extend(window)
142
+ self._silence_samples += self.window_size_samples
143
+
144
+ if self._silence_samples >= self._silence_threshold_samples:
145
+ # Segment complete - trim trailing silence
146
+ if self._speech_samples >= self._min_speech_samples:
147
+ trailing = (self._silence_samples // self.window_size_samples) * ws
148
+ completed_segment = bytes(
149
+ self._audio_buffer[:-trailing] if trailing else self._audio_buffer,
150
+ )
151
+
152
+ # Reset for next segment
153
+ self._is_speaking = False
154
+ self._silence_samples = 0
155
+ self._speech_samples = 0
156
+ self._audio_buffer.clear()
157
+ self._model.reset_states()
158
+ else:
159
+ # Not speaking - maintain rolling pre-speech buffer (auto-limited by deque maxlen)
160
+ self._pre_speech_buffer.append(window)
161
+
162
+ return self._is_speaking, completed_segment
163
+
164
+ def flush(self) -> bytes | None:
165
+ """Flush any remaining buffered speech when stream ends."""
166
+ if self._is_speaking and self._speech_samples >= self._min_speech_samples:
167
+ result = bytes(self._audio_buffer)
168
+ self.reset()
169
+ return result
170
+ self.reset()
171
+ return None
172
+
173
+ def get_segment_duration_seconds(self, segment: bytes) -> float:
174
+ """Calculate duration of audio segment in seconds."""
175
+ return len(segment) // 2 / self.sample_rate