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:
@@ -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}}
|
||||
Reference in New Issue
Block a user