log_work description now mentions that logs feed the task's auto-summary,
nudging the LLM toward specific log content (commands, decisions, failures)
rather than vague entries.
create_note description gains a runbook-shape clause: code blocks, numbered
procedures, and explicit 'save this as a note/runbook' signals should
spawn standalone notes. Task-specific work-in-progress routes to log_work
instead.
create_note tool:
- New 'description' parameter accepted and forwarded to the service.
- When status is set (creating a task), 'body' is dropped before the
service call. Task bodies are owned by the consolidation pipeline.
update_note tool:
- New 'description' parameter; routed through update_fields.
- When the resolved target has is_task=True and 'body' is in the
arguments, the call errors with a message nudging toward log_work or
description. Knowledge notes are unaffected.
HTTP routes (POST/PATCH/PUT /api/notes) accept body freely — the
restriction is only at the LLM tool layer.
log_work tool now invokes maybe_consolidate(reason='log_added') after a
successful create_log. The gate inside the consolidation service handles
threshold + setting checks.
update_note service snapshots old_status before mutation and fires
maybe_consolidate(reason='task_closed') when the status transitions into
'done' or 'cancelled'. Re-saving an already-terminal status doesn't
retrigger — only transitions count.
CI surfaced three issues:
- 'famous supply project' didn't substring-match 'Famous-Supply Work topics'
because the trailing filler word 'project' blocked the substring tier.
Strip {project, projects} from the query before the substring check.
- SequenceMatcher fallback against `combined` (title + description +
summary) diluted ratios to ~0.5 for plausible matches. Use title
directly; the 0.70 tier already handles description/summary mentions.
- Test patches used patch.object on a consumer module where
list_projects is imported locally — patch the source module instead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Root cause of the 2026-04-29 dentist-appointment incident: the model
called update_event(query="Appointment") when two events had
"Appointment" in their titles. find_events_by_query returned both,
upcoming-first ordered by start_dt — matches[0] was id=2 (a stale
pre-existing event with garbage end_dt), not id=15 (the one the user
just created via the journal flow). update_event_tool silently took
matches[0] and mutated the wrong event.
Fix: a new resolver helper `_resolve_event_for_action` funnels both
update_event_tool and delete_event_tool through one disambiguation
path. Lookup precedence:
- `event_id` → exact get_event lookup, no query at all
- `query` matching exactly one event → proceed
- `query` matching zero → return success=False, "no event found"
- `query` matching 2+ events → return success=False with a
`candidates` array of {id, title, start_dt, location} so the
model can pick one and call again with `event_id`
The candidates list is capped at 8 to keep the model's context tight.
The error message names the count and the next-step ("pass event_id
or refine the query") so the model can self-correct in one turn.
For delete_event, the disambiguation is even more important — the
silent-matches[0] path would have deleted the wrong event outright
rather than just mutating it. The tool description leans into that:
"Deleting the wrong event is a costly user error; never guess."
Tool surface change: `query` and `event_id` are now both optional;
the tool errors clearly when neither is supplied. The model already
knows id values from prior tool results (returned in `data.id`),
which is the natural feeder for the disambiguation flow.
5 new tests in test_calendar_tool_tz.py cover:
- ambiguous query → success=False with candidate list, no mutation
- event_id supplied → bypasses query lookup entirely
- non-existent event_id → clear "no event found" error
- neither identifier → "query or event_id required" error
- same disambiguation enforced for delete_event_tool
46 calendar/events tests pass; ruff clean.
Closes Fable #161.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A prod event surfaced today with `start_dt=2026-05-01T12:00Z` and
`end_dt=2026-03-30T12:00Z` — end was 32 days BEFORE start, almost
certainly from an earlier tool-call mishap (Fable #161). The
list_events filter trusted the bogus end_dt and excluded the event
from every read path that hit the upcoming window, even though
start_dt was correctly in range. The event stayed visible in the
calendar grid (different range) but vanished from "Upcoming",
search, briefings, and journal prep events list.
This is the hotfix half of the response. The structural follow-up is
Fable #160 — replace end_dt with a duration column so invalid state
becomes inexpressible.
## A. Filter robustness in list_events
Treat `end_dt <= start_dt` as if no end_dt exists. The filter now
splits into two branches:
- valid duration: end_dt IS NOT NULL AND end_dt > start_dt AND
end_dt >= date_from
- no/invalid duration: (end_dt IS NULL OR end_dt <= start_dt) AND
start_dt >= date_from
Same change applied to the recurring-event expansion's `duration`
calculation, which was producing negative timedeltas for corrupted
rows and computing nonsensical occurrence end times.
## B. Write-side validation in create/update
`create_event` and `update_event` now raise ValueError when the
resulting state would have end_dt <= start_dt. Update validates
against the *post-update* state, not just the field being changed —
so pushing start_dt past an existing end_dt also fails loudly. Bad
data shouldn't be persistable from any write path.
Surfaced cleanly:
- Calendar tool wrappers (create_event_tool / update_event_tool)
catch ValueError and return `{success: false, error: ...}`, which
the model can read and self-correct.
- Route handlers (POST /api/events, PATCH /api/events/<id>) catch
and return HTTP 400 with the validator's message instead of
letting it bubble to a 500.
4 new tests in test_events_service.py:
- create rejects end before start
- create rejects equal start/end (zero duration)
- update validates the post-update state (start pushed past existing end)
- list_events surfaces an event whose end_dt is before its start_dt
34 event-related tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reproducer (2026-04-29 dentist appointment): user said "this Friday,
I have an appointment" with no other details. The model immediately
called create_event with title="Appointment", description="User
mentioned an appointment this Friday but hasn't provided details
yet.", all_day=true. THEN it asked the user for time/location in
its reply. When the user came back with "8am at my dentist for
permanent crown fitting", the model called update_event — but never
updated the title, leaving the placeholder "Appointment" in the
calendar permanently.
The bug isn't about the tool surface, it's that the model created
an event before it had real content. The system prompt had no rule
against this, so the model hedged: "log a placeholder, ask for
details, then update". That pattern pollutes the calendar with
garbage titles and forces immediate update_event calls.
create_event tool description now includes an explicit anti-pattern:
record a moment, ask for the missing pieces, and only call create_event
once you have actual title + time + location. Stand-in titles like
"Appointment" / "Meeting" / "Event" with "details TBD" descriptions
are explicitly named as the failure mode.
Pure prompt change. 18 tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Belt-and-suspenders to the prompt-layer changes in 6c309f1. Even when
the model emits bogus task or place links, the server now refuses to
persist them.
## Task auto-linking guard
Reproducer (2026-04-27): a moment about restaging Docker on the swarm
ended up with `task_ids: [2]` (Weston's ADHD Evaluation) — the only
task in that day's prep. The model picked it up as filler.
`_filter_task_ids_by_keyword_overlap` now runs after id resolution: it
fetches each linked task's title, tokenizes both content and title
through `_content_keywords` (lowercased, stopwords stripped, <3-char
tokens dropped), and drops any link whose title shares no meaningful
keyword with the moment content. The drop is logged at INFO so we can
observe how often it fires post-deploy.
The guard runs against the merged id list, so it covers both the
preferred `task_titles` resolution path and the discouraged explicit
`task_ids` path.
## Place placeholder guard
Reproducer (2026-04-27): `place_names=["work"]` got passed to
`record_moment`. "work" / "home" / "office" aren't places — they're
role-labels for already-known geocoded locations.
`_filter_placeholder_places` drops a small set of generic single-word
labels before name resolution. Real user-named places that happen to
be one word (e.g. "Akron") pass through.
## Tests
9 new unit tests in `tests/test_record_moment_guards.py` cover:
- keyword tokenization & stopword stripping
- placeholder place filtering (generic, case-insensitive, real-place
pass-through)
- keyword-overlap filtering (the exact 4/27 reproducer, the genuine-
reference case, mixed/partial relevance, empty input)
13 tests pass; ruff clean.
Closes Fable task #158.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three related rough edges in the journal voice surfaced from real
journal usage 2026-04-27 → 2026-04-29:
1. **Persona overhelps.** When the user logged "today I'm prepping for
an ISP migration at Branch 14 Bedford for work at famous supply",
the assistant came back with "ISP migrations can be tricky. Are you
handling the network configuration yourself, or is there a team
supporting you? Also, are there any specific tasks or checks you
need to complete before the switch?" — pushing IT-helpdesk advice
the user didn't ask for. The user had to push back. JOURNAL_PERSONA
now leads with "CAPTURE first, advise only if asked" and the
RESPONSE STYLE block has an explicit anti-pattern banning
troubleshooting / checklist / process-advice follow-ups unless the
user explicitly invites them.
2. **Moments stored in third-person observer voice.** The dentist
appointment beat got written as "The user mentioned having an
appointment this Friday but hasn't provided details yet." — reads
like an LLM transcript annotation, not a journal jot. The
record_moment tool's `content` description previously said "in the
user's voice or third-person", which was the literal source of the
bug. New phrasing requires first-person/imperative with concrete
GOOD/BAD examples, and the JOURNAL_CALIBRATION block reinforces it.
3. **Inconsistent emoji use.** 4/27 was clinical, 4/29 had 😊 and 🛠️
in the appointment confirmation. RESPONSE STYLE now bans emojis
outright — the journal is a thinking-companion surface and the
emoji warmth reads as out-of-register chat-bot tone.
Bonus while in here:
- New MOMENT ENTITY LINKING section explicitly forbids attaching a
task_titles link unless the user references the task by name (the
4/27 Docker→ADHD auto-link bug; rest of that fix is in #158).
- Same section rejects generic place placeholders ("work" / "home" /
"office") in favor of letting the user name the real place.
22 tests pass (4 journal + 18 calendar tool); ruff clean.
Closes Fable task #157.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A user asked Fable to schedule "this Friday at 8am" on Wednesday 4/29
2026. The model picked 4/30 (Thursday) and confidently labeled it
"Friday." The TZ pipeline did everything correctly given the model's
date — the bug was upstream: the model was guessing weekdays from ISO
dates without an anchor, and the calendar tools had no way to verify.
Three layered fixes:
1. **System prompts now name the weekday alongside the ISO date.**
Both the journal-conversation prompt and the general chat prompt
used to say "Today is 2026-04-29 (America/New_York)." They now say
"Today is Wednesday, 2026-04-29 (...)." LLMs are unreliable at
deriving weekday names from ISO dates; supplying the name removes
the guess.
2. **`expected_weekday` parameter on create_event / update_event.**
When the model passes `expected_weekday="friday"`, the backend
computes the resolved start_date's weekday in the user's local
timezone and rejects mismatches with a self-correcting error
("Date 2026-04-30 falls on Thursday, not Friday. Recompute..."),
without creating the event. The check is local-aware: a Friday
23:00 event in Tokyo crosses midnight UTC but the local view
stays Friday, and the validator respects that.
3. **Tool descriptions instruct echo-and-confirm.** create_event and
update_event descriptions now tell the model: when the user names
a weekday, state the resolved date in the reply BEFORE calling
the tool, and pass `expected_weekday`. Costs nothing in code,
reinforces the validator.
6 new tests — match success, mismatch rejection (with create/update
not invoked), omitted-param backcompat, invalid weekday name, local-
not-UTC weekday computation, and the update_event variant. All 18
calendar-tool tests + 33 event-related tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A user reported "next Friday at 8am" landing on the wrong day. The
current `start` parameter accepts a combined ISO datetime string — when
the model emits something like `"2026-05-01T00:00:00Z"`, the parser
correctly honors the UTC tag and stores `2026-05-01 00:00 UTC`, which
displays as `2026-04-30 19:00` for a UTC-5 user. The bug isn't in our
parser; it's that we let the model TZ-tag the calendar day at all.
The fix moves the foot-gun: `create_event` and `update_event` now
prefer split fields (`start_date` + `start_time`, plus end variants).
A `YYYY-MM-DD` string carries no TZ metadata for a model to mis-tag,
and the backend builds the local datetime explicitly via
`datetime.combine(date, time, tzinfo=user_tz).astimezone(UTC)`. Strict
regex validation rejects anything with a TZ suffix on either field.
The legacy combined `start` / `end` fields are kept as a fallback so
saved tool-call payloads in conversation history still replay; new
calls are steered toward the split shape via the tool description.
7 new regression tests cover Eastern, Pacific, Tokyo (positive offset),
all-day inference, strict-shape rejection on both fields, backcompat
with the legacy `start` field, and the same fix for `update_event`.
27 of the event-related tests pass; ruff clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three real bugs surfaced from inspecting today's journal turns:
1. record_moment was getting fed hallucinated person_ids (the LLM passed
[1, 2] instead of the IDs save_person had just returned). Result: the
moment was linked to two random old test-data notes ("test task 2",
"Tell a joke"), not the people the user actually mentioned.
2. The calibration rule "ask before save_person" was being silently
ignored — model just called save_person on first mention of Victoria
and Mother without asking the user.
3. The model produced a verbatim-identical reply to its previous turn when
the user mentioned "overwhelmed" twice — same numbered-list of 4
options, same closing line. The "warm listener / ask gentle questions"
persona was pushing toward stock therapy-template patterns.
Fixes:
services/tools/journal.py — record_moment now accepts *_names parameters
(person_names, place_names, task_titles, note_titles). Server resolves
each name to a note ID via case-insensitive title match, scoped by
note_type or task-status. *_ids parameters still exist but are now
documented as DISCOURAGED. The LLM physically cannot invent the wrong ID
when using names — names with no match are silently dropped. Resolution
happens via _resolve_entity_ids_by_name helper.
services/journal_pipeline.py — JOURNAL_PERSONA tightened (no more
"warm/curious listener" framing that pushed toward stock comfort
patterns). JOURNAL_CALIBRATION rewritten as scannable sections with
imperative language: PEOPLE/PLACES require asking before save_person;
TASK/NOTE state changes use the confirmation flow; MOMENTS are silent
but MUST use *_names not *_ids; OTHER notes the no-set_rag_scope and
no-auto-notes invariants. Added a RESPONSE STYLE section that explicitly
forbids verbatim repetition and stock multi-option menus.
After deploy, force-regenerate today's prep via fable_trigger_journal_prep
to also pick up the tighter prep prompt from 590a07b.
Co-Authored-By: Claude Opus 4.7 <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>
trafilatura.extract dispatched via run_in_executor isn't safe to run
concurrently — two parallel calls can crash the process with a
libxml2-level double free. The top-level Wikipedia+SearXNG gather is
fine; only the inner per-article extraction needs to stay sequential,
matching the pre-parallelization behavior.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Runs the Wikipedia summary and SearXNG search concurrently and returns
both when available, so current-event questions aren't masked by a
generic role article from Wikipedia. When the Wikipedia summary includes
a thumbnail, it is cached through the existing image pipeline and
surfaced as an embeddable markdown snippet alongside the extract.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
RSS is now off by default. When disabled:
- Scheduler skips RSS feed sync during compilation slot
- Briefing pipeline skips RSS item gathering
- RSS LLM tools (get_rss_items, add_rss_feed) are hidden
- API routes return empty results for feeds/news
- Frontend hides News nav link, RSS Feeds and News Preferences in settings
- Briefing view hides news sidebar section
Toggle in Settings > Briefing > RSS / News.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Two related bugs where the server defaulted naive datetimes to UTC instead
of the configured user timezone, causing all-day events to land on the
previous day and briefings to "disappear" at UTC midnight.
- New services/tz.py helpers: get_user_tz, user_today, user_briefing_date
(the briefing day flips at 4am local to align with the compilation slot,
so the 00:00-04:00 local window still shows yesterday's briefing until
the new one is generated).
- calendar create/list/update tools now parse naive datetimes in the
user's TZ before converting to UTC for storage, and tool descriptions
tell the model to pass plain local dates.
- briefing_conversations.get_or_create_today_conversation and the
reset-today route use user_briefing_date so the in-progress briefing
doesn't get replaced at 19:00 NY / UTC midnight.
- _run_profile_closeout targets user-local "yesterday" for consistency.
Regression tests added for the TZ helpers and the calendar tool.
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>
Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>