Commit Graph

127 Commits

Author SHA1 Message Date
bvandeusen 5fe0fd126d fix(soft-delete): filter trashed rows across read/write paths
CI & Build / TypeScript typecheck (push) Has been cancelled
CI & Build / Python lint (push) Has been cancelled
CI & Build / Python tests (push) Has been cancelled
CI & Build / Build & push image (push) Has been cancelled
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>
2026-06-02 18:51:40 -04:00
bvandeusen e70fe545cc fix(trash): owner-scope all trash ops — close cross-tenant IDOR/disclosure
CI & Build / Python lint (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 2m18s
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>
2026-06-02 18:44:32 -04:00
bvandeusen 7861607fb8 feat(rules): project rule + topic suppressions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 1m18s
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>
2026-06-01 02:26:20 -04:00
bvandeusen c5469214e3 feat(rules): enter_project handshake (S4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 4s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Has been skipped
New enter_project(project_id) MCP tool composes get_project +
get_applicable_rules + get_project_milestone_summary + recent
open-tasks + recent notes into one round-trip, intended to be called
at session start (or whenever the active project changes) so Claude
has the full project context loaded before it starts mutating.

_INSTRUCTIONS now points Claude at enter_project for project-scoped
work, alongside the existing list_always_on_rules instruction. No
schema change; pure composition over existing services.

Closes the four-slice rules-consolidation plan (Scribe task #508):
S1+S2 (always_on flag + Scribe-first prompt, 658348f), S3 (project-
scoped rules, 43a860c), and now S4.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 01:14:37 -04:00
bvandeusen 43a860c3ac feat(rules): project-scoped rules (S3)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m7s
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>
2026-06-01 01:10:18 -04:00
bvandeusen 658348f208 feat(rules): always_on rulebook flag + Scribe-first prompt
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m1s
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>
2026-06-01 00:56:08 -04:00
bvandeusen a40334312f test(trash): update delete-tool tests to soft-delete contract (patch trash_svc.delete) 2026-05-29 09:24:58 -04:00
bvandeusen bfeed67cfe feat(trash): /api/trash blueprint + flip REST DELETE handlers to soft-delete 2026-05-28 21:13:31 -04:00
bvandeusen 580e4a2c0a feat(trash): list_trash/restore/purge_trash MCP tools + register + instructions 2026-05-28 21:10:47 -04:00
bvandeusen eb41e772cd feat(trash): exclude trashed rows from events/projects/milestones/rulebooks/embeddings reads + filtering tests 2026-05-28 20:33:40 -04:00
bvandeusen f80c327ecf feat(trash): restore/list_trash/purge/purge_expired + alive() helper 2026-05-28 20:02:21 -04:00
bvandeusen ce47ebc7de feat(trash): services/trash.py — delete() + cascade-stamp by batch 2026-05-28 20:01:35 -04:00
bvandeusen b250141e15 feat(plan): REST /api/tasks/planning endpoint 2026-05-28 10:17:38 -04:00
bvandeusen 4609abacd8 feat(plan): start_planning MCP tool + get_task rules augmentation + instructions 2026-05-28 10:17:20 -04:00
bvandeusen e269ac9d5c feat(plan): services/planning — start_planning aggregator 2026-05-28 10:16:36 -04:00
bvandeusen 737467f996 feat(plan): REST task routes — kind on create + list 2026-05-28 08:22:54 -04:00
bvandeusen fc4a1627b5 feat(plan): MCP task tools — kind on create_task + list_tasks 2026-05-28 08:22:31 -04:00
bvandeusen 50d2a0e9c0 feat(plan): services/notes — task_kind create param + list filter 2026-05-28 08:21:59 -04:00
bvandeusen 5f3da7c004 feat(rulebook): port script — parse FabledRulebook .md → Scribe DB 2026-05-27 22:39:50 -04:00
bvandeusen eab5c5a026 feat(rulebook): augment get_project with applicable_rules + MCP instructions 2026-05-27 21:52:18 -04:00
bvandeusen a1a6c5e47e feat(rulebook): MCP tools — 16 tools for rulebook/topic/rule/subscription 2026-05-27 21:51:39 -04:00
bvandeusen bbbd6b2f28 feat(rulebook): REST routes — rules, subscriptions, applicable rules 2026-05-27 21:34:53 -04:00
bvandeusen 0219c673c1 feat(rulebook): REST routes — rulebook + topic endpoints 2026-05-27 21:34:25 -04:00
bvandeusen 45fe198d54 feat(rulebook): service layer — subscriptions + get_applicable_rules 2026-05-27 21:19:14 -04:00
bvandeusen d3833ba5a4 feat(rulebook): service layer — Rule CRUD with multi-filter list_rules 2026-05-27 21:18:35 -04:00
bvandeusen 38e4220015 feat(rulebook): service layer — Topic CRUD 2026-05-27 21:17:54 -04:00
bvandeusen cfd801d181 feat(rulebook): service layer — Rulebook CRUD + find_rulebook_by_title 2026-05-27 21:17:21 -04:00
bvandeusen 8d73f553ca test: drop test_tools_calendar_always_available
Tested that services/tools/_registry exposed event tools to the LLM
tool layer. That layer was removed in Phase 8 commit 91bafb6; event
CRUD is now covered via the MCP tools in test_mcp_tool_events.py.
2026-05-27 18:10:06 -04:00
bvandeusen 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>
2026-05-27 17:47:18 -04:00
bvandeusen 05b0bf97d7 fix(mcp): list_events tz-aware UTC range + end-of-day inclusive
Phase 6 smoke caught:

  Error executing tool list_events:
    can't compare offset-naive and offset-aware datetimes

Event.start_dt is stored timezone-aware; the wrapper was passing naive
datetimes built from datetime.fromisoformat("YYYY-MM-DD"), so the SQL
comparison crashed. Also: the docstring promises "date_to inclusive at
end-of-day" but the code was using midnight-of-date_to, which would
silently miss same-day events after midnight.

Extracted the range math into _day_range_utc() so create/update_event's
_combine() can stay as-is (it stays naive — the service localizes
create/update inputs against the user's tz, that path didn't crash).

Test updated to match: assert tz-aware UTC datetimes and the +24h
bump for end-of-day-inclusive semantics.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:57:38 -04:00
bvandeusen 06a532b0e6 test(mcp): include Host header in raw-ASGI test scope
FastMCP's transport_security module enforces a Host header as DNS-
rebinding protection. Raw-ASGI scope construction doesn't fill it in
automatically (real HTTP clients always send one), so the test
request was getting 421 Misdirected Request with a log warning:

  Missing Host header in request

Production is unaffected — real curl, Claude Code, and any real
client send a Host header.
2026-05-27 13:11:39 -04:00
bvandeusen 65d3711a11 fix(mcp): start FastMCP session manager via Quart serving lifecycle
After fixing the /mcp path forwarding in 1fd303a, requests now reach
FastMCP — but its StreamableHTTPSessionManager raises:

  RuntimeError: Task group is not initialized. Make sure to use run().

The session manager owns a task group that must be running before it
can handle requests. In a stand-alone Starlette app this happens via
the `lifespan` parameter (lifespan = session_manager.run). Hosted
inside Quart, my dispatch wrapper only forwards HTTP events, not
lifespan, so the manager never got its startup signal.

Fix: hook session_manager.run() (an async context manager) into
Quart's @app.before_serving and @app.after_serving so the task group
is alive across the serving window.

The CI integration test was hitting the same crash because it drives
app.asgi_app raw without going through Quart's serving lifecycle —
@before_serving never fires. Updated the test to manually enter
session_manager.run() around the request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:59:29 -04:00
bvandeusen 1fd303abe3 fix(mcp): don't strip /mcp prefix; FastMCP's handler is mounted there
The dispatch wrapper was rewriting scope['path'] from '/mcp' to '/'
before handing off to FastMCP. But FastMCP's streamable_http_app
mounts the JSON-RPC handler at '/mcp' (its default), so the rewritten
'/' had no matching route and FastMCP returned 404. Auth middleware
was correctly firing first (a no-auth request still gets 401), the
bug was only on the post-auth path.

Symptom: `claude mcp add ...` succeeds, registration shows in
`claude mcp list`, but connection fails because the initialize
handshake returns 404 instead of an MCP capabilities response.

Fix: pass the scope through unmodified. FastMCP's own routing matches
the '/mcp' path.

Also tightened the integration test that should have caught this —
it was asserting `status != 401`, which a 404 trivially passes. Now
asserts `== 200`, the actual expected response for initialize.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:55:47 -04:00
bvandeusen 6aa84002b3 refactor(mcp): drop fable_ prefix from tool names; rebrand to Scribe
MCP clients see tools namespaced by the server's local name already
(mcp__<server>__<tool>), so the fable_ prefix on every tool name was
redundant and ate tokens in the model's tool list.

Tools renamed (34 total):
  fable_search → search
  fable_list_notes / get_note / create_note / update_note / delete_note → list_notes / ...
  fable_list_tasks / get_task / create_task / update_task / add_task_log → list_tasks / ...
  fable_list_projects / get_project / create_project / update_project → list_projects / ...
  fable_list_milestones / create_milestone / update_milestone → list_milestones / ...
  fable_list_events / create_event / get_event / update_event / delete_event → list_events / ...
  fable_list_tags → list_tags
  fable_get_recent → get_recent
  fable_list_persons / create_person / update_person → list_persons / ...
  fable_list_places / create_place / update_place → list_places / ...
  fable_list_lists / create_list / update_list → list_lists / ...

Also rebranded in MCP scope:
  FastMCP("fable", ...) → FastMCP("scribe", ...)
  auth realm "fable-mcp" → "scribe-mcp"
  ASGI scope key fable_user_id → scribe_user_id
  ContextVar label fable_mcp_user_id → scribe_mcp_user_id
  Tool docstrings "in Fable" / "Fable task" → "in Scribe" / "Scribe task"
  Server _INSTRUCTIONS prose

Deliberately kept:
  - The internal Python package name `fabledassistant` (per project naming
    convention — internal stays).
  - "Fabled Scribe" as the official product/brand name (page footer,
    smtp_from_name default).
  - References to the legacy `fable-mcp/` standalone package in docstrings
    explaining what we ported from — accurate until that directory is
    deleted in Phase 10.

Client impact: existing MCP registrations need
  claude mcp remove <name> && claude mcp add ...
once with a freshly-copied snippet from Settings → MCP Access. Claude
Code then re-discovers tools on connect — old conversations that
referenced fable_* tool names will see "tool not found" on those calls
until updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:48:55 -04:00
bvandeusen 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>
2026-05-26 21:02:30 -04:00
bvandeusen d4f3516552 feat(mcp): typed-entity tools (person/place/list)
Nine tools — list/create/update for each of person, place, list.
Get and delete reuse fable_get_note / fable_delete_note (typed
entities share the Note model).

Lists: the wrappers accept an `items: list[str]` for ergonomics and
translate to the {text, checked} dict shape that
services/knowledge.py and KnowledgeView.vue expect. items=[] clears;
items=None leaves unchanged.

Updates do an explicit get → merge → update round trip so updating
one typed field doesn't clobber the others stored alongside it in
entity_meta (which is a single JSONB column).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:53:36 -04:00
bvandeusen 6961144c3a feat(mcp): fable_list_tags + fable_get_recent
Two cross-type bootstrap tools:

- fable_list_tags: tag vocabulary with usage counts, top-N by count.
  Aggregation in Python (not SQL UNNEST) — trivial perf cost at
  personal scale, much easier to test.

- fable_get_recent: most-recently-touched items across notes, tasks,
  projects, events. Useful for Claude to ask 'what was I working on
  recently' at the start of a conversation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:51:59 -04:00
bvandeusen 9a76c4718b feat(mcp): event CRUD tools
Five new tools (events weren't in fable-mcp before). Split
start_date + start_time inputs combine into a naive datetime that
services/events.py interprets in the user's local timezone.

Sentinels for update:
  - empty strings → leave unchanged
  - duration_minutes=-1 → leave unchanged
  - duration_minutes=0 → set to point event (NULL duration)
  - start_date/start_time must BOTH be set to move the event

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:50:54 -04:00
bvandeusen 4d6bae77b4 feat(mcp): project + milestone CRUD tools
Seven tools matching existing fable-mcp contracts:
  - fable_list/get/create/update_project (no delete; archive via status)
  - fable_list/create/update_milestone (no get; no delete)

LLM-era similarity-check / 'confirmed' guard for create_project is
NOT replicated — Claude doesn't need it. The service's auto-summary
regeneration side effect (services.projects.update_project) stays
for now; gets removed in Phase 7 along with all other LLM code.

Notable sentinels:
  - update_milestone: order_index=-1 means "leave unchanged" (0 is valid)
  - create_milestone: description="" becomes None at the service layer

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:24:07 -04:00
bvandeusen d086c9b606 feat(mcp): task CRUD tools + add_task_log
Five tools wrapping services/notes.py with is_task=True (tasks are
notes with non-null status) plus services/task_logs.create_log for
add_task_log. Matches existing fable-mcp contracts. No delete_task —
preserves existing surface; cancel by updating status to "cancelled".

fable_get_task enriches with parent_title (extra service call when
parent_id is set), matching the existing route's behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:22:30 -04:00
bvandeusen b026421985 feat(mcp): note CRUD tools (list/get/create/update/delete)
Five tools wrapping services/notes.py with is_task=False. Signatures
mirror the existing fable-mcp note tool contracts so Claude usage is
unchanged.

Key behavior the tests pin down:
  - list_notes repackages (rows, total) tuple into {notes, total}
  - tag=""/search_text="" are "no filter" sentinels
  - update_note ONLY sends non-default fields to the service (the
    main risk: a default empty string overwriting real data)
  - tags=[] is an explicit clear; tags=None is "leave unchanged"
  - project_id=0 on create => orphan; on update => leave unchanged
    (preserved limitation from existing fable-mcp)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:20:27 -04:00
bvandeusen fd0431dfb6 feat(mcp): tools/ package + fable_search
Establishes the tool pattern: each tool module exposes register(mcp),
register_all() aggregates them, build_mcp_server() calls register_all.

fable_search mirrors the existing fable-mcp contract (q/content_type/limit
in; {results, total} out) but calls services.embeddings.semantic_search_notes
directly instead of going over HTTP. User comes from mcp.current_user_id().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:18:36 -04:00
bvandeusen 3579db2f06 feat(mcp): per-request user_id contextvar for tool handlers
Adds mcp._context.current_user_id() backed by a ContextVar. The ASGI
auth middleware sets it before dispatching to FastMCP and resets it
on the way out, so tool handlers can read the acting user without
re-parsing the request scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:17:04 -04:00
bvandeusen 3cc5c7dcab test(mcp): drop the bypass test (covered implicitly)
Driving Quart's full request pipeline via a hand-rolled ASGI scope
(no lifespan startup, no hypercorn-provided state) doesn't produce
a response. The 3 remaining tests cover the actual MCP middleware
behavior. The bypass property is implicit — if the middleware ate
non-/mcp requests, every existing /api/* test would fail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:58:45 -04:00
bvandeusen 38265906f1 test(mcp): drive ASGI app directly, skip Quart test_client
Quart's test_client expects its request pipeline to populate
app._preserved_context. Our /mcp middleware deliberately bypasses
that pipeline (forwarding straight to FastMCP), so test_client's
teardown blew up with AttributeError. The middleware is correct;
the test harness was wrong.

Build raw ASGI scope/receive/send and call app.asgi_app directly —
which is what production hypercorn does anyway.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:54:46 -04:00
bvandeusen 94f7a6de37 feat(mcp): mount /mcp endpoint with bearer-token auth
Wires FastMCP's streamable-HTTP ASGI sub-app into the Quart app via
asgi_app replacement. Requests under /mcp are stripped, auth-checked
against api_keys, and forwarded to FastMCP with fable_user_id set on
the ASGI scope. All other paths pass through to the original Quart
dispatch unchanged.

Tests cover the three auth paths (no header, invalid token, valid
token) plus a regression check that non-/mcp paths bypass the MCP
dispatch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:15:42 -04:00
bvandeusen caa504913f feat(mcp): bearer-token auth resolver
Thin parser over the existing api_keys lookup. Strips the Bearer
prefix, validates the token via services/api_keys.lookup_key (which
already filters revoked keys and updates last_used_at), and returns
the user_id for the in-flight MCP request.

Tests follow the existing mock-async_session pattern in
test_api_keys.py rather than introducing a real DB fixture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:13:31 -04:00
bvandeusen 7d71f126a2 fix(tests): relax voice ID regex test — don't assert HF casing convention
The voice_library regex's purpose is to prevent path traversal and
filter structurally-malformed IDs, not to enforce the HF catalog's
lowercase-language convention. Asserting that EN_US-amy-medium is
rejected was a category error — uppercase variants pass the regex
but would 404 at install time against HF, which is a harmless dead
end, not a security gap. Comment in the test now explains the scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:13:08 -04:00
bvandeusen 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>
2026-05-22 08:18:22 -04:00
bvandeusen 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>
2026-05-21 22:04:09 -04:00