Files
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

362 lines
13 KiB
Python

"""Obsidian tools — the seam between the chat loop and a connected vault.
Nine tools auto-mounted only when an Obsidian vault is the selected KB (via
:class:`~deeptutor.capabilities.obsidian.capability.ObsidianCapability`, which
runs the turn exclusively on these tools). Six read the vault (navigate links,
search, read notes, list tags) and three write it additively (create / append /
set a property). Every tool is a thin wrapper over the pure
:mod:`deeptutor.capabilities.obsidian.vault` operations.
The vault root is injected server-side as ``_vault_path`` by the capability's
``augment_kwargs`` (resolved from the selected KB's ``vault_path``); the model
never supplies or sees it, so it cannot read or write outside the vault.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from deeptutor.capabilities.obsidian import vault as vault_ops
from deeptutor.core.tool_protocol import BaseTool, ToolDefinition, ToolParameter, ToolResult
# Tool names mounted together when an Obsidian turn is active. Single source of
# truth so the mount policy and the registration list can't disagree.
OBSIDIAN_TOOL_NAMES: tuple[str, ...] = (
"obsidian_search",
"obsidian_read",
"obsidian_list",
"obsidian_backlinks",
"obsidian_links",
"obsidian_tags",
"obsidian_create_note",
"obsidian_append",
"obsidian_set_property",
)
def _vault_root(kwargs: dict[str, Any]) -> Path | None:
raw = str(kwargs.get("_vault_path") or "").strip()
if not raw:
return None
root = Path(raw)
return root if root.is_dir() else None
def _no_vault_result() -> ToolResult:
return ToolResult(
content="No Obsidian vault is connected on this turn; Obsidian tools are unavailable.",
success=False,
)
def _ok(payload: Any) -> ToolResult:
return ToolResult(content=json.dumps(payload, ensure_ascii=False), success=True)
def _err(message: str) -> ToolResult:
return ToolResult(content=message, success=False)
class _ObsidianTool(BaseTool):
"""Shared vault-root resolution + uniform error handling for Obsidian tools."""
async def execute(self, **kwargs: Any) -> ToolResult:
root = _vault_root(kwargs)
if root is None:
return _no_vault_result()
try:
return await self._run(root, kwargs)
except vault_ops.VaultError as exc:
return _err(str(exc))
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult: # pragma: no cover
raise NotImplementedError
class ObsidianSearchTool(_ObsidianTool):
"""Full-text search across the vault's notes."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_search",
description=(
"Search the user's Obsidian vault for notes whose title or body "
"contains the query (case-insensitive). Returns matching note "
"paths with a short snippet. Use this first to find where "
"something lives, then obsidian_read the promising notes."
),
parameters=[
ToolParameter(name="query", type="string", description="Text to search for."),
ToolParameter(
name="limit",
type="integer",
description="Max results (default 20).",
required=False,
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
query = str(kwargs.get("query") or "").strip()
if not query:
return _err("obsidian_search needs a non-empty 'query'.")
limit = _as_int(kwargs.get("limit"), default=20, lo=1, hi=100)
hits = vault_ops.search_notes(root, query, limit=limit)
return _ok({"query": query, "count": len(hits), "results": hits})
class ObsidianReadTool(_ObsidianTool):
"""Read a note's frontmatter and body."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_read",
description=(
"Read a note from the vault. Accepts a bare note name (resolved "
"like a [[wikilink]]) or a vault-relative path. Returns the "
"note's frontmatter properties and Markdown body."
),
parameters=[
ToolParameter(
name="note",
type="string",
description="Note name (e.g. 'Project Plan') or path (e.g. 'work/Plan.md').",
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
ref = str(kwargs.get("note") or "").strip()
if not ref:
return _err("obsidian_read needs a 'note' name or path.")
return _ok(vault_ops.read_note(root, ref))
class ObsidianListTool(_ObsidianTool):
"""List notes in the vault or a folder."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_list",
description=(
"List note paths in the vault, optionally restricted to a folder. "
"Use to discover structure when you don't have a search term."
),
parameters=[
ToolParameter(
name="folder",
type="string",
description="Vault-relative folder to list (empty = whole vault).",
required=False,
),
ToolParameter(
name="limit",
type="integer",
description="Max paths (default 200).",
required=False,
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
folder = str(kwargs.get("folder") or "").strip()
limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
paths = vault_ops.list_notes(root, folder=folder, limit=limit)
return _ok({"folder": folder, "count": len(paths), "notes": paths})
class ObsidianBacklinksTool(_ObsidianTool):
"""Find notes that link to a given note."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_backlinks",
description=(
"Find notes that link TO the given note via [[wikilink]]. The "
"backbone of vault navigation — follow backlinks to discover how "
"ideas connect."
),
parameters=[
ToolParameter(name="note", type="string", description="Note name or path."),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
ref = str(kwargs.get("note") or "").strip()
if not ref:
return _err("obsidian_backlinks needs a 'note' name or path.")
links = vault_ops.backlinks(root, ref)
return _ok({"note": ref, "count": len(links), "backlinks": links})
class ObsidianLinksTool(_ObsidianTool):
"""List the outgoing wikilinks of a note."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_links",
description=(
"List the notes a given note links OUT to via [[wikilink]], in "
"order. Pair with obsidian_backlinks to traverse the graph both ways."
),
parameters=[
ToolParameter(name="note", type="string", description="Note name or path."),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
ref = str(kwargs.get("note") or "").strip()
if not ref:
return _err("obsidian_links needs a 'note' name or path.")
links = vault_ops.outgoing_links(root, ref)
return _ok({"note": ref, "count": len(links), "links": links})
class ObsidianTagsTool(_ObsidianTool):
"""List the vault's tags by frequency."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_tags",
description=(
"List all tags used across the vault (inline #tags and frontmatter "
"tags), ranked by how many notes use each. Use to map the vault's "
"topics before drilling in."
),
parameters=[
ToolParameter(
name="limit",
type="integer",
description="Max tags (default 200).",
required=False,
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
limit = _as_int(kwargs.get("limit"), default=200, lo=1, hi=1000)
tags = vault_ops.collect_tags(root, limit=limit)
return _ok({"count": len(tags), "tags": tags})
class ObsidianCreateNoteTool(_ObsidianTool):
"""Create a new note (never overwrites)."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_create_note",
description=(
"Create a NEW note in the vault. Fails if the path already exists "
"(use obsidian_append for existing notes). Write valid Obsidian "
"Flavored Markdown: [[wikilinks]] for vault notes, > [!note] "
"callouts, ![[embeds]]. Pass structured metadata as 'properties' "
"(frontmatter), not inline."
),
parameters=[
ToolParameter(
name="path",
type="string",
description="Vault-relative path, e.g. 'Summaries/Photosynthesis.md'.",
),
ToolParameter(name="content", type="string", description="Markdown body."),
ToolParameter(
name="properties",
type="object",
description="Optional frontmatter properties (tags, aliases, …).",
required=False,
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
path = str(kwargs.get("path") or "").strip()
if not path:
return _err("obsidian_create_note needs a 'path'.")
properties = kwargs.get("properties")
frontmatter = properties if isinstance(properties, dict) else None
created = vault_ops.create_note(
root, path, str(kwargs.get("content") or ""), frontmatter=frontmatter
)
return _ok({"status": "created", "path": created})
class ObsidianAppendTool(_ObsidianTool):
"""Append to an existing note."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_append",
description=(
"Append Markdown to the end of an existing note. Use for adding to "
"a daily note, log, or running document without rewriting it."
),
parameters=[
ToolParameter(name="note", type="string", description="Note name or path."),
ToolParameter(name="content", type="string", description="Markdown to append."),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
ref = str(kwargs.get("note") or "").strip()
if not ref:
return _err("obsidian_append needs a 'note' name or path.")
updated = vault_ops.append_note(root, ref, str(kwargs.get("content") or ""))
return _ok({"status": "appended", "path": updated})
class ObsidianSetPropertyTool(_ObsidianTool):
"""Set a frontmatter property on an existing note."""
def get_definition(self) -> ToolDefinition:
return ToolDefinition(
name="obsidian_set_property",
description=(
"Set a single frontmatter property (key/value) on an existing "
"note, e.g. status, due date, or a tag list. Creates the "
"frontmatter block if absent; leaves the body untouched."
),
parameters=[
ToolParameter(name="note", type="string", description="Note name or path."),
ToolParameter(name="key", type="string", description="Property name."),
ToolParameter(
name="value",
type="string",
description="Property value (string, or a comma-free scalar).",
),
],
)
async def _run(self, root: Path, kwargs: dict[str, Any]) -> ToolResult:
ref = str(kwargs.get("note") or "").strip()
key = str(kwargs.get("key") or "").strip()
if not ref or not key:
return _err("obsidian_set_property needs 'note' and 'key'.")
updated = vault_ops.set_property(root, ref, key, kwargs.get("value"))
return _ok({"status": "updated", "path": updated, "key": key})
def _as_int(value: Any, *, default: int, lo: int, hi: int) -> int:
try:
out = int(value)
except (TypeError, ValueError):
return default
return max(lo, min(hi, out))
OBSIDIAN_TOOL_TYPES: tuple[type[BaseTool], ...] = (
ObsidianSearchTool,
ObsidianReadTool,
ObsidianListTool,
ObsidianBacklinksTool,
ObsidianLinksTool,
ObsidianTagsTool,
ObsidianCreateNoteTool,
ObsidianAppendTool,
ObsidianSetPropertyTool,
)
__all__ = ["OBSIDIAN_TOOL_NAMES", "OBSIDIAN_TOOL_TYPES"]