# 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.** ## Last Updated 2026-02-09 — Phase 3.5 complete (Task-Note companions, wikilink auto-create, backlinks, bug fixes, Alembic migration infrastructure) ## 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. - **Hierarchical tags:** `#project/webapp` stored as `"project/webapp"`. Filtering by `project` matches both `project` and `project/*` children via SQL `unnest` + `LIKE` prefix. - **Dark-first theming:** CSS custom properties on `:root` (light) and `[data-theme="dark"]`, with `prefers-color-scheme` detection defaulting to dark. - **Task-Note companion link:** Every task automatically gets a companion note (created in `create_task()`). Title changes sync bidirectionally. Deleting either cascades to the other. `_skip_cascade` flag prevents infinite loops. - **Wikilinks:** Obsidian-style `[[Title]]` and `[[Title|Display Text]]` in markdown bodies. Clicking a wikilink uses `POST /api/notes/resolve-title` to auto-create missing notes. - **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies and task descriptions for `[[Title]]` patterns referencing the given note. - **Idempotent raw SQL migrations:** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs and fresh database creation. ### High-Level Component Diagram ``` ┌─────────────────────────────────────────────┐ │ Docker Compose │ │ │ │ ┌──────────────────────┐ ┌────────────┐ │ │ │ fabledassistant │ │ ollama │ │ │ │ ┌────────────────┐ │ │ │ │ │ │ │ Quart Server │ │ │ LLM API │ │ │ │ │ ┌──────────┐ │ │ │ │ │ │ │ │ │ Vue SPA │ │ │ └────────────┘ │ │ │ │ │ (static) │ │ │ ▲ │ │ │ │ └──────────┘ │ │ │ │ │ │ │ ┌──────────┐ │ │ HTTP/REST │ │ │ │ │ /api/* │──┼──┼─────────┘ │ │ │ │ └──────────┘ │ │ │ │ │ │ │ │ │ ┌────────────┐ │ │ │ │ ▼ │ │ │ PostgreSQL │ │ │ │ │ ┌──────────┐ │ │ │ 16 │ │ │ │ │ │ asyncpg │──┼──┼──▶ │ │ │ │ │ └──────────┘ │ │ └────────────┘ │ │ │ └────────────────┘ │ │ │ └──────────────────────┘ │ └─────────────────────────────────────────────┘ ``` ## Data Model ### Notes (implemented) - `id` (int PK), `title` (str), `body` (markdown str), `tags` (ARRAY[str]), `parent_id` (nullable FK to self), `created_at`, `updated_at` - Tags are auto-extracted from body text on create/update via `#tag` regex - Supports hierarchical organization via `parent_id` - Lookup by exact title via `get_note_by_title()` for wikilink resolution - Auto-create via `get_or_create_note_by_title()` for wikilink clicks ### Tasks (implemented) - `id` (int PK), `title` (str), `description` (markdown str), `status` (enum: todo/in_progress/done), `priority` (enum: none/low/medium/high), `due_date` (date, nullable), `note_id` (FK to notes, auto-created companion), `tags` (ARRAY[str]), `created_at`, `updated_at` - Tags auto-extracted from description on create/update - Status enum with quick-toggle cycling: todo → in_progress → done → todo - Companion note auto-created on task creation, title synced bidirectionally - Deleting a task deletes its companion note (and vice versa) - Indexes: GIN on tags, B-tree on note_id, B-tree on status ### LLM Interactions (Phase 4) - Summarize notes, generate task breakdowns, search/query across notes, chat-style assistant within the app ## Project Structure (Current) ``` fabledassistant/ ├── summary.md # This file — canonical project context ├── pyproject.toml # Python project config ├── Dockerfile # Multi-stage build (Node → Python) ├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) ├── alembic.ini # Alembic config (prepend_sys_path = src) ├── alembic/ │ ├── env.py # Async migration runner │ └── versions/ │ ├── 0001_create_notes_table.py # Notes table (raw SQL, idempotent) │ ├── 0002_create_tasks_table.py # Tasks table + enums (raw SQL, idempotent) │ └── 0003_task_note_companion.py # Data migration: create companion notes for existing tasks ├── src/ │ └── fabledassistant/ │ ├── __init__.py │ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API │ ├── config.py # Config from env vars │ ├── models/ │ │ ├── __init__.py # async_session factory, Base, imports Note + Task │ │ ├── note.py # Note model (id, title, body, tags[], parent_id, timestamps) │ │ └── task.py # Task model (id, title, description, status, priority, due_date, note_id, tags, timestamps) │ ├── routes/ │ │ ├── __init__.py │ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /backlinks │ │ └── tasks.py # /api/tasks CRUD + PATCH status │ ├── services/ │ │ ├── notes.py # CRUD, tag filter, get_or_create_by_title, convert_note_to_task, get_backlinks, cascade delete/sync │ │ └── tasks.py # CRUD with auto companion note, cascade delete/sync, _skip_cascade flag │ ├── utils/ │ │ ├── __init__.py │ │ └── tags.py # extract_tags() — regex #tag extraction, skips code fences │ └── static/ # Vue production build (generated by Dockerfile) └── frontend/ ├── package.json # deps: vue, pinia, vue-router, marked, dompurify ├── vite.config.ts ├── tsconfig.json ├── src/ │ ├── App.vue # Shell: AppHeader + router-view + ToastNotification │ ├── main.ts # App init, imports theme.css │ ├── assets/ │ │ └── theme.css # CSS custom properties: light/dark themes, body reset │ ├── api/ │ │ └── client.ts # apiGet/apiPost/apiPut/apiPatch/apiDelete helpers │ ├── composables/ │ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme │ │ └── useAutocomplete.ts # #tag + [[wikilink]] autocomplete: Tab cycling, debounced search │ ├── stores/ │ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, fetchBacklinks, fetchAllTags │ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus │ │ └── toast.ts # Toast notification state, 3s auto-dismiss │ ├── types/ │ │ ├── note.ts # Note, NoteListResponse interfaces │ │ └── task.ts # Task, TaskStatus, TaskPriority, TaskListResponse │ ├── utils/ │ │ ├── tags.ts # extractTags(), linkifyTags(), linkifyWikilinks() │ │ └── markdown.ts # renderMarkdown() (full), renderPreview() (strips links/images for cards) │ ├── views/ │ │ ├── HomeView.vue # Landing page: recent notes + tasks (independent error handling) │ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination │ │ ├── NoteEditorView.vue # Create/edit: Ctrl+S, unsaved guard, toasts, autocomplete │ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, companion task link, convert-to-task, backlinks │ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle │ │ ├── TaskEditorView.vue # Create/edit task: fields, companion note link, Ctrl+S, dirty guard, autocomplete │ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, companion note link, backlinks │ ├── components/ │ │ ├── AppHeader.vue # Nav bar: brand, Notes + Tasks links, theme toggle │ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit │ │ ├── TaskCard.vue # Card with rendered preview, StatusBadge (clickable), PriorityBadge, due date, tags │ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling │ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none") │ │ ├── MarkdownToolbar.vue # Bold/italic/link/list/heading toolbar for editor │ │ ├── SearchBar.vue # Debounced search input │ │ ├── TagPill.vue # Clickable/dismissible tag pill │ │ ├── PaginationBar.vue # Prev/next + page numbers │ │ └── ToastNotification.vue # Fixed-position toast container │ └── router/ │ └── index.ts # Routes: /, /notes/*, /tasks/* └── public/ ``` ## API Endpoints (Current) | Method | Path | Description | |--------|------|-------------| | GET | `/api/health` | Health check | | GET | `/api/notes` | List notes (params: `q`, `tag`, `sort`, `order`, `limit`, `offset`) | | POST | `/api/notes` | Create note (body: `{title, body}` — tags auto-extracted) | | GET | `/api/notes/tags` | List all tags from notes + tasks (param: `q` for filter) | | GET | `/api/notes/by-title?title=...` | Resolve note by exact title (case-insensitive) | | POST | `/api/notes/resolve-title` | Get-or-create note by title (for wikilink clicks) | | GET | `/api/notes/:id` | Get single note | | PUT | `/api/notes/:id` | Update note (body: `{title?, body?}` — tags re-extracted if body changes) | | DELETE | `/api/notes/:id` | Delete note (cascades to companion task) | | POST | `/api/notes/:id/convert-to-task` | Convert note into a task (deletes original note) | | GET | `/api/notes/:id/backlinks` | List notes/tasks that reference this note via wikilinks | | GET | `/api/tasks` | List tasks (params: `q`, `tag`, `status`, `priority`, `note_id`, `sort`, `order`, `limit`, `offset`) | | POST | `/api/tasks` | Create task (body: `{title, description, status?, priority?, due_date?}` — companion note auto-created) | | GET | `/api/tasks/:id` | Get single task | | PUT | `/api/tasks/:id` | Update task (title syncs to companion note) | | PATCH | `/api/tasks/:id/status` | Quick status toggle (body: `{status}`) | | DELETE | `/api/tasks/:id` | Delete task (cascades to companion note) | ## Alembic Migrations ### Overview Migrations use raw SQL for full idempotency — safe to run on a fresh database or re-run on an existing one. The Dockerfile runs migrations automatically on container startup. ### Migration Chain ``` 0001_create_notes_table.py → 0002_create_tasks_table.py → 0003_task_note_companion.py ``` ### How Migrations Run The Dockerfile CMD runs: ```sh (alembic stamp --purge base 2>/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/0004_description.py ``` 2. **Use raw SQL for idempotency:** ```python from alembic import op revision = "0004" down_revision = "0003" 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 ## Phased Roadmap ### Phase 1 — Skeleton & Dev Environment ✓ - [x] Initialize Python project (pyproject.toml, Quart app scaffold) - [x] Initialize Vue.js project (Vite-based, inside `frontend/`) - [x] Set up Dockerfile (multi-stage: build Vue, serve with Quart) - [x] Docker Compose stack with Ollama service - [x] Quart serves Vue static build + `/api/health` endpoint - [x] Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic) ### Phase 2 — Notes CRUD + UX ✓ - [x] Database model for notes (title, body, tags[], parent_id, timestamps) - [x] REST API: create, read, update, delete, list notes with pagination - [x] Vue views: note list, note editor (markdown), note viewer (rendered) - [x] Search (ILIKE on title/body) and tag filtering (hierarchical via unnest) - [x] Inline `#tag` extraction from body text (backend regex, skips code fences) - [x] Hierarchical tag filtering (`#project` matches `project` and `project/*`) - [x] Dark/light theming with CSS custom properties + toggle + localStorage - [x] App header with navigation and theme toggle - [x] Tag pills (clickable + dismissible) on cards, viewer, and list filter bar - [x] Pagination bar with prev/next and page numbers - [x] Sort controls (field + asc/desc) - [x] Toast notifications (success/error, 3s auto-dismiss) - [x] Ctrl+S save shortcut in editor - [x] Unsaved changes guard (route leave + beforeunload) - [x] DOMPurify sanitization on rendered markdown - [x] Inline tag linkification in rendered markdown (clickable `#tag` links) ### Phase 3 — Tasks CRUD + Wikilinks ✓ - [x] Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums - [x] Alembic migration for tasks table with PG enums and indexes - [x] REST API: full CRUD + PATCH status toggle + filter by status/priority/note_id/tags - [x] Task-Note linking via optional `note_id` FK - [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown) - [x] StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components - [x] Obsidian-style wikilinks: `[[Title]]` and `[[Title|Display]]` in rendered markdown - [x] Wikilink click handling resolves notes by title via `/api/notes/by-title` - [x] "Linked Tasks" section on NoteViewerView with inline status toggling - [x] Theme variables for status/priority/wikilink/overdue colors (light + dark) ### Phase 3.5 — Note-Task Integration + Bug Fixes ✓ - [x] **Task-Note companion link:** Every task auto-creates a companion note on creation - [x] **Bidirectional title sync:** Renaming task syncs to companion note and vice versa - [x] **Cascade delete:** Deleting a task deletes its companion note (and vice versa) - [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist - [x] **Convert note to task:** Button on note viewer, creates task with companion note, deletes original - [x] **Backlinks system:** "What links here" section on note and task viewers - [x] **Rendered markdown previews:** NoteCard/TaskCard show rendered markdown (links/images stripped) - [x] **Tag autocomplete Tab cycling:** Tab cycles through suggestions, single match accepts immediately - [x] **HomeView error isolation:** Notes and tasks load independently (one failure doesn't break both) - [x] **SPA routing fix:** Replaced catch-all route with 404 error handler to prevent API interception - [x] **500 error handler:** JSON error responses for API routes, traceback logging - [x] **Idempotent migrations:** All migrations rewritten to raw SQL with IF NOT EXISTS guards - [x] **Auto-migration on startup:** Dockerfile runs `alembic upgrade head` before starting app - [x] **Data migration 0003:** Creates companion notes for pre-existing tasks ### Phase 4 — LLM Integration (next) - [ ] Ollama client in the backend (async HTTP via httpx/aiohttp) - [ ] API endpoints for LLM features (summarize note, generate tasks from note, freeform chat) - [ ] Vue components for LLM interactions (chat panel, summarize button, etc.) - [ ] Configurable LLM endpoint + model selection in app settings ### Phase 5 — Polish & Production Hardening - [ ] Authentication (single-user or multi-user, TBD) - [ ] Docker Swarm production stack (secrets, volumes, networking) - [ ] Backup/restore strategy for data - [ ] UI/UX refinements, responsive design - [ ] Error handling, logging, monitoring ### Future / Stretch - WebSocket streaming for LLM responses - Tagging/labeling system with LLM-suggested tags - Calendar/timeline view for tasks - Import/export (Markdown files, JSON) - Plugin/extension system ## Development Workflow - All development and testing done via Docker: `docker compose up --build` - No local dependency installation — everything containerized - Frontend dev: Vite dev server with proxy to Quart (via Docker) - Production build: Dockerfile multi-stage — Vite builds Vue into static files, Quart serves them - To reset database: `docker compose down -v && docker compose up --build` ## Open Questions - Authentication model: single-user (password-only) vs multi-user? - Should LLM streaming use WebSockets from Phase 4 or defer to Phase 5? ## Current Status **Phase:** Phase 3.5 complete. Note-task companion system, wikilink auto-create, backlinks, and bug fixes fully implemented. - Full notes CRUD with markdown editing, rendering, and wikilinks - Full tasks CRUD with status/priority enums, filters, companion note linking - Automatic companion note creation for every task - Bidirectional title sync and cascade delete between tasks and notes - Convert note to task functionality - Backlinks system showing "what links here" for notes and tasks - Wikilink clicks auto-create missing notes - Rendered markdown previews on list cards - Tag autocomplete with Tab cycling - Idempotent raw SQL migrations with auto-run on startup - Dark/light theme with status/priority/wikilink color variables - Ready for Phase 4: LLM Integration