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:
@@ -23,6 +23,7 @@ from fabledassistant.services.generation_buffer import (
|
||||
get_buffer,
|
||||
)
|
||||
from fabledassistant.services.generation_task import run_generation
|
||||
from fabledassistant.services.notes import get_notes_by_ids
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,7 +63,14 @@ async def get_conversation_route(conv_id: int):
|
||||
if conv is None:
|
||||
return jsonify({"error": "Conversation not found"}), 404
|
||||
result = conv.to_dict()
|
||||
result["messages"] = [m.to_dict() for m in conv.messages]
|
||||
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
|
||||
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
|
||||
result["messages"] = []
|
||||
for m in conv.messages:
|
||||
msg_dict = m.to_dict()
|
||||
if m.context_note_id and m.context_note_id in note_map:
|
||||
msg_dict["context_note_title"] = note_map[m.context_note_id].title
|
||||
result["messages"].append(msg_dict)
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
|
||||
@@ -530,6 +530,21 @@ async def list_todos(
|
||||
return await asyncio.to_thread(_list)
|
||||
|
||||
|
||||
async def search_todos(
|
||||
user_id: int,
|
||||
query: str,
|
||||
include_completed: bool = False,
|
||||
calendar_name: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Search CalDAV todos by keyword in summary or description."""
|
||||
todos = await list_todos(user_id, include_completed=include_completed, calendar_name=calendar_name)
|
||||
q = query.lower()
|
||||
return [
|
||||
t for t in todos
|
||||
if q in t.get("summary", "").lower() or q in (t.get("description") or "").lower()
|
||||
]
|
||||
|
||||
|
||||
async def complete_todo(
|
||||
user_id: int,
|
||||
query: str,
|
||||
|
||||
@@ -41,6 +41,10 @@ _TOOL_LABELS: dict[str, str] = {
|
||||
"create_task": "Creating task",
|
||||
"create_note": "Creating note",
|
||||
"update_note": "Updating note",
|
||||
"delete_note": "Deleting note",
|
||||
"delete_task": "Deleting task",
|
||||
"get_note": "Reading note",
|
||||
"list_notes": "Listing notes",
|
||||
"list_tasks": "Searching tasks",
|
||||
"search_notes": "Searching notes",
|
||||
"create_event": "Creating calendar event",
|
||||
@@ -51,6 +55,7 @@ _TOOL_LABELS: dict[str, str] = {
|
||||
"list_calendars": "Listing calendars",
|
||||
"create_todo": "Creating todo",
|
||||
"list_todos": "Listing todos",
|
||||
"search_todos": "Searching todos",
|
||||
"update_todo": "Updating todo",
|
||||
"complete_todo": "Completing todo",
|
||||
"delete_todo": "Removing todo",
|
||||
@@ -58,7 +63,7 @@ _TOOL_LABELS: dict[str, str] = {
|
||||
|
||||
# Tools that write data and require explicit user confirmation before executing.
|
||||
_WRITE_TOOLS: frozenset[str] = frozenset({
|
||||
"create_task", "create_note", "update_note",
|
||||
"create_task", "create_note", "update_note", "delete_note", "delete_task",
|
||||
"create_event", "update_event", "delete_event",
|
||||
"create_todo", "update_todo", "complete_todo", "delete_todo",
|
||||
})
|
||||
@@ -68,8 +73,13 @@ _TOOL_ACTIONS: dict[str, str] = {
|
||||
"create_task": "create a task",
|
||||
"create_note": "create a new note",
|
||||
"update_note": "update an existing note",
|
||||
"delete_note": "permanently delete a note",
|
||||
"delete_task": "permanently delete a task",
|
||||
"get_note": "read a note",
|
||||
"list_notes": "list notes",
|
||||
"list_tasks": "look up tasks",
|
||||
"search_notes": "search through notes",
|
||||
"search_todos": "search calendar todos",
|
||||
"create_event": "schedule a calendar event",
|
||||
"list_events": "check the calendar",
|
||||
"search_events": "search calendar events",
|
||||
@@ -351,7 +361,7 @@ async def run_generation(
|
||||
|
||||
# Invalidate the note context cache after any successful note write
|
||||
# so the next turn can pick up newly created/modified notes.
|
||||
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}:
|
||||
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
|
||||
clear_conv_note_cache(conv_id)
|
||||
|
||||
tool_record = {
|
||||
@@ -422,7 +432,7 @@ async def run_generation(
|
||||
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
|
||||
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
|
||||
|
||||
if result.get("success") and tool_name in {"create_task", "create_note", "update_note"}:
|
||||
if result.get("success") and tool_name in {"create_task", "create_note", "update_note", "delete_note", "delete_task"}:
|
||||
clear_conv_note_cache(conv_id)
|
||||
|
||||
tool_record = {
|
||||
|
||||
@@ -89,10 +89,16 @@ Rules:
|
||||
- "which calendars", "list calendars", "my calendars" → use list_calendars.
|
||||
- "create a calendar todo", "add a CalDAV todo" → use create_todo. Default to create_task for general tasks unless the user explicitly mentions CalDAV or calendar todo.
|
||||
- "show calendar todos", "list calendar todos" → use list_todos.
|
||||
- "find calendar todo", "search calendar todos", "find todo named X" → use search_todos with query=<keyword>.
|
||||
- "completed/finished calendar todo" → use complete_todo.
|
||||
- "delete/remove calendar todo" → use delete_todo.
|
||||
- "remind me X minutes/hours before" an event → convert to reminder_minutes parameter on create_event.
|
||||
- When the user asks about events in a time period (e.g. "events in September", "what's on next week", "my schedule for March"), use list_events with date_from/date_to covering that period. Do NOT use search_events for time-based queries — search_events is only for keyword matching against event titles.
|
||||
- "delete", "remove", "trash", "get rid of" a note (not a task) → use delete_note with query=<note name/keyword>. NEVER use this for tasks.
|
||||
- "delete", "remove", "trash", "get rid of" a task → use delete_task with query=<task name/keyword>. NEVER use this for notes.
|
||||
- "read", "open", "show me", "what does X say", "display", "pull up" a specific note → use get_note with query=<note name>.
|
||||
- "list my notes", "show notes", "recent notes", "browse notes", "notes tagged X" → use list_notes (with optional q or tags).
|
||||
- "tag X with Y", "add tag Y to X", "untag Y from X", "remove tag Y from X" → use update_note with tags=[Y] and tag_mode="add" or "remove".
|
||||
- Do NOT wrap the JSON in markdown code fences."""
|
||||
|
||||
|
||||
|
||||
@@ -324,23 +324,27 @@ async def build_context(
|
||||
tool_lines = [
|
||||
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
|
||||
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
|
||||
"Available actions: create_task, create_note, update_note, list_tasks, search_notes.",
|
||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes.",
|
||||
]
|
||||
if has_caldav:
|
||||
tool_lines[-1] = (
|
||||
"Available actions: create_task, create_note, update_note, list_tasks, search_notes, "
|
||||
"Available actions: create_task, create_note, update_note, delete_note, delete_task, get_note, list_notes, list_tasks, search_notes, "
|
||||
"create_event, list_events, search_events, update_event, delete_event, "
|
||||
"list_calendars, create_todo, list_todos, update_todo, complete_todo, delete_todo."
|
||||
"list_calendars, create_todo, list_todos, search_todos, update_todo, complete_todo, delete_todo."
|
||||
)
|
||||
tool_lines.append("For calendar events, use ISO 8601 datetime format (e.g. 2026-09-30T00:00:00).")
|
||||
tool_lines.append("CalDAV todos are separate from app tasks. Use create_task for app tasks, create_todo for CalDAV todos.")
|
||||
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
|
||||
tool_lines.append("Use search_todos to find a specific CalDAV todo by keyword when list_todos would return too many results.")
|
||||
tool_lines.append("For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format.")
|
||||
tool_lines.append(
|
||||
"Use update_note to edit/expand an existing note OR to update a task's status/priority/due_date. "
|
||||
"Use create_note ONLY for genuinely new notes with a different title. "
|
||||
"Use list_tasks to find tasks by status, priority, or due date (e.g. overdue, high priority, in progress). "
|
||||
"If a note was created earlier in the conversation and the user provides more content for it, use update_note."
|
||||
"If a note was created earlier in the conversation and the user provides more content for it, use update_note. "
|
||||
"Use get_note to read the full content of a specific note. "
|
||||
"Use list_notes to browse notes by recency or tag. "
|
||||
"Use delete_note / delete_task only when explicitly asked to delete — these require confirmation."
|
||||
)
|
||||
tool_guidance = "\n".join(tool_lines)
|
||||
|
||||
|
||||
@@ -250,6 +250,17 @@ async def search_notes_for_context(
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_notes_by_ids(user_id: int, note_ids: list[int]) -> dict[int, Note]:
|
||||
"""Batch fetch notes by ID list. Returns {note_id: Note}."""
|
||||
if not note_ids:
|
||||
return {}
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Note).where(Note.user_id == user_id, Note.id.in_(note_ids))
|
||||
)
|
||||
return {n.id: n for n in result.scalars().all()}
|
||||
|
||||
|
||||
async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
|
||||
note = await get_note(user_id, note_id)
|
||||
if note is None:
|
||||
|
||||
@@ -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}"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user