ilia bdbf09a9ac feat: Implement voice I/O services (TICKET-006, TICKET-010, TICKET-014)
 TICKET-006: Wake-word Detection Service
- Implemented wake-word detection using openWakeWord
- HTTP/WebSocket server on port 8002
- Real-time detection with configurable threshold
- Event emission for ASR integration
- Location: home-voice-agent/wake-word/

 TICKET-010: ASR Service
- Implemented ASR using faster-whisper
- HTTP endpoint for file transcription
- WebSocket endpoint for streaming transcription
- Support for multiple audio formats
- Auto language detection
- GPU acceleration support
- Location: home-voice-agent/asr/

 TICKET-014: TTS Service
- Implemented TTS using Piper
- HTTP endpoint for text-to-speech synthesis
- Low-latency processing (< 500ms)
- Multiple voice support
- WAV audio output
- Location: home-voice-agent/tts/

 TICKET-047: Updated Hardware Purchases
- Marked Pi5 kit, SSD, microphone, and speakers as purchased
- Updated progress log with purchase status

📚 Documentation:
- Added VOICE_SERVICES_README.md with complete testing guide
- Each service includes README.md with usage instructions
- All services ready for Pi5 deployment

🧪 Testing:
- Created test files for each service
- All imports validated
- FastAPI apps created successfully
- Code passes syntax validation

🚀 Ready for:
- Pi5 deployment
- End-to-end voice flow testing
- Integration with MCP server

Files Added:
- wake-word/detector.py
- wake-word/server.py
- wake-word/requirements.txt
- wake-word/README.md
- wake-word/test_detector.py
- asr/service.py
- asr/server.py
- asr/requirements.txt
- asr/README.md
- asr/test_service.py
- tts/service.py
- tts/server.py
- tts/requirements.txt
- tts/README.md
- tts/test_service.py
- VOICE_SERVICES_README.md

Files Modified:
- tickets/done/TICKET-047_hardware-purchases.md

Files Moved:
- tickets/backlog/TICKET-006_prototype-wake-word-node.md → tickets/done/
- tickets/backlog/TICKET-010_streaming-asr-service.md → tickets/done/
- tickets/backlog/TICKET-014_tts-service.md → tickets/done/
2026-01-12 22:22:38 -05:00

415 lines
13 KiB
Python

