Commit Graph

54 Commits

Author SHA1 Message Date
bvandeusen efe0a15b8c Add image search with local cache (Phase 23)
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>
2026-03-01 12:55:10 -05:00
bvandeusen 90afd3f131 Improve suggested notes: limit 8, threshold 0.45, show relevance scores
- 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>
2026-03-01 12:10:39 -05:00
bvandeusen 5c043b76b6 Validate create_note/create_task title is a string before DB insert
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>
2026-02-28 20:01:43 -05:00
bvandeusen 4998d04739 Fix search_web over-triggering when user references existing notes
Add a Python fast-path regex (_PRIOR_WORK_REFS) in classify_intent that
detects phrases like "research you did", "note you made", "using your
research", "based on the research" etc. and returns no-tool immediately —
saving the 19s intent LLM call and correctly letting the main model answer
using search_notes/context rather than firing off a web search.

Also tighten the intent prompt rules for search_web: explicitly prohibit
using it for creative/brainstorming requests or when the user references
existing notes, and add a rule that creative/ideation questions ("think of",
"come up with", "brainstorm") always route to null (chat).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 18:12:58 -05:00
bvandeusen fb3a6d2445 Fix 'default' model selection breaking readiness indicator
When a user selected the 'Default' option in Settings, the dropdown
sent an empty string "" to the backend. The route saved it as a DB row,
which caused get_setting() to return "" instead of falling back to
Config defaults. The chat status endpoint then tried to match "" against
installed model names — always failing — resulting in model: "not_found"
and a permanently failing readiness indicator.

services/settings.py:
- Add delete_setting() helper: removes a setting row so get_setting()
  correctly falls back to its hardcoded default argument

routes/settings.py:
- Import delete_setting
- When default_model or intent_model are saved as empty string, delete
  the DB row instead of storing "" — cleanly restores Config fallback

routes/chat.py:
- chat_status_route: add explicit `or Config.OLLAMA_MODEL` guard for
  any existing "" rows written before this fix (migration safety net)
- send_message and summarize routes: same guard on model resolution
  so empty settings never cause silent generation failures

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 16:06:02 -05:00
bvandeusen 926e4bb570 Upgrade intent model to qwen2.5:7b, simplify intent prompt rules
config.py:
- Default OLLAMA_INTENT_MODEL: qwen2.5:1.5b → qwen2.5:7b
- Startup will auto-pull and warm the new model on next container restart

intent.py:
- Replaced phrase-matching examples in search_web and research_topic rules
  with semantic descriptions. The 7B model doesn't need example phrases to
  understand intent — it can reason from the tool's purpose. Removes implied
  usage patterns that caused misclassifications on conversational phrasing
  (e.g. "I've been thinking about buying shirts, can you research this?").
- research_topic rule now explicitly covers any subject regardless of phrasing,
  including shopping decisions, comparisons, how-things-work questions, etc.
- search_web rule clarified as "short summary, no note" vs research_topic's
  "comprehensive written reference"

The 1.5B model required prescriptive phrase examples to route correctly; the
7B model has sufficient language understanding to classify from semantic intent.
Expected improvement: ~1-2s intent calls (vs 0.4-9s for the 1.5B model which
sometimes timed out or misclassified longer/conversational messages).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 15:51:27 -05:00
bvandeusen d1c7ce88df Fix intent classifier missing Research button requests
Two fixes for the intent model failing to route 'Research: X' messages
to research_topic:

1. Fast-path in classify_intent: if the message matches ^Research:\s+.+
   (the exact format the UI Research button always sends), skip the LLM
   call entirely and return research_topic with high confidence. This is
   100% reliable and saves an unnecessary model call for this pattern.

