A task is now a note with status/priority/due_date columns set (status IS NOT NULL). This eliminates the separate tasks table, companion note system, cascade deletes, bidirectional title sync, and _skip_cascade flags. Migration (0004): - Add status, priority, due_date columns to notes table - Migrate task data from companion notes and orphan tasks - Drop tasks table and old enum types Backend: - models/note.py: Add TaskStatus/TaskPriority enums, task columns, is_task property - models/task.py: Deleted (merged into note.py) - models/__init__.py: Re-export enums from note.py, remove Task import - services/notes.py: Remove companion/cascade logic, add is_task filter, convert_note_to_task, convert_task_to_note, simplified backlinks - services/tasks.py: Rewritten as thin wrappers around notes service - routes/notes.py: Add is_task filter (default false), task fields in CRUD, convert-to-note endpoint - routes/tasks.py: description→body (with fallback), remove note_id filter Frontend: - types/note.ts: Add TaskStatus, TaskPriority, task fields to Note interface - types/task.ts: Task is now a re-export alias for Note - stores/notes.ts: Simplify convertToTask, add convertToNote - stores/tasks.ts: description→body in createTask/updateTask - TaskEditorView: description→body, remove companion note UI - TaskViewerView: description→body, remove companion note link, add Convert to Note - NoteViewerView: Remove companion task UI, simplify convert-to-task - TaskCard: description→body, non-null assertions for status/priority Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
24 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.
Session Checklist
IMPORTANT: At the end of every change session, you MUST:
- Update this file (
summary.md) to reflect all architectural, data model, API, file structure, and roadmap changes made during the session.- 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-10 — Phase 3.6 complete (Merged tasks into notes — a task is just a note with task attributes)
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.pyuses@app.errorhandler(404)(not a catch-all route) to serve static files or fall back toindex.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
#tagsyntax (Obsidian-style), not manually entered. Backend is source of truth for tag extraction. - Hierarchical tags:
#project/webappstored as"project/webapp". Filtering byprojectmatches bothprojectandproject/*children via SQLunnest+LIKEprefix. - Dark-first theming: CSS custom properties on
:root(light) and[data-theme="dark"], withprefers-color-schemedetection defaulting to dark. - Unified note/task model: A task is just a note with task attributes enabled.
A note has
status IS NOT NULL⟹ it's a task. "Convert to task" setsstatus='todo'; "convert to note" clearsstatus,priority,due_date. No companion notes, no cascade deletes, no bidirectional sync needed. - Wikilinks: Obsidian-style
[[Title]]and[[Title|Display Text]]in markdown bodies. Clicking a wikilink usesPOST /api/notes/resolve-titleto auto-create missing notes. - Backlinks:
GET /api/notes/:id/backlinkssearches all note bodies for[[Title]]patterns referencing the given note. Results includetype: "note"ortype: "task"based on whether the linking note hasstatus IS NOT NULL. - Idempotent raw SQL migrations: All Alembic migrations use raw SQL with
CREATE TABLE IF NOT EXISTS,CREATE INDEX IF NOT EXISTS, andDO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_objectto 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 (unified — includes tasks)
id(int PK),title(str),body(markdown str),tags(ARRAY[str]),parent_id(nullable FK to self),status(nullable str —todo/in_progress/done),priority(nullable str —none/low/medium/high),due_date(nullable date),created_at,updated_at- A note is a task when
status IS NOT NULL— theis_taskproperty checks this - Tags are auto-extracted from body text on create/update via
#tagregex - 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 to_dict()returns:id,title,body,tags,parent_id,status,priority,due_date,is_task,created_at,updated_at- Indexes: GIN on tags, B-tree on status
Task ≡ Note with task attributes
- No separate tasks table. The
services/tasks.pymodule is a thin wrapper aroundservices/notes.pythat passesis_task=Truefor listing and defaultsstatus='todo',priority='none'for creation. - "Convert to task" =
update_note(id, status='todo', priority='none') - "Convert to note" =
update_note(id, status=None, priority=None, due_date=None) - Task body field is
body(notdescription) — standardized with notes
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
│ └── 0004_merge_tasks_into_notes.py # Add task columns to notes, migrate data, drop tasks table
├── 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 + TaskStatus + TaskPriority
│ │ └── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint
│ │ ├── notes.py # /api/notes CRUD + /by-title + /resolve-title + /convert-to-task + /convert-to-note + /backlinks
│ │ └── tasks.py # /api/tasks CRUD + PATCH status (thin wrappers, accepts body not description)
│ ├── services/
│ │ ├── notes.py # CRUD, is_task filter, status/priority filters, convert_note_to_task, convert_task_to_note, get_backlinks
│ │ └── tasks.py # Thin wrappers around notes.py (create_task, list_tasks with is_task=True, etc.)
│ ├── 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, convertToNote, fetchBacklinks, fetchAllTags
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (uses body not description)
│ │ └── toast.ts # Toast notification state, 3s auto-dismiss
│ ├── types/
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ └── task.ts # Task = re-export of Note; 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, convert-to-task (only when !is_task), backlinks
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination, status toggle
│ │ ├── TaskEditorView.vue # Create/edit task: fields (body not description), Ctrl+S, dirty guard, autocomplete
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges, convert-to-note button, 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 (body not description), 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; defaults to is_task=false — plain notes only; ?is_task=true for tasks, ?all=true for everything) |
| POST | /api/notes |
Create note (body: {title, body, status?, priority?, due_date?} — tags auto-extracted) |
| GET | /api/notes/tags |
List all tags from notes table (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 (response includes is_task, status, priority, due_date) |
| PUT | /api/notes/:id |
Update note (body: {title?, body?, status?, priority?, due_date?} — tags re-extracted if body changes) |
| DELETE | /api/notes/:id |
Delete note (simple delete, no cascade) |
| POST | /api/notes/:id/convert-to-task |
Set status='todo', priority='none' on note (returns 200) |
| POST | /api/notes/:id/convert-to-note |
Clear status, priority, due_date from note (returns 200) |
| GET | /api/notes/:id/backlinks |
List notes/tasks that reference this note via wikilinks |
| GET | /api/tasks |
List tasks (params: q, tag, status, priority, sort, order, limit, offset) — queries notes where status IS NOT NULL |
| POST | /api/tasks |
Create task (body: {title, body, status?, priority?, due_date?} — accepts description as fallback for body) |
| GET | /api/tasks/:id |
Get single task |
| PUT | /api/tasks/:id |
Update task (accepts body or description, prefers body) |
| PATCH | /api/tasks/:id/status |
Quick status toggle (body: {status}) |
| DELETE | /api/tasks/:id |
Delete task (simple delete) |
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 → 0004_merge_tasks_into_notes.py
How Migrations Run
The Dockerfile CMD runs:
(alembic stamp --purge base 2>/dev/null || true) && alembic upgrade head && hypercorn ...
alembic stamp --purge baseclears any stale revision stamps (safe no-op if none)alembic upgrade headruns 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:
-
Create the migration file:
alembic/versions/0005_description.py -
Use raw SQL for idempotency:
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") -
Rebuild and test:
docker compose up --buildTo test from a completely fresh database:
docker compose down -v && docker compose up --build
Important Notes
- Do NOT use
op.create_table()orsa.Enum()— SQLAlchemy's event system can fireCREATE TYPEeven withcreate_type=False, causing failures on re-run - Always write raw SQL with PostgreSQL idempotency guards
- Set
down_revisionto chain from the previous migration'srevision - 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/healthendpoint - 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
#tagextraction from body text (backend regex, skips code fences) - Hierarchical tag filtering (
#projectmatchesprojectandproject/*) - 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
#taglinks)
Phase 3 — Tasks CRUD + Wikilinks ✓
- 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/tags
- Vue views: task list (search, status/priority filters, sort, pagination), editor (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
Phase 3.5 — Note-Task Integration + Bug Fixes ✓
- Wikilink auto-create: Clicking
[[New Page]]creates the note if it doesn't exist - 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 headbefore starting app
Phase 3.6 — Merge Tasks into Notes ✓
- Unified note/task model: Task is just a note with
status IS NOT NULL - Migration 0004: Added
status,priority,due_datecolumns to notes table, migrated task data from companion notes and orphan tasks, droppedtaskstable - Eliminated companion note system: No more companion note creation, title sync, cascade deletes, or
_skip_cascadeflags - Standardized on
body: Tasks usebodyeverywhere (notdescription); API acceptsdescriptionas fallback - Convert to task: Simple
update_note(id, status='todo', priority='none')— same ID preserved - Convert to note: New
POST /api/notes/:id/convert-to-noteendpoint clears task attributes - Tasks service as thin wrappers:
services/tasks.pydelegates entirely toservices/notes.py - Frontend unified types:
Taskis a re-export alias forNote;note.tsdefinesTaskStatus,TaskPriority - Simplified views: Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView
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.6 complete. Tasks merged into notes — unified single-table model.
- Single
notestable with optionalstatus,priority,due_datecolumns - A note is a task when
status IS NOT NULL - Convert between note ↔ task by setting/clearing task attributes (same ID preserved)
- No companion notes, no cascade deletes, no bidirectional sync
- Task body standardized as
body(notdescription) services/tasks.pyis a thin wrapper aroundservices/notes.py- Frontend
Tasktype is an alias forNote - Full notes CRUD with markdown editing, rendering, and wikilinks
- Full tasks CRUD with status/priority enums and filters
- Backlinks, wikilink auto-create, tag autocomplete all work across unified model
- Dark/light theme with status/priority/wikilink color variables
- Ready for Phase 4: LLM Integration