Add persistent context sidebar, note title fix, and expanded tool suite

Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
  auto-found notes accumulate across turns; attached note shows with pin icon;
  × button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
  context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
  included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'

Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
  type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 14:40:34 -05:00
parent d6f4a6dbb6
commit 32e4ee12f2
13 changed files with 578 additions and 187 deletions
+264 -2
View File
@@ -15,10 +15,11 @@ from fabledassistant.services.caldav import (
list_events,
list_todos,
search_events,
search_todos,
update_event,
update_todo,
)
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
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
logger = logging.getLogger(__name__)
@@ -132,6 +133,16 @@ _CORE_TOOLS = [
"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": ["replace", "add", "remove"],
"description": "'replace' sets tags to exactly this list, 'add' appends without duplicates, 'remove' removes listed tags (default: replace)",
},
},
"required": ["query"],
},
@@ -149,6 +160,104 @@ _CORE_TOOLS = [
"type": "string",
"description": "Search query to find notes/tasks",
},
"type": {
"type": "string",
"enum": ["note", "task"],
"description": "Restrict results to only notes or only tasks. Omit to search both.",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "get_note",
"description": (
"Retrieve the full content of a specific note. Use this when the user asks to read, "
"view, or check what a particular note says. Returns the complete note body."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to identify the note",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"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, or tag. For tasks, use list_tasks instead."
),
"parameters": {
"type": "object",
"properties": {
"q": {
"type": "string",
"description": "Optional keyword filter",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Filter notes that have ALL of these tags",
},
"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)",
},
},
},
},
},
{
"type": "function",
"function": {
"name": "delete_note",
"description": (
"Delete a note permanently. Use ONLY when the user explicitly asks to delete or remove a note. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note to delete",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_task",
"description": (
"Delete a task permanently. Use ONLY when the user explicitly asks to delete or remove a task. "
"This action requires user confirmation and cannot be undone."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the task to delete",
},
},
"required": ["query"],
},
@@ -518,6 +627,34 @@ _CALDAV_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "search_todos",
"description": (
"Search CalDAV todos by keyword. Use this when the user asks to find a specific calendar todo "
"by name or content, rather than listing all todos."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Keyword to search for in todo summary or description",
},
"include_completed": {
"type": "boolean",
"description": "Include completed todos in search (default false)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to restrict search",
},
},
"required": ["query"],
},
},
},
]
@@ -622,6 +759,21 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
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", "replace")
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
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
@@ -673,7 +825,13 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "search_notes":
query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
type_filter = arguments.get("type")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
notes, total = await list_notes(user_id=user_id, q=query, is_task=is_task, limit=5)
results = []
for n in notes:
results.append({
@@ -859,6 +1017,110 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"data": result,
}
elif tool_name == "search_todos":
results = await search_todos(
user_id=user_id,
query=arguments.get("query", ""),
include_completed=bool(arguments.get("include_completed", False)),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "todos",
"data": {
"count": len(results),
"todos": results,
},
}
elif tool_name == "get_note":
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 [],
},
}
elif tool_name == "list_notes":
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
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 [],
"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,
},
}
elif tool_name == "delete_note":
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."}
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},
}
elif tool_name == "delete_task":
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=True, limit=3)
if not notes:
return {"success": False, "error": f"No task found matching '{query}'."}
note = notes[0]
if note.status is None:
return {"success": False, "error": f"'{note.title}' is a note. Use delete_note instead."}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete task."}
return {
"success": True,
"type": "task_deleted",
"data": {"id": note.id, "title": note.title},
}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}