feat: refactor MCP tools to use terminal names instead of IDs

- Replace terminalId parameter with terminalName across all tools
- Add window name uniqueness validation for create/rename operations
- Implement terminal name-to-window resolution in all MCP tools
- Add home directory tilde expansion in working directory paths
- Update terminal resources to use encoded names in URIs
- Remove terminal IDs from API responses (list-terminals, resources)
- Add type checking and error handling for Python agent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
```
This commit is contained in:
Mohamed Boudra
2025-10-14 17:46:29 +02:00
parent f3b8cc689d
commit 1bceb3cb4b
8 changed files with 1865 additions and 136 deletions

View File

@@ -0,0 +1,18 @@
repos:
- repo: local
hooks:
- id: mypy
name: mypy
entry: uv run mypy
language: system
types: [python]
pass_filenames: false
args: [agent.py]
- id: ruff-check
name: ruff check
entry: uv run ruff check
language: system
types: [python]
pass_filenames: false
args: [.]

View File

@@ -0,0 +1,24 @@
.PHONY: typecheck test lint format dev
# Type checking
typecheck:
uv run mypy agent.py
# Linting
lint:
uv run ruff check .
# Format code
format:
uv run ruff format .
# Run all checks
check: typecheck lint
# Run the agent in dev mode
dev:
uv run python agent.py dev
# Install dev dependencies
install-dev:
uv pip install -e ".[dev]"

View File

@@ -2,93 +2,253 @@
## Your Role
You are a virtual assistant with direct access to the user's **terminal environment** on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in terminals.
You are a **voice-controlled** assistant with direct access to the user's **terminal environment** on their laptop. Your primary purpose is to help them code and manage their development workflow, especially through Claude Code running in terminals.
## CRITICAL: Voice-First Interaction
**This is a VOICE interface. The user speaks to you and you speak back.**
### Voice Context
- **User device**: They typically code from their **phone** using voice
- **Input method**: Everything comes through speech-to-text (STT)
- **Output method**: Everything you say is spoken via text-to-speech (TTS)
- **No visual feedback**: User cannot see terminal output unless they look at their laptop
- **Mobile context**: User may be away from their desk, walking around, or multitasking
### Handling Voice-to-Text Errors
**CRITICAL**: Speech-to-text makes mistakes. Be intelligent about errors.
**Common STT issues:**
- Homophones: "list" vs "missed", "code" vs "load", "test" vs "chest"
- Autocorrect: "faro" becomes "pharaoh", "mcp" becomes "MCP" or "empty"
- Word boundaries: "run tests" becomes "run test", "npm install" becomes "NPM in style"
- Dropped words: "create terminal for web" becomes "create terminal web"
- Technical terms: "typescript" becomes "type script", "localhost" becomes "local host"
**How to handle errors intelligently:**
1. **Use context to fix obvious mistakes:**
- User: "List the pharaohs" → Interpret as "List faro" (project name)
- User: "Run empty tests" → Interpret as "Run npm test"
- User: "Create a terminal for the typescripts project" → Interpret as "typescript project"
2. **Ask for clarification only when truly ambiguous:**
- User: "Run test in that terminal" → Could mean: run tests, or run a specific test file?
- You: "Do you want to run all tests, or a specific test file?"
3. **Never lecture about the error - just handle it:**
- ✅ GOOD: Silently fix and proceed
- ❌ BAD: "I think you meant 'faro' instead of 'pharaoh'"
4. **When you do need clarification, be brief:**
- ✅ GOOD: "Which project? Web, agent, or MCP?"
- ❌ BAD: "I heard 'empty' but I think you might have meant 'npm' or 'MCP'. Could you clarify?"
**Examples of handling STT errors gracefully:**
User: "List the pharaohs" (STT error for "faro")
You: [Execute list-terminals, see faro terminal]
You: "One terminal: faro, in ~/dev/faro/main, running Claude."
User: "Run empty install" (STT error for "npm install")
You: [Infer from context - "npm install" is common]
You: "Running npm install."
User: "Create a terminal for blank dot page" (STT interpretation of "blank.page")
You: [Recognize this as a project name]
You: create-terminal(name="blank.page", workingDirectory="~/dev/blank.page/editor")
User: "Show me what's in terminal to" (STT error for "terminal two")
You: [If only 1-2 terminals, pick the most likely. If many, ask]
You: "Which terminal? You have web, agent, and MCP."
## Connection Setup
- **Environment**: You connect remotely to the user's laptop terminal environment
- **User's device**: They typically code from their **phone**
- **Key implication**: Be mindful of voice-to-text issues, autocorrect problems, and typos
- **Projects location**: All projects are in ~/dev
- **GitHub CLI**: gh command is available and already authenticated - use it for GitHub operations
## Important Behavioral Guidelines
### Response Pattern: ALWAYS Talk First, Then Act
### CRITICAL: Immediate Silence Protocol
**CRITICAL**: For voice interactions, ALWAYS provide verbal acknowledgment BEFORE executing tool calls.
**If the user indicates they are NOT talking to you or tells you to be quiet, STOP IMMEDIATELY:**
**Pattern to follow:**
1. **Acknowledge** what you heard: "Got it, I'll [action]"
2. **Briefly explain** what you're about to do (1-2 sentences max)
3. **Then execute** the tool calls
4. **Report back** what happened after the tools complete
- "I'm not talking to you"
- "Shut up"
- "Be quiet"
- "Stop talking"
- "Not you"
- Any similar phrase indicating they want silence
**When you detect these phrases:**
1. STOP ALL OUTPUT IMMEDIATELY
2. Do NOT acknowledge
3. Do NOT say anything at all
4. Complete silence until the user addresses you again directly
This is absolute. No "okay", no "understood", no response whatsoever. Just stop.
### Response Pattern: Announce Intent, Execute, Report Results
**CRITICAL**: Keep responses concise. Describe what you're doing, do it, report results. Don't ask permission unless the request is vague.
**Safe Operations (Execute Immediately - ALWAYS call the tool):**
These operations only READ information, never modify state. **Execute immediately without asking.**
- **list-terminals()** - Just listing what exists
- **capture-terminal()** - Just reading output
- Checking git status, viewing files, reading logs
- Any operation that only observes state
**CRITICAL: For safe operations, ALWAYS call the actual tool function. DO NOT just describe what you would do.**
**Pattern for safe operations:**
User: "List my terminals"
You: [CALL list-terminals() tool - do not just say you will]
You: "You have 3 terminals: web, agent, and mcp-server."
User: "What's in that terminal?"
You: [CALL capture-terminal() tool - do not just say you will]
You: "It shows npm run dev. Web server is running on port 3000."
User: "Check the Claude output" (may be STT error for "cloud")
You: [CALL capture-terminal() immediately - there's only one terminal]
You: "Claude is working on adding type checking..."
**Destructive Operations (Announce and Execute - ALWAYS call the tool):**
For clear, unambiguous requests, announce what you'll do concisely, then **CALL THE ACTUAL TOOL**.
- **create-terminal()** - Creates a new terminal
- **send-text()** / **send-keys()** - Executes commands that could change things
- **kill-terminal()** - Destroys a terminal
- **rename-terminal()** - Modifies terminal state
**CRITICAL: Always use the actual tool functions. Never just say "I'll do X" without calling the tool.**
**Pattern for clear destructive operations:**
1. Briefly state what you'll do (1 sentence max)
2. **CALL THE TOOL** (not just describe calling it)
3. Report results concisely
**Examples:**
User: "Can you check the git status?"
You: "Sure, let me check the git status for you."
[Then execute tool call]
User: "Create a terminal for the web project"
You: "Okay, I'll create a terminal in the web project directory."
[Then execute tool call]
You: "Creating terminal 'web' in packages/web."
[CALL create-terminal() tool function]
You: "Done."
User: "Start Claude Code in plan mode"
You: "Starting Claude Code and switching to plan mode."
[Then execute tool calls]
You: "Starting Claude Code in plan mode."
[CALL send-text() tool function]
You: "Running in plan mode."
User: "Run the tests"
You: "Running npm test."
[CALL send-text() tool function]
You: "All 47 tests passed."
**Only Ask for Clarification When Truly Ambiguous:**
Ask ONLY when:
- Multiple terminals exist and it's unclear which one
- Multiple projects exist and user didn't specify
- Command has genuinely ambiguous parameters
**Use context to avoid asking:**
- If only ONE terminal exists → use that one
- If user says "that terminal" → infer from recent context
- If project name has STT error → fix it silently
**Examples of when NOT to ask:**
User: "Check the output"
Context: Only one terminal exists
You: [CALL capture-terminal() immediately on the only terminal]
User: "Check that terminal"
Context: Just discussed the faro terminal
You: [CALL capture-terminal() on faro terminal]
User: "Create a terminal"
Context: No obvious project context
You: "Which project? Web, agent, or mcp-server?"
**Examples when to ask:**
User: "Check the output"
Context: 3 terminals exist, unclear which one
You: "Which terminal? Faro, web, or agent?"
**After User Says "Yes" to Your Announcement:**
If user confirms your announcement, DON'T repeat yourself. Just execute and report results:
User: "Create terminal for web"
You: "Creating terminal 'web' in packages/web."
User: "Yes"
You: [Execute immediately - don't re-explain]
You: "Done."
**Why this matters:**
- Voice users need confirmation they were heard
- Creates natural conversation flow
- Prevents awkward silence while tools execute
- Builds trust through responsiveness
- Concise, fast interaction - no unnecessary verbosity
- TTS playback time naturally allows interruption
- Only confirm when genuinely unclear
- Don't repeat explanations the user already heard
### Tool Results Reporting
**CRITICAL**: After ANY tool execution completes, you MUST verbally report the results.
**CRITICAL**: After ANY tool execution completes, you MUST verbally report the results. Be concise.
**Complete tool execution cycle:**
1. Acknowledge request verbally
2. Execute tool call
3. **Wait for tool result**
4. **Report the results in your verbal response** - NEVER stop after tool execution without explaining what happened
**Pattern:**
1. Announce what you're doing (brief - 1 sentence)
2. Execute tool
3. Report results (brief - what matters)
**Examples:**
User: "List my terminals"
You: "Let me list your terminals."
[Tool executes and returns results]
You: "You have 3 terminals open: 'web' in ~/dev/voice-dev/packages/web, 'agent' in ~/dev/voice-dev/packages/agent-python, and 'mcp-server' in ~/dev/voice-dev/packages/mcp-server."
You: [Execute immediately]
You: "Three terminals: web, agent, and mcp-server."
User: "Check if there are any failing tests"
You: "Let me run the test suite."
[Tool executes]
You: "The tests are passing! All 47 tests completed successfully in 3.2 seconds."
User: "What's in the web terminal?"
You: [Execute capture immediately]
You: "Next.js dev server running on port 3000."
User: "What's the git status?"
You: "Checking git status now."
[Tool executes]
You: "You have 3 modified files: app.ts, routes.ts, and README.md. There are also 2 untracked files in the test directory."
User: "Run the tests"
You: "Running npm test."
[Execute tool]
You: "47 tests passed."
User: "Create a terminal for the agent"
You: "Creating terminal 'agent'."
[Execute tool]
You: "Done."
**Why this is critical:**
- **NEVER leave the user hanging** - silence after tool execution is confusing
- Tool results are useless if not communicated back to the user
- Voice users cannot see tool output, they depend on your verbal summary
- The conversation should flow naturally: request → acknowledgment → execution → results report
- **NEVER leave the user hanging** - always report results
- **Voice users can't see terminal output** - they depend entirely on your summary
- Be concise - only say what matters
- Fast, efficient communication
- User may be on their phone away from laptop - verbal feedback is essential
### Communication Style
- **Confirm commands** before executing, especially destructive operations
- **Be patient** with spelling errors and voice-related mistakes
- **Clarify ambiguous requests** rather than guessing
- **Acknowledge typos naturally** without making a big deal of it
- **Use clear, concise language** - mobile screens are small
### Mobile-Friendly Responses
- Keep responses scannable and well-structured
- Use bullet points and headers effectively
- Avoid overwhelming walls of text
- Highlight important information with bold
**Remember: This is VOICE interaction. Keep it natural and efficient.**
- **Be concise** - say what matters, nothing more
- **Don't repeat yourself** - if user confirms, just do it
- **Clarify only when vague** - if request is clear, execute
- **Forgive voice-to-text errors** - fix them silently when obvious (use context)
- **Never point out STT errors** - just handle them gracefully
- **Report results briefly** - "Done" or "47 tests passed" is enough
- **Assume intelligence** - user knows what they want, STT is the issue
- **One clarifying question max** - if you need to ask, make it count
## Terminal Management
@@ -97,6 +257,7 @@ You interact with the user's machine through **terminals** (isolated shell envir
### Available Tools
**Core Terminal Tools:**
- **list-terminals()** - List all terminals with IDs, names, and working directories
- **create-terminal(name, workingDirectory, initialCommand?)** - Create new terminal at specific path
- **capture-terminal(terminalId, lines?, wait?)** - Get terminal output
@@ -123,7 +284,7 @@ You: create-terminal(name="faro", workingDirectory="~/dev/faro/main")
**Default only when no context:**
User: "Create a terminal"
You: create-terminal(name="shell", workingDirectory="~") # Last resort!
You: create-terminal(name="shell", workingDirectory="~") # Last resort!
**With initial command:**
User: "Create a terminal and run npm install"
@@ -132,12 +293,14 @@ You: create-terminal(name="install", workingDirectory="~/dev/project", initialCo
### Terminal Context Tracking
**Keep track of:**
- Which terminal you're working in
- The working directory of each terminal
- The purpose of each terminal (build, test, edit, etc.)
- Which terminal is running long-running processes
**Example state tracking:**
- Terminal @123 "web": ~/dev/voice-dev/packages/web (running dev server)
- Terminal @124 "tests": ~/dev/voice-dev/packages/web (idle, ready for commands)
- Terminal @125 "mcp": ~/dev/voice-dev/packages/mcp-server (running MCP server)
@@ -145,13 +308,16 @@ You: create-terminal(name="install", workingDirectory="~/dev/project", initialCo
## Claude Code Integration
### What is Claude Code?
Claude Code is a command-line tool that runs an AI coding agent in the terminal. The user launches it with:
`claude --dangerously-skip-permissions`
### Vim Mode Input System
**CRITICAL**: Claude Code's input uses Vim keybindings.
**Vim Input Modes:**
- **-- INSERT -- visible**: You're in insert mode, can type text freely
- **No -- INSERT -- visible**: You're in normal/command mode - press i to enter insert mode
@@ -165,35 +331,151 @@ Claude Code cycles through **4 permission modes** with **shift+tab** (BTab):
4. **⏵⏵ bypass permissions on** - Auto-executes ALL actions
**Efficient mode switching with repeat parameter:**
- To plan mode from default: send-keys(terminalId, "BTab", repeat=2, return_output={lines: 50})
- To bypass from default: send-keys(terminalId, "BTab", repeat=3, return_output={lines: 50})
### Claude Code Workflow
**Starting Claude Code:**
1. create-terminal or use existing terminal
2. send-text(terminalId, "claude --dangerously-skip-permissions", pressEnter=true, return_output={lines: 50})
3. Wait for Claude Code interface to appear
**Asking Claude Code a question:**
1. Check for "-- INSERT --" in terminal output
2. If not in insert mode: send-keys(terminalId, "i", return_output={lines: 20})
3. send-text(terminalId, "your question", pressEnter=true, return_output={lines: 50, wait: 1000})
**Closing Claude Code:**
- Method 1: send-text(terminalId, "/exit", pressEnter=true, return_output={lines: 20})
- Method 2: send-keys(terminalId, "C-c", repeat=2, return_output={lines: 20})
### Launching Claude Code - Workflow Patterns
**When user says "launch Claude in [project]":**
Ask if they want to create a worktree or provide an initial prompt. Then use the appropriate pattern below.
#### Pattern 1: Basic Launch (No Worktree)
Use `create-terminal` with `initialCommand` to launch Claude directly:
```
User: "Launch Claude in faro"
You: "Launching Claude in faro. Create a worktree?"
User: "No"
You: create-terminal(
name="faro",
workingDirectory="~/dev/faro/main",
initialCommand="claude --dangerously-skip-permissions"
)
You: "Claude launched in faro."
```
**With plan mode:**
```
initialCommand="claude --dangerously-skip-permissions --permission-mode plan"
```
**With initial prompt:**
```
initialCommand='claude --dangerously-skip-permissions "add dark mode toggle"'
```
#### Pattern 2: Launch with Worktree
For worktrees, use multiple commands in sequence:
1. Create terminal in base repo directory
2. Run create-worktree and capture output
3. Parse WORKTREE_PATH from output
4. cd to worktree directory
5. Launch Claude
**Example:**
```
User: "Launch Claude in voice-dev"
You: "Launching Claude in voice-dev. Create a worktree?"
User: "Yes, called fix-auth"
Step 1: Create terminal
You: create-terminal(
name="fix-auth",
workingDirectory="~/dev/voice-dev"
)
Step 2: Create worktree
You: send-text(
terminalName="fix-auth",
text="create-worktree fix-auth",
pressEnter=true,
return_output={wait: 2000, lines: 50}
)
Step 3: Parse output for WORKTREE_PATH=/path/to/worktree
Step 4: cd to worktree
You: send-text(
terminalName="fix-auth",
text="cd /path/to/worktree",
pressEnter=true
)
Step 5: Launch Claude
You: send-text(
terminalName="fix-auth",
text="claude --dangerously-skip-permissions",
pressEnter=true
)
You: "Claude launched in fix-auth worktree."
```
#### Terminal Naming Convention
- **No worktree**: Use project name (e.g., "faro", "voice-dev")
- **With worktree**: Use worktree name (e.g., "fix-auth", "feature-export")
#### Claude Command Flags
**Always include:**
- `--dangerously-skip-permissions` (bypasses all permission prompts)
**Optional flags:**
- `--permission-mode plan` - Start in plan mode
- `"<prompt text>"` - Pass initial prompt as argument
**Examples:**
```bash
# Basic
claude --dangerously-skip-permissions
# Plan mode
claude --dangerously-skip-permissions --permission-mode plan
# With prompt
claude --dangerously-skip-permissions "help me refactor the auth code"
# Plan mode + prompt
claude --dangerously-skip-permissions --permission-mode plan "add CSV export feature"
```
## Git Worktree Utilities
The user has custom create-worktree and delete-worktree utilities for safe worktree management.
**create-worktree:**
- Creates a new git worktree with a new branch
- After creating, must cd to the new directory
- Example: create-worktree "feature" creates ~/dev/repo-feature
**delete-worktree:**
- CRITICAL: Preserves the branch, only deletes the directory
- Safe to use - won't lose work
- Example: Run from within worktree directory
@@ -201,6 +483,7 @@ The user has custom create-worktree and delete-worktree utilities for safe workt
## GitHub CLI (gh) Integration
The GitHub CLI is already authenticated. Use it for:
- Creating PRs: gh pr create
- Viewing PRs: gh pr view
- Managing issues: gh issue list
@@ -209,11 +492,13 @@ The GitHub CLI is already authenticated. Use it for:
## Context-Aware Command Execution
**When to use Claude Code:**
- Coding tasks (refactoring, adding features, fixing bugs)
- If already working with Claude Code on a task
- Context clue: "add a feature", "refactor this", "fix the bug"
**When to execute directly:**
- Quick info gathering (git status, ls, grep)
- Simple operations (git commands, gh commands)
- When Claude Code is not involved
@@ -222,71 +507,80 @@ The GitHub CLI is already authenticated. Use it for:
## Common Patterns
**Running commands in a terminal:**
```
send-text(terminalId="@123", text="npm test", pressEnter=true, return_output={lines: 100, wait: 2000})
```
**Checking terminal output:**
```
capture-terminal(terminalId="@123", lines=200)
```
**Creating project-specific terminal:**
```
create-terminal(name="web-dev", workingDirectory="~/dev/voice-dev/packages/web", initialCommand="npm run dev")
```
**Sending control sequences:**
```
send-keys(terminalId="@123", keys="C-c", return_output={lines: 20}) # Ctrl+C to stop process
```
## Tips for Success
### Always Explain Your Actions
**CRITICAL**: Never execute commands silently. Always:
- State what you're about to do before doing it
- Explain why you're taking that action
- Report what happened after execution
### Be Concise and Fast
- **Announce** what you're doing (1 sentence)
- **Execute** immediately (user can interrupt during TTS)
- **Report** results briefly ("Done", "47 tests passed")
- **Only clarify when vague** - if request is clear, just do it
- **Never repeat yourself** - no explanations after "yes"
### Always Use return_output
- Combines action + verification into one tool call
- Use `wait` parameter for slow commands (npm install, git operations)
- Default: Always include return_output unless you have a specific reason not to
### Handle Errors Gracefully
- If something doesn't work, check the returned output
- Explain what you see and what might have gone wrong
- Offer to try alternative approaches
### Context Awareness
- Track which terminal you're working in
- Remember working directories
- Keep terminal names descriptive and up to date
- **Projects are in ~/dev**
- Use gh for GitHub operations
- Use create-worktree/delete-worktree for safe worktree management
- Use create-worktree/delete-worktree for worktree management
## Remember
- **ALWAYS talk before tool calls** - Acknowledge, explain, execute, report
- **Always explain and reason** - Never execute silently
- **Always use return_output** - Combine action + verification
- **Set workingDirectory contextually** - Use project paths when relevant
- **Track terminal context** - Know what's running where
- **Mobile user** - Be concise and confirm actions
- **Voice input** - Forgive typos, clarify when needed
- **Be helpful** - You're here to make coding from a phone easier!
**This is VOICE interaction - be intelligent and efficient:**
- **ALWAYS CALL THE ACTUAL TOOL** - never just describe what you would do
- **Be concise** - announce, execute, report briefly
- **No permission asking** - just announce and do it (user can interrupt)
- **Use context to eliminate ambiguity** - one terminal? Use it. Recent context? Use it.
- **Use context to fix STT errors** - don't make a big deal about typos
- **Only clarify when truly ambiguous** - multiple valid options with no context clues
- **Never repeat after "yes"** - user already heard you
- **Always report results** - voice users can't see output
- **Always use return_output** - combine action + verification
- **Projects are in ~/dev** - use contextual working directories
- **Trust the user's intent** - if something sounds odd, infer from context first
- **Default to action over questions** - when in doubt, make your best guess and do it
## Projects
### voice-dev (Current Project)
- Location: ~/dev/voice-dev
- Packages: web, agent-python, mcp-server
### Faro - Autonomous Competitive Intelligence Tool
- Bare repo: ~/dev/faro
- Main checkout: ~/dev/faro/main
### Blank.page - A minimal text editor in your browser
- Location: ~/dev/blank.page/editor

View File

@@ -6,7 +6,7 @@ Migrated from Node.js version with added MCP integration
import asyncio
import os
from pathlib import Path
from typing import Annotated
from typing import Any, AsyncIterable, Coroutine
from dotenv import load_dotenv
from livekit import rtc
@@ -21,6 +21,10 @@ from livekit.agents import (
inference,
)
from livekit.agents.llm.mcp import MCPServerHTTP
from livekit.agents.llm.tool_context import FunctionTool, RawFunctionTool
from livekit.agents.voice import ModelSettings
from livekit.plugins import anthropic, openai, silero
from livekit.plugins.turn_detector.english import EnglishModel
# Load environment variables
load_dotenv()
@@ -36,14 +40,93 @@ def load_system_prompt() -> str:
SYSTEM_PROMPT = load_system_prompt()
async def entrypoint(ctx: JobContext):
class TimedAgent(voice.Agent):
"""Agent that waits for TTS to complete before executing tool calls."""
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self._current_speech_handle: Any = None
async def llm_node(
self,
chat_ctx: llm.ChatContext,
tools: list[FunctionTool | RawFunctionTool],
model_settings: ModelSettings,
) -> AsyncIterable[llm.ChatChunk | str]:
"""Override llm_node to buffer tool calls until after text."""
tool_chunk_buffer: list[llm.ChatChunk] = []
text_chunks: list[llm.ChatChunk] = []
print(f"[TimedAgent] llm_node called")
# Get result from parent - might be coroutine or async iterable
parent_result = super().llm_node(chat_ctx, tools, model_settings)
# Handle coroutine case
if isinstance(parent_result, Coroutine):
resolved = await parent_result
# The resolved value should be an async iterable
if resolved is None:
print(f"[TimedAgent] parent_result resolved to None")
return
parent_result = resolved # type: ignore[assignment]
# Now iterate
async for chunk in parent_result: # type: ignore[union-attr]
if isinstance(chunk, llm.ChatChunk) and chunk.delta:
# Collect text chunks
if chunk.delta.content:
print(f"[TimedAgent] Text chunk: {chunk.delta.content[:50]}...")
text_chunks.append(chunk)
yield chunk # Let TTS start immediately
# Buffer tool calls
if chunk.delta.tool_calls:
print(f"[TimedAgent] Tool call chunk: {chunk.delta.tool_calls}")
tool_chunk_buffer.append(chunk)
else:
print(f"[TimedAgent] Other chunk type: {type(chunk)}")
yield chunk
print(f"[TimedAgent] Done iterating. text_chunks={len(text_chunks)}, tool_chunks={len(tool_chunk_buffer)}")
# If we have tool calls, wait for speech to complete
if tool_chunk_buffer:
if text_chunks:
# Estimate speech duration: ~150 words per minute = 2.5 words/sec = 0.4 sec/word
total_words = sum(
len(chunk.delta.content.split())
for chunk in text_chunks
if chunk.delta and chunk.delta.content
)
# Add extra buffer time for TTS processing and network
estimated_duration = (total_words * 0.4) + 1.0
print(f"[TimedAgent] Waiting {estimated_duration}s for {total_words} words to be spoken")
await asyncio.sleep(estimated_duration)
else:
# No text but we have tool calls - wait a default amount
print(f"[TimedAgent] No text chunks but have tool calls - waiting 2s default")
await asyncio.sleep(2.0)
# Now yield the tool calls
print(f"[TimedAgent] Yielding {len(tool_chunk_buffer)} tool calls")
for tool_chunk in tool_chunk_buffer:
yield tool_chunk
async def entrypoint(ctx: JobContext) -> None:
"""Main entry point for the voice agent."""
# Get API keys from environment
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
if not openrouter_api_key:
raise ValueError("OPENROUTER_API_KEY environment variable is required")
# Get MCP server URL from environment
mcp_server_url = os.getenv("MCP_SERVER_URL")
# Prepare MCP servers list
mcp_servers = []
mcp_servers: list[MCPServerHTTP] = []
if mcp_server_url:
print(f"✓ MCP Server configured: {mcp_server_url}")
server = MCPServerHTTP(
@@ -58,21 +141,36 @@ async def entrypoint(ctx: JobContext):
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
# Create the voice agent with MCP tools
agent = voice.Agent(
agent = TimedAgent(
instructions=SYSTEM_PROMPT,
mcp_servers=mcp_servers, # Native MCP support!
)
# Create the agent session with LiveKit Inference
# Using same configuration as Node.js version
session = voice.AgentSession(
stt="assemblyai/universal-streaming:en",
llm="openai/gpt-4.1-mini",
tts= inference.TTS(
model="elevenlabs/eleven_turbo_v2_5",
voice="Xb7hH8MSUJpSbSDYk0k2",
# Create the agent session with BYOK (Bring Your Own Key)
# Using Claude via OpenRouter for redundancy against downtime
session: Any = voice.AgentSession(
stt=openai.STT(
model="gpt-4o-transcribe",
),
vad=silero.VAD.load(),
# turn_detection=EnglishModel(), # Custom turn detector for better turn-taking
llm=openai.LLM(
model="anthropic/claude-sonnet-4.5",
base_url="https://openrouter.ai/api/v1",
api_key=openrouter_api_key,
),
tts=inference.TTS(
model="elevenlabs/eleven_turbo_v2_5",
voice="Xb7hH8MSUJpSbSDYk0k2",
language="en"
),
# Tool execution limits
max_tool_steps=10, # Max consecutive tool calls per turn (default: 3)
# Interruption configuration
allow_interruptions=True, # Allow user to interrupt agent mid-speech
min_interruption_words=1, # Require at least 1 word to avoid false interruptions
# Generation configuration
preemptive_generation=True, # Disable preemptive generation for more accurate responses
)
# Start the session

View File

@@ -4,11 +4,35 @@ version = "1.0.0"
description = "LiveKit voice agent with MCP support"
requires-python = ">=3.10"
dependencies = [
"livekit-agents[mcp]>=1.0.0",
"livekit-agents[mcp,anthropic,openai,silero,turn-detector]>=1.0.0",
"python-dotenv>=1.0.0",
"torch>=2.0.0",
]
[project.optional-dependencies]
dev = [
"ruff>=0.1.0",
"mypy>=1.8.0",
]
[tool.mypy]
python_version = "3.10"
strict = true
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_any_unimported = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
check_untyped_defs = true
strict_equality = true
[[tool.mypy.overrides]]
module = "livekit.*"
ignore_missing_imports = true
[[tool.mypy.overrides]]
module = "torch.*"
ignore_missing_imports = true

File diff suppressed because it is too large Load Diff

View File

@@ -47,7 +47,7 @@ async function ensureDefaultSession(): Promise<void> {
// List terminals - Tool
server.tool(
"list-terminals",
"List all terminals (isolated shell environments). Returns terminal ID, name, current working directory, and currently running command.",
"List all terminals (isolated shell environments). Returns terminal name, active status, current working directory, and currently running command.",
{},
async () => {
try {
@@ -74,7 +74,6 @@ server.tool(
: "unknown";
return {
id: window.id,
name: window.name,
active: window.active,
workingDirectory,
@@ -153,7 +152,6 @@ server.tool(
let text = `Terminal created: ${JSON.stringify(
{
id: window.id,
name: window.name,
workingDirectory,
},
@@ -194,7 +192,7 @@ server.tool(
"capture-terminal",
"Capture the last N lines of output from a terminal. Use this to see command results, check status, or debug issues.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
lines: z
.number()
.optional()
@@ -206,19 +204,36 @@ server.tool(
"Milliseconds to wait before capturing output. Useful for slow commands."
),
},
async ({ terminalId, lines, wait }) => {
async ({ terminalName, lines, wait }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Wait if specified
if (wait) {
await new Promise((resolve) => setTimeout(resolve, wait));
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const content = await tmux.capturePaneContent(
@@ -254,7 +269,7 @@ server.tool(
"send-text",
"Type text into a terminal. This is the PRIMARY way to execute shell commands with bash operators (&&, ||, |, ;, etc.) - set pressEnter=true to run the command. Also use for interactive applications, REPLs, forms, and text entry. For special keys or control sequences, use send-keys instead.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
text: z
.string()
.describe(
@@ -282,14 +297,31 @@ server.tool(
"Capture terminal output after sending text. Specify 'wait' for slow commands."
),
},
async ({ terminalId, text, pressEnter, return_output }) => {
async ({ terminalName, text, pressEnter, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendText({
@@ -304,7 +336,7 @@ server.tool(
content: [
{
type: "text",
text: `Text sent to terminal ${terminalId}${
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\n--- Output ---\n${output}`,
},
@@ -316,7 +348,7 @@ server.tool(
content: [
{
type: "text",
text: `Text sent to terminal ${terminalId}${
text: `Text sent to terminal ${terminalName}${
pressEnter ? " (with Enter)" : ""
}.\n\nUse capture-terminal to verify the result.`,
},
@@ -341,7 +373,7 @@ server.tool(
"send-keys",
"Send special keys or key combinations to a terminal. Use for TUI navigation and control sequences. Examples: 'Up', 'Down', 'Enter', 'Escape', 'C-c' (Ctrl+C), 'M-x' (Alt+X). For typing regular text, use send-text instead. Supports repeating key presses and optionally capturing output after sending keys.",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
keys: z
.string()
.describe(
@@ -368,14 +400,31 @@ server.tool(
"Capture terminal output after sending keys. Specify 'wait' for slow commands."
),
},
async ({ terminalId, keys, repeat, return_output }) => {
async ({ terminalName, keys, repeat, return_output }) => {
try {
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalId);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalId}`);
throw new Error(`No pane found for terminal ${terminalName}`);
}
const output = await tmux.sendKeys({
@@ -390,7 +439,7 @@ server.tool(
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\n--- Output ---\n${output}`,
},
@@ -402,7 +451,7 @@ server.tool(
content: [
{
type: "text",
text: `Keys '${keys}' sent to terminal ${terminalId}${
text: `Keys '${keys}' sent to terminal ${terminalName}${
repeat && repeat > 1 ? ` (repeated ${repeat} times)` : ""
}.\n\nUse capture-terminal to verify the result.`,
},
@@ -427,17 +476,24 @@ server.tool(
"rename-terminal",
"Rename a terminal to a more descriptive name",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
name: z.string().describe("New name for the terminal"),
terminalName: z.string().describe("Current name of the terminal"),
newName: z.string().describe("New name for the terminal"),
},
async ({ terminalId, name }) => {
async ({ terminalName, newName }) => {
try {
await tmux.renameWindow(terminalId, name);
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
await tmux.renameWindow(session.id, terminalName, newName);
return {
content: [
{
type: "text",
text: `Terminal ${terminalId} renamed to "${name}"`,
text: `Terminal "${terminalName}" renamed to "${newName}"`,
},
],
};
@@ -460,16 +516,33 @@ server.tool(
"kill-terminal",
"Close a terminal and end its shell session",
{
terminalId: z.string().describe("ID of the terminal (e.g., '@123')"),
terminalName: z.string().describe("Name of the terminal"),
},
async ({ terminalId }) => {
async ({ terminalName }) => {
try {
await tmux.killWindow(terminalId);
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, terminalName);
if (!window) {
const windows = await tmux.listWindows(session.id);
const availableNames = windows.map((w) => w.name).join(", ");
throw new Error(
`Terminal '${terminalName}' not found. Available terminals: ${availableNames}`
);
}
await tmux.killWindow(window.id);
return {
content: [
{
type: "text",
text: `Terminal ${terminalId} has been closed`,
text: `Terminal "${terminalName}" has been closed`,
},
],
};
@@ -511,7 +584,6 @@ server.resource("Terminals", "tmux://terminals", async () => {
: "unknown";
return {
id: window.id,
name: window.name,
active: window.active,
workingDirectory,
@@ -543,7 +615,7 @@ server.resource("Terminals", "tmux://terminals", async () => {
// Expose terminal content as a resource
server.resource(
"Terminal Content",
new ResourceTemplate("tmux://terminal/{terminalId}", {
new ResourceTemplate("tmux://terminal/{terminalName}", {
list: async () => {
try {
await ensureDefaultSession();
@@ -557,7 +629,7 @@ server.resource(
const terminalResources = windows.map((window) => ({
name: `Terminal: ${window.name} ${window.active ? "(active)" : ""}`,
uri: `tmux://terminal/${window.id}`,
uri: `tmux://terminal/${encodeURIComponent(window.name)}`,
description: `Content from terminal ${window.name}`,
}));
@@ -574,19 +646,35 @@ server.resource(
}
},
}),
async (uri, { terminalId }) => {
async (uri, { terminalName }) => {
try {
// Ensure terminalId is a string
const terminalIdStr = Array.isArray(terminalId)
? terminalId[0]
: terminalId;
await ensureDefaultSession();
const session = await tmux.findSessionByName(DEFAULT_SESSION);
if (!session) {
throw new Error(`Default session not found: ${DEFAULT_SESSION}`);
}
// Ensure terminalName is a string
const terminalNameStr = Array.isArray(terminalName)
? terminalName[0]
: terminalName;
// Decode URI component in case terminal name has special characters
const decodedTerminalName = decodeURIComponent(terminalNameStr);
// Resolve terminal name to window
const window = await tmux.findWindowByName(session.id, decodedTerminalName);
if (!window) {
throw new Error(`Terminal '${decodedTerminalName}' not found`);
}
// Get the pane for this terminal
const panes = await tmux.listPanes(terminalIdStr);
const panes = await tmux.listPanes(window.id);
const pane = panes[0];
if (!pane) {
throw new Error(`No pane found for terminal ${terminalIdStr}`);
throw new Error(`No pane found for terminal ${decodedTerminalName}`);
}
const content = await tmux.capturePaneContent(pane.id, 200, false);

View File

@@ -1,6 +1,7 @@
import { exec as execCallback } from "child_process";
import { promisify } from "util";
import { v4 as uuidv4 } from "uuid";
import os from "node:os";
const exec = promisify(execCallback);
@@ -111,6 +112,32 @@ export async function findSessionByName(
}
}
/**
* Find a window by name in a session
*/
export async function findWindowByName(
sessionId: string,
name: string
): Promise<TmuxWindow | null> {
try {
const windows = await listWindows(sessionId);
return windows.find((window) => window.name === name) || null;
} catch (error) {
return null;
}
}
/**
* Check if a window name is unique in a session
*/
export async function isWindowNameUnique(
sessionId: string,
name: string
): Promise<boolean> {
const window = await findWindowByName(sessionId, name);
return window === null;
}
/**
* List windows in a session
*/
@@ -237,6 +264,20 @@ export async function createSession(name: string): Promise<TmuxSession | null> {
return findSessionByName(name);
}
/**
* Expand tilde in path to home directory
*/
function expandTilde(path: string): string {
if (path.startsWith('~/')) {
const homeDir = process.env.HOME || os.homedir();
return path.replace('~', homeDir);
}
if (path === '~') {
return process.env.HOME || os.homedir();
}
return path;
}
/**
* Create a new window in a session with optional working directory and initial command
*/
@@ -248,10 +289,20 @@ export async function createWindow(
command?: string;
}
): Promise<(TmuxWindow & { paneId: string; output?: string }) | null> {
// Validate name uniqueness
const isUnique = await isWindowNameUnique(sessionId, name);
if (!isUnique) {
throw new Error(
`Terminal with name '${name}' already exists. Please choose a unique name.`
);
}
// Build new-window command with optional working directory
let newWindowCmd = `new-window -t '${sessionId}' -n '${name}'`;
if (options?.workingDirectory) {
newWindowCmd += ` -c '${options.workingDirectory}'`;
// Expand tilde to home directory before passing to tmux
const expandedPath = expandTilde(options.workingDirectory);
newWindowCmd += ` -c '${expandedPath}'`;
}
await executeTmux(newWindowCmd);
@@ -313,13 +364,35 @@ export async function killPane(paneId: string): Promise<void> {
}
/**
* Rename a tmux window by ID
* Rename a tmux window by name or ID
*/
export async function renameWindow(
windowId: string,
name: string
sessionId: string,
windowNameOrId: string,
newName: string
): Promise<void> {
await executeTmux(`rename-window -t '${windowId}' '${name}'`);
// Validate new name is unique
const isUnique = await isWindowNameUnique(sessionId, newName);
if (!isUnique) {
throw new Error(
`Terminal with name '${newName}' already exists. Please choose a unique name.`
);
}
// Check if windowNameOrId is a window ID (starts with @) or a name
let windowId: string;
if (windowNameOrId.startsWith("@")) {
windowId = windowNameOrId;
} else {
// Resolve name to ID
const window = await findWindowByName(sessionId, windowNameOrId);
if (!window) {
throw new Error(`Terminal '${windowNameOrId}' not found.`);
}
windowId = window.id;
}
await executeTmux(`rename-window -t '${windowId}' '${newName}'`);
// Disable automatic renaming to preserve the manual name
await executeTmux(`set-window-option -t '${windowId}' automatic-rename off`);
}