518 lines
18 KiB
Python
518 lines
18 KiB
Python
"""Built-in tool services (Phase 0 + Phase C read/search + Phase D mutations).
|
|
|
|
These functions are the single source of truth consumed by both the in-app
|
|
assistant and the MCP server. They delegate to the shared business-logic
|
|
services (``backend.services``) so routes and tools never diverge.
|
|
Mutating tools (create/edit/rename/move/delete/restore) are registered with
|
|
``ToolRisk.WRITE`` or ``ToolRisk.DANGEROUS`` and gated by the registry's
|
|
confirmation mechanism (two-step propose/apply).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any
|
|
|
|
from backend.indexer import get_backlinks as _get_backlinks
|
|
from backend.indexer import get_vault_names
|
|
from backend.services.backups import diff_backup as _diff_backup
|
|
from backend.services.backups import list_backup_files as _list_backup_files
|
|
from backend.services.files import read_file_text
|
|
from backend.services.graph import get_graph as _get_graph
|
|
from backend.services.mutations import (
|
|
append_to_file as _append_to_file,
|
|
)
|
|
from backend.services.mutations import (
|
|
create_directory as _create_directory,
|
|
)
|
|
from backend.services.mutations import (
|
|
create_file as _create_file,
|
|
)
|
|
from backend.services.mutations import (
|
|
delete_directory as _delete_directory,
|
|
)
|
|
from backend.services.mutations import (
|
|
delete_file as _delete_file,
|
|
)
|
|
from backend.services.mutations import (
|
|
edit_file as _edit_file,
|
|
)
|
|
from backend.services.mutations import (
|
|
move_path as _move_path,
|
|
)
|
|
from backend.services.mutations import (
|
|
rename_directory as _rename_directory,
|
|
)
|
|
from backend.services.mutations import (
|
|
rename_file as _rename_file,
|
|
)
|
|
from backend.services.mutations import (
|
|
replace_in_files as _replace_in_files,
|
|
)
|
|
from backend.services.mutations import (
|
|
restore_backup as _restore_backup,
|
|
)
|
|
from backend.services.recent import list_recent as _list_recent
|
|
from backend.services.search import advanced_search_vaults, search_vaults
|
|
from backend.services.search import list_tags as _list_tags
|
|
from backend.services.search import search_paths as _search_paths
|
|
from backend.services.vaults import browse_directory, list_accessible_vaults, list_all_files
|
|
from backend.tools.context import ToolContext, ToolRisk
|
|
from backend.tools.registry import tool
|
|
from backend.tools.schemas import (
|
|
AppendToFileInput,
|
|
CreateDirectoryInput,
|
|
CreateFileInput,
|
|
DeleteDirectoryInput,
|
|
DeleteFileInput,
|
|
DiffBackupInput,
|
|
EditFileInput,
|
|
GetBacklinksInput,
|
|
GetGraphInput,
|
|
ListAllFilesInput,
|
|
ListBackupsInput,
|
|
ListDirectoryInput,
|
|
ListRecentInput,
|
|
ListTagsInput,
|
|
ListVaultsInput,
|
|
MovePathInput,
|
|
ReadFileInput,
|
|
ReadFileRawInput,
|
|
RenameDirectoryInput,
|
|
RenameFileInput,
|
|
ReplaceInFilesInput,
|
|
RestoreBackupInput,
|
|
SearchAdvancedInput,
|
|
SearchFulltextInput,
|
|
SearchPathsInput,
|
|
SuggestTagsInput,
|
|
)
|
|
|
|
logger = logging.getLogger("obsigate.tools.service")
|
|
|
|
# Maximum file size returned by ``read_file`` (bytes). Quota configurable via
|
|
# ``BOOKSLM_MAX_TOOL_READ_BYTES``.
|
|
TOOL_MAX_READ_BYTES = int(os.environ.get("BOOKSLM_MAX_TOOL_READ_BYTES", "200000"))
|
|
|
|
|
|
# ── C1. Vaults / navigation ────────────────────────────────────────────────
|
|
|
|
|
|
@tool(
|
|
name="list_vaults",
|
|
description="List the vaults the current user is allowed to access.",
|
|
input_model=ListVaultsInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def list_vaults(ctx: ToolContext, _params: ListVaultsInput) -> list[dict[str, Any]]:
|
|
"""Return accessible vaults with a file count."""
|
|
return [
|
|
{"name": v["name"], "file_count": v["file_count"]}
|
|
for v in list_accessible_vaults(ctx.user)
|
|
]
|
|
|
|
|
|
@tool(
|
|
name="list_directory",
|
|
description="List files and subdirectories of a directory inside a vault.",
|
|
input_model=ListDirectoryInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def list_directory(ctx: ToolContext, params: ListDirectoryInput) -> list[dict[str, Any]]:
|
|
"""Return the entries of a vault directory (direct children only)."""
|
|
data = browse_directory(params.vault, params.path)
|
|
return [
|
|
{"name": item["name"], "path": item["path"], "type": item["type"]}
|
|
for item in data["items"]
|
|
]
|
|
|
|
|
|
@tool(
|
|
name="list_all_files",
|
|
description="List every file of a vault (optionally under a subdirectory), newest first.",
|
|
input_model=ListAllFilesInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def list_all_files_tool(ctx: ToolContext, params: ListAllFilesInput) -> dict[str, Any]:
|
|
"""Return a flat list of files with metadata."""
|
|
return list_all_files(params.vault, dir=params.dir, limit=params.limit, recursive=params.recursive)
|
|
|
|
|
|
# ── C2. Content reading ────────────────────────────────────────────────────
|
|
|
|
|
|
@tool(
|
|
name="read_file",
|
|
description="Read the text content of a file inside a vault (secrets redacted).",
|
|
input_model=ReadFileInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def read_file(ctx: ToolContext, params: ReadFileInput) -> dict[str, Any]:
|
|
"""Return the (redacted) content of a vault file."""
|
|
return read_file_text(
|
|
params.vault,
|
|
params.path,
|
|
redact=True,
|
|
max_bytes=TOOL_MAX_READ_BYTES,
|
|
)
|
|
|
|
|
|
@tool(
|
|
name="read_file_raw",
|
|
description="Read a file's raw text content, without the size cap (secrets redacted).",
|
|
input_model=ReadFileRawInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def read_file_raw(ctx: ToolContext, params: ReadFileRawInput) -> dict[str, Any]:
|
|
"""Return the full (redacted) raw text of a vault file."""
|
|
data = read_file_text(params.vault, params.path, redact=True, max_bytes=None)
|
|
return {"vault": data["vault"], "path": data["path"], "raw": data["content"]}
|
|
|
|
|
|
@tool(
|
|
name="get_backlinks",
|
|
description="List files that link to a target file via [[wikilinks]].",
|
|
input_model=GetBacklinksInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def get_backlinks(ctx: ToolContext, params: GetBacklinksInput) -> list[dict[str, Any]]:
|
|
"""Return backlinks, filtered to accessible vaults."""
|
|
backlinks = _get_backlinks(params.vault, params.path)
|
|
return [b for b in backlinks if ctx.has_vault_access(b["vault"])]
|
|
|
|
|
|
@tool(
|
|
name="list_backups",
|
|
description="List the available backup versions of a file (newest first).",
|
|
input_model=ListBackupsInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def list_backups(ctx: ToolContext, params: ListBackupsInput) -> dict[str, Any]:
|
|
"""Return the backup versions of a vault file."""
|
|
return {
|
|
"vault": params.vault,
|
|
"path": params.path,
|
|
"backups": _list_backup_files(params.vault, params.path),
|
|
}
|
|
|
|
|
|
@tool(
|
|
name="diff_backup",
|
|
description="Show a unified diff between a backup version and another version or the current file.",
|
|
input_model=DiffBackupInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def diff_backup(ctx: ToolContext, params: DiffBackupInput) -> dict[str, Any]:
|
|
"""Return the unified diff for a backup version."""
|
|
return _diff_backup(params.vault, params.path, params.version, params.compare_with)
|
|
|
|
|
|
@tool(
|
|
name="get_graph",
|
|
description="Return the graph (nodes and edges: parent/child + wikilinks) of a vault or directory.",
|
|
input_model=GetGraphInput,
|
|
risk=ToolRisk.READ,
|
|
requires_vault=True,
|
|
)
|
|
def get_graph(ctx: ToolContext, params: GetGraphInput) -> dict[str, Any]:
|
|
"""Return graph data for a vault subtree or the whole vault."""
|
|
return _get_graph(
|
|
params.vault,
|
|
path=params.path,
|
|
depth=params.depth,
|
|
scope=params.scope,
|
|
tag=params.tag,
|
|
)
|
|
|
|
|
|
# ── C3. Search ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
@tool(
|
|
name="search_fulltext",
|
|
description="Full-text search across one vault or all accessible vaults.",
|
|
input_model=SearchFulltextInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def search_fulltext(ctx: ToolContext, params: SearchFulltextInput) -> list[dict[str, Any]]:
|
|
"""Return ranked search results, filtered to accessible vaults."""
|
|
payload = search_vaults(params.q, vault=params.vault, tag=params.tag, limit=params.limit)
|
|
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
|
|
|
|
|
@tool(
|
|
name="search_advanced",
|
|
description="Advanced full-text search with operators, filters and facets.",
|
|
input_model=SearchAdvancedInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def search_advanced(ctx: ToolContext, params: SearchAdvancedInput) -> list[dict[str, Any]]:
|
|
"""Return advanced search results, filtered to accessible vaults."""
|
|
payload = advanced_search_vaults(
|
|
params.q,
|
|
vault=params.vault,
|
|
tag=params.tag,
|
|
limit=params.limit,
|
|
offset=params.offset,
|
|
sort=params.sort,
|
|
case_sensitive=params.case_sensitive,
|
|
whole_word=params.whole_word,
|
|
regex=params.regex,
|
|
include_paths=params.include_paths,
|
|
exclude_paths=params.exclude_paths,
|
|
created=params.created,
|
|
modified=params.modified,
|
|
size=params.size,
|
|
)
|
|
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
|
|
|
|
|
@tool(
|
|
name="search_paths",
|
|
description="Search files and directories by path substring.",
|
|
input_model=SearchPathsInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def search_paths(ctx: ToolContext, params: SearchPathsInput) -> list[dict[str, Any]]:
|
|
"""Return path matches, filtered to accessible vaults."""
|
|
payload = _search_paths(params.q, vault=params.vault)
|
|
return [r for r in payload["results"] if ctx.has_vault_access(r["vault"])]
|
|
|
|
|
|
@tool(
|
|
name="list_tags",
|
|
description="List tags with their occurrence counts for a vault or all accessible vaults.",
|
|
input_model=ListTagsInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def list_tags(ctx: ToolContext, params: ListTagsInput) -> list[dict[str, Any]]:
|
|
"""Return tags sorted by descending count."""
|
|
if params.vault and params.vault != "all":
|
|
ctx.require_vault_access(params.vault)
|
|
return [{"tag": tag, "count": count} for tag, count in _list_tags(params.vault).items()]
|
|
|
|
merged: dict[str, int] = {}
|
|
for name in get_vault_names():
|
|
if not ctx.has_vault_access(name):
|
|
continue
|
|
for tag, count in _list_tags(name).items():
|
|
merged[tag] = merged.get(tag, 0) + count
|
|
return [{"tag": tag, "count": count} for tag, count in sorted(merged.items(), key=lambda x: -x[1])]
|
|
|
|
|
|
@tool(
|
|
name="suggest_tags",
|
|
description="Suggest tags matching a prefix, across accessible vaults.",
|
|
input_model=SuggestTagsInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def suggest_tags(ctx: ToolContext, params: SuggestTagsInput) -> list[dict[str, Any]]:
|
|
"""Return tag suggestions, restricted to accessible vaults."""
|
|
from backend.search import suggest_tags as _suggest
|
|
|
|
if params.vault and params.vault != "all":
|
|
ctx.require_vault_access(params.vault)
|
|
return _suggest(params.q, vault_filter=params.vault, limit=params.limit)
|
|
|
|
merged: dict[str, int] = {}
|
|
for name in get_vault_names():
|
|
if not ctx.has_vault_access(name):
|
|
continue
|
|
for item in _suggest(params.q, vault_filter=name, limit=params.limit):
|
|
merged[item["tag"]] = merged.get(item["tag"], 0) + item["count"]
|
|
ordered = sorted(merged.items(), key=lambda x: -x[1])
|
|
return [{"tag": tag, "count": count} for tag, count in ordered[: params.limit]]
|
|
|
|
|
|
@tool(
|
|
name="list_recent",
|
|
description="List the current user's recently opened (or modified) files.",
|
|
input_model=ListRecentInput,
|
|
risk=ToolRisk.READ,
|
|
)
|
|
def list_recent(ctx: ToolContext, params: ListRecentInput) -> dict[str, Any]:
|
|
"""Return recent files for the calling user."""
|
|
user_vaults = ctx.user.get("_token_vaults") or ctx.user.get("vaults", [])
|
|
return _list_recent(
|
|
ctx.username,
|
|
user_vaults,
|
|
vault=params.vault,
|
|
limit=params.limit,
|
|
mode=params.mode,
|
|
)
|
|
|
|
|
|
# ── D. Mutations ───────────────────────────────────────────────────────────
|
|
|
|
|
|
@tool(
|
|
name="create_file",
|
|
description=(
|
|
"Create a new text file in a vault with optional initial content. "
|
|
"Parent directories are created automatically, so a single call with a "
|
|
"nested path (e.g. 'Folder/note.md') is enough to create a file inside "
|
|
"a new folder."
|
|
),
|
|
input_model=CreateFileInput,
|
|
risk=ToolRisk.WRITE,
|
|
requires_vault=True,
|
|
)
|
|
def create_file(ctx: ToolContext, params: CreateFileInput) -> dict[str, Any]:
|
|
"""Create a vault file (fails if it already exists)."""
|
|
return _create_file(params.vault, params.path, params.content)
|
|
|
|
|
|
@tool(
|
|
name="create_directory",
|
|
description=(
|
|
"Create a new directory (and parents) in a vault. Succeeds if it "
|
|
"already exists. Optional when creating a file: create_file already "
|
|
"creates parent directories."
|
|
),
|
|
input_model=CreateDirectoryInput,
|
|
risk=ToolRisk.WRITE,
|
|
requires_vault=True,
|
|
)
|
|
def create_directory(ctx: ToolContext, params: CreateDirectoryInput) -> dict[str, Any]:
|
|
"""Create a vault directory (idempotent)."""
|
|
return _create_directory(params.vault, params.path, exist_ok=True)
|
|
|
|
|
|
@tool(
|
|
name="edit_file",
|
|
description="Overwrite the full content of an existing file (automatic backup).",
|
|
input_model=EditFileInput,
|
|
risk=ToolRisk.WRITE,
|
|
requires_vault=True,
|
|
)
|
|
def edit_file(ctx: ToolContext, params: EditFileInput) -> dict[str, Any]:
|
|
"""Replace a vault file's content."""
|
|
return _edit_file(params.vault, params.path, params.content)
|
|
|
|
|
|
@tool(
|
|
name="append_to_file",
|
|
description="Append text to the end of an existing file (automatic backup).",
|
|
input_model=AppendToFileInput,
|
|
risk=ToolRisk.WRITE,
|
|
requires_vault=True,
|
|
)
|
|
def append_to_file(ctx: ToolContext, params: AppendToFileInput) -> dict[str, Any]:
|
|
"""Append content to a vault file."""
|
|
return _append_to_file(params.vault, params.path, params.content)
|
|
|
|
|
|
@tool(
|
|
name="rename_file",
|
|
description="Rename a file in place (same parent directory). Destructive: requires confirmation.",
|
|
input_model=RenameFileInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
requires_vault=True,
|
|
)
|
|
def rename_file(ctx: ToolContext, params: RenameFileInput) -> dict[str, Any]:
|
|
"""Rename a vault file."""
|
|
return _rename_file(params.vault, params.path, params.new_name)
|
|
|
|
|
|
@tool(
|
|
name="rename_directory",
|
|
description="Rename a directory in place (same parent directory). Destructive: requires confirmation.",
|
|
input_model=RenameDirectoryInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
requires_vault=True,
|
|
)
|
|
def rename_directory(ctx: ToolContext, params: RenameDirectoryInput) -> dict[str, Any]:
|
|
"""Rename a vault directory."""
|
|
return _rename_directory(params.vault, params.path, params.new_name)
|
|
|
|
|
|
@tool(
|
|
name="move_path",
|
|
description="Move a file or directory to another directory in the same vault. Destructive: requires confirmation.",
|
|
input_model=MovePathInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
requires_vault=True,
|
|
)
|
|
def move_path(ctx: ToolContext, params: MovePathInput) -> dict[str, Any]:
|
|
"""Move a vault file or directory."""
|
|
return _move_path(params.vault, params.source_path, params.destination_dir)
|
|
|
|
|
|
@tool(
|
|
name="replace_in_files",
|
|
description=(
|
|
"Find and replace text across vault files. Previews by default "
|
|
"(dry_run); set replace_all=true to apply. Destructive: requires confirmation."
|
|
),
|
|
input_model=ReplaceInFilesInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
)
|
|
def replace_in_files(ctx: ToolContext, params: ReplaceInFilesInput) -> dict[str, Any]:
|
|
"""Preview or apply a find/replace, filtered to permitted vaults."""
|
|
if params.vault and params.vault != "all":
|
|
ctx.require_vault_access(params.vault)
|
|
ctx.require_destructive_allowed(params.vault)
|
|
|
|
dry_run = params.dry_run if params.dry_run is not None else not params.replace_all
|
|
|
|
def _allowed(vault: str) -> bool:
|
|
return ctx.has_vault_access(vault) and ctx.destructive_tools_enabled(vault)
|
|
|
|
return _replace_in_files(
|
|
params.find,
|
|
params.replace,
|
|
vault=params.vault,
|
|
case_sensitive=params.case_sensitive,
|
|
whole_word=params.whole_word,
|
|
regex=params.regex,
|
|
include_paths=params.include_paths,
|
|
exclude_paths=params.exclude_paths,
|
|
replace_all=params.replace_all,
|
|
dry_run=dry_run,
|
|
is_vault_allowed=_allowed,
|
|
)
|
|
|
|
|
|
@tool(
|
|
name="delete_file",
|
|
description="Delete a file from a vault (automatic backup). Destructive: requires confirmation.",
|
|
input_model=DeleteFileInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
requires_vault=True,
|
|
)
|
|
def delete_file(ctx: ToolContext, params: DeleteFileInput) -> dict[str, Any]:
|
|
"""Delete a vault file."""
|
|
return _delete_file(params.vault, params.path)
|
|
|
|
|
|
@tool(
|
|
name="delete_directory",
|
|
description="Delete a directory (recursive by default) from a vault. Destructive: requires confirmation.",
|
|
input_model=DeleteDirectoryInput,
|
|
risk=ToolRisk.DANGEROUS,
|
|
requires_vault=True,
|
|
)
|
|
def delete_directory(ctx: ToolContext, params: DeleteDirectoryInput) -> dict[str, Any]:
|
|
"""Delete a vault directory."""
|
|
return _delete_directory(params.vault, params.path, recursive=params.recursive)
|
|
|
|
|
|
@tool(
|
|
name="restore_backup",
|
|
description="Restore a file from one of its backup versions (current version backed up first).",
|
|
input_model=RestoreBackupInput,
|
|
risk=ToolRisk.WRITE,
|
|
requires_vault=True,
|
|
)
|
|
def restore_backup(ctx: ToolContext, params: RestoreBackupInput) -> dict[str, Any]:
|
|
"""Restore a vault file from a backup."""
|
|
return _restore_backup(params.vault, params.path, params.version)
|