"""
Notes & Files Tool - Manage notes and search files.
"""
import re
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
from tools.base import BaseTool
# Path whitelist - only allow notes in home directory
NOTES_DIR = Path(__file__).parent.parent.parent / "data" / "notes" / "home"
FORBIDDEN_PATTERNS = ["work", "atlas/code", "projects"] # Safety: reject paths containing these
def _validate_path(path: Path) -> bool:
"""Validate that path is within allowed directory and doesn't contain forbidden patterns."""
path = path.resolve()
notes_dir = NOTES_DIR.resolve()
# Must be within notes directory
try:
path.relative_to(notes_dir)
except ValueError:
return False
# Check for forbidden patterns
path_str = str(path).lower()
for pattern in FORBIDDEN_PATTERNS:
if pattern in path_str:
return False
return True
def _ensure_notes_dir():
"""Ensure notes directory exists."""
NOTES_DIR.mkdir(parents=True, exist_ok=True)
def _sanitize_filename(name: str) -> str:
"""Convert note name to safe filename."""
filename = re.sub(r'[^\w\s-]', '', name)
filename = re.sub(r'\s+', '-', filename)
filename = filename[:50]
return filename.lower()
def _search_in_file(file_path: Path, query: str) -> bool:
"""Check if query matches file content (case-insensitive)."""
try:
content = file_path.read_text().lower()
return query.lower() in content
except Exception:
return False
class CreateNoteTool(BaseTool):
"""Tool for creating new notes."""
@property
def name(self) -> str:
return "create_note"
@property
def description(self) -> str:
return "Create a new note file. Returns the file path."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Note title (used for filename)"
},
"content": {
"type": "string",
"description": "Note content (Markdown supported)"
}
},
"required": ["title"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute create_note tool."""
_ensure_notes_dir()
title = arguments.get("title", "").strip()
if not title:
raise ValueError("Missing required argument: title")
content = arguments.get("content", "").strip()
# Generate filename
filename = _sanitize_filename(title)
file_path = NOTES_DIR / f"{filename}.md"
# Ensure unique filename
counter = 1
while file_path.exists():
file_path = NOTES_DIR / f"{filename}-{counter}.md"
counter += 1
if not _validate_path(file_path):
raise ValueError(f"Path not allowed: {file_path}")
# Write note file
note_content = f"# {title}\n\n"
if content:
note_content += content + "\n"
note_content += f"\n---\n*Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n"
file_path.write_text(note_content)
return f"Note '{title}' created at {file_path.relative_to(NOTES_DIR.parent.parent.parent)}"
class ReadNoteTool(BaseTool):
"""Tool for reading note files."""
@property
def name(self) -> str:
return "read_note"
@property
def description(self) -> str:
return "Read the content of a note file. Provide filename (with or without .md extension) or relative path."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Note filename (e.g., 'my-note' or 'my-note.md')"
}
},
"required": ["filename"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute read_note tool."""
_ensure_notes_dir()
filename = arguments.get("filename", "").strip()
if not filename:
raise ValueError("Missing required argument: filename")
# Add .md extension if not present
if not filename.endswith(".md"):
filename += ".md"
# Try to find file
file_path = NOTES_DIR / filename
if not _validate_path(file_path):
raise ValueError(f"Path not allowed: {file_path}")
if not file_path.exists():
# Try to find by partial match
matching_files = list(NOTES_DIR.glob(f"*{filename}*"))
if len(matching_files) == 1:
file_path = matching_files[0]
elif len(matching_files) > 1:
return f"Multiple files match '{filename}': {', '.join(f.name for f in matching_files)}"
else:
raise ValueError(f"Note not found: {filename}")
try:
content = file_path.read_text()
return f"**{file_path.stem}**\n\n{content}"
except Exception as e:
raise ValueError(f"Error reading note: {e}")
class AppendToNoteTool(BaseTool):
"""Tool for appending content to existing notes."""
@property
def name(self) -> str:
return "append_to_note"
@property
def description(self) -> str:
return "Append content to an existing note file."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"filename": {
"type": "string",
"description": "Note filename (e.g., 'my-note' or 'my-note.md')"
},
"content": {
"type": "string",
"description": "Content to append"
}
},
"required": ["filename", "content"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute append_to_note tool."""
_ensure_notes_dir()
filename = arguments.get("filename", "").strip()
content = arguments.get("content", "").strip()
if not filename:
raise ValueError("Missing required argument: filename")
if not content:
raise ValueError("Missing required argument: content")
# Add .md extension if not present
if not filename.endswith(".md"):
filename += ".md"
file_path = NOTES_DIR / filename
if not _validate_path(file_path):
raise ValueError(f"Path not allowed: {file_path}")
if not file_path.exists():
raise ValueError(f"Note not found: {filename}")
try:
# Read existing content
existing = file_path.read_text()
# Append new content
updated = existing.rstrip() + f"\n\n{content}\n"
updated += f"\n---\n*Updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n"
file_path.write_text(updated)
return f"Content appended to '{file_path.stem}'"
except Exception as e:
raise ValueError(f"Error appending to note: {e}")
class SearchNotesTool(BaseTool):
"""Tool for searching notes by content."""
@property
def name(self) -> str:
return "search_notes"
@property
def description(self) -> str:
return "Search notes by content (full-text search). Returns matching notes with excerpts."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query (searches in note content)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results",
"default": 10
}
},
"required": ["query"]
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute search_notes tool."""
_ensure_notes_dir()
query = arguments.get("query", "").strip()
if not query:
raise ValueError("Missing required argument: query")
limit = arguments.get("limit", 10)
# Search all markdown files
matches = []
for file_path in NOTES_DIR.glob("*.md"):
if not _validate_path(file_path):
continue
try:
if _search_in_file(file_path, query):
content = file_path.read_text()
# Extract excerpt (first 200 chars containing query)
query_lower = query.lower()
content_lower = content.lower()
idx = content_lower.find(query_lower)
if idx >= 0:
start = max(0, idx - 50)
end = min(len(content), idx + len(query) + 150)
excerpt = content[start:end]
if start > 0:
excerpt = "..." + excerpt
if end < len(content):
excerpt = excerpt + "..."
else:
excerpt = content[:200] + "..."
matches.append({
"filename": file_path.stem,
"excerpt": excerpt
})
except Exception:
continue
if not matches:
return f"No notes found matching '{query}'."
# Limit results
matches = matches[:limit]
# Format output
result = f"Found {len(matches)} note(s) matching '{query}':\n\n"
for match in matches:
result += f"**{match['filename']}**\n"
result += f"{match['excerpt']}\n\n"
return result.strip()
class ListNotesTool(BaseTool):
"""Tool for listing all notes."""
@property
def name(self) -> str:
return "list_notes"
@property
def description(self) -> str:
return "List all available notes."
def get_schema(self) -> Dict[str, Any]:
"""Get tool schema."""
return {
"name": self.name,
"description": self.description,
"inputSchema": {
"type": "object",
"properties": {
"limit": {
"type": "integer",
"description": "Maximum number of notes to list",
"default": 20
}
}
}
}
def execute(self, arguments: Dict[str, Any]) -> str:
"""Execute list_notes tool."""
_ensure_notes_dir()
limit = arguments.get("limit", 20)
notes = []
for file_path in sorted(NOTES_DIR.glob("*.md")):
if not _validate_path(file_path):
continue
try:
# Get first line (title) if possible
content = file_path.read_text()
first_line = content.split("\n")[0] if content else file_path.stem
if first_line.startswith("#"):
title = first_line[1:].strip()
else:
title = file_path.stem
notes.append({
"filename": file_path.stem,
"title": title
})
except Exception:
notes.append({
"filename": file_path.stem,
"title": file_path.stem
})
if not notes:
return "No notes found."
notes = notes[:limit]
result = f"Found {len(notes)} note(s):\n\n"
for note in notes:
result += f"- **{note['title']}** (`{note['filename']}.md`)\n"
return result.strip()