The chat generation pipeline previously forced think=True unconditionally
to match qwen3's combined think+tools template, locking the system into
that model family. Bench data (2026-05-21, qwen3:30b-a3b/qwen3:32b on
CPU) showed thinking adds 1-2 minutes per turn for unclear quality
benefit — qwen3:30b-a3b even produced more rambling with think on.
This decouples think from the model family by reading a per-user
`think_enabled` setting (default `false`). Non-qwen3 models can now run
through the same pipeline without the silent-generation failure mode
that content-gated thinking would have caused — they just don't think.
qwen3 users who still want thinking can opt in via the Settings UI.
Settings UI:
- New "Enable model thinking" checkbox in General → Assistant section.
- Help text explains the default-off rationale and when to opt in.
- Persists via the existing settings API; no schema migration needed
(Setting is key/value text).
Telemetry to confirm whether this regresses tool-call reliability on
qwen3 (the current model) is in a follow-up commit (generation_tool_log).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
- services/tools/journal.py — record_moment + search_journal tool handlers
- services/tools/_registry.py: add `journal` flag on ToolDef + tool() decorator
- get_tools_for_user(user_id, conversation_type='chat'|'journal') —
exclude journal-only tools from chat sessions; exclude set_rag_scope
from journal sessions
- services/tools/__init__.py: register the new journal module; drop the
unused get_briefing_tools export
- services/llm.py build_context: short-circuit for journal conversations,
using journal_pipeline.build_journal_system_prompt and skipping all
notes-RAG injection (preserves the journal/notes isolation invariant)
- services/generation_task.py: pass conversation_type into get_tools_for_user
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Content-based gating (_should_think) was introduced in 87fcaa6 to cut
TTFT on simple prompts, but it has no way to tell that short prompts
like "create a task titled X" are going to trigger a tool call — and
qwen3:14b's tool-call template is unreliable at think=False, producing
intermittent silent generations where output tokens burn but nothing
parses into content or tool_calls.
Reverting to always-on thinking restores the pre-87fcaa6 reliability
of tool emission at the cost of TTFT latency on short conversational
prompts. This also lets us delete the silent-round retry loop (which
can no longer fire) along with its bookkeeping.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Retry attempts were previously conflated with the initial call,
making prompt_tokens and headroom look cumulative and useless for
diagnosing the silent-round behavior. Move start-of-attempt captures
inside the retry loop and emit attempt_start / attempt_end lines so
each attempt's numbers stand alone.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Log num_ctx, message count, prompt/output tokens, headroom, and a
silent flag per round so we can correlate silent generations against
context pressure on the dev instance.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Qwen3's tool-call tokens sometimes fail to parse into either content
or tool_calls, burning output tokens and producing empty bubbles.
Detect the signature within a round (empty content, no tool calls,
eval_count > 0) and re-run the same round once with reasoning mode
enabled, which emits more reliable output. The post-loop fallback
remains as the final catch.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Qwen3:14b sometimes burns output tokens on tool-calling attempts whose
emission doesn't parse into any field we read — eval_count > 0 but no
thinking/content/tool_calls ever stream to the caller. Generation
completes "successfully," the user sees an empty assistant bubble, and
no error is logged. Seen in conv 220 today.
Two safety rails:
- stream_chat_with_tools now tracks whether it yielded anything; when
Ollama's done frame reports eval_count > 0 with zero yields, log a
warning including the last ~5 raw frames so the next occurrence leaves
breadcrumbs for diagnosis.
- run_generation checks the same post-condition after the tool loop
exits and, if content is empty with no tool calls but output_tokens
> 0, substitutes a visible fallback message and streams it as a chunk
so the user gets something readable instead of a blank bubble.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.
First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
_generate_title was receiving the full messages list from build_context,
which prepends RAG snippets, RSS excerpts, URL content, and briefing
article dumps INTO the user-role message string. The role=="user" filter
inside _generate_title then handed that composite blob (capped at 300
chars) to gemma3:4b as "the user's message", so the background model
was titling conversations based on article excerpts instead of what the
user actually typed — producing wildly wrong titles like "Briefing
Profile Preferences & Schedule" for a plain calendar query. See #109.
Pass the raw history + user_content + assistant reply instead.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds 38 parametrized tests for the _should_think classifier covering the
explicit-override path, empty/whitespace content, short/medium/long length
boundaries, case-insensitive keyword matching, and a chatty-message negative
set. These pin the content-based semantics so future tweaks to the keyword
list or length thresholds surface regressions immediately instead of going
unnoticed behind subtle latency changes.
Also drops the `think=True` overrides from the briefing /discuss-article
and /discuss-topic entry points. With `"discuss"` added to _THINK_KEYWORDS,
those canned prompts trip the classifier naturally, so the overrides were
redundant — keeping a uniform "classifier is authoritative" rule makes the
code easier to reason about.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The frontend hardcoded think=true on every chat send (ChatPanel full +
widget variants, KnowledgeView minichat), which defeated the _should_think
gate on the backend and made qwen3:14b spend 5-20s on chain-of-thought
reasoning for every turn — even "hi". This was the root cause of the
warm-path TTFT variance tracked in followup_ttft_variance.md: the logged
ttft_ms was really prefill + full thinking phase, bouncing with the depth
of the model's reasoning, not with cache or eviction.
All three frontend callers now pass think=false and let _should_think be
authoritative. The classifier is now a real content-based gate: explicit
think_requested=True still forces on as an override (briefing discuss
actions, future UI toggles, MCP callers), otherwise messages <80 chars
without reasoning keywords skip thinking, messages >=400 chars or
containing keywords like why/explain/analyze/debug/review/etc. get it.
Generation timing now separately records think_requested, the final
think decision, first_token_ms (first any chunk), and thinking_ms
(duration of the thinking phase). ttft_ms keeps its existing semantic
(first content token) so existing log analysis still works. The timing
log line surfaces all four fields so the old "just a big ttft number"
ambiguity is gone.
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>
Merge create_task into create_note (set status='todo' for tasks, omit
for notes), merge delete_task into delete_note, consolidate entity
tools (create/update_person → save_person, create/update_place →
save_place), rename get_note → read_note with clearer descriptions,
move calculate out of rag.py into utility.py, and extract shared
duplicate detection into check_duplicate() helper.
Updates all downstream references in generation_task.py, quick_capture.py,
ToolCallCard.vue, and WorkspaceView.vue.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Some exceptions (e.g. connection errors) produce empty str(e),
resulting in "Research failed: " with no explanation. Fall back to
the exception class name when the message is blank.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- cleanup_old_conversations now excludes briefing conversations (was
silently deleting briefing history after the retention window)
- list_conversations response now includes rag_project_id, matching the
shape returned by the single-conversation GET endpoint
- create_conversation_from_article: removed duplicate async_session import
(_session2 was a copy of the same import); consolidated into one
- MAX_TOOL_ROUNDS fixed from 5→6 to match the actual range(6) loop;
loop updated to range(MAX_TOOL_ROUNDS) so the constant is accurate
- Chat retention cleanup moved from per-request (every GET /conversations)
to a daily scheduled job in event_scheduler.py; route no longer runs
a DB write on every read
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>
Exposes OLLAMA_BACKGROUND_MODEL as a per-user setting in General settings,
alongside the Chat Model selector. Includes an inline warning when the same
model is selected for both, explaining the KV cache performance impact.
All background task callers (title generation, tag suggestions, project
summaries, RSS classification) now read background_model from user settings,
falling back to OLLAMA_BACKGROUND_MODEL env var.
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>
Routes simple/conversational messages to think=false automatically,
even when the user has thinking enabled. Patterns checked: word count
thresholds, complexity keywords, code blocks, skip patterns for greetings
and simple CRUD. Workspace mode (think=true from frontend) still benefits
from the classifier on short messages.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
wait_for_model_loaded() polled /api/ps for up to 180s waiting for the
model to appear as loaded. But Ollama lazy-loads models on the first
/api/chat request, so the poll will never succeed — it just blocks for
the full 180s after every Ollama restart before proceeding.
Removed the wait entirely. Ollama handles on-demand loading correctly.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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>