refactor(tools): decorator-based tool registry replaces monolithic tools.py

Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 10:01:00 -04:00
parent 5275be8588
commit ce2d76447c
15 changed files with 2129 additions and 2624 deletions
+384
View File
@@ -0,0 +1,384 @@
"""Note tools: create, update, get, list, delete, search."""
from __future__ import annotations
import logging
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.services.tools._helpers import (
_PUNCT_RE,
fuzzy_title_match,
parse_due_date,
resolve_project,
schedule_embedding,
)
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="create_note",
description="Create a new note for the user. Use this when the user asks you to write down, save, or record something as a note.",
parameters={
"title": {"type": "string", "description": "The note title"},
"body": {"type": "string", "description": "The note content in markdown"},
"tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags for the note (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the note body.'},
"project": {"type": "string", "description": "Optional project name to assign this note to. Only set this if the user explicitly named a project. Do NOT infer a project from the note content or context."},
"confirmed": {"type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing note."},
},
required=["title"],
)
async def create_note_tool(*, user_id, arguments, **_ctx):
note_title = arguments.get("title", "Untitled Note")
note_body = arguments.get("body", "")
note_tags = arguments.get("tags", [])
if not isinstance(note_title, str):
return {"success": False, "error": "title must be a string. Call create_note once per note."}
if not isinstance(note_body, str):
note_body = ""
if not isinstance(note_tags, list):
note_tags = []
project_name = arguments.get("project")
project_id = None
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
project_id = proj.id
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
exact = next((n for n in existing_notes if n.title.lower() == note_title.lower()), None)
if exact is not None:
return {"success": False, "error": f"A note titled '{note_title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate."}
clean_q = _PUNCT_RE.sub(" ", note_title).strip()
candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=False, limit=20)
near, ratio = fuzzy_title_match(note_title, candidates)
if near is not None:
return {"success": False, "requires_confirmation": True, "similar_note": {"id": near.id, "title": near.title}, "error": f"A note with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry."}
if not arguments.get("confirmed") and len(note_body.strip()) >= 80:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_query = f"{note_title}\n{note_body}".strip()
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=False)
if sem_hits:
best_score, best_note = sem_hits[0]
return {"success": False, "requires_confirmation": True, "similar_note": {"id": best_note.id, "title": best_note.title}, "error": f"A note with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry."}
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=note_tags,
project_id=project_id,
)
suggested = await suggest_tags(user_id, note_title, note_body)
schedule_embedding(note.id, user_id, note_title, note_body)
return {
"success": True,
"type": "note",
"data": {"id": note.id, "title": note.title, "project_id": note.project_id},
"suggested_tags": suggested,
}
@tool(
name="update_note",
description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the note or task to update"},
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields)"},
"title": {"type": "string", "description": "Optional new title"},
"mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "New task priority"},
"due_date": {"type": "string", "description": "New due date in YYYY-MM-DD format"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply (see tag_mode for how they're applied)"},
"tag_mode": {"type": "string", "enum": ["add", "remove", "replace"], "description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)"},
"project": {"type": "string", "description": "Optional project name to assign this note/task to (set to empty string to remove project)"},
"milestone": {"type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)"},
"recurrence_rule": {"type": "object", "description": "Set or update recurrence (same format as create_task). Pass null to remove."},
},
required=["query"],
)
async def update_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
new_body = arguments.get("body", "")
new_title = arguments.get("title")
mode = arguments.get("mode", "replace")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
note_only = [n for n in notes if n.status is None]
candidates = note_only or notes
if not candidates:
return {"success": False, "error": f"No note found matching '{query}'."}
note = candidates[0]
update_fields: dict = {}
if new_title:
update_fields["title"] = new_title
if new_body:
if mode == "append" and note.body:
update_fields["body"] = note.body + "\n\n" + new_body
else:
update_fields["body"] = new_body
if "status" in arguments:
update_fields["status"] = arguments["status"]
if "priority" in arguments:
update_fields["priority"] = arguments["priority"]
if "due_date" in arguments:
update_fields["due_date"] = parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "add")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
update_fields["project_id"] = proj.id
else:
update_fields["project_id"] = None
update_fields["milestone_id"] = None
if "milestone" in arguments:
milestone_name = arguments["milestone"]
if milestone_name:
ref_project_id = update_fields.get("project_id") or note.project_id
if ref_project_id is None:
return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."}
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd
ms = await _gmbt_upd(user_id, ref_project_id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."}
update_fields["milestone_id"] = ms.id
else:
update_fields["milestone_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
item_type = "task" if updated.status is not None else "note"
return {
"success": True,
"type": "note_updated",
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
"data": {
"id": updated.id,
"title": updated.title,
"item_type": item_type,
"status": updated.status,
"tags": updated.tags or [],
"project_id": updated.project_id,
},
"suggested_tags": suggested,
}
@tool(
name="search_notes",
description="Search notes and tasks by semantic meaning — finds content by concept or theme even when exact keywords don't appear.",
parameters={
"query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"},
"type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."},
"project": {"type": "string", "description": "Optional project name to restrict search to notes in that project"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_notes_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
type_filter = arguments.get("type")
project_name = arguments.get("project")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
search_project_id: int | None = None
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
search_project_id = proj.id
results_map: dict[int, dict] = {}
try:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_results = await _ssn(
user_id, query, limit=8, threshold=0.40,
project_id=search_project_id,
)
for score, n in sem_results:
n_is_task = n.status is not None
if is_task is True and not n_is_task:
continue
if is_task is False and n_is_task:
continue
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n_is_task else "note",
"status": n.status,
"relevance": f"{round(score * 100)}%",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
logger.debug("search_notes semantic: %d results for %r", len(results_map), query)
except Exception:
logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True)
try:
kw_notes, _ = await list_notes(
user_id=user_id, q=query, is_task=is_task,
project_id=search_project_id, limit=8,
)
for n in kw_notes:
if n.id not in results_map:
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n.status is not None else "note",
"status": n.status,
"relevance": "keyword",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
except Exception:
logger.warning("Keyword fallback in search_notes failed", exc_info=True)
results = list(results_map.values())[:8]
return {
"success": True,
"type": "search",
"data": {"query": query, "count": len(results), "results": results},
}
@tool(
name="get_note",
description="Retrieve the full content of a specific note or task. Use this when the user asks to read, view, or check what a particular note or task says. Returns the complete body.",
parameters={
"query": {"type": "string", "description": "Title or keyword to identify the note"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def get_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {"id": note.id, "title": note.title, "body": note.body or "", "tags": note.tags or []},
}
@tool(
name="list_notes",
description="Browse the user's notes (not tasks). Use this when the user asks to see, list, or browse their notes — by recency, keyword, tag, or project. For tasks, use list_tasks instead.",
parameters={
"q": {"type": "string", "description": "Optional keyword filter"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Filter notes that have ALL of these tags"},
"project": {"type": "string", "description": "Filter notes by project name"},
"sort": {"type": "string", "enum": ["updated_at", "created_at", "title"], "description": "Sort field (default: updated_at)"},
"limit": {"type": "integer", "description": "Maximum number of notes to return (default 10)"},
},
read_only=True,
briefing=True,
)
async def list_notes_tool(*, user_id, arguments, **_ctx):
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found."}
project_id = proj.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
project_id=project_id,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"updated_at": n.updated_at.isoformat(),
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {"total": total, "count": len(results), "results": results},
}
@tool(
name="delete_note",
description="Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. Always confirm with the user first — this cannot be undone.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the note to delete"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this note deleted."},
},
required=["query"],
)
async def delete_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, is_task=False, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
if note.status is not None:
return {"success": False, "error": f"'{note.title}' is a task. Use delete_task instead."}
if not arguments.get("confirmed"):
return {
"success": False,
"requires_confirmation": True,
"error": f"Deleting '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.",
}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete note."}
return {"success": True, "type": "note_deleted", "data": {"id": note.id, "title": note.title}}