✅ 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/
415 lines
13 KiB
Python
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()
|