diff --git a/server.py b/server.py index b2be997..742b6e9 100644 --- a/server.py +++ b/server.py @@ -43,6 +43,18 @@ OBSIDIAN_HIDDEN = {".obsidian", ".trash", ".git", ".DS_Store", "node_modules"} MARKDOWN_EXT = {".md", ".markdown"} +# ── Helpers ─────────────────────────────────────────────────────────────── + +def _compact_date(timestamp: float) -> str: + """Return YYYY-MM-DD (no time/microseconds/tz — 1/10 the token cost).""" + return datetime.fromtimestamp(timestamp, tz=timezone.utc).strftime("%Y-%m-%d") + + +def _is_hidden_path(rel: Path) -> bool: + """True if any path component is a dotfile/dotdir (excluding .obsidian).""" + return any(part.startswith(".") and part != ".obsidian" for part in rel.parts) + + # ── Helpers ─────────────────────────────────────────────────────────────── def _vault_path(vault_name: str) -> Path: @@ -69,18 +81,35 @@ def _resolve_doc(vault_name: str, doc_path: str) -> Path: return target -def _file_info(p: Path, relative_to: Path) -> dict[str, Any]: - """Build a file/directory info dict.""" - stat = p.stat() +def _file_info_lean(p: Path, relative_to: Path) -> dict[str, Any]: + """Bare index entry — name, path, type only. ~55 chars vs ~120.""" return { "name": p.name, "path": str(p.relative_to(relative_to)).replace("\\", "/"), "type": "directory" if p.is_dir() else "file", - "size_bytes": stat.st_size if p.is_file() else 0, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), } +def _file_info_full(p: Path, relative_to: Path) -> dict[str, Any]: + """Full metadata for a single file — size, date, counts.""" + stat = p.stat() + info: dict[str, Any] = { + "name": p.name, + "path": str(p.relative_to(relative_to)).replace("\\", "/"), + "type": "directory" if p.is_dir() else "file", + "size_bytes": stat.st_size if p.is_file() else None, + "modified": _compact_date(stat.st_mtime), + } + if p.is_file() and _is_markdown(p): + try: + content = p.read_text(encoding="utf-8", errors="replace") + except Exception: + content = "" + info["line_count"] = content.count("\n") + (1 if content else 0) + info["char_count"] = len(content) + return info + + def _is_markdown(p: Path) -> bool: return p.suffix.lower() in MARKDOWN_EXT @@ -159,8 +188,13 @@ def list_vault( include_files: bool = True, include_directories: bool = True, file_extensions: list[str] | None = None, + include_hidden: bool = False, ) -> dict[str, Any]: - """FILESYSTEM TOOL (not memory) — List files and folders in vault on disk. See structure before reading docs. + """FILESYSTEM TOOL (not memory) — Index files and folders in vault on disk. Use to see structure before reading docs. + + Returns a lean listing (name + path + type only). Use get_file_info for + size/date/counts on a specific file. Hidden dirs (.obsidian, .git, .trash) + are excluded by default. Args: vault_name: Name of the vault (subdirectory name under vaults root) @@ -172,16 +206,16 @@ def list_vault( include_directories: Include directories in results (default True). file_extensions: Optional filter — only return files with these extensions (e.g. ['.md', '.canvas']). Case-insensitive. + include_hidden: If True, include .obsidian config directory (default False). Usage: - list_vault("obsidian-skt") → root listing - list_vault("obsidian-skt", "") → same, explicit + list_vault("obsidian-skt") → root index + list_vault("obsidian-skt", "", True) → recursive index list_vault("obsidian-skt", "NPCs") → subfolder - list_vault("obsidian-skt", "", True) → recursive from root Returns: - directories: list of subdirectory info dicts - files: list of file info dicts (name, path, type, size, modified) + directories: [{name, path, type}] + files: [{name, path, type}] total: combined count """ vault = _vault_path(vault_name) @@ -204,22 +238,13 @@ def list_vault( iterator = target.iterdir() for entry in sorted(iterator, key=lambda p: (not p.is_dir(), p.name.lower())): - # Skip hidden/obsidian metadata unless explicitly inside .obsidian - if entry.name.startswith(".") and entry.name not in (".obsidian",): - continue - if entry.name in OBSIDIAN_HIDDEN and not str(entry.resolve()).startswith(str(vault / ".obsidian")): - continue - # Also skip entries nested inside any hidden/excluded directory ancestor - # (e.g. .git/objects/ab/cdef — the file name looks innocent but it lives - # under .git, which rglob walks into before we can stop it) rel = entry.relative_to(vault) - if any( - part.startswith(".") and part != ".obsidian" - for part in rel.parts - ): + + # Filter hidden directories/files unless include_hidden is set + if _is_hidden_path(rel) and not include_hidden: continue - info = _file_info(entry, vault) + info = _file_info_lean(entry, vault) if entry.is_dir(): if include_directories: directories.append(info) @@ -270,10 +295,27 @@ def get_vault_document(vault_name: str, doc_path: str) -> dict[str, Any]: "word_count": len(content.split()), "char_count": len(content), "size_bytes": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "modified": _compact_date(stat.st_mtime), } +@mcp.tool() +def get_file_info(vault_name: str, doc_path: str) -> dict[str, Any]: + """FILESYSTEM TOOL (not memory) — Get size, date, and line count for a single file. Use after list_vault to inspect a specific note. + + Args: + vault_name: Name of the vault. + doc_path: Relative path to the file within the vault (e.g. 'folder/note.md'). + + Returns metadata dict with: name, path, type, size_bytes, modified (YYYY-MM-DD), + and for markdown files: line_count, char_count. + """ + target = _resolve_doc(vault_name, doc_path) + if not target.exists(): + return {"error": f"File not found: {doc_path}", "vault": vault_name} + return _file_info_full(target, _vault_path(vault_name)) + + @mcp.tool() def search_vault(vault_name: str, query: str, max_results: int = 50) -> dict[str, Any]: """FILESYSTEM TOOL (not memory) — Search markdown files on disk. Find notes about characters, locations, concepts. @@ -290,18 +332,15 @@ def search_vault(vault_name: str, query: str, max_results: int = 50) -> dict[str files_searched = 0 for md_file in vault.rglob("*.md"): - # Skip hidden/trash - rel = str(md_file.relative_to(vault)) - if any(part.startswith(".") for part in md_file.relative_to(vault).parts): - continue - if ".trash" in rel or ".obsidian" in rel: + rel_path = md_file.relative_to(vault) + if _is_hidden_path(rel_path): continue files_searched += 1 matches = _search_file(md_file, query) if matches: results.append({ - "file": rel.replace("\\", "/"), + "file": str(rel_path).replace("\\", "/"), "matches": matches[:max_results], "match_count": len(matches), }) @@ -336,16 +375,14 @@ def get_recent_changes(vault_name: str, limit: int = 20) -> dict[str, Any]: md_files = [] for md_file in vault.rglob("*.md"): - rel = str(md_file.relative_to(vault)) - if any(part.startswith(".") for part in md_file.relative_to(vault).parts): - continue - if ".trash" in rel or ".obsidian" in rel: + rel_path = md_file.relative_to(vault) + if _is_hidden_path(rel_path): continue + rel = str(rel_path) stat = md_file.stat() md_files.append({ "path": rel.replace("\\", "/"), - "size_bytes": stat.st_size, - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "modified": _compact_date(stat.st_mtime), }) md_files.sort(key=lambda f: f["modified"], reverse=True) @@ -375,10 +412,8 @@ def get_backlinks(vault_name: str, doc_path: str) -> dict[str, Any]: backlinks = [] for md_file in vault.rglob("*.md"): - rel = str(md_file.relative_to(vault)).replace("\\", "/") - if any(part.startswith(".") for part in md_file.relative_to(vault).parts): - continue - if ".trash" in rel or ".obsidian" in rel: + rel_path = md_file.relative_to(vault) + if _is_hidden_path(rel_path): continue if md_file.resolve() == target.resolve(): continue # skip self @@ -404,10 +439,10 @@ def get_backlinks(vault_name: str, doc_path: str) -> dict[str, Any]: if matching_links: stat = md_file.stat() backlinks.append({ - "file": rel, + "file": str(rel_path).replace("\\", "/"), "links": matching_links, "link_count": len(matching_links), - "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + "modified": _compact_date(stat.st_mtime), }) backlinks.sort(key=lambda b: b["link_count"], reverse=True)