Wires FastMCP's streamable-HTTP ASGI sub-app into the Quart app via
asgi_app replacement. Requests under /mcp are stripped, auth-checked
against api_keys, and forwarded to FastMCP with fable_user_id set on
the ASGI scope. All other paths pass through to the original Quart
dispatch unchanged.
Tests cover the three auth paths (no header, invalid token, valid
token) plus a regression check that non-/mcp paths bypass the MCP
dispatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recurring app/db crashes with no clear cause in existing logs.
Adds three crash-class indicators with minimal overhead (~1 log
line/min, 0.1ms work per heartbeat).
services/diagnostics.py:
1. **Heartbeat** every 60s logs a snapshot:
- RSS memory (from /proc/self/status — no deps).
- asyncio task count.
- DB pool: size / checked_in / checked_out / overflow.
- Curator busy state (from is_curator_running()).
- Uptime.
A sudden silence in heartbeats bounds the crash time to within
60s. The last snapshot before silence usually rules in or out:
memory growth -> OOM, pool exhaustion -> connection leak, hung
curator -> stuck async task.
2. **Signal handler** for SIGTERM/SIGINT logs the signal name +
final snapshot before letting Hypercorn handle the actual
shutdown. Distinguishes 'orderly shutdown via signal X' from
'silent log gap then container exit code 137' (SIGKILL / OOM-kill
are uncatchable; their absence in our log IS the diagnostic).
3. **Asyncio exception hook** logs full tracebacks for unhandled
task exceptions with the task/coro name. Default behaviour
swallows these silently — exactly the pattern that locked us
out of chat at 409 for an hour back on 2026-05-22 before we
added the guard around run_generation.
app.py wires start_diagnostics() into before_serving and
stop_diagnostics() into after_serving. stop_diagnostics emits one
final snapshot so the silence that follows is intentional, not a
crash.
How to use the new logs to diagnose:
- App restarts with 'received SIGTERM' in the last lines:
Orderly shutdown (docker stop / swarm restart / manual). Look
upstream for who issued it.
- App restarts with no shutdown line, last heartbeat 30+s before:
Likely SIGKILL — OOM-kill or container resource limit. Check
'docker ps -a' for exit code 137, or 'dmesg | grep -i kill' on host.
- App restarts with no shutdown line, heartbeat showed climbing
RSS: Memory leak. Snapshot the last heartbeat's MB value vs
earlier — if it doubled over hours, OOM is the cause.
- App restarts, db_pool checked_out kept growing: Connection leak.
Look for code paths that open async_session() but never exit
the 'async with' block.
- App seemed alive but stopped responding to requests, heartbeats
continued: Curator hung holding _CURATOR_RUN_LOCK. Check
curator_busy=true across multiple heartbeats — if stuck >5min,
the Ollama call hung. Restart Ollama or the Scribe stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two architectural bugs in the conversation+curator rollout that
explain the no-response chat in dev:
1. Journal system prompt still instructed tool calls.
JOURNAL_CALIBRATION instructed the model to CALL record_moment,
search_notes, save_person, etc. — but the chat surface ships tools=[]
per the new architecture. The model received contradictory orders
('use these tools' + 'you have no tools') and produced either empty
output or tool-call-shaped text that gets stripped to empty content,
surfacing as status=error or stuck status=generating messages.
Replaced with a chat-only calibration: ~25 lines focused on tone,
length, anti-coaching, and the load-bearing rule 'never claim to
have done anything for the user' (the curator handles capture
silently and separately). JOURNAL_PERSONA also rewritten to drop
the 'use tools to act on their behalf' line.
2. Pre-warm warmed Config.OLLAMA_MODEL ahead of user's real choice.
_pull_model(Config.OLLAMA_MODEL, warm=True) at boot pushed the
system default (qwen3:latest) into VRAM before _warm_user_models()
ran for each user's actual default_model setting. On a single-GPU
setup the second warm could swap the first out — so the user's
chat model wasn't necessarily resident when their first message
landed. Now we just pull the supporting models without warming
them; only user-configured chat models get warm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.
Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
filtered to conversation_type='journal'. The scheduler's candidate
query is "journal AND (NULL OR stale)" so an index narrowed to
journal rows is the right shape — index size stays small even on
instances with many non-journal conversations.
models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.
services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
newest user message is newer than last_curator_run_at (or NULL).
Capped at 20 per sweep so a backlog after downtime doesn't stall
the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
so overlapping ticks can't double-fire on the same conversation.
Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.
routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
on success. Errors don't stamp so the scheduler retries.
What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
the curator's summary lives in the run result but doesn't reach the
chat model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BackgroundScheduler with a single CronTrigger fires scan_all_users_for
_auto_pins via asyncio.run_coroutine_threadsafe (mirrors the journal-
scheduler pattern). Wired into app startup/shutdown alongside the other
schedulers.
Removes the entire RSS feature surface — feeds, items, embeddings, reactions,
discussion-note flow, briefing news context, settings, env-vars, and DB
tables. Keeps the URL-generic article-reader (the read_article LLM tool)
under a clean module so the LLM can still fetch arbitrary article content
from URLs the user provides.
Backend:
- New services/article_fetcher.py — single source of trafilatura URL→text
- New services/tools/article.py — read_article tool (was nested under tools/rss)
- Delete services/rss.py, rss_classifier.py, rss_filtering.py, article_context.py
- Delete services/tools/rss.py
- Delete models/rss_feed.py (RssFeed, RssItem), models/rss_item_embedding.py
- services/embeddings.py: drop upsert/semantic_search/backfill RSS helpers
- services/llm.py: remove _build_briefing_article_context, briefing-conv branch,
ARTICLE_DISCUSS_SEED skip-RAG branch; drop get_rss_items / add_rss_feed from
the actions list
- services/generation_task.py: drop _maybe_save_article_discussion_note + caller
- routes/chat.py: drop /api/chat/from-article/<id> endpoint
- routes/journal.py: re-import via web.py refactor (article_fetcher path)
- services/tools/__init__.py: register `article`, drop `rss`
- services/tools/_registry.py: drop the requires=='rss' check
- app.py: drop backfill_rss_item_embeddings + backfill_rss_article_content tasks
- config.py: prose-only edit (no env var change — RSS env vars were never first-class)
Frontend:
- stores/settings.ts: drop rssEnabled
- SettingsView.vue: drop the RSS-classification mention
- api/client.ts: drop openArticleInChat (the from-article endpoint is gone)
Tests:
- Delete tests/test_rss_service.py, test_news_api.py, test_article_reading.py
Migration:
- 0042_drop_rss: DROP TABLE rss_item_embeddings, rss_item_reactions, rss_items,
rss_feeds; DELETE settings rows for rss_enabled / briefing_*_topics
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add fonts.googleapis.com to style-src and fonts.gstatic.com to
font-src in Content-Security-Policy header.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 'wasm-unsafe-eval' to script-src and blob: to worker-src in
Content-Security-Policy header. Required by onnxruntime-web to compile
the Silero VAD ONNX model. Also surface VAD init errors as a toast
instead of silent console log.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Three related fixes uncovered while benchmarking qwen3:14b against 8b:
- pick_num_ctx was only counting message content, missing the ~15K
tokens of tool schemas. num_ctx=8192 was being selected while actual
prompt_tokens hit 14K+, causing silent prompt truncation on every
tool-using request. Now includes json.dumps(tools) in the estimate.
KV cache priming in app.py and routes/settings.py also fetches tools
so the primed num_ctx matches what real chat requests will use.
- _should_think's heuristic classifier was overriding explicit
think=true requests from the frontend toggle and MCP, gating on
message length and regex patterns. Now a pass-through — the caller
is the source of truth. quick_capture hardcodes think=False since
it's a fast classification path that was relying on the old gating.
- delete_note description only mentioned "note or task", so the model
refused to call it for entries created by save_person / save_place /
create_list. Description now explicitly lists all five note_types it
handles.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace the hardcoded "2h" keep_alive everywhere with a helper that
returns OLLAMA_KEEP_ALIVE_MAIN (default 30m) for the interactive model
and OLLAMA_KEEP_ALIVE_BACKGROUND (default 10m) for the background
model. Lets the main model release VRAM during long idle periods
while keeping it warm enough for bursty chat use, and stops the
sporadic background model from camping VRAM it rarely needs.
Seven call sites updated to route through llm.keep_alive_for(model):
the streaming helpers, generate_completion, the two startup warmers,
the settings KV-cache primer, and the chat warmer endpoint.
Override via env vars: OLLAMA_KEEP_ALIVE_MAIN, OLLAMA_KEEP_ALIVE_BACKGROUND.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Startup now pulls Config.OLLAMA_MODEL (system default chat model) — previously only
embedding and background models were pulled; the primary chat model was skipped
- _warm_user_models expanded to also pull user-configured default_model and
background_model overrides that are missing from Ollama, rather than logging and
skipping them; pulls run before warm/KV-cache priming
- Add background_model to _MODEL_KEYS in settings route so clearing the dropdown
deletes the DB row instead of saving "", which caused Ollama failures in tag
suggestions, title generation, project summaries, and RSS classification
- Add http/https scheme validation to PUT /api/admin/base-url matching the CalDAV
route pattern; a bad value no longer silently breaks invite/password-reset links
- Update admin voice config description: "Reload models" button exists to avoid
a server restart, so the old "restart required" text was misleading
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- RRULE expansion: list_events now expands recurring events into
individual occurrences within the query window using python-dateutil
- CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route;
imports remote events into the internal store by caldav_uid
- Past event search: search_events accepts include_past=true to search
historical events; exposed in the LLM tool definition
- Internal reminders: migration 0037 adds reminder_minutes +
reminder_sent_at columns; event_scheduler.py checks every 5 min and
fires push notifications; CalDAV sync job runs hourly
- reminder_minutes now stored and returned in create/update routes + tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds pick_num_ctx() which selects the smallest context window tier
(8192, 16384, 32768) that fits the current messages with 25% headroom,
capped at OLLAMA_NUM_CTX. Threads num_ctx through generation_task.py so
every chat request uses the computed tier rather than a fixed 16384.
Fixes a critical cache miss bug: KV cache priming in app.py and
settings.py was sending requests without num_ctx, so Ollama sized the
cache at its model default (different from the 16384 real requests used),
forcing a full model reload on the first real user message. Both priming
sites now call pick_num_ctx() and pass the matching value.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
After loading each user's chat model into VRAM, send a minimal chat
request with the real system prompt (num_predict=1) to populate the
KV cache. The first real user message then only needs to process its
own tokens rather than the full ~5,600-token system prompt, reducing
cold-start TTFT from ~25s to <1s.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Background tasks (title generation, tag suggestions, project summaries,
RSS classification) were using qwen3:8b and wiping its KV cache after
every response, preventing prefix cache hits on subsequent user messages.
Adds OLLAMA_BACKGROUND_MODEL (default: qwen2.5:0.5b) config var and
routes all background LLM calls to it, keeping qwen3:8b's KV cache
warm between user messages for consistent sub-second TTFT.
Also adds infinite scroll to KnowledgeView (replaces load-more button)
and bakes spaCy en_core_web_sm into the Docker image to eliminate the
pip install on every startup.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add trafilatura + html2text to dependencies
- Replace custom HTMLStripper with html2text for RSS feed content
- Fetch full article text via httpx + trafilatura after each new item is stored;
falls back to RSS-provided content if fetch/extraction fails
- Raise CONTENT_MAX_CHARS from 2000 to 50000 (TEXT column, no migration needed)
- Re-embed items with full article content once enrichment completes
- Startup backfill enriches existing items with short content (<1000 chars)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
to the LLM and uses 8192 num_ctx to accommodate the larger prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Prevents models from sitting in VRAM indefinitely. Applies to both
streaming chat calls and the non-streaming generate_completion path,
as well as the startup warm-up request.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replace VOICE_ENABLED env var gate with DB-backed admin setting.
- services/voice_config.py: reads voice_enabled + voice_stt_model from
admin user's settings row (falls back to env var defaults)
- routes/admin.py: GET/PUT /api/admin/voice for admin configuration
- routes/voice.py, services/stt.py, services/tts.py: read enabled/model
from DB via voice_config instead of Config directly
- app.py: always schedule model loaders at startup; they self-gate on
the DB setting so no conditional needed at the call site
- SettingsView.vue: Voice section in Admin → Config tab (enable toggle +
STT model dropdown); user Voice tab now points to admin panel when disabled
No env var required to test — enable via Settings → Admin → Config → Voice.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
start_briefing_scheduler was called from before_serving (event loop thread)
and used run_coroutine_threadsafe(...).result() which blocks the calling
thread waiting for the coroutine to complete — but since the calling thread
IS the event loop, the coroutine could never run, causing a 10s timeout and
zero jobs scheduled.
Fix: make start_briefing_scheduler async and await _get_briefing_enabled_users()
directly. Also use asyncio.create_task for the catch-up rather than
run_coroutine_threadsafe. The background thread jobs (_run_user_slot_sync)
continue to use run_coroutine_threadsafe correctly since they run on the
APScheduler thread, not the event loop thread.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Project view:
- Add inline status advance buttons on kanban task cards (todo→in_progress,
in_progress→done); buttons reveal on hover, stop link navigation
Task viewer:
- Back button navigates to task's project instead of /tasks when project_id set
- Esc key navigates to project (or /tasks); blurs focused element first
Quick capture:
- Use user's configured model instead of hardcoded Config.OLLAMA_MODEL
- Remove create_project from classifier prompt (tool not offered, caused
task-shaped inputs to silently fall through to note fallback)
Briefing scheduler:
- Fix get_event_loop() → get_running_loop() so background thread uses the
correct hypercorn event loop (jobs were scheduling but never executing)
- Suppress bare greeting when both LLM synthesis lanes return empty
RSS feed UI (SettingsView):
- Show last-fetched age, category badge, and feed URL per row
- Category input field when adding a feed
- Refresh all button: fetches latest items, reloads list, toasts with count
- Enter key submits add-feed form; better empty-state hint with example feeds
Weather tool:
- Accept any city/region name in addition to 'home'/'work'/'all'
- Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries
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>
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>
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>
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>
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>
ensure_model only downloads a model if missing; it does not load it
into VRAM. The frontend warm call only covers the chat model (and only
after a user opens the dashboard). This left qwen2.5:1.5b (intent) cold,
causing simultaneous cold-load 500s when the first chat arrived.
Now both Config.OLLAMA_MODEL and Config.OLLAMA_INTENT_MODEL are warmed
at startup (after ensuring they're installed) via a fire-and-forget
/api/generate call with keep_alive=30m. The embedding model is still
pulled but not warmed (it's loaded on demand during backfill).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend security & correctness:
- Add rate_limit.py: sliding-window rate limiter (asyncio) applied to login,
register, forgot/reset password endpoints (10/60s or 5/300s per IP)
- app.py: add security headers in after_request (X-Frame-Options, CSP,
X-Content-Type-Options, Referrer-Policy) using setdefault to preserve SSE headers
- auth.py: refactor duplicate login_required/admin_required into shared _check_auth()
- config.py: add TRUST_PROXY_HEADERS for proxy-aware client IP resolution
- routes/auth.py: rate limiting, _client_ip() helper, cleaned-up reset_password route
- routes/chat.py, notes.py, tasks.py: int() DoS fix on last_event_id; limit capped
at 500; date.fromisoformat() wrapped in try/except → 400 on invalid dates
- services/auth.py: fix Setting.user_id update bug (filter on NULL not user.id);
reset_password_with_token returns int|None (user_id) instead of bool
- services/backup.py: add _security_notice to full backup JSON export
- services/assist.py: system prompt explicitly preserves markdown list structure
and nested indented sub-items
Infrastructure:
- docker-compose.yml: add healthcheck on app service (/api/health, 10s interval)
- .dockerignore: prevent secrets/node_modules/__pycache__/.env.* leaking into build
Frontend bug fixes:
- TaskCard.vue, TaskViewerView.vue: fix isOverdue() timezone bug (ISO string compare)
- useAssist.ts: accept() now resets state to idle when document changed since proposal
- stores/chat.ts: fix memory leak in _pollUntilLoaded() (try/catch around fetchStatus)
- TiptapEditor.vue: selection offset uses closest-match strategy (not first-match)
- utils/markdown.ts: explicit DOMPurify config with FORCE_BODY; remove as const
(DOMPurify expects mutable string[])
New features:
- Auto-save (5-minute interval) in NoteEditorView and TaskEditorView — only when
editing an existing dirty record; silent on error, shows "Auto-saved" toast
- sectionParser.ts: top-level bullet/numbered list items are now individual sections
in the AI Assist panel (previously treated as one undifferentiated block)
- editor-shared.css: extracted ~500 lines of CSS duplicated between both editors;
includes .inline-assist-btn at global scope (required for teleported elements)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- New NoteEmbedding model + migration 0014 stores float embeddings (JSONB)
- services/embeddings.py: get_embedding, upsert_note_embedding,
semantic_search_notes (cosine similarity), backfill_note_embeddings
- build_context() now tries semantic search first, falls back to keyword search;
accepts cached_note_ids to reuse last-turn notes and stabilise the system
prompt prefix for Ollama's KV cache
- generation_buffer.py: per-conversation note ID cache (get/set/clear)
- generation_task.py: passes cached IDs into build_context, updates cache
after each turn, and invalidates it after create_note/update_note/create_task
- app.py: pulls nomic-embed-text at startup and launches a background backfill
to embed all existing notes (30 s delay so Ollama has time to load the model)
- routes/notes.py + services/tools.py: fire-and-forget embedding update on
every note create or update via the API or LLM tool calls
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
If OLLAMA_INTENT_MODEL is configured and differs from OLLAMA_MODEL,
both are pulled concurrently as fire-and-forget tasks at startup.
Deduplicates via a set so pulling the same model twice is never attempted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>