From 6f5170854b88f24034a05e17d3a4fdc79dadadf9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 1 Mar 2026 19:13:07 -0500 Subject: [PATCH] Remove CalDAV todo tools; overhaul quick-capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove all 6 CalDAV todo tools (create/list/update/complete/delete/search_todos) from tools.py definitions, imports, execute_tool branches, intent routing rules, generation_task labels/actions, and llm.py system prompt hints. CalDAV event tools remain. Todo functions still exist in caldav.py but are no longer exposed. - Quick-capture now uses a dedicated classify_capture_intent() with a focused _CAPTURE_SYSTEM_PROMPT that always routes to a tool (never null). Tool set expanded: create_note/task/event + update_note + research_topic. - research_topic in quick-capture calls run_research_pipeline() directly (no SSE buffer). run_research_pipeline() now accepts buf=None; all buf.append_event calls are guarded so status events are skipped when no buffer is provided. - Fallback note now always sets body=text (was empty for texts ≤80 chars). Co-Authored-By: Claude Sonnet 4.6 --- src/fabledassistant/routes/quick_capture.py | 47 ++- .../services/generation_task.py | 13 - src/fabledassistant/services/intent.py | 77 ++++- src/fabledassistant/services/llm.py | 5 +- src/fabledassistant/services/research.py | 19 +- src/fabledassistant/services/tools.py | 267 ------------------ summary.md | 12 +- 7 files changed, 122 insertions(+), 318 deletions(-) diff --git a/src/fabledassistant/routes/quick_capture.py b/src/fabledassistant/routes/quick_capture.py index e0c60ab..7de75c1 100644 --- a/src/fabledassistant/routes/quick_capture.py +++ b/src/fabledassistant/routes/quick_capture.py @@ -11,17 +11,16 @@ from quart import Blueprint, jsonify, request from fabledassistant.auth import get_current_user_id, login_required from fabledassistant.config import Config -from fabledassistant.services.intent import classify_intent +from fabledassistant.services.intent import classify_capture_intent from fabledassistant.services.tools import execute_tool, get_tools_for_user logger = logging.getLogger(__name__) quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture") -# Only creation tools are offered to the intent classifier here — no delete, -# update, search, or research. This keeps the fallback safe (worst case we -# create a plain note) and avoids accidental destructive actions. -_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "create_todo"} +# Tools offered to the quick-capture classifier. Excludes destructive ops +# (delete_*) and read-only queries — worst-case fallback is a plain note. +_CAPTURE_TOOL_NAMES = {"create_note", "create_task", "create_event", "update_note", "research_topic"} @quick_capture_bp.route("", methods=["POST"]) @@ -42,21 +41,43 @@ async def quick_capture_route(): t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES ] - intent = await classify_intent(text, capture_tools, intent_model) + intent = await classify_capture_intent(text, capture_tools, intent_model) if intent.should_execute: + # research_topic bypasses execute_tool — run the pipeline directly + if intent.tool_name == "research_topic" and Config.searxng_enabled(): + from fabledassistant.services.research import run_research_pipeline + + topic = intent.arguments.get("topic", text) + model = Config.OLLAMA_MODEL + try: + note = await run_research_pipeline(topic, uid, model, intent_model) + logger.info( + "Quick-capture uid=%d: research note id=%d '%s'", + uid, note.id, note.title, + ) + return jsonify({ + "success": True, + "type": "note", + "message": f"Research note created: {note.title}", + "data": {"id": note.id, "title": note.title}, + }) + except Exception as exc: + logger.exception("Quick-capture research failed for topic: %s", topic) + return jsonify({"error": f"Research failed: {exc}"}), 500 + result = await execute_tool(uid, intent.tool_name, intent.arguments) if result.get("success"): item_type = result.get("type", "note") title = (result.get("data") or {}).get("title", "") logger.info( - "Quick-capture uid=%d: created %s '%s' via intent '%s'", + "Quick-capture uid=%d: %s '%s' via intent '%s'", uid, item_type, title, intent.tool_name, ) return jsonify({ "success": True, "type": item_type, - "message": f"{item_type.capitalize()} created: {title}", + "message": f"{item_type.capitalize()}: {title}", "data": result.get("data"), }) logger.warning( @@ -66,13 +87,9 @@ async def quick_capture_route(): # Fall through to plain-note fallback # Fallback: create a plain note with the raw text as the body. - # Use the first 80 chars as the title so short snippets look clean. - if len(text) <= 80: - fallback_title = text - fallback_body = "" - else: - fallback_title = text[:77] + "..." - fallback_body = text + # Always preserve the full text as the body; derive a short title. + fallback_title = text if len(text) <= 80 else text[:77] + "..." + fallback_body = text result = await execute_tool( uid, "create_note", {"title": fallback_title, "body": fallback_body} diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 2f727cd..e30a136 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -54,12 +54,6 @@ _TOOL_LABELS: dict[str, str] = { "update_event": "Updating calendar event", "delete_event": "Removing calendar event", "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", "search_web": "Searching the web", "research_topic": "Researching topic", } @@ -68,7 +62,6 @@ _TOOL_LABELS: dict[str, str] = { _WRITE_TOOLS: frozenset[str] = frozenset({ "create_task", "create_note", "update_note", "delete_note", "delete_task", "create_event", "update_event", "delete_event", - "create_todo", "update_todo", "complete_todo", "delete_todo", }) # Action phrases used in the acknowledgment prompt — "You are about to: {action}" @@ -82,18 +75,12 @@ _TOOL_ACTIONS: dict[str, str] = { "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", "update_event": "update a calendar event", "delete_event": "remove a calendar event", "list_calendars": "list available calendars", - "create_todo": "create a calendar todo", - "list_todos": "check calendar todos", - "update_todo": "update a calendar todo", - "complete_todo": "mark a todo complete", - "delete_todo": "remove a calendar todo", } diff --git a/src/fabledassistant/services/intent.py b/src/fabledassistant/services/intent.py index 22656e3..229804e 100644 --- a/src/fabledassistant/services/intent.py +++ b/src/fabledassistant/services/intent.py @@ -84,15 +84,9 @@ Rules: - "what are my overdue tasks", "show overdue", "tasks due today", "high priority tasks", "in progress tasks", "what's due this week" → use list_tasks with appropriate status/priority/due_before/due_after filters. For overdue, set due_before to today's date. - If a note was created earlier in the conversation and the user provides more content for it, use update_note (not create_note). - Use create_note ONLY when the user explicitly wants a brand new note that doesn't already exist. -- "update", "change", "rename", "set due date on" a CalDAV todo → use update_todo. - "update", "change", "move", "reschedule" an event → use update_event. - "delete", "cancel", "remove" an event → use delete_event. - "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=. -- "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=. NEVER use this for tasks. @@ -279,3 +273,74 @@ def _try_json(text: str) -> dict | list | None: return json.loads(text) except (json.JSONDecodeError, TypeError): return None + + +# ── Quick-capture classifier ────────────────────────────────────────────────── +# A stripped-down prompt designed for the /api/quick-capture endpoint. +# Unlike the general intent prompt, this ALWAYS routes to a create tool — +# null is not a valid response. + +_CAPTURE_SYSTEM_PROMPT = """\ +You are a quick-capture classifier. The user has sent a short snippet of text \ +from a mobile app or external client. Classify it as a note, task, or calendar \ +event, then extract the relevant fields. + +Today's date is {today}. + +Available tools: +{tool_summary} + +Rules: +- You MUST choose one of the available tools. Never return null. +- create_task: action items, todos, reminders, things to do ("buy milk", "call John", "fix the bug", "remind me to…") +- create_event: appointments, meetings, scheduled occurrences with a date/time ("dentist Friday 2pm", "team meeting next Tuesday") +- update_note: updating, editing, appending to an existing note or task ("add to my shopping list: eggs", "mark buy milk done", "append to my meeting notes", "update my project note") +- research_topic: user wants a comprehensive research note from web sources ("research X", "look up X and make a note", "find everything about X", "compile a note on X") +- create_note: everything else — ideas, observations, links, excerpts, longer text +- For create_task / create_event: extract a concise title; put any extra detail in "body" +- For create_note: use a short descriptive title (≤60 chars); put the FULL original text as "body" +- For update_note: set "query" to the note or task title to find; set other fields as needed +- For research_topic: set "topic" to the subject being researched +- For dates use YYYY-MM-DD; for datetime use ISO 8601 +- confidence: "high" if the type is clear; "medium" if you're guessing + +Respond with ONLY a JSON object: +{{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"}} + +Do NOT wrap in markdown code fences.""" + + +async def classify_capture_intent( + text: str, + tools: list[dict], + model: str, +) -> IntentResult: + """Classify quick-capture text and extract arguments. + + Uses a simplified prompt that always routes to a create tool — never null. + Returns IntentResult with tool_name set. Falls back to IntentResult() only + on LLM/parse failure (caller should handle that case). + """ + if not tools: + return IntentResult() + + tool_summary = _build_tool_summary(tools) + today = date_type.today().isoformat() + + messages = [ + { + "role": "system", + "content": _CAPTURE_SYSTEM_PROMPT.format( + today=today, tool_summary=tool_summary + ), + }, + {"role": "user", "content": text}, + ] + + try: + raw = await generate_completion(messages, model, max_tokens=300, num_ctx=2048) + except Exception: + logger.warning("Quick-capture intent LLM call failed", exc_info=True) + return IntentResult() + + return _parse_intent(raw, tools) diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 34781ad..5365700 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -389,13 +389,10 @@ async def build_context( if has_caldav: tool_lines[-1] = ( "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, search_todos, update_todo, complete_todo, delete_todo." + "create_event, list_events, search_events, update_event, delete_event, list_calendars." ) 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("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.") tool_lines.append( diff --git a/src/fabledassistant/services/research.py b/src/fabledassistant/services/research.py index dbd58b3..03744b9 100644 --- a/src/fabledassistant/services/research.py +++ b/src/fabledassistant/services/research.py @@ -26,15 +26,16 @@ async def run_research_pipeline( user_id: int, model: str, intent_model: str, - buf, + buf=None, ) -> Note: """Full research pipeline: search → fetch → synthesize → create note. - Emits status events via buf.append_event throughout. + Emits status events via buf.append_event throughout (when buf is provided). Returns the created Note. """ # Step 1: Generate sub-queries - buf.append_event("status", {"status": "Generating search queries..."}) + if buf is not None: + buf.append_event("status", {"status": "Generating search queries..."}) queries = await _generate_sub_queries(topic, intent_model) logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic) @@ -42,7 +43,8 @@ async def run_research_pipeline( async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]: if i > 0: await asyncio.sleep(0.2 * i) - buf.append_event("status", {"status": f"Searching: {query}..."}) + if buf is not None: + buf.append_event("status", {"status": f"Searching: {query}..."}) results = await _search_searxng(query) logger.info("Research: query '%s' → %d results", query, len(results)) return query, results @@ -64,7 +66,8 @@ async def run_research_pipeline( # Fetch all unique URLs in parallel async def _fetch_source(url: str, result: dict, query: str) -> dict: title = result.get("title", url) - buf.append_event("status", {"status": f"Reading: {title[:60]}..."}) + if buf is not None: + buf.append_event("status", {"status": f"Reading: {title[:60]}..."}) content = await fetch_url_content(url) return { "url": url, @@ -98,11 +101,13 @@ async def run_research_pipeline( ) # Step 4: Synthesize (streams tokens into chat as the note is being written) - buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."}) + if buf is not None: + buf.append_event("status", {"status": f"Synthesizing report from {len(synthesis_sources)} sources..."}) title, body = await _synthesize_note(topic, synthesis_sources, model, buf) # Step 5: Create note - buf.append_event("status", {"status": "Saving note..."}) + if buf is not None: + buf.append_event("status", {"status": "Saving note..."}) note = await create_note( user_id=user_id, title=title, diff --git a/src/fabledassistant/services/tools.py b/src/fabledassistant/services/tools.py index 64fbf1c..e1c205f 100644 --- a/src/fabledassistant/services/tools.py +++ b/src/fabledassistant/services/tools.py @@ -5,19 +5,13 @@ import logging from datetime import date, datetime from fabledassistant.services.caldav import ( - complete_todo, create_event, - create_todo, delete_event, - delete_todo, is_caldav_configured, list_calendars, list_events, - list_todos, search_events, - search_todos, update_event, - update_todo, ) from fabledassistant.config import Config from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note @@ -489,178 +483,6 @@ _CALDAV_TOOLS = [ }, }, }, - { - "type": "function", - "function": { - "name": "create_todo", - "description": "Create a CalDAV todo item on the calendar server. Use this when the user explicitly asks to create a calendar todo or CalDAV todo (NOT for regular app tasks).", - "parameters": { - "type": "object", - "properties": { - "summary": { - "type": "string", - "description": "The todo summary/title", - }, - "due": { - "type": "string", - "description": "Optional due date/datetime in ISO 8601 format", - }, - "description": { - "type": "string", - "description": "Optional description", - }, - "priority": { - "type": "integer", - "description": "Optional priority (1=highest, 9=lowest)", - }, - "reminder_minutes": { - "type": "integer", - "description": "Optional reminder N minutes before due date", - }, - "timezone": { - "type": "string", - "description": "Optional IANA timezone (e.g. 'America/New_York')", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name", - }, - }, - "required": ["summary"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "list_todos", - "description": "List CalDAV todos from the calendar server. Use this when the user asks to see their calendar todos.", - "parameters": { - "type": "object", - "properties": { - "include_completed": { - "type": "boolean", - "description": "Whether to include completed todos (default false)", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name", - }, - }, - }, - }, - }, - { - "type": "function", - "function": { - "name": "update_todo", - "description": "Update a CalDAV todo's summary, due date, description, or priority.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search term to find the todo to update (matches against summary)", - }, - "summary": { - "type": "string", - "description": "New summary/title for the todo", - }, - "due": { - "type": "string", - "description": "New due date or datetime in ISO 8601 format", - }, - "description": { - "type": "string", - "description": "New description", - }, - "priority": { - "type": "integer", - "description": "New priority (1=highest, 9=lowest)", - }, - "timezone": { - "type": "string", - "description": "Optional IANA timezone for due datetime", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name to search in", - }, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "complete_todo", - "description": "Mark a CalDAV todo as completed. Use this when the user says they finished or completed a calendar todo.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search term to find the todo to complete (matches against summary)", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name", - }, - }, - "required": ["query"], - }, - }, - }, - { - "type": "function", - "function": { - "name": "delete_todo", - "description": "Delete a CalDAV todo. Use this when the user asks to remove or delete a calendar todo.", - "parameters": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Search term to find the todo to delete (matches against summary)", - }, - "calendar_name": { - "type": "string", - "description": "Optional calendar name", - }, - }, - "required": ["query"], - }, - }, - }, - { - "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"], - }, - }, - }, ] @@ -1040,95 +862,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict: }, } - elif tool_name == "create_todo": - result = await create_todo( - user_id=user_id, - summary=arguments.get("summary", "Untitled Todo"), - due=arguments.get("due"), - description=arguments.get("description"), - priority=arguments.get("priority"), - reminder_minutes=arguments.get("reminder_minutes"), - timezone=arguments.get("timezone"), - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "todo", - "data": result, - } - - elif tool_name == "list_todos": - todos = await list_todos( - user_id=user_id, - include_completed=arguments.get("include_completed", False), - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "todos", - "data": { - "count": len(todos), - "todos": todos, - }, - } - - elif tool_name == "update_todo": - result = await update_todo( - user_id=user_id, - query=arguments["query"], - summary=arguments.get("summary"), - due=arguments.get("due"), - description=arguments.get("description"), - priority=arguments.get("priority"), - timezone=arguments.get("timezone"), - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "todo_updated", - "data": result, - } - - elif tool_name == "complete_todo": - result = await complete_todo( - user_id=user_id, - query=arguments["query"], - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "todo_completed", - "data": result, - } - - elif tool_name == "delete_todo": - result = await delete_todo( - user_id=user_id, - query=arguments["query"], - calendar_name=arguments.get("calendar_name"), - ) - return { - "success": True, - "type": "todo_deleted", - "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) diff --git a/summary.md b/summary.md index 6ae40d9..ff8c1d9 100644 --- a/summary.md +++ b/summary.md @@ -12,7 +12,7 @@ > Include file-level details in the commit body when the change is non-trivial. ## Last Updated -2026-03-01 — Image search with local cache (Phase 23); suggested notes improvements (limit 8, threshold 0.45, relevance scores); suggested notes UX; quick-capture endpoint +2026-03-01 — Quick-capture overhaul (dedicated classifier, research + update_note support); CalDAV todo tools removed; run_research_pipeline buf optional ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with @@ -278,7 +278,7 @@ fabledassistant/ │ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required) │ │ ├── images.py # /api/images/ GET — serve locally-cached images (no auth required; IDs are opaque) │ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions (all @login_required) -│ │ ├── quick_capture.py # /api/quick-capture POST — mobile/external single-shot item creation (session auth) +│ │ ├── quick_capture.py # /api/quick-capture POST — mobile/external single-shot item creation (session auth); uses classify_capture_intent(); supports create_note/task/event, update_note, research_topic; fallback always preserves full text as note body │ │ ├── tasks.py # /api/tasks CRUD + PATCH status (all @login_required) │ │ └── settings.py # /api/settings GET/PUT, GET /models — per-user settings + installed model list (@login_required) │ ├── services/ @@ -290,12 +290,12 @@ fabledassistant/ │ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged) │ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys) │ │ ├── generation_task.py # Background asyncio tasks: run_generation (chat, DB flush, titles, intent-first pipeline + tool loop) + run_assist_generation (lightweight, no DB); _INTENT_TRIGGER_WORDS + _should_skip_intent() for skipping intent on conversational messages -│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call (num_ctx=4096); IntentResult has ack field (one-sentence acknowledgment streamed as TTFT) +│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call (num_ctx=4096); IntentResult has ack field; classify_capture_intent() uses dedicated _CAPTURE_SYSTEM_PROMPT (always routes to a tool, never null) │ │ ├── images.py # Image cache service: fetch_and_store_image (SHA-256 dedup, content-type validation, 5MB cap), get_image_record, get_image_path -│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, full CalDAV suite incl. search_todos, search_web, research_topic, search_images when SearXNG enabled) + execute_tool dispatcher -│ │ ├── research.py # SearXNG research pipeline: parallel query/fetch, streaming synthesis; run_research_pipeline() → Note; _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000 +│ │ ├── tools.py # LLM tool definitions (create/delete note+task, update_note w/tag management, get_note, list_notes, search_notes w/type filter, list_tasks, CalDAV event suite (no todos), search_web, research_topic, search_images when SearXNG enabled) + execute_tool dispatcher +│ │ ├── research.py # SearXNG research pipeline: parallel query/fetch, streaming synthesis; run_research_pipeline(buf=None) → Note (buf optional — no status events when omitted); _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000 │ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response -│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), todos (create/list/search/update/complete/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search +│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search. Todo functions exist in code but are NOT exposed as LLM tools. │ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings │ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop │ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email