2. Expanded research_topic rule examples in the system prompt to include
   "Research: X" prefix format, shopping-style queries ("research where
   to buy X"), and clarification that the topic is everything after the
   keyword — improves LLM routing for natural-language research requests
   that don't match the previous narrow examples.

Root cause: qwen2.5:1.5b misclassified "Research: where to buy three-
quarter sleeve tee shirts" as general chat (shopping query phrasing
combined with the colon confused the small model).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 13:07:39 -05:00
bvandeusen a95d17fc04 Fix research_topic loop when intent misses and main model calls tool directly
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>
2026-02-28 13:05:36 -05:00
bvandeusen df4c52412d Phase 22b: Parallel research fetching, streaming synthesis, intent optimizations
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>
2026-02-27 18:24:15 -05:00
bvandeusen 590682a5d2 Phase 22: SearXNG web research pipeline + settings layout overhaul
Research pipeline (research_topic tool):
- New service: services/research.py — sub-query generation, SearXNG
  search, URL fetch, deduplication, and LLM synthesis into a note
- 5 sub-queries × 3 pages = up to 15 sources, capped at 12 for synthesis
- Synthesis uses num_ctx=16384 + max_tokens=8192 for long-form output
- Prompt demands 2500+ words, 6+ topic-appropriate sections, detailed prose
- 429 retry with backoff; 1s inter-query sleep; raw_decode JSON parsing

search_web tool (new):
- Lightweight single-query SearXNG search, results returned inline in chat
- LLM answers conversationally in round 1; no note created
- web_search result type with external links in ToolCallCard

Infrastructure:
- llm.py: generate_completion accepts num_ctx override
- config.py: SEARXNG_URL + Config.searxng_enabled()
- docker-compose: OLLAMA_NUM_PARALLEL=2, commented SEARXNG_URL example
- intent.py: search_web and research_topic routing rules

Settings UI:
- 2-column grid layout (small sections pair up, complex span full width)
- Search Test section: live SearXNG query with result preview
- GET /api/settings/search?q= proxy endpoint
- Research button (magnifier) in ChatView input toolbar → popover modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-27 15:21:38 -05:00
bvandeusen 432e0bd2a0 Show Qwen3 thinking output in chat as collapsible Reasoning block
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>
2026-02-26 23:16:59 -05:00
bvandeusen 5e83c8a56d Add explicit warm-wait before generation starts
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>
2026-02-26 22:49:06 -05:00
bvandeusen e119331645 Phase 21: Intent-first pipeline, visible ack, KV-stable system prompt
Pipeline changes (generation_task.py, intent.py):
- Remove optimistic streaming queue/race (_drain_queue deleted)
- Remove _generate_acknowledgment — ack now embedded in intent JSON
- Round 0: await intent (~400ms), stream ack immediately as TTFT,
  then execute tool sequentially; chat-only streams directly
- IntentResult.ack: one-sentence acknowledgment, intent max_tokens 200→350
- _parse_intent extracts and trims ack field

KV cache stability (llm.py, generation_buffer.py, generation_task.py):
- build_context: replace cached_note_ids with include_note_ids
- Auto-found notes populate context_meta["auto_notes"] for sidebar but
  are NOT injected into system prompt (--- Related Notes --- removed)
- Explicitly included notes injected as --- Included Notes ---
- _conv_note_cache dict + get/set/clear functions removed from generation_buffer.py
- All clear_conv_note_cache() calls removed

Cold model retry (llm.py):
- generate_completion (used by classify_intent) retries on HTTP 500:
  3 attempts with 3s/6s delays — prevents intent failure during cold load

API + frontend (routes/chat.py, stores/chat.ts, views/ChatView.vue, components/ChatPanel.vue):
- exclude_note_ids → include_note_ids throughout
- ChatView sidebar: Suggested (auto-found, + to include) + In Context (× to remove)
- ChatPanel: remove exclude button from context pills; no IDs passed to sendMessage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 22:34:54 -05:00
bvandeusen 316a85e13b Phase 20: Dedicated tag field — chip input, explicit tags array
Tags are now a first-class field rather than being auto-extracted from
the note body. A new TagInput.vue chip component handles tag entry in
both editor views with autocomplete, Enter/comma/backspace UX, and
space-to-hyphen sanitization.

