Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions community/voice-dev-terminal/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Voice Dev Terminal

![Community](https://img.shields.io/badge/OpenHome-Community-orange?style=flat-square)
![Author](https://img.shields.io/badge/Author-@hasni1731996-lightgrey?style=flat-square)

A voice-native developer terminal for OpenHome. Say "terminal", then keep talking — run git commands, check disk space, find files, tail logs — all hands-free, with LLM-generated commands and spoken summaries of the output.

## What Makes It Different

| | Alexa / Siri | `openhome-local-link` template | Voice Dev Terminal |
|---|---|---|---|
| Session | One command, done | One command, done | Stays open for multiple commands |
| Command source | Fixed set | LLM one-shot | LLM with full conversation history |
| Output | Raw or none | Raw string spoken back | LLM-summarized into natural speech |
| Dangerous commands | No guard | No guard | Voice confirmation required |
| Inline trigger | No | No | "terminal, git status" runs immediately |

## Trigger Phrases

- `terminal` / `open terminal` / `dev terminal` / `developer terminal`
- `run command` / `run a command` / `execute command`

## Example Conversations

**Basic session:**
> "terminal"
> → "Terminal ready. What do you need?"
> "what's my git status?"
> → "Running that now. You're on branch main, 2 files modified and not staged."
> "show recent commits"
> → "Running that now. 3 commits in the last day. Latest: fix auth bug, 2 hours ago."
> "done"
> → "Got it."

**Inline trigger (no prompt needed):**
> "terminal, check disk space"
> → "Running that now. Your disk is 78% full — 56 gigs free of 256."

**Dangerous command:**
> "remove the build folder"
> → "That runs: rm -rf ./build. Confirm?"
> "yes"
> → "Running that now. Done."

**Context-aware follow-up:**
> "list python files"
> → "Running that now. Found 12 Python files in this directory."
> "do it again but only in the src folder"
> → "Running that now." (LLM resolves to `find ./src -name "*.py"`)

## Exit Phrases

`done` · `bye` · `goodbye` · `that's all` · `all done` · `close terminal` · `never mind`

## Safety

Dangerous command tokens (`rm`, `rmdir`, `sudo`, `shutdown`, `reboot`, `kill`, `pkill`, `dd`, `mkfs`) trigger a spoken confirmation before the command runs. Say anything other than "yes" to cancel.

Commands run in your shell with your own permissions — the Local Link bridge provides no sandbox.

## Setup

This ability requires the **OpenHome CLI Local Link bridge** running on your machine.

### 1. Install the CLI

From the root of this repository (Python 3.10+):

```bash
python3 -m venv cli/.venv && source cli/.venv/bin/activate
pip install -e cli
```

### 2. Log in

```bash
openhome login # paste your API key from Dashboard → Settings → API Keys
```

### 3. Start the bridge

```bash
openhome local start # runs in the background
openhome local status # confirm it's running
openhome local logs # watch commands and output in real time
```

Keep it running while you use the ability.

### 4. Add the ability

Add **Voice Dev Terminal** to your Agent from the Dashboard and set the trigger words above.

## Notes

- The LLM targets macOS zsh/bash. On Linux, most commands work as-is; adjust the system prompt in `get_system_prompt()` if needed.
- Long-running commands time out after 30 seconds. Raise the timeout in `exec_local_command()` for things like `npm install` or large `find` operations.
- Conversation history is in-memory and resets when the session exits. There is no persistent command log.

## Author

Muhammad Hassan
Empty file.
149 changes: 149 additions & 0 deletions community/voice-dev-terminal/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
from src.agent.capability import MatchingCapability
from src.agent.capability_worker import CapabilityWorker
from src.main import AgentWorker

HOTWORDS = {
"terminal", "run command", "open terminal", "execute command",
"dev terminal", "developer terminal", "run a command",
}

DANGER_TOKENS = {"rm", "rmdir", "sudo", "shutdown", "reboot", "dd", "mkfs", "kill", "pkill"}

EXIT_WORDS = {"done", "bye", "goodbye"}
EXIT_PHRASES = ["that's all", "close terminal", "never mind", "all done", "exit terminal"]

CMD_SYSTEM_PROMPT = """You are a macOS terminal command generator. Convert the user's spoken request into a single shell command for zsh/bash.

Rules:
- Return ONLY the shell command — no explanation, no markdown, no backticks, no quotes
- Compatible with macOS (zsh/bash)
- Do not use sudo unless explicitly requested
- Use conversation history to resolve relative references like "do it again" or "with verbose"

Examples:
"list files" -> ls -la
"git status" -> git status
"check disk space" -> df -h
"find python files" -> find . -name "*.py"
"recent commits" -> git log --oneline -10
"what's running on port 3000" -> lsof -i :3000

Return ONLY the command, nothing else."""

SUMMARY_SYSTEM_PROMPT = """You translate raw terminal output into a short spoken reply.

Rules:
- 1 to 3 sentences, natural speech only
- Extract the key facts; never paste raw output verbatim
- If output is empty or blank, say the command finished with no output
- If there is an error, say what went wrong in plain terms
- Answer what the user actually asked for"""


class VoiceDevTerminal(MatchingCapability):
worker: AgentWorker = None
capability_worker: CapabilityWorker = None

# Do not change following tag of register capability
# {{register capability}}

def does_match(self, text: str) -> bool:
t = text.lower()
return any(w in t for w in HOTWORDS)

def call(self, worker: AgentWorker):
self.worker = worker
self.capability_worker = CapabilityWorker(self.worker)
self.worker.session_tasks.create(self._run())

async def _run(self):
try:
trigger = await self.capability_worker.wait_for_complete_transcription()
history = []

inline = self._strip_trigger(trigger)
if inline:
await self._handle_command(inline, history)
else:
await self.capability_worker.speak("Terminal ready. What do you need?")

while True:
utterance = await self.capability_worker.user_response()
if not utterance:
continue
if self._is_exit(utterance):
await self.capability_worker.speak("Got it.")
break
await self._handle_command(utterance, history)
except Exception as e:
self.worker.editor_logging_handler.error(f"[VoiceDevTerminal] {e}")
finally:
self.capability_worker.resume_normal_flow()

async def _handle_command(self, utterance: str, history: list):
cmd = self._generate_command(utterance, history)
if not cmd:
await self.capability_worker.speak("I couldn't generate a command for that.")
return

self.worker.editor_logging_handler.info(f"[VoiceDevTerminal] cmd: {cmd}")

if self._is_dangerous(cmd):
confirmed = await self.capability_worker.run_confirmation_loop(
f"That runs: {cmd}. Confirm?"
)
if not confirmed:
await self.capability_worker.speak("Cancelled.")
return

await self.capability_worker.speak("Running that now.")

try:
response = await self.capability_worker.exec_local_command(cmd, timeout=30.0)
except Exception as e:
self.worker.editor_logging_handler.error(f"[VoiceDevTerminal] exec error: {e}")
await self.capability_worker.speak("That command timed out or failed.")
return

output = response.get("data", "") if isinstance(response, dict) else str(response)
self.worker.editor_logging_handler.info(f"[VoiceDevTerminal] output: {output[:300]}")

history.append({"role": "user", "content": utterance})
history.append({"role": "assistant", "content": cmd})

spoken = self._summarize_output(utterance, cmd, output, history)
await self.capability_worker.speak(spoken)

def _generate_command(self, utterance: str, history: list) -> str:
raw = self.capability_worker.text_to_text_response(
utterance, history, CMD_SYSTEM_PROMPT
)
return (raw or "").strip()

def _summarize_output(self, utterance: str, cmd: str, output: str, history: list) -> str:
prompt = (
f"User asked: {utterance}\n"
f"Command run: {cmd}\n"
f"Output:\n{output or '(no output)'}"
)
raw = self.capability_worker.text_to_text_response(
prompt, history, SUMMARY_SYSTEM_PROMPT
)
return (raw or "Command completed.").strip()

def _is_dangerous(self, cmd: str) -> bool:
return bool(set(cmd.split()) & DANGER_TOKENS)

def _is_exit(self, text: str) -> bool:
t = text.lower().strip()
if any(p in t for p in EXIT_PHRASES):
return True
words = t.split()
return len(words) <= 2 and bool(set(words) & EXIT_WORDS)

def _strip_trigger(self, trigger: str) -> str:
cleaned = trigger.lower()
for hw in HOTWORDS:
cleaned = cleaned.replace(hw, "")
cleaned = cleaned.strip(" ,.-")
return cleaned if len(cleaned) > 3 else ""
Loading