- Dockerfile: ARG BUILD_VERSION=dev → ENV APP_VERSION baked into image
- ci.yml: BUILD_VERSION passed as build-arg; set to git tag name on v*
tag builds, "dev" on branch builds
- routes/api.py: GET /api/version returns {"version": APP_VERSION}
- SettingsView: fetches /api/version on mount, displays in About section
under General tab
Version source of truth is the git tag (YY.MM.DD.N CalVer).
pyproject.toml / package.json versions are no longer maintained.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Export now includes: projects, milestones, task_logs, note_drafts,
note_versions, push_subscriptions; full user fields (oauth_sub,
session_version); full message fields (status, tool_calls).
Restore v2 builds ID maps for all tables and patches FKs in dependency
order (users → projects → milestones → notes → child records). Notes
restored in two passes to handle self-referential parent_id correctly.
v1 backups still restore via _restore_v1 path (parent_id bug also fixed).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
run_research_pipeline now accepts project_id; generation_task.py passes
workspace_project_id when the tool is called from a workspace context.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
create_note now only checks against existing notes (is_task=False);
create_task only checks against existing tasks (is_task=True). All three
checks (exact title, fuzzy title, semantic similarity) pass the type
filter through to list_notes and semantic_search_notes.
Adds is_task param to semantic_search_notes() so callers can restrict
results to notes or tasks independently.
Prevents a note titled "Design enemy AI" from blocking a task with the
same title, and vice versa.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Raises body character limit from 80 → 200 (skips short/stub notes)
and threshold from 0.87 → 0.90 (only flags near-identical content).
Enemy design notes sharing a template will pass through cleanly;
accidentally duplicated notes with the same detailed content will
still trigger the confirmation prompt.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Queued message bubbles now match user bubble style (primary colour,
right-aligned, opacity 0.45) in both ChatView and WorkspaceView
- Queue persisted to localStorage (fa_conv_queue_<id>); survives page
refresh, restored on fetchConversation, cleared on delete
- ToolCallCard confirm/deny: buttons now inline in header row; "Create
anyway" calls POST /api/notes or /api/tasks directly (no LLM round-trip);
shows "✓ Created" / "Skipped" state; removed chatStore dependency
- POST /api/notes and POST /api/tasks now accept project (name string) and
resolve to project_id server-side, matching tools.py behaviour
- Remove semantic similarity duplicate check from create_note and
create_task — 0.87 threshold was too aggressive for topically-related
notes; title-based exact/fuzzy checks are sufficient
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dashboard chat: allow typing/sending during streaming (queued in store)
placeholder updates to "Type to queue…" during generation
- ChatView/WorkspaceView: replace queued chip with actual pending message
bubbles — light grey, dashed border, "Queued" badge above content; clear
button below the stack
- ToolCallCard: when tool returns requires_confirmation=True, show
"Similar content found" label (not "Error"), link to the similar note,
and "Create Anyway" / "Don't Create" buttons that auto-send the reply
- tools.py: fuzzy title match now returns requires_confirmation=True with
similar_note data instead of a hard error, so numbered-series notes
(Lore: X 0, Lore: X 1) can be created with one button click; semantic
match responses also include similar_note for the link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- sw.js: suppress notification when the target chat tab is already focused
(clients.matchAll visibility check before showNotification)
- generation_task.py: provide meaningful body for tool-only responses
(lists tool names instead of sending an empty string that browsers discard);
promote scheduling failure from debug to warning
- push.py: promote send errors from warning to error with exc_info;
log successful sends at INFO so they're visible in normal operation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The semantic similarity check was flagging unrelated short-title tasks
as duplicates (e.g. "Lore: Shell 0" matching "Lore: Reinitialization 0"
at 91%) because with no body, the embedding is purely title-based and
co-domain tasks in the same project share a tight embedding neighborhood.
Only run the semantic check when the body is ≥ 80 chars — enough
content to make a meaningful comparison. The fuzzy title check already
covers exact/near-exact title duplicates.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The 404 handler was unconditionally serving index.html (200) for all
non-API, non-static paths, including scanner probes for .php, .asp, .cgi
etc. Added _SPA_EXTENSIONS set so paths with unknown extensions get a
real 404 instead of a misleading 200.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- WorkspaceTaskPanel: add milestone <select> in task detail (PATCH /api/notes/:id),
replace delete ✕ with trash can SVG icon
- WorkspaceView: scroll to bottom when streaming ends so final message is visible
without a page refresh
- ToolCallCard: fix search_notes result count (was reading data.total; tool returns
data.count), so results no longer show "0 found"
- push.py: switch from deprecated WebPusher().send(vapid_private_key=...) to
webpush() function (pywebpush 2.x API compatibility)
- app.py: downgrade /api/health, /api/chat/status, and static asset requests from
INFO to DEBUG in after_request logger to reduce log noise at default log level
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New /graph route with D3 force simulation (GraphView.vue)
- Tag nodes as first-class graph nodes (string IDs "tag:name") — clicking
navigates to /notes?tags=name; tags shown by default
- Invisible project hub nodes attract project members into clusters
- Physics panel with live sliders: repulsion, link distance, link strength,
project pull, gravity (forceX/forceY, not forceCenter)
- Wikilink edges retain directed arrowheads; tag edges are thin, no arrowhead
- Graph nav link in AppHeader; `g` shortcut in App.vue
- Backend: build_note_graph() emits tag nodes + note→tag edges instead of
O(n²) note→note shared-tag mesh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add session_version to users table. Sessions now carry session_version
alongside user_id; @login_required rejects any session where the version
doesn't match the DB value.
- migration 0024: session_version INTEGER NOT NULL DEFAULT 1
- models/user.py: session_version Mapped[int] column
- auth.py: version mismatch → session.clear() + 401
- services/auth.py: change_password and reset_password_with_token both
increment session_version, evicting all other live sessions
- routes/auth.py: login/register/oauth_callback store session_version;
update_password rehydrates current session with new version so the
user who changed their password stays logged in
Existing sessions will require one re-login after the migration runs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NoteEditorView: two-column sidebar layout (project/milestone/tags/assist
always visible), removed assist toggle button, InlineAssistPanel removed.
Writing assist: whole_doc mode rewrites entire document; DiffView.vue
replaces editor during review showing full-document diff. Scope dropdown
in sidebar switches between whole-document and section modes.
Persistent drafts: migration 0022 adds note_drafts (UNIQUE per note+user)
and note_versions (max 20, auto-pruned) tables. Draft saved after generation
completes, restored on editor mount, cleared on accept/reject. Version
snapshot created automatically whenever note body changes on save.
HistoryPanel.vue: version list + DiffView modal, restore button writes
body back to editor.
Config: OLLAMA_NUM_CTX default raised to 65536; assist num_predict now
tracks Config.OLLAMA_NUM_CTX instead of a hardcoded 4096.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stream_chat: add think=False parameter passed through to Ollama payload.
qwen3 models have thinking enabled by default; without this flag the model
spends minutes generating internal thinking tokens that stream_chat silently
discards, leaving the frontend spinner blank until the SSE connection times
out and the widget disappears.
- create_assist_buffer: orphan (overwrite) a still-running buffer instead of
raising. The old asyncio task holds a direct reference and completes
harmlessly against the stale buffer. New requests always win.
- assist_route: remove the 409 guard that blocked new requests when a previous
generation got stuck. create_assist_buffer now handles this transparently.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three-layer guard on create_note and create_task:
1. Exact title match (get_note_by_title, case-insensitive) → hard block,
redirects model to use update_note instead
2. Fuzzy title match (SequenceMatcher ≥82%, punct-stripped word search for
candidates) → hard block; catches "Game Premise" vs "Game Premise Notes"
3. Semantic content similarity (semantic_search_notes threshold=0.87) → soft
block with requires_confirmation:true; model asks user then retries with
confirmed:true; graceful no-op if embedding model is unavailable
create_project and create_milestone now always require confirmed:true before
creation (structural entities — must be intentional). Both are also guarded
by exact + fuzzy title checks post-confirmation.
create_note and create_task gain an optional confirmed parameter (not
required by default; only used to bypass a content-similarity soft-block
after the user has been asked).
Helpers added to tools.py:
- _fuzzy_title_match(title, candidates, threshold=0.82)
- _PUNCT_RE for stripping punctuation before word-search candidate fetch
- difflib.SequenceMatcher and re imported at module level
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- routes/tasks.py: POST + PUT were silently dropping project_id,
milestone_id, parent_id from request body — root cause of association
not saving from the task editor
- routes/tasks.py: GET /api/tasks/:id now includes parent_title in
response (secondary lookup when parent_id is set)
- routes/notes.py: add PATCH /api/notes/:id for partial updates (used
by sub-task status toggle; PUT already existed but PATCH was missing)
- routes/projects.py: GET /api/projects/:id/notes now fetches milestone
IDs and passes them via milestone_ids so tasks assigned to a milestone
(but lacking project_id) are included in the project view
- services/notes.py: create_note() auto-sets project_id from milestone
when milestone_id is provided and project_id is omitted; list_notes()
gains milestone_ids param — when combined with project_id uses OR
condition (project_id=X OR milestone_id IN (...))
Frontend:
- NoteEditorView: add MilestoneSelector; milestone resets when project
changes; all save paths (save/create/auto-save) include milestone_id
- stores/notes.ts: add milestone_id to createNote + updateNote types
- TaskEditorView: sub-tasks now inherit milestone_id from parent task
- AppHeader: three-zone layout — brand left, Notes/Projects/Tasks/Chat
centered (absolute positioning), right rail with status/theme/? and
gear dropdown containing Settings/Users/Logs; mobile dropdown unchanged
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
On first boot, ensure_vapid_keys() generates a fresh EC P-256 key pair
and saves it to /data/vapid_keys.json (inside the app_data volume) so
it survives container restarts. Subsequent boots load from that file.
VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY env vars still take precedence for
deployments that prefer to manage keys externally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Startup no longer auto-warms Config.OLLAMA_MODEL. Instead it queries
all distinct default_model values from user settings, cross-references
with Ollama's installed models, and warms only the intersection.
Models that users have selected but not yet installed are skipped with
an info log — they are never auto-pulled. The embedding model pull
behaviour is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
conv.model was stored at conversation creation time and short-circuited
the get_setting() call, so changing default_model in Settings had no
effect on existing conversations. Now the user's default_model setting
is always the source of truth for generation and summarization.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
tools.py:
- Default tag_mode changed from 'replace' to 'add' — existing tags are
preserved unless the user explicitly requests replacement
- create_task, create_note, update_note, create_milestone: project and
milestone lookup now uses get_by_title with fuzzy fallback; returns a
clear error (with a hint to use list_projects/list_milestones) instead
of silently creating duplicate projects or milestones via get_or_create
- create_task: project/milestone resolved before note creation so a bad
project name fails fast without leaving an orphaned task behind
- update_note return value now includes item_type, tags, project_id, and
an 'updated' summary string so the LLM can confirm what was modified
- research_topic stub now logs an error and returns failure instead of
silently returning success when reached via wrong code path
ProjectView.vue:
- Milestone headers now show edit (✎) and delete (✕) action buttons on hover
- Edit triggers inline rename input; Enter/blur commits, Escape cancels
- Delete opens a confirmation modal clarifying tasks are unlinked not deleted
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The separate intent model (OLLAMA_INTENT_MODEL / qwen2.5:7b) is removed
from every part of the system. All classification now uses the primary model.
Changes:
- config.py: remove OLLAMA_INTENT_MODEL
- intent.py: remove classify_intent() and all supporting infrastructure
(_SYSTEM_PROMPT_TEMPLATE, _RESEARCH_PREFIX, _PRIOR_WORK_REFS); file now
only contains the quick-capture classifier
- quick_capture.py: classify_capture_intent() now called with Config.OLLAMA_MODEL
- generation_task.py: remove intent_model_setting DB lookup and get_setting import;
history summarization and research pipeline use the primary model directly
- research.py: remove intent_model parameter from run_research_pipeline() and
_generate_sub_queries(); both use the model param throughout
- routes/settings.py: remove intent_model from model-key validation and response
- app.py: remove intent model pre-warming at startup
- SettingsView.vue: remove Intent Model selector and related refs/state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The intent classifier (Phase 21) is removed from the main chat generation
path. The main model now handles all tool routing natively via Ollama's
structured tool-calling API, eliminating misidentification issues caused
by the small intent model.
Changes:
- generation_task.py: remove classify_intent call, intent_task, _WRITE_TOOLS,
_TOOL_ACTIONS, _INTENT_TRIGGER_WORDS, _should_skip_intent(), and the entire
round-0 intent-first + write-tool confirmation block (~315 lines removed)
- research_topic tool calls are now handled inline in the streaming loop:
runs run_research_pipeline, streams synthesis to buf, then breaks the round
loop (research is still the full response, no model follow-up)
- config.py: raise OLLAMA_NUM_CTX default from 8192 to 16384
The quick-capture dedicated classifier (classify_capture_intent) is unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add _process_note() — a second LLM pass using the main model that
transforms raw capture text into a well-formed note with a genuine
summary title and formatted body. Replaces the previous behaviour of
using the captured text verbatim as both title and body.
The processing prompt instructs the model to:
- Generate a 3-8 word summary title (never a verbatim copy)
- Format the body appropriately: bullet lists for items, clean prose
for stream-of-thought, organised paragraphs for raw notes/fragments
- Preserve all original information without inventing new facts
The enrichment pass runs for both the intent-classified create_note
path and the fallback path. On LLM/parse failure it degrades safely
to the old verbatim behaviour.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stream_chat and stream_chat_with_tools: remove read=300s per-chunk
timeout, replace with read=None. In httpx streaming mode, the read
timeout applies per-chunk — if Ollama pauses >300s while processing
a large input context before the first token, it raises ReadTimeout,
killing generation and leaving the assistant message as an empty stub.
With read=None the stream is unbounded; connect=30s still guards the
initial connection.
- chat_status_route: increase Ollama status check timeout 5s → 10s.
When Ollama is busy processing a large prompt it can be slow to
respond to /api/tags, causing the status indicator to briefly flip to
"offline" even though generation is running normally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove all 6 CalDAV todo tools (create/list/update/complete/delete/search_todos)
from tools.py definitions, imports, execute_tool branches, intent routing rules,
generation_task labels/actions, and llm.py system prompt hints. CalDAV event
tools remain. Todo functions still exist in caldav.py but are no longer exposed.
- Quick-capture now uses a dedicated classify_capture_intent() with a focused
_CAPTURE_SYSTEM_PROMPT that always routes to a tool (never null). Tool set
expanded: create_note/task/event + update_note + research_topic.
- research_topic in quick-capture calls run_research_pipeline() directly (no SSE
buffer). run_research_pipeline() now accepts buf=None; all buf.append_event
calls are guarded so status events are skipped when no buffer is provided.
- Fallback note now always sets body=text (was empty for texts ≤80 chars).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Quart's send_file uses cache_timeout= not max_age=. The TypeError on
every /api/images/<id> request caused a 500, which the browser rendered
as alt text.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- tools.py: search_images result now includes 'embed' (ready-to-use
markdown image syntax) and 'citation' fields instead of raw 'local_url';
adds 'instructions' field so the model knows to render them verbatim
- llm.py: system prompt now explicitly tells the model to embed images
using the 'embed' field rather than describing or listing URLs
- markdown.ts: explicitly allow src/alt in PURIFY_OPTS_FULL so img tags
are never stripped by DOMPurify
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Images found via SearXNG are fetched server-side, stored on disk, and
served from /api/images/<id> — the user's browser never contacts the
original image host. Original URLs are preserved for citation.
New files:
- alembic/versions/0016_add_image_cache.py — image_cache table
- src/fabledassistant/models/image_cache.py — SQLAlchemy model
- src/fabledassistant/services/images.py — fetch/store/serve logic
- src/fabledassistant/routes/images.py — GET /api/images/<id>
Modified:
- config.py: IMAGE_CACHE_DIR (/data/images), IMAGE_MAX_BYTES (5 MB)
- research.py: _search_searxng_images() — SearXNG categories=images
- tools.py: _IMAGE_TOOLS def + search_images branch in execute_tool
- intent.py: search_images routing rule (explicit visual language only)
- app.py: register images_bp
- docker-compose.yml: image_cache named volume mounted at /data/images
- ToolCallCard.vue: "image_search" label
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Raise similarity threshold 0.30 → 0.45: only genuinely relevant notes
shown; loosely-related notes no longer pad the sidebar
- Increase max suggested notes 3 → 8 (zero added compute — threshold is
the real gate; the embedding call is fixed regardless of limit)
- semantic_search_notes now returns list[tuple[float, Note]] instead of
list[Note] so scores propagate through context_meta to the frontend
- Keyword fallback notes carry score=null (no cosine similarity available)
- ChatView sidebar shows % badge on each suggested note:
green ≥75%, amber 60–74%, muted <60%
Hovering reveals the raw score in a tooltip
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Single POST that classifies natural-language text and creates the
appropriate item (note, task, event, or todo) in one synchronous
request — no SSE, no conversation context needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The same empty-string model guard added to chat.py was missing from the
notes assist route. If default_model was stored as "" in the DB, the
assist route would pass "" to Ollama which responds with 400, surfaced
to the user as a "400" error in the assist panel.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The model occasionally passes title as a list (e.g. when asked to create
multiple notes at once), causing asyncpg DataError on the INSERT. Return a
clear error result so the model sees the problem and retries with individual
calls instead of crashing with an unhandled exception.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>