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.
- agent_cli/__init__.py +5 -0
- agent_cli/__main__.py +6 -0
- agent_cli/_extras.json +14 -0
- agent_cli/_requirements/.gitkeep +0 -0
- agent_cli/_requirements/audio.txt +79 -0
- agent_cli/_requirements/faster-whisper.txt +215 -0
- agent_cli/_requirements/kokoro.txt +425 -0
- agent_cli/_requirements/llm.txt +183 -0
- agent_cli/_requirements/memory.txt +355 -0
- agent_cli/_requirements/mlx-whisper.txt +222 -0
- agent_cli/_requirements/piper.txt +176 -0
- agent_cli/_requirements/rag.txt +402 -0
- agent_cli/_requirements/server.txt +154 -0
- agent_cli/_requirements/speed.txt +77 -0
- agent_cli/_requirements/vad.txt +155 -0
- agent_cli/_requirements/wyoming.txt +71 -0
- agent_cli/_tools.py +368 -0
- agent_cli/agents/__init__.py +23 -0
- agent_cli/agents/_voice_agent_common.py +136 -0
- agent_cli/agents/assistant.py +383 -0
- agent_cli/agents/autocorrect.py +284 -0
- agent_cli/agents/chat.py +496 -0
- agent_cli/agents/memory/__init__.py +31 -0
- agent_cli/agents/memory/add.py +190 -0
- agent_cli/agents/memory/proxy.py +160 -0
- agent_cli/agents/rag_proxy.py +128 -0
- agent_cli/agents/speak.py +209 -0
- agent_cli/agents/transcribe.py +671 -0
- agent_cli/agents/transcribe_daemon.py +499 -0
- agent_cli/agents/voice_edit.py +291 -0
- agent_cli/api.py +22 -0
- agent_cli/cli.py +106 -0
- agent_cli/config.py +503 -0
- agent_cli/config_cmd.py +307 -0
- agent_cli/constants.py +27 -0
- agent_cli/core/__init__.py +1 -0
- agent_cli/core/audio.py +461 -0
- agent_cli/core/audio_format.py +299 -0
- agent_cli/core/chroma.py +88 -0
- agent_cli/core/deps.py +191 -0
- agent_cli/core/openai_proxy.py +139 -0
- agent_cli/core/process.py +195 -0
- agent_cli/core/reranker.py +120 -0
- agent_cli/core/sse.py +87 -0
- agent_cli/core/transcription_logger.py +70 -0
- agent_cli/core/utils.py +526 -0
- agent_cli/core/vad.py +175 -0
- agent_cli/core/watch.py +65 -0
- agent_cli/dev/__init__.py +14 -0
- agent_cli/dev/cli.py +1588 -0
- agent_cli/dev/coding_agents/__init__.py +19 -0
- agent_cli/dev/coding_agents/aider.py +24 -0
- agent_cli/dev/coding_agents/base.py +167 -0
- agent_cli/dev/coding_agents/claude.py +39 -0
- agent_cli/dev/coding_agents/codex.py +24 -0
- agent_cli/dev/coding_agents/continue_dev.py +15 -0
- agent_cli/dev/coding_agents/copilot.py +24 -0
- agent_cli/dev/coding_agents/cursor_agent.py +48 -0
- agent_cli/dev/coding_agents/gemini.py +28 -0
- agent_cli/dev/coding_agents/opencode.py +15 -0
- agent_cli/dev/coding_agents/registry.py +49 -0
- agent_cli/dev/editors/__init__.py +19 -0
- agent_cli/dev/editors/base.py +89 -0
- agent_cli/dev/editors/cursor.py +15 -0
- agent_cli/dev/editors/emacs.py +46 -0
- agent_cli/dev/editors/jetbrains.py +56 -0
- agent_cli/dev/editors/nano.py +31 -0
- agent_cli/dev/editors/neovim.py +33 -0
- agent_cli/dev/editors/registry.py +59 -0
- agent_cli/dev/editors/sublime.py +20 -0
- agent_cli/dev/editors/vim.py +42 -0
- agent_cli/dev/editors/vscode.py +15 -0
- agent_cli/dev/editors/zed.py +20 -0
- agent_cli/dev/project.py +568 -0
- agent_cli/dev/registry.py +52 -0
- agent_cli/dev/skill/SKILL.md +141 -0
- agent_cli/dev/skill/examples.md +571 -0
- agent_cli/dev/terminals/__init__.py +19 -0
- agent_cli/dev/terminals/apple_terminal.py +82 -0
- agent_cli/dev/terminals/base.py +56 -0
- agent_cli/dev/terminals/gnome.py +51 -0
- agent_cli/dev/terminals/iterm2.py +84 -0
- agent_cli/dev/terminals/kitty.py +77 -0
- agent_cli/dev/terminals/registry.py +48 -0
- agent_cli/dev/terminals/tmux.py +58 -0
- agent_cli/dev/terminals/warp.py +132 -0
- agent_cli/dev/terminals/zellij.py +78 -0
- agent_cli/dev/worktree.py +856 -0
- agent_cli/docs_gen.py +417 -0
- agent_cli/example-config.toml +185 -0
- agent_cli/install/__init__.py +5 -0
- agent_cli/install/common.py +89 -0
- agent_cli/install/extras.py +174 -0
- agent_cli/install/hotkeys.py +48 -0
- agent_cli/install/services.py +87 -0
- agent_cli/memory/__init__.py +7 -0
- agent_cli/memory/_files.py +250 -0
- agent_cli/memory/_filters.py +63 -0
- agent_cli/memory/_git.py +157 -0
- agent_cli/memory/_indexer.py +142 -0
- agent_cli/memory/_ingest.py +408 -0
- agent_cli/memory/_persistence.py +182 -0
- agent_cli/memory/_prompt.py +91 -0
- agent_cli/memory/_retrieval.py +294 -0
- agent_cli/memory/_store.py +169 -0
- agent_cli/memory/_streaming.py +44 -0
- agent_cli/memory/_tasks.py +48 -0
- agent_cli/memory/api.py +113 -0
- agent_cli/memory/client.py +272 -0
- agent_cli/memory/engine.py +361 -0
- agent_cli/memory/entities.py +43 -0
- agent_cli/memory/models.py +112 -0
- agent_cli/opts.py +433 -0
- agent_cli/py.typed +0 -0
- agent_cli/rag/__init__.py +3 -0
- agent_cli/rag/_indexer.py +67 -0
- agent_cli/rag/_indexing.py +226 -0
- agent_cli/rag/_prompt.py +30 -0
- agent_cli/rag/_retriever.py +156 -0
- agent_cli/rag/_store.py +48 -0
- agent_cli/rag/_utils.py +218 -0
- agent_cli/rag/api.py +175 -0
- agent_cli/rag/client.py +299 -0
- agent_cli/rag/engine.py +302 -0
- agent_cli/rag/models.py +55 -0
- agent_cli/scripts/.runtime/.gitkeep +0 -0
- agent_cli/scripts/__init__.py +1 -0
- agent_cli/scripts/check_plugin_skill_sync.py +50 -0
- agent_cli/scripts/linux-hotkeys/README.md +63 -0
- agent_cli/scripts/linux-hotkeys/toggle-autocorrect.sh +45 -0
- agent_cli/scripts/linux-hotkeys/toggle-transcription.sh +58 -0
- agent_cli/scripts/linux-hotkeys/toggle-voice-edit.sh +58 -0
- agent_cli/scripts/macos-hotkeys/README.md +45 -0
- agent_cli/scripts/macos-hotkeys/skhd-config-example +5 -0
- agent_cli/scripts/macos-hotkeys/toggle-autocorrect.sh +12 -0
- agent_cli/scripts/macos-hotkeys/toggle-transcription.sh +37 -0
- agent_cli/scripts/macos-hotkeys/toggle-voice-edit.sh +37 -0
- agent_cli/scripts/nvidia-asr-server/README.md +99 -0
- agent_cli/scripts/nvidia-asr-server/pyproject.toml +27 -0
- agent_cli/scripts/nvidia-asr-server/server.py +255 -0
- agent_cli/scripts/nvidia-asr-server/shell.nix +32 -0
- agent_cli/scripts/nvidia-asr-server/uv.lock +4654 -0
- agent_cli/scripts/run-openwakeword.sh +11 -0
- agent_cli/scripts/run-piper-windows.ps1 +30 -0
- agent_cli/scripts/run-piper.sh +24 -0
- agent_cli/scripts/run-whisper-linux.sh +40 -0
- agent_cli/scripts/run-whisper-macos.sh +6 -0
- agent_cli/scripts/run-whisper-windows.ps1 +51 -0
- agent_cli/scripts/run-whisper.sh +9 -0
- agent_cli/scripts/run_faster_whisper_server.py +136 -0
- agent_cli/scripts/setup-linux-hotkeys.sh +72 -0
- agent_cli/scripts/setup-linux.sh +108 -0
- agent_cli/scripts/setup-macos-hotkeys.sh +61 -0
- agent_cli/scripts/setup-macos.sh +76 -0
- agent_cli/scripts/setup-windows.ps1 +63 -0
- agent_cli/scripts/start-all-services-windows.ps1 +53 -0
- agent_cli/scripts/start-all-services.sh +178 -0
- agent_cli/scripts/sync_extras.py +138 -0
- agent_cli/server/__init__.py +3 -0
- agent_cli/server/cli.py +721 -0
- agent_cli/server/common.py +222 -0
- agent_cli/server/model_manager.py +288 -0
- agent_cli/server/model_registry.py +225 -0
- agent_cli/server/proxy/__init__.py +3 -0
- agent_cli/server/proxy/api.py +444 -0
- agent_cli/server/streaming.py +67 -0
- agent_cli/server/tts/__init__.py +3 -0
- agent_cli/server/tts/api.py +335 -0
- agent_cli/server/tts/backends/__init__.py +82 -0
- agent_cli/server/tts/backends/base.py +139 -0
- agent_cli/server/tts/backends/kokoro.py +403 -0
- agent_cli/server/tts/backends/piper.py +253 -0
- agent_cli/server/tts/model_manager.py +201 -0
- agent_cli/server/tts/model_registry.py +28 -0
- agent_cli/server/tts/wyoming_handler.py +249 -0
- agent_cli/server/whisper/__init__.py +3 -0
- agent_cli/server/whisper/api.py +413 -0
- agent_cli/server/whisper/backends/__init__.py +89 -0
- agent_cli/server/whisper/backends/base.py +97 -0
- agent_cli/server/whisper/backends/faster_whisper.py +225 -0
- agent_cli/server/whisper/backends/mlx.py +270 -0
- agent_cli/server/whisper/languages.py +116 -0
- agent_cli/server/whisper/model_manager.py +157 -0
- agent_cli/server/whisper/model_registry.py +28 -0
- agent_cli/server/whisper/wyoming_handler.py +203 -0
- agent_cli/services/__init__.py +343 -0
- agent_cli/services/_wyoming_utils.py +64 -0
- agent_cli/services/asr.py +506 -0
- agent_cli/services/llm.py +228 -0
- agent_cli/services/tts.py +450 -0
- agent_cli/services/wake_word.py +142 -0
- agent_cli-0.70.5.dist-info/METADATA +2118 -0
- agent_cli-0.70.5.dist-info/RECORD +196 -0
- agent_cli-0.70.5.dist-info/WHEEL +4 -0
- agent_cli-0.70.5.dist-info/entry_points.txt +4 -0
- agent_cli-0.70.5.dist-info/licenses/LICENSE +21 -0
agent_cli/core/utils.py
ADDED
|
@@ -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
|