Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts

Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
  create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
  support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
  (_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
  7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
  conversation history support for anaphora resolution; routing rules for
  update/delete events, todos, update_note vs create_note disambiguation,
  time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
  intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var

Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
  isConversational computed — prominent "Continue this conversation" CTA when
  no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
  textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
  state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
  todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 22:04:41 -05:00
parent 75560dee4e
commit 70cba72a80
15 changed files with 1569 additions and 71 deletions
+3
View File
@@ -24,6 +24,9 @@ class Config:
)
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3")
# Optional dedicated model for intent classification (should be small/fast).
# Falls back to OLLAMA_MODEL if not set. Can also be overridden per-user via settings.
OLLAMA_INTENT_MODEL: str = os.environ.get("OLLAMA_INTENT_MODEL", "")
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
+434 -13
View File
@@ -3,6 +3,7 @@
import asyncio
import logging
from datetime import date as date_type, datetime, timedelta
from zoneinfo import ZoneInfo
import caldav
import icalendar
@@ -11,7 +12,7 @@ from fabledassistant.services.settings import get_all_settings
logger = logging.getLogger(__name__)
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name"]
CALDAV_SETTING_KEYS = ["caldav_url", "caldav_username", "caldav_password", "caldav_calendar_name", "caldav_timezone"]
async def get_caldav_config(user_id: int) -> dict[str, str]:
@@ -41,6 +42,15 @@ def _get_calendar(client: caldav.DAVClient, calendar_name: str) -> caldav.Calend
return calendars[0]
def _get_all_calendars(client: caldav.DAVClient) -> list[caldav.Calendar]:
"""Get all calendars for the user (synchronous)."""
principal = client.principal()
calendars = principal.calendars()
if not calendars:
raise ValueError("No calendars found on the CalDAV server.")
return calendars
def _make_client(config: dict[str, str]) -> caldav.DAVClient:
"""Create a CalDAV client from config dict."""
return caldav.DAVClient(
@@ -58,15 +68,99 @@ def _parse_vevent(component) -> dict | None:
dtstart = component.get("DTSTART")
dtend = component.get("DTEND")
location = str(component.get("LOCATION", ""))
description = str(component.get("DESCRIPTION", ""))
uid = str(component.get("UID", ""))
start_str = dtstart.dt.isoformat() if dtstart else ""
end_str = dtend.dt.isoformat() if dtend else ""
return {
result = {
"uid": uid,
"title": title,
"start": start_str,
"end": end_str,
"location": location,
"description": description,
}
# Extract recurrence rule
rrule = component.get("RRULE")
if rrule:
result["recurrence"] = rrule.to_ical().decode("utf-8")
# Extract alarms
alarms = []
for sub in component.subcomponents:
if sub.name == "VALARM":
trigger = sub.get("TRIGGER")
if trigger and trigger.dt:
minutes = abs(int(trigger.dt.total_seconds() // 60))
alarms.append({"minutes_before": minutes})
if alarms:
result["alarms"] = alarms
# Extract attendees
attendees = component.get("ATTENDEE")
if attendees:
if not isinstance(attendees, list):
attendees = [attendees]
result["attendees"] = [str(a).replace("mailto:", "") for a in attendees]
return result
def _parse_vtodo(component) -> dict | None:
"""Extract todo data from a VTODO component."""
if component.name != "VTODO":
return None
uid = str(component.get("UID", ""))
summary = str(component.get("SUMMARY", ""))
description = str(component.get("DESCRIPTION", ""))
status = str(component.get("STATUS", ""))
due = component.get("DUE")
due_str = due.dt.isoformat() if due else ""
priority = component.get("PRIORITY")
priority_val = int(priority) if priority else None
return {
"uid": uid,
"summary": summary,
"description": description,
"due": due_str,
"status": status,
"priority": priority_val,
}
def _apply_timezone(dt: datetime, timezone: str | None) -> datetime:
"""Apply a timezone to a naive datetime. Returns dt unchanged if already aware."""
if dt.tzinfo is not None:
return dt
if timezone:
return dt.replace(tzinfo=ZoneInfo(timezone))
return dt
def _build_valarm(minutes_before: int) -> icalendar.Alarm:
"""Create a DISPLAY alarm component triggered N minutes before the event."""
alarm = icalendar.Alarm()
alarm.add("action", "DISPLAY")
alarm.add("description", "Reminder")
alarm.add("trigger", timedelta(minutes=-minutes_before))
return alarm
def _add_attendees(event: icalendar.Event, attendees: list[str]) -> None:
"""Add mailto: attendees to an iCalendar event."""
for email in attendees:
attendee = icalendar.vCalAddress(f"mailto:{email}")
event.add("attendee", attendee)
def _check_config(config: dict[str, str]) -> None:
"""Raise if CalDAV is not configured."""
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings -> Calendar to set it up.")
async def create_event(
user_id: int,
@@ -78,6 +172,10 @@ async def create_event(
location: str | None = None,
all_day: bool = False,
recurrence: str | None = None,
timezone: str | None = None,
reminder_minutes: int | None = None,
attendees: list[str] | None = None,
calendar_name: str | None = None,
) -> dict:
"""Create a calendar event.
@@ -86,8 +184,9 @@ async def create_event(
recurrence is an iCalendar RRULE string (e.g. "FREQ=YEARLY").
"""
config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
cal = icalendar.Calendar()
cal.add("prodid", "-//FabledAssistant//EN")
@@ -108,9 +207,9 @@ async def create_event(
result_start = d_start.isoformat()
result_end = d_end.isoformat()
else:
dt_start = datetime.fromisoformat(start)
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
if end:
dt_end = datetime.fromisoformat(end)
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
else:
dt_end = dt_start + timedelta(minutes=duration or 60)
event.add("dtstart", dt_start)
@@ -130,6 +229,10 @@ async def create_event(
key, value = part.split("=", 1)
rrule_parts[key.strip().lower()] = value.strip()
event.add("rrule", rrule_parts)
if reminder_minutes is not None:
event.add_component(_build_valarm(reminder_minutes))
if attendees:
_add_attendees(event, attendees)
cal.add_component(event)
@@ -137,7 +240,8 @@ async def create_event(
def _save():
client = _make_client(config)
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
calendar.save_event(ical_str)
await asyncio.to_thread(_save)
@@ -154,18 +258,30 @@ async def create_event(
async def list_events(user_id: int, date_from: str, date_to: str) -> list[dict]:
"""List calendar events in a date range. Dates are ISO datetime strings."""
"""List calendar events in a date range. Dates are ISO datetime strings.
Searches all calendars unless caldav_calendar_name is configured.
"""
config = await get_caldav_config(user_id)
if not (config.get("caldav_url") and config.get("caldav_username") and config.get("caldav_password")):
raise ValueError("CalDAV is not configured. Go to Settings → Calendar to set it up.")
_check_config(config)
dt_from = datetime.fromisoformat(date_from)
dt_to = datetime.fromisoformat(date_to)
def _search():
client = _make_client(config)
calendar = _get_calendar(client, config.get("caldav_calendar_name", ""))
return calendar.date_search(dt_from, dt_to)
cal_name = config.get("caldav_calendar_name", "")
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
all_results = []
for calendar in calendars:
try:
all_results.extend(calendar.date_search(dt_from, dt_to))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(calendar, 'name', '?'))
return all_results
results = await asyncio.to_thread(_search)
@@ -189,10 +305,315 @@ async def search_events(user_id: int, query: str, days_ahead: int = 90) -> list[
q = query.lower()
return [
e for e in all_events
if q in e["title"].lower() or q in e.get("location", "").lower()
if q in e["title"].lower() or q in e.get("location", "").lower() or q in e.get("description", "").lower()
]
async def update_event(
user_id: int,
query: str,
title: str | None = None,
start: str | None = None,
end: str | None = None,
description: str | None = None,
location: str | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
) -> dict:
"""Update a calendar event matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
def _do_update():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
now = datetime.now()
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
results = []
for cal in calendars:
try:
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
q = query.lower()
matches = []
for r in results:
cal_obj = icalendar.Calendar.from_ical(r.data)
for component in cal_obj.walk():
if component.name == "VEVENT":
event_title = str(component.get("SUMMARY", ""))
if q in event_title.lower():
matches.append((r, component))
if not matches:
raise ValueError(f"No event found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
event_obj, component = matches[0]
if title:
component["SUMMARY"] = title
if start:
dt_start = _apply_timezone(datetime.fromisoformat(start), tz)
del component["DTSTART"]
component.add("dtstart", dt_start)
if end:
dt_end = _apply_timezone(datetime.fromisoformat(end), tz)
if "DTEND" in component:
del component["DTEND"]
component.add("dtend", dt_end)
if description is not None:
if "DESCRIPTION" in component:
del component["DESCRIPTION"]
component.add("description", description)
if location is not None:
if "LOCATION" in component:
del component["LOCATION"]
component.add("location", location)
# Rebuild ical data and save
cal_data = icalendar.Calendar()
cal_data.add("prodid", "-//FabledAssistant//EN")
cal_data.add("version", "2.0")
cal_data.add_component(component)
event_obj.data = cal_data.to_ical().decode("utf-8")
event_obj.save()
return _parse_vevent(component)
return await asyncio.to_thread(_do_update)
async def delete_event(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Delete a calendar event matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _do_delete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
now = datetime.now()
if cal_name:
calendars = [_get_calendar(client, cal_name)]
else:
calendars = _get_all_calendars(client)
results = []
for cal in calendars:
try:
results.extend(cal.date_search(now - timedelta(days=30), now + timedelta(days=365)))
except Exception:
logger.warning("Failed to search calendar '%s'", getattr(cal, 'name', '?'))
q = query.lower()
matches = []
for r in results:
cal_obj = icalendar.Calendar.from_ical(r.data)
for component in cal_obj.walk():
if component.name == "VEVENT":
event_title = str(component.get("SUMMARY", ""))
if q in event_title.lower():
matches.append((r, component))
if not matches:
raise ValueError(f"No event found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
event_obj, component = matches[0]
parsed = _parse_vevent(component)
event_obj.delete()
return parsed
return await asyncio.to_thread(_do_delete)
async def list_calendars(user_id: int) -> list[dict]:
"""List all calendars for the user."""
config = await get_caldav_config(user_id)
_check_config(config)
def _list():
client = _make_client(config)
principal = client.principal()
calendars = principal.calendars()
return [{"name": c.name, "url": str(c.url)} for c in calendars]
return await asyncio.to_thread(_list)
async def create_todo(
user_id: int,
summary: str,
due: str | None = None,
description: str | None = None,
priority: int | None = None,
reminder_minutes: int | None = None,
timezone: str | None = None,
calendar_name: str | None = None,
) -> dict:
"""Create a CalDAV todo (VTODO)."""
config = await get_caldav_config(user_id)
_check_config(config)
tz = timezone or config.get("caldav_timezone") or None
def _create():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
kwargs = {"summary": summary}
if due:
dt_due = datetime.fromisoformat(due)
dt_due = _apply_timezone(dt_due, tz)
kwargs["due"] = dt_due
todo = calendar.save_todo(**kwargs)
# Modify component for extra fields
cal_obj = icalendar.Calendar.from_ical(todo.data)
modified = False
for component in cal_obj.walk():
if component.name == "VTODO":
if description:
component.add("description", description)
modified = True
if priority is not None:
component.add("priority", priority)
modified = True
if reminder_minutes is not None:
component.add_component(_build_valarm(reminder_minutes))
modified = True
if modified:
todo.data = cal_obj.to_ical().decode("utf-8")
todo.save()
return _parse_vtodo(component)
return {"summary": summary}
return await asyncio.to_thread(_create)
async def list_todos(
user_id: int,
include_completed: bool = False,
calendar_name: str | None = None,
) -> list[dict]:
"""List CalDAV todos."""
config = await get_caldav_config(user_id)
_check_config(config)
def _list():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=include_completed)
results = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
parsed = _parse_vtodo(component)
if parsed:
results.append(parsed)
return results
return await asyncio.to_thread(_list)
async def complete_todo(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Complete a CalDAV todo matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _complete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=False)
q = query.lower()
matches = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
if component.name == "VTODO":
s = str(component.get("SUMMARY", ""))
if q in s.lower():
matches.append((t, component))
if not matches:
raise ValueError(f"No todo found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
todo_obj, component = matches[0]
todo_obj.complete()
# Re-parse after completing
cal_obj = icalendar.Calendar.from_ical(todo_obj.data)
for comp in cal_obj.walk():
parsed = _parse_vtodo(comp)
if parsed:
return parsed
return {"summary": str(component.get("SUMMARY", "")), "status": "COMPLETED"}
return await asyncio.to_thread(_complete)
async def delete_todo(
user_id: int,
query: str,
calendar_name: str | None = None,
) -> dict:
"""Delete a CalDAV todo matching the query."""
config = await get_caldav_config(user_id)
_check_config(config)
def _delete():
client = _make_client(config)
cal_name = calendar_name or config.get("caldav_calendar_name", "")
calendar = _get_calendar(client, cal_name)
todos = calendar.todos(include_completed=True)
q = query.lower()
matches = []
for t in todos:
cal_obj = icalendar.Calendar.from_ical(t.data)
for component in cal_obj.walk():
if component.name == "VTODO":
s = str(component.get("SUMMARY", ""))
if q in s.lower():
matches.append((t, component))
if not matches:
raise ValueError(f"No todo found matching '{query}'.")
if len(matches) > 3:
titles = [str(m[1].get("SUMMARY", "")) for m in matches]
raise ValueError(f"Too many matches ({len(matches)}) for '{query}'. Be more specific. Found: {', '.join(titles[:10])}")
todo_obj, component = matches[0]
parsed = _parse_vtodo(component)
todo_obj.delete()
return parsed
return await asyncio.to_thread(_delete)
async def test_connection(user_id: int) -> dict:
"""Test the CalDAV connection and return status."""
config = await get_caldav_config(user_id)
@@ -4,6 +4,7 @@ Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
Runs independently of any HTTP connection.
"""
import asyncio
import json
import logging
import re
@@ -11,12 +12,14 @@ import time
from sqlalchemy import update
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import GenerationBuffer, GenerationState
from fabledassistant.services.llm import ChatChunk, generate_completion, stream_chat, stream_chat_with_tools
from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.intent import classify_intent
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tools import get_tools_for_user, execute_tool
logger = logging.getLogger(__name__)
@@ -92,9 +95,16 @@ async def run_generation(
last_flush = time.monotonic()
all_tool_calls: list[dict] = []
# Resolve tools based on user's configured integrations
tools = await get_tools_for_user(user_id)
logger.info("Starting generation for conv %d: model=%s, tools=%d", conv_id, model, len(tools))
# Resolve tools and intent model in parallel
tools, intent_model_setting = await asyncio.gather(
get_tools_for_user(user_id),
get_setting(user_id, "intent_model", ""),
)
intent_model = intent_model_setting or Config.OLLAMA_INTENT_MODEL or model
logger.info(
"Starting generation for conv %d: model=%s, intent_model=%s, tools=%d",
conv_id, model, intent_model, len(tools),
)
try:
cancelled = False
@@ -105,9 +115,24 @@ async def run_generation(
# Intent routing — first round only
if _round == 0 and tools:
intent = await classify_intent(user_content, tools, model)
if intent.tool_name:
logger.info("Intent router detected tool: %s(%s)", intent.tool_name, json.dumps(intent.arguments)[:200])
# Pass last 3 user/assistant pairs (6 messages) for anaphora resolution.
# messages = [system, *history, current_user] — exclude system and current user.
intent_history = [
m for m in messages[1:-1]
if m.get("role") in ("user", "assistant") and m.get("content")
][-6:]
intent = await classify_intent(user_content, tools, intent_model, history=intent_history)
if intent.should_execute:
logger.info(
"Intent router detected tool (confidence=%s): %s(%s)",
intent.confidence, intent.tool_name, json.dumps(intent.arguments)[:200],
)
elif intent.tool_name:
logger.info(
"Intent router low confidence (%s) for tool=%s — falling through to streaming",
intent.confidence, intent.tool_name,
)
if intent.should_execute:
result = await execute_tool(user_id, intent.tool_name, intent.arguments)
logger.info("Intent-routed tool %s result: success=%s", intent.tool_name, result.get("success"))
+55 -10
View File
@@ -21,6 +21,12 @@ logger = logging.getLogger(__name__)
class IntentResult:
tool_name: str | None = None # None = no tool, just chat
arguments: dict = field(default_factory=dict)
confidence: str = "high" # "high", "medium", or "low"
@property
def should_execute(self) -> bool:
"""True if a tool was identified with sufficient confidence."""
return self.tool_name is not None and self.confidence != "low"
def _build_tool_summary(tools: list[dict]) -> str:
@@ -45,8 +51,9 @@ def _build_tool_summary(tools: list[dict]) -> str:
_SYSTEM_PROMPT_TEMPLATE = """\
You are an intent classifier. Given a user message, decide whether it \
requires calling one of the available tools or is just general chat.
You are an intent classifier. Given a user message (and recent conversation \
history for context), decide whether it requires calling one of the available \
tools or is just general chat.
Today's date is {today}.
@@ -54,15 +61,33 @@ Available tools:
{tool_summary}
Respond with ONLY a JSON object, no other text:
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}}}
- If it's general chat: {{"tool": null}}
- If a tool should be called: {{"tool": "tool_name", "arguments": {{...}}, "confidence": "high"|"medium"|"low"}}
- If it's general chat: {{"tool": null, "confidence": "high"}}
Confidence levels:
- "high": the intent is clear and all required arguments are unambiguous
- "medium": probably requires the tool but some argument is uncertain or inferred
- "low": uncertain whether this needs a tool at all, or the message is too ambiguous to act on
Rules:
- Use recent conversation history to resolve references like "it", "that event", "the meeting", "move it", etc.
- For dates like "tomorrow", "next Friday", "in 3 days", resolve them to YYYY-MM-DD format.
- For datetime parameters, use ISO 8601 format (e.g. 2026-09-30T14:00:00).
- Only include arguments the user actually specified or that can be clearly inferred.
- Infer reasonable defaults: birthdays and holidays are all-day + yearly recurring; "weekly meeting" is weekly recurring.
- Use descriptive titles: "My Birthday" not just "Birthday", "Team Standup" not just "Meeting".
- "add to", "update", "edit", "expand", "flesh out", "modify", "append to", "continue writing" a note → use update_note with query=<note title from context> and mode="append" for additions or mode="replace" for full rewrites.
- 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", "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.
- "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.
- Do NOT wrap the JSON in markdown code fences."""
@@ -70,10 +95,14 @@ async def classify_intent(
user_message: str,
tools: list[dict],
model: str,
history: list[dict] | None = None,
) -> IntentResult:
"""Classify user intent via a fast non-streaming LLM call.
Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
history is a list of recent {role, content} messages (user/assistant only,
no system messages) for resolving anaphoric references.
Returns an IntentResult. On any failure, returns IntentResult(tool_name=None)
so the caller falls through to the normal streaming path.
"""
if not tools:
@@ -82,16 +111,25 @@ async def classify_intent(
tool_summary = _build_tool_summary(tools)
today = date_type.today().isoformat()
messages = [
messages: list[dict] = [
{
"role": "system",
"content": _SYSTEM_PROMPT_TEMPLATE.format(
today=today, tool_summary=tool_summary
),
},
{"role": "user", "content": user_message},
]
# Inject recent history turns so the model can resolve references
if history:
for turn in history:
role = turn.get("role")
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": user_message})
try:
raw = await generate_completion(messages, model)
except Exception:
@@ -124,8 +162,12 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
return IntentResult()
tool_name = parsed.get("tool")
confidence = parsed.get("confidence", "high")
if confidence not in ("high", "medium", "low"):
confidence = "high"
if tool_name is None:
return IntentResult()
return IntentResult(confidence=confidence)
# Validate tool name against available tools
valid_names = {
@@ -139,8 +181,11 @@ def _parse_intent(raw: str, tools: list[dict]) -> IntentResult:
if not isinstance(arguments, dict):
arguments = {}
logger.info("Intent classified: tool=%s, args=%s", tool_name, json.dumps(arguments)[:200])
return IntentResult(tool_name=tool_name, arguments=arguments)
logger.info(
"Intent classified: tool=%s, confidence=%s, args=%s",
tool_name, confidence, json.dumps(arguments)[:200],
)
return IntentResult(tool_name=tool_name, arguments=arguments, confidence=confidence)
def _try_json(text: str) -> dict | list | None:
+13 -2
View File
@@ -250,12 +250,23 @@ 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, search_notes.",
"Available actions: create_task, create_note, update_note, search_notes.",
]
if has_caldav:
tool_lines[-1] = "Available actions: create_task, create_note, search_notes, create_event, list_events, search_events."
tool_lines[-1] = (
"Available actions: create_task, create_note, update_note, search_notes, "
"create_event, list_events, search_events, update_event, delete_event, "
"list_calendars, create_todo, list_todos, 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("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. "
"Use create_note ONLY for genuinely new notes with a different title. "
"If a note was created earlier in the conversation and the user provides more content for it, use update_note."
)
tool_guidance = "\n".join(tool_lines)
system_parts = [
+386 -2
View File
@@ -4,12 +4,19 @@ 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,
update_event,
)
from fabledassistant.services.notes import create_note, list_notes
from fabledassistant.services.notes import create_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
logger = logging.getLogger(__name__)
@@ -67,6 +74,43 @@ _CORE_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "update_note",
"description": (
"Update an existing note's content or title. "
"Use this when the user asks to add to, edit, expand, flesh out, or modify a note that already exists. "
"NEVER use create_note when updating an existing note — always use update_note."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Title or keyword to find the note to update",
},
"body": {
"type": "string",
"description": "New note content in markdown",
},
"title": {
"type": "string",
"description": "Optional new title for the note",
},
"mode": {
"type": "string",
"enum": ["replace", "append"],
"description": (
"How to apply the new body: 'replace' overwrites the existing content (default), "
"'append' adds the new content after the existing content"
),
},
},
"required": ["query", "body"],
},
},
},
{
"type": "function",
"function": {
@@ -128,6 +172,23 @@ _CALDAV_TOOLS = [
"type": "string",
"description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)",
},
"reminder_minutes": {
"type": "integer",
"description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)",
},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "Optional list of attendee email addresses",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York'). Falls back to user's caldav_timezone setting.",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to create the event in. Falls back to default calendar.",
},
},
"required": ["title", "start"],
},
@@ -164,7 +225,187 @@ _CALDAV_TOOLS = [
"properties": {
"query": {
"type": "string",
"description": "Search keyword to match against event titles and locations",
"description": "Search keyword to match against event titles, locations, and descriptions",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "update_event",
"description": "Update an existing calendar event. Use this when the user asks to change, move, reschedule, or modify an event.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the event to update (matches against title)",
},
"title": {
"type": "string",
"description": "New title for the event",
},
"start": {
"type": "string",
"description": "New start datetime in ISO 8601 format",
},
"end": {
"type": "string",
"description": "New end datetime in ISO 8601 format",
},
"description": {
"type": "string",
"description": "New event description",
},
"location": {
"type": "string",
"description": "New event location",
},
"timezone": {
"type": "string",
"description": "Optional IANA timezone (e.g. 'America/New_York')",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "delete_event",
"description": "Delete a calendar event. Use this when the user asks to cancel, remove, or delete an event.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search term to find the event to delete (matches against title)",
},
"calendar_name": {
"type": "string",
"description": "Optional calendar name to search in",
},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "list_calendars",
"description": "List all available calendars. Use this when the user asks which calendars they have.",
"parameters": {
"type": "object",
"properties": {},
},
},
},
{
"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": "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"],
@@ -241,6 +482,48 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
"suggested_tags": suggested,
}
elif tool_name == "update_note":
query = arguments.get("query", "")
new_body = arguments.get("body", "")
new_title = arguments.get("title")
mode = arguments.get("mode", "replace")
# Find the note: exact title match first, then fuzzy search
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)
# Prefer notes (not tasks) when there are mixed results
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]
# Build the update payload
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
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 "")
return {
"success": True,
"type": "note_updated",
"data": {
"id": updated.id,
"title": updated.title,
},
"suggested_tags": suggested,
}
elif tool_name == "search_notes":
query = arguments.get("query", "")
notes, total = await list_notes(user_id=user_id, q=query, limit=5)
@@ -274,6 +557,10 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
location=arguments.get("location"),
all_day=arguments.get("all_day", False),
recurrence=arguments.get("recurrence"),
timezone=arguments.get("timezone"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
@@ -311,6 +598,103 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
},
}
elif tool_name == "update_event":
result = await update_event(
user_id=user_id,
query=arguments["query"],
title=arguments.get("title"),
start=arguments.get("start"),
end=arguments.get("end"),
description=arguments.get("description"),
location=arguments.get("location"),
timezone=arguments.get("timezone"),
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "event_updated",
"data": result,
}
elif tool_name == "delete_event":
result = await delete_event(
user_id=user_id,
query=arguments["query"],
calendar_name=arguments.get("calendar_name"),
)
return {
"success": True,
"type": "event_deleted",
"data": result,
}
elif tool_name == "list_calendars":
calendars = await list_calendars(user_id=user_id)
return {
"success": True,
"type": "calendars",
"data": {
"count": len(calendars),
"calendars": calendars,
},
}
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 == "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,
}
else:
return {"success": False, "error": f"Unknown tool: {tool_name}"}