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
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""Audio format conversion utilities using FFmpeg."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import logging
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
import wave
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import NamedTuple
|
|
13
|
+
|
|
14
|
+
from agent_cli import constants
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
VALID_EXTENSIONS = (".wav", ".mp3", ".m4a", ".flac", ".ogg", ".aac", ".webm")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WavPcmData(NamedTuple):
|
|
22
|
+
"""PCM data and parameters extracted from a WAV file."""
|
|
23
|
+
|
|
24
|
+
pcm_data: bytes
|
|
25
|
+
sample_rate: int
|
|
26
|
+
num_channels: int
|
|
27
|
+
sample_width: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extract_pcm_from_wav(wav_bytes: bytes) -> WavPcmData:
|
|
31
|
+
"""Extract raw PCM data and WAV parameters from WAV bytes.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
wav_bytes: WAV file data as bytes.
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
WavPcmData with pcm_data, sample_rate, num_channels, sample_width.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
wave.Error: If the data is not a valid WAV file.
|
|
41
|
+
|
|
42
|
+
"""
|
|
43
|
+
with io.BytesIO(wav_bytes) as buf, wave.open(buf, "rb") as wav_file:
|
|
44
|
+
return WavPcmData(
|
|
45
|
+
pcm_data=wav_file.readframes(wav_file.getnframes()),
|
|
46
|
+
sample_rate=wav_file.getframerate(),
|
|
47
|
+
num_channels=wav_file.getnchannels(),
|
|
48
|
+
sample_width=wav_file.getsampwidth(),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def is_valid_audio_file(value: object) -> bool:
|
|
53
|
+
"""Check if the provided value is a valid audio file.
|
|
54
|
+
|
|
55
|
+
Works with FastAPI UploadFile or any object with filename and content_type attributes.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
value: Object to check (typically an UploadFile).
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
True if the object appears to be a valid audio file.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
filename = getattr(value, "filename", None)
|
|
65
|
+
content_type = getattr(value, "content_type", None)
|
|
66
|
+
|
|
67
|
+
if not filename and not content_type:
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
if content_type and content_type.startswith("audio/"):
|
|
71
|
+
return True
|
|
72
|
+
return bool(filename and str(filename).lower().endswith(VALID_EXTENSIONS))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def convert_audio_to_wyoming_format(
|
|
76
|
+
audio_data: bytes,
|
|
77
|
+
source_filename: str,
|
|
78
|
+
) -> bytes:
|
|
79
|
+
"""Convert audio data to Wyoming-compatible format using FFmpeg.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
audio_data: Raw audio data
|
|
83
|
+
source_filename: Source filename to help FFmpeg detect format
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Converted audio data as raw PCM bytes (16kHz, 16-bit, mono)
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
RuntimeError: If FFmpeg is not available or conversion fails
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
# Check if FFmpeg is available
|
|
93
|
+
if not shutil.which("ffmpeg"):
|
|
94
|
+
msg = "FFmpeg not found in PATH. Please install FFmpeg to convert audio formats."
|
|
95
|
+
raise RuntimeError(msg)
|
|
96
|
+
|
|
97
|
+
# Create temporary files for input and output
|
|
98
|
+
suffix = _get_file_extension(source_filename)
|
|
99
|
+
with (
|
|
100
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as input_file,
|
|
101
|
+
tempfile.NamedTemporaryFile(delete=False, suffix=".wav") as output_file,
|
|
102
|
+
):
|
|
103
|
+
input_path = Path(input_file.name)
|
|
104
|
+
output_path = Path(output_file.name)
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
# Write input audio data
|
|
108
|
+
input_file.write(audio_data)
|
|
109
|
+
input_file.flush()
|
|
110
|
+
|
|
111
|
+
# Build FFmpeg command to convert to Wyoming format
|
|
112
|
+
# -f s16le: 16-bit signed little-endian PCM
|
|
113
|
+
# -ar 16000: 16kHz sample rate
|
|
114
|
+
# -ac 1: mono (1 channel)
|
|
115
|
+
cmd = [
|
|
116
|
+
"ffmpeg",
|
|
117
|
+
"-y",
|
|
118
|
+
"-i",
|
|
119
|
+
str(input_path),
|
|
120
|
+
"-f",
|
|
121
|
+
"s16le",
|
|
122
|
+
"-ar",
|
|
123
|
+
str(constants.AUDIO_RATE),
|
|
124
|
+
"-ac",
|
|
125
|
+
str(constants.AUDIO_CHANNELS),
|
|
126
|
+
str(output_path),
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
logger.debug("Running FFmpeg command: %s", " ".join(cmd))
|
|
130
|
+
|
|
131
|
+
# Run FFmpeg
|
|
132
|
+
result = subprocess.run(
|
|
133
|
+
cmd,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=False,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if result.returncode != 0:
|
|
140
|
+
stderr_text = result.stderr.decode("utf-8", errors="replace")
|
|
141
|
+
logger.error("FFmpeg failed with return code %d", result.returncode)
|
|
142
|
+
logger.error("FFmpeg stderr: %s", stderr_text)
|
|
143
|
+
msg = f"FFmpeg conversion failed: {stderr_text}"
|
|
144
|
+
raise RuntimeError(msg)
|
|
145
|
+
|
|
146
|
+
# Read converted audio data
|
|
147
|
+
return output_path.read_bytes()
|
|
148
|
+
|
|
149
|
+
finally:
|
|
150
|
+
# Clean up temporary files
|
|
151
|
+
input_path.unlink(missing_ok=True)
|
|
152
|
+
output_path.unlink(missing_ok=True)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _get_file_extension(filename: str) -> str:
|
|
156
|
+
"""Get file extension from filename, defaulting to .tmp.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
filename: Source filename
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
File extension including the dot
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
filename = str(filename).lower()
|
|
166
|
+
|
|
167
|
+
for ext in VALID_EXTENSIONS:
|
|
168
|
+
if filename.endswith(ext):
|
|
169
|
+
return ext
|
|
170
|
+
|
|
171
|
+
return ".tmp"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def check_ffmpeg_available() -> bool:
|
|
175
|
+
"""Check if FFmpeg is available in the system PATH.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
True if FFmpeg is available, False otherwise
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
return shutil.which("ffmpeg") is not None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_ffmpeg(
|
|
185
|
+
cmd: list[str],
|
|
186
|
+
timeout: int | None,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Run ffmpeg command with error handling."""
|
|
189
|
+
try:
|
|
190
|
+
result = subprocess.run(
|
|
191
|
+
cmd,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
check=False,
|
|
194
|
+
timeout=timeout,
|
|
195
|
+
)
|
|
196
|
+
except subprocess.TimeoutExpired as e:
|
|
197
|
+
msg = f"FFmpeg conversion timed out after {timeout} seconds"
|
|
198
|
+
raise RuntimeError(msg) from e
|
|
199
|
+
|
|
200
|
+
if result.returncode != 0:
|
|
201
|
+
stderr_text = result.stderr.decode("utf-8", errors="replace")
|
|
202
|
+
logger.error("FFmpeg MP3 conversion failed: %s", stderr_text)
|
|
203
|
+
msg = f"FFmpeg MP3 conversion failed: {stderr_text}"
|
|
204
|
+
raise RuntimeError(msg)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def convert_to_mp3(
|
|
208
|
+
audio_data: bytes,
|
|
209
|
+
*,
|
|
210
|
+
input_format: str = "wav",
|
|
211
|
+
sample_rate: int | None = None,
|
|
212
|
+
channels: int | None = None,
|
|
213
|
+
bitrate: str = "128k",
|
|
214
|
+
timeout: int | None = 60,
|
|
215
|
+
) -> bytes:
|
|
216
|
+
"""Convert audio data to MP3 format using FFmpeg.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
audio_data: Audio data as bytes.
|
|
220
|
+
input_format: Input format - "wav" (auto-detected) or "pcm" (raw s16le).
|
|
221
|
+
sample_rate: Sample rate in Hz (required if input_format is "pcm").
|
|
222
|
+
channels: Number of channels (required if input_format is "pcm").
|
|
223
|
+
bitrate: MP3 bitrate (e.g., "128k", "192k").
|
|
224
|
+
timeout: Timeout in seconds for FFmpeg, or None for no timeout.
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
MP3 audio data as bytes.
|
|
228
|
+
|
|
229
|
+
Raises:
|
|
230
|
+
RuntimeError: If FFmpeg is not available or conversion fails.
|
|
231
|
+
ValueError: If input_format is "pcm" but sample_rate or channels not provided.
|
|
232
|
+
|
|
233
|
+
"""
|
|
234
|
+
if input_format == "pcm" and (sample_rate is None or channels is None):
|
|
235
|
+
msg = "sample_rate and channels are required when input_format is 'pcm'"
|
|
236
|
+
raise ValueError(msg)
|
|
237
|
+
|
|
238
|
+
if not shutil.which("ffmpeg"):
|
|
239
|
+
msg = "FFmpeg not found in PATH. Please install FFmpeg for MP3 conversion."
|
|
240
|
+
raise RuntimeError(msg)
|
|
241
|
+
|
|
242
|
+
input_suffix = ".wav" if input_format == "wav" else ".raw"
|
|
243
|
+
tmp_dir = Path(tempfile.mkdtemp())
|
|
244
|
+
input_path = tmp_dir / f"input{input_suffix}"
|
|
245
|
+
output_path = tmp_dir / "output.mp3"
|
|
246
|
+
|
|
247
|
+
try:
|
|
248
|
+
input_path.write_bytes(audio_data)
|
|
249
|
+
|
|
250
|
+
cmd = ["ffmpeg", "-y"]
|
|
251
|
+
if input_format == "pcm":
|
|
252
|
+
cmd.extend(["-f", "s16le", "-ar", str(sample_rate), "-ac", str(channels)])
|
|
253
|
+
cmd.extend(["-i", str(input_path), "-b:a", bitrate, "-q:a", "2", str(output_path)])
|
|
254
|
+
|
|
255
|
+
logger.debug("Running FFmpeg MP3 conversion: %s", " ".join(cmd))
|
|
256
|
+
_run_ffmpeg(cmd, timeout)
|
|
257
|
+
|
|
258
|
+
return output_path.read_bytes()
|
|
259
|
+
finally:
|
|
260
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def save_audio_as_mp3(
|
|
264
|
+
audio_data: bytes,
|
|
265
|
+
output_path: Path,
|
|
266
|
+
sample_rate: int = constants.AUDIO_RATE,
|
|
267
|
+
channels: int = constants.AUDIO_CHANNELS,
|
|
268
|
+
bitrate: str = "64k",
|
|
269
|
+
) -> Path:
|
|
270
|
+
"""Convert raw PCM audio data to MP3 format and save to file.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
audio_data: Raw PCM audio data (16-bit signed little-endian).
|
|
274
|
+
output_path: Path where the MP3 file will be saved.
|
|
275
|
+
sample_rate: Audio sample rate in Hz.
|
|
276
|
+
channels: Number of audio channels.
|
|
277
|
+
bitrate: MP3 bitrate (e.g., "128k", "192k", "256k").
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Path to the saved MP3 file.
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
RuntimeError: If FFmpeg is not available or conversion fails.
|
|
284
|
+
|
|
285
|
+
"""
|
|
286
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
287
|
+
|
|
288
|
+
mp3_data = convert_to_mp3(
|
|
289
|
+
audio_data,
|
|
290
|
+
input_format="pcm",
|
|
291
|
+
sample_rate=sample_rate,
|
|
292
|
+
channels=channels,
|
|
293
|
+
bitrate=bitrate,
|
|
294
|
+
timeout=None,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
output_path.write_bytes(mp3_data)
|
|
298
|
+
logger.debug("Saved MP3 to %s", output_path)
|
|
299
|
+
return output_path
|
agent_cli/core/chroma.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Shared ChromaDB helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Any
|
|
6
|
+
|
|
7
|
+
from agent_cli.constants import DEFAULT_OPENAI_EMBEDDING_MODEL
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from chromadb import Collection
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def init_collection(
|
|
18
|
+
persistence_path: Path,
|
|
19
|
+
*,
|
|
20
|
+
name: str,
|
|
21
|
+
embedding_model: str = DEFAULT_OPENAI_EMBEDDING_MODEL,
|
|
22
|
+
openai_base_url: str | None = None,
|
|
23
|
+
openai_api_key: str | None = None,
|
|
24
|
+
subdir: str | None = None,
|
|
25
|
+
) -> Collection:
|
|
26
|
+
"""Initialize a Chroma collection with OpenAI-compatible embeddings."""
|
|
27
|
+
import chromadb # noqa: PLC0415
|
|
28
|
+
from chromadb.config import Settings # noqa: PLC0415
|
|
29
|
+
from chromadb.utils import embedding_functions # noqa: PLC0415
|
|
30
|
+
|
|
31
|
+
target_path = persistence_path / subdir if subdir else persistence_path
|
|
32
|
+
target_path.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
client = chromadb.PersistentClient(
|
|
34
|
+
path=str(target_path),
|
|
35
|
+
settings=Settings(anonymized_telemetry=False),
|
|
36
|
+
)
|
|
37
|
+
embed_fn = embedding_functions.OpenAIEmbeddingFunction(
|
|
38
|
+
api_base=openai_base_url,
|
|
39
|
+
api_key=openai_api_key or "dummy",
|
|
40
|
+
model_name=embedding_model,
|
|
41
|
+
)
|
|
42
|
+
return client.get_or_create_collection(name=name, embedding_function=embed_fn)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def flatten_metadatas(metadatas: Sequence[BaseModel]) -> list[dict[str, Any]]:
|
|
46
|
+
"""Serialize metadata models to JSON-safe dicts while preserving lists."""
|
|
47
|
+
return [meta.model_dump(mode="json", exclude_none=True) for meta in metadatas]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def upsert(
|
|
51
|
+
collection: Collection,
|
|
52
|
+
*,
|
|
53
|
+
ids: list[str],
|
|
54
|
+
documents: list[str],
|
|
55
|
+
metadatas: Sequence[BaseModel],
|
|
56
|
+
batch_size: int = 10,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Upsert documents with JSON-serialized metadata.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
collection: ChromaDB collection.
|
|
62
|
+
ids: Document IDs.
|
|
63
|
+
documents: Document contents.
|
|
64
|
+
metadatas: Pydantic metadata models.
|
|
65
|
+
batch_size: Max documents per embedding API call (default: 10).
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
if not ids:
|
|
69
|
+
return
|
|
70
|
+
serialized = flatten_metadatas(metadatas)
|
|
71
|
+
|
|
72
|
+
# Process in batches to avoid overwhelming the embedding service
|
|
73
|
+
for i in range(0, len(ids), batch_size):
|
|
74
|
+
batch_ids = ids[i : i + batch_size]
|
|
75
|
+
batch_docs = documents[i : i + batch_size]
|
|
76
|
+
batch_metas = serialized[i : i + batch_size]
|
|
77
|
+
collection.upsert(ids=batch_ids, documents=batch_docs, metadatas=batch_metas)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def delete(collection: Collection, ids: list[str]) -> None:
|
|
81
|
+
"""Delete documents by ID."""
|
|
82
|
+
if ids:
|
|
83
|
+
collection.delete(ids=ids)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def delete_where(collection: Collection, where: Mapping[str, Any]) -> None:
|
|
87
|
+
"""Delete documents by a filter."""
|
|
88
|
+
collection.delete(where=where)
|
agent_cli/core/deps.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Helpers for optional dependency checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from importlib.util import find_spec
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
11
|
+
|
|
12
|
+
import typer
|
|
13
|
+
|
|
14
|
+
from agent_cli.config import load_config
|
|
15
|
+
from agent_cli.core.utils import console, print_error_message
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
F = TypeVar("F", bound="Callable[..., object]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _get_auto_install_setting() -> bool:
|
|
24
|
+
"""Check if auto-install is enabled (default: True)."""
|
|
25
|
+
if os.environ.get("AGENT_CLI_NO_AUTO_INSTALL", "").lower() in ("1", "true", "yes"):
|
|
26
|
+
return False
|
|
27
|
+
return load_config().get("settings", {}).get("auto_install_extras", True)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Load extras from JSON file
|
|
31
|
+
_EXTRAS_FILE = Path(__file__).parent.parent / "_extras.json"
|
|
32
|
+
EXTRAS: dict[str, tuple[str, list[str]]] = {
|
|
33
|
+
k: (v[0], v[1]) for k, v in json.loads(_EXTRAS_FILE.read_text()).items()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _check_package_installed(pkg: str) -> bool:
|
|
38
|
+
"""Check if a single package is installed."""
|
|
39
|
+
top_module = pkg.split(".")[0]
|
|
40
|
+
try:
|
|
41
|
+
return find_spec(top_module) is not None
|
|
42
|
+
except (ValueError, ModuleNotFoundError):
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def check_extra_installed(extra: str) -> bool:
|
|
47
|
+
"""Check if packages for an extra are installed using find_spec (no actual import).
|
|
48
|
+
|
|
49
|
+
Supports `|` syntax for alternatives: "piper|kokoro" means ANY of these extras.
|
|
50
|
+
For regular extras, ALL packages must be installed.
|
|
51
|
+
"""
|
|
52
|
+
# Handle "extra1|extra2" syntax - any of these extras is sufficient
|
|
53
|
+
if "|" in extra:
|
|
54
|
+
return any(check_extra_installed(e) for e in extra.split("|"))
|
|
55
|
+
|
|
56
|
+
if extra not in EXTRAS:
|
|
57
|
+
return False # Unknown extra, trigger install attempt to surface error
|
|
58
|
+
_, packages = EXTRAS[extra]
|
|
59
|
+
|
|
60
|
+
# All packages must be installed
|
|
61
|
+
return all(_check_package_installed(pkg) for pkg in packages)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _format_extra_item(extra: str) -> str:
|
|
65
|
+
"""Format a single extra as a list item with description."""
|
|
66
|
+
desc, _ = EXTRAS.get(extra, ("", []))
|
|
67
|
+
if desc:
|
|
68
|
+
return f" - '{extra}' ({desc})"
|
|
69
|
+
return f" - '{extra}'"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _format_install_commands(extras: list[str]) -> list[str]:
|
|
73
|
+
"""Format install commands for one or more extras."""
|
|
74
|
+
combined = ",".join(extras)
|
|
75
|
+
extras_args = " ".join(extras)
|
|
76
|
+
return [
|
|
77
|
+
"Install with:",
|
|
78
|
+
f' [bold cyan]uv tool install -p 3.13 "agent-cli\\[{combined}]"[/bold cyan]',
|
|
79
|
+
" # or",
|
|
80
|
+
f" [bold cyan]agent-cli install-extras {extras_args}[/bold cyan]",
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_install_hint(extra: str) -> str:
|
|
85
|
+
"""Get install command hint for a single extra.
|
|
86
|
+
|
|
87
|
+
Supports `|` syntax for alternatives: "piper|kokoro" shows both options.
|
|
88
|
+
"""
|
|
89
|
+
# Handle "extra1|extra2" syntax - show all options
|
|
90
|
+
if "|" in extra:
|
|
91
|
+
alternatives = extra.split("|")
|
|
92
|
+
lines = ["This command requires one of:"]
|
|
93
|
+
lines.extend(_format_extra_item(alt) for alt in alternatives)
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append("Install one with:")
|
|
96
|
+
lines.extend(
|
|
97
|
+
f' [bold cyan]uv tool install -p 3.13 "agent-cli\\[{alt}]"[/bold cyan]'
|
|
98
|
+
for alt in alternatives
|
|
99
|
+
)
|
|
100
|
+
lines.append(" # or")
|
|
101
|
+
lines.extend(
|
|
102
|
+
f" [bold cyan]agent-cli install-extras {alt}[/bold cyan]" for alt in alternatives
|
|
103
|
+
)
|
|
104
|
+
return "\n".join(lines)
|
|
105
|
+
|
|
106
|
+
desc, _ = EXTRAS.get(extra, ("", []))
|
|
107
|
+
header = f"This command requires the '{extra}' extra"
|
|
108
|
+
if desc:
|
|
109
|
+
header += f" ({desc})"
|
|
110
|
+
header += "."
|
|
111
|
+
|
|
112
|
+
lines = [header, ""]
|
|
113
|
+
lines.extend(_format_install_commands([extra]))
|
|
114
|
+
return "\n".join(lines)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_combined_install_hint(extras: list[str]) -> str:
|
|
118
|
+
"""Get a combined install hint for multiple missing extras."""
|
|
119
|
+
if len(extras) == 1:
|
|
120
|
+
return get_install_hint(extras[0])
|
|
121
|
+
|
|
122
|
+
lines = ["This command requires the following extras:"]
|
|
123
|
+
lines.extend(_format_extra_item(extra) for extra in extras)
|
|
124
|
+
lines.append("")
|
|
125
|
+
lines.extend(_format_install_commands(extras))
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _try_auto_install(missing: list[str]) -> bool:
|
|
130
|
+
"""Attempt to auto-install missing extras. Returns True if successful."""
|
|
131
|
+
from agent_cli.install.extras import install_extras_programmatic # noqa: PLC0415
|
|
132
|
+
|
|
133
|
+
# Flatten alternatives (e.g., "piper|kokoro" -> just pick the first one)
|
|
134
|
+
extras_to_install = []
|
|
135
|
+
for extra in missing:
|
|
136
|
+
if "|" in extra:
|
|
137
|
+
# For alternatives, install the first option
|
|
138
|
+
extras_to_install.append(extra.split("|")[0])
|
|
139
|
+
else:
|
|
140
|
+
extras_to_install.append(extra)
|
|
141
|
+
|
|
142
|
+
console.print(
|
|
143
|
+
f"[yellow]Auto-installing missing extras: {', '.join(extras_to_install)}[/]",
|
|
144
|
+
)
|
|
145
|
+
return install_extras_programmatic(extras_to_install, quiet=True)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _check_and_install_extras(extras: tuple[str, ...]) -> list[str]:
|
|
149
|
+
"""Check for missing extras and attempt auto-install. Returns list of still-missing."""
|
|
150
|
+
missing = [e for e in extras if not check_extra_installed(e)]
|
|
151
|
+
if not missing:
|
|
152
|
+
return []
|
|
153
|
+
|
|
154
|
+
if not _get_auto_install_setting():
|
|
155
|
+
print_error_message(get_combined_install_hint(missing))
|
|
156
|
+
return missing
|
|
157
|
+
|
|
158
|
+
if not _try_auto_install(missing):
|
|
159
|
+
print_error_message("Auto-install failed.\n" + get_combined_install_hint(missing))
|
|
160
|
+
return missing
|
|
161
|
+
|
|
162
|
+
console.print("[green]Installation complete![/]")
|
|
163
|
+
still_missing = [e for e in extras if not check_extra_installed(e)]
|
|
164
|
+
if still_missing:
|
|
165
|
+
print_error_message(
|
|
166
|
+
"Auto-install completed but some dependencies are still missing.\n"
|
|
167
|
+
+ get_combined_install_hint(still_missing),
|
|
168
|
+
)
|
|
169
|
+
return still_missing
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def requires_extras(*extras: str) -> Callable[[F], F]:
|
|
173
|
+
"""Decorator to declare required extras for a command.
|
|
174
|
+
|
|
175
|
+
Auto-installs missing extras by default. Disable via AGENT_CLI_NO_AUTO_INSTALL=1
|
|
176
|
+
or config [settings] auto_install_extras = false.
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
def decorator(func: F) -> F:
|
|
180
|
+
func._required_extras = extras # type: ignore[attr-defined]
|
|
181
|
+
|
|
182
|
+
@functools.wraps(func)
|
|
183
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
184
|
+
if _check_and_install_extras(extras):
|
|
185
|
+
raise typer.Exit(1)
|
|
186
|
+
return func(*args, **kwargs)
|
|
187
|
+
|
|
188
|
+
wrapper._required_extras = extras # type: ignore[attr-defined]
|
|
189
|
+
return wrapper # type: ignore[return-value]
|
|
190
|
+
|
|
191
|
+
return decorator
|