Add _build_briefing_article_context() helper to llm.py that reads
rss_item_ids from briefing message metadata and injects article content
into the system prompt. Pass conv_id through build_context() and
generation_task.py.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Frontend sends user_timezone (IANA, from Intl.DateTimeFormat) with
every message POST; threaded through route → generation_task → build_context
- System prompt now tells the LLM the user's timezone so it creates
events with the correct UTC offset (e.g. 15:00+01:00 not 15:00Z)
- Calendar tool guidance updated to require UTC offset in all event
datetimes
- EventSlideOver: dateFromIso/timeFromIso now use JS Date to convert
stored UTC times to local time for display; toIso includes local
timezone offset when saving so the correct UTC time is stored
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>
- 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>
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>
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>
- 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>
When the intent model doesn't classify a research request (low confidence,
long message, etc.), the main model (qwen3) would correctly identify
research_topic itself and call it via the streaming tool loop. But
execute_tool("research_topic") only returns a dummy research_pending
placeholder, causing the model to see the result and retry — looping
up to MAX_TOOL_ROUNDS times.
Fix: filter research_topic out of stream_tools (the tool list given to
the main model via stream_chat_with_tools). research_topic is an
intent-only routing tool; the main model should never call it directly.
The full tools list (including research_topic) is still passed to
classify_intent so intent routing continues to work.
The _INTENT_ONLY_TOOLS frozenset makes this pattern explicit and
extensible for future intent-only tools.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
research.py:
- Parallelize all 5 SearXNG queries concurrently (200ms stagger via asyncio.gather)
- Parallelize all URL fetches in parallel (asyncio.gather) — up to 15 URLs at once
instead of sequential fetches; biggest performance win (was O(n) × 15s, now ~15s flat)
- _synthesize_note accepts buf: when provided uses stream_chat (num_ctx=16384,
num_predict=8192) to emit tokens into the chat buffer in real time so users see
the note being written; falls back to generate_completion when buf=None
- Added \n\n---\n\n separator before "Research complete!" to cleanly mark boundary
after streamed synthesis content
intent.py:
- classify_intent passes num_ctx=4096 to generate_completion — reduces VRAM pressure
and prefill time for the intent model call on every single request
generation_task.py:
- _INTENT_TRIGGER_WORDS frozenset (~50 action/object/date words) + _should_skip_intent()
skips intent classification for short messages (≤10 words) with no trigger words;
saves 400-800ms model call for conversational replies ("thanks", "okay", etc.)
- Added \n\n---\n\n separator before research "done" text in research_topic branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ollama streams message.thinking tokens alongside message.content when
think=True — previously silently dropped. Now forwarded end-to-end.
Backend:
- llm.py: ChatChunk type gains "thinking" variant; stream_chat_with_tools
yields ChatChunk(type="thinking") for msg.thinking chunks before content
- generation_task.py: thinking chunks emit "thinking_chunk" SSE events
(not added to content_so_far — not persisted to DB)
Frontend:
- types/chat.ts: Message.thinking?: string (session-only, not from DB)
- stores/chat.ts: streamingThinking ref; thinking_chunk handler accumulates
chunks; on done, thinking carried into committed Message object then cleared
- ChatMessage.vue: collapsible <details class="thinking-block"> shown for
messages that have .thinking content (collapsed by default)
- ChatView.vue + ChatPanel.vue: live thinking block in streaming bubble —
open while only thinking is flowing, auto-collapses when content arrives;
typing indicator hidden while thinking is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Instead of relying solely on retry-on-500, poll /api/ps before starting
any LLM stream so the main model has time to fully load into VRAM.
- llm.py: add wait_for_model_loaded(model, timeout=90s) — polls /api/ps
every 2s, returns True when model appears in loaded list
- generation_task.py: launch model_load_task in parallel with build_context
and classify_intent (both use fast/small-model ops that don't need the
main model); after context is built, await the load task — shows
"Loading model..." status only if the user actually has to wait;
logs a warning and proceeds if 90s timeout elapses
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds _stream_with_retry() async generator (wraps stream_chat_with_tools
with up to 2 retries on Ollama 500, 3s/6s delay). Previously only the
optimistic round 0 _fill_queue had retry logic. Two paths were still
bare: the declined-write-tool fresh stream, and the round 1+ stream.
Round 1 500s occur when tag suggestions (fire-and-forget inside
execute_tool) race the follow-up stream to the same model. The retry
waits for tag suggestions to complete before succeeding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
With optimistic streaming, intent (qwen2.5:1.5b) and the main stream
(qwen3:latest) start concurrently. When both models are cold-loading,
Ollama returns 500 for both simultaneously. The intent 500 was already
handled silently in classify_intent; the stream 500 now retries up to
2 times (3s then 6s delay) before propagating as an error. 500s only
occur on the first cold-load pair — subsequent requests hit warm models.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Start the main LLM stream immediately after build_context finishes instead
of waiting for intent classification to complete. Race the two concurrently:
- Intent wins before first token → cancel stream, execute tool (tool path
unchanged: confirmation, acknowledgment, multi-round loop all preserved)
- First token wins → discard intent, user sees output immediately
For pure chat messages (no tool needed, the common case) this eliminates
the full intent classification RTT from TTFT. For tool calls, intent
typically wins the race since it finishes before the main model produces
its first token, so tool behaviour is unchanged in practice.
Also extracts _drain_queue() as a module-level async generator helper.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Context sidebar + note title:
- ChatView: replace ephemeral context pills with a persistent right-panel sidebar;
auto-found notes accumulate across turns; attached note shows with pin icon;
× button excludes a note from future auto-search; hidden on mobile
- routes/chat.py: batch-fetch note titles via get_notes_by_ids() and inject
context_note_title into each message dict at conversation load time
- notes.py: add get_notes_by_ids() batch fetch helper
- types/chat.ts: add context_note_title field to Message interface
- stores/chat.ts: sendMessage accepts optional 5th arg contextNoteTitle,
included in optimistic user message
- ChatMessage.vue: context badge shows note title instead of 'Note #N'
Expanded LLM tool suite (all with intent router rules + ToolCallCard display):
- delete_note / delete_task: permanent delete with user confirmation (write tool),
type-safe (refuse to delete wrong type), clears note context cache on success
- get_note: fetch full note body by query (search_notes returns only 200-char preview)
- list_notes: browse notes by recency/keyword/tags with limit; notes only
- update_note: add tags + tag_mode (replace/add/remove) parameters
- search_notes: add optional type filter ("note" | "task")
- search_todos (CalDAV): keyword-filter todos, companion to list_todos
- caldav.py: add search_todos() built on top of list_todos()
- generation_task.py: register new tools in _WRITE_TOOLS, _TOOL_LABELS, _TOOL_ACTIONS
- llm.py: update available actions list and guidance in system prompt
- intent.py: routing rules for all new tools
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>
When a conversation exceeds 20 messages (10 exchanges), the oldest
messages are summarized into a compact 3-5 sentence paragraph using the
intent model, and only the most recent 6 messages are passed verbatim.
The summary is injected into the system prompt so the model retains
context without the full token cost. For short conversations the check
is O(1) and returns immediately. The status indicator shows
"Summarizing conversation history..." when the LLM call is needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
stream_chat_with_tools now accepts a think parameter. run_generation
forwards it to Ollama. The message POST route reads think from the
request body. ChatView passes think=true so qwen3 uses chain-of-thought
reasoning for full conversations; the dashboard widget and ChatPanel
omit it, staying fast. Dashboard button updated to "Think it through
in Chat →" to signal the deeper capability.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Before executing any write tool (create/update/delete), the backend now
pauses with an asyncio.Future and emits a tool_pending SSE event. The
frontend displays a ToolConfirmCard with Accept and Decline buttons.
Clicking Accept resolves the Future and proceeds; Decline records a
declined tool_call chip and falls through to regular streaming. Typing
single-word yes/no responses (e.g. "yes", "cancel") also works as
confirmation. 120s timeout auto-declines if no response.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the intent router detects a tool call, the acknowledgment sentence
and the tool now execute concurrently via asyncio.gather. The acknowledgment
uses the small intent model (already in VRAM) with max_tokens=40, so it
completes in ~200-400ms — the user sees text almost immediately instead of
staring at a status label for the full main-model TTFT (~22s).
The acknowledgment text is:
- Streamed to the client as a chunk event (clears the status spinner)
- Included in the assistant message for round 1 so the main LLM continues
coherently from where the acknowledgment left off
- Recorded in TTFT timing (acknowledgment counts as first token)
Varied phrasing is enforced in the system prompt so responses feel natural
rather than formulaic.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Docker Compose:
- Enable Ollama GPU passthrough (nvidia, count: all) in both dev and prod files
- Add OLLAMA_FLASH_ATTENTION=1 (faster attention on GPU in both files)
- Add OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m to prod (was already in dev)
- Remove 8G memory limit from prod Ollama service (CPU-bound constraint, no longer valid)
llm.py:
- Increase num_ctx 16384 → 32768 in stream_chat and stream_chat_with_tools (GPU VRAM allows it)
- Increase num_predict cap 4096 → 8192 for tool-augmented responses
generation_task.py:
- Parallelize build_context, get_tools_for_user, and get_setting all from the start
- As soon as tools list is ready (fast DB call), launch classify_intent as an asyncio.Task
- Await build_context and classify_intent together via asyncio.gather
- Intent result is pre-computed before the generation loop; loop just reads pre_intent on round 0
- intent_ms timing now reflects wall-clock time from intent start to completion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- build_context() moved from route handler into run_generation() background task.
The 202 response now returns immediately; client connects to SSE before
note search / URL fetch begins, so 'Building context...' status is visible.
- _generate_title() runs in a fire-and-forget asyncio.create_task() after the
'done' SSE event fires. Users see their response complete 2–5s sooner on new
conversations; title appears later in the sidebar without blocking the stream.
- generate_completion() now sets think:False and accepts a max_tokens limit.
Intent classifier passes max_tokens=200 (JSON only), title generator passes
max_tokens=30 (short title), eliminating qwen3 thinking-mode overhead on these
auxiliary calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug fix:
- ChatView.vue onMounted now skips fetchConversation when the conversation
is already loaded in the store (same guard that the convId watcher uses).
This prevents duplicate assistant messages when navigating from the
dashboard inline chat to /chat/:id after streaming completes.
Generation timing:
- logging.py: add log_generation() — persists per-generation timing
breakdown to app_logs (category=usage, action=generation) including
model, total_ms, intent_ms, ttft_ms, generation_ms, and per-tool timings.
Queryable via existing admin log viewer.
- generation_task.py: collect wall-clock timestamps at every pipeline stage:
intent classification, per-tool execution (both intent-routed and native),
time-to-first-token (measured from generation start to first content chunk),
LLM streaming round duration. Logs via log_generation() and includes timing
in the SSE 'done' event payload.
- types/chat.ts: add GenerationTiming interface; add optional timing field
to Message.
- chat.ts: capture timing from done event and attach to assistant message.
- ChatMessage.vue: show timing footer on assistant messages with breakdown:
"⏱ 4.2s total · first token 0.8s · analyzed 0.3s · created event 0.4s
· generated 3.5s". Visible this session; persisted to app_logs for
cross-session benchmarking.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Streaming status transparency:
- generation_task.py emits 'status' SSE events at each pipeline stage:
"Analyzing your request..." before intent classification, tool label
before each tool execution, "Generating/Composing response..." before
each LLM streaming round
- chat.ts adds streamingStatus ref; cleared on first chunk or done/error;
includes fast 5s poll loop after warmModel() until model shows as loaded
- ChatView.vue shows pulsing dot + italic status label above content area;
falls back to blinking cursor once content arrives
- HomeView.vue shows status label in dashboard panel instead of '...'
Model load state indicator:
- /api/chat/status now queries /api/tags and /api/ps in parallel to
distinguish installed-but-cold vs loaded-in-VRAM model states
- New model status values: 'not_found' | 'cold' | 'loaded' (was 'ready')
- chatReady true for both 'cold' and 'loaded' (cold models still work)
- AppHeader shows 5 states: gray pulse (checking), red (Ollama down),
orange (not installed), yellow pulse (cold), green (loaded)
- Inline short label ("Cold", "Ready", "Offline", etc.) visible without
hovering; detailed tooltip on hover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mistral didn't reliably use Ollama's structured tool calling API — it wrote
tool calls as JSON text instead of invoking them. This adds an intent routing
layer that classifies user intent via a fast non-streaming LLM call before
streaming, executing detected tools directly and bypassing native tool calling.
- Change default OLLAMA_MODEL from mistral to qwen3
- Add intent.py: classify_intent() with JSON parsing and fallback regex
- Integrate intent routing into generation_task.py round 0
- Add all-day event support (iCalendar DATE values) to CalDAV service
- Add recurring event support (RRULE) to CalDAV service and tool definition
- Improve create_event tool description for descriptive titles
- Enhance system prompt with structured tool usage guidance
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CalDAV integration: per-user calendar config, create/list/search events
via caldav library, LLM tools for calendar operations from chat
- LLM-suggested tags: new tag_suggestions service prompts LLM with existing
tags and note content to suggest 3-5 relevant tags; exposed via API
endpoints (suggest-tags, append-tag); integrated into editor views
(suggest button + clickable pills) and chat tool calls (pills in
ToolCallCard with one-click apply)
- Settings/model UI refinements, generation task improvements
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ollama tool/function calling integration allows the LLM to create tasks,
create notes, and search existing notes on behalf of the user during chat.
Multi-round tool loop (max 5 rounds) lets the model execute tools then
produce a natural language response. Tool results are persisted in a new
JSONB column on messages and rendered as compact cards with linked titles.
- Migration 0013: add tool_calls JSONB column to messages
- New services/tools.py: tool definitions + execute_tool dispatcher
- llm.py: ChatChunk dataclass, stream_chat_with_tools(), date in system prompt
- generation_task.py: multi-round tool call loop with SSE tool_call events
- Frontend: ToolCallRecord type, streamingToolCalls in store, ToolCallCard
component, rendering in ChatMessage and ChatView streaming bubble
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The assist flow previously tied the entire LLM generation to a single
POST request with no keepalives, causing NS_ERROR_NET_PARTIAL_TRANSFER
in Firefox when Hypercorn closed the connection during gaps between
chunks. This refactor decouples generation into a background task with
a buffer and a separate SSE stream — the same pattern used by chat.
- generation_buffer.py: Widen _buffers to support string keys, add
create/get/remove_assist_buffer() using "assist:{user_id}" keys,
fix cleanup log format for string keys
- generation_task.py: Add run_assist_generation() — lightweight
background task with no DB persistence or title generation
- notes.py: Replace single POST SSE route with POST /api/notes/assist
(returns 202) + GET /api/notes/assist/stream (SSE with 15s keepalives
and Last-Event-ID reconnection); 409 if already running
- useAssist.ts: Switch from apiStreamPost to apiPost + apiSSEStream
two-step pattern with named event mapping and stream handle cleanup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>