Backend:
- routes/notes.py: create reads tags from JSON; update accepts explicit
  tags (omit = keep existing); append_tag writes to tags array with
  dedup; suggest-tags accepts current_tags filter; remove extract_tags
- routes/tasks.py: same — explicit tags on create/update; remove extract_tags
- services/tag_suggestions.py: current_tags param replaces body extraction
- services/tools.py: create_note tool schema adds tags param; executor passes it
- services/llm.py: system prompt tells LLM to use tags param, not embed #tag in body

Frontend:
- components/TagInput.vue: new chip-based tag input (autocomplete, keyboard UX)
- NoteEditorView.vue / TaskEditorView.vue: tags ref loaded from note.tags;
  TagInput placed between title and body; save/autosave include tags; suggest
  now adds chips; fetchTagSuggestions passes current_tags; dirty tracks tags
- TiptapEditor.vue: remove fetchTags prop and TagSuggestion extension;
  keep TagDecoration for legacy inline #tag highlighting

No DB migration needed — tags column already correct.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 06:36:35 -05:00
bvandeusen 7947134e22 Fix tag handling for multi-word tags
Tags with spaces (e.g. #science fiction) were breaking extraction because
TAG_RE only matched word characters — it would stop at the space and extract
#science instead of #science-fiction.

- TAG_RE (backend + frontend): add hyphens to character class so #science-fiction
  is recognized as a single tag: [\w][\w-]* per segment
- System prompt: instruct LLM to use hyphens in multi-word tags, never spaces
- tag_suggestions.py: update prompt example + sanitize output by replacing
  spaces with hyphens as a safety net regardless of LLM output
- append-tag route: sanitize incoming tag (spaces → hyphens) before appending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 06:05:58 -05:00
bvandeusen d5a5373872 Add stream retry to all generation paths, not just round 0
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>
2026-02-26 05:55:09 -05:00
bvandeusen fc7b2e7305 Retry Ollama 500 errors in optimistic stream with backoff
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>
2026-02-25 23:15:05 -05:00
bvandeusen 98d3fca277 Implement optimistic streaming to eliminate intent classification latency
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>
2026-02-25 23:03:30 -05:00
bvandeusen 7b6248c8a7 Add OLLAMA_NUM_CTX config to reduce VRAM usage
Replaces the hardcoded num_ctx=32768 KV cache allocation with a
configurable env var defaulting to 8192. This significantly reduces
VRAM pressure when multiple services share the GPU.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 22:02:06 -05:00
bvandeusen 0984dae2e7 Improve favicon contrast and redesign email templates with logo
favicon.svg:
- Light mode: replace near-black fill (#2d3748) with indigo brand color
  (#6366f1 fill, #4f46e5 stroke, #a5b4fc page lines) — distinctive and
  high-contrast without the dark/black appearance
- Dark mode unchanged

email.py:
- Add _EMAIL_LOGO_SVG: inline SVG with white palette for rendering on
  the indigo header (white book, lavender lines, gold sparkle)
- Add _email_html(title, body): shared template wrapper — gray outer
  background, white card with border-radius, indigo header with logo +
  app name, content area, footer

notifications.py:
- Import and use _email_html for all six email functions: security alert,
  password reset, password reset success, invitation, task reminder,
  test email
- Clean up all inline HTML to match the new card layout and spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 20:33:10 -05:00
bvandeusen b37e15d59a Add Authentik OAuth/OIDC SSO, email change, and setup docs
Phase 18 changes:

OAuth/OIDC SSO (Authorization Code + PKCE):
- alembic/versions/0015_add_oauth_fields.py: add oauth_sub UNIQUE column,
  drop NOT NULL on password_hash
- src/fabledassistant/services/oauth.py: OIDC discovery (cached), build_auth_url,
  exchange_code, get_userinfo, find_or_create_oauth_user (sub→email auto-link→create)
- src/fabledassistant/routes/auth.py: GET /api/auth/oauth/login and
  GET /api/auth/oauth/callback; LOCAL_AUTH_ENABLED guards on login/register;
  /api/auth/status now returns oauth_enabled + local_auth_enabled
- src/fabledassistant/config.py: OIDC_ISSUER, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET,
  OIDC_SCOPES, LOCAL_AUTH_ENABLED, oidc_enabled() classmethod
- src/fabledassistant/models/user.py: password_hash nullable, oauth_sub field,
  has_password bool in to_dict()
- src/fabledassistant/services/auth.py: create_user accepts password=None +
  oauth_sub kwarg; authenticate returns None for OAuth-only users;
  add get_user_by_oauth_sub, link_oauth_sub, update_user_email
- frontend: AuthStatus + User types updated; auth store exposes oauthEnabled +
  localAuthEnabled; LoginView shows SSO button / hides password form accordingly

Email change:
- PUT /api/auth/email: requires password confirmation for local-auth users,
  skips check for OAuth-only users; enforces email uniqueness
- SettingsView.vue: new Email Address section pre-filled with current email,
  updates authStore.user in-place on success

Docs:
- docs/oauth-setup.md: step-by-step Authentik provider setup, example
  docker-compose env vars, account linking explanation, per-provider issuer URL table

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 20:12:13 -05:00
bvandeusen 667ccd70cd Security audit, bug fixes, auto-save, and writing assistant improvements
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>
2026-02-19 20:08:44 -05:00
bvandeusen 32e4ee12f2 Add persistent context sidebar, note title fix, and expanded tool suite
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>
2026-02-19 14:40:34 -05:00
bvandeusen d6f4a6dbb6 Add semantic note search (nomic-embed-text) and per-conversation note cache
- 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>
2026-02-18 21:44:58 -05:00
bvandeusen de5921904d Add conversation history summarization for long chats
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>
2026-02-18 21:33:00 -05:00
bvandeusen 24d3c5bc68 Enable thinking mode in full chat view, keep disabled in widget/panel
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>
2026-02-18 21:06:54 -05:00
bvandeusen 815eed2574 Add tool confirmation UI with Accept/Decline for write operations
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>
2026-02-18 20:43:47 -05:00
bvandeusen 1b63371bb3 Stream conversational acknowledgment in parallel with tool execution
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>
2026-02-18 20:29:58 -05:00
bvandeusen 7b02cc5cfd Revert context note truncation — preserve full note content
Pinned note: full body restored (truncation is wrong when the user is
explicitly asking about that note's content).
Auto-notes: restored to 2000 chars (800 was too restrictive for useful context).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 20:22:48 -05:00
bvandeusen 38697f2614 Reduce context note preview sizes to cut prefill latency
Auto-notes (keyword-matched): 2000 → 800 chars each (×3 max = 6000 → 2400 chars).
Pinned note (explicit context): was unbounded → capped at 4000 chars with [truncated] marker.

The main post-GPU bottleneck is TTFT caused by the prefill phase — the model
processing the full input before generating any tokens. Shorter context =
faster prefill. Users can ask follow-up questions for more detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-18 19:58:52 -05:00
bvandeusen 931a059e9f GPU support, parallel intent+context, and increased context window
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>
2026-02-18 19:29:31 -05:00
bvandeusen 92bf2768b6 Reduce perceived latency: move context build into task, title fire-and-forget, think:False on aux calls
- 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>
2026-02-18 18:50:37 -05:00
bvandeusen 765e99bb24 Fix duplicate message bug and add generation timing instrumentation
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>
2026-02-18 18:18:46 -05:00
bvandeusen fbce540638 Add streaming status UX and model load state indicator
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>
2026-02-18 17:54:37 -05:00
bvandeusen 4df5ec2d65 Add update_task fields, list_tasks, and update_todo tools
- update_note: extend with status/priority/due_date fields so task attributes
  can be changed via chat (mark done, set priority, move due date). body is now
  optional — task field updates work without touching content.
- list_tasks: new core tool with status/priority/due_before/due_after/limit
  filters backed by list_notes(is_task=True). Enables queries like
  "overdue tasks", "high priority tasks", "what's in progress".
- update_todo: new CalDAV tool to modify VTODO summary, due date, description,
  and priority — follows update_event pattern (modify component, rebuild ical,
  save). Completes the CalDAV todo CRUD suite.
- tools.py: add update_todo import + execute case (type: todo_updated)
- llm.py: add list_tasks and update_todo to available actions + guidance
- intent.py: routing rules for mark-done/priority/due-date → update_note,
  overdue/in-progress/high-priority queries → list_tasks, CalDAV todo updates
  → update_todo
- ToolCallCard.vue: tasks list block (linked titles + due + priority badges),
  todo_updated label, tool-task-priority CSS classes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 23:22:02 -05:00
bvandeusen 70cba72a80 Phase 10: CalDAV full lifecycle, update_note, dashboard inline streaming, keyboard shortcuts
Backend:
- caldav.py: Full event lifecycle — update_event, delete_event; VTODO suite —
  create_todo, list_todos, complete_todo, delete_todo; list_calendars; timezone
  support via ZoneInfo; reminders via VALARM; attendees; multi-calendar search
  (_get_all_calendars scans all calendars when no specific one is configured)
- tools.py: New update_note tool (find by title + replace/append modes),
  7 new CalDAV tool definitions, corresponding execute_tool cases
- llm.py: Update system prompt — add update_note guidance, full CalDAV action list
- intent.py: Confidence scoring (high/medium/low) + should_execute property;
  conversation history support for anaphora resolution; routing rules for
  update/delete events, todos, update_note vs create_note disambiguation,
  time-period → list_events (not search_events), reminder_minutes conversion
- generation_task.py: Parallel fetch of tools + intent_model setting; dedicated
  intent model (OLLAMA_INTENT_MODEL env var or per-user intent_model setting)
- config.py: Add OLLAMA_INTENT_MODEL env var

Frontend:
- HomeView.vue: Inline streaming response (no navigation); quick action chips;
  isConversational computed — prominent "Continue this conversation" CTA when
  no tool calls; auto-focus chat input on mount via chatInputRef
- DashboardChatInput.vue: defineExpose({ focus }) for external focus control
- ChatView.vue: Escape key handler — close picker → close sidebar → clear
  textarea → navigate home; onUnmounted cleanup
- App.vue: Global ? key shortcut toggles keyboard shortcuts overlay; shared
  state via useShortcuts composable; Transition animation
- AppHeader.vue: ? button for shortcuts overlay discoverability
- useShortcuts.ts (new): Shared showShortcuts ref + open/close/toggle helpers
- ToolCallCard.vue: note_updated, event_updated, event_deleted, calendars,
  todo, todos, todo_completed, todo_deleted label cases + render blocks
- SettingsView.vue: Intent model field + caldav_timezone setting

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 22:04:41 -05:00
bvandeusen 75560dee4e Switch default model to qwen3 and add intent routing for reliable tool calling
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>
2026-02-16 16:24:01 -05:00
bvandeusen d7bc3f3222 Add CalDAV calendar integration, LLM-suggested tags, and settings refinements
- 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>
2026-02-15 22:40:20 -05:00
bvandeusen 8996b45e50 Add LLM tool calling for creating tasks, notes, and searching from chat
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>
2026-02-14 23:34:36 -05:00
bvandeusen f089b16080 Improve model status indicators and tune timeouts
Green/red emoji circles replace Unicode symbols for at-a-glance model
readiness. Increase connect timeouts (10→30s) for cold model loading,
warm timeout (120→300s) for large models, and pull timeout (600→1800s)
to match route-level limit. Show error toast on SSE reconnection failure
instead of silently recovering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 23:01:59 -05:00
bvandeusen 953eaf2feb Add model selection, dashboard chat input, and model warming
- Add GET /api/chat/ps and POST /api/chat/warm endpoints for hot model
  visibility and pre-loading
- Extend PATCH /api/chat/conversations/:id to accept model in addition
  to title
- Add ModelSelector component with hot/cold indicators from Ollama /api/ps
- Add DashboardChatInput component (model selector + note picker + textarea)
  replacing the simple "New Chat" button on the dashboard
- Add model selector dropdown to ChatView header, persisted per-conversation
- Warm default model on dashboard mount via fire-and-forget background task
- Configure Ollama with OLLAMA_MAX_LOADED_MODELS=2 and OLLAMA_KEEP_ALIVE=30m
- Always-visible edit buttons on NoteCard/TaskCard (remove hover-only behavior)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 18:49:06 -05:00
bvandeusen e02b681e91 Add invitation system, table of contents, actionable dashboard, and settings improvements
Invitation system (Phase 5.7):
- invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry
- Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations
- Branded invitation email with registration link
- /register-invite frontend view with token validation and account creation
- Admin UI: invite form + pending invitations table with revoke
- Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL)

Table of contents + markdown improvements (Phase 5.8):
- TableOfContents component: sticky sidebar, heading parsing, smooth-scroll
- Custom marked renderer adds id attributes to headings for anchor links
- stripFirstLineTags() prevents leading #tags from rendering as headings
- NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px)
- Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1,
  qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models
- Settings model section split into Installed/Available tabs

Actionable dashboard (Phase 5.9):
- due_before/due_after query params on /api/tasks (exclusive < / inclusive >=)
- HomeView rewritten: overdue (red accent), due today, in progress, chats, notes
- 5 parallel API calls via Promise.allSettled
- Client-side done filtering and in-progress deduplication
- Task sections hidden when empty; status toggle removes done tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 08:46:51 -05:00
bvandeusen d354da5b51 Add email-based password reset flow
Users who forget their password can now request a reset link via email.
Tokens are SHA-256 hashed before storage, expire after 1 hour, and
previous unused tokens are invalidated on new requests. The forgot-password
endpoint always returns success to prevent email enumeration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:42:45 -05:00
bvandeusen d874e0e2ae Add application logging, SMTP email notifications, and supporting changes
Phase 5.4 — Application Logging:
- AppLog model + migration 0010 for unified audit/usage/error logging
- Usage logging middleware in app.py (after_request for /api/* requests)
- Error logging in 500 handler with traceback capture
- Audit logging for auth events (register, login, login_failed, logout,
  password_change) and admin actions (backup, restore, user_delete,
  registration_toggle, smtp_config, smtp_test)
- Admin log viewer (LogsView.vue) with stats, category/search/date
  filters, paginated table with expandable detail rows
- Admin logs API endpoints in admin.py (GET /logs, GET /logs/stats)
- Configurable retention via LOG_RETENTION_DAYS with hourly cleanup

Phase 5.5 — SMTP Email Notifications:
- aiosmtplib dependency for async email sending
- Email service (services/email.py) with STARTTLS/implicit TLS support
- Notification service (services/notifications.py) for security alerts
  and task due date reminders with per-user preferences
- Admin SMTP config endpoints (GET/PUT /api/admin/smtp, POST test)
- SMTP config in Config class with env var + Docker secret support
- Settings UI: notification preferences for all users, SMTP config
  section for admin with test email

Other changes:
- stream_chat() now accepts optional options dict (for num_predict)
- Increase assist MAX_BODY_CHARS from 3000 to 8000
- get_user_by_username() added to auth service
- apiStreamPost buffer processing refactored for robustness
- AppHeader: admin Logs nav link
- Router: /admin/logs route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 08:34:52 -05:00
bvandeusen a89d25f5d6 Refactor AI Assist to background-task + buffer architecture
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>
2026-02-13 00:27:21 -05:00
bvandeusen f77b029943 Add registration control, admin user management, and security hardening
- Registration auto-closes after first user; admin can toggle from /admin/users
- Admin user management view with user list and delete
- Password confirmation on registration form
- Password change in Settings (PUT /api/auth/password)
- Session cookie hardening: HttpOnly, SameSite=Lax, optional Secure flag
- Startup warning when SECRET_KEY is default
- Production deployment docs: reverse proxy, rate limiting, CSP headers
- Fix assist prompt to preserve markdown headings in target sections
- Simplify prod compose networking

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 17:49:22 -05:00
bvandeusen 586026bff6 Add Tiptap inline-preview editor, layout improvements, and README
Replace plain textarea with Tiptap (ProseMirror) WYSIWYG editor for notes
and tasks. Headings, bold, lists, and code blocks now render inline while
editing. Tags and wikilinks get visual highlighting via decoration plugins,
and autocomplete uses @tiptap/suggestion with heading disambiguation.

Layout: pin navbar with app-shell flex column (100dvh), sticky editor
toolbar above scrolling content, AI Assist panel fixed at bottom 1/3 with
full-height section selector and prompt. Increase max-width to 960px
across all views. Hide assist controls during streaming/review.

Other fixes: markdown paste handling, auto-syncing assist sections via
body watcher, trailing newline on AI accept, ChatView height fix.

Add README.md describing the app, usage guide, and technical overview.
Update summary.md with Phase 5.2 roadmap and current status.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 21:24:35 -05:00
bvandeusen cbfdf5289e Add multi-user auth, background generation, and chat UX improvements
Phase 5: Multi-user authentication with session cookies, bcrypt passwords,
first-user-is-admin pattern, per-user data isolation, backup/restore,
Docker Swarm production stack with secrets and network isolation.

Phase 5.1: Chat UX improvements:
- Background generation architecture (GenerationBuffer + asyncio task)
  with SSE fan-out, reconnect support, and periodic DB flushes
- LLM-generated conversation titles (first exchange + every 10th message)
- Stop generation button with cancel_event and partial content preservation
- Relative timestamps in sidebar (5m ago, 3h ago, then dates)
- Empty chat auto-cleanup on navigation away
- Save-as-note uses LLM for title generation, tags notes with "chat"
- Summarize-as-note also tags with "chat"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 14:36:30 -05:00
bvandeusen db01106714 Backend efficiency & consistency pass
- Rewrite get_all_tags() with SQL unnest instead of loading all notes
- Consolidate convert_note_to_task/convert_task_to_note to single-session ops
- Add search_notes_for_context() with single OR-keyword query for build_context()
- Drop selectinload from list_conversations(), use correlated subquery for message_count
- Add set_settings_batch() for single-transaction multi-setting upserts
- Extract get_installed_models() shared helper into services/llm.py
- Delete services/tasks.py pass-through wrapper; routes/tasks.py imports from services.notes
- Add B-tree indexes on notes.title and conversations.updated_at (migration 0007)
- Add logging to services/notes.py, services/chat.py, services/settings.py
- Safe Conversation.to_dict() when messages relationship is not loaded

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 08:27:11 -05:00
bvandeusen fb18d2c41d Add note context visibility in chat and standardize UI design tokens
Improve chat context: build_context() now returns metadata about auto-found
notes, emitted as an SSE event so the frontend can display context pills
showing which notes influenced the response. Users can promote notes for
deeper context (+) or exclude irrelevant ones (x). A note picker lets users
manually attach notes. Multi-word search uses per-term AND matching, and
auto-search iterates keywords individually for broader OR-style coverage.

Standardize styling: introduce CSS design tokens (--radius-sm/md/lg/pill,
--color-success/warning/overlay, --focus-ring) and migrate all components
to use them. Fix header alignment to full-width, add active nav link state,
replace hardcoded colors with CSS variables, and normalize button padding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 06:49:12 -05:00