In the full task editor (TaskEditorView) sidebar:
- Kind selector (Work / Plan / Issue), mirroring the Status/Priority selects.
- Systems multi-select (checkboxes of the project's systems, fetched via the
systems store), shown when a project is set.
Both wired through load (prefill from task.task_kind / task.systems), dirty
tracking, and save (kind + system_ids via the store's IssueFields). No new
colors — existing sb-field/sb-select tokens.
Deferred: the arose-from (provenance) picker — least-critical control and the
riskiest (task-search UI); the field is already supported by API/store/route for
a later add. NEEDS operator browser verification (CI typechecks only).
Refs plan 825 (S4b editor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
vue-tsc TS2345: System.color is string|null, but updateSystem's data param
typed color as string, so the store's Partial<Pick<System,...>> wasn't
assignable. Widen the param's color to string|null (clearing a color is valid).
Refs plan 825 (S4a).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend foundation for Issues + Systems (spec #825, S4a).
- frontend/src/api/systems.ts: typed client (System + list/create/update/delete)
over /api/projects/<id>/systems, matching the rulebooks api style.
- frontend/src/stores/systems.ts: Pinia store keyed by project (fetch/create/
update/archive/unarchive/delete), toast-on-error.
- frontend/src/components/SystemsSection.vue: a Systems management section —
cards (color swatch, name, description, 'N open' issue-count badge) with
inline create/edit, archive (hidden behind a 'show archived' toggle), and a
delete-confirm modal. v1 quality: loading skeleton, empty state, error toasts,
keyboard a11y, focus rings; reuses existing CSS tokens (no new colors).
- ProjectView.vue: new 'Systems' tab (between Notes and Rules), rendering
<SystemsSection :project-id>, wired like the existing rules tab.
S4b (next) adds issue-editor controls (kind=issue/system multi-select/arose-from),
open-issues lists, and the dashboard surface. NEEDS operator browser verification
(CI typechecks but can't render).
Refs plan 825 (S4a).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Dashboard:
- 'Done recently' chip-cloud -> compact uniform list (Active-now row style),
showing 5 with inline expand to the rest (backend already returns up to 8).
- New 'Projects' rail card: each active project with 'N open · M done'.
Backend already computed done_count (dashboard.py) — now surfaced in the
/api/dashboard payload per active project.
MCP Access (Connect Claude / Claude Code):
- Progressive disclosure: lead with the pre-filled plugin-install snippet;
fold server name, scope, marketplace URL, and the MCP-only path into a
single 'Customize' expander. Desktop tab keeps its own server-name field.
- Marketplace URL now defaults to this instance's own repo via
config.PLUGIN_MARKETPLACE_URL (env-overridable); /api/plugin/marketplace-url
falls back to it, so the field + install snippet are pre-filled out of the
box instead of showing a generic placeholder.
Refs #761
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Settings install command had a <your-scribe-repo> placeholder — not
copyable. Add an instance-global 'plugin_marketplace_url' setting (admin sets
it to the app's own repo) that every user's MCP Access reads, so the
/plugin marketplace add command is copyable out of the box. Keeps it universal
(each deployment configures its own repo) rather than hardcoding one.
- services/settings.get_admin_setting(key): admin-scoped global read.
- routes/plugin: GET /api/plugin/marketplace-url (any user) + PUT (admin).
- SettingsView: Admin → 'Plugin marketplace' field to set it; MCP Access
marketplace field falls back to the configured value.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per operator: the plugin install should supersede the bare MCP connection in
Settings, since the plugin incorporates the MCP and adds the session-start hook
+ skills. The Claude Code tab now leads with /plugin marketplace add + install
(with a persisted marketplace-URL field and the base-URL/key/project-id prompts
spelled out), and the old 'claude mcp add' command moves into a collapsed
'Advanced: connect the MCP only' disclosure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Header wordmark Fabled -> Scribe; fable:calendar-changed event ->
scribe:calendar-changed; SettingsView CSS comment.
- Drop dead Project.auto_summary + summary_updated_at columns (migration
0063) -- the Ollama-era summarizer is gone; model + 2 frontend types +
projects test updated.
- Remove pivot vestiges: diagnostics _curator_busy()/curator_busy
heartbeat field, tz BRIEFING_DAY_START_HOUR/user_briefing_date dead
aliases, the ignored 'model' param on get_embedding (+ its test).
ruff src/ clean; CI is the gate. Part of scribe plan #599.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.
Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.
ruff check src/ clean locally; CI (typecheck + pytest) is the gate.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 3 of #583. New DashboardView at /dashboard composes the approved layout:
done-recently strip, Active-now project panels (project -> active milestones ->
open tasks, in-progress flagged, + no-milestone group), and a rail with
upcoming events / week stats / quick-create (Task/Note/Process). '/' now
redirects to /dashboard; AppHeader gains a Dashboard link and relabels
Knowledge -> Browse (route unchanged). Empty + loading states included.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 6 of #582. NoteType gains 'process'. NoteEditorView branches to a plain
monospace textarea (labeled Prompt) for processes instead of the TipTap
rich-text editor — prompts are plain markdown and rich-text round-tripping
would mangle them. Title/tags/save path unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task 5 of #582. Add 'process' to the KnowledgeItem/activeType/KnowledgeCounts
types, a Processes entry in the type-filter row, a Workflow-icon quick-create
button (createNew('process') -> /notes/new?type=process), and a Process card
badge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drift-audit Group 7 frontend cleanup (no behavioral change):
- SettingsView: remove the 'auto-consolidate task bodies' toggle and its
saveAutoConsolidate handler. The auto_consolidate_tasks setting has zero
backend readers (curator removed in Phase 8); the control did nothing.
- AppSettings type: drop the dead assistant_name / default_model hints (kept
the open string index signature the store actually uses). Delete the fully
orphaned types/chat.ts (zero importers).
- notes/tasks Pinia stores: remove the list/filter/sort/pagination surface
that backed the removed /notes and /tasks list views (verified no consumer
uses the tasks/notes arrays, refresh, or any filter/sort/pagination method).
Kept currentNote/currentTask, loading, fetch/create/update/delete, convert,
patchStatus, startPlanning, backlinks, tags.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Drift-audit Group 2 (Phase-8 amputation — live wiring, no consumer):
- Recurring tasks never recurred: spawn_recurring_tasks() had no caller.
Register it as a 15-min interval job in the event scheduler (which
app.py already starts/stops). Also add a deleted_at IS NULL guard to
the spawn query in the same change, so a trashed recurring parent can
never resurrect children once the sweep is live.
- Event reminders were stamped reminder_sent_at but never delivered.
_fire_reminders now creates an 'event_reminder' in-app notification
before stamping, so a delivery failure stays retryable. Frontend
NotificationsPanel renders the new type (⏰ + message); message logic
pulled into a notifMessage() helper.
- Remove the dead _fire_push_notif no-op stub (push left in Phase 8) and
its three create_task call sites — no more throwaway tasks per share.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
The earlier sed-delete of the Push/ChatHistory/About sections from
the Notifications tab also clipped the tab's outer </div>. Vue's
type-checker happily accepted the unbalanced structure (templates
type-check on script bindings, not tag pairing) but Vite's Vue
compiler failed at build time:
Element is missing end tag.
file: src/views/SettingsView.vue:1062:5
(The reported line 1062 was the outermost .settings-content div —
Vue's parser blames the outermost open tag when an inner sibling
goes unclosed.)
Confirmed by counting: 115 open <div, 116 </div> before — and now
116/116 after restoring the closing tag between the Email Notifications
section and the Integrations tab.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The generated Claude Code snippet was outputting:
claude mcp add ... scribe-dev --url <URL> --header ...
But `claude mcp add` errors out with `unknown option '--url'`. The
URL is a positional argument, not a flag:
claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]
Dropped --url and put the URL inline as a positional. Claude Desktop
JSON snippet was already correct (uses {url, headers} keys, not a
CLI flag).
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>
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>
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>
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>
/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>
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>
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>
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>
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>