Lets a project mute individual rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two new association
tables (migration 0060), 4 MCP tools (suppress/unsuppress × rule/topic),
4 REST endpoints, and an inline "× skip" affordance plus collapsed
"Suppressed (N)" section in the project's Rules tab.
get_applicable_rules now emits suppressed_rules and suppressed_topics
(detail objects with rulebook/topic context, not just IDs) so the UI
can render the suppressed list without a follow-up lookup. The main
rules projection grew topic_id and rulebook_id columns for the per-row
suppress affordance.
Project deletion cascades the suppression rows via hard DELETE — they
are pure associations with no soft-delete column, and restoring a
deleted project should start fresh, not inherit stale mutes.
Project-scoped rules (Rule.project_id) are deliberately not suppressible
— delete them with delete_rule instead.
Implements plan-task #187.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Rules can now belong to either a rulebook topic OR a single project,
enforced by a CHECK constraint (exactly-one of topic_id/project_id).
Adds the create_project_rule MCP tool + REST endpoint, surfaces
project-scoped rules in get_project/get_task/start_planning under a
new project_rules field, and adds a project Rules tab section with an
inline create form so the operator can author project rules from the
UI without rulebook ceremony.
- migration 0059: rules.project_id (FK projects ON DELETE CASCADE),
topic_id now nullable, CHECK ck_rule_topic_xor_project, index on
project_id
- model: Rule gains project_id; to_dict exposes it
- service: create_project_rule with project-ownership guard; list_rules
with project_id filter UNIONs subscription-derived + project-scoped;
get_applicable_rules adds a project_rules field; get_rule / update_rule
/ delete_rule fetch via a shared _fetch_owned_rule that handles both
rulebook and project ownership paths
- trash: project delete cascades to project-scoped rules
- MCP: create_project_rule tool registered; _INSTRUCTIONS mentions both
create_rule and create_project_rule paths
- REST: POST /api/projects/<id>/rules (statement required, title derived
if omitted)
- frontend: Rule type gains nullable topic_id + project_id; createProjectRule
client; ProjectRulesTab.vue gains a "Project rules" section with inline
create form and per-rule expand/delete
- tests: register count → 18; create_project_rule unit tests (required
fields, title derivation, explicit-title pass-through); applicable_rules
shape tests now include project_rules; trash cascade test updated to
expect 5 executions
S1+S2 (always_on flag + Scribe-first prompt) shipped in 658348f.
S4 (enter_project handshake) follows.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds rulebooks.always_on (migration 0058) and a new list_always_on_rules
MCP tool so a session-start eager pull can fetch standing rules without
needing an active-project notion. Updates _INSTRUCTIONS so Claude calls
the new tool at session start and codifies engineering rules in Scribe
rather than CLAUDE.md / auto-memory.
Seeds FabledSword family rulebook to always_on=true on migrate, matching
its design role as the cross-project standards rulebook.
Frontend: badge in RulebookListPane for always-on rulebooks; toggle in
RulebookDetailPane header bound to a new toggleAlwaysOn store action.
This is S1+S2 of the rules-consolidation plan (Scribe task #508). S3
(project-scoped rules) and S4 (enter_project handshake) follow.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 8 deleted the Python models for these tables; this migration
drops the orphan SQL.
Dropped tables (CASCADE-safe):
conversations, messages, generation_tool_log,
moments + moment_embeddings + moment_people/places/tasks/notes,
pending_curator_actions, push_subscriptions, weather_cache,
rss_item_embeddings (legacy pre-pivot experiment)
Dropped per-user settings: every voice_*, journal_*, briefing_*,
curator_* key, plus default_model, background_model, assistant_name,
auto_consolidate_tasks, chat_retention_days, think_enabled,
rag_default_scope.
Hard cutover — no downgrade. Existing data in these tables is lost;
the spec explicitly accepted this in exchange for a clean schema.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backend foundation for curator-proposed mutations awaiting user
approval. No tools route to this yet — that's C3's job. This commit
just lands the schema and the service API everything else will use.
Migration 0051 — new table:
- id, user_id (CASCADE), conv_id (SET NULL — survives conv deletion).
- action_type (the tool name to replay), target_type/target_id/
target_label (display hints).
- payload (jsonb — the curator's proposed args, replayed verbatim
on approval).
- current_snapshot (jsonb — the target's state at proposal time, so
the review UI can render an honest diff even if other work modified
the entity between proposal and review).
- status ('pending' / 'approved' / 'rejected') + CHECK constraint.
- created_at / reviewed_at.
- Partial index ix_pending_curator_actions_user_pending narrowed to
status='pending' — the Needs Review panel hits this constantly,
history rows just accumulate.
Model: PendingCuratorAction with to_dict() for API serialization.
Service services/pending_actions.py:
- create_pending(...) — called from the curator interceptor (C3).
Accepts an already-fetched current_snapshot so each mutating tool
can capture target state in its own way (notes vs milestones vs
profile have different shapes).
- list_pending(user_id, limit=50) — what the Needs Review panel reads.
- approve(action_id, user_id) — replays via execute_tool and marks
approved on success. Stays pending on replay error so the user
can retry. NOTE: approve passes the request through execute_tool
unchanged for now; C3 will add authority='user' so the upcoming
curator interceptor doesn't re-intercept the replay and loop.
- reject(action_id, user_id) — marks rejected with no execution.
C3 next: wires the curator interceptor (authority='curator' on
execute_tool routes mutating tools to create_pending instead of
running them), adds the mutating tools back to the curator's
allowlist, and updates approve() to pass authority='user'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two-in-one cleanup motivated by the chat hang in dev 2026-05-22.
The crash root cause from the guarded-task traceback:
UnboundLocalError: cannot access local variable 'get_setting'
where it is not associated with a value
File generation_task.py:257, in run_generation
think = (await get_setting(user_id, 'think_enabled', 'false'))...
generation_task.py imports get_setting at module top, but a later
'if voice_mode: from ... import get_setting' block scopes it as a
function-local. When voice_mode=False the local import never runs,
but Python had already flagged get_setting as local for the entire
body — the think_enabled read at line 257 hit UnboundLocalError.
The line itself was dead-weight anyway. With the conversation+curator
architecture: chat ships tools=[] (think on a no-tools pass is pure
latency cost; nothing for the model to reason ABOUT in tool-call
terms), and the curator hardcodes think=False already. The user
setting was a holdover from before the architecture pivot. Removing
it entirely is cleaner than fixing the scoping bug to preserve a
toggle nobody should be using:
- generation_task.py: think hardcoded False. Removed the get_setting
call (which fixes the UnboundLocalError as a side effect).
- SettingsView.vue: dropped the Enable model thinking checkbox, the
thinkEnabled / savingThinkEnabled refs, the saveThinkEnabled
function, and the think_enabled load step.
- Migration 0050: DELETE FROM settings WHERE key='think_enabled'
to clean up any stored rows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The architecture loop closes. Curator extracts beats and writes a
≤240-char summary; the next chat turn loads that summary into the
journal system prompt so the chat model — which has no tools and
cannot retrieve anything itself — gains awareness of recent topics
captured by the curator.
Migration 0049:
- conversations.curator_summary (text, nullable). Last-write-wins; no
history of prior summaries.
models/conversation.py:
- New curator_summary column on Conversation.
services/curator_scheduler.py:
- _stamp_last_run() takes an optional summary kwarg; persists it when
non-empty (clobbering the previous summary). Empty summary keeps
the existing one rather than overwriting useful context with "".
- _sweep() passes result.summary through.
routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> writes curator_summary
alongside last_curator_run_at on success.
services/journal_pipeline.py:
- build_journal_system_prompt() gains an optional `conv_id` param.
When provided, appends a "CURATOR NOTES" block at the end of the
system prompt with the conversation's stored summary. Positioned
after ambient context so the chat model treats it as current
awareness rather than background.
services/llm.py:
- Threads conv_id through to build_journal_system_prompt.
This is the last commit of the conversation+curator architecture
arc (Fable #172):
- Phase 1a (a7002a8): chat=tools[], curator service backend
- Phase 1b (a73dd17): right-rail captures panel + manual trigger
- Phase 2 (83f1676): auto-scheduler every 15 min
- Phase 3 (this): curator summary → chat context feedback loop
Operator can now device-test the architecture end-to-end: have a
journal conversation (model can't lie about tool calls because it
has none), wait for the scheduler or hit "Process captures", see
moments appear in the right rail, then continue the conversation
and notice the chat model staying topic-aware via the summary block.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.
Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
filtered to conversation_type='journal'. The scheduler's candidate
query is "journal AND (NULL OR stale)" so an index narrowed to
journal rows is the right shape — index size stays small even on
instances with many non-journal conversations.
models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.
services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
newest user message is newer than last_curator_run_at (or NULL).
Capped at 20 per sweep so a backlog after downtime doesn't stall
the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
so overlapping ticks can't double-fire on the same conversation.
Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.
routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
on success. Errors don't stamp so the scheduler retries.
What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
the curator's summary lives in the run result but doesn't reach the
chat model.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Kokoro has been stale upstream since April 2025 (`requires_python<3.13`),
which broke the Python 3.14 build. Piper is the active replacement:
maintained by OHF/Home Assistant, depends only on onnxruntime +
pathvalidate (no torch, no spacy, no transformers), and has cp314
support today.
Dockerfile:
- Add `pip install piper-tts` after the STT install.
- Bundle two default voices (en_US-amy-medium, en_US-ryan-medium) into
/opt/piper-voices at build. Additional voices can be downloaded into
/data/voices via the admin UI (separate commit).
- Image add over the STT-only baseline: ~150 MB.
services/tts.py — full rewrite:
- New voice-discovery layer scans /opt/piper-voices + /data/voices for
.onnx + .onnx.json pairs. /data wins over /opt for the same id so
admin-downloaded voices can override bundled defaults.
- Single PiperVoice kept warm; switches via _switch_voice() when the
user changes their voice_tts_voice setting.
- list_voices() returns metadata read from .onnx.json sidecars (label
derived from filename, language, quality, sample_rate).
- synthesise() uses piper's SynthesisConfig; converts kokoro-shaped
`speed` multiplier to piper's `length_scale` (1.0 / speed).
- `voice_blend` parameter accepted but ignored — piper has no blend
equivalent; first entry's voice is used if anything is passed.
- Dropped: HuggingFace commit-hash tracking (~80 lines), the daily
check_for_kokoro_updates task, voice-tensor blending math.
routes/voice.py:
- tts_backend reports "piper" in /api/voice/status.
- /api/voice/voices no longer requires tts_available() — even with
the active voice failed to load, the catalog still lets the user
pick a different one.
- Synthesise request body dropped the voice_blend field; speed and
voice still supported.
alembic 0047_reset_voice_tts_settings:
- Deletes any stored voice_tts_voice (kokoro IDs that don't map to
piper) and voice_tts_blend (no piper equivalent) rows. Both
re-default cleanly on next read.
frontend:
- VoiceBlendEntry type removed from api/client.ts.
- synthesiseSpeech() signature dropped the voiceBlend parameter.
- SettingsView.vue Voice Blend section removed entirely (slider,
preview, slot management). voice_tts_blend save path removed.
- Default voice id changed from "af_heart" to "en_US-amy-medium".
- VoiceEntry gains optional language/quality/sample_rate fields
from the richer piper sidecar metadata.
Voice paths remain lazily guarded — `VOICE_ENABLED=false` (default)
starts the app cleanly regardless of which TTS deps are present.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an empirical surface for evaluating model swaps. One row per
assistant turn captures: model, think_enabled, tools_available,
tools_attempted, tools_succeeded, tools_failed (with error details
as JSONB). Without this, judging whether a new model "actually fires
record_moment when it should" relies on anecdote across user-reported
sessions. With it, the data is queryable directly.
Pieces:
- Migration 0046: generation_tool_log table with user_created and
per-conversation indexes.
- Model: SQLAlchemy GenerationToolLog with to_dict() for plain-dict
consumption outside session scope.
- Service: log_tool_outcomes() normalizes the in-app tool-call shape
(function/result/status) into the split buckets and persists. It
catches its own exceptions — telemetry failure must NEVER affect
the user-facing generation flow. recent_logs() helper for read.
- Integration in run_generation: called once per turn right after
log_generation, fire-and-forget.
- Tests: pure-normalization unit tests using a stub session — no DB
needed in CI. Cover the success/error split, the empty-tool-calls
case, the exception-swallowing contract, and the success=False
edge case where status incorrectly says "success".
No UI for the telemetry yet — internal infrastructure (the operator
is the consumer, not the journal user), which the FabledRulebook
"no UI no ship" explicitly excepts. Query via psql or extend the
Fable MCP later if direct shell access gets tiresome.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md
- pin_kind: NULL=rolling, 'auto'=stability-scan, 'manual'=user-declared.
- pin_label: NULL for rolling; auto-generated for 'auto'; user-supplied
string for 'manual' (may be NULL).
No backfill — every existing row stays rolling. The daily auto-pin scan
will catch up on the first run after deploy.
Spec: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
- description: user-stated goal / initial context for tasks (NULL for
knowledge notes).
- consolidated_at: timestamp of the most recent auto-summary pass (NULL
until first consolidation).
- Migration 0044 backfills description from body for existing rows where
status IS NOT NULL (i.e. tasks). Body left in place; first consolidation
pass will overwrite it.
Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.
The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.
## Schema (migration 0043)
- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
- end_dt valid (end > start) → duration_minutes = total minutes
- end_dt == start → duration_minutes = 0 (zero-duration point)
- end_dt NULL or end_dt < start → duration_minutes = NULL
(the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
API consumers (Flutter app, web frontend, CalDAV sync) keep
receiving the same response shape; they just no longer have a way
to PUT a stored `end_dt` that disagrees with `start_dt`.
## Service layer
- `Event.end_dt` becomes a `@property`. Setting it would require a
setter we deliberately don't define — writes always go through
`duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
reduction. Accepts (start, end_dt, duration_minutes), returns the
canonical `duration_minutes`, raises `ValueError` for negative
durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
`duration_minutes` for ergonomic compat; both convert via
`_normalize_duration`. Update validates the post-update state when
the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
(`start_dt <= date_to`) plus Python-side refinement using the
derived `end_dt`. Avoids Postgres-specific interval arithmetic in
the WHERE clause; refinement runs over a per-user result set so
there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
instead of computing `end - start`. No more negative-timedelta
hazard.
## CalDAV sync (incoming + outgoing)
- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
both convert iCal `DTEND` → `duration_minutes` on the way in.
Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
via the model's derived property. iCal interop is unchanged.
## Behavioral upgrade for `update_event`
Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.
## Tests
`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
duration valid as point event, end-before-start rejected, negative
duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
already passed
- Existing mock-event helper updated to use `duration_minutes`
instead of stored `end_dt`.
44 event-related tests pass; ruff clean.
## Out of scope (separate task)
Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.
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>
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>
The Discuss button on news cards was producing one-shot replies because
the model got the whole trafilatura blob dropped into history with a
canned "summarize and discuss this article" prompt — no length guard, no
prep, no invitation to converse. Large articles got silently truncated by
Ollama; small articles got a tepid reply.
This reworks discuss_article around a three-layer cache:
context_prepared → content_full → fresh trafilatura fetch
First click on a small article fetches once, writes through to both
caches, and passes the body straight into the synthetic read_article
tool-result. First click on a large article additionally runs a parallel
map step (services/article_context.py) that chunks the body on paragraph
boundaries, summarizes each ~8k chunk to ~300 words of dense factual
prose via the background model, and concatenates the summaries under
section headers — all pinned to num_ctx=16384 so the map step doesn't
itself fall victim to silent truncation. Repeat clicks on either path
skip straight to the chat turn.
The canned summary prompt is replaced with a conversational seed that
invites the user into an actual discussion rather than a one-shot
synopsis, matching the goal of "have a conversation about an article,
not just read it."
discuss_topic is intentionally left untouched — it's the multi-article
aggregation path and needs a separate rework. Follow-up task will decide
whether to retire it or rework it on the cached-context approach.
Closes task #106.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- RRULE expansion: list_events now expands recurring events into
individual occurrences within the query window using python-dateutil
- CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route;
imports remote events into the internal store by caldav_uid
- Past event search: search_events accepts include_past=true to search
historical events; exposed in the LLM tool definition
- Internal reminders: migration 0037 adds reminder_minutes +
reminder_sent_at columns; event_scheduler.py checks every 5 min and
fires push notifications; CalDAV sync job runs hourly
- reminder_minutes now stored and returned in create/update routes + tools
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
to the LLM and uses 8192 num_ctx to accommodate the larger prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TaskStatus enum was missing 'cancelled' — the LLM tried to use it and
hit TaskStatus("cancelled") raising ValueError → 500. Added the value,
a migration to extend the task_status Postgres enum, and proper 400
validation guards on both create and update task routes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Also comments out nvidia GPU reservation in docker-compose.yml
(no nvidia-container-toolkit on this host).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add session_version to users table. Sessions now carry session_version
alongside user_id; @login_required rejects any session where the version
doesn't match the DB value.
- migration 0024: session_version INTEGER NOT NULL DEFAULT 1
- models/user.py: session_version Mapped[int] column
- auth.py: version mismatch → session.clear() + 401
- services/auth.py: change_password and reset_password_with_token both
increment session_version, evicting all other live sessions
- routes/auth.py: login/register/oauth_callback store session_version;
update_password rehydrates current session with new version so the
user who changed their password stays logged in
Existing sessions will require one re-login after the migration runs.
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>
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>
- 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>
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>
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>
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>