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