import asyncio import logging import subprocess import sys import typer from timmy.agent import create_timmy from timmy.prompts import STATUS_PROMPT from timmy.tool_safety import format_action_description, get_impact_level, is_allowlisted logger = logging.getLogger(__name__) app = typer.Typer(help="Timmy — sovereign AI agent") # Stable session ID — Agno persists conversation history in SQLite keyed on this. # Every `timmy chat` invocation reuses the same session so context carries over. _CLI_SESSION_ID = "cli" # Shared option definitions (reused across commands for consistency). _BACKEND_OPTION = typer.Option( None, "--backend", "-b", help="Inference backend: 'ollama' (default) | 'grok' | 'claude'", ) _MODEL_SIZE_OPTION = typer.Option( None, "--model-size", "-s", help="Model size (reserved for future use).", ) def _is_interactive() -> bool: """Return True if stdin is a real terminal (human present).""" return hasattr(sys.stdin, "isatty") and sys.stdin.isatty() def _read_message_input(message: list[str]) -> str: """Join CLI args into a message, reading from stdin when requested. Returns the final message string. Raises ``typer.Exit(1)`` when stdin is explicitly requested (``-``) but empty. """ message_str = " ".join(message) if message_str == "-" or not _is_interactive(): try: stdin_content = sys.stdin.read().strip() except (KeyboardInterrupt, EOFError): stdin_content = "" if stdin_content: message_str = stdin_content elif message_str == "-": typer.echo("No input provided via stdin.", err=True) raise typer.Exit(1) return message_str def _resolve_session_id(session_id: str | None, new_session: bool) -> str: """Return the effective session ID for a chat invocation.""" import uuid if session_id is not None: return session_id if new_session: return str(uuid.uuid4()) return _CLI_SESSION_ID def _prompt_interactive(req, tool_name: str, tool_args: dict) -> None: """Display tool details and prompt the human for approval.""" description = format_action_description(tool_name, tool_args) impact = get_impact_level(tool_name) typer.echo() typer.echo(typer.style("Tool confirmation required", bold=True)) typer.echo(f" Impact: {impact.upper()}") typer.echo(f" {description}") typer.echo() if typer.confirm("Allow this action?", default=False): req.confirm() logger.info("CLI: approved %s", tool_name) else: req.reject(note="User rejected from CLI") logger.info("CLI: rejected %s", tool_name) def _decide_autonomous(req, tool_name: str, tool_args: dict) -> None: """Auto-approve allowlisted tools; reject everything else.""" if is_allowlisted(tool_name, tool_args): req.confirm() logger.info("AUTO-APPROVED (allowlist): %s", tool_name) else: req.reject(note="Auto-rejected: not in allowlist") logger.info("AUTO-REJECTED (not allowlisted): %s %s", tool_name, str(tool_args)[:100]) def _handle_tool_confirmation(agent, run_output, session_id: str, *, autonomous: bool = False): """Prompt user to approve/reject dangerous tool calls. When Agno pauses a run because a tool requires confirmation, this function displays the action, asks for approval via stdin, and resumes or rejects the run accordingly. When autonomous=True (or stdin is not a terminal), tool calls are checked against config/allowlist.yaml instead of prompting. Allowlisted calls are auto-approved; everything else is auto-rejected. Returns the final RunOutput after all confirmations are resolved. """ interactive = _is_interactive() and not autonomous decide = _prompt_interactive if interactive else _decide_autonomous max_rounds = 10 # safety limit for _ in range(max_rounds): status = getattr(run_output, "status", None) is_paused = status == "PAUSED" or str(status) == "RunStatus.paused" if not is_paused: return run_output reqs = getattr(run_output, "active_requirements", None) or [] if not reqs: return run_output for req in reqs: if not getattr(req, "needs_confirmation", False): continue te = req.tool_execution tool_name = getattr(te, "tool_name", "unknown") tool_args = getattr(te, "tool_args", {}) or {} decide(req, tool_name, tool_args) # Resume the run so the agent sees the confirmation result try: run_output = agent.continue_run( run_response=run_output, stream=False, session_id=session_id ) except Exception as exc: logger.error("CLI: continue_run failed: %s", exc) typer.echo(f"Error resuming: {exc}", err=True) return run_output return run_output @app.command() def tick( prompt: str | None = typer.Argument( None, help="Optional journal prompt for Timmy to reflect on" ), ): """Run one thinking cycle. Pass a prompt to ask Timmy a specific question.""" import asyncio from timmy.thinking import thinking_engine thought = asyncio.run(thinking_engine.think_once(prompt=prompt)) if thought: typer.echo(f"[{thought.seed_type}] {thought.content}") else: typer.echo("No thought produced (thinking disabled or Ollama down).") @app.command() def think( topic: str = typer.Argument(..., help="Topic to reason about"), backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, ): """Ask Timmy to think carefully about a topic.""" timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID) timmy.print_response(f"Think carefully about: {topic}", stream=True, session_id=_CLI_SESSION_ID) def _read_message_input(message: list[str]) -> str: """Join CLI arguments and read from stdin when appropriate.""" message_str = " ".join(message) if message_str == "-" or not _is_interactive(): try: stdin_content = sys.stdin.read().strip() except (KeyboardInterrupt, EOFError): stdin_content = "" if stdin_content: message_str = stdin_content elif message_str == "-": typer.echo("No input provided via stdin.", err=True) raise typer.Exit(1) return message_str def _resolve_session_id(session_id: str | None, new_session: bool) -> str: """Return the effective session ID based on CLI flags.""" import uuid if session_id is not None: return session_id if new_session: return str(uuid.uuid4()) return _CLI_SESSION_ID @app.command() def chat( message: list[str] = typer.Argument( ..., help="Message to send (multiple words are joined automatically)" ), backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, new_session: bool = typer.Option( False, "--new", "-n", help="Start a fresh conversation (ignore prior context)", ), session_id: str | None = typer.Option( None, "--session-id", help="Use a specific session ID for this conversation", ), autonomous: bool = typer.Option( False, "--autonomous", "-a", help="Autonomous mode: auto-approve allowlisted tools, reject the rest (no stdin prompts)", ), ): """Send a message to Timmy. Conversation history persists across invocations. Use --new to start fresh, or --session-id to use a specific session. Use --autonomous for non-interactive contexts (scripts, dev loops). Tool calls are checked against config/allowlist.yaml — allowlisted operations execute automatically, everything else is safely rejected. Read from stdin by passing "-" as the message or piping input. """ message_str = _read_message_input(message) session_id = _resolve_session_id(session_id, new_session) timmy = create_timmy(backend=backend, session_id=session_id) run_output = timmy.run(message_str, stream=False, session_id=session_id) run_output = _handle_tool_confirmation(timmy, run_output, session_id, autonomous=autonomous) content = run_output.content if hasattr(run_output, "content") else str(run_output) if content: from timmy.session import _clean_response typer.echo(_clean_response(content)) @app.command() def repl( backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, session_id: str | None = typer.Option( None, "--session-id", help="Use a specific session ID for this conversation", ), ): """Start an interactive REPL session with Timmy. Keeps the agent warm between messages. Conversation history is persisted across invocations. Use Ctrl+C or Ctrl+D to exit gracefully. """ from timmy.session import chat if session_id is None: session_id = _CLI_SESSION_ID typer.echo(typer.style("Timmy REPL", bold=True)) typer.echo("Type your messages below. Use Ctrl+C or Ctrl+D to exit.") typer.echo() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: while True: try: user_input = input("> ") except (KeyboardInterrupt, EOFError): typer.echo() typer.echo("Goodbye!") break user_input = user_input.strip() if not user_input: continue if user_input.lower() in ("exit", "quit", "q"): typer.echo("Goodbye!") break try: response = loop.run_until_complete(chat(user_input, session_id=session_id)) if response: typer.echo(response) typer.echo() except Exception as exc: typer.echo(f"Error: {exc}", err=True) finally: loop.close() @app.command() def status( backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, ): """Print Timmy's operational status.""" timmy = create_timmy(backend=backend, session_id=_CLI_SESSION_ID) timmy.print_response(STATUS_PROMPT, stream=False, session_id=_CLI_SESSION_ID) @app.command() def interview( backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, ): """Initialize Timmy and run a structured interview. Asks Timmy a series of questions about his identity, capabilities, values, and operation to verify he is working correctly. """ import asyncio from timmy.interview import InterviewEntry, format_transcript, run_interview from timmy.session import chat typer.echo("Initializing Timmy for interview...\n") # Use a single persistent event loop for the entire interview. # Calling asyncio.run() per question kills the loop between calls, # orphaning MCP stdio transports and causing "Event loop is closed." loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: # Force agent creation by calling chat once with a warm-up prompt try: loop.run_until_complete( chat("Hello, Timmy. We're about to start your interview.", session_id="interview") ) except Exception as exc: typer.echo(f"Warning: Initialization issue — {exc}", err=True) def _on_answer(entry: InterviewEntry) -> None: typer.echo(f"[{entry.category}]") typer.echo(f" Q: {entry.question}") typer.echo(f" A: {entry.answer}") typer.echo() typer.echo("Starting interview...\n") transcript = run_interview( chat_fn=lambda msg: loop.run_until_complete(chat(msg, session_id="interview")), on_answer=_on_answer, ) # Print full transcript at the end typer.echo("\n" + format_transcript(transcript)) finally: # Clean shutdown: close MCP sessions, then the loop try: from timmy.mcp_tools import close_mcp_sessions loop.run_until_complete(close_mcp_sessions()) except Exception as exc: logger.warning("MCP session close failed: %s", exc) pass loop.close() @app.command() def up( dev: bool = typer.Option(False, "--dev", help="Enable hot-reload for development"), build: bool = typer.Option(True, "--build/--no-build", help="Rebuild images before starting"), ): """Start Timmy Time in Docker (dashboard + agents).""" cmd = ["docker", "compose"] if dev: cmd += ["-f", "docker-compose.yml", "-f", "docker-compose.dev.yml"] cmd += ["up", "-d"] if build: cmd.append("--build") mode = "dev mode (hot-reload active)" if dev else "production mode" typer.echo(f"Starting Timmy Time in {mode}...") result = subprocess.run(cmd) if result.returncode == 0: typer.echo(f"\n Timmy Time running at http://localhost:8000 ({mode})\n") else: typer.echo("Failed to start. Is Docker running?", err=True) raise typer.Exit(1) @app.command() def down(): """Stop all Timmy Time Docker containers.""" subprocess.run(["docker", "compose", "down"], check=True) @app.command() def voice( whisper_model: str = typer.Option( "base.en", "--whisper", "-w", help="Whisper model: tiny.en, base.en, small.en, medium.en" ), use_say: bool = typer.Option(False, "--say", help="Use macOS `say` instead of Piper TTS"), threshold: float = typer.Option( 0.015, "--threshold", "-t", help="Mic silence threshold (RMS). Lower = more sensitive." ), silence: float = typer.Option(1.5, "--silence", help="Seconds of silence to end recording"), backend: str | None = _BACKEND_OPTION, model_size: str | None = _MODEL_SIZE_OPTION, ): """Start the sovereign voice loop — listen, think, speak. Everything runs locally: Whisper for STT, Ollama for LLM, Piper for TTS. No cloud, no network calls, no microphone data leaves your machine. """ from timmy.voice_loop import VoiceConfig, VoiceLoop config = VoiceConfig( whisper_model=whisper_model, use_say_fallback=use_say, silence_threshold=threshold, silence_duration=silence, backend=backend, model_size=model_size, ) loop = VoiceLoop(config=config) loop.run() @app.command() def route( message: list[str] = typer.Argument(..., help="Message to route"), ): """Show which agent would handle a message (debug routing).""" full_message = " ".join(message) from timmy.agents.loader import route_request_with_match agent_id, matched_pattern = route_request_with_match(full_message) if agent_id: typer.echo(f"→ {agent_id} (matched: {matched_pattern})") else: typer.echo("→ orchestrator (no pattern match)") @app.command() def focus( topic: str | None = typer.Argument( None, help='Topic to focus on (e.g. "three-phase loop"). Omit to show current focus.' ), clear: bool = typer.Option(False, "--clear", "-c", help="Clear focus and return to broad mode"), ): """Set deep-focus mode on a single problem. When focused, Timmy prioritizes the active topic in all responses and deprioritizes unrelated context. Focus persists across sessions. Examples: timmy focus "three-phase loop" # activate deep focus timmy focus # show current focus timmy focus --clear # return to broad mode """ from timmy.focus import focus_manager if clear: focus_manager.clear() typer.echo("Focus cleared — back to broad mode.") return if topic: focus_manager.set_topic(topic) typer.echo(f'Deep focus activated: "{topic}"') else: # Show current focus status if focus_manager.is_focused(): typer.echo(f'Deep focus: "{focus_manager.get_topic()}"') else: typer.echo("No active focus (broad mode).") @app.command(name="healthcheck") def healthcheck( json_output: bool = typer.Option(False, "--json", "-j", help="Output as JSON"), verbose: bool = typer.Option( False, "--verbose", "-v", help="Show verbose output including issue details" ), quiet: bool = typer.Option(False, "--quiet", "-q", help="Only show status line (no details)"), ): """Quick health snapshot before coding. Shows CI status, critical issues (P0/P1), test flakiness, and token economy. Fast execution (< 5 seconds) for pre-work checks. Refs: #710 """ import subprocess import sys from pathlib import Path script_path = ( Path(__file__).resolve().parent.parent.parent / "timmy_automations" / "daily_run" / "health_snapshot.py" ) cmd = [sys.executable, str(script_path)] if json_output: cmd.append("--json") if verbose: cmd.append("--verbose") if quiet: cmd.append("--quiet") result = subprocess.run(cmd) raise typer.Exit(result.returncode) def main(): app()