Files
FabledScribe/summary.md
T
bvandeusen e2338918b0 Add idempotent Alembic migrations, auto-migrate on startup, update summary
Rewrite migrations to raw SQL with IF NOT EXISTS/EXCEPTION guards for
full idempotency. Add notes table migration (0001), fix migration chain,
and run alembic upgrade head in Dockerfile CMD on container start.
Update summary.md with Phase 3.5 changes and Alembic instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 23:55:40 -05:00

22 KiB

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 /<path:path> 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:

(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:

    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:

    docker compose up --build
    

    To test from a completely fresh database:

    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 ✓

  • Initialize Python project (pyproject.toml, Quart app scaffold)
  • Initialize Vue.js project (Vite-based, inside frontend/)
  • Set up Dockerfile (multi-stage: build Vue, serve with Quart)
  • Docker Compose stack with Ollama service
  • Quart serves Vue static build + /api/health endpoint
  • Database setup (PostgreSQL 16, asyncpg, SQLAlchemy 2.0, Alembic)

Phase 2 — Notes CRUD + UX ✓

  • Database model for notes (title, body, tags[], parent_id, timestamps)
  • REST API: create, read, update, delete, list notes with pagination
  • Vue views: note list, note editor (markdown), note viewer (rendered)
  • Search (ILIKE on title/body) and tag filtering (hierarchical via unnest)
  • Inline #tag extraction from body text (backend regex, skips code fences)
  • Hierarchical tag filtering (#project matches project and project/*)
  • Dark/light theming with CSS custom properties + toggle + localStorage
  • App header with navigation and theme toggle
  • Tag pills (clickable + dismissible) on cards, viewer, and list filter bar
  • Pagination bar with prev/next and page numbers
  • Sort controls (field + asc/desc)
  • Toast notifications (success/error, 3s auto-dismiss)
  • Ctrl+S save shortcut in editor
  • Unsaved changes guard (route leave + beforeunload)
  • DOMPurify sanitization on rendered markdown
  • Inline tag linkification in rendered markdown (clickable #tag links)
  • Task model with status (todo/in_progress/done) and priority (none/low/medium/high) enums
  • Alembic migration for tasks table with PG enums and indexes
  • REST API: full CRUD + PATCH status toggle + filter by status/priority/note_id/tags
  • Task-Note linking via optional note_id FK
  • Vue views: task list (search, status/priority filters, sort, pagination), editor (note-link autocomplete, Ctrl+S, dirty guard), viewer (rendered markdown)
  • StatusBadge (clickable, cycles status), PriorityBadge, TaskCard components
  • Obsidian-style wikilinks: [[Title]] and [[Title|Display]] in rendered markdown
  • Wikilink click handling resolves notes by title via /api/notes/by-title
  • "Linked Tasks" section on NoteViewerView with inline status toggling
  • Theme variables for status/priority/wikilink/overdue colors (light + dark)

Phase 3.5 — Note-Task Integration + Bug Fixes ✓

  • Task-Note companion link: Every task auto-creates a companion note on creation
  • Bidirectional title sync: Renaming task syncs to companion note and vice versa
  • Cascade delete: Deleting a task deletes its companion note (and vice versa)
  • Wikilink auto-create: Clicking [[New Page]] creates the note if it doesn't exist
  • Convert note to task: Button on note viewer, creates task with companion note, deletes original
  • Backlinks system: "What links here" section on note and task viewers
  • Rendered markdown previews: NoteCard/TaskCard show rendered markdown (links/images stripped)
  • Tag autocomplete Tab cycling: Tab cycles through suggestions, single match accepts immediately
  • HomeView error isolation: Notes and tasks load independently (one failure doesn't break both)
  • SPA routing fix: Replaced catch-all route with 404 error handler to prevent API interception
  • 500 error handler: JSON error responses for API routes, traceback logging
  • Idempotent migrations: All migrations rewritten to raw SQL with IF NOT EXISTS guards
  • Auto-migration on startup: Dockerfile runs alembic upgrade head before starting app
  • 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