# 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-10 — Phase 4 complete (LLM Chat Integration — streaming chat with Ollama, note-aware context, save/summarize as note) ## 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. - **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" sets `status='todo'`; "convert to note" clears `status`, `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 uses `POST /api/notes/resolve-title` to auto-create missing notes. - **SSE over WebSockets for LLM streaming:** Uses `POST` with `text/event-stream` response (not EventSource). Frontend uses `fetch()` + `ReadableStream` since we need to POST the message body. Simpler than WebSockets; Quart supports async generators natively for SSE. - **Context building server-side:** Backend fetches URL content and searches notes — frontend just sends the message text + optional note ID. Keyword extraction uses simple word splitting with stopword filtering (no embeddings). - **Auto-pull model on startup:** Non-blocking, logs warning on failure so the app still starts even if Ollama isn't ready. - **Backlinks:** `GET /api/notes/:id/backlinks` searches all note bodies for `[[Title]]` patterns referencing the given note. Results include `type: "note"` or `type: "task"` based on whether the linking note has `status IS NOT NULL`. - **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 (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`** — the `is_task` property checks this - 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 - `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.py` module is a thin wrapper around `services/notes.py` that passes `is_task=True` for listing and defaults `status='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` (not `description`) — standardized with notes ### Conversations - `id` (int PK), `title` (str), `model` (str), `created_at`, `updated_at` - Has many messages (cascade delete) - Title auto-generated from first user message if not set - `to_dict()` returns: `id`, `title`, `model`, `message_count`, `created_at`, `updated_at` ### Messages - `id` (int PK), `conversation_id` (FK to conversations, CASCADE), `role` (str: system/user/assistant), `content` (str), `context_note_id` (nullable FK to notes, SET NULL), `created_at` - Index on `conversation_id` - `context_note_id` tracks which note was attached as context when message was sent - `to_dict()` returns: `id`, `conversation_id`, `role`, `content`, `context_note_id`, `created_at` ## 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 │ └── 0005_add_chat_tables.py # Conversations + messages tables with FKs and indexes ├── 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 + Conversation + Message │ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, status, priority, due_date, timestamps) + TaskStatus/TaskPriority enums + is_task property │ │ └── conversation.py # Conversation + Message models with relationships and to_dict() │ ├── routes/ │ │ ├── __init__.py │ │ ├── api.py # /api blueprint with /health endpoint │ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming, save/summarize as note, models list │ │ ├── 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.) │ │ ├── llm.py # Ollama interaction: ensure_model, stream_chat, generate_completion, fetch_url_content, build_context (keyword extraction, note search, URL fetching) │ │ └── chat.py # Conversation CRUD, add_message, save_response_as_note, summarize_conversation_as_note │ ├── 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 + ChatPanel (slide-out) + 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 + apiStreamPost (SSE via fetch + ReadableStream) │ ├── 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) │ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming), saveMessageAsNote, summarizeAsNote, fetchModels │ │ └── toast.ts # Toast notification state, 3s auto-dismiss │ ├── types/ │ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse │ │ ├── chat.ts # Message, Conversation, ConversationDetail, OllamaModel interfaces │ │ └── 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/ │ │ ├── ChatView.vue # Dedicated /chat page: sidebar (conversation list) + message thread + input + summarize │ │ ├── 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 + Chat links, chat panel toggle, theme toggle │ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop) │ │ ├── ChatMessage.vue # Message bubble: markdown rendering, role label, "Save as Note" action on assistant messages │ │ ├── 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/*, /chat, /chat/:id └── 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) | | GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) | | POST | `/api/chat/conversations` | Create conversation (body: `{title?, model?}`) | | GET | `/api/chat/conversations/:id` | Get conversation with all messages | | DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) | | PATCH | `/api/chat/conversations/:id` | Update conversation title (body: `{title}`) | | POST | `/api/chat/conversations/:id/messages` | Send message + stream LLM response via SSE (body: `{content, context_note_id?}`) | | POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as a new note | | POST | `/api/chat/conversations/:id/summarize` | Summarize conversation via LLM, save as note | | GET | `/api/chat/models` | List available Ollama models | ## 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 → 0005_add_chat_tables.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/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 ## 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/tags - [x] Vue views: task list (search, status/priority filters, sort, pagination), editor (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` ### Phase 3.5 — Note-Task Integration + Bug Fixes ✓ - [x] **Wikilink auto-create:** Clicking `[[New Page]]` creates the note if it doesn't exist - [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 ### Phase 3.6 — Merge Tasks into Notes ✓ - [x] **Unified note/task model:** Task is just a note with `status IS NOT NULL` - [x] **Migration 0004:** Added `status`, `priority`, `due_date` columns to notes table, migrated task data from companion notes and orphan tasks, dropped `tasks` table - [x] **Eliminated companion note system:** No more companion note creation, title sync, cascade deletes, or `_skip_cascade` flags - [x] **Standardized on `body`:** Tasks use `body` everywhere (not `description`); API accepts `description` as fallback - [x] **Convert to task:** Simple `update_note(id, status='todo', priority='none')` — same ID preserved - [x] **Convert to note:** New `POST /api/notes/:id/convert-to-note` endpoint clears task attributes - [x] **Tasks service as thin wrappers:** `services/tasks.py` delegates entirely to `services/notes.py` - [x] **Frontend unified types:** `Task` is a re-export alias for `Note`; `note.ts` defines `TaskStatus`, `TaskPriority` - [x] **Simplified views:** Removed companion note UI from TaskEditorView/TaskViewerView/NoteViewerView; added Convert to Note button on TaskViewerView ### Phase 4 — LLM Chat Integration ✓ - [x] **Ollama client:** async HTTP via httpx — `ensure_model()` (auto-pull on startup), `stream_chat()` (streaming NDJSON), `generate_completion()` (non-streaming) - [x] **Context building:** System prompt with note-aware context — includes current note (via `context_note_id`), keyword-extracted related note search (top 3), URL content fetching (regex detection, HTML stripping, truncated to 4000 chars) - [x] **Conversation persistence:** `conversations` + `messages` tables in PostgreSQL, full CRUD - [x] **SSE streaming endpoint:** `POST /api/chat/conversations/:id/messages` streams LLM response chunks as SSE events, saves complete response to DB on finish - [x] **Save as note:** Save any assistant message as a new note (first line as title) - [x] **Summarize conversation:** Send full conversation to LLM with summarize prompt, save result as note - [x] **Model management:** `GET /api/chat/models` lists available Ollama models; auto-pull default model (`llama3.1`) on app startup - [x] **Dedicated chat page:** `/chat` with sidebar (conversation list, create/delete) + message thread + markdown rendering + streaming indicator - [x] **Slide-out chat panel:** Toggle from header, receives `contextNoteId` from current route (`/notes/:id` or `/tasks/:id`), auto-creates conversation on first message - [x] **Frontend SSE client:** `apiStreamPost()` uses fetch + ReadableStream to parse SSE data lines - [x] **Auto-title:** Conversation title auto-set from first user message content ### 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 - 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? ## Current Status **Phase:** Phase 4 complete. LLM chat integration with streaming responses. - Full note-taking and task-tracking CRUD with markdown, wikilinks, backlinks, tags - Unified note/task model (a task is a note with `status IS NOT NULL`) - LLM chat via Ollama with SSE streaming responses - Note-aware context: auto-includes current note + searches related notes by keyword - URL content fetching: detects URLs in messages, fetches and includes content - Save assistant messages as notes; summarize entire conversations as notes - Dedicated `/chat` page with conversation sidebar + message thread - Slide-out chat panel accessible from any page, auto-includes note/task context - Auto-pull default model (`llama3.1`) on startup - Dark/light theme with CSS custom properties - Ready for Phase 5: Polish & Production Hardening