130 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-03-12 — Daily Briefing feature (full stack), CI release process fix, CalVer version tracking, content deduplication, task history improvements.
CI release process (2026-03-12):
.forgejo/workflows/ci.yml: removedmainfrombranchespush trigger. CI now fires only ondevpushes,v*tags, and pull requests. Main branch merges no longer trigger a run — the PR check covers pre-merge safety. Production release process: create a release via Forgejo UI onmainwith av*tag → tag push event fires CI → build job pushes:latest+:<version>Docker images to registry. Branch protection is not an obstacle because the UI release creates the tag through the API.
Daily Briefing — full feature (2026-03-11):
Backend:
- Migration
0026_add_briefing_tables.py:rss_feeds(url, name, user_id),rss_items(feed_id FK, guid, title, url, summary, pub_date),weather_cache(user_id UNIQUE, lat, lon, location_name, forecast_json, fetched_at); addedconversation_typeTEXT andbriefing_dateDATE toconversations. models/rss_feed.py,models/rss_item.py,models/weather_cache.py: SQLAlchemy models withto_dict().services/weather.py: geocoding via Nominatim (Open-Meteo), forecast fetch via Open-Meteo API, per-user DB cache with change detection.get_weather(user_id)returns structured dict with location, current, and 5-day forecast.services/rss.py: feedparser-based fetch, per-feed DB cache with prune-to-100.get_rss_items(user_id, limit)returns recent items across all user feeds.services/briefing_profile.py: service that reads user briefing config (CalDAV, weather, RSS, office days, tasks) and composes a profile note used as context for generation.services/briefing_pipeline.py: two-lane parallel gather (asyncasyncio.gatherover weather, RSS, tasks, calendar) → LLM synthesis. Streams result into aGenerationBufferusing the same SSE infrastructure as chat.routes/briefing.py: RSS CRUD at/api/briefing/feeds, weather config at/api/briefing/weather, briefing config at/api/briefing/config,POST /api/briefing/triggerto manually fire a slot. Registered asbriefing_bpinapp.py.routes/chat.py:GET /api/briefing/conversations/today— creates the day's briefing conversation if absent (type=briefing,briefing_date=today);GET /api/briefing/conversations— lists all past briefing conversations.services/generation_task.py/routes/chat.py:conversation_typefilter support so briefing conversations are excluded from the main chat list and vice versa.- APScheduler integration:
services/scheduler.pyruns briefing slots (morning, midday, evening) on schedule with catch-up logic — if a slot was missed while the server was down, it runs immediately on next startup. - LLM tools:
get_weatherandget_rss_itemsadded to_CORE_TOOLSinservices/tools.py.
Frontend:
frontend/src/views/BriefingView.vue(new): primary briefing page. Soft chat-style layout. Top section shows the digest card (expandable) followed by the conversation thread. Reply bar at bottom — sends via standard chat endpoints and streams via SSE. Manual "Refresh" button triggers a new briefing generation. Conversation history dropdown (past briefing dates) in header.frontend/src/components/BriefingSetupWizard.vue(new): 4-step setup wizard shown on first visit when briefing is not configured (welcome → location → office days → RSS feeds). Completes by enabling briefing in settings.frontend/src/views/SettingsView.vue: Briefing tab added (briefinginVALID_TABS): enable toggle, location geocoding input (live preview), office days checkboxes, time slot toggles (morning/midday/evening), RSS feed CRUD (add URL + name, list, delete), push notification toggle for briefing completion.frontend/src/router/index.ts:/briefingroute added →BriefingView.frontend/src/components/AppHeader.vue: "Briefing" nav link added.
Previous session (2026-03-11):
Version footer + CalVer tracking:
4636c9aAdd CalVer build-time version tracking:DockerfileinjectsBUILD_VERSIONARG asVITE_APP_VERSIONenv var during frontend build. Frontend readsimport.meta.env.VITE_APP_VERSIONand displays it in the Settings footer. Docker image tags include:<version>on release builds.2cb4e6dVersion footer, task history UI, note version retention policy: Settings page showsv<version>in footer. Task history now displays inline in a sidebar within the task editor (previously required navigation). Note version retention policy configurable (max versions per note, default 20 — enforced at write time inservices/note_versions.py).
Content deduplication + history sidebar:
6d593a0Move history to sidebar, simplify task assist, add content deduplication:HistoryPanelmoved to a collapsible right sidebar inNoteEditorView(previously a modal overlay). Writing assist panel simplified — removed multi-step UX, direct instruction input.build_context()inservices/llm.pydeduplicates injected content (RAG-found notes, sidebar includes, context notes) by ID so the same note cannot appear in multiple context blocks.
ShareDialog CSS fix:
a63f067Fix ShareDialog transparent background — replaced undefined CSS custom property references with explicit values.
Previous session (2026-03-11 — Multi-user & backup): Multi-user sharing & collaboration, in-app notifications, groups management, backup rewrite.
Multi-user sharing & collaboration:
alembic/versions/0025_add_sharing_and_notifications.py(new): createsgroups,group_memberships,project_shares,note_shares,notificationstables. Partial unique indexes via raw SQL. CHECK constraint enforces exclusive user/group target on share rows.src/fabledassistant/models/group.py(new):Group+GroupMembershipSQLAlchemy models.src/fabledassistant/models/share.py(new):ProjectShare+NoteSharemodels withshared_with_user_id,shared_with_group_id,permission,invited_bycolumns.src/fabledassistant/models/notification.py(new):Notificationmodel withtype,payload(JSONB),read_at.src/fabledassistant/models/__init__.py: exports all three new model groups.src/fabledassistant/services/access.py(new):PERMISSION_RANKdict (viewer=1 editor=2 admin=3 owner=4).get_project_permission(uid, project_id)andget_note_permission(uid, note_id)— check ownership, direct share, group-based share, and note→project inheritance; return highest permission string orNone. Helper predicates:can_read_*,can_write_*,can_admin_*.src/fabledassistant/services/groups.py(new):create_group,list_groups,update_group,delete_group,add_member,update_member_role,remove_member.src/fabledassistant/routes/groups.py(new): CRUD at/api/groupsand/api/groups/:id; member management at/api/groups/:id/members/:uid.src/fabledassistant/services/sharing.py(new):share_project/note,update_project/note_share,remove_project/note_share,list_project/note_shares,list_shared_with_me.src/fabledassistant/routes/shares.py(new): project shares at/api/projects/:id/shares, note shares at/api/notes/:id/shares,GET /api/shared-with-me.src/fabledassistant/services/notifications.py: extended withcreate_in_app_notification,notify_project_shared,notify_note_shared,notify_group_added,list_in_app_notifications,unread_notification_count,mark_notification_read,mark_all_notifications_read.src/fabledassistant/routes/in_app_notifications.py(new):GET /api/notifications,GET /api/notifications/count,POST /api/notifications/:id/read,POST /api/notifications/read-all.src/fabledassistant/routes/users.py(new):GET /api/users/search?q=— username/email prefix search, min 2 chars, excludes self.src/fabledassistant/services/projects.py: addedget_project_for_user(accessing_uid, project_id)→(Project, perm) | Noneandlist_projects_for_user(uid)(owned + shared).src/fabledassistant/services/notes.py: addedget_note_for_user(accessing_uid, note_id)→(Note, perm) | None.src/fabledassistant/routes/projects.py:list_projects_routeuseslist_projects_for_user;get_project_routeusesget_project_for_userand addspermissionfield;get_project_notes_routeaccess-checks viaget_project_for_user.src/fabledassistant/routes/notes.py,routes/tasks.py:get_note_route/get_task_routeuseget_note_for_userand addpermissionfield.src/fabledassistant/app.py: registersgroups_bp,shares_bp,notifications_bp,users_bp.
Frontend — sharing & notifications:
frontend/src/api/client.ts: addedShareEntry,GroupEntry,GroupMember,NotificationEntry,UserSearchResultTypeScript interfaces and API helper functions for all new endpoints.frontend/src/stores/notifications.ts(new): Pinia store —count,items,fetchCount,fetchAll,markRead,markAll.frontend/src/components/NotificationBell.vue(new): bell SVG icon with unread count badge, document-click-outside close, 60s polling interval, togglesNotificationsPanel.frontend/src/components/NotificationsPanel.vue(new): absolute-positioned dropdown listing unread notifications by type icon, bold inviter name, relative timestamp. "Mark all read" button. Click navigates topayload.url.frontend/src/components/ShareDialog.vue(new):<Teleport to="body">modal. Props:resourceType: 'project' | 'note',resourceId,resourceTitle. User tab: debounced user search dropdown + permission select + Add button. Group tab: group picker + permission select. Current shares list with inline permission change and remove.frontend/src/views/SharedWithMeView.vue(new):/sharedroute. Grid of shared projects (color bar, title, owner, permission badge). List of shared notes/tasks (icon, title, owner, permission badge).frontend/src/components/AppHeader.vue:NotificationBelladded tonav-rightbefore the?button; "Shared" nav link added to desktop center nav and mobile menu.frontend/src/views/ProjectView.vue: "Share" button +showShareref +<ShareDialog>at template bottom.frontend/src/views/NoteViewerView.vue: "Share" button in toolbar +<ShareDialog>after layout root.frontend/src/views/TaskViewerView.vue: "Share" button in toolbar +<ShareDialog>after layout root.frontend/src/views/SettingsView.vue: "groups" added toVALID_TABS; Groups admin tab added to sidebar. Groups panel: create group form, groups list with member count, per-group "Manage" panel with user search + role select + add, member list with per-member remove.frontend/src/router/index.ts:/shared→SharedWithMeViewroute added.
Backup rewrite:
src/fabledassistant/services/backup.py: full rewrite to version 2.export_full_backup()andexport_user_backup()now include all tables: projects, milestones, task logs, note drafts (AI draft model), note versions, push subscriptions.restore_full_backup()dispatches to_restore_v1()or_restore_v2()by version key._restore_v2()builds ID maps (user_id_map,project_id_map,milestone_id_map,note_id_map); two-pass note restore patchesparent_idafter all notes are inserted.
Previous session (2026-03-10): Feature batch: workspace view, graph view, settings tabs, message queue persistence, ToolCallCard direct-API confirm, duplicate detection scoping, push notification fixes.
UI polish pass — design language:
frontend/src/assets/theme.css: added Fraunces Google Font import;h1/h2/h3usefont-family: 'Fraunces', Georgia, serif;--radius-md8→12px,--radius-lg12→18px; added thin indigo-tinted scrollbars (4px); added bubble-specific CSS variables (--color-bubble-user-*,--color-bubble-asst-shadow); dark palette shifted from generic navy (#1a1a2e family) to slate-indigo (#111113 family).frontend/src/components/AppLogo.vue: logo book fill changed fromvar(--color-text)(black in light mode) tovar(--color-primary)(indigo). Stroke updated tocolor-mix(in srgb, var(--color-primary) 70%, transparent).frontend/src/components/AppHeader.vue: removedborder-bottomfrom.app-header; nav brand uses Fraunces font; active nav link usesinset 0 -2px 0 var(--color-primary)underline instead of background fill.frontend/src/components/ChatMessage.vue: user bubble ghosted (transparent bg, thin border, muted text); assistant bubble elevated with left2px solid var(--color-primary)accent + shadow; assistant role label in Fraunces italic indigo; thinking block uses left-accent instead of full border.frontend/src/views/ChatView.vue: removed sidebar/header/context-sidebar borders; streaming bubble matches assistant style; active conversation item uses 15% indigo tint; send button and queued bubbles use indigo gradient + glow.frontend/src/components/NoteCard.vue,TaskCard.vue: removedborder: 1px solid; replaced with whisper shadow (0 1px 4px + 0 0 0 1px indigo tint); elevated shadow on hover.frontend/src/views/HomeView.vue:dashboard-responseuses left indigo accent + elevation shadow (no border);dashboard-response-actionsborder-top removed; quick-action-chip hover adds indigo bg tint; project-mini-card uses whisper shadow; ms-fill progress bars usevar(--color-primary);.btn-ctaand.btn-open-chat.prominentuse indigo gradient + glow;.empty-state::beforeadds decorative✦marker.frontend/src/views/NotesListView.vue:.btn-newand.btn-ctause indigo gradient + glow on hover.
Design intent: "Illuminated Transcript" — LLM responses are primary artifacts (elevated, card-like, indigo accent left-border), user prompts are subordinate margin notes (ghosted, recessed). Buttons with the gradient carry intentional weight. Borders replaced by shadow depth throughout.
Previous session (2026-03-10): Feature batch: workspace view, graph view, settings tabs, message queue persistence, ToolCallCard direct-API confirm, duplicate detection scoping, push notification fixes.
Project Workspace (/workspace/:projectId):
frontend/src/views/WorkspaceView.vue(new): 3-panel layout (tasks / chat / notes) with CSS grid collapse. Panel toggles in ws-header. Conversation persisted tolocalStorageasworkspace_conv_{projectId}; deleted on unmount only if brand-new and empty.sendMessage()passesprojectIdas bothragProjectIdandworkspaceProjectId;think: truealways enabled. SSE watcher auto-loads note oncreate_note/update_note, refreshes task panel oncreate_task/update_task.frontend/src/components/WorkspaceTaskPanel.vue(new): tasks grouped by milestone (No Milestone first), collapsible groups, task detail slide-over with status cycle + milestone<select>+ read-only markdown body + TaskLogSection + inline-confirm delete.frontend/src/components/WorkspaceNoteEditor.vue(new): list view (search, inline tags, inline delete) ↔ editor view (TipTap + TagInput + WordCount + Suggest button + autosave viauseAutoSave).- Entry point: "Open Workspace" button on
ProjectView.vuepage header. - Height fix:
workspace-rootusesheight: 100%;app-content:has(.workspace-root)switches tooverflow: hidden. - Backend:
workspace_project_idaccepted in POST body (routes/chat.py), threaded torun_generationandbuild_context(); injects system prompt with project title so agent passesproject="Title"(not ID) to tools.
Graph View (/graph):
frontend/src/views/GraphView.vue(new): D3 force-directed graph of all notes/tasks. Tag nodes as first-class nodes (string IDs"tag:name"); invisible project hub nodes attract project members. Physics panel with sliders: repulsion (110), link distance (80), link strength (0.50), hub pull (0.05), gravity (0.03).forceX/forceYfor gravity (notforceCenter). Clicking tag node →/notes?tags=name. Note/task node → 340px slide-in peek panel (type badge, tags, rendered body, linked nodes; Escape/✕ closes).peek-slideCSS transition.- Router:
/graphroute added tofrontend/src/router/index.ts. - Keyboard shortcut: bare
gnavigates to/graph(added toApp.vue).
Settings page tabs:
frontend/src/views/SettingsView.vue: fully rewritten with 6 tabs — General, Account, Notifications, Integrations, Data, Admin. Tab state persisted tolocalStoragekeysettings_tab. Admin tab only shown whenauthStore.isAdmin. Session invalidation button added in Admin tab (POST /api/auth/invalidate-sessions).
Push notification fixes:
frontend/public/sw.js: suppresses notification if the relevant chat tab is already focused (clients.matchAll()visibility check beforeshowNotification). Works over HTTPS only — Firefox shows "not supported" over plain HTTP.src/fabledassistant/services/push.py: errors in_send_syncpromoted tologger.errorwithexc_info=True; successful sends logged atlogger.info.src/fabledassistant/services/generation_task.py: tool-only responses (emptycontent_so_far) now synthesise push notification body from tool names ("Completed: create_note, create_task") instead of sending an empty notification.
Message queue improvements:
frontend/src/components/DashboardChatInput.vue: removed|| store.streamingfrom disabled checks — queue works on the dashboard widget.frontend/src/stores/chat.ts: queue persisted tolocalStoragekeyfa_conv_queue_{convId}; saved on enqueue/dequeue, restored infetchConversation, removed on conversation delete.queuedMessagescomputed returns fullQueuedMessage[]for current conv.frontend/src/views/ChatView.vue+WorkspaceView.vue: queued messages rendered as user-style bubbles (linear-gradient(135deg, #6366f1, #4f46e5),border-bottom-right-radius: 4px, 18px radius, opacity 0.45, right-aligned via.chat-message.role-user { justify-content: flex-end }). "QUEUED" label inrgba(255,255,255,0.75). Cancel button clears entire queue.
ToolCallCard confirm/deny — direct API:
frontend/src/components/ToolCallCard.vue: confirm/deny buttons moved inline in the header row (no separate block). "Create anyway" posts directly toPOST /api/notesorPOST /api/tasks(inferred fromtoolCall.function); shows "✓ Created" on success, "Skipped" on deny — no LLM round-trip. RemoveduseChatStoredependency for these actions.confirmState: 'idle' | 'creating' | 'created' | 'denied'local ref.src/fabledassistant/routes/notes.py+routes/tasks.py: POST handlers now acceptproject(name string) and resolve toproject_idviaget_project_by_titlewhenproject_idnot supplied directly.
Duplicate detection fixes:
src/fabledassistant/services/tools.py:create_noteandcreate_tasknow scope all three duplicate checks (exact title, fuzzy title, semantic similarity) to the same type only —is_task=Trueforcreate_task,is_task=Falseforcreate_note. A note and a task can share the same title without conflict.src/fabledassistant/services/embeddings.py:semantic_search_notes()gainsis_task: bool | None = Noneparameter; filters SQL join toNote.status.isnot(None)/Note.status.is_(None)when set.- Semantic similarity threshold raised 0.87 → 0.90; minimum body length raised 80 → 200 chars. Catches near-identical content (accidentally duplicated notes) while allowing topically-related notes (e.g. enemy design templates) to coexist.
Abort button:
- ■ Stop button in
ChatView.vueheader andWorkspaceView.vueinput bar; callsstore.cancelGeneration().
CI fix (ruff E741):
src/fabledassistant/routes/task_logs.py: renamed ambiguous variablel→login list comprehension.
Previous session (2026-03-10): UI polish pass: "Illuminated Transcript" design language, Fraunces typography, indigo gradient buttons, shadow-based card depth, bubble hierarchy flip.
Previous session (2026-03-10): CI/CD optimization: custom runner base image, explicit label naming, pip caching, Node 22.
CI/CD infrastructure overhaul:
infra/Dockerfile.runner-base(new): Ubuntu 24.04 base image with Python 3.12 (native, no PPA) and Node 22 LTS pre-installed. Taggedpy3.12-node22. Eliminates the ~3-4 min deadsnakes PPA install that ran on every test job. Build/push command in file header.infra/act-runner-config.yml: addedrunner.labelssection mappingpy3.12-node22→runner-base:py3.12-node22. This is the authoritative label config —GITEA_RUNNER_LABELSenv var is only honoured on first registration and was being ignored after.runnerexists.infra/runner-compose.yml: removed redundantGITEA_RUNNER_LABELSenv var (labels now owned solely by config.yml)..forgejo/workflows/ci.yml: all jobs changed fromruns-on: ubuntu-latest→runs-on: py3.12-node22; removed deadsnakes Python install steps from test job; addedactions/cache@v3for pip keyed onhashFiles('pyproject.toml'); bumpednode-version20 → 22.Dockerfile: bumped frontend build stagenode:20-alpine→node:22-alpine..gitignore: added.claude/,settings.local.json,.vscodium/.
Runner label design: Label name (py3.12-node22) matches the image tag, making the dependency contract explicit in workflow files. ubuntu-latest alias removed — it was opaque and implied a moving target.
To activate on server (one-time): copy infra/act-runner-config.yml → /nfs/data/fabledsword/act_runner/config.yml, delete /data/.runner in the runner container, restart the stack. Runner will re-register with the py3.12-node22 label.
Previous session (2026-03-06): Session invalidation on credential change (password reset + password change).
Session invalidation on credential change:
models/user.py: addedsession_version: Mapped[int]column (default 1).Integerimport added.alembic/versions/0024_add_session_version.py: migration addssession_version INTEGER NOT NULL DEFAULT 1tousers.auth.py(_check_auth): after loading user, checkssession["session_version"] == user.session_version; mismatch →session.clear()+ 401 "Session expired. Please log in again."services/auth.py:change_passwordreturn type changed frombooltoint | None(returns newsession_versionon success,Noneon failure). Incrementsuser.session_versionbefore commit.reset_password_with_token: incrementsuser.session_versionbefore commit (invalidates all live sessions on password reset).
routes/auth.py:login,register,register_with_invite,oauth_callback: now also setsession["session_version"] = user.session_versionalongsidesession["user_id"].update_password: receivesnew_versionfromchange_password; updatessession["session_version"] = new_versionso the current user's own session stays valid while all other sessions are invalidated.
- Effect: All existing sessions will require re-login once after the migration runs (existing cookies lack
session_version, so they'll mismatch1). Subsequent credential changes immediately evict all other active sessions.
DRY refactoring pass (backend):
src/fabledassistant/models/base.py(new):TimestampMixin(created_at+updated_at) andCreatedAtMixin(created_atonly) — SQLAlchemy 2.0Mapped[]mixins. Applied to all 10+ models (Note,Project,Milestone,Conversation,Message,User,PushSubscription,TaskLog,NoteDraft,NoteVersion), removing ~60 lines of duplicated column definitions.src/fabledassistant/routes/utils.py(new):not_found(resource)→(Response, 404)andparse_iso_date(value, field)→date | None | (Response, 400). Used acrossnotes.py,tasks.py,projects.py,milestones.py,chat.py, replacing ~18 inline 404 blocks and ~4 duplicate date-parsing try/except blocks.routes/milestones.py:_milestone_dict(m)local async helper replaces 5 repetitions ofentry = m.to_dict(); entry.update(await get_milestone_progress(m.id)).
DRY refactoring pass (frontend):
frontend/src/composables/useAutoSave.ts(new):useAutoSave(dirty, saving, saveFn, intervalMs?)— interval-based autosave withonMounted/onUnmountedlifecycle, guards on dirty+saving state.frontend/src/composables/useEditorGuards.ts(new):useEditorGuards(dirty, saveFn)— registers Ctrl+S handler,beforeunloadwarning, andonBeforeRouteLeaveconfirm guard.frontend/src/composables/useTagSuggestions.ts(new):useTagSuggestions(title, body, tags, markDirty)— encapsulates/api/notes/suggest-tagscall and suggestion state.frontend/src/composables/useFloatingAssist.ts(new):useFloatingAssist(onRequest)— floating selection-based assist button positioning.frontend/src/composables/useListKeyboardNavigation.ts(new):useListKeyboardNavigation<T>(items, navigateTo, rowSelector?, enabled?)— j/k/Enter navigation with focus-in-input guard and optionalenabledref.frontend/src/components/ConfirmDialog.vue(new): reusable confirm modal. Props:title,message,confirmLabel(default "Delete"),danger(default true). Emitsconfirm/cancel. Uses<teleport to="body">internally.frontend/src/assets/editor-shared.css: added shared sidebar CSS (.sidebar-toggle,.sidebar-content,.sb-field,.sb-label,.sb-select,.sb-input,.sb-divider+ mobile collapse rules) used by both editor views.frontend/src/assets/viewer-shared.css(new): shared context-breadcrumb CSS (.context-bar,.ctx-crumb*) imported byNoteViewerViewandTaskViewerView.NoteEditorView.vue,TaskEditorView.vue: replaced ~120 lines each of duplicated floating-assist, tag-suggestion, autosave, and route-guard logic with composable calls; replaced inline<teleport>delete modal with<ConfirmDialog>; removed ~80 lines of duplicated scoped sidebar CSS.NotesListView.vue,TasksListView.vue: replaced inlineactiveIndex/onKeydown/scrollActiveIntoViewkeyboard-nav (~25 lines each) withuseListKeyboardNavigation. TasksListView passesisFlatcomputed ref asenabledguard.NoteViewerView.vue,TaskViewerView.vue: removed ~40 lines of duplicate.context-bar/.ctx-crumb*scoped CSS; added<style src="@/assets/viewer-shared.css" />.- No behaviour changes. Net: −634 lines, +237 lines across 31 files.
Previous Session
2026-03-06 — Project-aware writing assist, link suggestions, project-scoped RAG, semantic note search tool, SSE done-event race fix, RAG snippet increase, sidebar include full body, Enter-to-submit assist.
SSE done-event race fix: routes/notes.py and routes/chat.py — The SSE stream generator had a race condition where yield buf.format_sse(event) hands control back to the event loop, allowing the generation task to set buf.state = COMPLETED and append the done event between the last yield and the state check. The loop would then break without delivering the done event (containing full_text). Fixed by adding a final drain loop after the while exits in both stream generators. Added INFO-level logging to run_assist_generation showing input_chars, output_chars, event count, and done-event index.
RAG snippet + sidebar include: services/llm.py — RAG_AUTO_SNIPPET raised from 800 to 4000 chars. Sidebar-included notes (include_note_ids) now deliver full body (n.body or "") instead of the previous 2000-char truncation, matching the paperclip/context_note behaviour.
Assist MAX_BODY_CHARS: services/assist.py — raised from 8000 to 24000 chars, safely within the 16384-token Ollama context (input + output headroom considered).
DiffView equal-line collapsing: DiffView.vue — unchanged lines are now collapsed into ⋯ N unchanged lines markers when a run of 6+ equal lines exists, showing only 3 lines of context around each change (git-style). Makes reviewing large-document diffs practical.
Enter-to-submit assist textarea: Both NoteEditorView.vue and TaskEditorView.vue — writing assistant instruction textareas now submit on plain Enter (Shift+Enter inserts newline). Uses @keydown.enter.exact.prevent with canSubmit guard.
Project-aware writing assist: services/assist.py — build_assist_messages() accepts context_notes: list[dict] | None; when provided, injects a --- Project Context --- block into the user message (before the document) listing related note titles, tags, and body previews. Instructs the model to use [[wikilinks]] for referenced concepts. routes/notes.py assist_route — accepts project_id and note_id; fetches up to 5 project notes as context: definition-tagged notes first (up to 3, always injected for canonical term definitions), then recently-updated notes fill remaining slots. composables/useAssist.ts — accepts projectId?: Ref<number | null> third param; includes note_id and project_id in the POST body. Both NoteEditorView.vue and TaskEditorView.vue pass their project ref to useAssist.
Link suggestions: routes/notes.py — new POST /api/notes/link-suggestions endpoint. Accepts {body, project_id, exclude_note_id}. Fetches all project note titles (up to 200), runs regex to find titles appearing as plain text outside [[...]] spans, returns [{note_id, title, count}] sorted by occurrence count. NoteEditorView.vue — new "Link Suggestions" sidebar section: fetches suggestions on mount and debounced 2s after each body edit (when project is set). Per-term "Link" button applies [[Title]] wrapping to all unlinked occurrences; "Link all" applies all at once. Text replacement splits on [[...]] spans to avoid double-linking.
Project-scoped RAG: services/embeddings.py semantic_search_notes() — new project_id param filters the SQL join to Note.project_id == project_id. services/notes.py search_notes_for_context() — same project_id filter. services/llm.py build_context() — new rag_project_id param threaded to both search functions. services/generation_task.py run_generation() + routes/chat.py send_message_route — rag_project_id accepted from POST body and passed through. stores/chat.ts sendMessage() — new ragProjectId param included in POST. ChatView.vue — ProjectSelector added to chat sidebar under "Scope notes to project" label; when set, all RAG (semantic + keyword fallback) restricts to that project's notes.
Semantic note search tool: services/tools.py search_notes — upgraded from pure keyword to hybrid semantic+keyword. Runs semantic_search_notes() first (threshold 0.40, broader than auto-inject 0.60 since this is an intentional query); merges keyword results for any not already found. Results include a relevance field (percentage for semantic, "keyword" for text matches). Added project parameter (resolves by name, same pattern as list_notes). Preview extended to 300 chars. Tool description updated to explain semantic capability. services/llm.py tool guidance updated to direct the model to use search_notes for conceptual/thematic queries. generation_task.py status label updated to "Searching notes (semantic)".
Previous session (2026-03-06): Code review fixes: SSRF guard, config validation, race condition handling, tag autocomplete SQL, note versions now store tags, unmount cleanup, stale log string, PATCH due_date validation.
SSRF protection: services/llm.py — new _is_private_url(url) helper uses socket.getaddrinfo + ipaddress to block requests to loopback, private, link-local, reserved, and multicast addresses before fetching. follow_redirects set to False (redirects to internal IPs would bypass the check). All web research and search_web tool calls go through fetch_url_content, so this covers the full pipeline.
Config validation: config.py — new Config.validate() classmethod checks OLLAMA_NUM_CTX, LOG_RETENTION_DAYS, IMAGE_MAX_BYTES, SMTP_PORT bounds, and BASE_URL format when OIDC is enabled. app.py — Config.validate() called at the top of create_app() so misconfigurations surface immediately with clear messages instead of cryptic runtime errors.
create_buffer race: routes/chat.py — wrapped create_buffer(conv_id, assistant_msg.id) in try/except RuntimeError → returns 409 instead of 500 if two concurrent requests race past the buffer-running check (which is possible because there are await points between the GET check and the create call).
Stale log message: services/generation_task.py — "90s" → "180s" in the model-load timeout warning.
Tag autocomplete SQL: services/notes.py — get_all_tags() now pushes the q filter into the SQL query (ILIKE) and adds LIMIT 100 on both filtered and unfiltered paths, replacing the previous Python-side filter over a full table scan.
Note versions now store tags: Migration 0023_add_tags_to_note_versions.py adds tags TEXT[] NOT NULL DEFAULT '{}' to note_versions. models/note_version.py — added tags field; to_dict() includes it. services/note_versions.py — create_version() accepts optional tags param. services/notes.py — snapshots old_tags before update, passes them to create_version. Frontend: NoteVersion interface (in HistoryPanel.vue and types/task.ts) updated with tags: string[]. HistoryPanel.vue — restore emit updated to include tags as second argument. NoteEditorView.vue — restore handler now restores both body and tags.
useAssist unmount cleanup: NoteEditorView.vue — added onUnmounted(() => assist.clearSelection()) which closes any in-flight SSE stream handle, preventing orphaned server connections when navigating away mid-generate.
PATCH due_date validation: routes/notes.py — date.fromisoformat() in the PATCH handler now wrapped in try/except (ValueError, TypeError), returning 400 with a clear message instead of a 500.
Previous session (2026-03-06 earlier): SSE reconnection for chat and writing assist.
SSE reconnection — chat: stores/chat.ts — new reconnectIfGenerating(convId) function exported from the store. Checks isStreamingConv first (skips if stream already active). Finds the last assistant message with status: 'generating', removes it from the messages array (the done event re-adds the final version), initialises stream state with status: "Reconnecting...", then calls _streamGeneration which replays all buffered events from last_event_id=-1. If the buffer is >60s stale (already cleaned up), _streamGeneration exhausts its retries and falls back to fetchConversation to show the DB-saved final content. ChatView.vue — reconnectIfGenerating called (fire-and-forget, no await) in both onMounted and watch(convId) after the fetch step, covering both the page-refresh and navigate-away-and-back scenarios.
SSE reconnection — writing assistant: composables/useAssist.ts — extracted _buildProposedFullBody() helper. submit() now wraps the SSE connection in a retry loop (up to 3 retries, exponential back-off 1 s / 2 s / 4 s, capped at 5 s). Tracks lastEventId; reconnects use ?last_event_id=N so only new chunks are received (no duplication). Loop breaks early on done, error, or if state left 'streaming' (user cancelled). Falls back to accumulated text as draft on exhausted retries.
Model load timeout: generation_task.py — wait_for_model_loaded timeout raised from 90 s to 180 s.
Previous session (2026-03-05): Note editor sidebar layout, full-document writing assist, persistent drafts, version history, context window bump.
Note editor sidebar layout: NoteEditorView.vue fully refactored into two-column layout matching TaskEditorView. Right sidebar (280px) contains Project, Milestone, Tags + tag suggestions, and the Writing Assistant panel (always visible, no toggle). Mobile collapses to accordion. Removed assistOpen ref and ✨ Assist toggle button. InlineAssistPanel removed from note editor.
Full-document writing assist: services/assist.py — new whole_doc: bool param with a separate system prompt that instructs the model to output the complete revised document. routes/notes.py — whole_doc accepted from POST body; validation updated (section mode requires target_section, both modes require instruction). composables/useAssist.ts — added scopeMode ref ('document' | 'section'), proposedFullBody ref (full body after section replacement or whole-doc output), updated diff to always compare full document snapshots (bodySnapshot → proposedFullBody). Scope dropdown in sidebar drives scopeMode and selectedSection. canSubmit no longer requires a target in document mode. selectSection() and selectTextRange() now set scopeMode = 'section' automatically.
DiffView.vue: New standalone component (frontend/src/components/DiffView.vue). Full-width, flex-fills available height. Sticky summary bar (insertions / deletions count). Scrollable monospace diff body. Replaces the editor main area during review state.
Main area state switching (NoteEditorView): streaming → stream preview (rendered markdown); review → DiffView full-width; idle → normal Write/Preview/TiptapEditor.
Persistent drafts — backend: Migration 0022_add_note_versions_and_drafts.py — note_drafts table (UNIQUE per note_id + user_id; fields: proposed_body, original_body, instruction, scope) and note_versions table (max 20 per note; fields: body, title, created_at). Models models/note_draft.py, models/note_version.py. Added imports to models/__init__.py. Services services/note_drafts.py (upsert_draft via INSERT … ON CONFLICT, get_draft, delete_draft) and services/note_versions.py (create_version with auto-prune to 20, list_versions, get_version). services/notes.py update_note() — snapshots old body/title before applying changes; calls create_version when body actually changes. Routes in routes/notes.py: GET/PUT/DELETE /api/notes/<id>/draft, GET /api/notes/<id>/versions, GET /api/notes/<id>/versions/<vid>.
Persistent drafts — frontend: useAssist.ts — accepts optional noteId: Ref<number | null> second param; saves draft via PUT /api/notes/<id>/draft after done event; loadDraft(draft) restores bodySnapshot, proposedFullBody, instruction, scopeMode, and sets state to 'review'; accept() and reject() call DELETE /api/notes/<id>/draft. NoteEditorView.vue — on mount, after note loads, fetches draft and calls assist.loadDraft() if one exists. NoteDraft interface exported from useAssist.ts; NoteDraft + NoteVersion interfaces added to frontend/src/types/task.ts.
Version history browser: HistoryPanel.vue — wide modal (900px, 80vh), version list on left (lazy body loading), DiffView on right (selected version vs current body), Restore button emits body to parent. NoteEditorView.vue — "History" toolbar button, renders <HistoryPanel> with restore handler (body = $event; markDirty()).
Context window + output token limits: OLLAMA_NUM_CTX default raised from 16384 to 65536 in config.py (overridable via env var; comment updated to remove stale VRAM reference). run_assist_generation in generation_task.py — num_predict changed from hardcoded 4096 to Config.OLLAMA_NUM_CTX, so assist output budget always matches the configured context window.
Previous session (2026-03-05 earlier): Fix writing assist: disable thinking mode, drop stuck-buffer 409.
Task Work Log (backend): New task_logs table (migration 0021_add_task_logs.py) with FK to notes(id) CASCADE and users(id) CASCADE, indexes on task_id and user_id. SQLAlchemy model models/task_log.py. Service services/task_logs.py with create_log, list_logs, update_log (uses _UNSET sentinel so None can explicitly clear duration_minutes), delete_log — all ownership-checked. Blueprint routes/task_logs.py registered in app.py at /api/tasks/<task_id>/logs (GET, POST, PATCH /<log_id>, DELETE /<log_id>). LLM tool log_work added to _CORE_TOOLS in services/tools.py: resolves task by title, calls create_log, returns {success, log, task}.
Task Work Log (frontend): TaskLog interface added to frontend/src/types/task.ts. TaskLogSection.vue component (props: taskId) — renders logs ASC with date+time timestamps (not just date, so multiple same-day entries are distinguishable), duration badge, inline edit (textarea + duration field + Save/Cancel), markdown rendering via renderMarkdown. New-entry textarea has autofocus. Integrated below TiptapEditor in TaskEditorView.vue.
Writing assistant — reliability (Pass 1): services/generation_task.py run_assist_generation now retries up to 3 times on HTTP 500, with 3s × attempt delay and buf.content_so_far reset between attempts. composables/useAssist.ts: removed chatStore.chatReady gate from canSubmit (chat store no longer needed; Ollama errors surface via backend response); replaced with useToastStore for error toasts on SSE error events, catch blocks, and accept-conflict detection.
Writing assistant — inline UX (Pass 2): New InlineAssistPanel.vue component renders streaming preview (animated pulse + live markdown) and diff review (monospace red/green diff or full-text toggle + Accept/Reject) directly in the editor column. The right-hand assist side panel now only shows the instruction input (idle state); streaming and review output moved inline. .assist-active-hint style added to editor-shared.css. Both NoteEditorView.vue and TaskEditorView.vue updated: assistLabel computed from the target section title, InlineAssistPanel rendered between MarkdownToolbar and TiptapEditor, aside restricted to idle/error states.
Task editor UI overhaul: TaskEditorView.vue restructured into a two-column layout: left .task-main (Write/Preview tabs + MarkdownToolbar + InlineAssistPanel + TiptapEditor + TaskLogSection) and right .task-sidebar (280px, all metadata: status, priority, due date, project, milestone, parent task, sub-tasks, tags + suggest). Title remains full-width above both columns. Sidebar collapses to an accordion on max-width: 720px (toggle button hidden on desktop). sidebarOpen ref defaults to true. Body tab defaults to Preview (showPreview = ref(true)).
Previous session (2026-03-04): Per-conversation streaming state + auto-new-chat on open. Keyboard navigation overhaul.
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.
- First-class tag field: Tags live in the
tags ARRAY[text]column and are explicitly set by the client — they are NOT auto-extracted from the body text.extract_tags()inutils/tags.pyis preserved but no longer called on save.TagDecoration.tskeeps visual#taghighlighting in existing notes that have inline tags for cosmetic backward compatibility. - 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. - Design tokens: Standardized CSS custom properties for border-radius
(
--radius-sm: 6px,--radius-md: 8px,--radius-lg: 12px), semantic colors (--color-success,--color-warning,--color-overlay), and focus ring (--focus-ring). All components reference tokens instead of hardcoded values. - 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. - Background generation architecture: LLM streaming runs in a detached
asyncio.Taskthat writes into an in-memoryGenerationBuffer. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer hascancel_eventfor user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture — assist buffers are keyed by"assist:{user_id}"(string keys) instead of conversation ID (int keys). Assist generation has no DB persistence, no title generation, no cancellation. - SSE over WebSockets for LLM streaming: SSE clients connect via
GET /api/chat/conversations/:id/generation/streamwithLast-Event-IDreconnection support. Frontend usesfetch()+ReadableStream. Simpler than WebSockets; Quart supports async generators natively for SSE. - Context building server-side: Backend fetches URL content and searches notes —
frontend sends the message text + optional context note ID + optional
include_note_ids. Keyword extraction uses simple word splitting with stopword filtering (no embeddings).build_context()returns(messages, context_meta)tuple; metadata includes auto-found note IDs/titles sent to frontend via SSEcontextevent before streaming. Multi-word search splits terms into per-word ILIKE with AND logic (not adjacent match). Auto-found notes populate the sidebar but are not injected into the system prompt automatically — users must click+in the sidebar to include them (stable system prompt prefix enables Ollama KV cache reuse). Explicitly included notes appear as--- Included Notes ---in the system prompt. - Reverse proxy required for production: The app does not terminate TLS. A reverse proxy (Nginx/Traefik/Caddy) must sit in front of port 5000. Do not expose the app directly to the internet.
- Registration auto-closes: After the first user registers (admin), registration
is closed by default. The admin can toggle it from
/admin/users. The setting is stored as the admin user'sregistration_opensetting key. - No blocking long-running operations: Any potentially slow operation (model pulls, LLM calls, URL fetching, etc.) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses (chat messages and model pull progress). Frontend keeps the UI responsive during async operations (loading states, streaming indicators, progress percentages). This applies to all future features as well.
- Auto-pull model on startup: Non-blocking background task, logs warning on failure so the app still starts even if Ollama isn't ready.
- 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. - Session cookie security:
HttpOnlyandSameSite=Laxalways set.Secureflag controlled bySECURE_COOKIESenv var (enable when behind TLS). - Default SECRET_KEY warning: App logs a WARNING on startup if the default
dev-secret-change-mekey is in use. - Security headers: Applied in
app.pyafter_requestusingsetdefaultso SSE/streaming responses that set their own headers aren't clobbered:X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy, and a restrictiveContent-Security-Policy. - Rate limiting: In-memory sliding-window rate limiter (
rate_limit.py) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s, silent drop to avoid timing info), reset-password (10/60s). Keys are per-IP. - Proxy-aware client IP:
TRUST_PROXY_HEADERSenv var enables reading real client IP fromX-Forwarded-For/X-Real-IPwhen behind a trusted reverse proxy. Off by default. - Auto-save: Both note and task editors auto-save every 5 minutes when editing an existing dirty record. Silent on error; shows "Auto-saved" toast on success.
High-Level Component Diagram
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
Data Model
Users
id(int PK),username(text UNIQUE NOT NULL),email(text nullable),password_hash(text nullable — NULL for OAuth-only accounts),oauth_sub(text UNIQUE nullable — OIDC subject identifier),role(text NOT NULL DEFAULT 'user'),created_at(timestamptz)- First registered user auto-assigned
role='admin'; claims orphaned data - Passwords hashed with bcrypt; OAuth-only users have
password_hash = NULL to_dict()excludespassword_hash; includeshas_password: bool
Notes (unified — includes tasks)
id(int PK),title(str),body(markdown str),tags(ARRAY[str]),parent_id(nullable FK to self),user_id(nullable FK to users, CASCADE),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 explicitly provided by the client — NOT extracted from body text
- 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, B-tree on title
Task ≡ Note with task attributes
- No separate tasks table.
routes/tasks.pyimports directly fromservices/notes.pywithis_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
Settings
- Composite PK:
(user_id, key)—user_idFK to users (CASCADE),key(text) value(text)- Per-user key-value store for settings (assistant name, default model, etc.)
- CRUD via
services/settings.py: all functions takeuser_idas first parameter
Conversations
id(int PK),title(str),model(str),user_id(nullable FK to users, CASCADE),created_at,updated_at- Has many messages (cascade delete)
- Title auto-generated by LLM on first exchange and re-generated every 10th message
- Index on
updated_atfor list ordering to_dict()returns:id,title,model,message_count,created_at,updated_atlist_conversations()uses a subquery formessage_countinstead of eager-loading all messages
App Logs
id(int PK),category(text NOT NULL —audit/usage/error),user_id(int FK users ON DELETE SET NULL),username(text — denormalized),action(text),endpoint(text),method(text),status_code(int),duration_ms(real),ip_address(text),details(text — JSON-serialized),created_at(timestamptz DEFAULT now())- Single table with
categoryfield for all log types - Denormalized
usernamepreserves attribution after user deletion detailsstores flexible JSON data (error tracebacks, audit metadata, etc.)- Indexes:
category,user_id,created_at, composite(category, created_at DESC) - Automatic retention cleanup via hourly asyncio task (configurable
LOG_RETENTION_DAYS, default 90)
Invitation Tokens
id(int PK),email(text NOT NULL),token_hash(text NOT NULL UNIQUE),invited_by(int FK users CASCADE),expires_at(timestamptz NOT NULL),used(boolean NOT NULL DEFAULT FALSE),created_at(timestamptz)- Admin creates invitation → raw token emailed → recipient registers via
/register-invite?token=... - Token stored as SHA256 hash (same pattern as password reset)
- 7-day expiration, one-time use, previous unused invitations for same email auto-revoked
- Indexes:
token_hash,email
Messages
id(int PK),conversation_id(FK to conversations, CASCADE),role(str: system/user/assistant),content(str),status(str, default'complete'—complete/generating/error),context_note_id(nullable FK to notes, SET NULL),tool_calls(nullable JSONB),created_at- Index on
conversation_id context_note_idtracks which note was attached as context when message was senttool_callsstores an array of tool call records[{function, arguments, result, status}]for assistant messages that used toolsto_dict()returns:id,conversation_id,role,content,status,context_note_id,tool_calls,created_atcontext_note_titleis NOT a DB column — it is synthesised at query time inget_conversation_routevia a batchget_notes_by_ids()lookup and injected into the message dict so the frontend can display the note title without a separate fetch
Project Structure (Current)
fabledassistant/
├── summary.md # This file — canonical project context
├── pyproject.toml # Python project config (deps include caldav, icalendar, httpx)
├── Dockerfile # Multi-stage build (Node → Python)
├── .dockerignore # Prevents secrets/node_modules/__pycache__/.env.* leaking into build
├── docker-compose.yml # Dev compose (app, PostgreSQL, Ollama) — app service has healthcheck
├── docker-compose.prod.yml # Production stack (Docker Swarm, secrets, network isolation)
├── alembic.ini # Alembic config (prepend_sys_path = src)
├── docs/
│ └── oauth-setup.md # Step-by-step Authentik OIDC setup guide with example docker-compose config
├── 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
│ ├── 0006_add_settings_table.py # Settings key-value table
│ ├── 0007_add_title_and_updated_at_indexes.py # B-tree indexes on notes.title and conversations.updated_at
│ ├── 0008_add_users_and_user_id.py # Users table, user_id FKs on notes/conversations/settings, composite PK for settings
│ ├── 0009_add_message_status.py # Add status column to messages table (default 'complete')
│ ├── 0010_add_app_logs_table.py # App logs table for audit, usage, and error logging
│ ├── 0011_add_password_reset_tokens.py # Password reset tokens table
│ ├── 0012_add_invitation_tokens.py # Invitation tokens table
│ ├── 0013_add_tool_calls_to_messages.py # Add tool_calls JSONB column to messages
│ ├── 0014_add_note_embeddings.py # note_embeddings table for semantic search (note_id PK, user_id, embedding JSONB, updated_at)
│ └── 0015_add_oauth_fields.py # Add oauth_sub UNIQUE column; DROP NOT NULL on password_hash
├── src/
│ └── fabledassistant/
│ ├── __init__.py
│ ├── app.py # Quart app factory: SPA via 404 handler, JSON 404/500 for API, request logging, security headers (after_request)
│ ├── auth.py # Auth decorators: login_required, admin_required, get_current_user_id — shared _check_auth() helper
│ ├── config.py # Config from env vars + Docker secrets file support (_read_secret) + SECURE_COOKIES + TRUST_PROXY_HEADERS + OLLAMA_NUM_CTX (KV cache window, default 8192) + OIDC_ISSUER/CLIENT_ID/CLIENT_SECRET/SCOPES + LOCAL_AUTH_ENABLED + oidc_enabled() classmethod + SEARXNG_URL + searxng_enabled() classmethod
│ ├── rate_limit.py # In-memory sliding-window rate limiter (asyncio.Lock + defaultdict); is_rate_limited(key, max, window)
│ ├── models/
│ │ ├── __init__.py # async_session factory, Base, imports all models
│ │ ├── user.py # User model (id, username, email, password_hash nullable, oauth_sub unique nullable, role, created_at); to_dict() includes has_password bool
│ │ ├── note.py # Note model (unified: id, title, body, tags[], parent_id, user_id, status, priority, due_date, timestamps)
│ │ ├── conversation.py # Conversation + Message models with user_id
│ │ ├── setting.py # Setting model (composite PK: user_id + key, value TEXT)
│ │ ├── app_log.py # AppLog model (id, category, user_id, username, action, endpoint, method, status_code, duration_ms, ip_address, details, created_at)
│ │ └── invitation.py # InvitationToken model (id, email, token_hash, invited_by, expires_at, used, created_at)
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py # /api blueprint with /health endpoint (public)
│ │ ├── auth.py # /api/auth blueprint: register, login, logout, me, password, email change, status, password reset, invitation registration, OAuth login+callback — rate limiting, LOCAL_AUTH_ENABLED guards, _client_ip() helper
│ │ ├── admin.py # /api/admin blueprint: backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only)
│ │ ├── chat.py # /api/chat blueprint: conversations CRUD, SSE message streaming (all @login_required)
│ │ ├── images.py # /api/images/<id> GET — serve locally-cached images (no auth required; IDs are opaque)
│ │ ├── notes.py # /api/notes CRUD + wikilinks + backlinks + tag suggestions + link suggestions + assist; POST accepts `project` name string (resolves to project_id)
│ │ ├── quick_capture.py # /api/quick-capture POST — mobile/external single-shot item creation
│ │ ├── tasks.py # /api/tasks CRUD + PATCH status; POST accepts `project` name string
│ │ ├── task_logs.py # /api/tasks/<id>/logs — GET/POST/PATCH/<log_id>/DELETE/<log_id>
│ │ ├── projects.py # /api/projects CRUD
│ │ ├── milestones.py # /api/projects/<pid>/milestones CRUD
│ │ ├── push.py # /api/push — VAPID public key, subscribe/unsubscribe
│ │ ├── export.py # /api/export?format=markdown|json — ZIP or JSON export
│ │ └── settings.py # /api/settings GET/PUT, GET /models
│ ├── services/
│ │ ├── auth.py # Auth business logic: hash_password, verify_password, create_user (password optional, oauth_sub kwarg), authenticate (returns None for OAuth-only users), get_user_by_id/username/email/oauth_sub, update_user_email, link_oauth_sub, is_registration_open, list_users, delete_user, password reset tokens, invitation tokens
│ │ ├── oauth.py # OIDC/OAuth2 service: get_oidc_config (discovery, cached), build_auth_url (PKCE), exchange_code, get_userinfo, find_or_create_oauth_user (sub lookup → email auto-link → create)
│ │ ├── backup.py # Backup/restore: export_full_backup, export_user_backup, restore_full_backup
│ │ ├── notes.py # CRUD with user_id isolation, is_task filter, convert, backlinks, search_notes_for_context
│ │ ├── llm.py # Ollama interaction: build_context with user_id, streaming (stream_chat + stream_chat_with_tools), ChatChunk dataclass, URL fetching; generate_completion accepts num_ctx kwarg; uses Config.OLLAMA_NUM_CTX for KV cache window
│ │ ├── chat.py # Conversation CRUD with user_id isolation, add_message, save/summarize as note (LLM-titled, chat-tagged)
│ │ ├── generation_buffer.py # In-memory SSE event buffer with cancel_event, reconnect support, auto-cleanup; supports chat (int keys) and assist (string keys)
│ │ ├── generation_task.py # Background asyncio tasks: run_generation + run_assist_generation. Tool-only responses synthesise push notification body from tool names. workspace_project_id threaded through to build_context().
│ │ ├── intent.py # Intent routing: classify_intent() makes fast non-streaming LLM call (num_ctx=4096); IntentResult has ack field; classify_capture_intent() uses dedicated _CAPTURE_SYSTEM_PROMPT (always routes to a tool, never null)
│ │ ├── images.py # Image cache service: fetch_and_store_image (SHA-256 dedup, content-type validation, 5MB cap), get_image_record, get_image_path
│ │ ├── tools.py # LLM tool definitions + execute_tool dispatcher. Duplicate detection in create_note/create_task: exact title, fuzzy (SequenceMatcher ≥0.82), semantic (cosine ≥0.90, body ≥200 chars) — all scoped to same type (is_task). POST /api/notes|tasks accept `project` name string.
│ │ ├── research.py # SearXNG research pipeline: parallel query/fetch, streaming synthesis; run_research_pipeline(buf=None) → Note (buf optional — no status events when omitted); _search_searxng_images() for image category search; constants SEARXNG_QUERIES=5, PAGES_PER_QUERY=3, MAX_SYNTHESIS_SOURCES=12, CHARS_PER_SOURCE=2000
│ │ ├── tag_suggestions.py # LLM-powered tag suggestions: suggest_tags() builds prompt with existing tags, calls generate_completion, parses JSON response
│ │ ├── caldav.py # CalDAV integration: full event lifecycle (create/list/search/update/delete), list_calendars, timezone (ZoneInfo), reminders (VALARM), attendees, multi-calendar search. Todo functions exist in code but are NOT exposed as LLM tools.
│ │ ├── settings.py # Settings CRUD with user_id isolation: get_setting, set_setting, set_settings_batch, get_all_settings
│ │ ├── logging.py # App logging: log_audit, log_usage, log_error, get_logs, get_log_stats, delete_old_logs, start_log_retention_loop
│ │ ├── email.py # SMTP email service: get_smtp_config, is_smtp_configured, send_email, send_test_email
│ │ ├── notifications.py # Notification service: notify_security_event, check_due_tasks, start_notification_loop
│ │ ├── push.py # VAPID key generation/loading, send_push_notification (fire-and-forget); pywebpush 2.x API (webpush() function). sw.js suppresses notification when relevant chat tab is focused.
│ │ ├── projects.py # Project CRUD + get_project_by_title + get_project_summary (includes milestone_summary)
│ │ ├── milestones.py # Milestone CRUD + get_milestone_progress
│ │ ├── task_logs.py # create_log, list_logs, update_log (_UNSET sentinel for optional duration_minutes), delete_log — all ownership-checked
│ │ └── assist.py # Writing assistant: build_assist_messages (section/whole-doc modes, project context injection)
│ ├── 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: .app-shell (100dvh flex column) with AppHeader + .app-content (flex:1, scrollable) wrapping router-view; starts status polling + loads settings on mount
│ ├── main.ts # App init, imports theme.css
│ ├── assets/
│ │ ├── theme.css # CSS custom properties: light/dark themes, body reset, design tokens, responsive breakpoints (480/768/1024), utility classes (.hide-mobile/.hide-desktop), mobile touch targets; light mode uses indigo brand color (#6366f1) + indigo-tinted off-white backgrounds (#f5f5fb/#ededf5)
│ │ └── editor-shared.css # Shared editor styles for NoteEditorView + TaskEditorView: layout, toolbar, tag suggestions, assist panel, diff view, modal, responsive, .inline-assist-btn (global — required for teleported elements)
│ ├── api/
│ │ └── client.ts # ApiError class, apiGet/apiPost/apiPut/apiPatch/apiDelete + apiSSEStream (fetch+ReadableStream), apiStreamPost (legacy), auto 401→login redirect
│ ├── composables/
│ │ ├── useTheme.ts # Theme toggle, localStorage, prefers-color-scheme
│ │ ├── useShortcuts.ts # Shared showShortcuts ref + open/close/toggle helpers (used by App.vue + AppHeader.vue)
│ │ ├── useAutocomplete.ts # Legacy textarea autocomplete (replaced by Tiptap suggestion extensions)
│ │ └── useAssist.ts # AI Assist composable: section parsing, target selection, two-step POST+SSE streaming (apiPost → apiSSEStream), accept/reject (accept() resets to idle on doc-change error), proofread (full-document), LCS line diff (DiffLine/computeDiff), isProofreading ref; watches body ref for auto-sync
│ ├── stores/
│ │ ├── auth.ts # Auth state: user, isAuthenticated, isAdmin, oauthEnabled, localAuthEnabled, login/register/logout/checkAuth/checkHasUsers
│ │ ├── notes.ts # CRUD + tag filter, resolveTitle, convertToTask, convertToNote, fetchBacklinks, fetchAllTags (with toast errors)
│ │ ├── tasks.ts # CRUD + status/priority filter, patchStatus (with toast errors)
│ │ ├── chat.ts # Conversation CRUD, sendMessage (SSE streaming, includeNoteIds param), status polling (memory-leak-safe _pollUntilLoaded), running models, model warming, updateConversationModel (with toast errors)
│ │ ├── settings.ts # App settings: assistantName, defaultModel, installedModels, defaultChatModel, defaultIntentModel, pullModel, deleteModel (with toast errors)
│ │ └── toast.ts # Toast notification state (success/error/warning), 4s auto-dismiss, dismiss(id)
│ ├── types/
│ │ ├── auth.ts # User interface (incl. has_password bool), AuthStatus interface (incl. oauth_enabled, local_auth_enabled)
│ │ ├── note.ts # Note interface (with status, priority, due_date, is_task) + TaskStatus, TaskPriority types + NoteListResponse
│ │ ├── chat.ts # ToolCallRecord, Message, Conversation, ConversationDetail, ContextMeta, OllamaModel, RunningModel, OllamaStatus interfaces
│ │ ├── settings.ts # AppSettings interface, ModelInfo interface (name, description, size, bestFor, category)
│ │ └── task.ts # Task = re-export of Note; TaskListResponse
│ ├── extensions/
│ │ ├── TagDecoration.ts # ProseMirror decoration plugin: highlights #tags with .inline-tag class
│ │ ├── WikilinkDecoration.ts # ProseMirror decoration plugin: highlights [[wikilinks]] with .wikilink class
│ │ ├── TagSuggestion.ts # @tiptap/suggestion extension for #tag autocomplete (suppressed at line start for headings)
│ │ ├── WikilinkSuggestion.ts # @tiptap/suggestion extension for [[wikilink]] autocomplete
│ │ └── suggestionRenderer.ts # Shared Vue renderer for suggestion dropdowns (creates/positions SuggestionDropdown)
│ ├── utils/
│ │ ├── tags.ts # extractTags(), linkifyTags() (with (?<!&) to skip HTML entities), linkifyWikilinks()
│ │ ├── markdown.ts # renderMarkdown() (full) + renderPreview() (strips links/images) — decodes HTML entities before marked, replaces ' after sanitization; explicit DOMPurify config with FORCE_BODY
│ │ ├── markdownSerializer.ts # Tiptap JSON → markdown serializer: handles all StarterKit nodes + marks
│ │ └── sectionParser.ts # parseMarkdownSections() (heading-based) + parseFallbackSections() (paragraph/Q&A-style — single-line ≤120 char paragraphs become pseudo-headings; top-level bullet/numbered list items become individual sections)
│ ├── views/
│ │ ├── LoginView.vue # Login form; "Login with Authentik" SSO button when oauth_enabled; hides password form when local_auth_disabled; handles ?error=oauth query param
│ │ ├── RegisterView.vue # Register form with password confirmation; shows "closed" message when registration disabled
│ │ ├── RegisterInviteView.vue # Invitation-based registration: validates token, creates account with pre-set email
│ │ ├── UserManagementView.vue # Admin user management: registration toggle, invitations (send/revoke), user list with delete
│ │ ├── ChatView.vue # Dedicated /chat page: responsive sidebar, bubble messages, note picker, context sidebar (auto-included + suggested), model selector, abort button, bulk-select conversations, per-conv message queue with localStorage persistence; queued bubbles indigo gradient right-aligned
│ │ ├── WorkspaceView.vue # /workspace/:projectId — 3-panel (tasks/chat/notes), CSS grid collapse, SSE tool-call watcher auto-loads notes + refreshes tasks, conv persisted to localStorage
│ │ ├── GraphView.vue # /graph — D3 force-directed graph: note/task/tag/project-hub nodes, physics panel, peek panel slide-in on click, tag-click nav to /notes?tags=
│ │ ├── HomeView.vue # Chat-first dashboard: quick actions + chat widget (top, full-width), inline response panel, two-column grid (3fr tasks / 2fr notes); task sections: Overdue, Due Today, Due This Week, High Priority, In Progress, Other (capped 10, due-dated first); 8 recent notes; model warming on mount
│ │ ├── SettingsView.vue # Tabbed settings (6 tabs: General, Account, Notifications, Integrations, Data, Admin); tab state in localStorage key `settings_tab`; Admin tab: session invalidation button, SMTP, base URL, data export/restore, user management links
│ │ ├── NotesListView.vue # Note list: search, sort, tag filter pills, pagination
│ │ ├── NoteEditorView.vue # Create/edit: Tiptap editor, sticky toolbar, TagInput chip field (between title and body), ProjectSelector + MilestoneSelector (milestone resets when project changes), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), unsaved guard; styles from editor-shared.css
│ │ ├── NoteViewerView.vue # Markdown render, wikilink auto-create, convert-to-task, backlinks, table of contents sidebar
│ │ ├── TasksListView.vue # Task list: search, status/priority filters, sort, pagination
│ │ ├── TaskEditorView.vue # Create/edit task: Tiptap editor, sticky toolbar, TagInput chip field (between metadata fields and body), AI Assist panel (right-side 320px, collapsible), line-level diff view, Proofread action, floating inline ✨ pill on text selection, LLM tag suggestions (adds chips), Ctrl+S, auto-save (5min), dirty guard; styles from editor-shared.css + task-specific scoped styles
│ │ └── TaskViewerView.vue # Task detail: rendered markdown, badges (isOverdue uses ISO string comparison), convert-to-note, backlinks, table of contents sidebar
│ ├── components/
│ │ ├── LogsView.vue # Admin log viewer: stats summary, category/search/date filters, paginated table with IP column + expandable detail rows (expands on ip_address or details)
│ │ ├── AppHeader.vue # Nav bar: three-zone layout — brand (left), Notes/Projects/Tasks/Chat centered (absolute positioning), right rail: status + ? + theme + gear dropdown (Settings/Users/Logs) + username/signout; hamburger + mobile dropdown on ≤768px
│ │ ├── ChatPanel.vue # Slide-out chat panel (right side overlay, receives contextNoteId prop), bubble-style messages, floating dark input, note picker, context pills with promote (+) only (no exclude)
│ │ ├── ModelSelector.vue # Model dropdown (v-model pattern): fetches installed + running models, hot/cold indicators
│ │ ├── DashboardChatInput.vue # Inline chat bar: ModelSelector + note picker + textarea + send button; emits submit event
│ │ ├── WorkspaceTaskPanel.vue # Tasks grouped by milestone, collapsible, detail slide-over (status cycle + milestone select + body + TaskLogSection + delete)
│ │ ├── WorkspaceNoteEditor.vue # List view (search, inline tags, delete) ↔ TipTap editor (TagInput + WordCount + autosave)
│ │ ├── ToolCallCard.vue # Compact card for tool call results + suggested tag pills. Duplicate `requires_confirmation` state: inline confirm/deny buttons in header row; "Create anyway" → direct POST /api/notes or /api/tasks (no LLM round-trip); `confirmState: idle|creating|created|denied`
│ │ ├── ChatMessage.vue # Message bubble: markdown rendering, tool call cards, configurable assistant name label, "Save as Note" action on assistant messages
│ │ ├── NoteCard.vue # Card with rendered markdown preview (v-html), TagPill, tag-click emit, hover edit button
│ │ ├── TaskCard.vue # Card with rendered preview (body not description), StatusBadge (clickable), PriorityBadge, due date, tags, hover edit button
│ │ ├── StatusBadge.vue # Color-coded status badge, optional clickable cycling
│ │ ├── PriorityBadge.vue # Color-coded priority indicator (hidden for "none")
│ │ ├── MarkdownToolbar.vue # Tiptap command-based toolbar: bold/italic/link/list/heading with active state highlighting
│ │ ├── TagInput.vue # Chip-based tag input: Enter/comma/click to confirm, Backspace removes last, × removes chip, autocomplete from /api/notes/tags, space→hyphen sanitization
│ │ ├── TiptapEditor.vue # Tiptap wrapper: markdown↔HTML round-trip, paste handling, selection change emit (closest-match offset strategy), expose editor; no fetchTags prop (TagSuggestion removed, TagDecoration kept)
│ │ ├── SuggestionDropdown.vue # Shared autocomplete dropdown for tag/wikilink suggestions
│ │ ├── SearchBar.vue # Debounced search input
│ │ ├── TagPill.vue # Clickable/dismissible tag pill
│ │ ├── PaginationBar.vue # Prev/next + page numbers
│ │ ├── TableOfContents.vue # Sticky sidebar TOC: parses markdown headings, smooth-scroll on click, hidden ≤1200px
│ │ └── ToastNotification.vue # Fixed-position toast container with close button, warning support
│ └── router/
│ └── index.ts # Routes: /, /login, /register, /register-invite, /notes/*, /tasks/*, /chat, /chat/:id, /workspace/:projectId, /graph, /projects, /projects/:id, /settings, /admin/users, /admin/logs; beforeEach auth guard
└── public/
API Endpoints (Current)
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Health check (public) |
| GET | /api/auth/status |
Returns {has_users, registration_open, oauth_enabled, local_auth_enabled} (public) |
| POST | /api/auth/register |
Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
| POST | /api/auth/login |
Login with username/password (403 if local auth disabled; returns None for OAuth-only users) |
| POST | /api/auth/logout |
Logout (clear session) |
| GET | /api/auth/me |
Get current user info (includes has_password bool) |
| PUT | /api/auth/password |
Change password (body: {current_password, new_password}) |
| PUT | /api/auth/email |
Change email (body: {email, password?}; password required only if user has local password) |
| GET | /api/auth/oauth/login |
Initiate OIDC flow — generates PKCE verifier + state, stores in session, redirects to provider |
| GET | /api/auth/oauth/callback |
OIDC callback — exchanges code, fetches userinfo, finds/creates user, sets session, redirects to / |
| GET | /api/admin/backup |
Export backup (?scope=user for own data, full requires admin) |
| POST | /api/admin/restore |
Restore from JSON backup (admin only) |
| GET | /api/admin/users |
List all users (admin only) |
| DELETE | /api/admin/users/:id |
Delete a user (admin only, cannot delete self) |
| GET | /api/admin/registration |
Get registration open/closed status (admin only) |
| PUT | /api/admin/registration |
Toggle registration open/closed (admin only, body: {open: bool}) |
| GET | /api/admin/logs |
List log entries (admin only, params: category, user_id, search, date_from, date_to, limit, offset) |
| GET | /api/admin/logs/stats |
Get log category counts (admin only) |
| GET | /api/notes |
List notes (params: q, tag, sort, order, limit, offset, project_id, milestone_id, parent_id, type; type=note default — plain notes only; type=task for tasks, type=all for everything) |
| POST | /api/notes |
Create note (body: {title, body, tags?: string[], status?, priority?, due_date?} — tags explicit array) |
| GET | /api/notes/tags |
List all tags from notes table (param: q for filter) |
| POST | /api/notes/suggest-tags |
LLM-suggest tags (body: {title, body, current_tags?: string[]}, returns {suggested_tags: [...]}) |
| POST | /api/notes/:id/append-tag |
Add tag to note's tags array with deduplication (body: {tag}, returns updated note) |
| 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?, tags?: string[], status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}) |
| PATCH | /api/notes/:id |
Partial update note — same accepted fields as PUT; used for sub-task status toggle |
| 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 |
| POST | /api/notes/assist |
Launch background assist generation, return 202 (body: {body, target_section, instruction}; 409 if already running) |
| GET | /api/notes/assist/stream |
SSE endpoint tailing assist generation buffer; supports Last-Event-ID reconnection; emits chunk, done, error events with 15s keepalives |
| GET | /api/auth/invitation/:token |
Validate invitation token, returns {valid, email?} (public) |
| POST | /api/auth/register-with-invite |
Register with invitation token (body: {token, username, password}) (public) |
| GET | /api/admin/base-url |
Get application base URL setting (admin only) |
| PUT | /api/admin/base-url |
Set application base URL (admin only, body: {base_url}) |
| POST | /api/admin/invitations |
Create invitation (admin only, body: {email}) — sends email with registration link |
| GET | /api/admin/invitations |
List pending invitations (admin only) |
| DELETE | /api/admin/invitations/:id |
Revoke invitation (admin only) |
| GET | /api/tasks |
List tasks (params: q, tag, status, priority, due_before, due_after, sort, order, limit, offset) — queries notes where status IS NOT NULL |
| POST | /api/tasks |
Create task (body: {title, body, tags?: string[], status?, priority?, due_date?} — accepts description as fallback for body) |
| GET | /api/tasks/:id |
Get single task |
| PUT | /api/tasks/:id |
Update task (body: {title?, body?, tags?: string[], status?, priority?, due_date?} — accepts description as fallback for 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 or model (body: {title?, model?}) |
| POST | /api/chat/conversations/:id/messages |
Start generation: save user message, launch background task, return 202 (body: {content, context_note_id?, include_note_ids?}) |
| GET | /api/chat/conversations/:id/generation/stream |
SSE endpoint tailing generation buffer; supports Last-Event-ID reconnection; emits context, chunk, done, error events |
| POST | /api/chat/conversations/:id/generation/cancel |
Cancel active generation (sets cancel_event, saves partial content) |
| POST | /api/chat/messages/:id/save-as-note |
Save assistant message as a new note (LLM-generated title, tagged chat) |
| POST | /api/chat/conversations/:id/summarize |
Summarize conversation via LLM, save as note |
| GET | /api/chat/ps |
List currently loaded (hot) Ollama models ({models: [{name, size, size_vram, expires_at}]}) |
| POST | /api/chat/warm |
Pre-load a model into Ollama memory (body: {model}, returns 202) |
| GET | /api/chat/status |
Check Ollama availability and model readiness ({ollama, model, default_model}) |
| GET | /api/chat/models |
List available Ollama models |
| POST | /api/chat/models/pull |
Pull/download a model from Ollama via SSE streaming progress (body: {model}, response: SSE with {status, completed, total} events) |
| POST | /api/chat/models/delete |
Delete a model from Ollama (body: {model}) |
| GET | /api/admin/smtp |
Get SMTP config (password masked) (admin only) |
| PUT | /api/admin/smtp |
Save SMTP config to admin settings (admin only, body: {smtp_host, smtp_port, ...}) |
| POST | /api/admin/smtp/test |
Send test email (admin only, body: {recipient}) |
| GET | /api/settings |
Get all app settings as {key: value} dict |
| PUT | /api/settings |
Update settings (body: {key: value, ...}) |
| GET | /api/settings/models |
Get installed Ollama models + configured defaults ({models, default_chat_model, default_intent_model}) |
| GET | /api/projects |
List projects |
| POST | /api/projects |
Create project |
| GET | /api/projects/:id |
Get project with milestone summary |
| PATCH | /api/projects/:id |
Update project |
| DELETE | /api/projects/:id |
Delete project |
| GET | /api/projects/:id/milestones |
List milestones for project |
| POST | /api/projects/:id/milestones |
Create milestone |
| PATCH | /api/projects/:id/milestones/:mid |
Update milestone |
| DELETE | /api/projects/:id/milestones/:mid |
Delete milestone |
| GET | /api/tasks/:id/logs |
List work logs for task |
| POST | /api/tasks/:id/logs |
Create work log (body: {content, duration_minutes?}) |
| PATCH | /api/tasks/:id/logs/:log_id |
Update work log |
| DELETE | /api/tasks/:id/logs/:log_id |
Delete work log |
| GET | /api/push/vapid-public-key |
Get VAPID public key for push subscription |
| POST | /api/push/subscribe |
Register push subscription |
| DELETE | /api/push/subscribe |
Unregister push subscription |
| GET | /api/export |
Export notes/tasks (?format=markdown → ZIP, ?format=json → JSON array) |
| POST | /api/auth/invalidate-sessions |
Bump session_version — invalidates all other sessions, keeps current alive |
| POST | /api/chat/conversations/:id/messages |
Start generation (body: {content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?}) — workspace_project_id injects project system prompt |
| POST | /api/chat/conversations/bulk-delete |
Delete multiple conversations (body: {ids: number[]}) |
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 → 0002 → 0003 → 0004 → 0005 → 0006 → 0007 → 0008 → 0009 → 0010 → 0011 → 0012 → 0013 → 0014 → 0015
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
Implemented Features
Notes & Tasks
- Full CRUD with markdown bodies, first-class tag array (chip input in editors), 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 viauseShortcutscomposable. - Global keyboard shortcuts (
App.vue):g+h/n/t/p/c— navigate to home / notes / tasks / projects / chatn— new note;t— new taske— jump to edit (onnote-viewortask-viewroute)/— focus search bar (dispatchesshortcut:focus-search; list views listen + callSearchBar.focus())c— focus chat input on home (dispatchesshortcut:focus-chat); navigate to/chatelsewherej/k— move selection down / up in flat note/task lists;Enteropens selected itemEscape— progressive: close shortcuts panel → blur active input → navigate home- All shortcuts suppressed when focus is in an input, textarea, or contenteditable
a:focus-visibleadded totheme.cssfocus-ring ruleset so router-link cards show visible focus outlinesSearchBar.vueexposesfocus()viadefineExposefor external focus dispatch
LLM Chat
- Ollama integration via async HTTP (httpx), default model
qwen3:latest(config.pyfallback); docker-compose default isllama3.1(override viaOLLAMA_MODELenv var or userdefault_modelsetting); auto-pull on startup - Background generation with
GenerationBuffer(in-memory SSE fan-out,Last-Event-IDreconnect, 60s cleanup) - Stop generation with partial content preservation
- Note-aware context building: current note + keyword search for related notes + URL fetching
- Context sidebar (Phase 21): Right panel in ChatView shows two sections: Suggested (auto-found
by semantic/keyword search — click
+to include) and In Context (explicitly included by user — click×to remove). Removing an included note moves it back to Suggested. Hidden on mobile (≤768px). Auto-found notes are shown as sidebar candidates only — they are NOT injected into the system prompt automatically, keeping the system prompt prefix stable for Ollama KV cache reuse. - Note picker (paperclip) in chat input adds notes directly to persistent
includedNotes(not a one-shot attachment) — picked notes appear in "In Context" and remain for the entire conversation.context_note_titlesynthesised server-side at conversation load via batchget_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
/chatpage 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); user's
default_modelsetting is always the source of truth for generation and summarization —conv.model(stored at creation time) no longer short-circuits the setting lookup - Dashboard inline chat input: model selector + note picker + textarea; creates conversation and navigates
- Startup warm-up (fixed): No longer warms
Config.OLLAMA_MODEL. At startup, queries all distinctdefault_modelvalues from user settings, cross-references with Ollama's installed models, and warms only the intersection. Models selected by users but not yet installed are skipped with an info log. - Model warming: default model pre-loaded into Ollama on dashboard mount via fire-and-forget POST
- Model load state indicator:
GET /api/chat/statusnow queries/api/tagsand/api/psin 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).chatReadyis 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. AfterwarmModel()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/psproxies Ollama/api/ps; ModelSelector shows green/red emoji circles - Ollama configured with
OLLAMA_MAX_LOADED_MODELS=2andOLLAMA_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_callsJSONB column and rendered as compact ToolCallCard components. SSE emitstool_callevents 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; protected by a three-layer duplicate guard:- Exact title (case-insensitive via
get_note_by_title) → hard block, redirect toupdate_note - Fuzzy title (
SequenceMatcher≥82%; strips punctuation before word search for candidates) → hard block - Semantic content (
semantic_search_notesthreshold 0.87, graceful no-op if embedding model down) → soft block withrequires_confirmation:true; model must ask user then retry withconfirmed:trueBoth tools have an optionalconfirmedparameter (not required by default — only needed when a content-similarity warning fires).
- Exact title (case-insensitive via
create_project— always requiresconfirmed:truebefore creation (structural, must be intentional); additionally guarded by exact + fuzzy title checks (≥82%) against existing projectscreate_milestone— always requiresconfirmed:true; additionally guarded by exact + fuzzy title checks against existing milestones within the same projectupdate_note— edit note content (replace/append) AND update task fields:status,priority,due_date,tags(withtag_mode: replace/add/remove); finds by exact title first, falls back to fuzzy searchdelete_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 bystatus,priority,due_before,due_after,limit; backed bylist_notes(is_task=True); enables "overdue tasks", "high priority", "in progress" queriessearch_notes— keyword search across notes and tasks; optionaltype: "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_todoskeyword-filters the todo list — companion tolist_todos) - Duplicate guard helpers in
services/tools.py:_fuzzy_title_match(title, candidates, threshold=0.82)usesSequenceMatcher; semantic check usessemantic_search_notesat threshold 0.87 (vs. 0.45 for RAG suggestions)._PUNCT_REstrips punctuation from title before word-search to avoid false-negative candidates (e.g. "Shackled - Game Premise" → "Shackled Game Premise" query).
- Streaming status transparency: The backend emits
statusSSE events at each pipeline stage so the user always sees what's happening instead of a blank progress dot. Stages: (1) intent ack text streamed as achunkevent (tool responses — TTFT ~400ms) or"Generating response..."status event (chat-only responses); (2) human-readable tool label (e.g."Creating calendar event...") before each tool executes; (3)"Composing response..."before tool follow-up rounds. Frontend:chat.tsstoresstreamingStatusref, cleared on first content chunk or on done/error.ChatView.vueshows a pulsing dot + italic label above the content while status is active, then falls back to the blinking cursor when content streams in.HomeView.vuedashboard panel shows the status label in place of...before any content arrives. - Intent routing (intent-first pipeline, Phase 21): On the first round,
build_context()andclassify_intent()run concurrently. Once intent returns (~400ms), the pipeline immediately acts: if a tool is detected, the intent's one-sentenceackfield is streamed as the first chunk (becoming TTFT), the tool executes, then the main model generates a follow-up response with the tool result. For chat-only responses, the model streams directly with no ack prefix. No optimistic streaming queue or race — eliminates wasted GPU prefill when intent won the race.IntentResult.ack(one-sentence acknowledgment) is embedded in the intent JSON output, so no additional LLM call is needed for acknowledgment. Intent modelmax_tokens350 (was 200). Dedicated intent model configurable viaOLLAMA_INTENT_MODELenv var (defaultqwen2.5:7b) or per-userintent_modelsetting — smaller/faster model for routing. Main model default isqwen3:latest(configurable viaOLLAMA_MODELenv var or per-userdefault_modelsetting — both settable as dropdowns in the Settings UI populated from installed models).OLLAMA_NUM_CTXenv 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.generate_completion(used by intent classifier) retries on HTTP 500 (3 attempts, 3s/6s delays) to handle cold model loading without failing intent classification. - Intent performance optimizations:
- Intent skip heuristic:
_should_skip_intent(msg)ingeneration_task.pyskips the intent model call entirely for short messages (≤10 words) that contain none of the_INTENT_TRIGGER_WORDSfrozenset (~50 action/object/date words). Saves 400–800ms for conversational replies like "thanks", "can you explain that?", "okay" without risking missed tool calls on longer or action-verb messages. - Intent
num_ctx=4096: Intent classification calls use a 4k context window (override) instead of the default, reducing VRAM pressure and prefill time on every request. - Prior-work fast-path:
_PRIOR_WORK_REFSregex inintent.pydetects phrases like "research you did", "note you made", "using your research", "based on the research" etc. and returns no-tool immediately — skips the LLM call entirely so the main model answers usingsearch_notes/context instead of firing a web search. Preventssearch_webbeing triggered when the user references existing notes. search_webscoping rules: Intent prompt explicitly prohibitssearch_webfor creative/ brainstorming requests, game design, writing help, or when the user references existing notes. Creative/ideation requests ("think of", "come up with", "brainstorm") always route to null (chat).search_webis only for genuinely new real-time facts not in the user's notes.
- Intent skip heuristic:
- Image search with local cache (Phase 23):
search_imagestool (SearXNGcategories=images) fetches images server-side, stores them on disk, and serves them from/api/images/<id>— the user's browser never contacts the original image host. Original URLs preserved for citation.- New migration
0016_add_image_cache.py:image_cachetable (id,url_hash,original_url,source_domain,title,content_type,file_size,file_ext,fetched_at) services/images.py:fetch_and_store_image()— SHA-256 dedup, content-type validation (image/*only), 5 MB cap, same-originRefererheader to bypass hotlink protection,IntegrityErrorrace handling.get_image_record(),get_image_path().routes/images.py:GET /api/images/<id>— no auth (IDs are opaque SHA-256-derived);Cache-Control: max-age=86400; 404 if file missing from disk.research.py:_search_searxng_images(query)— SearXNG withcategories=images, returns[{img_src, page_url, title, source_domain}]with same 429-retry logic as text search.tools.py:_IMAGE_TOOLSlist +search_imagesbranch inexecute_tool— fetches top 2 images, returns{type: "image_search", data: {images: [{local_url, page_url, source_domain, title}]}}.intent.py: routing rule — only triggers on explicit visual language ("show me", "what does X look like", "picture of", "photo of"). Factual/description questions stay as null (chat).- Config:
IMAGE_CACHE_DIR(default/data/images),IMAGE_MAX_BYTES(default 5 MB). - Docker: named volume
app_datamounted at/data(covers all future persistent storage, not just images). Bind-mount alternative./data:/datadocumented in compose comments. - No CSP changes needed —
/api/images/is same-origin, already covered byimg-src 'self'.
- New migration
- Suggested notes improvements:
semantic_search_notesnow returnslist[tuple[float, Note]](waslist[Note]); scores threaded throughcontext_meta["auto_notes"]to frontend. Sidebar shows%badge per note: green ≥75%, amber 60–74%, muted 45–59%. Threshold raised 0.30 → 0.45 (only genuinely relevant notes shown). Limit raised 3 → 8 (no added compute — threshold is the real gate). Keyword fallback notes carryscore: null(no badge shown). - Quick-capture endpoint:
POST /api/quick-captureinroutes/quick_capture.py(session auth via cookie). Takes{"text": "natural language string"}, runsclassify_intentfiltered to creation-only tools (create_note,create_task,create_event,create_todo), callsexecute_tool, and returns a single synchronous JSON response — no SSE, no conversation ID. Falls back tocreate_noteif intent is unclear or low-confidence. Response:{"success", "type", "message", "data", "fallback?"}. Designed for Android/mobile quick-capture use case. Auth: session cookie fromPOST /api/auth/login. - Bug fixes (this session):
create_note/create_tasklist-title crash:execute_toolnow validatestitleis a string and returns a structured error so the model self-corrects with individual calls (vs. crashing asyncpg).- AI Assist panel 400 errors:
routes/notes.pyassist route was missingor Config.OLLAMA_MODELsafety net — emptydefault_modelDB value was passed to Ollama as-is, causing 400. - Note picker → persistent context: paperclip attachment now calls
includeNote()directly (persistentincludedNotes) instead of the one-shotattachedNotepattern.
- Default model setting fix:
delete_setting()added toservices/settings.py— saving an empty model value in Settings now deletes the DB row rather than storing"", soget_setting()falls back to theConfigdefault. Safety netor Config.OLLAMA_MODELguards added inroutes/chat.pystatus, message, and summarize routes to prevent empty-string model from breaking readiness indicator. - Web research pipeline (Phase 22):
research.pyimplements a full autonomous research pipeline triggered by "research X and make a note" (intent routes toresearch_topictool) or via the 🔍 Research button in ChatView (sends "Research: {topic}" message). Pipeline stages:- Intent model generates 5 focused sub-queries as a JSON array (falls back to
[topic]on parse failure) - All 5 SearXNG queries execute in parallel with 200ms stagger (avoids hammering rate limiter)
- All unique URLs (up to 15) fetched in parallel via
asyncio.gather; duplicates deduplicated by URL - Failed fetches filtered; up to 12 sources passed to synthesis LLM
- Synthesis uses
stream_chatwithnum_ctx=16384,num_predict=8192— tokens stream into the chat buffer in real time (user sees the note being written); minimum 2500 words, 6+ topic-appropriate sections (topic determines section structure, not hardcoded), detailed prose,## Sourcessection - Note created with
tags=["research"]; "Research complete!" separator appended to chat Also includessearch_webtool for lightweight single-query searches (no note created; results returned to LLM for conversational answer). Both tools only added to tool list whenSEARXNG_URLis set. SearXNG429handling: 3-attempt retry with exponential backoff per query. Sub-query JSON parsed withjson.JSONDecoder().raw_decode()to handle trailing model text. Frontend:ToolCallCard.vuehandlesweb_search(external links),research_pending,research_notetypes. Settings:GET /api/settings/search?q=proxy + Search Test section in SettingsView. Settings UI: 2-column grid layout (paired simple cards,full-widthfor complex sections). Docker:OLLAMA_NUM_PARALLEL=2to prevent research and live chat queuing each other. SearXNG setup: add app server IP tobotdetection.ip_lists.pass_ipin SearXNGsettings.ymlto bypass rate limiter for trusted backend requests while keeping it active for public users.
- Intent model generates 5 focused sub-queries as a JSON array (falls back to
- 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. - Dedicated tag chip input (Phase 20): Tags are now a first-class UI field.
TagInput.vueis a chip-based input placed between the title and body in both editor views. Chips confirmed with Enter/comma/autocomplete click; Backspace removes last chip; × removes individual chips. Autocomplete fetches from/api/notes/tags?q=. Tags saved as explicittags: string[]in create/update payloads.TagSuggestionTiptap extension removed;TagDecorationkept for legacy inline#tagdisplay. - LLM-suggested tags: Backend service (
tag_suggestions.py) prompts LLM with existing user tags and note content, returns 3-5 relevant tag suggestions filtered againstcurrent_tags(not body text). Exposed viaPOST /api/notes/suggest-tags(acceptscurrent_tagslist) andPOST /api/notes/:id/append-tag. Integrated in: (1) Editor views — "Suggest tags" button shows clickable pills that add chips to TagInput (not body); (2) Chat tool calls —create_note/create_taskresults includesuggested_tags, rendered as pills in ToolCallCard with one-click apply via append-tag API. LLM instructed to use thetagsparameter, not embed#tagtext in note body.
Projects & Milestones (Phases A + G)
- Project model (
models/project.py): id, user_id (FK CASCADE), title, description, goal, status ("active"/"completed"/"archived"), color, timestamps. - Milestone model (
models/milestone.py): id, user_id, project_id (FK CASCADE), title, description, status, order_index, timestamps. Hierarchy: Project → Milestone → Task. notestable:project_idFK (SET NULL) +milestone_idFK (SET NULL) columns.parent_idcolumn now has a FK constraint tonotes.id(SET NULL) to enable sub-tasks.- Migrations:
0017_add_projects.py,0020_add_milestones.py(milestone_id on notes). - Services:
services/projects.py(CRUD +get_or_create_project+get_project_summary);services/milestones.py(CRUD +get_milestone_progress+get_project_milestone_summary). - Routes:
routes/projects.pyunder/api/projects;routes/milestones.pyunder/api/projects/<pid>/milestones. - LLM tools:
create_project,list_projects,get_project,update_project;create_milestone,list_milestones;create_note/create_task/update_noteaccept optionalproject+milestone+parent_taskparams. - Frontend:
ProjectListView.vue(card grid, stacked milestone progress bars per card),ProjectView.vue(milestone-grouped kanban + inline milestone management ++button in Todo column header →/tasks/new?projectId=X&milestoneId=Y),ProjectSelector.vue(dropdown used in NoteEditorView + TaskEditorView),MilestoneSelector.vue(used in NoteEditorView + TaskEditorView). - Sub-tasks in TaskEditorView: Sub-tasks section fetches child tasks (
parent_id=<id>), shows toggle + inline create form. URL query params?projectId=X&milestoneId=Y&parentId=Zpre-fill editor fields on new task. - Types:
frontend/src/types/note.tsNote interface now includesproject_id: number | nullandmilestone_id: number | null. app.pyregistersprojects_bpandmilestones_bp; router adds/projectsand/projects/:id.services/notes.py:list_notes()acceptsparent_idfilter and newmilestone_ids: list[int]param — when provided withproject_id, usesOR (project_id=X OR milestone_id IN (...))so tasks assigned to a milestone-but-not-project are still returned.create_note()auto-setsproject_idfrom the milestone's project whenmilestone_idis provided andproject_idis omitted.services/milestones.py:find_milestone_by_title()for cross-project milestone lookup.GET /api/notesnow accepts additional query params:project_id,milestone_id,parent_id,type(note/task/all).routes/projects.pyGET /api/projects/:id/notes: fetches project's milestone IDs and passes them viamilestone_idstolist_notes, so tasks assigned via milestone (but lackingproject_id) are included.routes/tasks.pybug fix:POST /api/tasksandPUT /api/tasks/:idwere silently droppingproject_id,milestone_id, andparent_idfrom the request body. Both routes now read and pass all three fields.GET /api/tasks/:idnow includesparent_titlein the response (secondary lookup whenparent_idis set).routes/notes.py: AddedPATCH /api/notes/:idfor partial updates (same accepted fields as PUT). Used by sub-task status toggle in the frontend.services/tools.py:list_notestool supports project filter +updated_at;list_taskstool supports milestone without requiring project;tag_modedefaults toadd; fail-fast project resolution raises tool error immediately.
RAG Auto-injection (Phase B)
- Notes scoring ≥0.60 cosine similarity are injected automatically into every chat system prompt (max 3, up to 800 chars each) under
--- Relevant Notes ---heading. semantic_search_notes()acceptsthresholdparam (default 0.45 for sidebar suggestions; 0.60 for auto-inject).build_context()constants:RAG_AUTO_THRESHOLD=0.60,RAG_AUTO_LIMIT=3,RAG_AUTO_SNIPPET=800.context_metaaddsauto_injected_noteslist +auto_injected: boolflag on eachauto_notesitem.excluded_note_idsparam inbuild_context()+ POST body lets users remove a note from auto-injection.- ChatView sidebar: "Auto-included" section (green border) above "Suggested" and "In Context".
Summarization Improvements (Phase C)
_HISTORY_SUMMARY_THRESHOLDraised 20 → 30;_HISTORY_KEEP_RECENTraised 6 → 8; summarymax_tokensraised 200 → 400.- Improved summary prompt captures: note/task/project names, decisions, open questions, topic arc.
- Two-pass summarization for histories >50 messages (first half → summary_a, combined → final).
Browser Push Notifications (Phase E)
models/push_subscription.py: PushSubscription — endpoint, p256dh, auth, user_agent; UNIQUE(user_id, endpoint).- Migration:
0018_add_push_subscriptions.py(down_revision="0017"). services/push.py:vapid_enabled(),save_subscription(),delete_subscription(),send_push_notification()(lazy-imports pywebpush, fire-and-forget, removes 410 Gone subs).ensure_vapid_keys()auto-generates an EC P-256 key pair on first boot, saves to/data/vapid_keys.json(insideapp_datavolume) so it survives container restarts.routes/push.py:GET /api/push/vapid-public-key,POST/DELETE /api/push/subscribe.- Config:
VAPID_PRIVATE_KEY,VAPID_PUBLIC_KEY,VAPID_CLAIMS_SUBenv vars — all optional (keys are auto-generated at startup if absent). Env vars still take precedence for deployments that manage keys externally. generation_task.py: fires push afterbuf.state = COMPLETED(guarded byvapid_enabled()).pywebpush>=2.0added topyproject.toml.- Frontend:
frontend/public/sw.js(push + notificationclick handlers);frontend/src/stores/push.ts(isSupported, permission, isSubscribed, subscribe/unsubscribe); SettingsView "Push Notifications" toggle.
PWA Manifest (Phase F)
frontend/public/manifest.json: name, short_name, start_url, display: standalone, icons.index.html:<link rel="manifest">,<meta name="theme-color">, Apple mobile meta tags.main.ts: service worker registration.- Installable on iOS (Share → Add to Home Screen) and Android. Push works on Android (iOS 16.4+ partial).
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-invitetoken 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 providerGET /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=falsehides password form and blocksPOST /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 (
httpxalready 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; updatesauthStore.userin-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_htmlinemail.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_logstable: 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-schemedetection; 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 (#6366f1fill,#4f46e5stroke,#a5b4fclines); dark mode uses light gray; gold sparkle always visible;@media (prefers-color-scheme: dark)in SVG<style>
Infrastructure
- Multi-stage Dockerfile: Node build → Python runtime, auto-migration on startup
- Docker Compose (dev) with app service healthcheck (
/api/health, 10s interval, 30s start period) + Docker Swarm production stack with secrets, overlay networks, health checks .dockerignoreprevents secrets,node_modules,__pycache__,.env.*from leaking into build context- Production compose (
docker-compose.prod.yml):TRUST_PROXY_HEADERS=true+SECURE_COOKIES=trueset for Traefik deployments; port 5000 not published directly (Traefik routes internally) - Quart serves Vue SPA from static + REST API under
/api/; 404 handler for SPA routing - Security headers applied in
after_request:X-Content-Type-Options,X-Frame-Options: DENY,Referrer-Policy,Content-Security-Policy - In-memory sliding-window rate limiter on all auth endpoints (login, register, forgot/reset password); proxy-aware client IP with
TRUST_PROXY_HEADERS - SQLAlchemy async engine configured with
pool_pre_ping=True(tests connections before use, discards stale ones after Postgres restart) andpool_recycle=1800(recycles idle connections every 30 min to prevent TCP/firewall staleness) - Alembic migrations: 20 migrations (0001–0020), all raw SQL with idempotency guards (IF NOT EXISTS, DO $$ BEGIN...EXCEPTION)
- Config from env vars + Docker secrets file support (
_read_secret)
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
Android Companion App
A native Flutter app at /home/bvandeusen/Nextcloud/Projects/fabled_app (separate repository).
Stack
- Flutter + Dart, Riverpod (state), GoRouter (navigation), Dio (HTTP), PersistCookieJar (session)
- SSE streaming for chat using
fetch+ReadableStreambridge - Self-update support via Forgejo release API (
update_provider.dart)
Architecture
- Data layer:
lib/data/models/,lib/data/api/,lib/data/repositories/ - State: Riverpod
AsyncNotifierProviderper resource inlib/providers/ - Navigation: GoRouter with
ShellRoutefor tabbed main shell;_RouterNotifierrefreshes on auth change
Models & API Coverage
| Resource | Model fields | API endpoints |
|---|---|---|
| Note | id, title, body, tags, project_id, milestone_id, created_at, updated_at | GET/POST /api/notes, GET/PUT/DELETE /api/notes/:id |
| Task | id, title, body, status, priority, due_date, project_id, milestone_id, parent_id, created_at, updated_at | GET/POST /api/tasks, GET/PUT/DELETE /api/tasks/:id |
| Project | id, title, description, goal, status, color, created_at, updated_at | GET/POST /api/projects, GET/PATCH/DELETE /api/projects/:id |
| Conversation | standard chat fields | GET/POST conversations, SSE stream |
| QuickCapture | text → type+title+message | POST /api/quick-capture |
Navigation Shell (4 tabs)
Notes · Tasks · Projects · Chat — bottom NavigationBar on phone; NavigationRail on tablet/landscape.
Quick Capture bar at the top of the shell (persists across all tabs); Settings accessible from top-right icon.
Feature Status
| Feature | Status | Notes |
|---|---|---|
| Notes CRUD | ✅ | tags chip input; project selector in editor |
| Tasks CRUD | ✅ | project selector in editor |
| Projects list | ✅ | active/archived sections; long-press status change; create dialog |
| Chat + SSE | ✅ | full streaming |
| Quick Capture | ✅ | offline queue with retry |
| Tags | ✅ (model + API) | Chip input in NoteEditScreen; typed as List<String> |
| Project assignment | ✅ | ProjectSelector dropdown in Note + Task editors |
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
Key Files
lib/
app.dart # GoRouter + _Shell + _QuickCaptureBar
core/constants.dart # Routes.*
data/
models/note.dart # Note (tags, project_id, milestone_id)
models/task.dart # Task (project_id, milestone_id, parent_id)
models/project.dart # Project
api/notes_api.dart # create/update accept tags, project_id
api/tasks_api.dart # create accepts project_id
api/projects_api.dart # full CRUD
repositories/notes_repository.dart
repositories/tasks_repository.dart
repositories/projects_repository.dart
providers/
api_client_provider.dart # all API + repository providers
notes_provider.dart # NotesNotifier
tasks_provider.dart # TasksNotifier
projects_provider.dart # ProjectsNotifier
screens/
notes/note_edit_screen.dart # chip tag input + ProjectSelector
tasks/task_edit_screen.dart # ProjectSelector
projects/project_list_screen.dart
widgets/
project_selector.dart # reusable DropdownButtonFormField
API Compatibility Notes
GET /api/projects/:idreturns a flat JSON object (not{project: ...}wrapper); includessummaryfield.POST /api/projectsreturns project dict directly (201).PATCH /api/projects/:idreturns updated project dict.- Task body field name is
body(notdescription) — Flutter mapsdescription→bodyon serialize.
Backlog
- Calendar/timeline view for tasks
- Import/export (Markdown files, JSON)
- Email integration (read/send emails from chat via IMAP/SMTP tools)
- Session invalidation on user deletion
- Note relation map (Obsidian-style graph): Interactive force-directed graph visualizing connections between
notes via wikilinks (
[[Title]]) and shared tags. Nodes = notes/tasks; edges = wikilink references or tag co-occurrence. Clicking a node navigates to that note. Filter by tag or depth. Useful for discovering clusters of related notes and orphaned notes with no connections. - Flutter push notifications: Requires FCM/APNs integration (separate from web VAPID). Would need
a FCM server key in Config and a new notification pathway in
generation_task.py. - Flutter milestone support: Milestone grouping in project view deferred; could add as a filtered task list view once the feature is well-established on web.