spych.cli
spych CLI entry point.
Usage:
spych
Examples: spych ollama --model llama3.2:latest spych --theme light claude_code_cli spych claude_code_sdk --setting-sources user project local spych codex_cli --listen-duration 8 spych gemini_cli spych opencode_cli --model anthropic/claude-sonnet-4-5
# Live transcription
spych live
spych live --output-path my_transcript --output-format srt
spych live --stop-key q --terminate-words "stop recording"
spych live --no-timestamps --whisper-model small.en
# Multi-agent: run several agents under different wake words at once
spych multi --agents claude_code_sdk ollama --ollama-model llama3.2:latest
# Voice management
spych profile_my_voice --name my_voice
spych profile_my_voice --name my_voice --alternate-output-file ./my_voice_backup.wav
# User management
spych users
spych claude --user UserA
spych claude --user none
1""" 2spych CLI entry point. 3 4Usage: 5 spych <agent> [options] 6 7Examples: 8 spych ollama --model llama3.2:latest 9 spych --theme light claude_code_cli 10 spych claude_code_sdk --setting-sources user project local 11 spych codex_cli --listen-duration 8 12 spych gemini_cli 13 spych opencode_cli --model anthropic/claude-sonnet-4-5 14 15 # Live transcription 16 spych live 17 spych live --output-path my_transcript --output-format srt 18 spych live --stop-key q --terminate-words "stop recording" 19 spych live --no-timestamps --whisper-model small.en 20 21 # Multi-agent: run several agents under different wake words at once 22 spych multi --agents claude_code_sdk ollama --ollama-model llama3.2:latest 23 24 # Voice management 25 spych profile_my_voice --name my_voice 26 spych profile_my_voice --name my_voice --alternate-output-file ./my_voice_backup.wav 27 28 # User management 29 spych users 30 spych claude --user UserA 31 spych claude --user none 32""" 33 34import argparse 35import sys 36from importlib.metadata import version 37 38 39def _parse_bool(value: str) -> bool: 40 if value.lower() in ("true", "1", "yes"): 41 return True 42 if value.lower() in ("false", "0", "no"): 43 return False 44 raise argparse.ArgumentTypeError(f"Boolean value expected, got: {value!r}") 45 46 47def _add_shared_args(parser: argparse.ArgumentParser) -> None: 48 """Args shared by all agents.""" 49 parser.add_argument( 50 "--personality", 51 default=None, 52 metavar="NAME", 53 help=( 54 "Apply a named personality preset (e.g. jarvis). " 55 "Sets default wake words, voice, name, and response style. " 56 "Any explicit flag overrides the preset." 57 ), 58 ) 59 parser.add_argument( 60 "--name", 61 metavar="NAME", 62 help="Custom display name for the agent", 63 ) 64 parser.add_argument( 65 "--wake-words", 66 nargs="+", 67 metavar="WORD", 68 help="One or more wake words that trigger the agent", 69 ) 70 parser.add_argument( 71 "--terminate-words", 72 nargs="+", 73 metavar="WORD", 74 default=["terminate"], 75 help="Words that stop the listener (default: terminate)", 76 ) 77 parser.add_argument( 78 "--listen-duration", 79 type=float, 80 metavar="SECONDS", 81 help="Seconds to listen after wake word (default: 5)", 82 ) 83 parser.add_argument( 84 "--follow-up-listen-duration", 85 type=float, 86 metavar="SECONDS", 87 help="Seconds to listen for follow-up answers (default: 0)", 88 ) 89 parser.add_argument( 90 "--inactivity-timeout", 91 type=float, 92 default=4.0, 93 metavar="SECONDS", 94 help="Seconds of inactivity before pivoting back to wake word (default: 4.0)", 95 ) 96 parser.add_argument( 97 "--response-style", 98 default="", 99 metavar="STYLE", 100 help=( 101 "Style for reformatting output. " 102 "Choices: military, five_year_old, fast, pirate, news_anchor, haiku, shakespearean, robot", 103 "caveman", 104 "yoda", 105 ), 106 ) 107 parser.add_argument( 108 "--use-speaker", 109 type=_parse_bool, 110 default=True, 111 metavar="BOOL", 112 help="Speak responses aloud via TTS (default: true)", 113 ) 114 parser.add_argument( 115 "--speaker-voice", 116 default="af_heart", 117 metavar="VOICE", 118 help=( 119 "Voice name for spoken responses (default: af_heart). " 120 "Works for both Chatterbox (wave voices) and Kokoro (pt voices). " 121 "See: https://github.com/connor-makowski/spych/tree/main/voices" 122 ), 123 ) 124 parser.add_argument( 125 "--speaker-backend", 126 default="", 127 choices=["chatterbox", "kokoro"], 128 metavar="BACKEND", 129 help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)", 130 ) 131 parser.add_argument( 132 "--verbose", 133 action="store_true", 134 default=False, 135 help=( 136 "Use verbose scroll output (spinner + full log) instead of the " 137 "TUI dashboard (default: false)" 138 ), 139 ) 140 parser.add_argument( 141 "--user", 142 default=None, 143 metavar="NAME", 144 help="The user name to use for tailored responses (default: default user from settings)", 145 ) 146 parser.add_argument( 147 "--intermediate-responses", 148 type=_parse_bool, 149 default=True, 150 metavar="BOOL", 151 help="Enable intermediate response chaining for long-running tasks (default: true)", 152 ) 153 154 155def _add_agent_args(parser: argparse.ArgumentParser) -> None: 156 """Args shared by all coding agents (non-Ollama).""" 157 parser.add_argument( 158 "--continue-conversation", 159 type=_parse_bool, 160 metavar="BOOL", 161 default=True, 162 help="Resume the most recent session (default: true)", 163 ) 164 parser.add_argument( 165 "--show-tool-events", 166 type=_parse_bool, 167 metavar="BOOL", 168 default=True, 169 help="Print live tool start/end events (default: true)", 170 ) 171 172 173def _build_shared_kwargs(args: argparse.Namespace) -> dict: 174 kwargs = {} 175 # Personality preset provides base defaults; explicit CLI flags override. 176 if getattr(args, "personality", None): 177 from spych.utils import get_personality 178 179 kwargs.update(get_personality(args.personality)) 180 if args.name is not None: 181 kwargs["name"] = args.name 182 if args.wake_words: 183 kwargs["wake_words"] = args.wake_words 184 if args.terminate_words: 185 kwargs["terminate_words"] = args.terminate_words 186 if args.listen_duration is not None: 187 kwargs["listen_duration"] = args.listen_duration 188 if getattr(args, "follow_up_listen_duration", None) is not None: 189 kwargs["follow_up_listen_duration"] = args.follow_up_listen_duration 190 if getattr(args, "inactivity_timeout", 4.0) is not None: 191 kwargs["inactivity_timeout"] = args.inactivity_timeout 192 if args.use_speaker is not None: 193 kwargs["use_speaker"] = args.use_speaker 194 if args.speaker_voice != "af_heart": 195 kwargs["speaker_voice"] = args.speaker_voice 196 if getattr(args, "speaker_backend", ""): 197 kwargs["speaker_backend"] = args.speaker_backend 198 if args.response_style: 199 kwargs["response_style"] = args.response_style 200 if args.user: 201 kwargs["user"] = args.user 202 if not args.intermediate_responses: 203 kwargs["allow_intermediate_responses"] = False 204 return kwargs 205 206 207def _build_agent_kwargs(args: argparse.Namespace) -> dict: 208 kwargs = _build_shared_kwargs(args) 209 kwargs["continue_conversation"] = args.continue_conversation 210 kwargs["show_tool_events"] = args.show_tool_events 211 return kwargs 212 213 214def main(): 215 __version__ = version("spych") 216 parser = argparse.ArgumentParser( 217 prog="spych", 218 description=f"spych {__version__}: Launch a voice agent from the terminal.", 219 formatter_class=argparse.RawDescriptionHelpFormatter, 220 epilog=__doc__, 221 ) 222 223 parser.add_argument( 224 "-v", 225 "--version", 226 action="version", 227 version=f"spych {__version__}", 228 help="Show the version number and exit", 229 ) 230 231 parser.add_argument( 232 "--theme", 233 default="dark", 234 choices=["dark", "light", "solarized", "mono"], 235 metavar="THEME", 236 help=( 237 "Colour theme for terminal output. " 238 "Choices: dark (default), light, solarized, mono" 239 ), 240 ) 241 242 subparsers = parser.add_subparsers(dest="agent", metavar="agent") 243 subparsers.required = True 244 245 # Aliases → canonical name; used to normalise args.agent after parsing. 246 _AGENT_ALIASES: dict[str, str] = { 247 "claude": "claude_code_sdk", 248 "codex": "codex_cli", 249 "gemini": "gemini_cli", 250 "opencode": "opencode_cli", 251 } 252 253 # ------------------------------------------------------------------ # 254 # ollama # 255 # ------------------------------------------------------------------ # 256 p_ollama = subparsers.add_parser( 257 "ollama", help="Talk to a local Ollama model" 258 ) 259 _add_shared_args(p_ollama) 260 p_ollama.add_argument( 261 "--model", 262 default="llama3.2:latest", 263 metavar="MODEL", 264 help="Ollama model name (default: llama3.2:latest)", 265 ) 266 p_ollama.add_argument( 267 "--history-length", 268 type=int, 269 default=10, 270 metavar="N", 271 help="Past interactions to include in context (default: 10)", 272 ) 273 p_ollama.add_argument( 274 "--host", 275 default="http://localhost:11434", 276 metavar="URL", 277 help="Ollama instance URL (default: http://localhost:11434)", 278 ) 279 280 # ------------------------------------------------------------------ # 281 # claude_code_cli # 282 # ------------------------------------------------------------------ # 283 p_claude_cli = subparsers.add_parser( 284 "claude_code_cli", 285 help="Voice-control Claude Code via the CLI", 286 ) 287 _add_shared_args(p_claude_cli) 288 _add_agent_args(p_claude_cli) 289 290 # ------------------------------------------------------------------ # 291 # claude_code_sdk # 292 # ------------------------------------------------------------------ # 293 p_claude_sdk = subparsers.add_parser( 294 "claude_code_sdk", 295 aliases=["claude"], 296 help="Voice-control Claude Code via the Agent SDK", 297 ) 298 _add_shared_args(p_claude_sdk) 299 _add_agent_args(p_claude_sdk) 300 p_claude_sdk.add_argument( 301 "--setting-sources", 302 nargs="+", 303 metavar="SOURCE", 304 default=["user", "project", "local"], 305 help="Claude Code settings sources to load (default: user project local)", 306 ) 307 308 # ------------------------------------------------------------------ # 309 # codex_cli # 310 # ------------------------------------------------------------------ # 311 p_codex = subparsers.add_parser( 312 "codex_cli", 313 aliases=["codex"], 314 help="Voice-control the OpenAI Codex agent", 315 ) 316 _add_shared_args(p_codex) 317 _add_agent_args(p_codex) 318 319 # ------------------------------------------------------------------ # 320 # gemini_cli # 321 # ------------------------------------------------------------------ # 322 p_gemini = subparsers.add_parser( 323 "gemini_cli", 324 aliases=["gemini"], 325 help="Voice-control the Google Gemini agent", 326 ) 327 _add_shared_args(p_gemini) 328 _add_agent_args(p_gemini) 329 330 # ------------------------------------------------------------------ # 331 # opencode_cli # 332 # ------------------------------------------------------------------ # 333 p_opencode = subparsers.add_parser( 334 "opencode_cli", 335 aliases=["opencode"], 336 help="Voice-control the OpenCode agent", 337 ) 338 _add_shared_args(p_opencode) 339 _add_agent_args(p_opencode) 340 p_opencode.add_argument( 341 "--model", 342 default=None, 343 metavar="MODEL", 344 help="Model in provider/model format, e.g. anthropic/claude-sonnet-4-5", 345 ) 346 347 # ------------------------------------------------------------------ # 348 # live — continuous transcription to file # 349 # ------------------------------------------------------------------ # 350 p_live = subparsers.add_parser( 351 "live", 352 help="Continuously transcribe speech to .txt and/or .srt files", 353 formatter_class=argparse.RawDescriptionHelpFormatter, 354 description=( 355 "Start a live transcription session. Records continuously using VAD\n" 356 "and writes output to disk in real time.\n\n" 357 "Stop by pressing the stop key (default: q + Enter), saying a\n" 358 "terminate word, or pressing Ctrl+C." 359 ), 360 ) 361 p_live.add_argument( 362 "--output-path", 363 default="transcript", 364 metavar="PATH", 365 help="Base output file path without extension (default: transcript)", 366 ) 367 p_live.add_argument( 368 "--output-format", 369 default="srt", 370 choices=["txt", "srt", "both"], 371 metavar="FORMAT", 372 help="Output format: txt, srt, or both (default: both)", 373 ) 374 p_live.add_argument( 375 "--no-timestamps", 376 action="store_true", 377 help="Omit timestamps from terminal and .txt output", 378 ) 379 p_live.add_argument( 380 "--stop-key", 381 default="q", 382 metavar="KEY", 383 help="Key to type (then Enter) to stop the session (default: q)", 384 ) 385 p_live.add_argument( 386 "--terminate-words", 387 nargs="+", 388 metavar="WORD", 389 help="Spoken words that stop the session (e.g. 'stop recording')", 390 ) 391 p_live.add_argument( 392 "--device-index", 393 type=int, 394 default=-1, 395 metavar="N", 396 help="Microphone device index; -1 uses system default (default: -1)", 397 ) 398 p_live.add_argument( 399 "--whisper-model", 400 default="base.en", 401 metavar="MODEL", 402 help="faster-whisper model name (default: base.en)", 403 ) 404 p_live.add_argument( 405 "--whisper-device", 406 default="auto", 407 choices=["auto", "cpu", "cuda"], 408 metavar="DEVICE", 409 help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.", 410 ) 411 p_live.add_argument( 412 "--whisper-compute-type", 413 default="int8", 414 choices=["int8", "float16", "float32"], 415 metavar="TYPE", 416 help="Compute type for whisper: int8, float16, float32 (default: int8)", 417 ) 418 p_live.add_argument( 419 "--no-speech-threshold", 420 type=float, 421 default=0.3, 422 metavar="FLOAT", 423 help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)", 424 ) 425 p_live.add_argument( 426 "--speech-threshold", 427 type=float, 428 default=0.5, 429 metavar="FLOAT", 430 help="VAD speech onset probability (default: 0.5)", 431 ) 432 p_live.add_argument( 433 "--silence-threshold", 434 type=float, 435 default=0.35, 436 metavar="FLOAT", 437 help="VAD silence probability during speech (default: 0.35)", 438 ) 439 p_live.add_argument( 440 "--silence-frames", 441 type=int, 442 default=20, 443 metavar="N", 444 help="Consecutive silent frames required to end a segment (~32ms each, default: 20)", 445 ) 446 p_live.add_argument( 447 "--speech-pad-frames", 448 type=int, 449 default=5, 450 metavar="N", 451 help="Pre-roll frames and onset confirmation count (default: 5)", 452 ) 453 p_live.add_argument( 454 "--max-speech-duration", 455 type=float, 456 default=30.0, 457 metavar="SECONDS", 458 help="Hard cap on a single segment in seconds (default: 30.0)", 459 ) 460 p_live.add_argument( 461 "--context-words", 462 type=int, 463 default=32, 464 metavar="N", 465 help="Trailing words passed as whisper initial_prompt for context (default: 32)", 466 ) 467 468 # ------------------------------------------------------------------ # 469 p_live_translation = subparsers.add_parser( 470 "live-translation", 471 help="Bidirectional live translation between two languages", 472 formatter_class=argparse.RawDescriptionHelpFormatter, 473 description=( 474 "Start a bidirectional live translation session. Either participant\n" 475 "can speak in either language; Whisper transcribes and Ollama detects\n" 476 "which language was spoken then translates to the other.\n\n" 477 "Each utterance is shown as two lines prefixed with [HH:MM:SS](lang):\n" 478 " [00:00:05](en) Hello, how are you?\n" 479 " [00:00:05](es) Hola, ¿cómo estás?\n\n" 480 "Stop by pressing the stop key (default: q + Enter), saying a\n" 481 "terminate word, or pressing Ctrl+C." 482 ), 483 ) 484 p_live_translation.add_argument( 485 "--languages", 486 required=True, 487 nargs=2, 488 metavar="LANG", 489 help="Two BCP-47 language codes for the conversation pair (e.g. en es)", 490 ) 491 p_live_translation.add_argument( 492 "--ollama-host", 493 default="http://localhost:11434", 494 metavar="URL", 495 help="Ollama HTTP base URL for translation (default: http://localhost:11434)", 496 ) 497 p_live_translation.add_argument( 498 "--ollama-translation-model", 499 default="llama3.2", 500 metavar="MODEL", 501 help="Ollama model name used for translation (default: llama3.2)", 502 ) 503 p_live_translation.add_argument( 504 "--no-speaker", 505 action="store_true", 506 help="Disable TTS — do not speak translated segments aloud (speaker is on by default)", 507 ) 508 p_live_translation.add_argument( 509 "--speaker-voice", 510 default="", 511 metavar="VOICE", 512 help="Wave voice name for zero-shot cloning; omit to use the model's built-in default voice", 513 ) 514 p_live_translation.add_argument( 515 "--output-path", 516 default="transcript", 517 metavar="PATH", 518 help="Base output file path without extension (default: transcript)", 519 ) 520 p_live_translation.add_argument( 521 "--output-format", 522 default=None, 523 choices=["txt", "srt", "both"], 524 metavar="FORMAT", 525 help="Save transcript to file: txt, srt, or both (default: no file output)", 526 ) 527 p_live_translation.add_argument( 528 "--no-timestamps", 529 action="store_true", 530 help="Omit timestamps from terminal and .txt output", 531 ) 532 p_live_translation.add_argument( 533 "--stop-key", 534 default="q", 535 metavar="KEY", 536 help="Key to type (then Enter) to stop the session (default: q)", 537 ) 538 p_live_translation.add_argument( 539 "--terminate-words", 540 nargs="+", 541 metavar="WORD", 542 help="Spoken words that stop the session (e.g. 'stop recording')", 543 ) 544 p_live_translation.add_argument( 545 "--device-index", 546 type=int, 547 default=-1, 548 metavar="N", 549 help="Microphone device index; -1 uses system default (default: -1)", 550 ) 551 p_live_translation.add_argument( 552 "--whisper-model", 553 default="small", 554 metavar="MODEL", 555 help="faster-whisper model name; .en suffix stripped automatically (default: base)", 556 ) 557 p_live_translation.add_argument( 558 "--whisper-device", 559 default="auto", 560 choices=["auto", "cpu", "cuda"], 561 metavar="DEVICE", 562 help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.", 563 ) 564 p_live_translation.add_argument( 565 "--whisper-compute-type", 566 default="int8", 567 choices=["int8", "float16", "float32"], 568 metavar="TYPE", 569 help="Compute type for whisper: int8, float16, float32 (default: int8)", 570 ) 571 p_live_translation.add_argument( 572 "--no-speech-threshold", 573 type=float, 574 default=0.3, 575 metavar="FLOAT", 576 help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)", 577 ) 578 p_live_translation.add_argument( 579 "--speech-threshold", 580 type=float, 581 default=0.5, 582 metavar="FLOAT", 583 help="VAD speech onset probability (default: 0.5)", 584 ) 585 p_live_translation.add_argument( 586 "--silence-threshold", 587 type=float, 588 default=0.35, 589 metavar="FLOAT", 590 help="VAD silence probability during speech (default: 0.35)", 591 ) 592 p_live_translation.add_argument( 593 "--silence-frames", 594 type=int, 595 default=20, 596 metavar="N", 597 help="Consecutive silent frames required to end a segment (~32ms each, default: 20)", 598 ) 599 p_live_translation.add_argument( 600 "--speech-pad-frames", 601 type=int, 602 default=5, 603 metavar="N", 604 help="Pre-roll frames and onset confirmation count (default: 5)", 605 ) 606 p_live_translation.add_argument( 607 "--max-speech-duration", 608 type=float, 609 default=30.0, 610 metavar="SECONDS", 611 help="Hard cap on a single segment in seconds (default: 30.0)", 612 ) 613 # ------------------------------------------------------------------ # 614 p_multi = subparsers.add_parser( 615 "multi", 616 help="Run multiple agents simultaneously under different wake words", 617 formatter_class=argparse.RawDescriptionHelpFormatter, 618 description=( 619 "Run several agents at once. Each agent uses its own default wake " 620 "words unless overridden.\n\n" 621 "Example:\n" 622 " spych multi --agents claude_code_cli gemini_cli\n" 623 " spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n" 624 " spych multi --agents claude_code_sdk codex_cli --listen-duration 8" 625 ), 626 ) 627 p_multi.add_argument( 628 "--agents", 629 nargs="+", 630 required=True, 631 metavar="AGENT", 632 choices=[ 633 "claude_code_cli", 634 "claude", 635 "claude_code_sdk", 636 "claude_sdk", 637 "codex_cli", 638 "codex", 639 "gemini_cli", 640 "gemini", 641 "opencode_cli", 642 "opencode", 643 "ollama", 644 ], 645 help=( 646 "Agents to run. Choices: claude (claude_code_cli), " 647 "claude_sdk (claude_code_sdk), codex (codex_cli), " 648 "gemini (gemini_cli), opencode (opencode_cli), ollama" 649 ), 650 ) 651 p_multi.add_argument( 652 "--terminate-words", 653 nargs="+", 654 metavar="WORD", 655 default=["terminate"], 656 help="Words that stop all agents (default: terminate)", 657 ) 658 p_multi.add_argument( 659 "--listen-duration", 660 type=float, 661 default=5, 662 metavar="SECONDS", 663 help="Seconds to listen after a wake word (default: 5)", 664 ) 665 p_multi.add_argument( 666 "--follow-up-listen-duration", 667 type=float, 668 default=0, 669 metavar="SECONDS", 670 help="Seconds to listen for follow-up answers (default: 0)", 671 ) 672 p_multi.add_argument( 673 "--inactivity-timeout", 674 type=float, 675 default=4.0, 676 metavar="SECONDS", 677 help="Seconds of inactivity before pivoting back to wake word (default: 4.0)", 678 ) 679 p_multi.add_argument( 680 "--continue-conversation", 681 type=_parse_bool, 682 default=True, 683 metavar="BOOL", 684 help="Resume most recent session for each coding agent (default: true)", 685 ) 686 p_multi.add_argument( 687 "--show-tool-events", 688 type=_parse_bool, 689 default=True, 690 metavar="BOOL", 691 help="Print live tool start/end events (default: true)", 692 ) 693 p_multi.add_argument( 694 "--speaker-backend", 695 default="", 696 choices=["chatterbox", "kokoro"], 697 metavar="BACKEND", 698 help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)", 699 ) 700 p_multi.add_argument( 701 "--use-speaker", 702 type=_parse_bool, 703 default=True, 704 metavar="BOOL", 705 help="Speak responses aloud via TTS (default: true)", 706 ) 707 # ollama-specific flags (only used when 'ollama' is in --agents) 708 p_multi.add_argument( 709 "--ollama-model", 710 default="llama3.2:latest", 711 metavar="MODEL", 712 help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.", 713 ) 714 p_multi.add_argument( 715 "--ollama-host", 716 default="http://localhost:11434", 717 metavar="URL", 718 help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.", 719 ) 720 p_multi.add_argument( 721 "--ollama-history-length", 722 type=int, 723 default=10, 724 metavar="N", 725 help="Ollama context history length (default: 10). Only used when ollama is in --agents.", 726 ) 727 # opencode-specific flag 728 p_multi.add_argument( 729 "--opencode-model", 730 default=None, 731 metavar="MODEL", 732 help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.", 733 ) 734 # claude_code_sdk-specific flag 735 p_multi.add_argument( 736 "--setting-sources", 737 nargs="+", 738 metavar="SOURCE", 739 default=["user", "project", "local"], 740 help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.", 741 ) 742 743 # ------------------------------------------------------------------ # 744 # profile_my_voice — Record a custom voice profile # 745 # ------------------------------------------------------------------ # 746 p_profile = subparsers.add_parser( 747 "profile_my_voice", 748 help="Record a 10-second voice sample to create a custom profile", 749 ) 750 p_profile.add_argument( 751 "--name", 752 required=True, 753 metavar="NAME", 754 help="The name to save this voice profile as (e.g. 'my_voice')", 755 ) 756 p_profile.add_argument( 757 "--device-index", 758 type=int, 759 default=-1, 760 metavar="N", 761 help="Microphone device index; -1 uses system default (default: -1)", 762 ) 763 p_profile.add_argument( 764 "--alternate-output-file", 765 default=None, 766 metavar="PATH", 767 help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')", 768 ) 769 770 # ------------------------------------------------------------------ # 771 # users — manage user profiles # 772 # ------------------------------------------------------------------ # 773 p_users = subparsers.add_parser( 774 "users", 775 help="Manage user profiles and global settings", 776 description=( 777 "Launch an interactive menu to manage user profiles and global " 778 "preferences. Profiles store personal info (name, age, extra context) " 779 "used to tailor agent responses. You can also set the default user " 780 "and terminal theme here." 781 ), 782 ) 783 784 # ------------------------------------------------------------------ # 785 # Dispatch # 786 # ------------------------------------------------------------------ # 787 args = parser.parse_args() 788 789 # Normalise any alias back to the canonical agent name so the dispatch 790 # block below only needs to handle one name per agent. 791 args.agent = _AGENT_ALIASES.get(args.agent, args.agent) 792 793 # Apply color theme as early as possible so all subsequent output uses it. 794 if args.theme != "dark": 795 from spych.cli_tools import set_theme 796 797 set_theme(args.theme) 798 799 # ------------------------------------------------------------------ # 800 # Single-agent dispatch # 801 # ------------------------------------------------------------------ # 802 803 # Default wake words per agent — mirrors the factory function defaults. 804 _DEFAULT_WAKE_WORDS: dict[str, list[str]] = { 805 "ollama": ["llama", "ollama", "lama"], 806 "claude_code_cli": ["claude", "clod", "cloud", "clawed"], 807 "claude_code_sdk": ["claude", "clod", "cloud", "clawed"], 808 "codex_cli": ["codex"], 809 "gemini_cli": ["gemini"], 810 "opencode_cli": ["opencode", "open code"], 811 } 812 813 _AGENT_RESPONDERS: dict[str, str] = { 814 "ollama": "Ollama", 815 "claude_code_cli": "Claude Code CLI", 816 "claude_code_sdk": "Claude Code SDK", 817 "codex_cli": "Codex CLI", 818 "gemini_cli": "Gemini CLI", 819 "opencode_cli": "OpenCode CLI", 820 } 821 822 def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict): 823 """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass.""" 824 from spych.dashboard import AgentDashboard 825 from spych.utils import get_user, get_default_user 826 827 user_name = kwargs.get("user") or get_default_user() 828 profile_name = "User" 829 if user_name and user_name.lower() != "none": 830 profile = get_user(user_name) 831 if profile: 832 profile_name = profile.get("name", "User") or "User" 833 834 wake_words = kwargs.get( 835 "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, []) 836 ) 837 838 display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name) 839 kwargs["display_name"] = display_responder 840 841 dashboard = AgentDashboard( 842 agent_name=kwargs.get("name", agent_name), 843 wake_words=wake_words, 844 responder_name=display_responder, 845 response_style=kwargs.get("response_style", ""), 846 use_speaker=kwargs.get("use_speaker", True), 847 speaker_voice=kwargs.get("speaker_voice", "af_heart"), 848 user_name=profile_name, 849 ) 850 print(" ◌ Running healthchecks...") 851 kwargs["dashboard"] = dashboard 852 return dashboard 853 854 if args.agent == "ollama": 855 from spych.agents import ollama 856 857 kwargs = _build_shared_kwargs(args) 858 kwargs["model"] = args.model 859 kwargs["history_length"] = args.history_length 860 kwargs["host"] = args.host 861 dashboard = ( 862 _start_dashboard("Ollama", "OllamaResponder", kwargs) 863 if not args.verbose 864 else None 865 ) 866 try: 867 ollama(**kwargs) 868 finally: 869 if dashboard is not None: 870 dashboard.stop() 871 872 elif args.agent == "claude_code_cli": 873 from spych.agents import claude_code_cli 874 875 kwargs = _build_agent_kwargs(args) 876 dashboard = ( 877 _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs) 878 if not args.verbose 879 else None 880 ) 881 try: 882 claude_code_cli(**kwargs) 883 finally: 884 if dashboard is not None: 885 dashboard.stop() 886 887 elif args.agent == "claude_code_sdk": 888 from spych.agents import claude_code_sdk 889 890 kwargs = _build_agent_kwargs(args) 891 kwargs["setting_sources"] = args.setting_sources 892 dashboard = ( 893 _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs) 894 if not args.verbose 895 else None 896 ) 897 try: 898 claude_code_sdk(**kwargs) 899 finally: 900 if dashboard is not None: 901 dashboard.stop() 902 903 elif args.agent == "codex_cli": 904 from spych.agents import codex_cli 905 906 kwargs = _build_agent_kwargs(args) 907 dashboard = ( 908 _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs) 909 if not args.verbose 910 else None 911 ) 912 try: 913 codex_cli(**kwargs) 914 finally: 915 if dashboard is not None: 916 dashboard.stop() 917 918 elif args.agent == "gemini_cli": 919 from spych.agents import gemini_cli 920 921 kwargs = _build_agent_kwargs(args) 922 dashboard = ( 923 _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs) 924 if not args.verbose 925 else None 926 ) 927 try: 928 gemini_cli(**kwargs) 929 finally: 930 if dashboard is not None: 931 dashboard.stop() 932 933 elif args.agent == "opencode_cli": 934 from spych.agents import opencode_cli 935 936 kwargs = _build_agent_kwargs(args) 937 if args.model is not None: 938 kwargs["model"] = args.model 939 dashboard = ( 940 _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs) 941 if not args.verbose 942 else None 943 ) 944 try: 945 opencode_cli(**kwargs) 946 finally: 947 if dashboard is not None: 948 dashboard.stop() 949 950 elif args.agent == "live": 951 from spych.live import SpychLive 952 953 SpychLive( 954 output_format=args.output_format, 955 output_path=args.output_path, 956 show_timestamps=not args.no_timestamps, 957 stop_key=args.stop_key, 958 terminate_words=args.terminate_words, 959 device_index=args.device_index, 960 whisper_model=args.whisper_model, 961 whisper_device=args.whisper_device, 962 whisper_compute_type=args.whisper_compute_type, 963 no_speech_threshold=args.no_speech_threshold, 964 speech_threshold=args.speech_threshold, 965 silence_threshold=args.silence_threshold, 966 silence_frames_threshold=args.silence_frames, 967 speech_pad_frames=args.speech_pad_frames, 968 max_speech_duration_s=args.max_speech_duration, 969 context_words=args.context_words, 970 ).start() 971 972 elif args.agent == "live-translation": 973 from spych.live_translation import SpychLiveTranslation 974 975 SpychLiveTranslation( 976 lang_a=args.languages[0], 977 lang_b=args.languages[1], 978 output_format=args.output_format or "", 979 output_path=args.output_path, 980 show_timestamps=not args.no_timestamps, 981 stop_key=args.stop_key, 982 terminate_words=args.terminate_words, 983 device_index=args.device_index, 984 whisper_model=args.whisper_model, 985 whisper_device=args.whisper_device, 986 whisper_compute_type=args.whisper_compute_type, 987 no_speech_threshold=args.no_speech_threshold, 988 speech_threshold=args.speech_threshold, 989 silence_threshold=args.silence_threshold, 990 silence_frames_threshold=args.silence_frames, 991 speech_pad_frames=args.speech_pad_frames, 992 max_speech_duration_s=args.max_speech_duration, 993 ollama_host=args.ollama_host, 994 ollama_translation_model=args.ollama_translation_model, 995 use_speaker=not args.no_speaker, 996 speaker_voice=args.speaker_voice, 997 ).start() 998 999 elif args.agent == "profile_my_voice": 1000 from spych.voice_manager import profile_my_voice 1001 1002 profile_my_voice( 1003 name=args.name, 1004 device_index=args.device_index, 1005 alternate_output_file=args.alternate_output_file, 1006 ) 1007 1008 elif args.agent == "users": 1009 from spych.utils import ( 1010 get_all_users, 1011 get_user, 1012 set_user, 1013 set_default_user, 1014 get_default_user, 1015 set_setting, 1016 get_setting, 1017 ) 1018 from spych.cli_tools import set_theme 1019 1020 def users_menu(): 1021 while True: 1022 print("\n " + "=" * 20) 1023 print(" SPYCH USER MANAGEMENT") 1024 print(" " + "=" * 20) 1025 1026 users = get_all_users() 1027 default_user = get_default_user() 1028 current_theme = get_setting("theme", "dark") 1029 1030 print(f"\n Default User: {default_user or 'None'}") 1031 print(f" Current Theme: {current_theme}") 1032 print("\n Users:") 1033 if not users: 1034 print(" (No users found)") 1035 for u in users: 1036 print( 1037 f" - {u}{' (default)' if u == default_user else ''}" 1038 ) 1039 1040 print("\n Options:") 1041 print(" 1. Create new user") 1042 print(" 2. Edit user") 1043 print(" 3. Delete user") 1044 print(" 4. Set default user") 1045 print(" 5. Set theme") 1046 print(" 6. Exit") 1047 1048 choice = input("\n Choice: ").strip() 1049 1050 if choice == "1": 1051 name = input(" User name: ").strip() 1052 if name: 1053 data = { 1054 "name": input(" Full name: ").strip(), 1055 "age": input(" Age: ").strip(), 1056 "gender": input(" Gender: ").strip(), 1057 "extra": input(" Extra info: ").strip(), 1058 } 1059 set_user(name, data) 1060 print(f" User '{name}' created.") 1061 1062 elif choice == "2": 1063 name = input(" User name to edit: ").strip() 1064 user = get_user(name) 1065 if user: 1066 print(f" Editing {name} (leave blank to keep current)") 1067 user["name"] = input( 1068 f" Full name [{user.get('name', '')}]: " 1069 ).strip() or user.get("name", "") 1070 user["age"] = input( 1071 f" Age [{user.get('age', '')}]: " 1072 ).strip() or user.get("age", "") 1073 user["gender"] = input( 1074 f" Gender [{user.get('gender', '')}]: " 1075 ).strip() or user.get("gender", "") 1076 user["extra"] = input( 1077 f" Extra info [{user.get('extra', '')}]: " 1078 ).strip() or user.get("extra", "") 1079 set_user(name, user) 1080 print(f" User '{name}' updated.") 1081 else: 1082 print(" User not found.") 1083 1084 elif choice == "3": 1085 name = input(" User name to delete: ").strip() 1086 path = os.path.join(get_cache_dir("users"), f"{name}.json") 1087 if os.path.exists(path): 1088 os.remove(path) 1089 if get_default_user() == name: 1090 set_default_user(None) 1091 print(f" User '{name}' deleted.") 1092 else: 1093 print(" User not found.") 1094 1095 elif choice == "4": 1096 name = input(" Default user name (or 'none'): ").strip() 1097 if name.lower() == "none": 1098 set_default_user(None) 1099 print(" Default user cleared.") 1100 elif name in get_all_users(): 1101 set_default_user(name) 1102 print(f" Default user set to '{name}'.") 1103 else: 1104 print(" User not found.") 1105 1106 elif choice == "5": 1107 theme = ( 1108 input(" Theme (dark, light, solarized, mono): ") 1109 .strip() 1110 .lower() 1111 ) 1112 if theme in ["dark", "light", "solarized", "mono"]: 1113 set_setting("theme", theme) 1114 set_theme(theme) 1115 print(f" Theme set to '{theme}'.") 1116 else: 1117 print(" Invalid theme.") 1118 1119 elif choice == "6": 1120 break 1121 1122 users_menu() 1123 1124 # ------------------------------------------------------------------ # 1125 # Multi-agent dispatch # 1126 # ------------------------------------------------------------------ # 1127 elif args.agent == "multi": 1128 from spych.core import Spych 1129 from spych.orchestrator import SpychOrchestrator 1130 1131 # A single Spych transcription object shared by all responders. 1132 spych_object = Spych(whisper_model="base.en") 1133 1134 # Build dashboard before responders so it can be injected. 1135 multi_dashboard = None 1136 if not args.verbose: 1137 from spych.dashboard import AgentDashboard 1138 from spych.utils import get_user, get_default_user 1139 1140 user_name = args.user or get_default_user() 1141 profile_name = "User" 1142 if user_name and user_name.lower() != "none": 1143 profile = get_user(user_name) 1144 if profile: 1145 profile_name = profile.get("name", "User") or "User" 1146 1147 first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0]) 1148 _multi_name_map = { 1149 "claude_code_cli": "Claude", 1150 "claude_code_sdk": "Claude", 1151 "codex_cli": "Codex", 1152 "gemini_cli": "Gemini", 1153 "opencode_cli": "OpenCode", 1154 "ollama": "Ollama", 1155 } 1156 multi_dashboard = AgentDashboard( 1157 agent_name=_multi_name_map.get(first_agent, first_agent), 1158 wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []), 1159 responder_name=_AGENT_RESPONDERS.get(first_agent, ""), 1160 use_speaker=args.use_speaker, 1161 user_name=profile_name, 1162 ) 1163 print(" ◌ Running healthchecks...") 1164 1165 entries = [] 1166 1167 for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]: 1168 if agent_name == "claude_code_cli": 1169 from spych.agents.claude import LocalClaudeCodeCLIResponder 1170 1171 entries.append( 1172 { 1173 "responder": LocalClaudeCodeCLIResponder( 1174 spych_object=spych_object, 1175 continue_conversation=args.continue_conversation, 1176 listen_duration=args.listen_duration, 1177 follow_up_listen_duration=args.follow_up_listen_duration, 1178 inactivity_timeout=args.inactivity_timeout, 1179 speaker_backend=args.speaker_backend, 1180 use_speaker=args.use_speaker, 1181 show_tool_events=args.show_tool_events, 1182 dashboard=multi_dashboard, 1183 user=args.user, 1184 display_name=_AGENT_RESPONDERS.get( 1185 "claude_code_cli" 1186 ), 1187 ), 1188 "wake_words": ["claude", "clod", "cloud", "clawed"], 1189 "terminate_words": args.terminate_words, 1190 } 1191 ) 1192 1193 elif agent_name == "claude_code_sdk": 1194 from spych.agents.claude import LocalClaudeCodeSDKResponder 1195 1196 entries.append( 1197 { 1198 "responder": LocalClaudeCodeSDKResponder( 1199 spych_object=spych_object, 1200 continue_conversation=args.continue_conversation, 1201 listen_duration=args.listen_duration, 1202 follow_up_listen_duration=args.follow_up_listen_duration, 1203 inactivity_timeout=args.inactivity_timeout, 1204 speaker_backend=args.speaker_backend, 1205 use_speaker=args.use_speaker, 1206 setting_sources=args.setting_sources, 1207 show_tool_events=args.show_tool_events, 1208 dashboard=multi_dashboard, 1209 user=args.user, 1210 display_name=_AGENT_RESPONDERS.get( 1211 "claude_code_sdk" 1212 ), 1213 ), 1214 "wake_words": ["claude", "clod", "cloud", "clawed"], 1215 "terminate_words": args.terminate_words, 1216 } 1217 ) 1218 1219 elif agent_name == "codex_cli": 1220 from spych.agents.codex import LocalCodexCLIResponder 1221 1222 entries.append( 1223 { 1224 "responder": LocalCodexCLIResponder( 1225 spych_object=spych_object, 1226 continue_conversation=args.continue_conversation, 1227 listen_duration=args.listen_duration, 1228 follow_up_listen_duration=args.follow_up_listen_duration, 1229 inactivity_timeout=args.inactivity_timeout, 1230 speaker_backend=args.speaker_backend, 1231 use_speaker=args.use_speaker, 1232 show_tool_events=args.show_tool_events, 1233 dashboard=multi_dashboard, 1234 user=args.user, 1235 display_name=_AGENT_RESPONDERS.get("codex_cli"), 1236 ), 1237 "wake_words": ["codex"], 1238 "terminate_words": args.terminate_words, 1239 } 1240 ) 1241 1242 elif agent_name == "gemini_cli": 1243 from spych.agents.gemini import LocalGeminiCLIResponder 1244 1245 entries.append( 1246 { 1247 "responder": LocalGeminiCLIResponder( 1248 spych_object=spych_object, 1249 continue_conversation=args.continue_conversation, 1250 listen_duration=args.listen_duration, 1251 follow_up_listen_duration=args.follow_up_listen_duration, 1252 inactivity_timeout=args.inactivity_timeout, 1253 speaker_backend=args.speaker_backend, 1254 use_speaker=args.use_speaker, 1255 show_tool_events=args.show_tool_events, 1256 dashboard=multi_dashboard, 1257 user=args.user, 1258 display_name=_AGENT_RESPONDERS.get("gemini_cli"), 1259 ), 1260 "wake_words": ["gemini"], 1261 "terminate_words": args.terminate_words, 1262 } 1263 ) 1264 1265 elif agent_name == "opencode_cli": 1266 from spych.agents.opencode import LocalOpenCodeCLIResponder 1267 1268 entries.append( 1269 { 1270 "responder": LocalOpenCodeCLIResponder( 1271 spych_object=spych_object, 1272 continue_conversation=args.continue_conversation, 1273 listen_duration=args.listen_duration, 1274 follow_up_listen_duration=args.follow_up_listen_duration, 1275 inactivity_timeout=args.inactivity_timeout, 1276 speaker_backend=args.speaker_backend, 1277 use_speaker=args.use_speaker, 1278 show_tool_events=args.show_tool_events, 1279 model=args.opencode_model, 1280 dashboard=multi_dashboard, 1281 user=args.user, 1282 display_name=_AGENT_RESPONDERS.get("opencode_cli"), 1283 ), 1284 "wake_words": ["opencode", "open code"], 1285 "terminate_words": args.terminate_words, 1286 } 1287 ) 1288 1289 elif agent_name == "ollama": 1290 from spych.agents.ollama import OllamaResponder 1291 1292 entries.append( 1293 { 1294 "responder": OllamaResponder( 1295 spych_object=spych_object, 1296 model=args.ollama_model, 1297 history_length=args.ollama_history_length, 1298 host=args.ollama_host, 1299 listen_duration=args.listen_duration, 1300 follow_up_listen_duration=args.follow_up_listen_duration, 1301 inactivity_timeout=args.inactivity_timeout, 1302 speaker_backend=args.speaker_backend, 1303 use_speaker=args.use_speaker, 1304 dashboard=multi_dashboard, 1305 user=args.user, 1306 display_name=_AGENT_RESPONDERS.get("ollama"), 1307 ), 1308 "wake_words": ["llama", "ollama", "lama"], 1309 "terminate_words": args.terminate_words, 1310 } 1311 ) 1312 1313 try: 1314 SpychOrchestrator(entries=entries).start() 1315 finally: 1316 if multi_dashboard is not None: 1317 multi_dashboard.stop() 1318 1319 else: 1320 parser.print_help() 1321 sys.exit(1) 1322 1323 1324if __name__ == "__main__": 1325 main()
def
main():
215def main(): 216 __version__ = version("spych") 217 parser = argparse.ArgumentParser( 218 prog="spych", 219 description=f"spych {__version__}: Launch a voice agent from the terminal.", 220 formatter_class=argparse.RawDescriptionHelpFormatter, 221 epilog=__doc__, 222 ) 223 224 parser.add_argument( 225 "-v", 226 "--version", 227 action="version", 228 version=f"spych {__version__}", 229 help="Show the version number and exit", 230 ) 231 232 parser.add_argument( 233 "--theme", 234 default="dark", 235 choices=["dark", "light", "solarized", "mono"], 236 metavar="THEME", 237 help=( 238 "Colour theme for terminal output. " 239 "Choices: dark (default), light, solarized, mono" 240 ), 241 ) 242 243 subparsers = parser.add_subparsers(dest="agent", metavar="agent") 244 subparsers.required = True 245 246 # Aliases → canonical name; used to normalise args.agent after parsing. 247 _AGENT_ALIASES: dict[str, str] = { 248 "claude": "claude_code_sdk", 249 "codex": "codex_cli", 250 "gemini": "gemini_cli", 251 "opencode": "opencode_cli", 252 } 253 254 # ------------------------------------------------------------------ # 255 # ollama # 256 # ------------------------------------------------------------------ # 257 p_ollama = subparsers.add_parser( 258 "ollama", help="Talk to a local Ollama model" 259 ) 260 _add_shared_args(p_ollama) 261 p_ollama.add_argument( 262 "--model", 263 default="llama3.2:latest", 264 metavar="MODEL", 265 help="Ollama model name (default: llama3.2:latest)", 266 ) 267 p_ollama.add_argument( 268 "--history-length", 269 type=int, 270 default=10, 271 metavar="N", 272 help="Past interactions to include in context (default: 10)", 273 ) 274 p_ollama.add_argument( 275 "--host", 276 default="http://localhost:11434", 277 metavar="URL", 278 help="Ollama instance URL (default: http://localhost:11434)", 279 ) 280 281 # ------------------------------------------------------------------ # 282 # claude_code_cli # 283 # ------------------------------------------------------------------ # 284 p_claude_cli = subparsers.add_parser( 285 "claude_code_cli", 286 help="Voice-control Claude Code via the CLI", 287 ) 288 _add_shared_args(p_claude_cli) 289 _add_agent_args(p_claude_cli) 290 291 # ------------------------------------------------------------------ # 292 # claude_code_sdk # 293 # ------------------------------------------------------------------ # 294 p_claude_sdk = subparsers.add_parser( 295 "claude_code_sdk", 296 aliases=["claude"], 297 help="Voice-control Claude Code via the Agent SDK", 298 ) 299 _add_shared_args(p_claude_sdk) 300 _add_agent_args(p_claude_sdk) 301 p_claude_sdk.add_argument( 302 "--setting-sources", 303 nargs="+", 304 metavar="SOURCE", 305 default=["user", "project", "local"], 306 help="Claude Code settings sources to load (default: user project local)", 307 ) 308 309 # ------------------------------------------------------------------ # 310 # codex_cli # 311 # ------------------------------------------------------------------ # 312 p_codex = subparsers.add_parser( 313 "codex_cli", 314 aliases=["codex"], 315 help="Voice-control the OpenAI Codex agent", 316 ) 317 _add_shared_args(p_codex) 318 _add_agent_args(p_codex) 319 320 # ------------------------------------------------------------------ # 321 # gemini_cli # 322 # ------------------------------------------------------------------ # 323 p_gemini = subparsers.add_parser( 324 "gemini_cli", 325 aliases=["gemini"], 326 help="Voice-control the Google Gemini agent", 327 ) 328 _add_shared_args(p_gemini) 329 _add_agent_args(p_gemini) 330 331 # ------------------------------------------------------------------ # 332 # opencode_cli # 333 # ------------------------------------------------------------------ # 334 p_opencode = subparsers.add_parser( 335 "opencode_cli", 336 aliases=["opencode"], 337 help="Voice-control the OpenCode agent", 338 ) 339 _add_shared_args(p_opencode) 340 _add_agent_args(p_opencode) 341 p_opencode.add_argument( 342 "--model", 343 default=None, 344 metavar="MODEL", 345 help="Model in provider/model format, e.g. anthropic/claude-sonnet-4-5", 346 ) 347 348 # ------------------------------------------------------------------ # 349 # live — continuous transcription to file # 350 # ------------------------------------------------------------------ # 351 p_live = subparsers.add_parser( 352 "live", 353 help="Continuously transcribe speech to .txt and/or .srt files", 354 formatter_class=argparse.RawDescriptionHelpFormatter, 355 description=( 356 "Start a live transcription session. Records continuously using VAD\n" 357 "and writes output to disk in real time.\n\n" 358 "Stop by pressing the stop key (default: q + Enter), saying a\n" 359 "terminate word, or pressing Ctrl+C." 360 ), 361 ) 362 p_live.add_argument( 363 "--output-path", 364 default="transcript", 365 metavar="PATH", 366 help="Base output file path without extension (default: transcript)", 367 ) 368 p_live.add_argument( 369 "--output-format", 370 default="srt", 371 choices=["txt", "srt", "both"], 372 metavar="FORMAT", 373 help="Output format: txt, srt, or both (default: both)", 374 ) 375 p_live.add_argument( 376 "--no-timestamps", 377 action="store_true", 378 help="Omit timestamps from terminal and .txt output", 379 ) 380 p_live.add_argument( 381 "--stop-key", 382 default="q", 383 metavar="KEY", 384 help="Key to type (then Enter) to stop the session (default: q)", 385 ) 386 p_live.add_argument( 387 "--terminate-words", 388 nargs="+", 389 metavar="WORD", 390 help="Spoken words that stop the session (e.g. 'stop recording')", 391 ) 392 p_live.add_argument( 393 "--device-index", 394 type=int, 395 default=-1, 396 metavar="N", 397 help="Microphone device index; -1 uses system default (default: -1)", 398 ) 399 p_live.add_argument( 400 "--whisper-model", 401 default="base.en", 402 metavar="MODEL", 403 help="faster-whisper model name (default: base.en)", 404 ) 405 p_live.add_argument( 406 "--whisper-device", 407 default="auto", 408 choices=["auto", "cpu", "cuda"], 409 metavar="DEVICE", 410 help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.", 411 ) 412 p_live.add_argument( 413 "--whisper-compute-type", 414 default="int8", 415 choices=["int8", "float16", "float32"], 416 metavar="TYPE", 417 help="Compute type for whisper: int8, float16, float32 (default: int8)", 418 ) 419 p_live.add_argument( 420 "--no-speech-threshold", 421 type=float, 422 default=0.3, 423 metavar="FLOAT", 424 help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)", 425 ) 426 p_live.add_argument( 427 "--speech-threshold", 428 type=float, 429 default=0.5, 430 metavar="FLOAT", 431 help="VAD speech onset probability (default: 0.5)", 432 ) 433 p_live.add_argument( 434 "--silence-threshold", 435 type=float, 436 default=0.35, 437 metavar="FLOAT", 438 help="VAD silence probability during speech (default: 0.35)", 439 ) 440 p_live.add_argument( 441 "--silence-frames", 442 type=int, 443 default=20, 444 metavar="N", 445 help="Consecutive silent frames required to end a segment (~32ms each, default: 20)", 446 ) 447 p_live.add_argument( 448 "--speech-pad-frames", 449 type=int, 450 default=5, 451 metavar="N", 452 help="Pre-roll frames and onset confirmation count (default: 5)", 453 ) 454 p_live.add_argument( 455 "--max-speech-duration", 456 type=float, 457 default=30.0, 458 metavar="SECONDS", 459 help="Hard cap on a single segment in seconds (default: 30.0)", 460 ) 461 p_live.add_argument( 462 "--context-words", 463 type=int, 464 default=32, 465 metavar="N", 466 help="Trailing words passed as whisper initial_prompt for context (default: 32)", 467 ) 468 469 # ------------------------------------------------------------------ # 470 p_live_translation = subparsers.add_parser( 471 "live-translation", 472 help="Bidirectional live translation between two languages", 473 formatter_class=argparse.RawDescriptionHelpFormatter, 474 description=( 475 "Start a bidirectional live translation session. Either participant\n" 476 "can speak in either language; Whisper transcribes and Ollama detects\n" 477 "which language was spoken then translates to the other.\n\n" 478 "Each utterance is shown as two lines prefixed with [HH:MM:SS](lang):\n" 479 " [00:00:05](en) Hello, how are you?\n" 480 " [00:00:05](es) Hola, ¿cómo estás?\n\n" 481 "Stop by pressing the stop key (default: q + Enter), saying a\n" 482 "terminate word, or pressing Ctrl+C." 483 ), 484 ) 485 p_live_translation.add_argument( 486 "--languages", 487 required=True, 488 nargs=2, 489 metavar="LANG", 490 help="Two BCP-47 language codes for the conversation pair (e.g. en es)", 491 ) 492 p_live_translation.add_argument( 493 "--ollama-host", 494 default="http://localhost:11434", 495 metavar="URL", 496 help="Ollama HTTP base URL for translation (default: http://localhost:11434)", 497 ) 498 p_live_translation.add_argument( 499 "--ollama-translation-model", 500 default="llama3.2", 501 metavar="MODEL", 502 help="Ollama model name used for translation (default: llama3.2)", 503 ) 504 p_live_translation.add_argument( 505 "--no-speaker", 506 action="store_true", 507 help="Disable TTS — do not speak translated segments aloud (speaker is on by default)", 508 ) 509 p_live_translation.add_argument( 510 "--speaker-voice", 511 default="", 512 metavar="VOICE", 513 help="Wave voice name for zero-shot cloning; omit to use the model's built-in default voice", 514 ) 515 p_live_translation.add_argument( 516 "--output-path", 517 default="transcript", 518 metavar="PATH", 519 help="Base output file path without extension (default: transcript)", 520 ) 521 p_live_translation.add_argument( 522 "--output-format", 523 default=None, 524 choices=["txt", "srt", "both"], 525 metavar="FORMAT", 526 help="Save transcript to file: txt, srt, or both (default: no file output)", 527 ) 528 p_live_translation.add_argument( 529 "--no-timestamps", 530 action="store_true", 531 help="Omit timestamps from terminal and .txt output", 532 ) 533 p_live_translation.add_argument( 534 "--stop-key", 535 default="q", 536 metavar="KEY", 537 help="Key to type (then Enter) to stop the session (default: q)", 538 ) 539 p_live_translation.add_argument( 540 "--terminate-words", 541 nargs="+", 542 metavar="WORD", 543 help="Spoken words that stop the session (e.g. 'stop recording')", 544 ) 545 p_live_translation.add_argument( 546 "--device-index", 547 type=int, 548 default=-1, 549 metavar="N", 550 help="Microphone device index; -1 uses system default (default: -1)", 551 ) 552 p_live_translation.add_argument( 553 "--whisper-model", 554 default="small", 555 metavar="MODEL", 556 help="faster-whisper model name; .en suffix stripped automatically (default: base)", 557 ) 558 p_live_translation.add_argument( 559 "--whisper-device", 560 default="auto", 561 choices=["auto", "cpu", "cuda"], 562 metavar="DEVICE", 563 help="Device for whisper inference: auto, cpu, or cuda (default: auto). auto uses cuda when Python <=3.13 and CUDA is available, otherwise cpu. cuda requires nvidia-cublas-cu12 + nvidia-cudnn-cu12.", 564 ) 565 p_live_translation.add_argument( 566 "--whisper-compute-type", 567 default="int8", 568 choices=["int8", "float16", "float32"], 569 metavar="TYPE", 570 help="Compute type for whisper: int8, float16, float32 (default: int8)", 571 ) 572 p_live_translation.add_argument( 573 "--no-speech-threshold", 574 type=float, 575 default=0.3, 576 metavar="FLOAT", 577 help="Whisper no_speech_prob cutoff — segments above this are dropped (default: 0.3)", 578 ) 579 p_live_translation.add_argument( 580 "--speech-threshold", 581 type=float, 582 default=0.5, 583 metavar="FLOAT", 584 help="VAD speech onset probability (default: 0.5)", 585 ) 586 p_live_translation.add_argument( 587 "--silence-threshold", 588 type=float, 589 default=0.35, 590 metavar="FLOAT", 591 help="VAD silence probability during speech (default: 0.35)", 592 ) 593 p_live_translation.add_argument( 594 "--silence-frames", 595 type=int, 596 default=20, 597 metavar="N", 598 help="Consecutive silent frames required to end a segment (~32ms each, default: 20)", 599 ) 600 p_live_translation.add_argument( 601 "--speech-pad-frames", 602 type=int, 603 default=5, 604 metavar="N", 605 help="Pre-roll frames and onset confirmation count (default: 5)", 606 ) 607 p_live_translation.add_argument( 608 "--max-speech-duration", 609 type=float, 610 default=30.0, 611 metavar="SECONDS", 612 help="Hard cap on a single segment in seconds (default: 30.0)", 613 ) 614 # ------------------------------------------------------------------ # 615 p_multi = subparsers.add_parser( 616 "multi", 617 help="Run multiple agents simultaneously under different wake words", 618 formatter_class=argparse.RawDescriptionHelpFormatter, 619 description=( 620 "Run several agents at once. Each agent uses its own default wake " 621 "words unless overridden.\n\n" 622 "Example:\n" 623 " spych multi --agents claude_code_cli gemini_cli\n" 624 " spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n" 625 " spych multi --agents claude_code_sdk codex_cli --listen-duration 8" 626 ), 627 ) 628 p_multi.add_argument( 629 "--agents", 630 nargs="+", 631 required=True, 632 metavar="AGENT", 633 choices=[ 634 "claude_code_cli", 635 "claude", 636 "claude_code_sdk", 637 "claude_sdk", 638 "codex_cli", 639 "codex", 640 "gemini_cli", 641 "gemini", 642 "opencode_cli", 643 "opencode", 644 "ollama", 645 ], 646 help=( 647 "Agents to run. Choices: claude (claude_code_cli), " 648 "claude_sdk (claude_code_sdk), codex (codex_cli), " 649 "gemini (gemini_cli), opencode (opencode_cli), ollama" 650 ), 651 ) 652 p_multi.add_argument( 653 "--terminate-words", 654 nargs="+", 655 metavar="WORD", 656 default=["terminate"], 657 help="Words that stop all agents (default: terminate)", 658 ) 659 p_multi.add_argument( 660 "--listen-duration", 661 type=float, 662 default=5, 663 metavar="SECONDS", 664 help="Seconds to listen after a wake word (default: 5)", 665 ) 666 p_multi.add_argument( 667 "--follow-up-listen-duration", 668 type=float, 669 default=0, 670 metavar="SECONDS", 671 help="Seconds to listen for follow-up answers (default: 0)", 672 ) 673 p_multi.add_argument( 674 "--inactivity-timeout", 675 type=float, 676 default=4.0, 677 metavar="SECONDS", 678 help="Seconds of inactivity before pivoting back to wake word (default: 4.0)", 679 ) 680 p_multi.add_argument( 681 "--continue-conversation", 682 type=_parse_bool, 683 default=True, 684 metavar="BOOL", 685 help="Resume most recent session for each coding agent (default: true)", 686 ) 687 p_multi.add_argument( 688 "--show-tool-events", 689 type=_parse_bool, 690 default=True, 691 metavar="BOOL", 692 help="Print live tool start/end events (default: true)", 693 ) 694 p_multi.add_argument( 695 "--speaker-backend", 696 default="", 697 choices=["chatterbox", "kokoro"], 698 metavar="BACKEND", 699 help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)", 700 ) 701 p_multi.add_argument( 702 "--use-speaker", 703 type=_parse_bool, 704 default=True, 705 metavar="BOOL", 706 help="Speak responses aloud via TTS (default: true)", 707 ) 708 # ollama-specific flags (only used when 'ollama' is in --agents) 709 p_multi.add_argument( 710 "--ollama-model", 711 default="llama3.2:latest", 712 metavar="MODEL", 713 help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.", 714 ) 715 p_multi.add_argument( 716 "--ollama-host", 717 default="http://localhost:11434", 718 metavar="URL", 719 help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.", 720 ) 721 p_multi.add_argument( 722 "--ollama-history-length", 723 type=int, 724 default=10, 725 metavar="N", 726 help="Ollama context history length (default: 10). Only used when ollama is in --agents.", 727 ) 728 # opencode-specific flag 729 p_multi.add_argument( 730 "--opencode-model", 731 default=None, 732 metavar="MODEL", 733 help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.", 734 ) 735 # claude_code_sdk-specific flag 736 p_multi.add_argument( 737 "--setting-sources", 738 nargs="+", 739 metavar="SOURCE", 740 default=["user", "project", "local"], 741 help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.", 742 ) 743 744 # ------------------------------------------------------------------ # 745 # profile_my_voice — Record a custom voice profile # 746 # ------------------------------------------------------------------ # 747 p_profile = subparsers.add_parser( 748 "profile_my_voice", 749 help="Record a 10-second voice sample to create a custom profile", 750 ) 751 p_profile.add_argument( 752 "--name", 753 required=True, 754 metavar="NAME", 755 help="The name to save this voice profile as (e.g. 'my_voice')", 756 ) 757 p_profile.add_argument( 758 "--device-index", 759 type=int, 760 default=-1, 761 metavar="N", 762 help="Microphone device index; -1 uses system default (default: -1)", 763 ) 764 p_profile.add_argument( 765 "--alternate-output-file", 766 default=None, 767 metavar="PATH", 768 help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')", 769 ) 770 771 # ------------------------------------------------------------------ # 772 # users — manage user profiles # 773 # ------------------------------------------------------------------ # 774 p_users = subparsers.add_parser( 775 "users", 776 help="Manage user profiles and global settings", 777 description=( 778 "Launch an interactive menu to manage user profiles and global " 779 "preferences. Profiles store personal info (name, age, extra context) " 780 "used to tailor agent responses. You can also set the default user " 781 "and terminal theme here." 782 ), 783 ) 784 785 # ------------------------------------------------------------------ # 786 # Dispatch # 787 # ------------------------------------------------------------------ # 788 args = parser.parse_args() 789 790 # Normalise any alias back to the canonical agent name so the dispatch 791 # block below only needs to handle one name per agent. 792 args.agent = _AGENT_ALIASES.get(args.agent, args.agent) 793 794 # Apply color theme as early as possible so all subsequent output uses it. 795 if args.theme != "dark": 796 from spych.cli_tools import set_theme 797 798 set_theme(args.theme) 799 800 # ------------------------------------------------------------------ # 801 # Single-agent dispatch # 802 # ------------------------------------------------------------------ # 803 804 # Default wake words per agent — mirrors the factory function defaults. 805 _DEFAULT_WAKE_WORDS: dict[str, list[str]] = { 806 "ollama": ["llama", "ollama", "lama"], 807 "claude_code_cli": ["claude", "clod", "cloud", "clawed"], 808 "claude_code_sdk": ["claude", "clod", "cloud", "clawed"], 809 "codex_cli": ["codex"], 810 "gemini_cli": ["gemini"], 811 "opencode_cli": ["opencode", "open code"], 812 } 813 814 _AGENT_RESPONDERS: dict[str, str] = { 815 "ollama": "Ollama", 816 "claude_code_cli": "Claude Code CLI", 817 "claude_code_sdk": "Claude Code SDK", 818 "codex_cli": "Codex CLI", 819 "gemini_cli": "Gemini CLI", 820 "opencode_cli": "OpenCode CLI", 821 } 822 823 def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict): 824 """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass.""" 825 from spych.dashboard import AgentDashboard 826 from spych.utils import get_user, get_default_user 827 828 user_name = kwargs.get("user") or get_default_user() 829 profile_name = "User" 830 if user_name and user_name.lower() != "none": 831 profile = get_user(user_name) 832 if profile: 833 profile_name = profile.get("name", "User") or "User" 834 835 wake_words = kwargs.get( 836 "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, []) 837 ) 838 839 display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name) 840 kwargs["display_name"] = display_responder 841 842 dashboard = AgentDashboard( 843 agent_name=kwargs.get("name", agent_name), 844 wake_words=wake_words, 845 responder_name=display_responder, 846 response_style=kwargs.get("response_style", ""), 847 use_speaker=kwargs.get("use_speaker", True), 848 speaker_voice=kwargs.get("speaker_voice", "af_heart"), 849 user_name=profile_name, 850 ) 851 print(" ◌ Running healthchecks...") 852 kwargs["dashboard"] = dashboard 853 return dashboard 854 855 if args.agent == "ollama": 856 from spych.agents import ollama 857 858 kwargs = _build_shared_kwargs(args) 859 kwargs["model"] = args.model 860 kwargs["history_length"] = args.history_length 861 kwargs["host"] = args.host 862 dashboard = ( 863 _start_dashboard("Ollama", "OllamaResponder", kwargs) 864 if not args.verbose 865 else None 866 ) 867 try: 868 ollama(**kwargs) 869 finally: 870 if dashboard is not None: 871 dashboard.stop() 872 873 elif args.agent == "claude_code_cli": 874 from spych.agents import claude_code_cli 875 876 kwargs = _build_agent_kwargs(args) 877 dashboard = ( 878 _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs) 879 if not args.verbose 880 else None 881 ) 882 try: 883 claude_code_cli(**kwargs) 884 finally: 885 if dashboard is not None: 886 dashboard.stop() 887 888 elif args.agent == "claude_code_sdk": 889 from spych.agents import claude_code_sdk 890 891 kwargs = _build_agent_kwargs(args) 892 kwargs["setting_sources"] = args.setting_sources 893 dashboard = ( 894 _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs) 895 if not args.verbose 896 else None 897 ) 898 try: 899 claude_code_sdk(**kwargs) 900 finally: 901 if dashboard is not None: 902 dashboard.stop() 903 904 elif args.agent == "codex_cli": 905 from spych.agents import codex_cli 906 907 kwargs = _build_agent_kwargs(args) 908 dashboard = ( 909 _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs) 910 if not args.verbose 911 else None 912 ) 913 try: 914 codex_cli(**kwargs) 915 finally: 916 if dashboard is not None: 917 dashboard.stop() 918 919 elif args.agent == "gemini_cli": 920 from spych.agents import gemini_cli 921 922 kwargs = _build_agent_kwargs(args) 923 dashboard = ( 924 _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs) 925 if not args.verbose 926 else None 927 ) 928 try: 929 gemini_cli(**kwargs) 930 finally: 931 if dashboard is not None: 932 dashboard.stop() 933 934 elif args.agent == "opencode_cli": 935 from spych.agents import opencode_cli 936 937 kwargs = _build_agent_kwargs(args) 938 if args.model is not None: 939 kwargs["model"] = args.model 940 dashboard = ( 941 _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs) 942 if not args.verbose 943 else None 944 ) 945 try: 946 opencode_cli(**kwargs) 947 finally: 948 if dashboard is not None: 949 dashboard.stop() 950 951 elif args.agent == "live": 952 from spych.live import SpychLive 953 954 SpychLive( 955 output_format=args.output_format, 956 output_path=args.output_path, 957 show_timestamps=not args.no_timestamps, 958 stop_key=args.stop_key, 959 terminate_words=args.terminate_words, 960 device_index=args.device_index, 961 whisper_model=args.whisper_model, 962 whisper_device=args.whisper_device, 963 whisper_compute_type=args.whisper_compute_type, 964 no_speech_threshold=args.no_speech_threshold, 965 speech_threshold=args.speech_threshold, 966 silence_threshold=args.silence_threshold, 967 silence_frames_threshold=args.silence_frames, 968 speech_pad_frames=args.speech_pad_frames, 969 max_speech_duration_s=args.max_speech_duration, 970 context_words=args.context_words, 971 ).start() 972 973 elif args.agent == "live-translation": 974 from spych.live_translation import SpychLiveTranslation 975 976 SpychLiveTranslation( 977 lang_a=args.languages[0], 978 lang_b=args.languages[1], 979 output_format=args.output_format or "", 980 output_path=args.output_path, 981 show_timestamps=not args.no_timestamps, 982 stop_key=args.stop_key, 983 terminate_words=args.terminate_words, 984 device_index=args.device_index, 985 whisper_model=args.whisper_model, 986 whisper_device=args.whisper_device, 987 whisper_compute_type=args.whisper_compute_type, 988 no_speech_threshold=args.no_speech_threshold, 989 speech_threshold=args.speech_threshold, 990 silence_threshold=args.silence_threshold, 991 silence_frames_threshold=args.silence_frames, 992 speech_pad_frames=args.speech_pad_frames, 993 max_speech_duration_s=args.max_speech_duration, 994 ollama_host=args.ollama_host, 995 ollama_translation_model=args.ollama_translation_model, 996 use_speaker=not args.no_speaker, 997 speaker_voice=args.speaker_voice, 998 ).start() 999 1000 elif args.agent == "profile_my_voice": 1001 from spych.voice_manager import profile_my_voice 1002 1003 profile_my_voice( 1004 name=args.name, 1005 device_index=args.device_index, 1006 alternate_output_file=args.alternate_output_file, 1007 ) 1008 1009 elif args.agent == "users": 1010 from spych.utils import ( 1011 get_all_users, 1012 get_user, 1013 set_user, 1014 set_default_user, 1015 get_default_user, 1016 set_setting, 1017 get_setting, 1018 ) 1019 from spych.cli_tools import set_theme 1020 1021 def users_menu(): 1022 while True: 1023 print("\n " + "=" * 20) 1024 print(" SPYCH USER MANAGEMENT") 1025 print(" " + "=" * 20) 1026 1027 users = get_all_users() 1028 default_user = get_default_user() 1029 current_theme = get_setting("theme", "dark") 1030 1031 print(f"\n Default User: {default_user or 'None'}") 1032 print(f" Current Theme: {current_theme}") 1033 print("\n Users:") 1034 if not users: 1035 print(" (No users found)") 1036 for u in users: 1037 print( 1038 f" - {u}{' (default)' if u == default_user else ''}" 1039 ) 1040 1041 print("\n Options:") 1042 print(" 1. Create new user") 1043 print(" 2. Edit user") 1044 print(" 3. Delete user") 1045 print(" 4. Set default user") 1046 print(" 5. Set theme") 1047 print(" 6. Exit") 1048 1049 choice = input("\n Choice: ").strip() 1050 1051 if choice == "1": 1052 name = input(" User name: ").strip() 1053 if name: 1054 data = { 1055 "name": input(" Full name: ").strip(), 1056 "age": input(" Age: ").strip(), 1057 "gender": input(" Gender: ").strip(), 1058 "extra": input(" Extra info: ").strip(), 1059 } 1060 set_user(name, data) 1061 print(f" User '{name}' created.") 1062 1063 elif choice == "2": 1064 name = input(" User name to edit: ").strip() 1065 user = get_user(name) 1066 if user: 1067 print(f" Editing {name} (leave blank to keep current)") 1068 user["name"] = input( 1069 f" Full name [{user.get('name', '')}]: " 1070 ).strip() or user.get("name", "") 1071 user["age"] = input( 1072 f" Age [{user.get('age', '')}]: " 1073 ).strip() or user.get("age", "") 1074 user["gender"] = input( 1075 f" Gender [{user.get('gender', '')}]: " 1076 ).strip() or user.get("gender", "") 1077 user["extra"] = input( 1078 f" Extra info [{user.get('extra', '')}]: " 1079 ).strip() or user.get("extra", "") 1080 set_user(name, user) 1081 print(f" User '{name}' updated.") 1082 else: 1083 print(" User not found.") 1084 1085 elif choice == "3": 1086 name = input(" User name to delete: ").strip() 1087 path = os.path.join(get_cache_dir("users"), f"{name}.json") 1088 if os.path.exists(path): 1089 os.remove(path) 1090 if get_default_user() == name: 1091 set_default_user(None) 1092 print(f" User '{name}' deleted.") 1093 else: 1094 print(" User not found.") 1095 1096 elif choice == "4": 1097 name = input(" Default user name (or 'none'): ").strip() 1098 if name.lower() == "none": 1099 set_default_user(None) 1100 print(" Default user cleared.") 1101 elif name in get_all_users(): 1102 set_default_user(name) 1103 print(f" Default user set to '{name}'.") 1104 else: 1105 print(" User not found.") 1106 1107 elif choice == "5": 1108 theme = ( 1109 input(" Theme (dark, light, solarized, mono): ") 1110 .strip() 1111 .lower() 1112 ) 1113 if theme in ["dark", "light", "solarized", "mono"]: 1114 set_setting("theme", theme) 1115 set_theme(theme) 1116 print(f" Theme set to '{theme}'.") 1117 else: 1118 print(" Invalid theme.") 1119 1120 elif choice == "6": 1121 break 1122 1123 users_menu() 1124 1125 # ------------------------------------------------------------------ # 1126 # Multi-agent dispatch # 1127 # ------------------------------------------------------------------ # 1128 elif args.agent == "multi": 1129 from spych.core import Spych 1130 from spych.orchestrator import SpychOrchestrator 1131 1132 # A single Spych transcription object shared by all responders. 1133 spych_object = Spych(whisper_model="base.en") 1134 1135 # Build dashboard before responders so it can be injected. 1136 multi_dashboard = None 1137 if not args.verbose: 1138 from spych.dashboard import AgentDashboard 1139 from spych.utils import get_user, get_default_user 1140 1141 user_name = args.user or get_default_user() 1142 profile_name = "User" 1143 if user_name and user_name.lower() != "none": 1144 profile = get_user(user_name) 1145 if profile: 1146 profile_name = profile.get("name", "User") or "User" 1147 1148 first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0]) 1149 _multi_name_map = { 1150 "claude_code_cli": "Claude", 1151 "claude_code_sdk": "Claude", 1152 "codex_cli": "Codex", 1153 "gemini_cli": "Gemini", 1154 "opencode_cli": "OpenCode", 1155 "ollama": "Ollama", 1156 } 1157 multi_dashboard = AgentDashboard( 1158 agent_name=_multi_name_map.get(first_agent, first_agent), 1159 wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []), 1160 responder_name=_AGENT_RESPONDERS.get(first_agent, ""), 1161 use_speaker=args.use_speaker, 1162 user_name=profile_name, 1163 ) 1164 print(" ◌ Running healthchecks...") 1165 1166 entries = [] 1167 1168 for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]: 1169 if agent_name == "claude_code_cli": 1170 from spych.agents.claude import LocalClaudeCodeCLIResponder 1171 1172 entries.append( 1173 { 1174 "responder": LocalClaudeCodeCLIResponder( 1175 spych_object=spych_object, 1176 continue_conversation=args.continue_conversation, 1177 listen_duration=args.listen_duration, 1178 follow_up_listen_duration=args.follow_up_listen_duration, 1179 inactivity_timeout=args.inactivity_timeout, 1180 speaker_backend=args.speaker_backend, 1181 use_speaker=args.use_speaker, 1182 show_tool_events=args.show_tool_events, 1183 dashboard=multi_dashboard, 1184 user=args.user, 1185 display_name=_AGENT_RESPONDERS.get( 1186 "claude_code_cli" 1187 ), 1188 ), 1189 "wake_words": ["claude", "clod", "cloud", "clawed"], 1190 "terminate_words": args.terminate_words, 1191 } 1192 ) 1193 1194 elif agent_name == "claude_code_sdk": 1195 from spych.agents.claude import LocalClaudeCodeSDKResponder 1196 1197 entries.append( 1198 { 1199 "responder": LocalClaudeCodeSDKResponder( 1200 spych_object=spych_object, 1201 continue_conversation=args.continue_conversation, 1202 listen_duration=args.listen_duration, 1203 follow_up_listen_duration=args.follow_up_listen_duration, 1204 inactivity_timeout=args.inactivity_timeout, 1205 speaker_backend=args.speaker_backend, 1206 use_speaker=args.use_speaker, 1207 setting_sources=args.setting_sources, 1208 show_tool_events=args.show_tool_events, 1209 dashboard=multi_dashboard, 1210 user=args.user, 1211 display_name=_AGENT_RESPONDERS.get( 1212 "claude_code_sdk" 1213 ), 1214 ), 1215 "wake_words": ["claude", "clod", "cloud", "clawed"], 1216 "terminate_words": args.terminate_words, 1217 } 1218 ) 1219 1220 elif agent_name == "codex_cli": 1221 from spych.agents.codex import LocalCodexCLIResponder 1222 1223 entries.append( 1224 { 1225 "responder": LocalCodexCLIResponder( 1226 spych_object=spych_object, 1227 continue_conversation=args.continue_conversation, 1228 listen_duration=args.listen_duration, 1229 follow_up_listen_duration=args.follow_up_listen_duration, 1230 inactivity_timeout=args.inactivity_timeout, 1231 speaker_backend=args.speaker_backend, 1232 use_speaker=args.use_speaker, 1233 show_tool_events=args.show_tool_events, 1234 dashboard=multi_dashboard, 1235 user=args.user, 1236 display_name=_AGENT_RESPONDERS.get("codex_cli"), 1237 ), 1238 "wake_words": ["codex"], 1239 "terminate_words": args.terminate_words, 1240 } 1241 ) 1242 1243 elif agent_name == "gemini_cli": 1244 from spych.agents.gemini import LocalGeminiCLIResponder 1245 1246 entries.append( 1247 { 1248 "responder": LocalGeminiCLIResponder( 1249 spych_object=spych_object, 1250 continue_conversation=args.continue_conversation, 1251 listen_duration=args.listen_duration, 1252 follow_up_listen_duration=args.follow_up_listen_duration, 1253 inactivity_timeout=args.inactivity_timeout, 1254 speaker_backend=args.speaker_backend, 1255 use_speaker=args.use_speaker, 1256 show_tool_events=args.show_tool_events, 1257 dashboard=multi_dashboard, 1258 user=args.user, 1259 display_name=_AGENT_RESPONDERS.get("gemini_cli"), 1260 ), 1261 "wake_words": ["gemini"], 1262 "terminate_words": args.terminate_words, 1263 } 1264 ) 1265 1266 elif agent_name == "opencode_cli": 1267 from spych.agents.opencode import LocalOpenCodeCLIResponder 1268 1269 entries.append( 1270 { 1271 "responder": LocalOpenCodeCLIResponder( 1272 spych_object=spych_object, 1273 continue_conversation=args.continue_conversation, 1274 listen_duration=args.listen_duration, 1275 follow_up_listen_duration=args.follow_up_listen_duration, 1276 inactivity_timeout=args.inactivity_timeout, 1277 speaker_backend=args.speaker_backend, 1278 use_speaker=args.use_speaker, 1279 show_tool_events=args.show_tool_events, 1280 model=args.opencode_model, 1281 dashboard=multi_dashboard, 1282 user=args.user, 1283 display_name=_AGENT_RESPONDERS.get("opencode_cli"), 1284 ), 1285 "wake_words": ["opencode", "open code"], 1286 "terminate_words": args.terminate_words, 1287 } 1288 ) 1289 1290 elif agent_name == "ollama": 1291 from spych.agents.ollama import OllamaResponder 1292 1293 entries.append( 1294 { 1295 "responder": OllamaResponder( 1296 spych_object=spych_object, 1297 model=args.ollama_model, 1298 history_length=args.ollama_history_length, 1299 host=args.ollama_host, 1300 listen_duration=args.listen_duration, 1301 follow_up_listen_duration=args.follow_up_listen_duration, 1302 inactivity_timeout=args.inactivity_timeout, 1303 speaker_backend=args.speaker_backend, 1304 use_speaker=args.use_speaker, 1305 dashboard=multi_dashboard, 1306 user=args.user, 1307 display_name=_AGENT_RESPONDERS.get("ollama"), 1308 ), 1309 "wake_words": ["llama", "ollama", "lama"], 1310 "terminate_words": args.terminate_words, 1311 } 1312 ) 1313 1314 try: 1315 SpychOrchestrator(entries=entries).start() 1316 finally: 1317 if multi_dashboard is not None: 1318 multi_dashboard.stop() 1319 1320 else: 1321 parser.print_help() 1322 sys.exit(1)