# Fabled Assistant - Project Context > **Purpose:** This file is the canonical reference for re-initializing Claude Code > context on this project. **Update this file after every change session.** ## Session Checklist > **IMPORTANT:** At the end of every change session, you MUST: > 1. **Update this file** (`summary.md`) to reflect all architectural, data model, > API, file structure, and roadmap changes made during the session. > 2. **Commit all changes** with a descriptive commit message summarizing what was > done (e.g., "Merge tasks into notes: single table with task attributes"). > Include file-level details in the commit body when the change is non-trivial. ## Last Updated 2026-02-26 — Phase 19: Light mode indigo theme, OLLAMA_NUM_CTX, model dropdowns, optimistic streaming ## Project Overview Fabled Assistant is a self-hosted note-taking and task-tracking application with integrated LLM capabilities. It is designed to run on container infrastructure (Docker Swarm) and connect to Ollama or any self-hostable LLM-compatible system for AI-assisted features. ## Core Architecture ### Stack | Layer | Technology | Notes | |-------------|-----------|-------| | Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API | | Backend/API | Quart (Python 3.12) | Async framework; serves both API and built frontend static files | | LLM | Ollama | Or any OpenAI-compatible self-hosted LLM API | | Database | PostgreSQL 16 | asyncpg driver, SQLAlchemy 2.0 async ORM, Alembic migrations | | Deployment | Docker Compose | Single-container app + separate DB + LLM service | ### Key Design Decisions - **Single container for frontend + API:** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. - **Quart chosen for familiarity:** The maintainer (bvandeusen) knows Quart well. - **SPA routing via 404 handler:** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) get JSON 404 responses. This avoids a catch-all `/` route intercepting API GETs. - **LLM integration is a separate service:** The app communicates with Ollama (or compatible) over HTTP. - **Inline tag extraction:** Tags are extracted from note/task body text using `#tag` syntax (Obsidian-style), not manually entered. Backend is source of truth for tag extraction. The `TAG_RE` regex uses `(?/dev/null || true) && alembic upgrade head && hypercorn ... ``` - `alembic stamp --purge base` clears any stale revision stamps (safe no-op if none) - `alembic upgrade head` runs all pending migrations in order - Migrations are idempotent so re-running is safe ### Creating a New Migration When adding a new migration, follow these conventions: 1. **Create the migration file:** ``` alembic/versions/0005_description.py ``` 2. **Use raw SQL for idempotency:** ```python from alembic import op revision = "0005" down_revision = "0004" def upgrade() -> None: # For new enums: op.execute(""" DO $$ BEGIN CREATE TYPE my_enum AS ENUM ('a', 'b'); EXCEPTION WHEN duplicate_object THEN null; END $$ """) # For new tables: op.execute(""" CREATE TABLE IF NOT EXISTS my_table (...) """) # For new indexes: op.execute("CREATE INDEX IF NOT EXISTS ix_name ON table (col)") # For adding columns: op.execute(""" DO $$ BEGIN ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT ''; EXCEPTION WHEN duplicate_column THEN null; END $$ """) def downgrade() -> None: op.execute("DROP INDEX IF EXISTS ix_name") op.execute("DROP TABLE IF EXISTS my_table") op.execute("DROP TYPE IF EXISTS my_enum") ``` 3. **Rebuild and test:** ```bash docker compose up --build ``` To test from a completely fresh database: ```bash docker compose down -v && docker compose up --build ``` ### Important Notes - Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run - Always write raw SQL with PostgreSQL idempotency guards - Set `down_revision` to chain from the previous migration's `revision` - Data migrations (like 0003) should use `op.get_bind()` + `sa.text()` for queries ## Implemented Features ### Notes & Tasks - Full CRUD with markdown bodies, Obsidian-style `#tag` extraction (skips code fences), hierarchical tag filtering - Unified data model: a task is a note with `status IS NOT NULL` — convert freely between note ↔ task - Task attributes: status (todo/in_progress/done), priority (none/low/medium/high), due_date - Obsidian-style `[[wikilinks]]` with auto-create on click, backlinks ("what links here") - Tiptap WYSIWYG editor with markdown round-trip, tag/wikilink autocomplete and decorations, sticky toolbar - AI Assist panel (right-side 320px, toggle persisted to localStorage): section targeting, floating ✨ pill on text selection, full-document Proofread action, line-level diff view in review state (LCS algorithm), "Show full text" toggle, accept/reject; panel collapses to bottom 45% on mobile (≤768px) - Table of contents sidebar on note/task viewers (auto-generated from headings, hidden ≤1200px) - Inline edit buttons on NoteCard/TaskCard (hover on desktop, always visible on touch) - Search (ILIKE), sort, tag filter pills, pagination on list views ### Dashboard - **Chat-first layout:** Quick action chips + chat input at top (full width); no page title. Inline streaming response (full width) appears between widget and content grid when active. - **Two-column grid:** Tasks left (3fr) / Recent Notes right (2fr); collapses to single column on mobile. Max-width 1400px. - **Left column — 6 task sections** in urgency order, all cascading-deduplicated: Overdue (red border), Due Today, Due This Week, High Priority (amber border), In Progress, Other (capped at 10). "Other" = broad fetch of all non-done tasks deduped against shown sections; due-dated items first (asc), then undated by priority desc. - **Right column** — 8 most recently edited notes. - Priority-aware sorting (priority desc → due date asc) within each section. - All sections hidden when empty; marking a task done removes it from all lists. - **Inline chat:** Streams response inline — no navigation to `/chat`. Tool calls shown live. Conversational (no tool calls) response promotes "Continue this conversation →" button. - **Quick action chips:** Pre-defined prompts above the chat input. - **Auto-focus:** Dashboard chat input gains focus on page mount. - **Keyboard shortcut — Escape in ChatView:** Cascade close: note picker → mobile sidebar → clear textarea → navigate to `/` home. - **Keyboard shortcuts overlay:** Press `?` or click `?` in nav bar. State shared via `useShortcuts` composable. ### LLM Chat - Ollama integration via async HTTP (httpx), default model `qwen3:latest` (better tool support than mistral), auto-pull on startup - Background generation with `GenerationBuffer` (in-memory SSE fan-out, `Last-Event-ID` reconnect, 60s cleanup) - Stop generation with partial content preservation - Note-aware context building: current note + keyword search for related notes + URL fetching - **Persistent context sidebar:** Right panel in ChatView accumulates auto-found notes across turns. Manually attached note appears with 📌 pin, clears after send. × excludes from future auto-search. Hidden on mobile (≤768px). Replaces old ephemeral context pills in the message stream. - Note picker (paperclip) in chat input; attached note title passed to store for optimistic render. `context_note_title` synthesised server-side at conversation load via batch `get_notes_by_ids()`; message badge shows note title instead of "Note #N". - LLM-generated conversation titles (re-generated every 10th message) - Save assistant messages as notes (LLM-titled, chat-tagged); summarize conversations - Dedicated `/chat` page with responsive sidebar + slide-out chat panel from header - Model catalog with installed/available tabs, download progress, select, remove - Per-conversation model selection via ModelSelector dropdown in chat header (persisted via PATCH) - Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates - Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST - **Model load state indicator:** `GET /api/chat/status` now queries `/api/tags` and `/api/ps` in parallel. Model status has three values: `"not_found"` (not installed), `"cold"` (installed but not in VRAM — first response will be slow), `"loaded"` (hot in VRAM, fast response). `chatReady` is true for both `"cold"` and `"loaded"` since cold models still work. AppHeader shows five distinct visual states: gray pulse (checking), red (Ollama down), orange (model not installed), yellow pulse (cold), green (loaded). Each state has a short inline text label ("Cold", "Ready", "Offline", etc.) visible without hovering, plus a tooltip with detail. After `warmModel()` is called, a fast 5s polling loop runs for up to 60s until the indicator transitions to green (instead of waiting for the 30s poll cycle). - Hot/cold model indicators: `/api/chat/ps` proxies Ollama `/api/ps`; ModelSelector shows green/red emoji circles - Ollama configured with `OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=30m` - Timeout tuning: connect timeout 30s (cold model loading), warm timeout 300s, pull timeout 1800s - SSE reconnection failure shows error toast instead of silently recovering - **LLM tool calling:** Models with tool support can create tasks, create/update notes, search and list notes/tasks, and manage CalDAV events/todos on behalf of the user during chat. Multi-round tool loop (max 5 rounds) allows the LLM to execute tools and then produce a natural language response incorporating results. Tool call results are persisted in message `tool_calls` JSONB column and rendered as compact ToolCallCard components. SSE emits `tool_call` events for real-time rendering. System prompt includes today's date for relative date resolution. Graceful degradation: models without tool support respond normally. Full tool suite: - `create_task` / `create_note` — create new items - `update_note` — edit note content (replace/append) AND update task fields: `status`, `priority`, `due_date`, `tags` (with `tag_mode`: replace/add/remove); finds by exact title first, falls back to fuzzy search - `delete_note` / `delete_task` — permanently delete (require user confirmation via confirm UI; validates type so delete_note won't delete a task and vice versa; clears note context cache) - `get_note` — retrieve full note body by title/keyword (search_notes only returns 200-char preview) - `list_notes` — browse notes by recency/keyword/tags with configurable limit; notes only (use list_tasks for tasks) - `list_tasks` — filter tasks by `status`, `priority`, `due_before`, `due_after`, `limit`; backed by `list_notes(is_task=True)`; enables "overdue tasks", "high priority", "in progress" queries - `search_notes` — keyword search across notes and tasks; optional `type: "note"|"task"` filter - Full CalDAV suite: `create_event`, `list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo` (`search_todos` keyword-filters the todo list — companion to `list_todos`) - **Streaming status transparency:** The backend emits `status` SSE events at each pipeline stage so the user always sees what's happening instead of a blank progress dot. Stages: (1) `"Generating response..."` immediately after context is built (no blocking wait for intent); (2) human-readable tool label (e.g. `"Creating calendar event..."`) before each tool executes (both intent-routed and native); (3) `"Composing response..."` before tool follow-up rounds. Frontend: `chat.ts` stores `streamingStatus` ref, cleared on first content chunk or on done/error. `ChatView.vue` shows a pulsing dot + italic label above the content while status is active, then falls back to the blinking cursor when content streams in. `HomeView.vue` dashboard panel shows the status label in place of `...` before any content arrives. - **Intent routing (optimistic streaming):** On the first round, the backend races intent classification against the start of the LLM stream using `asyncio.wait(FIRST_COMPLETED)`. The LLM stream is immediately started into an `asyncio.Queue` while `classify_intent()` runs concurrently. If the stream produces its first token before classification completes, the intent task is cancelled and the user sees tokens immediately (zero blocking for pure chat). If classification finishes first and detects a tool call, the stream is cancelled and the tool executes directly — bypassing the model's native (sometimes unreliable) tool calling API. Falls through to normal streaming when no tool is detected or classification fails. Dedicated intent model configurable via `OLLAMA_INTENT_MODEL` env var (default `qwen2.5:1.5b`) or per-user `intent_model` setting — smaller/faster model for routing. Main model default is `qwen3:latest` (configurable via `OLLAMA_MODEL` env var or per-user `default_model` setting — both settable as dropdowns in the Settings UI populated from installed models). `OLLAMA_NUM_CTX` env var (default 8192) controls the KV cache context window for all generation calls; reducing from 32768 cuts VRAM usage ~4x without affecting most conversations. Supports confidence levels ("high"/"medium"/"low") — low-confidence intents fall through to streaming. Passes last 6 user/assistant turns as history for anaphora resolution ("move it", "cancel that"). Intent router rules cover: update/delete events, CalDAV todos, time-period → list_events (not search_events), update_note vs create_note disambiguation, reminder_minutes conversion, delete_note vs delete_task disambiguation, get_note for "read/show me this note", list_notes for "browse/list notes", tag management via update_note (tag_mode add/remove), search_todos. - **CalDAV calendar integration:** Per-user CalDAV settings (URL, username, password, calendar name, timezone). LLM tools: `create_event` (all_day, recurrence, timezone, reminder_minutes, attendees, calendar_name), `list_events`, `search_events`, `update_event`, `delete_event`, `list_calendars`, `create_todo`, `list_todos`, `search_todos`, `update_todo`, `complete_todo`, `delete_todo`. All-day events use iCalendar DATE values; recurrence uses RRULE (e.g. `FREQ=YEARLY`). VALARM components added for reminders. Attendees via mailto: vCalAddress. Multi-calendar search: when no specific calendar configured, all calendars are scanned. Runs synchronous caldav library calls in asyncio executor. Settings UI for CalDAV config including timezone. - **LLM-suggested tags:** Backend service (`tag_suggestions.py`) prompts LLM with existing user tags and note content, returns 3-5 relevant tag suggestions. Tags already in body are filtered out. Exposed via `POST /api/notes/suggest-tags` and `POST /api/notes/:id/append-tag`. Integrated in: (1) Editor views — "Suggest tags" button shows clickable pills that insert `#tag` into body; (2) Chat tool calls — `create_note`/`create_task` results include `suggested_tags`, rendered as pills in ToolCallCard with one-click apply via append-tag API. ### Authentication & User Management - Session cookie auth with bcrypt, first-user-is-admin, orphaned data claiming - Per-user data isolation across all resources - Registration auto-closes after first user; admin toggle; invitation-based registration - Email invitation system: admin sends invite → branded email → `/register-invite` token flow - Password reset: email-based, SHA256-hashed tokens, 1-hour expiry - Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag - Admin user management: list, delete, invite, revoke - **OAuth/OIDC SSO (Phase 18):** Authorization Code + PKCE flow via any OIDC provider (Authentik, Keycloak, etc.) - `GET /api/auth/oauth/login` → generates state + PKCE verifier, stores in session, redirects to provider - `GET /api/auth/oauth/callback` → exchanges code, fetches userinfo, sets session, redirects to `/` - Account linking: sub lookup → email auto-link → auto-provision with collision-safe username - `LOCAL_AUTH_ENABLED=false` hides password form and blocks `POST /api/auth/login|register` - OAuth-only users have `password_hash = NULL`; `authenticate()` returns None for them - OIDC discovery response cached in-process after first fetch; no new Python dependencies (`httpx` already present) - Config: `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` (or `_FILE`), `OIDC_SCOPES` - **Email change:** `PUT /api/auth/email` — requires current password for local-auth users; OAuth-only users can change freely; checks email uniqueness; updates `authStore.user` in-place ### Notifications & Email - SMTP email service (aiosmtplib): STARTTLS (587) and implicit TLS (465) - Security alerts: login, failed login, logout, password change (fire-and-forget) - Task due date reminders: hourly background check, grouped per user, dedup via logs - Invitation and password reset emails with branded HTML templates - Per-user notification preferences (task reminders, security alerts) - Admin SMTP configuration via Settings UI with test email - **Shared email template** (`_email_html` in `email.py`): gray outer background, white card, indigo header with inline SVG logo (`_EMAIL_LOGO_SVG`) + "Fabled Assistant" wordmark, content area, footer; used by all six email types (security alert, password reset, reset success, invitation, task reminder, test email) ### Logging & Observability - Single `app_logs` table: audit (security events), usage (API requests), error (unhandled exceptions) - Request logging middleware with timing (skips log endpoints to avoid recursion) - Admin log viewer: stats summary, category/search/date filters, paginated table, expandable JSON details - Configurable retention via `LOG_RETENTION_DAYS` (default 90), hourly cleanup ### Settings & Admin - Per-user key-value settings store (assistant name, default model, notification prefs) - Admin: backup/restore (full or per-user JSON), SMTP config, base URL, registration toggle, user management, log viewer - Configurable base URL for email links (admin setting, env var fallback) ### UI & Theming - Dark/light theme with CSS custom properties, design tokens, `prefers-color-scheme` detection; light mode uses indigo brand color (`--color-primary: #6366f1`) and indigo-tinted off-white backgrounds (`--color-bg: #f5f5fb`, `--color-bg-secondary: #ededf5`) with white cards to pop content - Responsive design: breakpoints (480/768/1024), hamburger menu, mobile touch targets (44px), sidebar overlays - App shell: navbar always visible, 100dvh flex layout, all views fit within viewport - Toast notifications (success/error/warning, 4s auto-dismiss) - DOMPurify sanitization on all rendered markdown - **Site-wide max-width:** List/chat/settings views 1200px; editor pages 1400px; note/task viewer layout 1400px (content area 1100px) - **Favicon:** `frontend/public/favicon.svg` — light mode uses indigo brand color (`#6366f1` fill, `#4f46e5` stroke, `#a5b4fc` lines); dark mode uses light gray; gold sparkle always visible; `@media (prefers-color-scheme: dark)` in SVG `