8d739c5da17fc72605219a189ff8905ee9d6860a
349 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
8d739c5da1 |
perf(search): offload cosine scoring off event loop; document best-effort feed
Drift-audit Group 9 (param-cliff / unbounded search work): - semantic_search_notes: the O(rows) cosine-similarity scoring loop ran synchronously on the event loop, so every RAG injection / search stalled other requests proportional to the user's embedding count. Move the scoring into asyncio.to_thread (results unchanged). The deeper fix — bounding the candidate set via pgvector ORDER BY/LIMIT — is noted as separate infra work. - _semantic_knowledge_search: documented the best-effort top-N semantics — is the capped candidate-window size (not the true match count), matches beyond the cap aren't page-reachable, and each page recomputes the full merge. Prevents the silent-truncation trap; cached ranked-id paging / pgvector is the fix if exhaustive pagination is ever required. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7ce5bb8450 |
fix(lifecycle): OAuth pw 500, invite lockout, reminder re-arm, partial-unique
Drift-audit Group 8 (lifecycle gaps): - change_password no longer 500s for OAuth-only users: short-circuit when password_hash is None (verify_password would crash on None) so the route returns a clean 4xx instead of a 500. - register_with_invitation no longer locks the invitee out on a username collision: create the user FIRST, then mark the token used, so a failed creation (409) leaves the single-use invite valid for retry. - update_event re-arms reminder_sent_at when start_dt/reminder_minutes change, so a rescheduled event fires again instead of being permanently suppressed. - Migration 0061: uq_topic_per_rulebook / uq_rule_per_topic become PARTIAL unique indexes (WHERE deleted_at IS NULL). Trashing 'X' then recreating it no longer 500s on the dead row's title. Model __table_args__ updated to match. Deferred: per-occurrence reminders for recurring events (event_scheduler) — needs a per-occurrence reminder-state design, not a one-line gate tweak. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
2fd9a2300a |
fix(caldav): point-event round-trip, recurrence push, delete propagation
Drift-audit Group 5 #7/#8 + Group 7 (CalDAV write-path): - Point events no longer fabricate a 60-min DTEND: caldav.create_event emits DTSTART-only when there's no end and no duration, so the next pull doesn't read it back as duration_minutes=60 and silently lengthen the event. - Recurrence edits now propagate: caldav.update_event gains a recurrence param (sentinel = leave unchanged; value/empty = set/clear RRULE), and _push_update passes the local event's rule so a changed/cleared RRULE isn't overwritten by the stale remote rule on the next pull. - Event deletions propagate to CalDAV: trash.delete captures an event's caldav_uid before soft-deleting and fires _push_delete, so a UI/MCP delete removes the remote copy instead of leaving it to linger. (delete_event the service primitive is kept — still tested/usable — rather than removed.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c016bd664e |
fix(status-enum): add paused to ProjectStatus, validate, fix progress
Drift-audit Group 6 + Group 5 #6 (enum-extension / status drift): - ProjectStatus gains 'paused' — routes and frontend already treated it as first-class, but the enum (the source of truth) omitted it and the error strings lied. A future CHECK derived from the enum would have rejected existing paused rows. - create_project/update_project now validate status via ProjectStatus at the service layer (canonical gate; notes.status has no DB CHECK), so the MCP create/update_project path can't persist a typo'd status. MCP docstrings realigned to the 4-value domain; route error strings corrected. - get_milestone_progress: cancelled tasks are excluded from the percent denominator (and now reported in status_counts), so a milestone whose only open task was cancelled reaches 100% instead of stalling below it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
aef5009fc2 |
fix(contract-drift): MCP read-only scope, shared-note writes, event TZ
Drift-audit Group 5 (high-severity contract drift): - MCP read-only keys could call every write tool: the Bearer resolver discarded api_key.scope and dispatch had no gate. Add resolve_bearer() (returns user_id + scope) and a scope gate in the /mcp ASGI wrapper that buffers the JSON-RPC body and rejects tools/call for any tool outside a read all-list when scope=='read' (default-deny for unknown/new tools). - Shared project notes/tasks panel was empty for non-owners: get_project_notes_route now queries notes/milestones with the project OWNER's uid (mirrors the already-fixed milestones route). - Shared editors couldn't save/delete shared NOTES (tasks worked): the three notes write routes now resolve via get_note_for_user, gate on can_write_note, and write as the owner — matching the tasks routes. - Event timezone drift: naive datetimes from the MCP date+time split are now localized to the user's tz at a single canonical service point (create_event /update_event), so MCP- and UI-created events agree. tz-aware inputs (REST/CalDAV) pass through untouched. - create_note validates status/priority (TaskStatus/TaskPriority), closing the MCP create_task path that let out-of-enum values persist (no DB CHECK). Tests cover resolve_bearer scope + the write-tool classifier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
c363a5a6df |
fix(retention): add cleanup sweeps + CalDAV orphan reconciliation
Drift-audit Group 4 (retention / unbounded growth): - CalDAV pull now reconciles deletions: a previously-synced event whose caldav_uid no longer appears remotely within the synced window is soft-deleted (one batch_id per run, restorable), so a remote delete propagates locally instead of orphaning forever. Guarded on a non-empty fetch so a spurious empty result can't wipe every local copy. Also wrap the blocking fetch in a 120s wait_for and log run duration. - Notifications: hourly loop now purges read notifications older than 30d (unread kept). Table no longer grows without bound. - Auth tokens: new daily sweep deletes password-reset / invitation tokens whose validity window ended >7d ago; wired via start_auth_token_retention_loop in app startup. Both tables previously only flipped used=True, never pruned. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
5fe0fd126d |
fix(soft-delete): filter trashed rows across read/write paths
Drift-audit Group 3 (soft-delete lifecycle gaps). Trashed rows were leaking into reads and being mutated/resurrected by writes: - update SELECTs now exclude trashed rows: update_milestone, update_project, update_event, and get_milestone_in_project (the latter backs all four milestone routes). Mutating a trashed row silently persisted and reappeared on restore. - MCP get_recent (notes/projects/events) and list_tags now filter deleted_at IS NULL, so trashed items stop surfacing in the agent's bootstrap context and tag counts. - convert_task_to_note clears recurrence_rule + recurrence_next_spawn_at so a demoted note can't spawn children via the (now-live) sweep. - caldav pull skips locally-trashed events (by caldav_uid) instead of resurrecting them via update or creating a duplicate live copy. - trash _cascade now stamps the FULL sub-task subtree (iterative descent), not just direct children, so deeply nested sub-tasks restore as one batch. Test updated for the new descent query. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
8b49ea896a |
fix(schedulers): wire recurring-task spawn + deliver event reminders
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> |
||
|
|
e70fe545cc |
fix(trash): owner-scope all trash ops — close cross-tenant IDOR/disclosure
Drift-audit Group 1 (authz/IDOR). Multi-user is live, so these were exploitable ACL bypasses: - trash.py: add _owner_clause() and apply it to _exists_alive, restore, purge, list_trash, and purge_expired. A batch_id is a bearer token; without an owner predicate a leaked/guessed id let one tenant read (list_trash), restore, or PERMANENTLY purge another's content. Topics and rules carried no owner check at all (_OWNER mapped them to None) — ownership now derives through the parent rulebook (or owning project, for project-scoped rules). - purge_expired is now per-user; trash_scheduler iterates every user and applies that user's own trash_retention_days window, instead of applying user 1's window to everyone (early data loss for other users). - rulebooks subscribe/unsubscribe_project now assert project ownership, matching the suppression endpoints. - topic/rule DELETE routes return 404 when nothing owned was removed. Regression test locks in that every model — including topics/rules — gets a real owner clause. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
7861607fb8 |
feat(rules): project rule + topic suppressions
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> |
||
|
|
43a860c3ac |
feat(rules): project-scoped rules (S3)
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
|
||
|
|
658348f208 |
feat(rules): always_on rulebook flag + Scribe-first prompt
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> |
||
|
|
d8f577e753 | feat(trash): daily retention purge scheduler (03:30 UTC) wired into app lifecycle | ||
|
|
eb41e772cd | feat(trash): exclude trashed rows from events/projects/milestones/rulebooks/embeddings reads + filtering tests | ||
|
|
e7f214fc80 | feat(trash): exclude trashed rows from notes + knowledge read paths | ||
|
|
f80c327ecf | feat(trash): restore/list_trash/purge/purge_expired + alive() helper | ||
|
|
ce47ebc7de | feat(trash): services/trash.py — delete() + cascade-stamp by batch | ||
|
|
dc93675470 | feat(plan): KnowledgeView Plans facet + plan badge (knowledge endpoints + UI) | ||
|
|
e269ac9d5c | feat(plan): services/planning — start_planning aggregator | ||
|
|
50d2a0e9c0 | feat(plan): services/notes — task_kind create param + list filter | ||
|
|
45fe198d54 | feat(rulebook): service layer — subscriptions + get_applicable_rules | ||
|
|
d3833ba5a4 | feat(rulebook): service layer — Rule CRUD with multi-filter list_rules | ||
|
|
38e4220015 | feat(rulebook): service layer — Topic CRUD | ||
|
|
cfd801d181 | feat(rulebook): service layer — Rulebook CRUD + find_rulebook_by_title | ||
|
|
4806c34a3c |
refactor: Phase 10 — Ollama service, image cache, config, frontend orphans
Final cleanup phase of the MCP-first pivot.
docker-compose:
- docker-compose.yml: drop ollama service + OLLAMA_URL/MODEL env vars +
IMAGE_CACHE / VAPID env comments
- docker-compose.prod.yml: drop ollama service + Ollama env + GPU
reservation
- docker-compose.quickstart.yml: drop ollama service + Ollama env +
GPU-reservation comment; quickstart instructions now point at the
MCP Access tab instead of model-pull
Config:
- Drop OLLAMA_URL, OLLAMA_MODEL, OLLAMA_BACKGROUND_MODEL,
OLLAMA_KEEP_ALIVE_*, OLLAMA_NUM_CTX, EMBEDDING_MODEL (fastembed
is hard-coded inside services/embeddings.py)
- Drop IMAGE_CACHE_DIR, IMAGE_MAX_BYTES (image cache subsystem
deleted)
- Drop VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY, VAPID_CLAIMS_SUB (push
deleted in phase 8)
- Drop VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND (voice
deleted in phase 8)
- Drop Config.validate() rules for those keys
Image cache deletion:
- services/images.py, routes/images.py, models/image_cache.py
- models/__init__.py: drop ImageCache import
- app.py: drop images_bp registration
- alembic/versions/0054_drop_image_cache.py: DROP TABLE image_cache
Frontend client.ts orphan exports stripped:
- getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice,
uninstallVoice, transcribeAudio, synthesiseSpeech,
VoiceStatusResult / VoiceEntry / VoiceLibraryEntry types
- getJournalConfig, saveJournalConfig, getJournalToday/Day/Days,
triggerJournalPrep, runJournalCurator, listPendingActions,
approvePendingAction, rejectPendingAction, listJournalMoments,
updateJournalMoment, deleteJournalMoment, geocodeAddress
- JournalConfig / JournalLocation / JournalConversation /
JournalMessage / JournalDayPayload / JournalMoment /
CuratorRunResult / PendingCuratorAction types
- consolidateProfile, clearProfileObservations, listProfileObservations
- ProfileObservationEntry, learned_summary/observations_* fields on
UserProfile
- consolidateTask (cascading update to TaskEditorView)
- getFableMcpInfo, getNewsItems, GetNewsItemsParams, NewsItem import
TaskEditorView:
- Drop the auto-summary banner + Re-consolidate button
- Drop isBodyAutoMaintained gate (editor is always user-controlled now)
- Drop reconsolidate function + reconsolidating ref
SettingsView:
- profile ref no longer initialises learned_summary /
observations_count / observations_updated_at (those fields are
gone from UserProfile type)
Surviving frontend composables/components flagged for likely future
cleanup but not deleted in this commit (no compile errors, just
unreferenced after Phase 7-8):
- useAssist, useFloatingAssist, useTagSuggestions, useVad,
useListenMode, useOnnxPreloader (composables)
- WorkspaceNoteEditor, WorkspaceTaskPanel, WeatherCard, InlineAssistPanel
(components)
- api/client.ts still references /api/notes/assist/* and
/api/notes/suggest-tags via useAssist + useTagSuggestions — those
endpoints 404 now but no caller hits them; dead at runtime, harmless.
Compose stack collapses to two services: `app` + `db`. No Ollama, no
voice models, no fable-mcp wheel build. First-boot install reduces to:
docker compose up -d
→ visit web UI → register → Settings → MCP Access → copy snippet
→ claude mcp add … → done.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
91bafb641f |
refactor: Phase 8 — backend deletion (chat / voice / push / journal / curator)
Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.
Deleted (services/):
chat, generation_buffer, generation_log, generation_task, llm, tools/
(entire package), stt, tts, voice_config, voice_library, push,
journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
journal_search, curator, curator_scheduler, consolidation,
tag_suggestions, research, weather, article_fetcher, pending_actions,
moments, assist, wikipedia.
Deleted (routes/):
chat, voice, push, journal, quick_capture, fable_mcp_dist.
Deleted (models/):
conversation, generation_tool_log, push_subscription,
pending_curator_action, moment, weather_cache.
Deleted (tests/):
test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
test_notes_consolidation_trigger, test_record_moment_guards,
test_research_pipeline, test_tools_*, test_tool_use_fixes,
test_voice_library, test_weather_service, test_calendar_tool_tz,
test_wikipedia.
Deleted (top-level):
fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
also removed from Dockerfile).
app.py:
- blueprint registrations for the 6 deleted routes
- startup hook trimmed: no more Ollama warmup, KV-cache priming,
journal/curator schedulers, voice model loading
- shutdown hook simplified
- httpx import dropped (was for Ollama calls)
pyproject.toml:
- removed deps: pywebpush, feedparser, html2text, trafilatura
- removed [voice] extras entirely
- description updated for the MCP-first architecture
Dockerfile:
- removed faster-whisper / piper-tts install steps
- removed bundled piper voice download stage
- removed fable-mcp wheel build stage
Surviving-file edits:
- services/auth.py: drop Conversation table claim on first-user setup
- services/backup.py: drop conversation / push-subscription export+restore;
v1/v2 restore now silently skip pre-pivot conversation data
- services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
drop _maybe_trigger_project_summary (LLM auto-summary)
- services/projects.py: drop generate_project_summary + backfill_project_summaries
(both LLM-driven)
- services/user_profile.py: drop append_observations / consolidate /
clear_learned_data (curator-tied) and build_profile_context
(was LLM system-prompt builder)
- services/notifications.py: stub out _fire_push_notif (was send_push_notification)
- services/event_scheduler.py: drop event-reminder push + chat-retention
cleanup job; keep CalDAV pull-sync + reminders job (in-app)
- services/diagnostics.py: _curator_busy() always False
- routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
- routes/tasks.py: drop /<id>/consolidate endpoint
- routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
(was in services/llm.py)
- routes/admin.py: drop /voice, /voice/reload endpoints
- routes/profile.py: drop /consolidate, /observations (GET, DELETE)
- models/__init__.py: drop the 6 dead model imports
Frontend cascade:
- stores/push.ts: deleted entirely (no callers after Phase 7)
- stores/settings.ts: drop checkVoiceStatus + voice-status state
- views/SettingsView.vue: drop Locations section + journalConfig state
(was tied to /api/journal/config); drop JournalConfig + journal/voice
api/client imports
- frontend/api/client.ts: orphaned voice/journal/profile-observation/
fable-mcp-dist exports are left as dead but harmless (call them and
they 404; type-check is clean).
Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
||
|
|
52d6a8ed53 |
feat(embeddings): swap Ollama for fastembed (in-process ONNX)
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> |
||
|
|
8a8d6fc9f2 |
feat(diagnostics): persist crash state to /data so it survives container death
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>
|
||
|
|
eb02603092 |
feat: diagnostic instrumentation for crash investigation
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> |
||
|
|
1b65c44339 |
ux: rename model fields + enforce serial curator execution
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>
|
||
|
|
f72bba91aa |
tighten prompts: curator dedup + entity intros, prep no-invent, chat one-question
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>
|
||
|
|
85b212fbf2 |
refactor(models): route tasks to chat vs worker per new architecture
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> |
||
|
|
3a316551be |
feat(curator): authority routing — mutating tools queue for review (C3/5)
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>
|
||
|
|
6be7328d8c |
feat(curator): pending_curator_actions schema + service (C2/5)
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>
|
||
|
|
a988ffa349 |
feat(curator): cross-reference past work in the summary (C1/5)
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> |
||
|
|
d76f52b578 |
feat(curator): additive-only tool scope; transcript shows User/Assistant only
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> |
||
|
|
37596ce31c |
remove(llm): retire think_enabled setting entirely
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>
|
||
|
|
fdb0f10848 |
fix(chat,curator): unstick chat from silent generation crashes; curator only sees new messages
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> |
||
|
|
49325816a3 |
fix(journal): chat-only system prompt; don't pre-warm OLLAMA_MODEL
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>
|
||
|
|
fa97ade8e3 |
feat(journal): curator summary feeds back into chat context (Phase 3)
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 ( |
||
|
|
83f1676d72 |
feat(journal): auto-scheduler for curator (Phase 2)
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> |
||
|
|
a7002a89a0 |
feat(journal): chat model has no tools; curator runs them async (Phase 1a)
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> |
||
|
|
39ab5d69a9 |
feat(voice): admin UI to browse + install piper voices from HuggingFace
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> |
||
|
|
a28f75994a |
feat(voice): swap kokoro TTS → piper-tts
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> |
||
|
|
bf7a29e8a0 |
feat(llm): per-turn tool-call telemetry (generation_tool_log)
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> |
||
|
|
d345b32856 |
feat(llm): user-controlled think mode (default off); remove qwen3 hardcode
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> |
||
|
|
5d2d27c499 |
fix(journal): anti-hallucination hardening + message_count fix
Prep prose (services/journal_prep.py): - Emit explicit "WEATHER: none available — do NOT mention weather" absent-marker so a small model can't invent partly-cloudy/temperature prose when both configured locations have empty addresses. - Replace negative-only system rule with positive-anchored guidance forbidding weather/temp/precip mentions unless a numeric WEATHER section is present; also bans echoing parenthetical labels verbatim. - Reword overdue header to "(past their due date, still open — backlog, not today's work)" and render lines as "was due <date>, N day(s) overdue" with correct singular/plural. Supersedes the wording noted in Fable task #159. - Deterministic fabricated-weather reconciler: low-false-positive regex detects fabricated weather phrasing; on trip with an empty section, regenerate once with a corrective. Persistent fabrication logs ERROR rather than mangling prose. Journal route (routes/journal.py): - Override message_count with len(messages) in _day_payload. The chat path already does this; the journal path was hitting the Conversation.to_dict() fallback to 0 because messages aren't eager-loaded on that instance. Tests: - tests/test_journal_message_count.py — pins the model-level trap and the override contract (3 cases). - tests/test_journal_prep_hardening.py — 11 cases covering the fabricated-weather reconciler and absent-marker rendering. - tests/test_journal_prep_filtering.py — updated one stale assertion. Tracks Fable task #171. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> |
||
|
|
ce41f2a3ee |
feat(versions): include pin_kind/pin_label in backup export+restore
Both export paths emit pin_kind and pin_label per note_version row. Restore reads them via .get() so backups predating the schema still import cleanly (defaults to None → rolling). |
||
|
|
b1226d4e16 |
feat(versions): daily 03:00 UTC auto-pin scan scheduler
BackgroundScheduler with a single CronTrigger fires scan_all_users_for _auto_pins via asyncio.run_coroutine_threadsafe (mirrors the journal- scheduler pattern). Wired into app startup/shutdown alongside the other schedulers. |
||
|
|
37c704e875 |
feat(versions): auto-pin scan promotes stable versions
_promote_stable_versions_for_note is the pure-function core: walks versions chronologically and pins any with a >= AUTO_PIN_STABILITY_DAYS (2-day) gap to the next version (or to now, for the latest). Auto- generated label describes the stability window. _scan_one_note loads versions for one note, runs the promotion, commits mutations to the attached rows, then calls prune_auto_pins to cap the auto bucket. scan_user_for_auto_pins fans out across the user's notes; scan_all_users_for_auto_pins is the top-level entrypoint for the cron. Per-note and per-user errors are caught and logged. |