MCP clients see tools namespaced by the server's local name already
(mcp__<server>__<tool>), so the fable_ prefix on every tool name was
redundant and ate tokens in the model's tool list.
Tools renamed (34 total):
fable_search → search
fable_list_notes / get_note / create_note / update_note / delete_note → list_notes / ...
fable_list_tasks / get_task / create_task / update_task / add_task_log → list_tasks / ...
fable_list_projects / get_project / create_project / update_project → list_projects / ...
fable_list_milestones / create_milestone / update_milestone → list_milestones / ...
fable_list_events / create_event / get_event / update_event / delete_event → list_events / ...
fable_list_tags → list_tags
fable_get_recent → get_recent
fable_list_persons / create_person / update_person → list_persons / ...
fable_list_places / create_place / update_place → list_places / ...
fable_list_lists / create_list / update_list → list_lists / ...
Also rebranded in MCP scope:
FastMCP("fable", ...) → FastMCP("scribe", ...)
auth realm "fable-mcp" → "scribe-mcp"
ASGI scope key fable_user_id → scribe_user_id
ContextVar label fable_mcp_user_id → scribe_mcp_user_id
Tool docstrings "in Fable" / "Fable task" → "in Scribe" / "Scribe task"
Server _INSTRUCTIONS prose
Deliberately kept:
- The internal Python package name `fabledassistant` (per project naming
convention — internal stays).
- "Fabled Scribe" as the official product/brand name (page footer,
smtp_from_name default).
- References to the legacy `fable-mcp/` standalone package in docstrings
explaining what we ported from — accurate until that directory is
deleted in Phase 10.
Client impact: existing MCP registrations need
claude mcp remove <name> && claude mcp add ...
once with a freshly-copied snippet from Settings → MCP Access. Claude
Code then re-discovers tools on connect — old conversations that
referenced fable_* tool names will see "tool not found" on those calls
until updated.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings → MCP Access now lets you tune the generated snippet:
- Server name input (default: 'scribe', stored per browser in
localStorage). The name appears both in the claude mcp add
command and as the JSON key in claude_desktop_config.json's
mcpServers map.
- Scope dropdown: user / project / local. Drives the --scope
flag in the claude mcp add snippet. Picks 'project' to commit
the server into the current repo's .mcp.json.
User-visible 'Fable' → 'Scribe' in MCP Access tab copy (lead text,
Claude Desktop step). Branding pivot in the rest of the app
(assistant_name placeholder, SMTP defaults, version line, etc.) is
deferred — chat/journal copy is going away in Phase 7 anyway.
Deliberately NOT touched:
- Tool names (fable_*) — protocol-level identifiers; renaming
breaks any Claude session, agent, or automation that referenced
them. Warrants its own phase.
- mcp/server.py: FastMCP('fable', ...) server name — same reason.
- Internal package name fabledassistant — per the project's
naming convention (CLAUDE.md memory), internal stays.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the apikeys settings tab for the new MCP architecture:
- Tab label: 'API Keys' → 'MCP Access'
- Shows the in-app MCP URL (<origin>/mcp) with a copy button
- Claude Code snippet uses --transport http + --url + --header
- Claude Desktop snippet uses {url, headers: {Authorization}}
- Drops the wheel-download flow, the 'Other' client tab, and the
stdio env file / Claude config download helpers — those were
for the standalone fable-mcp package which goes away in phase 8
The api_keys backend stays unchanged — keys double as bearer tokens
for the /mcp endpoint via the existing auth.py middleware.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Ollama HTTP get_embedding with a fastembed.TextEmbedding
singleton loaded lazily on first call. Model: BAAI/bge-small-en-v1.5
(384-dim), cached to /data/fastembed-cache.
Public API unchanged:
- get_embedding(text, model=None) — `model` now silently ignored
- upsert_note_embedding
- semantic_search_notes
- backfill_note_embeddings
_cosine_similarity gains a defensive length-mismatch check so any
stale 768-dim row that survived the migration is treated as 0.0
similarity rather than crashing zip().
The Ollama client dep stays in pyproject for now (other services still
use it); Phase 7 removes it once chat/journal/curator are gone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Nine tools — list/create/update for each of person, place, list.
Get and delete reuse fable_get_note / fable_delete_note (typed
entities share the Note model).
Lists: the wrappers accept an `items: list[str]` for ergonomics and
translate to the {text, checked} dict shape that
services/knowledge.py and KnowledgeView.vue expect. items=[] clears;
items=None leaves unchanged.
Updates do an explicit get → merge → update round trip so updating
one typed field doesn't clobber the others stored alongside it in
entity_meta (which is a single JSONB column).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two cross-type bootstrap tools:
- fable_list_tags: tag vocabulary with usage counts, top-N by count.
Aggregation in Python (not SQL UNNEST) — trivial perf cost at
personal scale, much easier to test.
- fable_get_recent: most-recently-touched items across notes, tasks,
projects, events. Useful for Claude to ask 'what was I working on
recently' at the start of a conversation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five new tools (events weren't in fable-mcp before). Split
start_date + start_time inputs combine into a naive datetime that
services/events.py interprets in the user's local timezone.
Sentinels for update:
- empty strings → leave unchanged
- duration_minutes=-1 → leave unchanged
- duration_minutes=0 → set to point event (NULL duration)
- start_date/start_time must BOTH be set to move the event
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tools matching existing fable-mcp contracts:
- fable_list/get/create/update_project (no delete; archive via status)
- fable_list/create/update_milestone (no get; no delete)
LLM-era similarity-check / 'confirmed' guard for create_project is
NOT replicated — Claude doesn't need it. The service's auto-summary
regeneration side effect (services.projects.update_project) stays
for now; gets removed in Phase 7 along with all other LLM code.
Notable sentinels:
- update_milestone: order_index=-1 means "leave unchanged" (0 is valid)
- create_milestone: description="" becomes None at the service layer
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools wrapping services/notes.py with is_task=True (tasks are
notes with non-null status) plus services/task_logs.create_log for
add_task_log. Matches existing fable-mcp contracts. No delete_task —
preserves existing surface; cancel by updating status to "cancelled".
fable_get_task enriches with parent_title (extra service call when
parent_id is set), matching the existing route's behavior.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tools wrapping services/notes.py with is_task=False. Signatures
mirror the existing fable-mcp note tool contracts so Claude usage is
unchanged.
Key behavior the tests pin down:
- list_notes repackages (rows, total) tuple into {notes, total}
- tag=""/search_text="" are "no filter" sentinels
- update_note ONLY sends non-default fields to the service (the
main risk: a default empty string overwriting real data)
- tags=[] is an explicit clear; tags=None is "leave unchanged"
- project_id=0 on create => orphan; on update => leave unchanged
(preserved limitation from existing fable-mcp)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establishes the tool pattern: each tool module exposes register(mcp),
register_all() aggregates them, build_mcp_server() calls register_all.
fable_search mirrors the existing fable-mcp contract (q/content_type/limit
in; {results, total} out) but calls services.embeddings.semantic_search_notes
directly instead of going over HTTP. User comes from mcp.current_user_id().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds mcp._context.current_user_id() backed by a ContextVar. The ASGI
auth middleware sets it before dispatching to FastMCP and resets it
on the way out, so tool handlers can read the acting user without
re-parsing the request scope.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Driving Quart's full request pipeline via a hand-rolled ASGI scope
(no lifespan startup, no hypercorn-provided state) doesn't produce
a response. The 3 remaining tests cover the actual MCP middleware
behavior. The bypass property is implicit — if the middleware ate
non-/mcp requests, every existing /api/* test would fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quart's test_client expects its request pipeline to populate
app._preserved_context. Our /mcp middleware deliberately bypasses
that pipeline (forwarding straight to FastMCP), so test_client's
teardown blew up with AttributeError. The middleware is correct;
the test harness was wrong.
Build raw ASGI scope/receive/send and call app.asgi_app directly —
which is what production hypercorn does anyway.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires FastMCP's streamable-HTTP ASGI sub-app into the Quart app via
asgi_app replacement. Requests under /mcp are stripped, auth-checked
against api_keys, and forwarded to FastMCP with fable_user_id set on
the ASGI scope. All other paths pass through to the original Quart
dispatch unchanged.
Tests cover the three auth paths (no header, invalid token, valid
token) plus a regression check that non-/mcp paths bypass the MCP
dispatch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Thin parser over the existing api_keys lookup. Strips the Bearer
prefix, validates the token via services/api_keys.lookup_key (which
already filters revoked keys and updates last_used_at), and returns
the user_id for the in-flight MCP request.
Tests follow the existing mock-async_session pattern in
test_api_keys.py rather than introducing a real DB fixture.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Empty FastMCP instance with the post-pivot instructions block. Tools
get registered in phases 2 and 3; ASGI mounting + bearer-auth comes
in task 1.4.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First step of the MCP-first pivot. Adds the official Anthropic MCP SDK
so we can mount a FastMCP HTTP endpoint inside the main Quart app.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous diagnostic instrumentation only wrote to stdout — fine for
'tail the logs while debugging', useless for 'crash happened at 3am
and Docker rotated the logs by morning'. This commit makes the
diagnostic state durable across container restart, OOM-kill, and log
rotation by writing to the mounted /data volume.
Four artifacts in /data/diagnostics/:
- current.json — overwritten atomically every heartbeat. Holds the
last known good snapshot (rss, asyncio_tasks, db_pool, curator_busy,
uptime, pid). Post-crash, this file alone tells you what the app
was doing 0-60 seconds before it died. Atomic write (tmp+rename)
so a crash mid-write can't leave a half-written file.
- last_shutdown.json — written when SIGTERM/SIGINT is caught OR
after_serving fires cleanly. If this file's mtime is older than
current.json's, the previous run died WITHOUT calling shutdown
(== SIGKILL, OOM-kill, or container hard-stop).
- last_exception.json — written when the asyncio exception hook
fires. Includes task name, coro name, exception type and message
alongside the resource snapshot.
- diag.log + diag.log.1..5 — rotating file log (10 MB × 5 backups
= 50 MB cap) containing every heartbeat, signal, and exception.
Separate from the app's stdout logger so Docker log rotation
can't take it out.
- previous_run.json — written at startup IF the post-mortem detects
the previous run died abruptly. Includes the abrupt-death snapshot
preserved for retrospection, so a recurring crash pattern can be
diffed over time.
Post-mortem at startup:
- Reads current.json + last_shutdown.json mtimes.
- If current.json is newer (== no clean shutdown happened after the
last heartbeat), logs a WARNING: 'PREVIOUS RUN DIED ABRUPTLY. Last
heartbeat was Xs before this startup. Last-known state: {...}'
- The warning lands in BOTH stdout AND the persistent diag.log, so
the operator notices it even if they only check one place.
- Stashes the abrupt-death snapshot in previous_run.json for later.
How the operator uses this after a crash:
1. cat /data/diagnostics/current.json -- last known good state
2. cat /data/diagnostics/last_shutdown.json -- did it shut down cleanly?
3. cat /data/diagnostics/last_exception.json -- any unhandled exception?
4. tail -100 /data/diagnostics/diag.log -- the lead-up
If current is newer than last_shutdown and last_exception doesn't
exist: SIGKILL or OOM (uncatchable). Check docker exit code 137
and host dmesg for oom-killer lines.
If last_exception.json exists: a background task crashed. The
traceback in the file names the coro.
If current.json's rss_mb was climbing across heartbeats: memory
leak / OOM trajectory. Bound the cause to whatever was active.
If current.json's db_pool checked_out was climbing: connection leak.
Look for code paths opening async_session() without exiting
'async with'.
If curator_busy=true across multiple heartbeats: curator hung on
Ollama. Restart Ollama or the Scribe stack to release the lock.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Recurring app/db crashes with no clear cause in existing logs.
Adds three crash-class indicators with minimal overhead (~1 log
line/min, 0.1ms work per heartbeat).
services/diagnostics.py:
1. **Heartbeat** every 60s logs a snapshot:
- RSS memory (from /proc/self/status — no deps).
- asyncio task count.
- DB pool: size / checked_in / checked_out / overflow.
- Curator busy state (from is_curator_running()).
- Uptime.
A sudden silence in heartbeats bounds the crash time to within
60s. The last snapshot before silence usually rules in or out:
memory growth -> OOM, pool exhaustion -> connection leak, hung
curator -> stuck async task.
2. **Signal handler** for SIGTERM/SIGINT logs the signal name +
final snapshot before letting Hypercorn handle the actual
shutdown. Distinguishes 'orderly shutdown via signal X' from
'silent log gap then container exit code 137' (SIGKILL / OOM-kill
are uncatchable; their absence in our log IS the diagnostic).
3. **Asyncio exception hook** logs full tracebacks for unhandled
task exceptions with the task/coro name. Default behaviour
swallows these silently — exactly the pattern that locked us
out of chat at 409 for an hour back on 2026-05-22 before we
added the guard around run_generation.
app.py wires start_diagnostics() into before_serving and
stop_diagnostics() into after_serving. stop_diagnostics emits one
final snapshot so the silence that follows is intentional, not a
crash.
How to use the new logs to diagnose:
- App restarts with 'received SIGTERM' in the last lines:
Orderly shutdown (docker stop / swarm restart / manual). Look
upstream for who issued it.
- App restarts with no shutdown line, last heartbeat 30+s before:
Likely SIGKILL — OOM-kill or container resource limit. Check
'docker ps -a' for exit code 137, or 'dmesg | grep -i kill' on host.
- App restarts with no shutdown line, heartbeat showed climbing
RSS: Memory leak. Snapshot the last heartbeat's MB value vs
earlier — if it doubled over hours, OOM is the cause.
- App restarts, db_pool checked_out kept growing: Connection leak.
Look for code paths that open async_session() but never exit
the 'async with' block.
- App seemed alive but stopped responding to requests, heartbeats
continued: Curator hung holding _CURATOR_RUN_LOCK. Check
curator_busy=true across multiple heartbeats — if stuck >5min,
the Ollama call hung. Restart Ollama or the Scribe stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three coordinated changes per operator request 2026-05-24:
1. Settings UI rename matching the language we actually use:
- Chat Model -> Chat & Voice Model
- Worker Model -> Curator Model
Setting KEYS (default_model / background_model) unchanged on
purpose; renaming them requires a migration touching 50+ call
sites for purely UX-facing benefit.
2. Settings UI help text rewritten:
- Chat & Voice: documents that it handles chat AND small
conversational automations (titles, tags). Recommends
OLLAMA_NUM_PARALLEL=2+ on the Ollama server so background
automations get their own KV-cache slot and don't evict
the chat model's working state.
- Curator: notes the app enforces SERIAL execution regardless
of NUM_PARALLEL — only one curator pass runs at a time. This
matters most for 70b CPU models where a second instance
would waste system RAM.
3. Enforce serial curator execution globally:
- New module-level _CURATOR_RUN_LOCK in services/curator.py.
- run_curator_for_conversation now wraps its body in 'async
with _CURATOR_RUN_LOCK' — every entry point (scheduler sweep,
manual route trigger, future hooks) is serialized through it.
- is_curator_running() helper exposes the lock state.
- routes/journal.py manual trigger checks is_curator_running()
first and returns 409 {busy: true} immediately rather than
blocking the HTTP request for minutes waiting for a 70b CPU
pass to finish. The user can retry once the curator clears.
Why a 409 instead of queue: a curator pass on a 70b CPU model
can take 5+ minutes. Tying up an HTTP worker that long is bad;
making the user wait without feedback is worse. 409 surfaces
the busy state immediately and the user retries when they want.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three prompt fixes addressing real failure modes observed in dev
journal data (conv 312, May 23):
curator.py — JOURNAL_CALIBRATION:
1. Strengthen the one-call-per-beat rule. Previous wording said 'do
not collapse multiple beats' but didn't explicitly forbid the
reverse: multiple record_moment calls for the SAME beat with
different phrasings. Observed in moments 7+8, 9+10, 11+14, 12+15,
13+16 — same content captured twice within a single curator pass.
New rule: explicit 'EXACTLY ONE tool call per distinct beat', plus
a 'check whether you already recorded this beat this turn' step.
2. Rewrite the save_person/save_place guidance. Previous wording
over-emphasized 'better to skip than invent' to the point that
the curator ignored explicit user introductions like 'my father's
name is Dale and my mother's name is Lynn, we went to Olive Garden'
— no save_person for Dale or Lynn, no save_place for Olive Garden.
The conservative-skip rule should apply to AMBIGUOUS mentions
('a friend told me'), not to explicit introductions. New rule
spells this out with positive examples.
journal_prep.py — _PREP_SYSTEM_PROMPT:
Extend the no-invent guards. The existing rule covered weather
specifically; today's prep added new fabrications:
- 'tasks due today include X' when tasks_due_today is empty and X is
actually 64 days overdue
- 'at 1:00 PM' when no time exists in the data
- 'currently in progress' applied to tasks where status is 'todo'
Three new rules: (a) never invent a task's due status — frame by the
bucket it actually appears under; (b) never invent times of day —
tasks have dates, not times; (c) never paraphrase a task's status
to something the data doesn't say.
journal_pipeline.py — JOURNAL_CALIBRATION:
1. Promote the one-question rule from buried bullet to top of the
prompt, with stronger phrasing ('ONE question per reply, MAXIMUM
... if you find yourself writing a second question mark, delete
it'). Observed: 3 questions per reply in every conv 312 assistant
turn ('how was it? what'd you order? did she enjoy it?').
2. Add explicit no-fishing rule: don't ask the user to share pictures,
send details, fetch information for the model. Reacts to what they
actually said, not what they didn't. Observed: 'do you have any
pictures you can share?' on msg 789.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Chat and background model roles effectively swapped during the
conversation+curator pivot, but call sites still used OLD routing.
This commit re-routes each call to the model whose new role fits.
Moved to background_model (worker — heavy, deliberate):
- services/journal_prep.py: daily prep generation.
- services/user_profile.py: observation consolidation.
Moved to default_model (chat — small, fast):
- services/chat.py save_response_as_note: note title generation.
- services/tag_suggestions.py: tag suggestions.
Already routed correctly (unchanged): curator, closeout, consolidation,
project summaries, history summarization.
SettingsView.vue: help text rewritten for both model fields to
describe new roles. Background Model UI label renamed to Worker
Model so the heavier role is visible from the picker. Warning copy
updated to recommend OLLAMA_MAX_LOADED_MODELS=2+ so chat and worker
can stay loaded simultaneously.
Schema names default_model and background_model unchanged on purpose
(renaming requires migration + touches ~50 call sites for UX-only gain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The frontend half of the review queue. Closes the curator approval
loop end-to-end.
JournalView.vue:
- New 'Needs Review' section in the right rail, ABOVE the Captures
panel (per the design decision: pending stands out, captures are
ambient). Hidden entirely when nothing is pending so the rail stays
calm.
- Each pending action renders as a card:
- Header: action_type chip (e.g. 'update_note') + human-readable
title built from pendingTitle() ('Update Famous Supply network
restage', 'Delete Old grocery list', etc.).
- Diff body:
- For deletes: a red 'Permanent delete' warning.
- For updates: field-level diff rows (field name | old | → | new)
computed by pendingDiff(), which compares the curator's payload
against the snapshot taken at proposal time. Skips lookup-only
params (query, task, project, milestone, confirmed) so the diff
shows only what'd actually change.
- Empty-diff fallback for tools without snapshot helpers.
- Approve / Reject buttons. Disabled while a request is in flight
via reviewingIds Set so double-clicks can't fire twice.
- Approve calls approvePendingAction → server replays the original
tool call with authority='user'; toast on success/error.
- Reject calls rejectPendingAction → marks rejected, no execution.
- Both actions refresh the pending list AND the moments list (since
approving an update_note could affect what shows in captures).
- loadPendingActions() also runs after every manual curator trigger
and on initial mount, so the panel reflects current state without
manual page refresh.
CSS: warm-tinted panel using --color-warning so the section visually
distinguishes from the neutral captures feed below. Approve button
in success-green, reject in muted. Diff rows use a grid layout with
old-value strikethrough and an arrow separator.
End-to-end demo loop:
1. Have a journal conversation that includes 'mark the Famous Supply
task as done'.
2. Wait for curator sweep or hit 'Process captures'.
3. Curator search_notes('Famous Supply'), then update_note(...) is
intercepted by execute_tool(authority='curator') and queued.
4. The Needs Review panel shows: 'Update task Famous Supply network
restage' with status diff todo→done.
5. Click Approve → execute_tool replays with authority='user' →
the task moves to done. Card disappears from Needs Review.
This is the last C* commit in the queue. The curator now has a safe
path to mutate user data via proposals, with the user firmly in the
loop on every change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The HTTP surface for the review queue. Three endpoints, all under
the existing /api/journal blueprint to keep the journal-related routes
together:
- GET /api/journal/pending — list current user's pending actions.
- POST /api/journal/pending/<id>/approve — replay the proposed tool
call via execute_tool(authority='user'). On success, marks
the row 'approved'; on replay error, leaves it pending so
the user can retry.
- POST /api/journal/pending/<id>/reject — marks 'rejected' with no
execution.
Each route is a thin wrapper around services/pending_actions and
delegates user-scoping to the service (which checks user_id on every
load — actions are private to the proposer).
api/client.ts:
- PendingCuratorAction interface mirroring the backend dict shape:
id, user_id, conv_id, action_type, target_type/id/label, payload,
current_snapshot, status, timestamps.
- listPendingActions / approvePendingAction / rejectPendingAction
helpers for the upcoming Needs Review panel.
C5 next: the panel itself.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The interceptor that closes the loop on the curator review queue.
With this commit, the curator can call update_note / update_milestone
/ update_project / update_profile / delete_note — those calls are
caught by execute_tool's authority='curator' path, snapshotted, and
written to pending_curator_actions for the user to approve or reject
later. Additive tools still run immediately.
services/tools/_registry.py:
- New _CURATOR_MUTATING_TOOLS frozenset: {update_note, update_milestone,
update_project, update_profile, delete_note}. update_event /
delete_event intentionally excluded — calendar events should always
be explicit user intent.
- execute_tool gains a keyword-only parameter, defaulting
to 'user'. Default behaviour is unchanged; existing callers keep
working without changes.
- When authority='curator' AND tool is in _CURATOR_MUTATING_TOOLS,
_queue_for_review captures a snapshot of the target via a per-tool
helper and writes a pending action. Returns {success:true,
pending:true, action_id:N, message:...} so the curator sees the
call as 'completed' for its bookkeeping.
- Per-tool snapshot helpers: _snapshot_note (covers update_note +
delete_note — uses the same fuzzy match update_note_tool uses, so
the snapshot reflects what'd actually be mutated), _snapshot_milestone,
_snapshot_project, _snapshot_profile. Snapshot capture is best-effort
— failure logs but still queues with empty snapshot so a curator
proposal never silently drops.
services/curator.py:
- Allowlist now includes the five mutating tools. They're safe to expose
because execute_tool intercepts them; the curator can propose without
being able to actually mutate.
- The execute_tool call now passes authority='curator'.
- System prompt explicitly authorizes the proposal pattern:
'update_note', 'update_milestone', 'update_project', 'update_profile',
'delete_note' are described as proposing tools that wait for user
approval. 'Don't try to update or delete anything' line removed.
services/pending_actions.py:
- approve() now passes authority='user' on the replay so the curator
interceptor doesn't re-route the replay back into pending and create
an infinite loop.
What's left in the queue:
- C4: API routes (list/approve/reject endpoints).
- C5: Frontend Needs Review panel.
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>
Layer 2 of the surfacing strategy (per 2026-05-23 design discussion).
The curator already has search_notes / search_journal / search_projects
in its allowlist for entity resolution; this commit just directs it
to use those searches more broadly — to surface relevant past work
that connects to today's beats.
Specifically, the system prompt now instructs the curator to:
- Search for projects/topics/people the user mentions, even when not
strictly needed for record_moment entity linking.
- Weave 1-2 short references to relevant past entries into the final
summary line, when they connect meaningfully to today's beats.
The summary feeds back into the chat model's system prompt on the
next turn (per Phase 3 of the architecture), so the chat model gains
contextual awareness of related past work without needing tools to
retrieve it itself.
Light explicit guardrails in the prompt: don't enumerate (avoid 'found
5 related notes'), don't invent references (only mention what was
actually retrieved), don't force a connection when nothing relevant
turns up.
This is the prompt-only Layer 2. Layer 1 (always-on RAG injection
into chat context) was already in place. Layer 3 (dedicated 'you
might want to revisit' surface in the right rail) is deliberately
deferred until 1+2 are observed in practice.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related tightenings to the curator's behavior, both driven by user
questions about scope (2026-05-23):
1. **Tighten the prompt to extract beats only from User: lines.**
The transcript shows each message prefixed with role (User: / Assistant:).
The previous prompt instructed the model to capture beats but didn't
explicitly forbid using Assistant: content as a source. A small or
medium model could read 'It sounds like you had coffee with Sarah'
from an Assistant: line and turn it into a moment, even though that's
the assistant paraphrasing the user — not a user statement.
New prompt explicitly: Only User: lines are journal entries. Assistant:
lines are context for disambiguation only. Never create a record from
content that appears only in Assistant: text.
2. **Additive-only tool allowlist for the curator.**
The curator previously had access to the full journal tool set —
including update_*, delete_*, create_event, set_rag_scope, etc. The
architecture removed tools from the chat for exactly the reason that
confidently-wrong tool calls corrupt user data; the curator faces
the same risk async. Filtering the tool list at curator-time keeps
the boundary tight even if the system prompt fails to dissuade the
model from hallucinated tool names.
New _CURATOR_ALLOWED_TOOLS frozenset includes:
- Additive primary work: record_moment, create_note (handles both
notes and tasks via status), log_work (appends to existing task
timeline — additive on its own row), save_person, save_place,
create_project, create_milestone.
- Read-only helpers needed for entity resolution: search_notes,
search_projects, search_journal, list_tasks, list_projects,
list_milestones, read_note, get_project, get_profile.
Explicitly excluded: every update_*, every delete_*, create_event
(calendar events need explicit user intent, not curator inference),
set_rag_scope, lookup/research_topic/search_images (different
surface entirely).
Two-layer enforcement: the system prompt lists what's available and
forbids the rest, AND the actual tools list passed to Ollama is
filtered to the allowlist. So even if the model hallucinates a
forbidden tool name, the call can't fire — execute_tool returns
'Unknown tool: <name>'.
Bonus cleanup: _format_transcript now skips system and tool-role
messages. They were noise for the curator's task (system prompts
are instructions, tool results are JSON from prior calls). The
narrowed transcript matches the contract the prompt enforces.
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>
Two related reliability fixes.
1. routes/chat.py — guard run_generation against uncaught exceptions.
run_generation is launched with asyncio.create_task(); any exception
raised inside the coroutine is silently swallowed by the event loop,
the buffer stays in GenerationState.RUNNING forever, and every
subsequent POST /api/chat/conversations/<id>/messages returns 409
'Generation already in progress' — locking the user out of the chat
with no log trail.
Observed in dev 2026-05-22: assistant message 768 created at 20:36:59
with status=generating, stayed in that state for an hour+, and four
follow-up message attempts returned 409 instantly. The generation
task hung before any internal log line could fire, so the only
diagnostic was the 409 responses themselves.
Wrap run_generation in _run_generation_guarded() that catches
exceptions, logs with full traceback, transitions the buffer to
ERRORED, emits a final 'done' SSE event so any active stream
client closes cleanly, and marks the assistant message status=error
in the DB. After this, a stuck conversation recovers on its own
the next time the user sends a message — no manual DB poke needed.
2. services/curator_scheduler.py — pass last_curator_run_at as 'since'
to the curator so each sweep only sees messages added after the
previous successful pass.
Previously the scheduler called run_curator_for_conversation(conv_id)
with no 'since' argument, so the curator defaulted to its 24h
lookback window. Within an active journal session that meant every
15-min sweep re-extracted beats from messages already captured
on prior sweeps — producing duplicate moments.
_candidate_conversations() now returns (conv_id, last_curator_run_at)
tuples; _sweep() threads the timestamp through. First-run case
(last_curator_run_at IS NULL) falls back to the curator's default
24h window, which is what we want — process recent backlog on
first contact, then only deltas after.
Manual trigger path (POST /api/journal/curator/run/<conv_id>) is
intentionally NOT changed; it still passes since=None so the
24h re-sweep behaviour is preserved for ad-hoc 'reprocess today'
clicks from the UI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two architectural bugs in the conversation+curator rollout that
explain the no-response chat in dev:
1. Journal system prompt still instructed tool calls.
JOURNAL_CALIBRATION instructed the model to CALL record_moment,
search_notes, save_person, etc. — but the chat surface ships tools=[]
per the new architecture. The model received contradictory orders
('use these tools' + 'you have no tools') and produced either empty
output or tool-call-shaped text that gets stripped to empty content,
surfacing as status=error or stuck status=generating messages.
Replaced with a chat-only calibration: ~25 lines focused on tone,
length, anti-coaching, and the load-bearing rule 'never claim to
have done anything for the user' (the curator handles capture
silently and separately). JOURNAL_PERSONA also rewritten to drop
the 'use tools to act on their behalf' line.
2. Pre-warm warmed Config.OLLAMA_MODEL ahead of user's real choice.
_pull_model(Config.OLLAMA_MODEL, warm=True) at boot pushed the
system default (qwen3:latest) into VRAM before _warm_user_models()
ran for each user's actual default_model setting. On a single-GPU
setup the second warm could swap the first out — so the user's
chat model wasn't necessarily resident when their first message
landed. Now we just pull the supporting models without warming
them; only user-configured chat models get warm.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/api/journal/moments takes date_from + date_to query params, not the
single 'date' name the frontend was sending. Filter was silently
ignored; the panel showed every moment in the database ordered by
recency, making it look like a weird recap of past events instead of
today's captures.
No backend change; just send the right param names.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Buried smoking gun: every CI run since the ci-python:3.14 migration
has silently failed to push the `:dev` tag. The build logs for commit
2a374d9 show:
/var/run/act/workflow/tags.sh: 4: [[: not found
/var/run/act/workflow/tags.sh: 6: [[: not found
act_runner invokes the workflow's `run:` block with `sh -e` (dash on
Debian-based ci-python:3.14, NOT bash). The original bash-only `[[ ]]`
syntax failed silently, the `:dev` tag never got appended to TAGS,
and only the SHA-tagged image was pushed. The `:dev` tag in the
registry has been stuck on whatever build last managed to push it —
likely back when CI ran on a bash-y Ubuntu runner before the migration.
This is why the deployed stack has been running a stale image despite
multiple successful "CI passed" runs: it pulls `:dev`, and `:dev` was
months out of date.
POSIX `case` is dash-compatible AND bash-compatible. Same intent
(decide which extra tags to append based on ref); no behaviour change
other than actually executing correctly.
This commit itself touches .forgejo/workflows/ci.yml, so it triggers
a fresh CI run that — for the first time in a while — should push
both :<sha> AND :dev. After this lands, redeploying the stack will
finally pull the recent code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets you re-run CI from the Forgejo Actions UI without needing a
trivial commit. Useful when:
- An image has been built but the deployed stack didn't pick it up
(re-run forces a fresh push + any post-CI hooks fire again).
- A transient upstream issue caused a build to fail (HF download
flake during the voice-bundle step, registry hiccup, etc.) and
re-running against the same source produces different behaviour.
This commit itself touches .forgejo/workflows/ci.yml so it triggers
a build by the normal paths rule, giving you a fresh :dev image
right now in addition to enabling future manual re-runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vue's template parser doesn't handle JS-style \\' escaping inside
double-quoted attribute values, so `today\\'s` produced a compiler
crash during the production frontend build. Rephrased to avoid the
apostrophe entirely. No functional change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The voice_library regex's purpose is to prevent path traversal and
filter structurally-malformed IDs, not to enforce the HF catalog's
lowercase-language convention. Asserting that EN_US-amy-medium is
rejected was a category error — uppercase variants pass the regex
but would 404 at install time against HF, which is a harmless dead
end, not a security gap. Comment in the test now explains the scope.
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>
Frontend half of the conversation+curator architecture. Pairs with the
backend in commit a7002a8. With this commit, you can have a journal
conversation (chat model has no tools, doesn't try to capture), then
press a button and see what the curator extracts.
JournalView.vue:
- New "Captures" section in the right rail, above the existing
"Upcoming" events block. Shows moments from the selected day with
timestamp, content, and entity/task/note chips.
- "Process captures" button (Sparkles icon). Disabled for non-today
days because we're not back-running the curator over historical
conversations. Toast on success/failure with timing + tool-call
count from the CuratorRunResult.
- Captures auto-load on day change AND immediately after a curator
run completes — the right rail reflects current state without a
page reload.
- Bound CSS scoped to the rail: cards with a primary-color left
border, monospaced timestamps, chips for people/places/tasks/notes.
api/client.ts:
- CuratorRunResult type matching the backend dataclass.
- runJournalCurator(convId) helper.
- Pass empty body to apiPost() to satisfy the 2-arg signature
(caller-side fix, not a backend change).
What's not in this commit (deferred):
- The captures panel doesn't show captures from days where the curator
hasn't run yet, even if they would later be captured. Visible only
AFTER a curator pass. (Phase 2's scheduler closes this gap by
running automatically.)
- No edit/delete affordances on captures yet — that comes when we
add the moment-editing UI (out of scope for the conversation+curator
architecture commit chain).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend half of the conversation+curator architecture (Fable #172).
Decouples the journal chat surface from tool calling: the chat model
now sees `tools=[]` and just talks, while a separate curator pass
extracts beats and fires the tool calls.
services/generation_task.py:
- When conversation_type == "journal", pass `tools=[]` to Ollama
regardless of what the journal tool set would normally provide.
The chat model literally cannot fire record_moment / create_task /
etc., so it cannot lie about firing them — the primary failure
mode this architecture removes.
services/curator.py (new):
- `run_curator_for_conversation(conv_id, since=None)` loads recent
messages, builds a curator-specific system prompt (extract beats,
emit tool calls, optionally a one-line summary), and iterates the
Ollama tool-call loop using the user's background_model so the
chat model's KV cache survives.
- Same tool registry as a normal journal conversation
(record_moment, search_notes, update_task, create_task,
save_person, save_place, etc.). The curator chooses naturally
among them; no need for a separate curator-specific filter.
- Returns CuratorRunResult with per-call status + a summary line.
- Caps at 4 tool-call rounds — bounded task (extract beats from a
fixed transcript), shouldn't need more.
- Errors land in result.error rather than raising; the manual
trigger surface (and later the scheduler) want a structured
result, not exceptions.
routes/journal.py:
- New POST /api/journal/curator/run/<conv_id> for manual triggers.
Validates conv ownership before running. Returns the
CuratorRunResult dict so the UI can show what was captured.
What's not in this commit (deferred to later phases):
- The scheduler that auto-runs the curator (phase 2 — adds the
`conversations.last_curator_run_at` column + APScheduler job).
- Curator → chat feedback loop (phase 3 — summary gets injected
into subsequent chat system prompts).
- Right-rail captures panel in JournalView (phase 1b — pure frontend
work, separate commit for clean review).
- Research surface separation (phase 4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on the kokoro→piper swap (B1), this adds the admin-side
voice management story so additional voices can be installed without
rebuilding the image. The bundled two voices stay as immediate defaults;
everything else is opt-in via a one-click install from the catalog.
Backend (services/voice_library.py):
- fetch_catalog() pulls voices.json from the piper-voices HF repo with
a 24h in-memory TTL. Manual refresh available via ?refresh=1 on the
library endpoint.
- shape_catalog_for_ui() projects the raw HF dict (~250 voices, lots of
nesting) into UI-friendly cards: id, name, language, country, quality,
size, install state. Sorted by language_code then name for stable
display. Install state distinguishes bundled (read-only) from user
(admin-installed, can be removed).
- install_voice() downloads .onnx + .onnx.json into /data/voices with
atomic .tmp → rename so a failed partial download can't leave a
corrupt model around. Idempotent — re-installing an already-present
voice is a no-op.
- uninstall_voice() removes /data voices; bundled /opt voices raise
PermissionError (403 at the route layer).
- Strict voice-id regex prevents path traversal in install/uninstall.
Routes (admin-only, since these write to shared /data and affect all
users on the instance):
- GET /api/voice/voices/library
- POST /api/voice/voices/install
- DELETE /api/voice/voices/<voice_id>
Frontend:
- New "Voice Library" section in Settings → Voice, visible only to
admin users. Collapsed by default; expand to load the catalog
on-demand (doesn't hammer HF for non-admins).
- Free-text filter across id, language code, language name, country,
and dataset name. Refresh button forces a catalog re-fetch.
- Per-voice row shows id, language/country/quality/speaker count, size,
and either an Install button, a Remove button (user voices), or a
"bundled" badge (read-only voices in /opt/piper-voices).
- Installs and uninstalls refresh both the library list AND the active
voice picker so the new voice is immediately selectable.
- VoiceLibraryEntry exported from api/client.ts; new client helpers
getVoiceLibrary/installVoice/uninstallVoice.
Tests:
- Pure-transformation unit tests for shape_catalog_for_ui,
_resolve_file_urls, and the voice-id regex (path-traversal coverage).
- DB/network paths (fetch_catalog, install_voice) need a real
environment — left to CI integration tests or device verification.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
python:3.14-slim doesn't ship curl or wget. The previous voice-download
step assumed it did and failed with "curl: not found" (exit 127) in
build stage 8.
Replaced with a Docker BuildKit heredoc that runs python3 directly,
using urllib.request.urlretrieve. Python is already installed (it's
the base image), so this needs no additional apt packages and keeps
the image footprint identical. The `# syntax=docker/dockerfile:1`
directive at the top of this file already pulls in a BuildKit
frontend that supports heredoc syntax.
The download itself is unchanged: en_US-amy-medium and en_US-ryan-medium
into /opt/piper-voices, with both .onnx and .onnx.json sidecar files.
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>
Previously removed all voice deps from the runtime image because of the
numpy<2 / cp314 wheel chain. Actual upstream check (PyPI 2026-05-21)
shows the chain has resolved for the STT half:
- ctranslate2 v4.7.2 (2026-05-19) ships cp314 wheels
- faster-whisper v1.2.1 is pure Python and works on any supported runtime
- onnxruntime v1.26.0 has cp314 wheels (not used here but shared with
the upcoming piper-tts install)
The blocker was kokoro, not the whole stack. Kokoro has been stale
upstream since April 2025 with a `requires_python='<3.13'` pin; that's
being replaced separately with piper-tts.
This commit restores ONLY STT — faster-whisper + soundfile. No torch
(ctranslate2 does its own CPU inference), no kokoro, no spacy. Image
add: ~150 MB.
Voice code is lazily guarded; STT now works when VOICE_ENABLED=true.
TTS still fails gracefully (kokoro import error logged, voice degrades)
until the piper-tts swap lands.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI broke on the build job: kokoro's resolver walks back to a version
that pins numpy<2, which has no cp314 wheel; pip falls back to compiling
numpy from source; python:3.14-slim has no compiler; build fails.
Removing the voice deps install (torch + faster-whisper + kokoro +
soundfile + spacy) from the runtime image:
- unblocks the 3.14 build immediately
- shrinks the image by ~2 GB (torch alone)
- aligns with the explicit operator preference (voice/TTS doesn't pay
off in their workflow; conversational chat will get smaller/faster
with the new no-tools chat model on GPU, so transcription matters
even less)
Voice paths in code are already lazily guarded — TYPE_CHECKING-only
imports plus try/except inside load_stt_model. With VOICE_ENABLED=false
(default), the app starts cleanly with no voice deps installed. With
voice enabled, the import error is caught and logged; the feature
degrades gracefully rather than crashing.
To re-enable voice in a future build, `pyproject.toml` already has the
`voice` extra ready: install it with `pip install .[voice]` plus the
torch index pin, and download spacy en_core_web_sm. Dockerfile comment
documents the path.
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>
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>