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="cpu", 407 choices=["cpu", "cuda"], 408 metavar="DEVICE", 409 help="Device for whisper inference: cpu or cuda (default: cpu)", 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_multi = subparsers.add_parser( 470 "multi", 471 help="Run multiple agents simultaneously under different wake words", 472 formatter_class=argparse.RawDescriptionHelpFormatter, 473 description=( 474 "Run several agents at once. Each agent uses its own default wake " 475 "words unless overridden.\n\n" 476 "Example:\n" 477 " spych multi --agents claude_code_cli gemini_cli\n" 478 " spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n" 479 " spych multi --agents claude_code_sdk codex_cli --listen-duration 8" 480 ), 481 ) 482 p_multi.add_argument( 483 "--agents", 484 nargs="+", 485 required=True, 486 metavar="AGENT", 487 choices=[ 488 "claude_code_cli", 489 "claude", 490 "claude_code_sdk", 491 "claude_sdk", 492 "codex_cli", 493 "codex", 494 "gemini_cli", 495 "gemini", 496 "opencode_cli", 497 "opencode", 498 "ollama", 499 ], 500 help=( 501 "Agents to run. Choices: claude (claude_code_cli), " 502 "claude_sdk (claude_code_sdk), codex (codex_cli), " 503 "gemini (gemini_cli), opencode (opencode_cli), ollama" 504 ), 505 ) 506 p_multi.add_argument( 507 "--terminate-words", 508 nargs="+", 509 metavar="WORD", 510 default=["terminate"], 511 help="Words that stop all agents (default: terminate)", 512 ) 513 p_multi.add_argument( 514 "--listen-duration", 515 type=float, 516 default=5, 517 metavar="SECONDS", 518 help="Seconds to listen after a wake word (default: 5)", 519 ) 520 p_multi.add_argument( 521 "--follow-up-listen-duration", 522 type=float, 523 default=0, 524 metavar="SECONDS", 525 help="Seconds to listen for follow-up answers (default: 0)", 526 ) 527 p_multi.add_argument( 528 "--inactivity-timeout", 529 type=float, 530 default=4.0, 531 metavar="SECONDS", 532 help="Seconds of inactivity before pivoting back to wake word (default: 4.0)", 533 ) 534 p_multi.add_argument( 535 "--continue-conversation", 536 type=_parse_bool, 537 default=True, 538 metavar="BOOL", 539 help="Resume most recent session for each coding agent (default: true)", 540 ) 541 p_multi.add_argument( 542 "--show-tool-events", 543 type=_parse_bool, 544 default=True, 545 metavar="BOOL", 546 help="Print live tool start/end events (default: true)", 547 ) 548 p_multi.add_argument( 549 "--speaker-backend", 550 default="", 551 choices=["chatterbox", "kokoro"], 552 metavar="BACKEND", 553 help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)", 554 ) 555 p_multi.add_argument( 556 "--use-speaker", 557 type=_parse_bool, 558 default=True, 559 metavar="BOOL", 560 help="Speak responses aloud via TTS (default: true)", 561 ) 562 # ollama-specific flags (only used when 'ollama' is in --agents) 563 p_multi.add_argument( 564 "--ollama-model", 565 default="llama3.2:latest", 566 metavar="MODEL", 567 help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.", 568 ) 569 p_multi.add_argument( 570 "--ollama-host", 571 default="http://localhost:11434", 572 metavar="URL", 573 help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.", 574 ) 575 p_multi.add_argument( 576 "--ollama-history-length", 577 type=int, 578 default=10, 579 metavar="N", 580 help="Ollama context history length (default: 10). Only used when ollama is in --agents.", 581 ) 582 # opencode-specific flag 583 p_multi.add_argument( 584 "--opencode-model", 585 default=None, 586 metavar="MODEL", 587 help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.", 588 ) 589 # claude_code_sdk-specific flag 590 p_multi.add_argument( 591 "--setting-sources", 592 nargs="+", 593 metavar="SOURCE", 594 default=["user", "project", "local"], 595 help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.", 596 ) 597 598 # ------------------------------------------------------------------ # 599 # profile_my_voice — Record a custom voice profile # 600 # ------------------------------------------------------------------ # 601 p_profile = subparsers.add_parser( 602 "profile_my_voice", 603 help="Record a 10-second voice sample to create a custom profile", 604 ) 605 p_profile.add_argument( 606 "--name", 607 required=True, 608 metavar="NAME", 609 help="The name to save this voice profile as (e.g. 'my_voice')", 610 ) 611 p_profile.add_argument( 612 "--device-index", 613 type=int, 614 default=-1, 615 metavar="N", 616 help="Microphone device index; -1 uses system default (default: -1)", 617 ) 618 p_profile.add_argument( 619 "--alternate-output-file", 620 default=None, 621 metavar="PATH", 622 help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')", 623 ) 624 625 # ------------------------------------------------------------------ # 626 # users — manage user profiles # 627 # ------------------------------------------------------------------ # 628 p_users = subparsers.add_parser( 629 "users", 630 help="Manage user profiles and global settings", 631 description=( 632 "Launch an interactive menu to manage user profiles and global " 633 "preferences. Profiles store personal info (name, age, extra context) " 634 "used to tailor agent responses. You can also set the default user " 635 "and terminal theme here." 636 ), 637 ) 638 639 # ------------------------------------------------------------------ # 640 # Dispatch # 641 # ------------------------------------------------------------------ # 642 args = parser.parse_args() 643 644 # Normalise any alias back to the canonical agent name so the dispatch 645 # block below only needs to handle one name per agent. 646 args.agent = _AGENT_ALIASES.get(args.agent, args.agent) 647 648 # Apply color theme as early as possible so all subsequent output uses it. 649 if args.theme != "dark": 650 from spych.cli_tools import set_theme 651 652 set_theme(args.theme) 653 654 # ------------------------------------------------------------------ # 655 # Single-agent dispatch # 656 # ------------------------------------------------------------------ # 657 658 # Default wake words per agent — mirrors the factory function defaults. 659 _DEFAULT_WAKE_WORDS: dict[str, list[str]] = { 660 "ollama": ["llama", "ollama", "lama"], 661 "claude_code_cli": ["claude", "clod", "cloud", "clawed"], 662 "claude_code_sdk": ["claude", "clod", "cloud", "clawed"], 663 "codex_cli": ["codex"], 664 "gemini_cli": ["gemini"], 665 "opencode_cli": ["opencode", "open code"], 666 } 667 668 _AGENT_RESPONDERS: dict[str, str] = { 669 "ollama": "Ollama", 670 "claude_code_cli": "Claude Code CLI", 671 "claude_code_sdk": "Claude Code SDK", 672 "codex_cli": "Codex CLI", 673 "gemini_cli": "Gemini CLI", 674 "opencode_cli": "OpenCode CLI", 675 } 676 677 def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict): 678 """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass.""" 679 from spych.dashboard import AgentDashboard 680 from spych.utils import get_user, get_default_user 681 682 user_name = kwargs.get("user") or get_default_user() 683 profile_name = "User" 684 if user_name and user_name.lower() != "none": 685 profile = get_user(user_name) 686 if profile: 687 profile_name = profile.get("name", "User") or "User" 688 689 wake_words = kwargs.get( 690 "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, []) 691 ) 692 693 display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name) 694 kwargs["display_name"] = display_responder 695 696 dashboard = AgentDashboard( 697 agent_name=kwargs.get("name", agent_name), 698 wake_words=wake_words, 699 responder_name=display_responder, 700 response_style=kwargs.get("response_style", ""), 701 use_speaker=kwargs.get("use_speaker", True), 702 speaker_voice=kwargs.get("speaker_voice", "af_heart"), 703 user_name=profile_name, 704 ) 705 print(" ◌ Running healthchecks...") 706 kwargs["dashboard"] = dashboard 707 return dashboard 708 709 if args.agent == "ollama": 710 from spych.agents import ollama 711 712 kwargs = _build_shared_kwargs(args) 713 kwargs["model"] = args.model 714 kwargs["history_length"] = args.history_length 715 kwargs["host"] = args.host 716 dashboard = ( 717 _start_dashboard("Ollama", "OllamaResponder", kwargs) 718 if not args.verbose 719 else None 720 ) 721 try: 722 ollama(**kwargs) 723 finally: 724 if dashboard is not None: 725 dashboard.stop() 726 727 elif args.agent == "claude_code_cli": 728 from spych.agents import claude_code_cli 729 730 kwargs = _build_agent_kwargs(args) 731 dashboard = ( 732 _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs) 733 if not args.verbose 734 else None 735 ) 736 try: 737 claude_code_cli(**kwargs) 738 finally: 739 if dashboard is not None: 740 dashboard.stop() 741 742 elif args.agent == "claude_code_sdk": 743 from spych.agents import claude_code_sdk 744 745 kwargs = _build_agent_kwargs(args) 746 kwargs["setting_sources"] = args.setting_sources 747 dashboard = ( 748 _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs) 749 if not args.verbose 750 else None 751 ) 752 try: 753 claude_code_sdk(**kwargs) 754 finally: 755 if dashboard is not None: 756 dashboard.stop() 757 758 elif args.agent == "codex_cli": 759 from spych.agents import codex_cli 760 761 kwargs = _build_agent_kwargs(args) 762 dashboard = ( 763 _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs) 764 if not args.verbose 765 else None 766 ) 767 try: 768 codex_cli(**kwargs) 769 finally: 770 if dashboard is not None: 771 dashboard.stop() 772 773 elif args.agent == "gemini_cli": 774 from spych.agents import gemini_cli 775 776 kwargs = _build_agent_kwargs(args) 777 dashboard = ( 778 _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs) 779 if not args.verbose 780 else None 781 ) 782 try: 783 gemini_cli(**kwargs) 784 finally: 785 if dashboard is not None: 786 dashboard.stop() 787 788 elif args.agent == "opencode_cli": 789 from spych.agents import opencode_cli 790 791 kwargs = _build_agent_kwargs(args) 792 if args.model is not None: 793 kwargs["model"] = args.model 794 dashboard = ( 795 _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs) 796 if not args.verbose 797 else None 798 ) 799 try: 800 opencode_cli(**kwargs) 801 finally: 802 if dashboard is not None: 803 dashboard.stop() 804 805 elif args.agent == "live": 806 from spych.live import SpychLive 807 808 SpychLive( 809 output_format=args.output_format, 810 output_path=args.output_path, 811 show_timestamps=not args.no_timestamps, 812 stop_key=args.stop_key, 813 terminate_words=args.terminate_words, 814 device_index=args.device_index, 815 whisper_model=args.whisper_model, 816 whisper_device=args.whisper_device, 817 whisper_compute_type=args.whisper_compute_type, 818 no_speech_threshold=args.no_speech_threshold, 819 speech_threshold=args.speech_threshold, 820 silence_threshold=args.silence_threshold, 821 silence_frames_threshold=args.silence_frames, 822 speech_pad_frames=args.speech_pad_frames, 823 max_speech_duration_s=args.max_speech_duration, 824 context_words=args.context_words, 825 ).start() 826 827 elif args.agent == "profile_my_voice": 828 from spych.voice_manager import profile_my_voice 829 830 profile_my_voice( 831 name=args.name, 832 device_index=args.device_index, 833 alternate_output_file=args.alternate_output_file, 834 ) 835 836 elif args.agent == "users": 837 from spych.utils import ( 838 get_all_users, 839 get_user, 840 set_user, 841 set_default_user, 842 get_default_user, 843 set_setting, 844 get_setting, 845 ) 846 from spych.cli_tools import set_theme 847 848 def users_menu(): 849 while True: 850 print("\n " + "=" * 20) 851 print(" SPYCH USER MANAGEMENT") 852 print(" " + "=" * 20) 853 854 users = get_all_users() 855 default_user = get_default_user() 856 current_theme = get_setting("theme", "dark") 857 858 print(f"\n Default User: {default_user or 'None'}") 859 print(f" Current Theme: {current_theme}") 860 print("\n Users:") 861 if not users: 862 print(" (No users found)") 863 for u in users: 864 print( 865 f" - {u}{' (default)' if u == default_user else ''}" 866 ) 867 868 print("\n Options:") 869 print(" 1. Create new user") 870 print(" 2. Edit user") 871 print(" 3. Delete user") 872 print(" 4. Set default user") 873 print(" 5. Set theme") 874 print(" 6. Exit") 875 876 choice = input("\n Choice: ").strip() 877 878 if choice == "1": 879 name = input(" User name: ").strip() 880 if name: 881 data = { 882 "name": input(" Full name: ").strip(), 883 "age": input(" Age: ").strip(), 884 "gender": input(" Gender: ").strip(), 885 "extra": input(" Extra info: ").strip(), 886 } 887 set_user(name, data) 888 print(f" User '{name}' created.") 889 890 elif choice == "2": 891 name = input(" User name to edit: ").strip() 892 user = get_user(name) 893 if user: 894 print(f" Editing {name} (leave blank to keep current)") 895 user["name"] = input( 896 f" Full name [{user.get('name', '')}]: " 897 ).strip() or user.get("name", "") 898 user["age"] = input( 899 f" Age [{user.get('age', '')}]: " 900 ).strip() or user.get("age", "") 901 user["gender"] = input( 902 f" Gender [{user.get('gender', '')}]: " 903 ).strip() or user.get("gender", "") 904 user["extra"] = input( 905 f" Extra info [{user.get('extra', '')}]: " 906 ).strip() or user.get("extra", "") 907 set_user(name, user) 908 print(f" User '{name}' updated.") 909 else: 910 print(" User not found.") 911 912 elif choice == "3": 913 name = input(" User name to delete: ").strip() 914 path = os.path.join(get_cache_dir("users"), f"{name}.json") 915 if os.path.exists(path): 916 os.remove(path) 917 if get_default_user() == name: 918 set_default_user(None) 919 print(f" User '{name}' deleted.") 920 else: 921 print(" User not found.") 922 923 elif choice == "4": 924 name = input(" Default user name (or 'none'): ").strip() 925 if name.lower() == "none": 926 set_default_user(None) 927 print(" Default user cleared.") 928 elif name in get_all_users(): 929 set_default_user(name) 930 print(f" Default user set to '{name}'.") 931 else: 932 print(" User not found.") 933 934 elif choice == "5": 935 theme = ( 936 input(" Theme (dark, light, solarized, mono): ") 937 .strip() 938 .lower() 939 ) 940 if theme in ["dark", "light", "solarized", "mono"]: 941 set_setting("theme", theme) 942 set_theme(theme) 943 print(f" Theme set to '{theme}'.") 944 else: 945 print(" Invalid theme.") 946 947 elif choice == "6": 948 break 949 950 users_menu() 951 952 # ------------------------------------------------------------------ # 953 # Multi-agent dispatch # 954 # ------------------------------------------------------------------ # 955 elif args.agent == "multi": 956 from spych.core import Spych 957 from spych.orchestrator import SpychOrchestrator 958 959 # A single Spych transcription object shared by all responders. 960 spych_object = Spych(whisper_model="base.en") 961 962 # Build dashboard before responders so it can be injected. 963 multi_dashboard = None 964 if not args.verbose: 965 from spych.dashboard import AgentDashboard 966 from spych.utils import get_user, get_default_user 967 968 user_name = args.user or get_default_user() 969 profile_name = "User" 970 if user_name and user_name.lower() != "none": 971 profile = get_user(user_name) 972 if profile: 973 profile_name = profile.get("name", "User") or "User" 974 975 first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0]) 976 _multi_name_map = { 977 "claude_code_cli": "Claude", 978 "claude_code_sdk": "Claude", 979 "codex_cli": "Codex", 980 "gemini_cli": "Gemini", 981 "opencode_cli": "OpenCode", 982 "ollama": "Ollama", 983 } 984 multi_dashboard = AgentDashboard( 985 agent_name=_multi_name_map.get(first_agent, first_agent), 986 wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []), 987 responder_name=_AGENT_RESPONDERS.get(first_agent, ""), 988 use_speaker=args.use_speaker, 989 user_name=profile_name, 990 ) 991 print(" ◌ Running healthchecks...") 992 993 entries = [] 994 995 for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]: 996 if agent_name == "claude_code_cli": 997 from spych.agents.claude import LocalClaudeCodeCLIResponder 998 999 entries.append( 1000 { 1001 "responder": LocalClaudeCodeCLIResponder( 1002 spych_object=spych_object, 1003 continue_conversation=args.continue_conversation, 1004 listen_duration=args.listen_duration, 1005 follow_up_listen_duration=args.follow_up_listen_duration, 1006 inactivity_timeout=args.inactivity_timeout, 1007 speaker_backend=args.speaker_backend, 1008 use_speaker=args.use_speaker, 1009 show_tool_events=args.show_tool_events, 1010 dashboard=multi_dashboard, 1011 user=args.user, 1012 display_name=_AGENT_RESPONDERS.get( 1013 "claude_code_cli" 1014 ), 1015 ), 1016 "wake_words": ["claude", "clod", "cloud", "clawed"], 1017 "terminate_words": args.terminate_words, 1018 } 1019 ) 1020 1021 elif agent_name == "claude_code_sdk": 1022 from spych.agents.claude import LocalClaudeCodeSDKResponder 1023 1024 entries.append( 1025 { 1026 "responder": LocalClaudeCodeSDKResponder( 1027 spych_object=spych_object, 1028 continue_conversation=args.continue_conversation, 1029 listen_duration=args.listen_duration, 1030 follow_up_listen_duration=args.follow_up_listen_duration, 1031 inactivity_timeout=args.inactivity_timeout, 1032 speaker_backend=args.speaker_backend, 1033 use_speaker=args.use_speaker, 1034 setting_sources=args.setting_sources, 1035 show_tool_events=args.show_tool_events, 1036 dashboard=multi_dashboard, 1037 user=args.user, 1038 display_name=_AGENT_RESPONDERS.get( 1039 "claude_code_sdk" 1040 ), 1041 ), 1042 "wake_words": ["claude", "clod", "cloud", "clawed"], 1043 "terminate_words": args.terminate_words, 1044 } 1045 ) 1046 1047 elif agent_name == "codex_cli": 1048 from spych.agents.codex import LocalCodexCLIResponder 1049 1050 entries.append( 1051 { 1052 "responder": LocalCodexCLIResponder( 1053 spych_object=spych_object, 1054 continue_conversation=args.continue_conversation, 1055 listen_duration=args.listen_duration, 1056 follow_up_listen_duration=args.follow_up_listen_duration, 1057 inactivity_timeout=args.inactivity_timeout, 1058 speaker_backend=args.speaker_backend, 1059 use_speaker=args.use_speaker, 1060 show_tool_events=args.show_tool_events, 1061 dashboard=multi_dashboard, 1062 user=args.user, 1063 display_name=_AGENT_RESPONDERS.get("codex_cli"), 1064 ), 1065 "wake_words": ["codex"], 1066 "terminate_words": args.terminate_words, 1067 } 1068 ) 1069 1070 elif agent_name == "gemini_cli": 1071 from spych.agents.gemini import LocalGeminiCLIResponder 1072 1073 entries.append( 1074 { 1075 "responder": LocalGeminiCLIResponder( 1076 spych_object=spych_object, 1077 continue_conversation=args.continue_conversation, 1078 listen_duration=args.listen_duration, 1079 follow_up_listen_duration=args.follow_up_listen_duration, 1080 inactivity_timeout=args.inactivity_timeout, 1081 speaker_backend=args.speaker_backend, 1082 use_speaker=args.use_speaker, 1083 show_tool_events=args.show_tool_events, 1084 dashboard=multi_dashboard, 1085 user=args.user, 1086 display_name=_AGENT_RESPONDERS.get("gemini_cli"), 1087 ), 1088 "wake_words": ["gemini"], 1089 "terminate_words": args.terminate_words, 1090 } 1091 ) 1092 1093 elif agent_name == "opencode_cli": 1094 from spych.agents.opencode import LocalOpenCodeCLIResponder 1095 1096 entries.append( 1097 { 1098 "responder": LocalOpenCodeCLIResponder( 1099 spych_object=spych_object, 1100 continue_conversation=args.continue_conversation, 1101 listen_duration=args.listen_duration, 1102 follow_up_listen_duration=args.follow_up_listen_duration, 1103 inactivity_timeout=args.inactivity_timeout, 1104 speaker_backend=args.speaker_backend, 1105 use_speaker=args.use_speaker, 1106 show_tool_events=args.show_tool_events, 1107 model=args.opencode_model, 1108 dashboard=multi_dashboard, 1109 user=args.user, 1110 display_name=_AGENT_RESPONDERS.get("opencode_cli"), 1111 ), 1112 "wake_words": ["opencode", "open code"], 1113 "terminate_words": args.terminate_words, 1114 } 1115 ) 1116 1117 elif agent_name == "ollama": 1118 from spych.agents.ollama import OllamaResponder 1119 1120 entries.append( 1121 { 1122 "responder": OllamaResponder( 1123 spych_object=spych_object, 1124 model=args.ollama_model, 1125 history_length=args.ollama_history_length, 1126 host=args.ollama_host, 1127 listen_duration=args.listen_duration, 1128 follow_up_listen_duration=args.follow_up_listen_duration, 1129 inactivity_timeout=args.inactivity_timeout, 1130 speaker_backend=args.speaker_backend, 1131 use_speaker=args.use_speaker, 1132 dashboard=multi_dashboard, 1133 user=args.user, 1134 display_name=_AGENT_RESPONDERS.get("ollama"), 1135 ), 1136 "wake_words": ["llama", "ollama", "lama"], 1137 "terminate_words": args.terminate_words, 1138 } 1139 ) 1140 1141 try: 1142 SpychOrchestrator(entries=entries).start() 1143 finally: 1144 if multi_dashboard is not None: 1145 multi_dashboard.stop() 1146 1147 else: 1148 parser.print_help() 1149 sys.exit(1) 1150 1151 1152if __name__ == "__main__": 1153 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="cpu", 408 choices=["cpu", "cuda"], 409 metavar="DEVICE", 410 help="Device for whisper inference: cpu or cuda (default: cpu)", 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_multi = subparsers.add_parser( 471 "multi", 472 help="Run multiple agents simultaneously under different wake words", 473 formatter_class=argparse.RawDescriptionHelpFormatter, 474 description=( 475 "Run several agents at once. Each agent uses its own default wake " 476 "words unless overridden.\n\n" 477 "Example:\n" 478 " spych multi --agents claude_code_cli gemini_cli\n" 479 " spych multi --agents claude_code_cli ollama --ollama-model llama3.2:latest\n" 480 " spych multi --agents claude_code_sdk codex_cli --listen-duration 8" 481 ), 482 ) 483 p_multi.add_argument( 484 "--agents", 485 nargs="+", 486 required=True, 487 metavar="AGENT", 488 choices=[ 489 "claude_code_cli", 490 "claude", 491 "claude_code_sdk", 492 "claude_sdk", 493 "codex_cli", 494 "codex", 495 "gemini_cli", 496 "gemini", 497 "opencode_cli", 498 "opencode", 499 "ollama", 500 ], 501 help=( 502 "Agents to run. Choices: claude (claude_code_cli), " 503 "claude_sdk (claude_code_sdk), codex (codex_cli), " 504 "gemini (gemini_cli), opencode (opencode_cli), ollama" 505 ), 506 ) 507 p_multi.add_argument( 508 "--terminate-words", 509 nargs="+", 510 metavar="WORD", 511 default=["terminate"], 512 help="Words that stop all agents (default: terminate)", 513 ) 514 p_multi.add_argument( 515 "--listen-duration", 516 type=float, 517 default=5, 518 metavar="SECONDS", 519 help="Seconds to listen after a wake word (default: 5)", 520 ) 521 p_multi.add_argument( 522 "--follow-up-listen-duration", 523 type=float, 524 default=0, 525 metavar="SECONDS", 526 help="Seconds to listen for follow-up answers (default: 0)", 527 ) 528 p_multi.add_argument( 529 "--inactivity-timeout", 530 type=float, 531 default=4.0, 532 metavar="SECONDS", 533 help="Seconds of inactivity before pivoting back to wake word (default: 4.0)", 534 ) 535 p_multi.add_argument( 536 "--continue-conversation", 537 type=_parse_bool, 538 default=True, 539 metavar="BOOL", 540 help="Resume most recent session for each coding agent (default: true)", 541 ) 542 p_multi.add_argument( 543 "--show-tool-events", 544 type=_parse_bool, 545 default=True, 546 metavar="BOOL", 547 help="Print live tool start/end events (default: true)", 548 ) 549 p_multi.add_argument( 550 "--speaker-backend", 551 default="", 552 choices=["chatterbox", "kokoro"], 553 metavar="BACKEND", 554 help="Explicit TTS backend to use (default: priority Chatterbox then Kokoro)", 555 ) 556 p_multi.add_argument( 557 "--use-speaker", 558 type=_parse_bool, 559 default=True, 560 metavar="BOOL", 561 help="Speak responses aloud via TTS (default: true)", 562 ) 563 # ollama-specific flags (only used when 'ollama' is in --agents) 564 p_multi.add_argument( 565 "--ollama-model", 566 default="llama3.2:latest", 567 metavar="MODEL", 568 help="Ollama model (default: llama3.2:latest). Only used when ollama is in --agents.", 569 ) 570 p_multi.add_argument( 571 "--ollama-host", 572 default="http://localhost:11434", 573 metavar="URL", 574 help="Ollama instance URL (default: http://localhost:11434). Only used when ollama is in --agents.", 575 ) 576 p_multi.add_argument( 577 "--ollama-history-length", 578 type=int, 579 default=10, 580 metavar="N", 581 help="Ollama context history length (default: 10). Only used when ollama is in --agents.", 582 ) 583 # opencode-specific flag 584 p_multi.add_argument( 585 "--opencode-model", 586 default=None, 587 metavar="MODEL", 588 help="OpenCode model in provider/model format. Only used when opencode_cli is in --agents.", 589 ) 590 # claude_code_sdk-specific flag 591 p_multi.add_argument( 592 "--setting-sources", 593 nargs="+", 594 metavar="SOURCE", 595 default=["user", "project", "local"], 596 help="Claude Code SDK setting sources (default: user project local). Only used when claude_code_sdk is in --agents.", 597 ) 598 599 # ------------------------------------------------------------------ # 600 # profile_my_voice — Record a custom voice profile # 601 # ------------------------------------------------------------------ # 602 p_profile = subparsers.add_parser( 603 "profile_my_voice", 604 help="Record a 10-second voice sample to create a custom profile", 605 ) 606 p_profile.add_argument( 607 "--name", 608 required=True, 609 metavar="NAME", 610 help="The name to save this voice profile as (e.g. 'my_voice')", 611 ) 612 p_profile.add_argument( 613 "--device-index", 614 type=int, 615 default=-1, 616 metavar="N", 617 help="Microphone device index; -1 uses system default (default: -1)", 618 ) 619 p_profile.add_argument( 620 "--alternate-output-file", 621 default=None, 622 metavar="PATH", 623 help="An alternate file path to save the voice profile to (e.g. './my_voice.wav')", 624 ) 625 626 # ------------------------------------------------------------------ # 627 # users — manage user profiles # 628 # ------------------------------------------------------------------ # 629 p_users = subparsers.add_parser( 630 "users", 631 help="Manage user profiles and global settings", 632 description=( 633 "Launch an interactive menu to manage user profiles and global " 634 "preferences. Profiles store personal info (name, age, extra context) " 635 "used to tailor agent responses. You can also set the default user " 636 "and terminal theme here." 637 ), 638 ) 639 640 # ------------------------------------------------------------------ # 641 # Dispatch # 642 # ------------------------------------------------------------------ # 643 args = parser.parse_args() 644 645 # Normalise any alias back to the canonical agent name so the dispatch 646 # block below only needs to handle one name per agent. 647 args.agent = _AGENT_ALIASES.get(args.agent, args.agent) 648 649 # Apply color theme as early as possible so all subsequent output uses it. 650 if args.theme != "dark": 651 from spych.cli_tools import set_theme 652 653 set_theme(args.theme) 654 655 # ------------------------------------------------------------------ # 656 # Single-agent dispatch # 657 # ------------------------------------------------------------------ # 658 659 # Default wake words per agent — mirrors the factory function defaults. 660 _DEFAULT_WAKE_WORDS: dict[str, list[str]] = { 661 "ollama": ["llama", "ollama", "lama"], 662 "claude_code_cli": ["claude", "clod", "cloud", "clawed"], 663 "claude_code_sdk": ["claude", "clod", "cloud", "clawed"], 664 "codex_cli": ["codex"], 665 "gemini_cli": ["gemini"], 666 "opencode_cli": ["opencode", "open code"], 667 } 668 669 _AGENT_RESPONDERS: dict[str, str] = { 670 "ollama": "Ollama", 671 "claude_code_cli": "Claude Code CLI", 672 "claude_code_sdk": "Claude Code SDK", 673 "codex_cli": "Codex CLI", 674 "gemini_cli": "Gemini CLI", 675 "opencode_cli": "OpenCode CLI", 676 } 677 678 def _start_dashboard(agent_name: str, responder_name: str, kwargs: dict): 679 """Create a dashboard and inject it into kwargs; start is deferred until healthchecks pass.""" 680 from spych.dashboard import AgentDashboard 681 from spych.utils import get_user, get_default_user 682 683 user_name = kwargs.get("user") or get_default_user() 684 profile_name = "User" 685 if user_name and user_name.lower() != "none": 686 profile = get_user(user_name) 687 if profile: 688 profile_name = profile.get("name", "User") or "User" 689 690 wake_words = kwargs.get( 691 "wake_words", _DEFAULT_WAKE_WORDS.get(args.agent, []) 692 ) 693 694 display_responder = _AGENT_RESPONDERS.get(args.agent, responder_name) 695 kwargs["display_name"] = display_responder 696 697 dashboard = AgentDashboard( 698 agent_name=kwargs.get("name", agent_name), 699 wake_words=wake_words, 700 responder_name=display_responder, 701 response_style=kwargs.get("response_style", ""), 702 use_speaker=kwargs.get("use_speaker", True), 703 speaker_voice=kwargs.get("speaker_voice", "af_heart"), 704 user_name=profile_name, 705 ) 706 print(" ◌ Running healthchecks...") 707 kwargs["dashboard"] = dashboard 708 return dashboard 709 710 if args.agent == "ollama": 711 from spych.agents import ollama 712 713 kwargs = _build_shared_kwargs(args) 714 kwargs["model"] = args.model 715 kwargs["history_length"] = args.history_length 716 kwargs["host"] = args.host 717 dashboard = ( 718 _start_dashboard("Ollama", "OllamaResponder", kwargs) 719 if not args.verbose 720 else None 721 ) 722 try: 723 ollama(**kwargs) 724 finally: 725 if dashboard is not None: 726 dashboard.stop() 727 728 elif args.agent == "claude_code_cli": 729 from spych.agents import claude_code_cli 730 731 kwargs = _build_agent_kwargs(args) 732 dashboard = ( 733 _start_dashboard("Claude", "LocalClaudeCodeCLIResponder", kwargs) 734 if not args.verbose 735 else None 736 ) 737 try: 738 claude_code_cli(**kwargs) 739 finally: 740 if dashboard is not None: 741 dashboard.stop() 742 743 elif args.agent == "claude_code_sdk": 744 from spych.agents import claude_code_sdk 745 746 kwargs = _build_agent_kwargs(args) 747 kwargs["setting_sources"] = args.setting_sources 748 dashboard = ( 749 _start_dashboard("Claude", "LocalClaudeCodeSDKResponder", kwargs) 750 if not args.verbose 751 else None 752 ) 753 try: 754 claude_code_sdk(**kwargs) 755 finally: 756 if dashboard is not None: 757 dashboard.stop() 758 759 elif args.agent == "codex_cli": 760 from spych.agents import codex_cli 761 762 kwargs = _build_agent_kwargs(args) 763 dashboard = ( 764 _start_dashboard("Codex", "LocalCodexCLIResponder", kwargs) 765 if not args.verbose 766 else None 767 ) 768 try: 769 codex_cli(**kwargs) 770 finally: 771 if dashboard is not None: 772 dashboard.stop() 773 774 elif args.agent == "gemini_cli": 775 from spych.agents import gemini_cli 776 777 kwargs = _build_agent_kwargs(args) 778 dashboard = ( 779 _start_dashboard("Gemini", "LocalGeminiCLIResponder", kwargs) 780 if not args.verbose 781 else None 782 ) 783 try: 784 gemini_cli(**kwargs) 785 finally: 786 if dashboard is not None: 787 dashboard.stop() 788 789 elif args.agent == "opencode_cli": 790 from spych.agents import opencode_cli 791 792 kwargs = _build_agent_kwargs(args) 793 if args.model is not None: 794 kwargs["model"] = args.model 795 dashboard = ( 796 _start_dashboard("OpenCode", "LocalOpenCodeCLIResponder", kwargs) 797 if not args.verbose 798 else None 799 ) 800 try: 801 opencode_cli(**kwargs) 802 finally: 803 if dashboard is not None: 804 dashboard.stop() 805 806 elif args.agent == "live": 807 from spych.live import SpychLive 808 809 SpychLive( 810 output_format=args.output_format, 811 output_path=args.output_path, 812 show_timestamps=not args.no_timestamps, 813 stop_key=args.stop_key, 814 terminate_words=args.terminate_words, 815 device_index=args.device_index, 816 whisper_model=args.whisper_model, 817 whisper_device=args.whisper_device, 818 whisper_compute_type=args.whisper_compute_type, 819 no_speech_threshold=args.no_speech_threshold, 820 speech_threshold=args.speech_threshold, 821 silence_threshold=args.silence_threshold, 822 silence_frames_threshold=args.silence_frames, 823 speech_pad_frames=args.speech_pad_frames, 824 max_speech_duration_s=args.max_speech_duration, 825 context_words=args.context_words, 826 ).start() 827 828 elif args.agent == "profile_my_voice": 829 from spych.voice_manager import profile_my_voice 830 831 profile_my_voice( 832 name=args.name, 833 device_index=args.device_index, 834 alternate_output_file=args.alternate_output_file, 835 ) 836 837 elif args.agent == "users": 838 from spych.utils import ( 839 get_all_users, 840 get_user, 841 set_user, 842 set_default_user, 843 get_default_user, 844 set_setting, 845 get_setting, 846 ) 847 from spych.cli_tools import set_theme 848 849 def users_menu(): 850 while True: 851 print("\n " + "=" * 20) 852 print(" SPYCH USER MANAGEMENT") 853 print(" " + "=" * 20) 854 855 users = get_all_users() 856 default_user = get_default_user() 857 current_theme = get_setting("theme", "dark") 858 859 print(f"\n Default User: {default_user or 'None'}") 860 print(f" Current Theme: {current_theme}") 861 print("\n Users:") 862 if not users: 863 print(" (No users found)") 864 for u in users: 865 print( 866 f" - {u}{' (default)' if u == default_user else ''}" 867 ) 868 869 print("\n Options:") 870 print(" 1. Create new user") 871 print(" 2. Edit user") 872 print(" 3. Delete user") 873 print(" 4. Set default user") 874 print(" 5. Set theme") 875 print(" 6. Exit") 876 877 choice = input("\n Choice: ").strip() 878 879 if choice == "1": 880 name = input(" User name: ").strip() 881 if name: 882 data = { 883 "name": input(" Full name: ").strip(), 884 "age": input(" Age: ").strip(), 885 "gender": input(" Gender: ").strip(), 886 "extra": input(" Extra info: ").strip(), 887 } 888 set_user(name, data) 889 print(f" User '{name}' created.") 890 891 elif choice == "2": 892 name = input(" User name to edit: ").strip() 893 user = get_user(name) 894 if user: 895 print(f" Editing {name} (leave blank to keep current)") 896 user["name"] = input( 897 f" Full name [{user.get('name', '')}]: " 898 ).strip() or user.get("name", "") 899 user["age"] = input( 900 f" Age [{user.get('age', '')}]: " 901 ).strip() or user.get("age", "") 902 user["gender"] = input( 903 f" Gender [{user.get('gender', '')}]: " 904 ).strip() or user.get("gender", "") 905 user["extra"] = input( 906 f" Extra info [{user.get('extra', '')}]: " 907 ).strip() or user.get("extra", "") 908 set_user(name, user) 909 print(f" User '{name}' updated.") 910 else: 911 print(" User not found.") 912 913 elif choice == "3": 914 name = input(" User name to delete: ").strip() 915 path = os.path.join(get_cache_dir("users"), f"{name}.json") 916 if os.path.exists(path): 917 os.remove(path) 918 if get_default_user() == name: 919 set_default_user(None) 920 print(f" User '{name}' deleted.") 921 else: 922 print(" User not found.") 923 924 elif choice == "4": 925 name = input(" Default user name (or 'none'): ").strip() 926 if name.lower() == "none": 927 set_default_user(None) 928 print(" Default user cleared.") 929 elif name in get_all_users(): 930 set_default_user(name) 931 print(f" Default user set to '{name}'.") 932 else: 933 print(" User not found.") 934 935 elif choice == "5": 936 theme = ( 937 input(" Theme (dark, light, solarized, mono): ") 938 .strip() 939 .lower() 940 ) 941 if theme in ["dark", "light", "solarized", "mono"]: 942 set_setting("theme", theme) 943 set_theme(theme) 944 print(f" Theme set to '{theme}'.") 945 else: 946 print(" Invalid theme.") 947 948 elif choice == "6": 949 break 950 951 users_menu() 952 953 # ------------------------------------------------------------------ # 954 # Multi-agent dispatch # 955 # ------------------------------------------------------------------ # 956 elif args.agent == "multi": 957 from spych.core import Spych 958 from spych.orchestrator import SpychOrchestrator 959 960 # A single Spych transcription object shared by all responders. 961 spych_object = Spych(whisper_model="base.en") 962 963 # Build dashboard before responders so it can be injected. 964 multi_dashboard = None 965 if not args.verbose: 966 from spych.dashboard import AgentDashboard 967 from spych.utils import get_user, get_default_user 968 969 user_name = args.user or get_default_user() 970 profile_name = "User" 971 if user_name and user_name.lower() != "none": 972 profile = get_user(user_name) 973 if profile: 974 profile_name = profile.get("name", "User") or "User" 975 976 first_agent = _AGENT_ALIASES.get(args.agents[0], args.agents[0]) 977 _multi_name_map = { 978 "claude_code_cli": "Claude", 979 "claude_code_sdk": "Claude", 980 "codex_cli": "Codex", 981 "gemini_cli": "Gemini", 982 "opencode_cli": "OpenCode", 983 "ollama": "Ollama", 984 } 985 multi_dashboard = AgentDashboard( 986 agent_name=_multi_name_map.get(first_agent, first_agent), 987 wake_words=_DEFAULT_WAKE_WORDS.get(first_agent, []), 988 responder_name=_AGENT_RESPONDERS.get(first_agent, ""), 989 use_speaker=args.use_speaker, 990 user_name=profile_name, 991 ) 992 print(" ◌ Running healthchecks...") 993 994 entries = [] 995 996 for agent_name in [_AGENT_ALIASES.get(a, a) for a in args.agents]: 997 if agent_name == "claude_code_cli": 998 from spych.agents.claude import LocalClaudeCodeCLIResponder 999 1000 entries.append( 1001 { 1002 "responder": LocalClaudeCodeCLIResponder( 1003 spych_object=spych_object, 1004 continue_conversation=args.continue_conversation, 1005 listen_duration=args.listen_duration, 1006 follow_up_listen_duration=args.follow_up_listen_duration, 1007 inactivity_timeout=args.inactivity_timeout, 1008 speaker_backend=args.speaker_backend, 1009 use_speaker=args.use_speaker, 1010 show_tool_events=args.show_tool_events, 1011 dashboard=multi_dashboard, 1012 user=args.user, 1013 display_name=_AGENT_RESPONDERS.get( 1014 "claude_code_cli" 1015 ), 1016 ), 1017 "wake_words": ["claude", "clod", "cloud", "clawed"], 1018 "terminate_words": args.terminate_words, 1019 } 1020 ) 1021 1022 elif agent_name == "claude_code_sdk": 1023 from spych.agents.claude import LocalClaudeCodeSDKResponder 1024 1025 entries.append( 1026 { 1027 "responder": LocalClaudeCodeSDKResponder( 1028 spych_object=spych_object, 1029 continue_conversation=args.continue_conversation, 1030 listen_duration=args.listen_duration, 1031 follow_up_listen_duration=args.follow_up_listen_duration, 1032 inactivity_timeout=args.inactivity_timeout, 1033 speaker_backend=args.speaker_backend, 1034 use_speaker=args.use_speaker, 1035 setting_sources=args.setting_sources, 1036 show_tool_events=args.show_tool_events, 1037 dashboard=multi_dashboard, 1038 user=args.user, 1039 display_name=_AGENT_RESPONDERS.get( 1040 "claude_code_sdk" 1041 ), 1042 ), 1043 "wake_words": ["claude", "clod", "cloud", "clawed"], 1044 "terminate_words": args.terminate_words, 1045 } 1046 ) 1047 1048 elif agent_name == "codex_cli": 1049 from spych.agents.codex import LocalCodexCLIResponder 1050 1051 entries.append( 1052 { 1053 "responder": LocalCodexCLIResponder( 1054 spych_object=spych_object, 1055 continue_conversation=args.continue_conversation, 1056 listen_duration=args.listen_duration, 1057 follow_up_listen_duration=args.follow_up_listen_duration, 1058 inactivity_timeout=args.inactivity_timeout, 1059 speaker_backend=args.speaker_backend, 1060 use_speaker=args.use_speaker, 1061 show_tool_events=args.show_tool_events, 1062 dashboard=multi_dashboard, 1063 user=args.user, 1064 display_name=_AGENT_RESPONDERS.get("codex_cli"), 1065 ), 1066 "wake_words": ["codex"], 1067 "terminate_words": args.terminate_words, 1068 } 1069 ) 1070 1071 elif agent_name == "gemini_cli": 1072 from spych.agents.gemini import LocalGeminiCLIResponder 1073 1074 entries.append( 1075 { 1076 "responder": LocalGeminiCLIResponder( 1077 spych_object=spych_object, 1078 continue_conversation=args.continue_conversation, 1079 listen_duration=args.listen_duration, 1080 follow_up_listen_duration=args.follow_up_listen_duration, 1081 inactivity_timeout=args.inactivity_timeout, 1082 speaker_backend=args.speaker_backend, 1083 use_speaker=args.use_speaker, 1084 show_tool_events=args.show_tool_events, 1085 dashboard=multi_dashboard, 1086 user=args.user, 1087 display_name=_AGENT_RESPONDERS.get("gemini_cli"), 1088 ), 1089 "wake_words": ["gemini"], 1090 "terminate_words": args.terminate_words, 1091 } 1092 ) 1093 1094 elif agent_name == "opencode_cli": 1095 from spych.agents.opencode import LocalOpenCodeCLIResponder 1096 1097 entries.append( 1098 { 1099 "responder": LocalOpenCodeCLIResponder( 1100 spych_object=spych_object, 1101 continue_conversation=args.continue_conversation, 1102 listen_duration=args.listen_duration, 1103 follow_up_listen_duration=args.follow_up_listen_duration, 1104 inactivity_timeout=args.inactivity_timeout, 1105 speaker_backend=args.speaker_backend, 1106 use_speaker=args.use_speaker, 1107 show_tool_events=args.show_tool_events, 1108 model=args.opencode_model, 1109 dashboard=multi_dashboard, 1110 user=args.user, 1111 display_name=_AGENT_RESPONDERS.get("opencode_cli"), 1112 ), 1113 "wake_words": ["opencode", "open code"], 1114 "terminate_words": args.terminate_words, 1115 } 1116 ) 1117 1118 elif agent_name == "ollama": 1119 from spych.agents.ollama import OllamaResponder 1120 1121 entries.append( 1122 { 1123 "responder": OllamaResponder( 1124 spych_object=spych_object, 1125 model=args.ollama_model, 1126 history_length=args.ollama_history_length, 1127 host=args.ollama_host, 1128 listen_duration=args.listen_duration, 1129 follow_up_listen_duration=args.follow_up_listen_duration, 1130 inactivity_timeout=args.inactivity_timeout, 1131 speaker_backend=args.speaker_backend, 1132 use_speaker=args.use_speaker, 1133 dashboard=multi_dashboard, 1134 user=args.user, 1135 display_name=_AGENT_RESPONDERS.get("ollama"), 1136 ), 1137 "wake_words": ["llama", "ollama", "lama"], 1138 "terminate_words": args.terminate_words, 1139 } 1140 ) 1141 1142 try: 1143 SpychOrchestrator(entries=entries).start() 1144 finally: 1145 if multi_dashboard is not None: 1146 multi_dashboard.stop() 1147 1148 else: 1149 parser.print_help() 1150 sys.exit(1)