Files
Timmy-time-dashboard/src/timmy/cli.py
Kimi Agent 2f15435fed
Some checks failed
Tests / lint (push) Has been cancelled
Tests / test (push) Has been cancelled
[kimi] Implement quick health snapshot before coding (#710) (#828)
2026-03-21 21:53:40 +00:00

532 lines
17 KiB
Python

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()