Compare commits

...

390 Commits

Author SHA1 Message Date
bvandeusen 05f0cc2c4b Merge pull request 'fix(plugin): keep always-on rules alive across compaction (0.1.11)' (#76) from dev into main 2026-06-30 23:01:28 -04:00
bvandeusen f6629d4bcf fix(plugin): keep always-on rules alive across compaction (0.1.10 → 0.1.11)
Always-on rules were on-demand, not always-present: Tier-1 static context only
tells the agent to call list_always_on_rules(), and Tier-2 dynamic fetch is dark
(token doesn't reach the hook subprocess). On compaction the fetched rules get
summarized away while the harness's own built-in git instruction ("branch first")
survives in the base prompt — so post-compact the generic git instinct wins and
rule #1 ("dev is home") is missed.

- scribe_static_context.md: new "Operator rules govern consequential actions"
  bullet — before any git branch/commit/push or hard-to-reverse action, loaded
  rules beat generic harness/default habits; re-pull rules if not loaded or
  summarized by a compaction. Tier 1 = always fires, keyless, re-fires on compact.
- scribe_session_context.sh: compaction banner now re-pulls list_always_on_rules(),
  not just enter_project().
- plugin.json: 0.1.10 → 0.1.11 so autoUpdate ships the plugin/ change (#1040).

Generic and instance-agnostic per rules #115/#119 — no operator-specific rule
text hardcoded. Refs issue #1197.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4bNefPFAz7esmMZMZmkzL
2026-06-30 12:41:45 -04:00
bvandeusen 03772ff424 Merge pull request 'fix(plugin): bump to 0.1.10 to ship the auto-inject hook' (#75) from dev into main 2026-06-22 22:08:25 -04:00
bvandeusen 2bc054d7ef fix(plugin): bump version 0.1.9 → 0.1.10 to ship auto-inject hook
Path A's UserPromptSubmit hook (scribe_autoinject.sh) + hooks.json were
merged to main in PR #74 but the plugin version was never bumped, so the
autoUpdate marketplace (keyed by version string) never re-pulled the
snapshot — the hook was stranded, uninstallable, and not running in any
session. Bumping the version is what makes installs detect and pull it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 22:07:19 -04:00
bvandeusen 058e8794af Merge pull request 'KB injection tuning: pgvector substrate + retrieval telemetry + title-first auto-inject' (#74) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 20s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 13s
2026-06-22 20:56:15 -04:00
bvandeusen eec241d3c0 feat(plugin): sharpen the recall-before-acting reflex in static context
Turn the SessionStart static guidance into a concrete recall trigger — search
Scribe before answering about the operator projects/people/places/decisions or
starting a task, and pass the active project id to scope results — instead of a
vague "search for related work". Step 4 (pull-path sharpening); the
cross-encoder rerank half is deferred until auto_inject telemetry shows
precision is the bottleneck.

Scribe: project 2, milestone 93, task 1034.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:39:54 -04:00
bvandeusen 8126db3203 feat(plugin): knowledge auto-inject (Path A) — title-first per-turn awareness
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 21s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 55s
New UserPromptSubmit hook (scribe_autoinject.sh) + GET /api/plugin/retrieve that
surface the TITLES (never bodies) of the few notes clearing four anti-bloat
gates: a per-user confidence threshold (stricter than pull search), a margin
gate, per-session dedup (exclude_ids), and a top-k ceiling. Each retrieval is
logged to retrieval_logs as source=auto_inject so the threshold can be tuned
from data. Per-user config (enable / threshold / top-k) is DB-backed via
/api/settings with a Settings UI card; defaults enabled, threshold 0.55,
top-k 3 (conservative — tune once auto_inject telemetry accrues).

Scribe: project 2, milestone 93, task 1033.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:31:07 -04:00
bvandeusen 807f478cac feat(search): retrieval telemetry — log every semantic retrieval
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 58s
Add retrieval_logs (migration 0068) + services/retrieval_telemetry with a
fire-and-forget record_retrieval(), wired into the MCP search tool
(source=mcp_search) and the REST search route (source=rest_search). Captures
query, effective params, and the per-result score distribution so KB-injection
thresholds can be tuned from data rather than guessed.

Scribe: project 2, milestone 93, task 1032.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:10:15 -04:00
bvandeusen 513019786e feat(search): pgvector substrate — vector(384) + HNSW for semantic search
Move semantic_search_notes off the full-table Python cosine scan onto a native
pgvector column: indexed ORDER BY embedding <=> :q LIMIT k (HNSW, cosine).
Migration 0067 enables the extension, converts the JSONB embedding column to
vector(384) (stale-dim rows dropped and regenerated by the startup backfill),
and builds the HNSW cosine index. Postgres image moves postgres:16-alpine ->
pgvector/pgvector:pg17 across prod, quickstart, and CI.

Scribe: project 2, milestone 93, task 1031.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
2026-06-22 20:10:15 -04:00
bvandeusen f8c58a7f0f Merge pull request 'feat(mcp): S5 — issue-kind guidance across all instruction surfaces' (#73) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 16s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 23:32:04 -04:00
bvandeusen 5fbee18a94 feat(mcp): S5 — issue-kind guidance across all instruction surfaces
CI & Build / Python lint (push) Successful in 3s
CI & Build / integration (push) Successful in 15s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m3s
Plan #825 (T2 — Issues task_kind) shipped S1–S4 but its S5 docs slice
never landed, so every behavioral surface the plugin pushes to the agent
still described the pre-kind convention ("tag `issue`" on a create_note).
Result: agents fixed bugs without reaching for kind=issue and dumped the
work as logs on unrelated open tasks.

- _INSTRUCTIONS: rewrite the "record a problem" bullet to
  create_task(kind="issue") with symptom→cause→fix + arose_from_id /
  system_ids, and an explicit "not a work-log on an unrelated task"; add
  Issue + System to the hierarchy section.
- skills/systematic-debugging, verification: drop "tag `issue`" /
  create_note-issue, point at create_task(kind="issue").
- skills/using-scribe: add issues/systems to the entity list + reflex #6.
- hooks/scribe_static_context: fix → its own issue on the keyless floor.

Instance-agnostic, prose-only; no schema or tool-behavior change.
Pairs with always-on rule #118. Issue: #855.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:22:17 -04:00
bvandeusen 6cdac307af Merge pull request 'DB maintenance + health observability + Postgres integration CI lane' (#72) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / integration (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 20:54:34 -04:00
bvandeusen 4f31890bde test(ci): dispose engine between integration tests (per-loop pool)
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Successful in 14s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 13s
First integration run proved the lane works (run_maintenance test passed against
real Postgres), but the health test failed with 'Future attached to a different
loop': pytest-asyncio uses a fresh loop per test while the app's module-level
engine pools a connection from the prior test's loop. Dispose the engine in each
test's teardown so the next test starts with an empty pool on its own loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:24:09 -04:00
bvandeusen 2ad2e943f3 test(ci): add Postgres integration lane + real run_maintenance guard
CI & Build / Python lint (push) Successful in 2s
CI & Build / integration (push) Failing after 19s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 1m1s
The unit suite can't catch sync/async API mismatches against SQLAlchemy (an
un-awaited execution_options passed green CI but failed at runtime: VACUUM 0/6).
Add a real-Postgres integration lane modelled on the family pattern (rules
6/79-82): a new CI 'integration' job with a postgres:16 service, bridge-IP
discovery, busybox-safe readiness wait, and 'alembic upgrade head', running
pytest -m integration. Non-gating, like the unit lane.

- tests/test_integration_db_maintenance.py: runs run_maintenance() and
  get_table_health() against real Postgres; asserts all allowlisted tables
  vacuum OK (the await regression makes this fail) and health reports real stats.
- pyproject: register the 'integration' marker.
- conftest: integration-marked tests use the real DATABASE_URL, not the stub.
- ci.yml: unit 'test' job now runs -m 'not integration'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 19:19:18 -04:00
bvandeusen e6c89f6b88 fix(db): await AsyncConnection.execution_options in run_maintenance
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 20s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m0s
execution_options() is a coroutine on AsyncConnection and must be awaited;
the un-awaited call returned a coroutine, so exec_driver_sql() blew up with
AttributeError and every table's VACUUM was skipped (Run-now reported 0/6).
A prior change had wrongly dropped the await. Fix it and make the test mock
execution_options async so this call shape is actually exercised.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:31:30 -04:00
bvandeusen 96079d5b77 feat(db): table-health readout — per-table bloat metrics in admin card
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 52s
You can't decide what to maintain without seeing what's bloating. Adds a
read-only health panel driven by Postgres' own statistics views.

- services/db_maintenance.py: get_table_health() queries pg_stat_user_tables +
  pg_total_relation_size + pg_database_size — per-table size, live/dead tuples,
  dead-tuple ratio (the bloat signal), and last (auto)vacuum/(auto)analyze.
- routes/admin.py: admin-only GET /api/admin/db-maintenance/health.
- SettingsView.vue: 'Table health' table in the maintenance card, all tables
  sorted by dead tuples, rows >=20% dead-ratio flagged; total DB size shown;
  refreshes after a Run-now so the dead-tuple drop is visible.
- Tests: health row/size shaping + null-timestamp passthrough; route + service
  surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 17:53:24 -04:00
bvandeusen c4553d937c feat(db): scheduled DB maintenance — daily targeted VACUUM (ANALYZE)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 54s
Adds a daily off-hours VACUUM (ANALYZE) over the high-churn tables the
retention/purge sweeps churn (app_logs, notifications, token tables, notes,
note_versions), on top of Postgres autovacuum, to reclaim bloat left by the
nightly bulk DELETEs and keep planner stats fresh.

- services/db_maintenance.py: run_maintenance() over a closed table allowlist
  via an AUTOCOMMIT connection (VACUUM can't run in a txn); per-table summary
  persisted as the db_maintenance_last_run admin setting.
- services/db_maintenance_scheduler.py: BackgroundScheduler cron (default
  04:00 UTC, after the 03:30 trash purge); enabled-gate checked at fire time;
  live reschedule on hour change. Wired into app.py start/stop.
- routes/admin.py: admin-only GET/PUT /api/admin/db-maintenance + POST /run.
- settings.py: set_admin_setting() (write-side of get_admin_setting) for
  out-of-request writes.
- SettingsView.vue: admin 'Database maintenance' card — enable toggle, run-hour
  (UTC), Run-now, last-run summary.
- Tests: allowlist is closed, VACUUM issued per table, one failure doesn't
  abort the rest, summary persisted; route/scheduler/service surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:42:04 -04:00
bvandeusen d324205450 Merge pull request 'Release: Issues+Systems, milestone-as-plan, plugin reliability/skills/dedup, compaction hygiene' (#71) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 14s
2026-06-14 15:51:44 -04:00
bvandeusen ee02ed37c1 feat(plugin): compaction-hygiene guidance — recommend safe compaction at seams
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 59s
#834. The pre-compaction complement to the shipped post-compaction re-grounding
banner. Because Scribe records progress as you go (task status, work-logs,
decision notes), a compaction at a clean work-seam is lossless — so guide the
model to recommend it proactively rather than letting auto-compact fire mid-task.

Placed in the ALWAYS-loaded channels (operator wants it consistently in context,
not relevance-gated like a skill): MCP _INSTRUCTIONS (every handshake) + the
static SessionStart floor (every session, MCP-independent). Behavior: at the end
of a block of work in a long session, ensure in-flight state is logged, then tell
the operator it's a safe moment to /compact (naming what was logged); recommend
at seams, not every turn; the model can't run /compact itself.

plugin.json 0.1.8 → 0.1.9 so clients re-pull the static-context change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:41:24 -04:00
bvandeusen dd1fc2d506 feat(mcp): extend dedup gate to create_rule / create_project_rule
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 52s
Completes the Phase 5 follow-up: rules now get the same update-over-create
gate. Title-based only (rules aren't a semantic-retrieval/RAG surface), scoped
to the same topic (rulebook rule) or same project (project rule). force=true
overrides; fail-open like the note/task gate.

Deferred-item decisions (operator): REST/web gating SKIPPED (kept MCP-only —
humans rarely double-create and a hard block needs UI affordance); orphan scope
kept orphan↔orphan (no change). So this rule gate is the only remaining build.

- services/dedup.py: find_duplicate_rule(title, topic_id|project_id).
- create_rule + create_project_rule: force param + gate.
- tests: rule title match, scope-required guard, tool gate (block + force).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:43:17 -04:00
bvandeusen 5102ffb558 fix(dedup): fail open when the duplicate check can't run
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 56s
The Phase 5 gate added a DB query before every create_note/create_task. When
that query fails (DB unreachable, etc.) the create must NOT error — a dedup
check is advisory infrastructure, not a correctness gate. Wrap the title query
so any failure degrades to "no duplicate found" and the create proceeds.

Also fixes 7 existing create tests that don't mock the DB: they now exercise
the fail-open path (no Postgres in the unit-test job) instead of erroring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:24:20 -04:00
bvandeusen 322cbc3b5e feat(mcp): Phase 5 — write-time near-duplicate gate (update-over-create)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Failing after 42s
CI & Build / Build & push image (push) Has been skipped
#755 Phase 5. create_note / create_task now BLOCK a near-duplicate instead of
silently inserting: they return {"duplicate": true, "existing_id", message}
pointing at the record to UPDATE. Fights store bloat and stale competing copies
that semantic search (RAG) would otherwise resurface for reconciliation. A
force=true override creates anyway for genuinely-distinct records.

- services/dedup.py: find_duplicate_note — two signals, scoped to owner + same
  project + same kind: (1) normalized-title exact match (cheap, always); (2)
  semantic cosine ≥ 0.90 but ONLY when body ≥ 200 chars (short/title-only
  embeddings false-positive — the pre-pivot lesson). Project-less (orphan)
  records compare only to other orphans on BOTH signals (orphan_only on the
  semantic call) — they're not matched across every project.
- Gate wired into the MCP create_note/create_task tools (the LLM write path)
  with force override; _INSTRUCTIONS documents the duplicate response + force.
- Opt-in by design: the service helper is only called from the interactive
  create tools. Internal/programmatic creates (recurrence spawn, imports) go
  straight through services.create_note and are NOT gated — a recurring task
  spawning its next same-titled instance must not be blocked.
- Scope v1: MCP tools only. REST/web (human CRUD, needs a UI affordance) and
  create_rule (not a RAG surface; _INSTRUCTIONS already steer it) are follow-ups.
- tests: dedup service (title/semantic/body-gate/type-filter) + tool gate
  (blocks, force bypasses) for notes and tasks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:21:35 -04:00
bvandeusen 33f9a0a4d4 feat(plugin): Phase 4 — Scribe Processes auto-surface as local skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 1m3s
#755 Phase 4. Saved Scribe Processes (DRY pass, Drift Audit, …) now surface as
auto-triggered Claude Code skills instead of pull-only get_process calls.

Design correction vs the plan: stubs live in the USER's ~/.claude/skills/, NOT
plugin/skills/_instance/. The plugin is git-cloned and identical per install, so
instance-specific generated files can't ride in it; personal skills are
live-detected within the session (verified via claude-code-guide). MCP prompts
were the alternative but are pull-only (no relevance auto-surface), so skills are
the right primitive.

- backend: GET /api/plugin/processes manifest (services/plugin_context.
  build_process_manifest) — {name, slug, description} per Process; description is
  the auto-surface trigger (title + preview); slugs deduped, blanks skipped.
- plugin: scribe_sync_processes.sh writes ~/.claude/skills/scribe-proc-<slug>/
  SKILL.md (body = "call get_process(name), follow verbatim") and PRUNES stale
  scribe-proc-* stubs. Fail-open + silent; a transient fetch failure never wipes
  existing stubs. Runs as a 2nd SessionStart hook + via the /scribe:sync command.
- plugin.json 0.1.7 → 0.1.8; README updated.
- tests: build_process_manifest (render, slug dedupe, blank-title skip, preview
  truncation). Sync script's write+prune validated in isolation (plugin/** is not
  CI-covered): correct stubs created, stale pruned, unrelated skills untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:00:32 -04:00
bvandeusen e8d6de287b test: fix obsolete create_task kind=plan passthrough test
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m8s
test_create_task_passes_kind asserted create_task forwards kind=plan; the
hard-retire guard now rejects that. Exercise passthrough with kind=issue
instead. (Service-level create_note still accepts task_kind=plan by design —
the guard lives at the user-facing tool/route layer, not the primitive.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:34:30 -04:00
bvandeusen f7742173aa chore(plans): make kind=plan retirement consistent across MCP, REST, UI, skills
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 30s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Build & push image (push) Has been skipped
Audit of the plugin + MCP surface after milestone-as-plan (T3): every path
that could still create a kind=plan task or describe the old plan-task model
is now aligned with the hard-retire decision.

- create_task (MCP + REST POST /api/tasks): reject kind=plan with a message
  pointing to start_planning. The 'plan' enum value stays valid so legacy
  plan-tasks remain readable; update paths never touch kind, so they round-trip.
- create_task / get_task docstrings: 'plan' dropped from creatable kinds;
  get_task's rules-augmentation noted as legacy-only (get_milestone for new plans).
- skills/writing-plans: rewritten for milestone-as-plan (body = design, steps =
  child tasks, get_milestone to read back).
- skills/using-scribe: "plans live in milestones via start_planning", not kind=plan.
- TaskEditorView Kind selector: offers Work/Issue; "Plan (legacy)" shown only
  when the loaded task is already kind=plan (display round-trip).
- test: create_task rejects kind=plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:31:51 -04:00
bvandeusen 1f6c592226 feat(plans): milestone-as-plan-container; retire kind=plan (T3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 22s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 59s
The milestone becomes the plan container: a new nullable milestones.body
holds the design/intent (Goal/Approach/Verification) and individual steps
live as first-class child tasks (milestone_id) instead of checkboxes crammed
into one kind=plan task body. start_planning now creates a MILESTONE seeded
with the body template (not a kind=plan task) and returns it with applicable
rules; a new get_milestone MCP tool reads the plan back (body + steps + rules).

kind=plan is hard-retired going forward — start_planning never creates one.
The 'plan' task_kind enum value stays valid so the 11 historical plan-tasks
remain readable in place; no body-shredding backfill (corpus review showed
auto-splitting their checklists into tasks would be lossy: embedded code
blocks, a non-binary [~] state, tables, ID-encoded hierarchy).

- migration 0066: add milestones.body
- model/service/route/MCP: body passthrough on create+update; get_milestone
- server _INSTRUCTIONS: "plan" = milestone w/ body + child step-tasks
- UI: ProjectView shows/edits a milestone's plan body; start_planning expands
  the new milestone and opens its plan editor
- tests updated to the milestone contract + new body/get_milestone coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:22:22 -04:00
bvandeusen c972af2690 ci: make npm cache step non-fatal (fixes recurring typecheck flake #828)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 14s
The TypeScript-typecheck job intermittently failed at 'Cache npm download
cache' (transient cache-backend hiccup), which skipped install + type check and
marked the run red — 3x during the issues+systems build, all on pushes the
cache step had no bearing on. continue-on-error: true degrades a cache failure
to 'install without cache' instead of failing the job.

Closes the rerun churn from task #828.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 11:57:20 -04:00
bvandeusen b6d01686d8 feat(issues): S4b editor controls — Kind selector + Systems multi-select
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 36s
In the full task editor (TaskEditorView) sidebar:
- Kind selector (Work / Plan / Issue), mirroring the Status/Priority selects.
- Systems multi-select (checkboxes of the project's systems, fetched via the
  systems store), shown when a project is set.
Both wired through load (prefill from task.task_kind / task.systems), dirty
tracking, and save (kind + system_ids via the store's IssueFields). No new
colors — existing sb-field/sb-select tokens.

Deferred: the arose-from (provenance) picker — least-critical control and the
riskiest (task-search UI); the field is already supported by API/store/route for
a later add. NEEDS operator browser verification (CI typechecks only).

Refs plan 825 (S4b editor).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:56:53 -04:00
bvandeusen 94d32c524a feat(issues): S4b frontend — open-issues lists + issue plumbing
CI & Build / Python lint (push) Successful in 5s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 1m36s
CI & Build / Build & push image (push) Successful in 59s
- types/note.ts: Note gains systems? + arose_from_id?; TaskKind includes 'issue'.
- stores/tasks.ts: create/update accept IssueFields (kind/system_ids/arose_from_id).
- api/systems.ts: getProjectIssues + TaskLike.
- DashboardView.vue: 'Open issues' rail section from dashboard.open_issues
  (links to /tasks/<id>, project + status).
- SystemsSection.vue (project Systems tab): 'Open issues' list via getProjectIssues,
  with system chips, links to /tasks/<id>. Both reuse existing CSS tokens.

Issue-editor controls (kind selector / system multi-select / arose-from picker
in WorkspaceTaskPanel) are the remaining S4b piece. NEEDS operator browser
verification (CI typechecks only).

Refs plan 825 (S4b frontend — lists).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:50:41 -04:00
bvandeusen 79040fe5db feat(issues): S4b backend — REST task issue fields + dashboard open-issues
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 59s
Closes the REST gap S4b's UI needs (S2 only extended MCP tools):
- routes/tasks.py: create/update accept system_ids (set-semantics) + arose_from_id;
  GET/create/update return the task's associated systems. kind=issue already
  flowed via task_kind. Associations set via services/systems (ACL-checked;
  can_write_note already gated).
- services/dashboard.py: _open_issues section (owner-scoped, ranked like other
  task lists, capped) added to build_dashboard. Dashboard test updated for the
  new key.

Refs plan 825 (S4b, backend half).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 10:42:24 -04:00
bvandeusen 9293a9b198 fix(issues): S4a typecheck — allow null color in updateSystem param
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 37s
vue-tsc TS2345: System.color is string|null, but updateSystem's data param
typed color as string, so the store's Partial<Pick<System,...>> wasn't
assignable. Widen the param's color to string|null (clearing a color is valid).

Refs plan 825 (S4a).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:46:40 -04:00
bvandeusen 4da29562bd feat(issues): S4a UI — Systems management section in project view
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 23s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Has been skipped
Frontend foundation for Issues + Systems (spec #825, S4a).

- frontend/src/api/systems.ts: typed client (System + list/create/update/delete)
  over /api/projects/<id>/systems, matching the rulebooks api style.
- frontend/src/stores/systems.ts: Pinia store keyed by project (fetch/create/
  update/archive/unarchive/delete), toast-on-error.
- frontend/src/components/SystemsSection.vue: a Systems management section —
  cards (color swatch, name, description, 'N open' issue-count badge) with
  inline create/edit, archive (hidden behind a 'show archived' toggle), and a
  delete-confirm modal. v1 quality: loading skeleton, empty state, error toasts,
  keyboard a11y, focus rings; reuses existing CSS tokens (no new colors).
- ProjectView.vue: new 'Systems' tab (between Notes and Rules), rendering
  <SystemsSection :project-id>, wired like the existing rules tab.

S4b (next) adds issue-editor controls (kind=issue/system multi-select/arose-from),
open-issues lists, and the dashboard surface. NEEDS operator browser verification
(CI typechecks but can't render).

Refs plan 825 (S4a).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:42:40 -04:00
bvandeusen 4f22646c88 feat(issues): S3 REST routes — systems CRUD + project open-issues
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 56s
Third slice of Issues + Systems (spec #825).

routes/systems.py (nested /api/projects/<id>/...): GET/POST systems (list adds
per-system open_issue_count via one grouped query), GET/PATCH/DELETE a system
(GET returns records split into issues/tasks/notes), GET .../systems/<id>/records
(kind/open_only filters), GET .../issues (project's open issues for the project
view + dashboard roll-up). login_required; project access via get_project_for_user;
writes gated by can_write_project (clean 403); system.project_id verified to match
the path. Blueprint registered in app.py.

services/systems.py: + open_issue_counts_by_system (one grouped query) and
list_issues (project issues, open by default).

Tests: structural (blueprint registered + in app, handlers callable, service
contracts take user_id) — matches the house route-test pattern.

Refs plan 825 (S3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:14:54 -04:00
bvandeusen 85e0501705 feat(issues): S2 MCP tools — system CRUD + issue/system wiring
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m3s
Second slice of Issues + Systems (spec #825).

New mcp/tools/systems.py: create_system, list_systems, get_system (records
split into issues/tasks/notes), update_system (incl. archive via status),
list_system_records (kind/open_only filters), delete_system. Registered in
register_all; read tools (get_system, list_systems, list_system_records) added
to the read-only-key allowlist (write tools default-deny).

create_task/update_task: kind now accepts 'issue'; new system_ids (set-semantics
associations) and arose_from_id (provenance, 0=unchanged/-1=clear) args.
create_note/update_note: new system_ids arg (notes associate with systems too).
services/notes.create_note: arose_from_id passthrough (update_note already
handles it via setattr).

Tests: MCP system tools + create_task issue-wiring (kind/provenance/systems),
service layer mocked.

Refs plan 825 (S2).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:07:37 -04:00
bvandeusen b91c447b0b feat(issues): S1 schema — issue task_kind, System entity, associations
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 1m5s
First slice of the Issues + Systems feature (spec #825, plan #819 T2).

Schema (migration 0065):
- task_kind CHECK expands work|plan -> work|plan|issue (same-change, rule 36)
- notes.arose_from_id: optional self-FK for issue->originating-task provenance
  (distinct from parent_id sub-task hierarchy)
- systems: per-project, self-describing (name + description) subsystem/area
- record_systems: M2M join linking any note/task/issue to systems (mutable)

Models: System + RecordSystem; note.py gains arose_from_id (+ index, to_dict).
Service services/systems.py: CRUD, archive, soft-delete, set/list associations,
records-for-system, open-issue count — all gated via services/access.py project
permissions (rule 78, no bare-owner filters). Unit tests lock the ACL gating;
the migration is exercised by CI's integration lane (alembic upgrade head).

is_task stays a derived property (status is not None) — unchanged. T1 (typing-
axis rationalization) intentionally NOT bundled; this only adds the enum value.

Refs plan 825 (S1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:53:51 -04:00
bvandeusen 88106309f4 feat(plugin): add 4 Scribe-native process-skills (restore superpowers gap)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m12s
Superpowers was uninstalled but its replacements were never built (only
using-scribe shipped) — a live functional hole. Author the 4 the operator
wants back, each integrated with Scribe's toolset rather than generic copies:
- writing-plans     -> start_planning / kind=plan task, not local .md
- systematic-debugging -> capture issue (symptom->cause->fix, tag issue) on resolve
- verification      -> log results to the task work-log; honest done
- brainstorming     -> recall prior thinking first; capture the decision note

Skipped TDD + receiving-code-review per operator (well-covered by Claude/them).
Manifest + using-scribe list now advertise only the 4 that ship. Remove the
stale docs/superpowers/*.md reference in _INSTRUCTIONS (superpowers is gone).
Plugin 0.1.6 -> 0.1.7.

Refs plan 821 (Phase 3 of 755).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 20:52:34 -04:00
bvandeusen d99c4e3c15 feat(plugin): compaction re-grounding in SessionStart hook (A7)
Deliver 'don't silently lose work at compaction' via the mechanism that
actually works. Verified contract: a PreCompact hook CANNOT make the model
flush to Scribe (host hooks can't trigger model tool calls, and can't know the
in-flight task ids), and its additionalContext only shapes the one-shot summary.
The correct tool is SessionStart scoped to source=compact, which fires AFTER
compaction and injects context the model reads.

Our SessionStart hook is matcher-less, so it already fires on compact — it just
said nothing compaction-specific. Now it reads the stdin event  and,
when source==compact, leads with a banner telling the model to reload the active
project + in-flight tasks from Scribe and reconcile half-remembered state.
Durable path = record-as-you-go (A4/B8) + this post-compaction reload.

Refs plan 812 (A7); supersedes the literal 'PreCompact hook' idea.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:49:10 -04:00
bvandeusen c0b9831b0f feat(mcp): issue-capture convention in _INSTRUCTIONS (B8)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 59s
Finish the breakfix/issue-logging gap as a lightweight convention: when
recording a solved problem, capture symptom -> root cause -> fix and tag it
'issue' so it's findable instead of re-diagnosed. Pairs with the B9 trigger
('log when a problem is found'). No schema change — a structured note_type/
task_kind=issue is deferred to a joint schema pass with B7.

Refs plan 812 (B8 convention; B7 deferred).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:27:33 -04:00
bvandeusen 700cfc664b feat(plugin): surface dynamic-tier failures in SessionStart hook (A2)
Fail-open but no longer silent. When the dynamic context fetch yields nothing,
append a short status line to the injected context so a session can tell
'couldn't load live context' apart from 'Scribe had nothing to say':
- endpoint+token present but fetch empty/failed -> 'instance unreachable / request failed'
- endpoint present but token absent -> fingerprints the known Claude Code
  userConfig export gap ('API token did not reach this hook')
A fully unconfigured install (no url AND no token) stays quiet — static-only is
the intended mode there. Static Tier 1 still always carries the mandate.

Refs plan 812 item A2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:14:27 -04:00
bvandeusen f125f86e16 ref(mcp): make the dev-ACL instruction self-contained (no instance coupling)
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 56s
Drop the '(This instance's rules carry the specifics.)' pointer — universal
_INSTRUCTIONS must not assume this install has a particular rulebook. State the
ACL principle on its own so it holds for any Scribe install/fork.

Refs plan 812 (instance-agnostic product principle).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 16:01:17 -04:00
bvandeusen 95e1d47ceb ref(mcp): neutralize dev-shaped vocabulary in _INSTRUCTIONS + add write-mandate (B9/A4)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m8s
The MCP instructions are domain-neutral except a thin layer of dev vocabulary
and one project-specific paragraph (B10 audit, task 812). Make the data store's
own instructions serve any domain, and add the missing positive write-mandate.

B9 (neutralize):
- 'before writing code' -> 'before you dive in'
- Note examples 'dev-logs' -> 'logs of what happened'
- record trigger 'a merge, a shipped feature, a finished plan' + 'dev-log note'
  -> 'finishing a task, or hitting/discovering a problem that changes direction'
  (folds in B8: log pivots, not just wins; mirrors the static-tier wording)
- recall examples 'ticket/dev-log' -> 'task/prior note' (server + SKILL.md)
- 'Engineering and workflow rules' -> 'Workflow and standards rules'
- slim the 'developing Scribe itself' ACL paragraph to a neutral one-liner
  (project-specific specifics already live in rules #47/#78)

A4 (write-mandate): state up front that Scribe is the system of record — record
work here, recall before acting, don't keep project work in local files.

Refs plan 812 (B9, A4, B8-trigger); B10 audit work-log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:57:22 -04:00
bvandeusen f2ab02ba2b ref, plugin: neutral, concrete triggers in static SessionStart mandate
Plain-language 'related prior work' instead of 'prior art'; replace the
dev-shaped 'meaningful landing (a merge, a shipped feature, a finished plan)'
with concrete neutral triggers — log on task completion and when a problem is
found, so direction pivots are captured, not just successes. Keeps the static
mandate domain-neutral (pre-empts B9 drift in plan 812).

Refs task 809 / plan 812 item A1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:20:18 -04:00
bvandeusen d11eb9145b feat(plugin): two-tier SessionStart hook — static keyless floor + dynamic enrichment
The SessionStart push channel was single-tier: it curled /api/plugin/context
with a Bearer token and, on any failure (missing/unexported token, network
error, missing curl), injected nothing and exited 0 — silently. A known
upstream Claude Code gap (sensitive userConfig not reliably exported to hook
subprocesses) trips this routinely, so a fresh session gets no signal to reach
for Scribe and falls back to local file-memory (root cause of unlogged work on
remote/rc sessions).

Split into two tiers:
- Tier 1 (static, keyless, networkless, always fires): inject bundled
  scribe_static_context.md — the load-bearing behavioral mandate. Cannot be
  suppressed by the upstream key bug.
- Tier 2 (dynamic, best-effort, fails open): existing curl for live rules +
  active-project context, appended below the static block. Lights up as
  enrichment once the key reaches the hook.

Only jq is now required (JSON envelope); curl/token gate the dynamic tier only.
Bump plugin 0.1.5 -> 0.1.6 so clients pick up the change.

Refs milestone 55; task 809; decision note 810.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 15:01:35 -04:00
bvandeusen e631a4e615 Merge pull request 'feat(plugin): Scribe replaces native memory by instruction; tighten project-scope discipline' (#70) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 15s
2026-06-10 13:35:28 -04:00
bvandeusen 9eddb8497c feat(plugin): Scribe replaces native memory by instruction; tighten project-scope discipline
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 57s
Direction change (operator, see plan task #755 work-log): the plugin must
NOT depend on disabling a native Claude function to work. It earns its place
by steering behavior, not by toggling autoMemoryEnabled.

Memory doctrine (no dual-write):
- using-scribe SKILL.md gains "Scribe holds these functions — don't keep a
  second copy": route rules/recall/planning to Scribe, don't also write them
  to native auto-memory, never instruct disabling a native function, and
  accept a "Scribe-shaped hole" if the plugin is removed (recover over time).
- mcp/server.py _INSTRUCTIONS: drop the paragraph that told the model to
  create/refresh a "rules live in Scribe" pointer in CLAUDE.md / ~/.claude
  memory. That was an active dual-write instruction; the SessionStart hook is
  the bridge now. Replaced with the no-dual-write / no-settings-dependency
  doctrine. Supersedes plan #755 Phase 6 ("set autoMemoryEnabled:false").

Project-scope discipline (stop cross-project bleed):
- using-scribe SKILL.md gains "Stay inside the active project's scope": pass
  project_id to every read, only reference/offer work on the in-scope project,
  ask before switching.
- _INSTRUCTIONS scope bullet extended from reads to referencing/offering, and
  flags get_recent as cross-project.
- get_recent docstring gains a scope note steering to scoped list_* when a
  project is active.

plugin.json 0.1.4 -> 0.1.5 so clients' caches actually refresh (re-shipping
under the same version does not bust the cache).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:08:17 -04:00
bvandeusen 974fa6a215 Merge pull request 'feat(ui): declutter dashboard done-recently + MCP-access, add per-project stats' (#69) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 14s
2026-06-10 11:11:23 -04:00
bvandeusen da511fcc9f feat(ui): declutter dashboard done-recently + MCP-access, add per-project stats
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 31s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 58s
Dashboard:
- 'Done recently' chip-cloud -> compact uniform list (Active-now row style),
  showing 5 with inline expand to the rest (backend already returns up to 8).
- New 'Projects' rail card: each active project with 'N open · M done'.
  Backend already computed done_count (dashboard.py) — now surfaced in the
  /api/dashboard payload per active project.

MCP Access (Connect Claude / Claude Code):
- Progressive disclosure: lead with the pre-filled plugin-install snippet;
  fold server name, scope, marketplace URL, and the MCP-only path into a
  single 'Customize' expander. Desktop tab keeps its own server-name field.
- Marketplace URL now defaults to this instance's own repo via
  config.PLUGIN_MARKETPLACE_URL (env-overridable); /api/plugin/marketplace-url
  falls back to it, so the field + install snippet are pre-filled out of the
  box instead of showing a generic placeholder.

Refs #761

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:04:23 -04:00
bvandeusen 79aec4f9c1 Merge pull request 'chore(plugin): bump to 0.1.4 so clients pick up the using-scribe update' (#68) from dev into main 2026-06-10 10:43:23 -04:00
bvandeusen 2f9c9b0e0b chore(plugin): bump to 0.1.4 so clients pick up the using-scribe rule-scope update
SKILL.md gained the 'Where a new rule goes' section (rule-scope model) in
50b6902 but plugin.json was not bumped, so autoUpdate clients stay on 0.1.3
and never reinstall the new skill content. Bump to propagate.

(MCP tool descriptions are unaffected by this — they are served live by the
remote app and refresh on the next session's MCP handshake, not via the
plugin bundle.)

Refs #755

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:42:55 -04:00
bvandeusen 4c8044826f Merge pull request 'docs(mcp): encode rule-scope model in rulebook tool descriptions' (#67) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 13s
2026-06-10 10:18:50 -04:00
bvandeusen 50b6902fe2 docs(mcp): encode rule-scope model in rulebook tool descriptions
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 59s
Make the always-on / subscribed / project-rule distinction explicit at the
authoring surface so it can't silently regress (for this operator or other
users). Previously the tools said only 'cross-project rulebook rule' and a
bare 'subscribe a project' — nothing steered project-specific detail away
from shared rulebooks, which is how a Scribe-pinned rule ends up binding
every family project.

Principle encoded in 5 places: a rule's home is chosen by WHO it should bind,
and both rulebook tiers are SHARED so their rules stay general — they differ
in reach (all projects vs opt-in by theme), not generality. Project-specific
detail goes in create_project_rule.

- server.py MCP instructions: add the 3-tier authoring principle
- create_rule / create_rulebook / create_project_rule / subscribe_* docstrings
- using-scribe SKILL.md: a 'Where a new rule goes' note for the pull path

Refs #755

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:15:01 -04:00
bvandeusen 2a5f5fdbe1 Merge pull request 'feat(plugin): make using-scribe skill actively pull standing rules' (#66) from dev into main 2026-06-10 02:20:28 -04:00
bvandeusen 1983e8f4b1 feat(plugin): make using-scribe skill actively pull standing rules
The SessionStart push channel cannot reliably deliver a sensitive API
token to the hook subprocess (upstream Claude Code bug anthropics/
claude-code#62442 — sensitive plugin userConfig is not persisted and is
absent on a normal session). Stop depending on that push for standing
rules: make the using-scribe bootstrap skill own the load instead.

- description: name the FIRST ACTION (list_always_on_rules + enter_project
  when a repo/project is in scope) so it auto-surfaces at session start
- add a 'Do this first' block instructing an active pull; demote the
  SessionStart hook to a bonus, not a precondition (it fail-opens and may
  be absent)
- reflex step 2: rules come from list_always_on_rules(), not from an
  assumed SessionStart injection

The hook + hooks.json are left in place: they fail-open and resume adding
value automatically if #62442 is fixed or the token is made non-sensitive.

Refs #755 (Phase 1: push channel descoped to optional; pull is load-bearing)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 02:19:13 -04:00
bvandeusen 30826d250c chore: remove pre-plugin scribe_session_context.sh dogfood hook
Superseded by the plugin's own SessionStart hook (plugin/hooks/). This root
scripts/ copy read the now-deleted project .mcp.json (dead scribe-dev /
devassistant host), so it could never fire. Single Scribe environment now,
reached only via the Scribe plugin MCP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:49:12 -04:00
bvandeusen 2c36249c15 Merge pull request 'feat(plugin): resolve session project from git remote, not a pinned project_id' (#65) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 13s
2026-06-10 01:37:48 -04:00
bvandeusen 8fe571e175 feat(plugin): resolve session project from git remote, not a pinned project_id
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m0s
The SessionStart hook asked for a project_id via plugin userConfig, which pins
one install to a single project — wrong for an operator working across many
repos/projects. Resolve the active project server-side from the working repo's
git remote instead (a stable identifier, not a dir-name guess).

- repo_bindings table (migration 0064) + RepoBinding model: (user, repo_key) ->
  project, FKs CASCADE.
- services/repo_bindings: normalize_repo_key collapses ssh/https/scp/creds/port/
  .git to host/owner/repo; resolve/set/list/delete.
- GET /api/plugin/context takes ?repo=<remote>; unbound repo -> a "bind this
  repo" hint with a ready bind_repo() call. project_id kept as manual override.
- MCP tools: bind_repo / list_repo_bindings / unbind_repo.
- Hook sends ?repo=$(git remote get-url origin) URL-encoded; all project_id
  handling removed. plugin.json drops the project_id userConfig (0.1.2 -> 0.1.3).
- Tests: normalize equivalence classes + unbound-hint rendering.

Refs task 755 (Scribe-as-plugin push channel).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:33:58 -04:00
bvandeusen 6cc47c7222 Merge pull request 'fix(plugin): session-start hook reads API token from CLAUDE_PLUGIN_OPTION env, not user_config placeholder' (#64) from dev into main 2026-06-10 00:52:02 -04:00
bvandeusen 651119cfb0 fix(plugin): read API token from CLAUDE_PLUGIN_OPTION env, not user_config placeholder
The SessionStart push-channel hook passed the key via
SCRIBE_TOKEN="${user_config.api_token}" in hooks.json, but api_token is
sensitive:true. Claude Code keeps sensitive userConfig in the keychain and
does not interpolate it into hook command strings (only into mcpServers
headers), so the hook received the literal placeholder, sent it as the Bearer
token, and the context endpoint 401'd -> fail-open -> no context injected.

Read the harness-exported CLAUDE_PLUGIN_OPTION_<key> env vars instead (SCRIBE_*
still override for the settings.json dogfooding path), and treat any unexpanded
${...} literal as unset so the hook fails open cleanly instead of 401-ing.
Bump 0.1.1 -> 0.1.2 so installs refresh the cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:43:40 -04:00
bvandeusen 3a5835b109 Merge pull request 'fix(plugin): load the bundled MCP server + admin-configurable marketplace URL' (#63) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 12s
2026-06-10 00:25:22 -04:00
bvandeusen ff91948fa3 chore(plugin): bump to 0.1.1 so clients pick up the mcpServers fix
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:25:09 -04:00
bvandeusen 559f70eef0 fix(plugin): inline mcpServers in plugin.json so the MCP server actually loads
/reload-plugins reported '0 plugin MCP servers'. Root cause: plugin.json had
"mcpServers": "./.mcp.json" — a string path, which is neither a valid inline
object nor a recognized reference (per docs, plugin MCP servers are a root
.mcp.json OR an inline object in plugin.json), so it parsed to zero servers.

Inline the mcpServers object directly in plugin.json and remove the separate
.mcp.json. The user_config substitution syntax was already correct
(plugins-reference: values substitute as ${user_config.KEY} in MCP configs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:24:06 -04:00
bvandeusen 1d82e81527 feat(plugin): admin-configurable marketplace URL as the install default
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 59s
The Settings install command had a <your-scribe-repo> placeholder — not
copyable. Add an instance-global 'plugin_marketplace_url' setting (admin sets
it to the app's own repo) that every user's MCP Access reads, so the
/plugin marketplace add command is copyable out of the box. Keeps it universal
(each deployment configures its own repo) rather than hardcoding one.

- services/settings.get_admin_setting(key): admin-scoped global read.
- routes/plugin: GET /api/plugin/marketplace-url (any user) + PUT (admin).
- SettingsView: Admin → 'Plugin marketplace' field to set it; MCP Access
  marketplace field falls back to the configured value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:17:05 -04:00
bvandeusen b1674169a0 Merge pull request 'feat: Scribe-as-plugin foundation (push-channel endpoint, in-repo plugin, plugin-install Settings UI, backup v3)' (#62) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 15s
2026-06-10 00:02:53 -04:00
bvandeusen c0b3ec7d9b feat(settings): lead MCP Access with plugin install, demote raw MCP
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 57s
Per operator: the plugin install should supersede the bare MCP connection in
Settings, since the plugin incorporates the MCP and adds the session-start hook
+ skills. The Claude Code tab now leads with /plugin marketplace add + install
(with a persisted marketplace-URL field and the base-URL/key/project-id prompts
spelled out), and the old 'claude mcp add' command moves into a collapsed
'Advanced: connect the MCP only' disclosure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:52:15 -04:00
bvandeusen d6c8470ab2 feat(backup): v3 backup covers rulebooks, rules, events + join tables
The v2 backup silently dropped the entire rulebook system (rulebooks, topics,
rules), the project subscription/suppression join tables, and events — so a
'full' backup wasn't. v3 adds all of them with FK re-mapping on restore, and a
_not_included field that names the still-deferred tables (ACL groups/shares,
api_keys, embeddings, transient/operational) so the gap is explicit, not silent.

restore_full_backup routes v2 and v3 through one path; v3-only sections are
guarded by data.get so a v2 payload still restores cleanly.

Tests: version/coverage constants, pure join-table row helpers, and the export
contract via a mocked session (CI has no DB; full round-trip is a manual check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:52:14 -04:00
bvandeusen 9924f873b9 feat(plugin): ship the Scribe Claude Code plugin in-repo (marketplace + plugin)
Per operator: the plugin lives in the app repo so it ships and versions in
lockstep with the app and the /api/plugin/context contract it targets (same
co-location rationale as the former in-repo MCP). A git-cloned marketplace
supports relative plugin sources, so the FabledScribe repo IS the marketplace.

- .claude-plugin/marketplace.json — source ./plugin
- plugin/.claude-plugin/plugin.json — userConfig (base URL, api key, project id)
- plugin/.mcp.json — http scribe server, ${user_config.*} substitution
- plugin/hooks/ — SessionStart push-channel hook (fail-open)
- plugin/skills/using-scribe — bootstrap skill
- plugin/README.md — install via the FabledScribe repo marketplace

Phase 2 of plan #755. Install/userConfig-substitution test pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:04:09 -04:00
bvandeusen 3ab16fcbdb feat(plugin): add /api/plugin/context push-channel endpoint + dogfood hook
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 1m6s
Phase 1 of plan #755 (Scribe-as-plugin). Gives Scribe its own session-start
push channel so always-on rules + active-project context surface without being
asked — the gap behind 'I have to prompt for everything'.

- services/plugin_context.build_session_context: renders always-on rule titles
  grouped by topic (under the 10k additionalContext cap; full text stays one
  list_always_on_rules/get_rule call away) + optional project goal/open-task
  count + a recall/update-over-create reflex line. Capped at 9000 chars.
- routes/plugin GET /api/plugin/context (login_required already accepts Bearer
  fmcp_ keys; read scope suffices).
- tests: titles-not-statements, project scoping, length cap (pure mocks).
- scripts/scribe_session_context.sh: dogfood SessionStart hook, fail-open,
  reads url+token from .mcp.json. Superseded in Phase 2 by the plugin-bundled
  hook using userConfig.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:43:08 -04:00
bvandeusen 8c1b19f49c chore(docs): retire dead fable-mcp wheel-distribution refs; untrack docs/superpowers
The old standalone fable-mcp wheel/download flow is gone from code (no route,
no Dockerfile build, no FABLE_MCP_DIST_DIR). Update api-keys-and-mcp,
api-reference, architecture, configuration, development to describe the
in-app HTTP MCP at /mcp (Bearer auth). Untrack the 18 committed
docs/superpowers/ files so the existing .gitignore takes effect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:42:58 -04:00
bvandeusen 51feaddcd3 Merge pull request 'feat(mcp): make Scribe reflexively recall + scope reads to the active project' (#61) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 17s
2026-06-04 23:13:27 -04:00
bvandeusen e3c6124912 feat(mcp): make Scribe reflexively recall + scope reads to the active project
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 56s
The MCP surface advertised writing well but recall poorly, and project
scoping had no anchor that survived past enter_project's snapshot:

- search / list_notes dropped the project_id their services already
  support, so a scoped search was impossible — every query swept all
  projects and bled unrelated work into the session.
- The tool descriptions were mechanical ("Semantic search over the
  user's notes and tasks") with no trigger telling Claude WHEN to reach
  for them; the server instructions were all write-discipline and said
  nothing about searching before answering or starting work.

Changes:
- search, list_notes: add project_id param, wired to the service.
- search, list_notes, list_tasks: trigger-worded descriptions that push
  passing the active project's id and reserve project_id=0 for a
  deliberate cross-project sweep.
- _INSTRUCTIONS: add a 'Reach for Scribe to RECALL, not just to record'
  block — search before answering/starting, check for an existing ticket
  before create_task, scope reads to the active project (which does not
  stick on the server).

Paired with always-on rule #75 in the FabledSword-family rulebook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:43:55 -04:00
bvandeusen 964c8005d5 Merge pull request 'Package rename (fabledassistant→scribe) + pivot cleanup' (#60) from dev into main
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 16s
2026-06-03 16:28:10 -04:00
bvandeusen 70ab3f38c6 chore: remove pre-pivot dead code + finish Scribe rebrand (#599 t1-3)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m1s
- Header wordmark Fabled -> Scribe; fable:calendar-changed event ->
  scribe:calendar-changed; SettingsView CSS comment.
- Drop dead Project.auto_summary + summary_updated_at columns (migration
  0063) -- the Ollama-era summarizer is gone; model + 2 frontend types +
  projects test updated.
- Remove pivot vestiges: diagnostics _curator_busy()/curator_busy
  heartbeat field, tz BRIEFING_DAY_START_HOUR/user_briefing_date dead
  aliases, the ignored 'model' param on get_embedding (+ its test).

ruff src/ clean; CI is the gate. Part of scribe plan #599.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:16:44 -04:00
bvandeusen b255a0f90e refactor: rename package fabledassistant -> scribe (code-only)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 1m14s
Renames src/fabledassistant -> src/scribe and all imports, plus the
default DB name and DB user/password (fabled -> scribe) in config +
compose. 952 refs / 154 files. Reverses the old 'internal name stays
fabledassistant' convention.

Code-only: live databases are still physically named 'fabledassistant'.
Deployed environments must set POSTGRES_DB / POSTGRES_USER (or rename the
DB) since the defaults now resolve to 'scribe'. Repo (FabledScribe), git
host (fabledsword), MCP (fabled-git) and the image name (fabledscribe)
are intentionally unchanged.

ruff check src/ clean locally; CI (typecheck + pytest) is the gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:48:35 -04:00
bvandeusen 9ddc418f5f Merge pull request 'main publishes :latest + ACL _INSTRUCTIONS guard' (#59) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 49s
CI & Build / Build & push image (push) Successful in 15s
2026-06-03 14:41:15 -04:00
bvandeusen 1d4c206563 ci: main publishes :latest (main is the production line)
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 54s
CI & Build / Build & push image (push) Successful in 18s
main pushes now move :latest (in addition to the immutable :<sha>), so a
merge to main updates production's pointer directly — no separate release
needed just to refresh :latest. The v* release tag's distinct job becomes
the dated :<version> marker (it still refreshes :latest harmlessly). Still
no :main tag. Rules 47/46 + 10/4 updated to match on both instances.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:27:53 -04:00
bvandeusen 301352f628 Merge pull request 'MCP _INSTRUCTIONS: multi-user sharing ACL guard' (#58) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 25s
2026-06-03 14:18:01 -04:00
bvandeusen d4666bea7f feat(mcp): add multi-user sharing ACL guard to _INSTRUCTIONS
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m15s
The ACL constraint (scope every read/mutation by owner + shares via
services/access.py) is a security-correctness invariant that should
always be loaded, and it's FabledScribe-specific — so it belongs in
Scribe's own contained _INSTRUCTIONS, not the cross-project FabledSword
rulebook. The redundant rulebook rule will be retired once this ships
to prod.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:47:02 -04:00
bvandeusen 837489e4f2 Merge pull request 'CI: build on main (and drop the :main tag)' (#57) from dev into main
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 14s
2026-06-03 12:44:44 -04:00
bvandeusen 9a0d5f3109 ci: drop the :main tag — main builds publish only the immutable :<sha>
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 14s
:latest (release-only) is the single production pointer; a :main moving
tag just duplicated it. main pushes still gate + build (the :<sha> image
is the rollback point), but no longer publish a :main alias. The tag was
new and unreferenced, so nothing depends on it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:52:21 -04:00
bvandeusen 5a930319ba ci: gate and build main too (:main image); :latest stays release-only
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 32s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 15s
Previously main pushes were deliberately skipped — CI only ran on dev
and v* tags. This conflicted with the intended policy (CI on dev AND
main). Now main is a first-class gated, built line: dev->:dev, main->:main,
v* tag->:latest + :<version>, every build also tagged with the commit sha.
Per-ref concurrency already supersedes rapid pushes, so dev and main run
independently without stacking identical work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:27:09 -04:00
bvandeusen 266af7870d Merge pull request 'MCP instruction hardening + milestone-unset' (#56) from dev into main 2026-06-03 11:19:09 -04:00
bvandeusen f446573c3d feat(mcp): proactive project bootstrapping at session start
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m6s
Adds an always-on _INSTRUCTIONS directive: when work touches Scribe and
no project is in scope, search for a related project and propose
enter_project (confirm first), or offer to create one (confirm name/goal
first) — never silently adopt or create. Pairs with the enter_project
handshake and the host-memory pointer directive. Closes scribe task #585.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 11:01:55 -04:00
bvandeusen 82d6812c7f feat(mcp): milestone_id=-1 clears a task's milestone (update_task)
Optional FKs on update_task previously had no way to express 'remove' —
0 meant leave-unchanged and any positive int meant set, so a milestone
(or project) could only be cleared via the web UI. Now -1 clears the FK
(NULL); clearing project_id also clears milestone_id since a milestone
can't outlive its project. update_note already NULLs on None, so the
change is confined to the tool wrapper. Closes scribe task #586.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:59:47 -04:00
bvandeusen 8c9ca45479 feat(mcp): instruct agents to keep a Scribe-rules pointer in host memory
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 1m6s
When a project subscribes to a rulebook, the agent should ensure the
host's persistent memory carries a pointer that engineering/workflow
rules live in Scribe (loaded via list_always_on_rules / enter_project),
plus a one-line note of the current project's work. Pairs with the
existing 'don't duplicate rules into memory' directive: memory holds the
pointer + project context, Scribe holds the rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 10:42:23 -04:00
bvandeusen e023c21aa1 docs(mcp): instruct agents to drive task lifecycle + log work
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m48s
Adds a 'keep task state honest' directive to the MCP _INSTRUCTIONS: set
in_progress on start, log progress with add_task_log as you go, set done the
moment work completes (never leave finished work at todo), and write a dated
dev-log note on the project at significant landings. Reinforced in the
update_task status docstring. App-layer + always-loaded, no rule/config needed
— closes the gap where finished work (e.g. a shipped plan) sat open because the
lifecycle was available but never prescribed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 09:24:26 -04:00
bvandeusen e3d7007417 Merge pull request 'Drift-audit remediation + Stored Processes + Dashboard' (#55) from dev into main 2026-06-03 08:11:14 -04:00
bvandeusen 65c85bab15 feat(dashboard): DashboardView landing + route + nav
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 43s
CI & Build / Build & push image (push) Successful in 44s
Task 3 of #583. New DashboardView at /dashboard composes the approved layout:
done-recently strip, Active-now project panels (project -> active milestones ->
open tasks, in-progress flagged, + no-milestone group), and a rail with
upcoming events / week stats / quick-create (Task/Note/Process). '/' now
redirects to /dashboard; AppHeader gains a Dashboard link and relabels
Knowledge -> Browse (route unchanged). Empty + loading states included.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:24:32 -04:00
bvandeusen 7ef7d10b24 feat(dashboard): GET /api/dashboard endpoint
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 44s
CI & Build / Build & push image (push) Successful in 1m17s
Task 2 of #583. Minimal login-gated blueprint returning build_dashboard(uid);
registered in the app factory.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:22:08 -04:00
bvandeusen 6a3619555d feat(dashboard): aggregation service build_dashboard
Task 1 of #583. build_dashboard(user_id) assembles the /dashboard payload:
most-recently-active projects (ranked by max child updated_at) each broken
into active milestones -> open tasks (in_progress->priority->recency, capped 5),
recently-completed (7d/8), upcoming events (7d), week stats. Owner-scoped,
trashed excluded; each section isolated via _safe so one failure doesn't blank
the page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:21:23 -04:00
bvandeusen 8b3bd4804e feat(processes): monospace prompt editor for note_type=process
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Successful in 41s
Task 6 of #582. NoteType gains 'process'. NoteEditorView branches to a plain
monospace textarea (labeled Prompt) for processes instead of the TipTap
rich-text editor — prompts are plain markdown and rich-text round-tripping
would mangle them. Title/tags/save path unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:33:23 -04:00
bvandeusen 74b337b587 feat(processes): surface processes in the Knowledge view
Task 5 of #582. Add 'process' to the KnowledgeItem/activeType/KnowledgeCounts
types, a Processes entry in the type-filter row, a Workflow-icon quick-create
button (createNew('process') -> /notes/new?type=process), and a Process card
badge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:33:23 -04:00
bvandeusen fb1ae915e4 feat(processes): expose process as a knowledge type
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 58s
CI & Build / Build & push image (push) Successful in 1m16s
Task 4 of #582. Add 'process' to the knowledge route _VALID_TYPES and to the
get_knowledge_counts facet + total. query_knowledge/_apply_type_filter already
handle arbitrary note_type, so listing by type=process works unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:29:08 -04:00
bvandeusen c2b2694ea3 docs(mcp): document Processes in server instructions
Task 3 of #582. Tells Claude that note_type=process notes are reusable saved
prompts and to fire them via list_processes/get_process on 'run the X process'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:29:08 -04:00
bvandeusen 7b5a75989a feat(processes): MCP create/list/get/update_process tools
Task 2 of #582. New mcp/tools/processes.py mirrors entities.py — tools wrap
notes_svc directly. get_process is the fire mechanism (returns the full prompt
via resolve_process; surfaces other_matches on an ambiguous name). Registered
in register_all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:27:41 -04:00
bvandeusen 1babe59843 feat(processes): add resolve_process name/id resolver
Task 1 of the Stored Processes plan (#582). resolve_process(user_id, name_or_id)
resolves a note_type=process note owner-scoped + non-trashed, precedence
numeric id -> exact case-insensitive title -> substring; returns
(note, other_candidates) so an ambiguous fuzzy match can be disambiguated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 22:26:29 -04:00
bvandeusen 2c929a0435 feat(reminders): per-occurrence reminders for recurring events
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Successful in 1m6s
Drift-audit Group 8 (final item). _fire_reminders previously gated on the
base row (reminder_sent_at IS NULL AND start_dt > now), so a recurring event
reminded at most once ever — once the first occurrence passed, no future
occurrence qualified.

Now recurring events are evaluated every sweep against their next occurrence
(rrulestr.after(now)), and reminder_sent_at stores the start of the occurrence
last reminded about. Each new occurrence has a distinct marker, so it re-arms
and fires exactly once per occurrence. One-shot events keep the classic
NULL gate. Also adds the deleted_at filter so trashed events stop reminding.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:50:45 -04:00
bvandeusen cf4962d7e8 chore(profile): drop dead curator columns from UserProfile
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 1m1s
CI & Build / Build & push image (push) Successful in 58s
Drift-audit Group 7: learned_summary, observations_raw, and
observations_updated_at were populated by the curator/LLM-profile machinery
removed in the Phase-8 pivot. Nothing has written them since and the profile
API returned permanently-empty fields. Remove them from the model + to_dict
and drop the columns (migration 0062). Verified zero frontend/backend/test
consumers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:48:37 -04:00
bvandeusen 64bc50c788 chore(frontend): drop dead settings toggle, types, and store list-surface
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 34s
CI & Build / Python tests (push) Successful in 56s
CI & Build / Build & push image (push) Has been cancelled
Drift-audit Group 7 frontend cleanup (no behavioral change):

- SettingsView: remove the 'auto-consolidate task bodies' toggle and its
  saveAutoConsolidate handler. The auto_consolidate_tasks setting has zero
  backend readers (curator removed in Phase 8); the control did nothing.
- AppSettings type: drop the dead assistant_name / default_model hints (kept
  the open string index signature the store actually uses). Delete the fully
  orphaned types/chat.ts (zero importers).
- notes/tasks Pinia stores: remove the list/filter/sort/pagination surface
  that backed the removed /notes and /tasks list views (verified no consumer
  uses the tasks/notes arrays, refresh, or any filter/sort/pagination method).
  Kept currentNote/currentTask, loading, fetch/create/update/delete, convert,
  patchStatus, startPlanning, backlinks, tags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:47:28 -04:00
bvandeusen c39d7356ed chore(dead-code): fix prod image, drop orphaned code, correct delete_rule doc
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m40s
Drift-audit Group 7 (renamed/removed lingers) + Group 5 #9:

- docker-compose.prod.yml pulled fabledassistant:latest, a tag CI stopped
  publishing after the rename. Point it at fabledscribe:latest (the name CI
  and quickstart use). The internal DB name stays fabledassistant by design.
- Remove the unused hard-delete delete_note imports from the notes and tasks
  route modules (they delete via trash; the import was an attractive nuisance
  that bypassed soft-delete).
- delete_rule MCP tool: docstring/warning said 'permanently delete' but the
  body moves the rule to recoverable trash. Corrected to match.
- Delete services/calendar_sync.py: fully orphaned (zero importers) and it
  read Config attrs that no longer exist, so any re-wiring would crash.
- Remove dead services: notes.search_notes_for_context and logging.log_generation
  (zero callers; log_generation wrote a 'generation' category no stats/UI surface).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:28:36 -04:00
bvandeusen 8d739c5da1 perf(search): offload cosine scoring off event loop; document best-effort feed
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Build & push image (push) Has been cancelled
CI & Build / Python tests (push) Has been cancelled
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>
2026-06-02 19:24:57 -04:00
bvandeusen 7ce5bb8450 fix(lifecycle): OAuth pw 500, invite lockout, reminder re-arm, partial-unique
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 38s
CI & Build / Python tests (push) Successful in 57s
CI & Build / Build & push image (push) Has been cancelled
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>
2026-06-02 19:23:35 -04:00
bvandeusen 2fd9a2300a fix(caldav): point-event round-trip, recurrence push, delete propagation
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 36s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 1m16s
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>
2026-06-02 19:20:14 -04:00
bvandeusen c016bd664e fix(status-enum): add paused to ProjectStatus, validate, fix progress
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 46s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 1m14s
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>
2026-06-02 19:16:45 -04:00
bvandeusen 4a220db513 test(mcp): import resolve_bearer at module level
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python tests (push) Successful in 46s
CI & Build / Build & push image (push) Successful in 1m32s
CI fix for aef5009: test_resolve_bearer_none_for_invalid referenced
resolve_bearer but the import lived inside an earlier test only. Hoist it
to the module import. Production code unaffected (1 failed / 284 passed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 19:12:14 -04:00
bvandeusen aef5009fc2 fix(contract-drift): MCP read-only scope, shared-note writes, event TZ
CI & Build / Python lint (push) Successful in 3s
CI & Build / Python tests (push) Failing after 24s
CI & Build / TypeScript typecheck (push) Successful in 42s
CI & Build / Build & push image (push) Has been skipped
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>
2026-06-02 19:02:19 -04:00
bvandeusen c363a5a6df fix(retention): add cleanup sweeps + CalDAV orphan reconciliation
CI & Build / TypeScript typecheck (push) Successful in 37s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Python tests (push) Successful in 42s
CI & Build / Build & push image (push) Successful in 1m29s
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>
2026-06-02 18:55:23 -04:00
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 8b49ea896a fix(schedulers): wire recurring-task spawn + deliver event reminders
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 1m10s
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>
2026-06-02 18:47:54 -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 0e980ee4b0 Merge pull request 'Project rule + topic suppressions' (#54) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 22s
2026-06-01 08:01:59 -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 b5870d4694 Merge pull request 'Rules consolidation: Scribe-first check, project-scoped rules, enter_project handshake' (#53) from dev into main 2026-06-01 01:16:55 -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 c810d63bee Merge pull request 'MCP plan-prompt tune + Flutter docs removal' (#52) from dev into main
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 45s
CI & Build / Build & push image (push) Successful in 24s
2026-06-01 00:13:31 -04:00
bvandeusen fd20b67b22 docs: remove Flutter companion app from project surface
CI & Build / Python lint (push) Successful in 8s
CI & Build / TypeScript typecheck (push) Successful in 39s
CI & Build / Python tests (push) Successful in 52s
CI & Build / Build & push image (push) Successful in 3m43s
The Flutter app (separate fabled_app repo) no longer adds value over
web/PWA access. Strip the in-repo surface that referenced it:

- delete docs/android-app.md
- drop README docs-table row and feature-list mention
- drop the two Flutter roadmap bullets from docs/features.md
- remove the Flutter port subsection from docs/design-system.md

The standalone fabled_app repo is untouched here; archival/deletion
of that repo is a separate decision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:37:35 -04:00
bvandeusen 7031e36670 chore(mcp): tighten plan-instruction to override .md plan habit
The prior wording ("not in local .md files") was a footer after a
how-to and lost to the much louder superpowers brainstorming /
writing-plans skill flow, which terminates by saving to
docs/superpowers/specs/*.md and docs/superpowers/plans/*.md.

Reorder so start_planning is named as the FIRST action, explicitly
override the .md skill paths, and extend the rule to cover specs as
well as plans (matches the rulebook's rule 27).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-31 23:31:46 -04:00
bvandeusen a3a056d6fd v26.05.30.1 — MCP-first pivot + Rulebook + Plans + Soft-delete 2026-05-29 22:50:34 -04:00
bvandeusen 2f577fee58 fix(mcp): stateless HTTP transport so client reconnects after redeploy
Stateful session manager strands Claude Code after a container redeploy:
it reconnects with a now-unknown Mcp-Session-Id, the server 404s, and the
client won't re-initialize on a 404 (claude-code #60949). Stateless makes
each request self-contained (bearer-auth only) so post-deploy reconnect
works without a manual /mcp retry.
2026-05-29 13:16:38 -04:00
bvandeusen e5565b73dc feat(trash): Settings retention field (trash_retention_days, 0=keep forever) 2026-05-29 12:12:04 -04:00
bvandeusen b579aa1c88 feat(trash): TrashView page, nav links, and g+x shortcut 2026-05-29 12:10:27 -04:00
bvandeusen e5796b6f5c feat(trash): frontend trash API client + Pinia store 2026-05-29 12:07:44 -04:00
bvandeusen d8f577e753 feat(trash): daily retention purge scheduler (03:30 UTC) wired into app lifecycle 2026-05-29 11:47:02 -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 c3af24ef51 feat(trash): MCP delete tools soft-delete via trash; add delete_task/project/milestone 2026-05-28 21:10:13 -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 e7f214fc80 feat(trash): exclude trashed rows from notes + knowledge read paths 2026-05-28 20:30:23 -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 7f0d99d383 feat(trash): SoftDeleteMixin applied to the 7 soft-deletable models 2026-05-28 19:54:01 -04:00
bvandeusen 84b75f7a73 feat(trash): migration 0057 — deleted_at + deleted_batch_id on 7 tables 2026-05-28 19:53:19 -04:00
bvandeusen 30fbf7b117 docs(mcp): add conceptual primer (what each entity is for) to MCP instructions 2026-05-28 18:25:38 -04:00
bvandeusen d04b6f4bba style(rulebook): narrow the Topics column in /rules 2026-05-28 11:42:08 -04:00
bvandeusen 311322fdc8 fix(plan): export startPlanning from tasks store (type-check) 2026-05-28 11:20:44 -04:00
bvandeusen dc93675470 feat(plan): KnowledgeView Plans facet + plan badge (knowledge endpoints + UI) 2026-05-28 11:12:59 -04:00
bvandeusen 2f5ef9124a feat(plan): Start planning button on project view 2026-05-28 10:44:40 -04:00
bvandeusen b30cf06096 fix(plan): move Applicable Rules panel to TaskEditorView (the routed task surface) 2026-05-28 10:43:41 -04:00
bvandeusen 75d3d40038 feat(plan): plan-task viewer shows Applicable Rules panel 2026-05-28 10:42:03 -04:00
bvandeusen 1d5f49fe3b feat(plan): frontend startPlanning API + store action + task_kind type 2026-05-28 10:41:24 -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 8754b1c94d feat(plan): task_kind on Note model + to_dict 2026-05-28 08:15:29 -04:00
bvandeusen ac462d1203 feat(plan): migration 0056 — task_kind column on notes 2026-05-28 08:15:13 -04:00
bvandeusen 5f3da7c004 feat(rulebook): port script — parse FabledRulebook .md → Scribe DB 2026-05-27 22:39:50 -04:00
bvandeusen 9658e9a35c feat(rulebook): Project Rules tab — applicable rules + subscription chips 2026-05-27 22:20:19 -04:00
bvandeusen f2afb2a8bf feat(rulebook): /rules route, nav entry, g+r shortcut 2026-05-27 22:01:54 -04:00
bvandeusen 447adf816c feat(rulebook): subscription panel — toggle projects per rulebook 2026-05-27 22:01:03 -04:00
bvandeusen 75d8e7ab49 feat(rulebook): RulesView three-pane shell + child panes + rule editor 2026-05-27 22:00:32 -04:00
bvandeusen 605dd0a13a feat(rulebook): frontend API client + Pinia store 2026-05-27 21:59:23 -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 45c2197cdf feat(rulebook): SQLAlchemy models for rulebooks, topics, rules 2026-05-27 21:16:49 -04:00
bvandeusen 05e379263a feat(rulebook): migration 0055 — rulebooks, topics, rules, subscriptions 2026-05-27 21:16:24 -04:00
bvandeusen 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>
2026-05-27 19:10:25 -04:00
bvandeusen b3fca3ced4 migration: drop chat/journal/push/curator/weather tables (Phase 9)
Phase 8 deleted the Python models for these tables; this migration
drops the orphan SQL.

Dropped tables (CASCADE-safe):
  conversations, messages, generation_tool_log,
  moments + moment_embeddings + moment_people/places/tasks/notes,
  pending_curator_actions, push_subscriptions, weather_cache,
  rss_item_embeddings (legacy pre-pivot experiment)

Dropped per-user settings: every voice_*, journal_*, briefing_*,
curator_* key, plus default_model, background_model, assistant_name,
auto_consolidate_tasks, chat_retention_days, think_enabled,
rag_default_scope.

Hard cutover — no downgrade. Existing data in these tables is lost;
the spec explicitly accepted this in exchange for a clean schema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:16:18 -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 42861142db chore: remove benchmark notes that were accidentally committed
The bench-*.md files got swept into the Phase 8 mega-commit by
`git add -A`. They were local working-tree notes the user never
intended to track.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:09:32 -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 8bec68abc0 fix(ui): restore missing </div> closing the Notifications tab
The earlier sed-delete of the Push/ChatHistory/About sections from
the Notifications tab also clipped the tab's outer </div>. Vue's
type-checker happily accepted the unbalanced structure (templates
type-check on script bindings, not tag pairing) but Vite's Vue
compiler failed at build time:

  Element is missing end tag.
  file: src/views/SettingsView.vue:1062:5

(The reported line 1062 was the outermost .settings-content div —
Vue's parser blames the outermost open tag when an inner sibling
goes unclosed.)

Confirmed by counting: 115 open <div, 116 </div> before — and now
116/116 after restoring the closing tag between the Email Notifications
section and the Integrations tab.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:38:06 -04:00
bvandeusen ba6f2c7614 refactor(ui): purge SettingsView dead JS left over from Phase 7
The Phase 7 template strip left script-level state, functions, and
imports that no template ever referenced. TypeScript strict mode
(noUnusedLocals + noUnusedParameters) caught all of them on CI.

Removed from the script:
  - chat retention: chatRetentionDays, savingRetention, saveRetention
  - VAPID/push: vapidResetting/Msg/Error, resetVapidKeys, usePushStore,
    pushStore.checkSubscription() call in onMounted
  - admin voice block: adminVoiceEnabled, adminVoiceSttModel,
    savingAdminVoice, adminVoiceSaved, voiceLoadingModels,
    saveAdminVoice, reloadVoiceModels (plus the matching admin
    template block — that was still present and referenced these)
  - user voice block: voiceStatus, voiceStatusLoading, availableVoices,
    voiceTtsVoice, voiceTtsSpeed, voiceSpeechStyle, savingVoice,
    voiceSaved, voiceTabLoaded, the whole voice library
    (voiceLibrary, voiceLibraryLoading/Error/Filter/Expanded,
    installingVoiceIds, uninstallingVoiceIds, filteredVoiceLibrary,
    formatVoiceSize, loadVoiceLibrary, refreshInstalledVoices,
    installLibraryVoice, uninstallLibraryVoice, loadVoiceTab,
    voicePreviewing, previewVoice, saveVoiceSettings)
  - observations / consolidation: consolidating, clearingObs,
    observations, observationsExpanded/Loading/Loaded,
    toggleObservations, onToggleCloseout, runConsolidate,
    clearObservations
  - assistant / model management: assistantName, defaultModel,
    backgroundModel, installedModels, defaultChatModel, OllamaModel
    interface, ollamaModels, pullModelName, pullProgress, pulling,
    deletingModel, formatBytes, loadOllamaModels, pullModel,
    deleteModel, saveAssistant, saving, saved
  - api/client imports: getVoiceStatus, getVoiceList, getVoiceLibrary,
    installVoice, uninstallVoice, synthesiseSpeech, consolidateProfile,
    clearProfileObservations, listProfileObservations,
    VoiceStatusResult, VoiceEntry, VoiceLibraryEntry,
    ProfileObservationEntry

Surviving: journalConfig + locations editor + temp_unit selector
(Profile→Locations section still uses these to manage home/work
addresses for any future feature; the journal-specific prep/closeout
fields on the same object are dead but harmless as object members).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:26:51 -04:00
bvandeusen 18eb1e7ab2 refactor(ui): Phase 7 — strip chat/voice/journal/workspace/home surfaces
Frontend deletion phase of the MCP-first pivot. All in-app
conversational surfaces are gone — Claude/MCP is the assistant now.

Deleted views:
  ChatView, JournalView, WorkspaceView, HomeView

Deleted components:
  ChatPanel, ChatInputBar, ChatMessage, ChatStreamingBubble,
  ToolCallCard, ToolConfirmCard, WorkspaceChatWidget

Deleted composables + store:
  useVoiceRecorder, useVoiceAudio, useStreamingTts, stores/chat

Router changes:
  - / now redirects to /knowledge (was /journal)
  - dropped /chat, /chat/:id, /journal, /workspace/:projectId
  - /tasks still redirects to / (→ /knowledge)
  - /notes still redirects to /knowledge

KnowledgeView:
  - removed ChatPanel + ChatInputBar embeds
  - removed minichat floating widget + state + handlers
  - removed Chat link from today bar
  - removed `chatStore` driven auto-refresh-on-tool-call watch

App.vue:
  - removed useChatStore + startStatusPolling/stopStatusPolling
  - removed VAD ONNX preloader (voice subsystem dead)
  - removed visibilitychange listener (only did voice status re-check)
  - removed `c` single-key shortcut (focus chat / goto chat)
  - removed `g+c` two-key sequence (goto chat)
  - removed Chat section from shortcuts overlay
  - removed `.chat-page` / `.workspace-root` CSS overflow rule

AppHeader.vue:
  - removed useChatStore + status indicator (Ollama model status)
  - removed Chat / Journal nav links (desktop + mobile)

SettingsView.vue (4598 → 4079 lines):
  - removed Voice tab entirely
  - Notifications tab: dropped Push Notifications + Chat History
    + About sections (kept Email Notifications)
  - General tab: dropped Assistant (name + model pickers) +
    Model Management sections (kept Tasks + Timezone)
  - Profile tab: dropped Journal + Observations sections
  - VALID_TABS + tab list array no longer include "voice"
  - removed `loadVoiceTab()` activation trigger

Service worker (frontend/public/sw.js):
  - dropped push and notificationclick handlers (push subsystem
    only fired on internal generation completion, which is gone)
  - kept empty fetch handler as PWA installability shell

Script-level dead code (state refs, helper functions referencing
removed APIs) remains in SettingsView and stores/push.ts and
stores/settings.ts for now — Phase 8 backend deletion will clean
those up alongside the matching backend route removals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 15:14:00 -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 02fe500d61 fix(mcp): disable DNS-rebinding protection on FastMCP
FastMCP defaults to an allow-list of localhost variants for the Host
header (DNS-rebinding protection). Any deployment behind a reverse
proxy hitting a non-localhost hostname (e.g. devassistant.traefik.internal)
gets 421 Misdirected Request with:

  WARNING mcp.server.transport_security: Invalid Host header: <name>

The protection exists to stop a malicious browser page from rebinding
DNS to attack a localhost MCP server. Our deployment is HTTP transport
behind a reverse proxy with bearer-token auth, which already gates
every request — so the rebinding threat doesn't apply. Disabling
the check lets any Host through; auth still rejects unauthorized
requests at 401.

This also makes the integration test pass without test-only host
hackery — every realistic Host header (Traefik internal hostname,
CDN domain, custom DNS) now reaches FastMCP cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:16:47 -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 0cc09f917d fix(ui): claude mcp add takes URL as positional arg, not --url
The generated Claude Code snippet was outputting:
  claude mcp add ... scribe-dev --url <URL> --header ...

But `claude mcp add` errors out with `unknown option '--url'`. The
URL is a positional argument, not a flag:
  claude mcp add [--transport ...] [--scope ...] <name> <url> [--header ...]

Dropped --url and put the URL inline as a positional. Claude Desktop
JSON snippet was already correct (uses {url, headers} keys, not a
CLI flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 12:45:28 -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 97c22941a8 feat(ui): configurable MCP server name + scope; start Scribe rebrand
Settings → MCP Access now lets you tune the generated snippet:

  - Server name input (default: 'scribe', stored per browser in
    localStorage). The name appears both in the claude mcp add
    command and as the JSON key in claude_desktop_config.json's
    mcpServers map.
  - Scope dropdown: user / project / local. Drives the --scope
    flag in the claude mcp add snippet. Picks 'project' to commit
    the server into the current repo's .mcp.json.

User-visible 'Fable' → 'Scribe' in MCP Access tab copy (lead text,
Claude Desktop step). Branding pivot in the rest of the app
(assistant_name placeholder, SMTP defaults, version line, etc.) is
deferred — chat/journal copy is going away in Phase 7 anyway.

Deliberately NOT touched:
  - Tool names (fable_*) — protocol-level identifiers; renaming
    breaks any Claude session, agent, or automation that referenced
    them. Warrants its own phase.
  - mcp/server.py: FastMCP('fable', ...) server name — same reason.
  - Internal package name fabledassistant — per the project's
    naming convention (CLAUDE.md memory), internal stays.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:22:28 -04:00
bvandeusen 27b5c45f27 feat(ui): MCP Access tab — HTTP transport, in-app endpoint
Rewrites the apikeys settings tab for the new MCP architecture:

  - Tab label: 'API Keys' → 'MCP Access'
  - Shows the in-app MCP URL (<origin>/mcp) with a copy button
  - Claude Code snippet uses --transport http + --url + --header
  - Claude Desktop snippet uses {url, headers: {Authorization}}
  - Drops the wheel-download flow, the 'Other' client tab, and the
    stdio env file / Claude config download helpers — those were
    for the standalone fable-mcp package which goes away in phase 8

The api_keys backend stays unchanged — keys double as bearer tokens
for the /mcp endpoint via the existing auth.py middleware.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:23:06 -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 12d0ebeb84 migration: clear note_embeddings for fastembed swap (768d → 384d)
JSONB column so no type change needed — just wipe and let the
startup backfill regenerate at the new dimension.
2026-05-26 21:01:23 -04:00
bvandeusen 5a9859e12f deps: add fastembed (ollama client stays for now, removed in phase 7) 2026-05-26 21:01:00 -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 198f11ee09 feat(mcp): scaffold in-app FastMCP package
Empty FastMCP instance with the post-pivot instructions block. Tools
get registered in phases 2 and 3; ASGI mounting + bearer-auth comes
in task 1.4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:12:57 -04:00
bvandeusen b97a8ce457 deps: add mcp[cli] for in-app MCP server
First step of the MCP-first pivot. Adds the official Anthropic MCP SDK
so we can mount a FastMCP HTTP endpoint inside the main Quart app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 19:12:35 -04:00
bvandeusen 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>
2026-05-25 01:32:56 -04:00
bvandeusen 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>
2026-05-25 00:31:05 -04:00
bvandeusen 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>
2026-05-24 11:30:42 -04:00
bvandeusen 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>
2026-05-23 23:27:07 -04:00
bvandeusen 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>
2026-05-23 11:00:47 -04:00
bvandeusen 48b99b62be feat(curator): Needs Review panel in journal right rail (C5/5)
The frontend half of the review queue. Closes the curator approval
loop end-to-end.

JournalView.vue:
- New 'Needs Review' section in the right rail, ABOVE the Captures
  panel (per the design decision: pending stands out, captures are
  ambient). Hidden entirely when nothing is pending so the rail stays
  calm.
- Each pending action renders as a card:
  - Header: action_type chip (e.g. 'update_note') + human-readable
    title built from pendingTitle() ('Update Famous Supply network
    restage', 'Delete Old grocery list', etc.).
  - Diff body:
    - For deletes: a red 'Permanent delete' warning.
    - For updates: field-level diff rows (field name | old | → | new)
      computed by pendingDiff(), which compares the curator's payload
      against the snapshot taken at proposal time. Skips lookup-only
      params (query, task, project, milestone, confirmed) so the diff
      shows only what'd actually change.
    - Empty-diff fallback for tools without snapshot helpers.
  - Approve / Reject buttons. Disabled while a request is in flight
    via reviewingIds Set so double-clicks can't fire twice.
- Approve calls approvePendingAction → server replays the original
  tool call with authority='user'; toast on success/error.
- Reject calls rejectPendingAction → marks rejected, no execution.
- Both actions refresh the pending list AND the moments list (since
  approving an update_note could affect what shows in captures).
- loadPendingActions() also runs after every manual curator trigger
  and on initial mount, so the panel reflects current state without
  manual page refresh.

CSS: warm-tinted panel using --color-warning so the section visually
distinguishes from the neutral captures feed below. Approve button
in success-green, reject in muted. Diff rows use a grid layout with
old-value strikethrough and an arrow separator.

End-to-end demo loop:
1. Have a journal conversation that includes 'mark the Famous Supply
   task as done'.
2. Wait for curator sweep or hit 'Process captures'.
3. Curator search_notes('Famous Supply'), then update_note(...) is
   intercepted by execute_tool(authority='curator') and queued.
4. The Needs Review panel shows: 'Update task Famous Supply network
   restage' with status diff todo→done.
5. Click Approve → execute_tool replays with authority='user' →
   the task moves to done. Card disappears from Needs Review.

This is the last C* commit in the queue. The curator now has a safe
path to mutate user data via proposals, with the user firmly in the
loop on every change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:33:42 -04:00
bvandeusen 4048a771d2 feat(curator): pending-action API routes + client helpers (C4/5)
The HTTP surface for the review queue. Three endpoints, all under
the existing /api/journal blueprint to keep the journal-related routes
together:

- GET    /api/journal/pending — list current user's pending actions.
- POST   /api/journal/pending/<id>/approve — replay the proposed tool
         call via execute_tool(authority='user'). On success, marks
         the row 'approved'; on replay error, leaves it pending so
         the user can retry.
- POST   /api/journal/pending/<id>/reject — marks 'rejected' with no
         execution.

Each route is a thin wrapper around services/pending_actions and
delegates user-scoping to the service (which checks user_id on every
load — actions are private to the proposer).

api/client.ts:
- PendingCuratorAction interface mirroring the backend dict shape:
  id, user_id, conv_id, action_type, target_type/id/label, payload,
  current_snapshot, status, timestamps.
- listPendingActions / approvePendingAction / rejectPendingAction
  helpers for the upcoming Needs Review panel.

C5 next: the panel itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 10:09:40 -04:00
bvandeusen 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>
2026-05-22 22:37:55 -04:00
bvandeusen 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>
2026-05-22 22:34:16 -04:00
bvandeusen 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>
2026-05-22 22:32:01 -04:00
bvandeusen 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>
2026-05-22 22:11:25 -04:00
bvandeusen 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>
2026-05-22 21:03:25 -04:00
bvandeusen 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>
2026-05-22 17:55:52 -04:00
bvandeusen 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>
2026-05-22 16:42:33 -04:00
bvandeusen dac5433353 fix(journal): captures panel filter uses date_from and date_to
/api/journal/moments takes date_from + date_to query params, not the
single 'date' name the frontend was sending. Filter was silently
ignored; the panel showed every moment in the database ordered by
recency, making it look like a weird recap of past events instead of
today's captures.

No backend change; just send the right param names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:59:50 -04:00
bvandeusen bccee7f192 fix(ci): use POSIX case for tag selection so :dev actually pushes
Buried smoking gun: every CI run since the ci-python:3.14 migration
has silently failed to push the `:dev` tag. The build logs for commit
2a374d9 show:

    /var/run/act/workflow/tags.sh: 4: [[: not found
    /var/run/act/workflow/tags.sh: 6: [[: not found

act_runner invokes the workflow's `run:` block with `sh -e` (dash on
Debian-based ci-python:3.14, NOT bash). The original bash-only `[[ ]]`
syntax failed silently, the `:dev` tag never got appended to TAGS,
and only the SHA-tagged image was pushed. The `:dev` tag in the
registry has been stuck on whatever build last managed to push it —
likely back when CI ran on a bash-y Ubuntu runner before the migration.

This is why the deployed stack has been running a stale image despite
multiple successful "CI passed" runs: it pulls `:dev`, and `:dev` was
months out of date.

POSIX `case` is dash-compatible AND bash-compatible. Same intent
(decide which extra tags to append based on ref); no behaviour change
other than actually executing correctly.

This commit itself touches .forgejo/workflows/ci.yml, so it triggers
a fresh CI run that — for the first time in a while — should push
both :<sha> AND :dev. After this lands, redeploying the stack will
finally pull the recent code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:20:46 -04:00
bvandeusen 2a374d9b86 ci: add workflow_dispatch for manual re-runs
Lets you re-run CI from the Forgejo Actions UI without needing a
trivial commit. Useful when:
- An image has been built but the deployed stack didn't pick it up
  (re-run forces a fresh push + any post-CI hooks fire again).
- A transient upstream issue caused a build to fail (HF download
  flake during the voice-bundle step, registry hiccup, etc.) and
  re-running against the same source produces different behaviour.

This commit itself touches .forgejo/workflows/ci.yml so it triggers
a build by the normal paths rule, giving you a fresh :dev image
right now in addition to enabling future manual re-runs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:13:08 -04:00
bvandeusen 9d70c7be76 fix(journal): rephrase captures-button title to avoid Vue template escape
Vue's template parser doesn't handle JS-style \\' escaping inside
double-quoted attribute values, so `today\\'s` produced a compiler
crash during the production frontend build. Rephrased to avoid the
apostrophe entirely. No functional change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:37:49 -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 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 (a7002a8): chat=tools[], curator service backend
- Phase 1b (a73dd17): right-rail captures panel + manual trigger
- Phase 2   (83f1676): auto-scheduler every 15 min
- Phase 3   (this): curator summary → chat context feedback loop

Operator can now device-test the architecture end-to-end: have a
journal conversation (model can't lie about tool calls because it
has none), wait for the scheduler or hit "Process captures", see
moments appear in the right rail, then continue the conversation
and notice the chat model staying topic-aware via the summary block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:09:33 -04:00
bvandeusen 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>
2026-05-22 10:07:12 -04:00
bvandeusen a73dd17a1b feat(journal): right-rail captures panel + manual curator trigger (Phase 1b)
Frontend half of the conversation+curator architecture. Pairs with the
backend in commit a7002a8. With this commit, you can have a journal
conversation (chat model has no tools, doesn't try to capture), then
press a button and see what the curator extracts.

JournalView.vue:
- New "Captures" section in the right rail, above the existing
  "Upcoming" events block. Shows moments from the selected day with
  timestamp, content, and entity/task/note chips.
- "Process captures" button (Sparkles icon). Disabled for non-today
  days because we're not back-running the curator over historical
  conversations. Toast on success/failure with timing + tool-call
  count from the CuratorRunResult.
- Captures auto-load on day change AND immediately after a curator
  run completes — the right rail reflects current state without a
  page reload.
- Bound CSS scoped to the rail: cards with a primary-color left
  border, monospaced timestamps, chips for people/places/tasks/notes.

api/client.ts:
- CuratorRunResult type matching the backend dataclass.
- runJournalCurator(convId) helper.
- Pass empty body to apiPost() to satisfy the 2-arg signature
  (caller-side fix, not a backend change).

What's not in this commit (deferred):
- The captures panel doesn't show captures from days where the curator
  hasn't run yet, even if they would later be captured. Visible only
  AFTER a curator pass. (Phase 2's scheduler closes this gap by
  running automatically.)
- No edit/delete affordances on captures yet — that comes when we
  add the moment-editing UI (out of scope for the conversation+curator
  architecture commit chain).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:04:56 -04:00
bvandeusen 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>
2026-05-22 09:03:24 -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 4a9d8eaa2d fix(docker): download piper voices via Python urllib (curl not in slim)
python:3.14-slim doesn't ship curl or wget. The previous voice-download
step assumed it did and failed with "curl: not found" (exit 127) in
build stage 8.

Replaced with a Docker BuildKit heredoc that runs python3 directly,
using urllib.request.urlretrieve. Python is already installed (it's
the base image), so this needs no additional apt packages and keeps
the image footprint identical. The `# syntax=docker/dockerfile:1`
directive at the top of this file already pulls in a BuildKit
frontend that supports heredoc syntax.

The download itself is unchanged: en_US-amy-medium and en_US-ryan-medium
into /opt/piper-voices, with both .onnx and .onnx.json sidecar files.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:06:28 -04:00
bvandeusen 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>
2026-05-22 07:59:09 -04:00
bvandeusen c91b9c46ff fix(docker): restore STT (faster-whisper) on Python 3.14
Previously removed all voice deps from the runtime image because of the
numpy<2 / cp314 wheel chain. Actual upstream check (PyPI 2026-05-21)
shows the chain has resolved for the STT half:

- ctranslate2 v4.7.2 (2026-05-19) ships cp314 wheels
- faster-whisper v1.2.1 is pure Python and works on any supported runtime
- onnxruntime v1.26.0 has cp314 wheels (not used here but shared with
  the upcoming piper-tts install)

The blocker was kokoro, not the whole stack. Kokoro has been stale
upstream since April 2025 with a `requires_python='<3.13'` pin; that's
being replaced separately with piper-tts.

This commit restores ONLY STT — faster-whisper + soundfile. No torch
(ctranslate2 does its own CPU inference), no kokoro, no spacy. Image
add: ~150 MB.

Voice code is lazily guarded; STT now works when VOICE_ENABLED=true.
TTS still fails gracefully (kokoro import error logged, voice degrades)
until the piper-tts swap lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:58:12 -04:00
bvandeusen 9137bf698a fix(docker): drop voice deps from runtime image to unblock 3.14 build
CI broke on the build job: kokoro's resolver walks back to a version
that pins numpy<2, which has no cp314 wheel; pip falls back to compiling
numpy from source; python:3.14-slim has no compiler; build fails.

Removing the voice deps install (torch + faster-whisper + kokoro +
soundfile + spacy) from the runtime image:
- unblocks the 3.14 build immediately
- shrinks the image by ~2 GB (torch alone)
- aligns with the explicit operator preference (voice/TTS doesn't pay
  off in their workflow; conversational chat will get smaller/faster
  with the new no-tools chat model on GPU, so transcription matters
  even less)

Voice paths in code are already lazily guarded — TYPE_CHECKING-only
imports plus try/except inside load_stt_model. With VOICE_ENABLED=false
(default), the app starts cleanly with no voice deps installed. With
voice enabled, the import error is caught and logged; the feature
degrades gracefully rather than crashing.

To re-enable voice in a future build, `pyproject.toml` already has the
`voice` extra ready: install it with `pip install .[voice]` plus the
torch index pin, and download spacy en_core_web_sm. Dockerfile comment
documents the path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:18:56 -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
bvandeusen 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>
2026-05-21 22:00:44 -04:00
bvandeusen 2d5d3ffdff bench_ollama: PEP 723 inline script metadata for uv-run
Was failing with ModuleNotFoundError for httpx when run via system
python — httpx is a project dep but isn't on the system interpreter's
path. Adding PEP 723 script metadata + uv-run shebang means the script
auto-resolves its deps in an ephemeral venv on every invocation, no
project-venv setup required.

Run with `uv run scripts/bench_ollama.py …` or directly via the shebang
`./scripts/bench_ollama.py …`. `python scripts/bench_ollama.py …` still
works only when httpx happens to be on the active interpreter.

Docstring updated to reflect the running options.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:53:18 -04:00
bvandeusen cf986b5097 bench_ollama: add --think on|off|auto for cross-family comparison
The curator scenario hardcoded think=true, which is qwen3-family-specific.
Non-qwen3 models silently ignore the field, so cross-family curator
comparisons were apples-to-oranges (qwen thinks, others don't).

New --think flag:
- auto (default): scenario-driven — chat=off, curator=on. Matches the
  prior behaviour and the most common case.
- off: force disabled across all runs. Use for fair cross-family
  comparison; aligns behaviour explicitly even though non-qwen models
  would ignore think anyway.
- on: force enabled across all runs. Use to measure what think
  contributes on the same model (paired runs: --think off then on).

Output markdown table now records the think mode used, so saved results
are self-documenting when you diff cross-server or cross-config.

Docstring + usage examples updated to reflect the qwen3 candidate set
the bench was originally tuned for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 13:34:20 -04:00
bvandeusen d3d4294c30 scripts: add bench_ollama.py for CPU/GPU model benchmarking
Standalone tool to measure Ollama model performance under the two
workload shapes the chat+curator architecture would impose:

- chat scenario: short user message, short reply, no thinking. Mirrors
  the no-tools chat companion's expected load.
- curator scenario: ~700-token journal transcript with an extraction
  prompt, thinking enabled. Mirrors the curator's expected load.

Defaults to CPU-only inference (num_gpu=0). Streams responses; reports
TTFT, total wall time, tokens/sec (from Ollama's eval_count/eval_duration
so it excludes client-side stream overhead), and prompt token count.
First request per (model, num_gpu) is a warm-up to load the model into
memory; not counted in the measured runs.

Designed for cross-server comparison: --server points at any Ollama
instance, --out writes a markdown table. Comparing the two CPU servers
becomes a matter of running the same command on each and diffing the
output.

Lives outside the chat/curator architecture commitment — measurement
tool only. Tells us "is qwen2.5:32b on CPU fast enough for a 10-20 min
curator cadence?" without writing any of the architecture code yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 08:53:10 -04:00
bvandeusen 41d252e9d1 deps: pin requires-python = ">=3.14"; commit uv.lock
Match CI + runtime target exactly — both run Python 3.14, so the
package metadata signals consumers that we don't test against 3.12/3.13.

uv.lock is tracked so the test job's `uv venv` resolution is
reproducible (currently the test job installs the editable package
without consulting the lockfile; future work could wire `uv sync` in).
Lockfile resolves 179 packages against Python 3.14.4.

ci-requirements.md updated to drop the prior "permissive lower bound"
caveat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:25:01 -04:00
bvandeusen 6cf70e22db compose(db): lenient healthcheck + stop_grace_period to survive host stalls
Mitigation for the nightly fabledscribe Postgres outage on the
vdnt-docker02 Swarm node (incidents 2026-05-15/16/17 around 03:50 UTC).
Confirmed kill chain (not the trigger): a brief host-level setns/exec
stall makes the Docker healthcheck exec fail with exit 1 → unhealthy →
SIGKILL → fast-shutdown can't finish on NFS in 10s → exit 137 → swarm
restart_policy.max_attempts: 5 burns out → DB stays dead.

Hardens the `db` service so a transient host blip can't escalate to
killing the database:
- stop_grace_period: 120s (gives PG room to fsync on shutdown)
- healthcheck: interval 30s / timeout 10s / retries 10 / start_period 180s
  (only gates app startup order — not authoritative liveness)
- prod: restart_policy condition=on-failure, max_attempts=0, window=120s
- quickstart/dev: restart: unless-stopped

Host-side trigger (what stalls runc/exec at ~03:50 UTC) is still under
investigation — see project_pg_nightly_outage.md.

Note: the Portainer prod stack differs from docker-compose.prod.yml
here (NFS bind, traefik labels, no ollama). The same `db:` block needs
to be pasted into Portainer for the prod mitigation to apply.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:18:19 -04:00
bvandeusen 3f1bcc3360 ci: consume shared ci-python:3.14 image; bump runtime to 3.14
Migrate to the FabledRulebook CI-Runner contract:

- .forgejo/workflows/ci.yml: all four jobs (typecheck/lint/test/build)
  now schedule on the `python-ci` runner label and run inside
  container.image: git.fabledsword.com/bvandeusen/ci-python:3.14
  (Python 3.14 + Node 24 + ruff + uv + Docker CLI). Dropped the inline
  uv install in the test job — uv is now baked into the image.
- Dockerfile: production runtime bumped to python:3.14-slim so test
  results stay representative against what we ship.
- ci-requirements.md: new file at repo root declaring image deps and
  per-job installs (per FabledRulebook ci-runners.md).
- infra/Dockerfile.runner-base: deleted. The in-repo runner base
  (Ubuntu 24.04 + Python 3.12 + Node 22) is superseded by the shared
  ci-python image. The runner-host deployment files
  (runner-compose.yml + act-runner-config.yml) stay as deployment-shape
  documentation; source of truth is the deployed config.
- docs/development.md: CI/CD + Runner sections refreshed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 20:16:26 -04:00
bvandeusen 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>
2026-05-20 18:54:35 -04:00
bvandeusen 2414437061 Merge pull request 'feat: closeout + tool-use fixes + task-as-record + version pinning' (#50) from dev into main 2026-05-13 23:27:33 +00:00
bvandeusen e6f2ee2b94 feat(frontend): pin/unpin/auto-pin UI in HistoryPanel
Version list rows now render a kind-aware badge: filled circle for
manual pins (with the label inline), half-filled circle for auto-pinned
versions. The right pane gains a control row above the diff:

- Unpinned: 'Pin version' button → label input → Save creates a manual
  pin with that label.
- Manual: 'Edit label' + 'Unpin' buttons.
- Auto: 'Pin permanently' (promotes auto → manual with editable label).

Local state is patched from the API response so the UI updates without
reloading the panel.
2026-05-13 14:00:57 -04:00
bvandeusen 59dee3a19f feat(frontend): NoteVersion pin fields + pin/unpin client helpers 2026-05-13 13:59:47 -04:00
bvandeusen 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).
2026-05-13 13:59:20 -04:00
bvandeusen 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.
2026-05-13 13:58:34 -04:00
bvandeusen 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.
2026-05-13 13:57:56 -04:00
bvandeusen bb6249e00e feat(versions): prune_auto_pins FIFO-trims auto-pinned bucket
Auto-pinned versions live in their own bucket with MAX_AUTO_PINS=25 cap.
The scan job calls this after each note's promotions complete; the
oldest auto-pinned rows are dropped past the cap. Manual pins and
rolling rows are untouched.
2026-05-13 13:57:09 -04:00
bvandeusen 9c0308dfee feat(versions): POST/DELETE /api/notes/:id/versions/:vid/pin 2026-05-13 13:56:42 -04:00
bvandeusen 925a53e0f7 feat(versions): pin_version and unpin_version services
pin_version sets pin_kind='manual' and pin_label on the target row.
Accepts already-pinned rows (promotes auto→manual, updates label).
Labels are capped at PIN_LABEL_MAX_LEN=500 chars; longer values raise
ValueError before any DB access.

unpin_version clears both fields, downgrading the row to rolling. Does
NOT delete — if the row is past the rolling FIFO depth, the next
autosave's prune will drop it.
2026-05-13 13:56:21 -04:00
bvandeusen b65d736869 feat(versions): rolling-cap prune ignores pinned versions
The DELETE inside create_version now filters pin_kind IS NULL so pinned
rows (auto or manual) aren't counted toward MAX_VERSIONS=50 and aren't
candidates for deletion. Pinned versions live indefinitely regardless
of how heavy rolling autosave traffic gets on the same note.
2026-05-13 13:55:44 -04:00
bvandeusen 17211c6e82 feat(schema): add note_version.pin_kind and pin_label
Spec: docs/superpowers/specs/2026-05-13-note-version-pinning-design.md

- pin_kind: NULL=rolling, 'auto'=stability-scan, 'manual'=user-declared.
- pin_label: NULL for rolling; auto-generated for 'auto'; user-supplied
  string for 'manual' (may be NULL).

No backfill — every existing row stays rolling. The daily auto-pin scan
will catch up on the first run after deploy.
2026-05-13 13:55:18 -04:00
bvandeusen 90aa1f2fdb fix(tests): preserves_body test \$Note stub needs project_id
The knowledge-note return path in create_note_tool reads note.project_id;
the SimpleNamespace fake didn't define it, so the tool crashed with
AttributeError instead of returning. The task-branch test already
included project_id; mirror that here.
2026-05-13 12:30:09 -04:00
bvandeusen b519a1c140 feat(frontend): auto-consolidate tasks toggle in General settings
New Tasks section in the General tab with a single checkbox controlling
whether the consolidation pipeline fires automatically. Persists to the
auto_consolidate_tasks user setting (string 'true'/'false'). Manual
'Re-consolidate' in the task editor bypasses the gate.
2026-05-13 12:23:06 -04:00
bvandeusen 257b306a27 feat(frontend): gate body editor when task body is auto-maintained
When consolidated_at is set on a task, the editor:
- shows a banner above the body indicating the body is auto-summarized
- hides the Write tab; locks the body view to read-only preview
- exposes a Re-consolidate button that calls POST /api/tasks/:id/consolidate
  and refreshes the body from the response

Pre-consolidation behavior is unchanged — the Write tab and TiptapEditor
remain available.
2026-05-13 12:21:51 -04:00
bvandeusen 8b0878f227 feat(frontend): description field in task editor + Goal block in viewer
Note type gains description and consolidated_at fields. TaskEditorView
adds a Goal textarea above the body editor (wired through dirty/save/
autosave paths). TaskViewerView renders Goal as a subordinate block
above the body, plus a subtle 'Auto-summarized from work logs' banner
when consolidated_at is set.

Also adds a consolidateTask client function for the upcoming
re-consolidate button (Task 11).
2026-05-13 12:20:42 -04:00
bvandeusen 9191ab5b27 feat(tasks): POST /api/tasks/:id/consolidate + separate body/description fields
New endpoint manually triggers a consolidation pass for a single task.
Bypasses the auto_consolidate_tasks setting since the user is asking
explicitly. Returns the task with the freshly-written body and
consolidated_at timestamp.

Also un-aliases description and body in the create/update task routes
(was: description folded into body as legacy fallback). With separate
fields under the task-as-durable-record design, both flow through as
distinct kwargs to create_note / update_note.
2026-05-13 12:16:43 -04:00
bvandeusen fd25d2e436 docs(tools): clarify log_work feeds summary; create_note runbook guidance
log_work description now mentions that logs feed the task's auto-summary,
nudging the LLM toward specific log content (commands, decisions, failures)
rather than vague entries.

create_note description gains a runbook-shape clause: code blocks, numbered
procedures, and explicit 'save this as a note/runbook' signals should
spawn standalone notes. Task-specific work-in-progress routes to log_work
instead.
2026-05-13 12:15:36 -04:00
bvandeusen 103db883ad feat(tools): tasks accept description; reject body writes via tools
create_note tool:
- New 'description' parameter accepted and forwarded to the service.
- When status is set (creating a task), 'body' is dropped before the
  service call. Task bodies are owned by the consolidation pipeline.

update_note tool:
- New 'description' parameter; routed through update_fields.
- When the resolved target has is_task=True and 'body' is in the
  arguments, the call errors with a message nudging toward log_work or
  description. Knowledge notes are unaffected.

HTTP routes (POST/PATCH/PUT /api/notes) accept body freely — the
restriction is only at the LLM tool layer.
2026-05-13 12:15:09 -04:00
bvandeusen 5fa203019a feat(consolidation): trigger from log_work and status terminal transitions
log_work tool now invokes maybe_consolidate(reason='log_added') after a
successful create_log. The gate inside the consolidation service handles
threshold + setting checks.

update_note service snapshots old_status before mutation and fires
maybe_consolidate(reason='task_closed') when the status transitions into
'done' or 'cancelled'. Re-saving an already-terminal status doesn't
retrigger — only transitions count.
2026-05-13 12:13:31 -04:00
bvandeusen bda6e6c80f feat(consolidation): full consolidate_task with background model
consolidate_task reads the task title, description (read-only context),
and chronological work logs; builds a prompt via _build_consolidation_prompt;
calls generate_completion with the user's background_model setting; on a
non-empty result, writes back to Note.body, stamps consolidated_at, and
re-runs the embedding pipeline.

Errors are caught and logged. LLM failures leave body untouched so the
next trigger retries cleanly. Per-task asyncio lock prevents simultaneous
passes for the same task.
2026-05-13 12:12:37 -04:00
bvandeusen 5419330633 feat(consolidation): debounced gate for task body consolidation
New services/consolidation.py module with maybe_consolidate() — the
debounced trigger gate. Two reasons:

- log_added: gated by DEFAULT_LOG_THRESHOLD (3) counted since the task's
  consolidated_at timestamp.
- task_closed: bypasses the count gate; fires whenever status flips to
  done/cancelled.

Both reasons gated by the auto_consolidate_tasks user setting (default
on). Per-task asyncio.Lock prevents two simultaneous passes for the same
task. consolidate_task is a stub here — full implementation in the next
commit.
2026-05-13 12:11:41 -04:00
bvandeusen 362ead7f0d feat(notes): accept and return description field through service and routes
create_note service accepts a new description kwarg and forwards it to the
Note constructor. PUT/PATCH/POST routes include description in the field
whitelist. update_note already passed **fields through setattr, so the new
column is reachable without touching that signature.
2026-05-13 12:11:03 -04:00
bvandeusen 8a3bba4eb8 feat(schema): add note.description and note.consolidated_at
Spec: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md

- description: user-stated goal / initial context for tasks (NULL for
  knowledge notes).
- consolidated_at: timestamp of the most recent auto-summary pass (NULL
  until first consolidation).
- Migration 0044 backfills description from body for existing rows where
  status IS NOT NULL (i.e. tasks). Body left in place; first consolidation
  pass will overwrite it.
2026-05-13 12:09:45 -04:00
bvandeusen 76dc75a03b Merge pull request 'feat: journal closeout for profile observations + LLM tool-use fixes' (#49) from dev into main 2026-05-13 01:31:19 +00:00
bvandeusen a551f52682 fix(tools): score_project_match strips 'project' filler + uses title for SequenceMatcher
CI surfaced three issues:
- 'famous supply project' didn't substring-match 'Famous-Supply Work topics'
  because the trailing filler word 'project' blocked the substring tier.
  Strip {project, projects} from the query before the substring check.
- SequenceMatcher fallback against `combined` (title + description +
  summary) diluted ratios to ~0.5 for plausible matches. Use title
  directly; the 0.70 tier already handles description/summary mentions.
- Test patches used patch.object on a consumer module where
  list_projects is imported locally — patch the source module instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:30:37 -04:00
bvandeusen 6de855e226 feat(llm): search-first heuristic in chat tool_lines static block
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:44:00 -04:00
bvandeusen c6357e52d9 feat(journal): search-first heuristic for existing work in calibration prompt
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:42 -04:00
bvandeusen 5d40f2113f feat(tools): record_moment requires task_titles from prior search_notes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:21 -04:00
bvandeusen 9a96fdb3c0 refactor(tools): resolve_project uses shared score_project_match helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:43:05 -04:00
bvandeusen 460959f0d4 refactor(tools): search_projects uses shared score_project_match helper
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:42:34 -04:00
bvandeusen 0dbbb98cf5 feat(tools): score_project_match helper with substring-first tiering
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:42:01 -04:00
bvandeusen 4b7ca1b17e feat(tools): search_notes description nudges away from type-nouns in query
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:41:28 -04:00
bvandeusen 65a3689aaa fix(notes): list_notes strips type-nouns from q before ILIKE filter
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:41:15 -04:00
bvandeusen 42c11dedae feat(notes): _strip_type_nouns helper for search query sanitization
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:54 -04:00
bvandeusen 0fbb1fbd92 fix(tools): search_projects returns success:True so result isn't mislabeled error
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:40:27 -04:00
bvandeusen bb650ba563 feat(profile): Settings panel with recent journal observations
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:44:51 -04:00
bvandeusen c663532fd4 feat(profile): Settings toggle for nightly journal closeout
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:59 -04:00
bvandeusen 090b7d83dd feat(frontend): API client for profile observations + closeout flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:43:27 -04:00
bvandeusen 4e9eead3ab feat(profile): GET /api/profile/observations returns recent raw entries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:36:42 -04:00
bvandeusen fc6ebf81eb feat(journal): closeout catch-up on startup when slot already passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:36:29 -04:00
bvandeusen b88d5ee6b3 feat(journal): register per-user closeout job at day_rollover_hour
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:35:57 -04:00
bvandeusen 020bd6614b test(journal): cover closeout skip paths (no conv / prep-only / sentinel)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:58 -04:00
bvandeusen 4403026797 feat(journal): run_for_user orchestrates closeout extraction
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:34:42 -04:00
bvandeusen 552943d6c0 feat(journal): closeout system prompt with structured-field guard
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:57 -04:00
bvandeusen 2576be9e49 feat(journal): _build_transcript caps content and message window
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:27 -04:00
bvandeusen e17fc088b2 feat(journal): journal_closeout._filter_messages excludes daily_prep
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:33:03 -04:00
bvandeusen c5b0344240 feat(journal): add closeout_enabled to default config
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 14:32:40 -04:00
bvandeusen c8765959ea Merge pull request 'Release v26.04.29.4 — recurrence UI + journal weather wiring fixes' (#48) from dev into main 2026-04-30 02:08:19 +00:00
bvandeusen c33cab7020 fix(journal): wire weather refresh on config save; drop orphaned cache rows
Two related gaps in the journal weather panel:

1. Saving locations via PUT /journal/config didn't trigger a weather
   fetch, so newly-entered sites had no cache row (or a stale one) until
   the user manually clicked the panel's refresh button. The panel
   rendered "two sites with empty values" against pre-existing cache
   rows that no longer matched what the user had configured.

2. get_cached_weather_rows returned every WeatherCache row for the user
   regardless of whether the location was still in journal_config.
   Briefing-era rows survived migration 0040 (which only deleted the
   briefing_config setting, not the cache table) and showed up as
   ghost tabs in the UI.

Changes:
- get_cached_weather_rows accepts an optional valid_keys filter; rows
  whose location_key is not in the set are excluded.
- routes/journal.py:
  - put_config kicks off a background refresh_location_cache for any
    saved location with valid lat/lon.
  - GET /weather and POST /weather/refresh both pass valid_keys derived
    from the current config so orphaned rows don't surface.
- services/journal_prep.py filters the weather section to currently-
  configured locations as well; uses a lazy import of get_journal_config
  to avoid a cycle (journal_scheduler imports journal_prep).

153 tests pass.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:37:23 -04:00
bvandeusen 36cd08c236 feat(events): expose recurrence presets in EventSlideOver
Adds a "Repeat" select (None / Daily / Weekly / Monthly / Yearly) that
reads/writes the existing Event.recurrence RRULE. CalDAV-imported rules
with extra parts (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR) surface as a disabled
"Custom" option with the raw rule shown read-only — visible but
preserved unless the user explicitly picks a preset to replace it.

EventUpdatePayload.recurrence is now string | null so we can clear via
PATCH; backend service already treats null as "clear" (recurrence is in
the nullable set in update_event).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 20:19:49 -04:00
bvandeusen f85b92a885 Release v26.04.29.3 — Event-store data integrity + EventSlideOver redesign 2026-04-29 20:04:43 +00:00
bvandeusen 84640a0dc4 fix(events-tools): refuse ambiguous query, require event_id to disambiguate (#161)
Root cause of the 2026-04-29 dentist-appointment incident: the model
called update_event(query="Appointment") when two events had
"Appointment" in their titles. find_events_by_query returned both,
upcoming-first ordered by start_dt — matches[0] was id=2 (a stale
pre-existing event with garbage end_dt), not id=15 (the one the user
just created via the journal flow). update_event_tool silently took
matches[0] and mutated the wrong event.

Fix: a new resolver helper `_resolve_event_for_action` funnels both
update_event_tool and delete_event_tool through one disambiguation
path. Lookup precedence:
  - `event_id` → exact get_event lookup, no query at all
  - `query` matching exactly one event → proceed
  - `query` matching zero → return success=False, "no event found"
  - `query` matching 2+ events → return success=False with a
    `candidates` array of {id, title, start_dt, location} so the
    model can pick one and call again with `event_id`

The candidates list is capped at 8 to keep the model's context tight.
The error message names the count and the next-step ("pass event_id
or refine the query") so the model can self-correct in one turn.

For delete_event, the disambiguation is even more important — the
silent-matches[0] path would have deleted the wrong event outright
rather than just mutating it. The tool description leans into that:
"Deleting the wrong event is a costly user error; never guess."

Tool surface change: `query` and `event_id` are now both optional;
the tool errors clearly when neither is supplied. The model already
knows id values from prior tool results (returned in `data.id`),
which is the natural feeder for the disambiguation flow.

5 new tests in test_calendar_tool_tz.py cover:
- ambiguous query → success=False with candidate list, no mutation
- event_id supplied → bypasses query lookup entirely
- non-existent event_id → clear "no event found" error
- neither identifier → "query or event_id required" error
- same disambiguation enforced for delete_event_tool

46 calendar/events tests pass; ruff clean.

Closes Fable #161.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:29:13 -04:00
bvandeusen 4c58603009 feat(events): replace end_dt column with duration_minutes (#160)
Structural fix for the "end before start" bug class observed on prod
2026-04-29. Bad data became inexpressible at the schema level instead
of getting trapped in defensive read-path filters.

The hotfix that landed earlier today (94b169f) is reverted by the
preceding revert commit; this commit supersedes it cleanly with a
proper data-model change.

## Schema (migration 0043)

- Add `duration_minutes INTEGER NULLABLE` column on `events`.
- CHECK constraint: ``duration_minutes IS NULL OR duration_minutes >= 0``.
- Backfill from existing `end_dt`:
    - end_dt valid (end > start) → duration_minutes = total minutes
    - end_dt == start → duration_minutes = 0 (zero-duration point)
    - end_dt NULL or end_dt < start → duration_minutes = NULL
      (the corrupt prod row collapses cleanly to a point event)
- Drop the `end_dt` column. The wire format is preserved — `to_dict()`
  emits `end_dt` as a derived `start_dt + duration_minutes`. Existing
  API consumers (Flutter app, web frontend, CalDAV sync) keep
  receiving the same response shape; they just no longer have a way
  to PUT a stored `end_dt` that disagrees with `start_dt`.

## Service layer

- `Event.end_dt` becomes a `@property`. Setting it would require a
  setter we deliberately don't define — writes always go through
  `duration_minutes`.
- `_normalize_duration` is the single source-of-truth for input
  reduction. Accepts (start, end_dt, duration_minutes), returns the
  canonical `duration_minutes`, raises `ValueError` for negative
  durations, end-before-start, or end/duration disagreement.
- `create_event` and `update_event` accept either `end_dt` or
  `duration_minutes` for ergonomic compat; both convert via
  `_normalize_duration`. Update validates the post-update state when
  the patch includes either.
- `list_events` filter is simpler now: a coarse SQL prefilter
  (`start_dt <= date_to`) plus Python-side refinement using the
  derived `end_dt`. Avoids Postgres-specific interval arithmetic in
  the WHERE clause; refinement runs over a per-user result set so
  there's no scan-cost concern at personal scale.
- Recurring-event expansion uses `event.duration_minutes` directly
  instead of computing `end - start`. No more negative-timedelta
  hazard.

## CalDAV sync (incoming + outgoing)

- `caldav_sync.py` (pull) and `calendar_sync.py` (Radicale upsert)
  both convert iCal `DTEND` → `duration_minutes` on the way in.
  Outbound iCal still emits `DTEND` as `start_dt + duration_minutes`
  via the model's derived property. iCal interop is unchanged.

## Behavioral upgrade for `update_event`

Pure end_dt model: moving start past the existing end_dt would either
silently corrupt or hard-reject. Duration model: the duration is
preserved by default, so moving start slides the effective end
forward — which is what users mean when they "move" an event.
Explicit clear is still possible via `end_dt=None`.

## Tests

`tests/test_events_service.py`:
- 6 new `_normalize_duration` unit tests (sugar conversion, zero
  duration valid as point event, end-before-start rejected, negative
  duration rejected, inconsistent end+duration rejected, none → None)
- New behavioral test: `update_event` preserves duration when only
  start_dt changes (sliding semantics)
- New: clearing `end_dt=None` on update collapses to point event
- New: list_events surfaces a point event in the upcoming window
- New: list_events excludes a timed event whose effective end has
  already passed
- Existing mock-event helper updated to use `duration_minutes`
  instead of stored `end_dt`.

44 event-related tests pass; ruff clean.

## Out of scope (separate task)

Fable #161 — `find_events_by_query` returning multiple matches and
silently picking matches[0]. The exact root cause of how event id=2
got mutated in the first place; orthogonal to the storage model.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 14:19:44 -04:00
bvandeusen b7e7073425 Revert "fix(events): tolerate corrupt end_dt + reject end<=start at write time"
This reverts commit 94b169f31c.
2026-04-29 14:18:01 -04:00
bvandeusen 94b169f31c fix(events): tolerate corrupt end_dt + reject end<=start at write time
A prod event surfaced today with `start_dt=2026-05-01T12:00Z` and
`end_dt=2026-03-30T12:00Z` — end was 32 days BEFORE start, almost
certainly from an earlier tool-call mishap (Fable #161). The
list_events filter trusted the bogus end_dt and excluded the event
from every read path that hit the upcoming window, even though
start_dt was correctly in range. The event stayed visible in the
calendar grid (different range) but vanished from "Upcoming",
search, briefings, and journal prep events list.

This is the hotfix half of the response. The structural follow-up is
Fable #160 — replace end_dt with a duration column so invalid state
becomes inexpressible.

## A. Filter robustness in list_events

Treat `end_dt <= start_dt` as if no end_dt exists. The filter now
splits into two branches:
- valid duration: end_dt IS NOT NULL AND end_dt > start_dt AND
  end_dt >= date_from
- no/invalid duration: (end_dt IS NULL OR end_dt <= start_dt) AND
  start_dt >= date_from

Same change applied to the recurring-event expansion's `duration`
calculation, which was producing negative timedeltas for corrupted
rows and computing nonsensical occurrence end times.

## B. Write-side validation in create/update

`create_event` and `update_event` now raise ValueError when the
resulting state would have end_dt <= start_dt. Update validates
against the *post-update* state, not just the field being changed —
so pushing start_dt past an existing end_dt also fails loudly. Bad
data shouldn't be persistable from any write path.

Surfaced cleanly:
- Calendar tool wrappers (create_event_tool / update_event_tool)
  catch ValueError and return `{success: false, error: ...}`, which
  the model can read and self-correct.
- Route handlers (POST /api/events, PATCH /api/events/<id>) catch
  and return HTTP 400 with the validator's message instead of
  letting it bubble to a 500.

4 new tests in test_events_service.py:
- create rejects end before start
- create rejects equal start/end (zero duration)
- update validates the post-update state (start pushed past existing end)
- list_events surfaces an event whose end_dt is before its start_dt

34 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:48:28 -04:00
bvandeusen 2db23cec7a refactor(events): turn EventSlideOver into a centered modal with auto-save
User feedback: the right-edge slide-over panel pinned its action buttons
to a thin floor band where the destructive ghost-style Delete button was
functionally invisible against the dark surface. Save / Cancel / Delete
all sat in the same floor strip, isolated from the form content.

This refactor changes the surface and the commit model.

## Centered modal, not slide-over
Backdrop dim covers the whole viewport; the panel sits centered with a
12px corner radius and a soft shadow. The form scrolls internally when
content overflows the viewport (max-height: calc(100vh - 2.5rem)).
File kept as `EventSlideOver.vue` to avoid touching the three consumers
(CalendarView, HomeView, ToolCallCard).

## Action buttons removed; close = save
- Save button: gone. Auto-save fires when the user closes via X, Esc,
  backdrop click, or pressing Enter inside a text field.
- Cancel button: gone. Esc / X / backdrop click already cover dismiss;
  a labeled "Cancel" was redundant.
- Delete button: moved to the header as a Trash2 icon (edit mode only).
  Click → header swaps to inline confirm "Delete this event? [Yes,
  delete] [No]" — same two-step flow, just relocated. Esc during the
  confirm cancels back to edit mode rather than closing the modal,
  giving the user a clear way out of the destructive prompt.

## Validity-aware close
All exit paths funnel through `attemptClose`:
  - Form valid → save (POST or PATCH), then close.
  - Form invalid in EDIT mode → discard the in-memory change and
    close, with a toast naming the missing field
    ("Title required — change discarded"). Keeps the user from
    silently corrupting an event.
  - Form invalid in CREATE mode → close silently. Nothing was
    committed; calling that out adds noise.

Emit signature unchanged (close / created / updated / deleted), so the
three consumers continue to work without edits.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 13:14:17 -04:00
bvandeusen b81c4aa600 Release v26.04.29.2 — Anti-placeholder rule on create_event 2026-04-29 16:10:53 +00:00
bvandeusen 35fab6cbf7 fix(calendar-tool): forbid placeholder events in create_event description
Reproducer (2026-04-29 dentist appointment): user said "this Friday,
I have an appointment" with no other details. The model immediately
called create_event with title="Appointment", description="User
mentioned an appointment this Friday but hasn't provided details
yet.", all_day=true. THEN it asked the user for time/location in
its reply. When the user came back with "8am at my dentist for
permanent crown fitting", the model called update_event — but never
updated the title, leaving the placeholder "Appointment" in the
calendar permanently.

The bug isn't about the tool surface, it's that the model created
an event before it had real content. The system prompt had no rule
against this, so the model hedged: "log a placeholder, ask for
details, then update". That pattern pollutes the calendar with
garbage titles and forces immediate update_event calls.

create_event tool description now includes an explicit anti-pattern:
record a moment, ask for the missing pieces, and only call create_event
once you have actual title + time + location. Stand-in titles like
"Appointment" / "Meeting" / "Event" with "details TBD" descriptions
are explicitly named as the failure mode.

Pure prompt change. 18 tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 10:54:39 -04:00
bvandeusen 404698521f Release v26.04.29.1 — Date durability + journal voice tuning 2026-04-29 14:37:45 +00:00
bvandeusen 9f8b451d15 fix(journal-prep): bucket tasks + drop non-proximate events (#159)
Two filtering issues that made the daily prep noisy and trained the
user to ignore it.

## Tasks: bucket into due-today / upcoming / overdue

The prep was calling `list_notes(due_before=day_date)` and labeling the
result as "tasks due today". That filter is strictly less-than, so it
returned only OVERDUE tasks (a single 68-day-stale task in this user's
case), while the prompt still framed them as fresh today's work. Each
day of the prep treated the same overdue task as new — the user
learned to ignore the line entirely.

`gather_daily_sections` now runs three queries:
- `tasks_due_today` — `due_after=day_date AND due_before=day_date+1`
- `tasks_upcoming` — next 7 days, exclusive of today
- `tasks_overdue` — strictly before today
Overdue entries carry a `days_overdue` count. `_render_sections_for_prompt`
emits three labeled headers ("TASKS DUE TODAY", "UPCOMING TASKS",
"OVERDUE TASKS (still on the list, not currently due)"). The system
prompt has a new TASK BUCKETS rule telling the model: don't call
overdue items "due today"; surface them with their staleness duration
("still on the list 68 days") and frame as a backlog reminder rather
than today's work.

Backwards-compat: `sections["tasks"]` still exists, now as the union
of all three buckets — strictly more useful than the prior overdue-
only behavior any frontend consumer was getting before.

## Events: tz-aware window + proximity filter

The user's "Birthday — 2026-09-29 (FREQ=YEARLY)" event was surfacing
in every daily prep, 5 months out. Root cause: `gather_daily_sections`
built `day_start`/`day_end` as NAIVE datetimes; `list_events` then
called `rrulestr(...).between(naive_from, naive_to)` against an
aware `dtstart`, which throws TypeError, hits the `except Exception`
fallback, and appends the canonical event row — regardless of whether
today is anywhere near a recurrence.

Fix:
1. Construct the day window as TZ-aware in the user's local timezone
   and convert to UTC before the query. RRULE expansion now runs
   correctly.
2. Defense-in-depth `_filter_proximate_events` drops events whose
   start_dt is more than 7 days from `day_date` (in the user's local
   TZ — not UTC, so a Friday 23:00 NY event isn't misclassified as
   Saturday). If list_events ever leaks a far-future row again, the
   prep doesn't surface it.

10 new tests in `tests/test_journal_prep_filtering.py` cover task
bucketing (overdue marker, due-today no-marker, no-due-date), the
proximity filter (the 4/29 reproducer, in-window keeps, local-vs-UTC
boundary, unparseable dates kept rather than suppressed), and the
rendering (overdue staleness shown, due-today doesn't repeat the date,
correct section ordering).

53 tests pass across journal_prep + journal_search + record_moment +
calendar_tool + events. Ruff clean.

Closes Fable task #159.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:31:12 -04:00
bvandeusen 4f18023284 fix(journal): server-side guards on record_moment links + place names (#158)
Belt-and-suspenders to the prompt-layer changes in 6c309f1. Even when
the model emits bogus task or place links, the server now refuses to
persist them.

## Task auto-linking guard

Reproducer (2026-04-27): a moment about restaging Docker on the swarm
ended up with `task_ids: [2]` (Weston's ADHD Evaluation) — the only
task in that day's prep. The model picked it up as filler.

`_filter_task_ids_by_keyword_overlap` now runs after id resolution: it
fetches each linked task's title, tokenizes both content and title
through `_content_keywords` (lowercased, stopwords stripped, <3-char
tokens dropped), and drops any link whose title shares no meaningful
keyword with the moment content. The drop is logged at INFO so we can
observe how often it fires post-deploy.

The guard runs against the merged id list, so it covers both the
preferred `task_titles` resolution path and the discouraged explicit
`task_ids` path.

## Place placeholder guard

Reproducer (2026-04-27): `place_names=["work"]` got passed to
`record_moment`. "work" / "home" / "office" aren't places — they're
role-labels for already-known geocoded locations.

`_filter_placeholder_places` drops a small set of generic single-word
labels before name resolution. Real user-named places that happen to
be one word (e.g. "Akron") pass through.

## Tests

9 new unit tests in `tests/test_record_moment_guards.py` cover:
  - keyword tokenization & stopword stripping
  - placeholder place filtering (generic, case-insensitive, real-place
    pass-through)
  - keyword-overlap filtering (the exact 4/27 reproducer, the genuine-
    reference case, mixed/partial relevance, empty input)

13 tests pass; ruff clean.

Closes Fable task #158.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:25:16 -04:00
bvandeusen 6c309f1331 fix(journal): tune persona — capture-first, anti-overhelp, first-person moments (#157)
Three related rough edges in the journal voice surfaced from real
journal usage 2026-04-27 → 2026-04-29:

1. **Persona overhelps.** When the user logged "today I'm prepping for
   an ISP migration at Branch 14 Bedford for work at famous supply",
   the assistant came back with "ISP migrations can be tricky. Are you
   handling the network configuration yourself, or is there a team
   supporting you? Also, are there any specific tasks or checks you
   need to complete before the switch?" — pushing IT-helpdesk advice
   the user didn't ask for. The user had to push back. JOURNAL_PERSONA
   now leads with "CAPTURE first, advise only if asked" and the
   RESPONSE STYLE block has an explicit anti-pattern banning
   troubleshooting / checklist / process-advice follow-ups unless the
   user explicitly invites them.

2. **Moments stored in third-person observer voice.** The dentist
   appointment beat got written as "The user mentioned having an
   appointment this Friday but hasn't provided details yet." — reads
   like an LLM transcript annotation, not a journal jot. The
   record_moment tool's `content` description previously said "in the
   user's voice or third-person", which was the literal source of the
   bug. New phrasing requires first-person/imperative with concrete
   GOOD/BAD examples, and the JOURNAL_CALIBRATION block reinforces it.

3. **Inconsistent emoji use.** 4/27 was clinical, 4/29 had 😊 and 🛠️
   in the appointment confirmation. RESPONSE STYLE now bans emojis
   outright — the journal is a thinking-companion surface and the
   emoji warmth reads as out-of-register chat-bot tone.

Bonus while in here:
- New MOMENT ENTITY LINKING section explicitly forbids attaching a
  task_titles link unless the user references the task by name (the
  4/27 Docker→ADHD auto-link bug; rest of that fix is in #158).
- Same section rejects generic place placeholders ("work" / "home" /
  "office") in favor of letting the user name the real place.

22 tests pass (4 journal + 18 calendar tool); ruff clean.

Closes Fable task #157.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 09:21:35 -04:00
bvandeusen 03d725ea3e fix(calendar-tool): anchor today's weekday in prompts + verify expected_weekday on create/update
A user asked Fable to schedule "this Friday at 8am" on Wednesday 4/29
2026. The model picked 4/30 (Thursday) and confidently labeled it
"Friday." The TZ pipeline did everything correctly given the model's
date — the bug was upstream: the model was guessing weekdays from ISO
dates without an anchor, and the calendar tools had no way to verify.

Three layered fixes:

1. **System prompts now name the weekday alongside the ISO date.**
   Both the journal-conversation prompt and the general chat prompt
   used to say "Today is 2026-04-29 (America/New_York)." They now say
   "Today is Wednesday, 2026-04-29 (...)." LLMs are unreliable at
   deriving weekday names from ISO dates; supplying the name removes
   the guess.

2. **`expected_weekday` parameter on create_event / update_event.**
   When the model passes `expected_weekday="friday"`, the backend
   computes the resolved start_date's weekday in the user's local
   timezone and rejects mismatches with a self-correcting error
   ("Date 2026-04-30 falls on Thursday, not Friday. Recompute..."),
   without creating the event. The check is local-aware: a Friday
   23:00 event in Tokyo crosses midnight UTC but the local view
   stays Friday, and the validator respects that.

3. **Tool descriptions instruct echo-and-confirm.** create_event and
   update_event descriptions now tell the model: when the user names
   a weekday, state the resolved date in the reply BEFORE calling
   the tool, and pass `expected_weekday`. Costs nothing in code,
   reinforces the validator.

6 new tests — match success, mismatch rejection (with create/update
not invoked), omitted-param backcompat, invalid weekday name, local-
not-UTC weekday computation, and the update_event variant. All 18
calendar-tool tests + 33 event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:43:32 -04:00
bvandeusen 611c940527 fix(calendar-tool): split start/end into date+time to make event creation TZ-durable
A user reported "next Friday at 8am" landing on the wrong day. The
current `start` parameter accepts a combined ISO datetime string — when
the model emits something like `"2026-05-01T00:00:00Z"`, the parser
correctly honors the UTC tag and stores `2026-05-01 00:00 UTC`, which
displays as `2026-04-30 19:00` for a UTC-5 user. The bug isn't in our
parser; it's that we let the model TZ-tag the calendar day at all.

The fix moves the foot-gun: `create_event` and `update_event` now
prefer split fields (`start_date` + `start_time`, plus end variants).
A `YYYY-MM-DD` string carries no TZ metadata for a model to mis-tag,
and the backend builds the local datetime explicitly via
`datetime.combine(date, time, tzinfo=user_tz).astimezone(UTC)`. Strict
regex validation rejects anything with a TZ suffix on either field.

The legacy combined `start` / `end` fields are kept as a fallback so
saved tool-call payloads in conversation history still replay; new
calls are steered toward the split shape via the tool description.

7 new regression tests cover Eastern, Pacific, Tokyo (positive offset),
all-day inference, strict-shape rejection on both fields, backcompat
with the legacy `start` field, and the same fix for `update_event`.
27 of the event-related tests pass; ruff clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:16:25 -04:00
bvandeusen 3b2a0a119f chore(fable-mcp): bump version to 0.3.0 — journal tools replace briefing
The MCP server's briefing introspection tools were replaced with journal
equivalents in c549827, but the package version stayed at 0.2.6 — so
pipx upgrades against existing installs were no-ops and production MCP
clients still served the obsolete briefing tools (which 404 against the
migrated backend).

Bumping minor since this is a breaking tool-surface change (briefing
tools removed). Reinstall via `pipx install --force` to pick up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-29 08:08:01 -04:00
bvandeusen a7de3296b3 docs(design-system): note Flutter app port
Adds a "Flutter app port — shipped 2026-04-28" section between the
web Surface phase and Open threads. Records the two FabledApp commits
(foundation 0f05f47, surface b9e68e3), explains the ActionColors
ThemeExtension shape, points to reference call sites for the
ActionColors.primary (calendar event Save) and ActionColors.destructive
(confirm-Delete dialogs across notes/tasks/chat/calendar) patterns so
downstream screens have a template to follow when reclassifying their
own buttons opportunistically.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 19:44:22 -04:00
bvandeusen 88b351a96e Merge pull request 'Release v26.04.28.1 — Design system polish, journal config in Profile, doc cleanup' (#44) from dev into main 2026-04-28 13:12:54 +00:00
bvandeusen bc239119f3 fix: SettingsView TS errors + calendar month/year font
CI typecheck failed: JournalLocation interface requires `address: string`,
but Profile-tab Locations code created defaults like `{ label: 'Home' }`
without it. Symptoms were six TS2741 errors in SettingsView.vue.

Fix: include `address` in defaults and treat the user's place-name input
as the address field. Now homeQuery / workQuery sync to
locations.{home|work}.address — both at load time and on geocode.
loadJournalConfig now reads address (was reading label, which is fixed
to "Home"/"Work"); geocodeFor writes the typed query into address while
also setting lat/lon on success.

Calendar Month/Year title was actually rendering Fraunces, not Inter as
I'd claimed. FullCalendar renders .fc-toolbar-title as an <h2>, which
the global theme.css `h1, h2 { font-family: 'Fraunces' }` rule catches
before the parent .fc font-family inherit can do anything. At 1.1rem
it's below the doc's "Fraunces only at ≥18px" threshold, so explicit
Inter override on the deep selector.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:58:15 -04:00
bvandeusen 30dfbce426 fix(typography): strip chrome italic; reserve italic for emphasis
Per the design rule "italic is for emphasis, not for design", removed
every chrome `font-style: italic` declaration across the frontend.
42 declarations gone across 24 files: empty-state placeholder copy,
loading messages, "no results" hints, ghost text, voice/role labels,
field placeholder text, brand wordmark, et al.

Markdown content emphasis is unaffected — `<em>` and `<i>` tags from
`*emphasis*` markup still render italic via browser default styling
(prose.css doesn't override em behavior). User-typed emphasis in
notes, journal entries, and chat messages keeps its italic.

Specific spots that lost the decorative italic:

- KnowledgeView .filter-label, .empty-narrator
- NoteEditorView .ef-label, link-suggest related
- ChatPanel .empty-msg, .empty-greeting, .role-label
- ChatMessage .role-assistant .role-label (the "Fable" voice tag —
  was italic per the doc's Illuminated Transcript spec, but per the
  new typography rule the speaker tag stays regular and lets the
  border-left + glow do the bubble framing on their own)
- AppHeader .brand-text ("Fabled" wordmark)
- editor-shared.css .title-input::placeholder
- ProjectView .project-title-input::placeholder
- HomeView .urgency-loading
- WorkspaceTaskPanel .empty-group
- WeatherCard .weather-unavailable
- SharedWithMeView .empty-msg
- DiffView empty-state spans
- ToolCallCard .tool-event-more
- SettingsView 7 spots (.you-label, .geo-pending, hint text, etc.)
- + several other empty-state / hint text spots

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:54:26 -04:00
bvandeusen 65ba1cc82a fix(typography): bump italic-Fraunces section/field labels to readable size
KnowledgeView's "Type" / "Tags" filter section headers (.filter-label)
and NoteEditorView's entity-field labels (.ef-label — Email, Phone,
Address etc on person/place notes) were using italic Fraunces accent
at 0.72rem / 0.78rem. The italic + small + decorative-serif combo
read as illegible flourish rather than functional section headers.

Bumped:
- .filter-label 0.72rem → 0.95rem, margin-bottom 6px → 8px
- .ef-label 0.78rem → 0.92rem

Italic Fraunces accent preserved (keeps the branded character that
matches the rest of the surface). Just enlarged to a readable size.

ChatMessage's .role-assistant .role-label kept at 0.8rem italic
Fraunces — that's the doc's Illuminated Transcript voice label, a
decorative speaker tag rather than a functional heading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:42:05 -04:00
bvandeusen 4cfea784a9 fix(projects): tab counter NaN when a status bucket is empty
services/projects.py:get_project_summary built task_counts dynamically
from a GROUP BY query, so a project with no done tasks would omit the
'done' key entirely. Frontend's TypeScript interface declares all three
lifecycle keys as required, and ProjectView.vue summed them to render
the Tasks tab counter — undefined + N = NaN.

Two fixes:
1. Backend: initialise task_counts with {todo: 0, in_progress: 0,
   done: 0} so the service returns the contract its consumers expect.
   Catches the same problem for HomeView's project widget and any
   other consumer.
2. Frontend: defensive ?? 0 on the tab-counter sum, so the existing
   deploy renders correctly even before the backend rolls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 07:40:21 -04:00
bvandeusen 016a2bd270 docs: clear briefing remnants and document journal system
The Briefing system was retired weeks ago and replaced with the
conversational Journal, but several current-state docs still
described it as live. Updates:

architecture.md
- settings keys: briefing_enabled / briefing_locations / office_days →
  journal_config (JSON: locations, temp_unit, prep schedule)
- conversation_type: chat/briefing/mcp → chat/journal/mcp
- briefing_date column → day_date on Conversation
- "Briefing-Related Tables" section: dropped rss_feeds + rss_items
  rows (retired with PR #43); kept weather_cache and clarified that
  lat/lon now live in journal_config.locations, not on the cache row
- routes/chat.py description: dropped trailing "briefing conversation
  routes" mention
- routes/briefing.py → routes/journal.py with the current endpoint
  shape (config / today / day / days / weather / moments / trigger-
  prep)
- services/briefing_pipeline + scheduler + conversations + profile →
  services/journal_prep + journal_pipeline + journal_scheduler +
  journal_search/moments + user_profile.build_profile_context

features.md
- "Daily Briefing" section rewritten as "Daily Journal":
  conversational, single morning prep, right rail with weather +
  upcoming events, profile-tab configuration, "what the assistant
  has learned" framing
- Settings table: dropped Briefing tab row, added Profile tab row,
  fixed Notifications row's stale briefing-push reference

api-reference.md
- /api/briefing/* endpoints replaced with /api/journal/* — config,
  today/day/days, trigger-prep, weather (cached/current/refresh/
  geocode), moments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:11:43 -04:00
bvandeusen 39d56cfcf3 feat(settings): re-add journal config to Profile tab; clear briefing remnants
The Briefing Settings section was removed during the briefing→journal
migration but its data (locations, temp unit, prep schedule) is still
read by the journal backend. There was no UI to set any of it, so
weather couldn't render and prep timing wasn't tunable.

Re-adds the missing config inside the existing Profile tab — it's all
"about the user" data and a separate Journal tab would just clutter
the sidebar. New sections:

Locations (after Work Schedule)
- Home and Work place-name inputs with on-blur geocoding via
  /api/journal/weather/geocode
- Temperature unit toggle (Celsius / Fahrenheit)
- Status messages distinguish ok / pending / error

Journal (before What the Assistant Has Learned)
- Daily prep auto-generate toggle
- Prep generation hour:minute (24-hour input)
- Day rollover hour (so 1–3am entries still count as the previous day)
- All controls disable cleanly when prep is off

Cleanup
- Profile "About You" desc: "chat and briefings" → "chat and the daily journal"
- Profile "Interests" desc: "personalise news and briefing context" →
  "personalise the journal's daily prep and chat responses"
- Profile "Work Schedule" desc: "Helps the briefing" → "Helps the journal"
- Profile "What the Assistant Has Learned" desc: clarifies the summary
  is included in the journal's system prompt; observations come from
  journal + chat (not briefing)
- General "Timezone" desc: "schedule briefings" → "schedule the daily
  journal prep"
- Removed the dead `.briefing-*` CSS block (~190 lines of styles for
  retired briefing UI: feed-row, slot-row, add-feed-form, etc.) and
  replaced with fresh `.location-row`, `.unit-toggle`, `.unit-btn`,
  `.checkbox-label`, `.time-row`, `.time-input`, `.geo-msg` rules used
  by the new sections. unit-btn.active uses Moss action-primary per
  Hybrid; tokens flow through the rest.

The "What the Assistant Has Learned" section was confirmed
load-bearing for the journal — `journal_pipeline.py:139` calls
`build_profile_context()` which feeds learned_summary plus other
profile fields into the journal's system prompt. Not a remnant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-28 00:05:07 -04:00
bvandeusen 3f287d7417 fix(design): brighten status-dot indicator lights
The "ready" / "cold" / "unavailable" dots in the AppHeader status
indicator were pulling --color-success / --color-warning /
--color-danger which after the foundation pass became Moss /
Warning gold-brown / Error terracotta — all visibly muted. The
green-ready dot in particular read too dark to register as a
"ready light", and the pulse glow (bright emerald) had nothing to
glow off of.

Status dots are indicator *lights*, not semantic-palette UI
elements. Decouple them with hardcoded vital values:
- ready: #4ade80 (matches the existing pulse glow)
- cold:  #facc15
- red:   #ef4444
Orange already used a hardcoded bright #f97316; left as-is.

The rest of the system continues to use --color-success /
--color-warning / --color-danger semantically (toast-success,
validation errors, etc.) — only the indicator-light contexts get
the brighter palette.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:33:33 -04:00
bvandeusen 64ab24864a docs(design-system): mark surface phase shipped end-to-end
Updates the Scribe-specific decisions section header and replaces
the Surface-Phase TODO checklist with a per-PR ship table covering
the seven PRs that landed today (93a3beb3c1ec40). Adds an
explicit "Out of scope — deferred indefinitely" subsection so
future-me knows what's intentionally not done (stroke-weight
overrides, filled-as-active state, type-scale tokens, etc.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:23:36 -04:00
bvandeusen 3c1ec4077f feat(design): surface phase PR 7 — edge surfaces polish
Per the spec, the final per-surface PR bundles the smaller / chrome-y /
library-driven views (Header / Home / Calendar / Graph) together with
EventSlideOver since it's the calendar's primary interaction surface.

Button reclassification per Hybrid rule
- CalendarView btn-new-event: accent gradient → Moss action-primary.
  Creating an event is a workflow action, not a brand moment.
- EventSlideOver btn-primary (Save): accent gradient → Moss
- EventSlideOver btn-secondary (Cancel/No): muted ghost → Bronze
  action-secondary
- EventSlideOver btn-danger-ghost (Delete): hardcoded #ef4444 →
  --color-action-destructive ghost (with filled hover)
- EventSlideOver btn-danger (confirm Delete): hardcoded #ef4444 →
  --color-action-destructive filled

Brand-moment CTAs kept on accent
- HomeView btn-workspace-hero: home-page hero CTA into the active
  project — central brand-moment surface, gradient stays
- HomeView btn-workspace-sm: per-card workspace icon with the
  accent-tinted bg + hover-to-accent pattern, kept

Two-weights-only
- Snapped every font-weight 600/700 to 500 across the bundle
  (HomeView, GraphView, CalendarView, AppHeader, EventSlideOver).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:17:04 -04:00
bvandeusen 541e2ed713 feat(design): surface phase PR 6 — Settings polish
Settings is the densest button surface in the app. Per the surface-
phase spec, classify every button on a deliberate token.

Button reclassification per Hybrid rule
- btn-save (~13 instances across General/Account/Notifications/
  Integrations/Data/Briefing/Admin tabs): flat accent → Moss
  action-primary
- btn-primary (Groups tab "Save Retention", "+ New Group"): flat
  accent → Moss action-primary
- btn-secondary (Detect timezone, Test SMTP, Refresh, Export, Add
  slot, Preview voice, etc.): outline-on-bg-secondary +
  hover-to-accent → Bronze action-secondary, filled
- btn-danger filled (Reset VAPID, Revoke API key Yes): added the
  rule (was previously unstyled — relied on no-rule). Now Oxblood
  action-destructive.
- btn-danger-outline (Invalidate sessions, Clear observations,
  Delete personal data): generic --color-danger → Oxblood
  action-destructive
- btn-danger-sm hover (group + member delete): --color-danger →
  --color-action-destructive
- btn-delete row hover, btn-confirm-delete (user delete two-stage):
  --color-danger → Oxblood. Confirm filled, cancel ghost.
- btn-toggle-open ("Open registration"): flat accent → Moss
- model-delete-btn hover: --color-danger → Oxblood
- btn-warn hover: muted-border-warning → filled-warning

Two-weights-only
- Snapped every font-weight 600/700 to 500.

Out of scope for this PR
- Voice/tone copy audit across labels and help text — per spec,
  opportunistic only. Skipped.
- Settings row de-bordering — current section dividers are
  structural; left as-is.
- Validation-state colors (.input-error etc.) keep --color-danger
  (Error terracotta) per the doc — Error is for validation/error
  messages, Oxblood is for destructive actions.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:13:38 -04:00
bvandeusen ff498ce1a4 feat(design): surface phase PR 5 — Project + Workspace polish
Per the surface-phase spec for the Project list, Project view, and
3-panel Workspace cluster:

Button reclassification per Hybrid rule
- ProjectView:
  - btn-workspace ("Open Workspace"): kept on accent gradient — the
    workspace is the project's central feature moment, a Scribe-
    flavored action, brand moment per the doc.
  - btn-share: muted-outline → Bronze action-secondary
  - btn-danger-outline (Delete project): generic-border + danger-on-
    hover → Oxblood outline + filled hover (proper destructive)
  - btn-save-panel: accent gradient → Moss action-primary
  - btn-ms-confirm (milestone confirm): flat accent → Moss
  - btn-ms-cancel: ghost → Bronze action-secondary
  - .modal-btn-danger: --color-danger → --color-action-destructive
- ProjectListView:
  - btn-primary ("+ New Project"): flat accent → Moss action-primary.
    Empty-state CTA (.empty-action) keeps accent — that *is* a
    brand moment per Hybrid, "create your first project".
- WorkspaceTaskPanel + WorkspaceNoteEditor:
  - btn-delete-task / btn-delete: hover --color-danger → Oxblood
  - btn-delete-confirm / btn-confirm-delete: --color-danger pair →
    --color-action-destructive pair, with filled hover for stronger
    affordance
  - btn-save (WorkspaceNoteEditor): flat accent → Moss
- WorkspaceChatWidget:
  - History toggle: replaced unicode ▾ with Lucide ChevronDown
    (PR 1 carry-over for emoji-as-icon)

Two-weights-only
- Snapped every font-weight 600/700 to 500 across the cluster.

Borders kept (per structural-not-decorative rule)
- Milestone group cards: standalone containers, keep
- Project cards in list: standalone containers, keep
- Task slide-over panel: drawer modal, keep
- Workspace 3-panel separators: major section dividers, keep

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:09:33 -04:00
bvandeusen efb3534f3a feat(design): surface phase PR 4 — Knowledge cluster polish
Per the surface-phase spec for the Notes/Tasks viewers and editors:

Long-form line-height
- prose.css: bumped global .prose from 1.6 to 1.7. Applies to Note
  viewer body, Task viewer body, anywhere markdown renders into a
  reading surface. Chat assistant bubble already had the explicit
  override; now consistent with the rest.

Button reclassification per Hybrid rule
- Shared editor-shared.css:
  - btn-save: accent gradient → Moss (action-primary). Saving is
    "operating the software", not a brand moment.
  - btn-delete: --color-danger (Error terracotta) → Oxblood
    (action-destructive). Layout updated for inline-flex so the
    Trash2 icon at call sites lines up alongside the label.
- NoteEditorView, TaskEditorView: Delete buttons now contain a
  Trash2 icon per Hybrid's "destructive paired with icon" rule.
- NoteViewerView, TaskViewerView:
  - btn-edit (and TaskViewer's btn-advance): accent gradient → Moss.
    Switching to edit / advancing status are workflow actions.
  - btn-convert, btn-share: ghost-on-hover-to-accent → Bronze
    action-secondary (alternate paths).

Two-weights-only
- Snapped every font-weight: 600/700 to 500 across editor-shared.css
  and the five Knowledge-cluster views.

Out of scope for this PR (deliberate punt)
- Smaller utility buttons (.btn-suggest-tags, .btn-link-all,
  .btn-add-subtask, the AI-assist generate/proofread/accept/reject
  set, etc.) — currently ghost-styled, generally compliant. Will
  revisit only if they read off in practice.
- Filter chip / sub-task list-row border audit — deferred since
  current styles already lean on background tint for affordance.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 23:02:59 -04:00
bvandeusen 4192a64c0f feat(design): surface phase PR 3 — Chat surface polish
Per the surface-phase spec for the Chat cluster (ChatView, ChatPanel,
ChatMessage, ChatInputBar, ToolCallCard):

Long-form line-height
- ChatMessage: assistant-bubble .message-content jumps from 1.55 to
  1.7 — chat is a reading surface, not a snippet stream. User
  bubbles stay tighter.
- JournalView: dropped its :deep(.role-assistant .message-content)
  override; ChatMessage handles it now and Journal inherits.

ToolCallCard borders
- Removed the outer 1px border. ToolCallCard always renders inside
  an assistant bubble; the bubble already contains it. Background
  tint differentiates without re-bordering. Per the
  structural-not-decorative rule.
- Error state preserved as a 3px left-edge accent in --color-danger,
  mirroring the assistant bubble's own left-edge pattern.

Button reclassification per Hybrid rule
- ChatView .bulk-link "All"/"None": accent text → --color-text-secondary
  (these are tertiary list-control affordances, not brand moments)
- ChatView .bulk-delete-btn: --color-danger (Error terracotta) →
  --color-action-destructive (Oxblood) per Hybrid; paired with a
  Trash2 icon since destructive should always be reinforced by an
  icon, not just color
- ChatView .btn-delete-conv hover: same Error → Oxblood swap
- .btn-new-conv stays accent (brand moment, correct already)
- .btn-send stays accent gradient (primary brand moment, foundation)

Two-weights-only
- Snapped every font-weight: 600/700 to 500 across ChatView,
  ChatPanel, ChatMessage, ToolCallCard per the doc rule.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 22:42:44 -04:00
bvandeusen f20e54a068 fix(journal): widen events window to match prep's framing
The right rail's loadEvents was using "now → now + 14 days", which
filtered out events earlier today that had already passed. The prep
uses "today 00:00 → today 23:59" so it includes those events and
explicitly notes they're "in the past". The two surfaces talked
about different sets of events.

Widen the right rail to "today 00:00 → today + 14 days 23:59" so
events from earlier today still surface and group under "Today". The
prep and the right rail now reference the same set within today.
Events further out (next 14 days) keep working as before.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:55:01 -04:00
bvandeusen de4b1d7c7e fix(weather): match prep behavior — serve cached weather regardless of age
The /api/journal/weather route was filtering out cache rows older than 24
hours via parse_weather_card_data, while journal_prep.py read the same
rows raw without freshness checking. Result: the daily prep referenced
"home" and "work" temperatures while the right-rail UI showed nothing —
two surfaces, same backing data, inconsistent visibility.

Two changes:

1. parse_weather_card_data no longer returns None for stale data.
   WeatherCard already exposes fetched_at and gracefully hides
   today_high / forecast fields when they're absent, so old data renders
   with whatever fields the cached forecast still covers.

2. The /weather route opportunistically schedules a background refresh
   for any cache row older than 4 hours. If the user's journal_config
   has lat/lon for that location_key, the refresh runs and the next
   page load gets fresh data; if no usable config, the refresh is a
   silent no-op and the stale cache is still served.

This makes prep and UI consistent. It also self-heals over time — once
locations are configured, stale caches get refreshed on the next page
load instead of waiting indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:19:57 -04:00
bvandeusen 3d916d7357 feat(design): surface phase PR 2 — Journal polish
Per the surface-phase spec for Journal:

- Weather refresh button: ↻ unicode → Lucide RotateCcw (PR 1
  carry-over for emoji-as-icon)
- .journal-title: dropped redundant font-family override (h1 inherits
  Fraunces from theme.css); also snapped weight 700 → 500 per doc's
  "two weights only" rule
- .weather-tab.active, .events-day-label, .event-title, .panel-label:
  snapped 600/700 → 500 per same rule
- Added long-form 1.7 line-height to journal-chat-panel assistant
  bubble content (the daily prep is prose, not chat snippets)
- Removed dead .news-section / .news-card / .news-* / .reaction-btn
  CSS — news functionality was retired with the briefing→journal
  migration (PR #43); styles were never cleaned up

Buttons audited per Hybrid rule: btn-trigger ("Refresh prep") and
weather-refresh-btn are tertiary actions, already correctly styled
as Pewter ghost. .btn-send (Journal Send) flows through ChatInputBar
on the accent gradient — brand moment, kept as-is.

Borders audited per structural-not-decorative: header bottom,
sidebar left, section dividers, form input borders all kept (all
genuinely structural). No decorative borders to remove.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:53:09 -04:00
bvandeusen df423ab906 fix(task-editor): prevent preview pane and work log from flex-shrinking
.task-main is a flex column. Children default to flex-shrink: 1, so
when body content is taller than the available column height, flex
squeezes .preview-pane back to its min-height (200px) and the
overflow renders visibly on top of subsequent siblings — making the
TaskLogSection appear in the middle of the body content.

Surfaced by viewing a task with an unusually long markdown body in
preview mode. Pre-existed PR 1, just rarely hit.

NoteEditorView uses the standard block-flow .editor-main and isn't
affected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:45:50 -04:00
bvandeusen d023209f13 chore(deps): regenerate package-lock for lucide-vue-next
CI's npm ci step requires lock and package.json to be in sync.
Adds the lucide-vue-next ^0.469.0 entry plus the indirect
@esbuild platform variants that npm pulls in on lockfile refresh.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 20:05:50 -04:00
bvandeusen 93a3beb5d6 feat(design): surface phase PR 1 — Lucide icon migration + scale enforcement
Replaces every hand-inlined SVG in the chrome (60 across 15 files)
with Lucide components. Snaps icon sizes to the doc's 16/24 scale —
all icons in this pass land at 16. AppLogo wordmark stays as the
legitimate "custom app icon" exception per the doc; GraphView's <svg>
mount point for D3 stays as well.

Replaces emoji-as-icons in clear button-affordance cases with their
Lucide equivalents: ✕ → X (close/dismiss buttons across 8 files), ✓ →
Check (ProjectView task-advance button), 🎤 → Mic (ChatPanel voice
CTA), 📎 → Paperclip (ChatView context toggle), &uarr; → ArrowUp
(ChatInputBar Send), &times; → X (ChatPanel context-note remove), ☀/☾
→ Sun/Moon (AppHeader theme toggle).

Inline emoji punctuation in textual confirmation copy ("Saved ✓",
"Created ✓", `done: "✓"` status maps, `'✓' : '📄'` interpolations)
is left for surface-phase voice/tone touchups — replacing requires
structural template changes and is best done per-component.

Stroke weight stays at Lucide's default 2px; tightening to the doc's
1.5/1 is deferred per the surface-phase spec (option ii: drop-in +
scale enforcement).

Adds lucide-vue-next ^0.469.0 to frontend/package.json. Docker
rebuild handles the install.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:56:01 -04:00
bvandeusen 54710b35e9 docs(design-system): note foundation pass shipped; expand surface-phase thread
Updates the "Scribe-specific decisions in progress" header to reflect
that the foundation pass landed in 7a9a8b7. Replaces the brief "next
phase" placeholder with a concrete shipped/remaining split — listing
what foundation covered and the surface-phase work that still needs
doing (button reclassification, Lucide migration, border audit,
voice/tone, long-form line-height, bubble shape tweaks).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 18:05:47 -04:00
bvandeusen 7a9a8b7819 feat(design): foundation pass — dusty violet palette, warm parchment light, Inter + JetBrains Mono
Applies the FabledSword + Scribe iteration palette to theme.css end to
end: indigo (#7c3aed) → dusty violet (#5B4A8A) accent, cool grey →
Obsidian/Iron/Pewter dark surfaces, warm parchment (#F5F1E8) light
mode, Inter body + JetBrains Mono code loaded alongside Fraunces, and
neutral hairline scrollbars (chrome is structural, not branded).

Adds the action token set (--color-action-primary Moss,
--color-action-secondary Bronze, --color-action-destructive Oxblood,
--color-action-ghost-border Pewter) but does not yet reclassify any
buttons — surface-phase work. Buttons remain dusty-violet gradients in
the meantime, by design.

Removes deprecated --color-accent-warm; replaces concrete usages with
--color-text-secondary (dates, flavor copy) or --color-warning (paused
status). Sweeps hardcoded indigo literals in component scoped CSS so
they don't bypass the token system.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 17:20:01 -04:00
bvandeusen 057b30b89e Merge pull request 'Release v26.04.27 — Journal replaces Briefing; RSS retired; Scribe rebrand' (#43) from dev into main 2026-04-27 12:12:43 +00:00
bvandeusen a3071a53cb feat(routing): land on Journal at root; move Knowledge to /knowledge
Routing changes:
- / now redirects to /journal (Journal becomes the home view)
- /knowledge becomes a real route pointing at KnowledgeView (was a redirect to /)
- /notes redirect target updated from / to /knowledge (the Knowledge surface,
  which is where the notes-related dashboard lives)

To revert (Knowledge as home): change the redirect target on the / route
back to "/knowledge". Knowledge stays a real route either way, so the swap
is a one-line edit.

Nav: Knowledge link in AppHeader now points to /knowledge. The "active" check
simplified to route.path === "/knowledge" since / is no longer Knowledge's
home. The brand logo link stays at "/" — clicks still go "home", which is
now Journal.

Keyboard shortcuts in App.vue (Escape, g h, g t) still navigate to "/" and
correctly land on Journal via the redirect — no change needed there.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 22:46:47 -04:00
bvandeusen 4faaa5246b fix(journal): make record_moment mandatory, not a "nice to have"
Inspection showed only ONE record_moment call across the entire day's
journal — and that one had hallucinated person_ids. Multiple clear beats
went uncaptured: AP installation, going to watch a show with daughter,
decompressing-with-game.

The prior calibration said "use record_moment freely for meaningful
beats" — too soft. The model treated it as optional, especially when
already in chatbot-reply mode.

Rewritten: record_moment is now framed as the model's PRIMARY JOB. The
calibration includes an explicit checklist of what counts as a beat
(event, encounter, decision, observation, plan, feeling, accomplishment)
and an explicit instruction to call record_moment FIRST, before composing
the reply. Multiple beats → multiple calls. The ONLY skip case spelled
out: purely meta-conversational messages (acknowledgements, meta-asks
about prior tool results).

Tests on a fresh conversation will tell us if this moves the needle —
today's journal is poisoned by ten prior chatbot-flavored turns that
the model is pattern-matching against in its own history.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 21:37:29 -04:00
bvandeusen 0ed9cbf666 fix(journal): restore prep prose; soften persona toward chat-like
Per user clarification: previous over-rotation dropped the LLM-generated
prep prose entirely (just a phase greeting) and made the chat persona
extremely sparse ("you are a place where words go down"). User actually
wanted only the chat replies pulled back, NOT the prep dropped, and the
chat to behave largely like normal /chat — asking follow-ups and
verifying earlier details.

services/journal_prep.py — restored:
- _render_sections_for_prompt
- _PREP_SYSTEM_PROMPT (the direct, briefing-style prompt from 590a07b)
- _generate_prep_prose
- _fallback_prep_text
- ensure_daily_prep_message now calls _generate_prep_prose again
- removed _phase_for_now / _phase_prompt helpers (no longer needed)

services/journal_pipeline.py — persona rewritten:
- Old: "You are the user's journal. Be quiet. Listen. You are not helpful."
- New: "You are the user's assistant. Behave like the rest of the app's
  chat: respond conversationally, ask follow-up questions, verify details
  from earlier turns, use tools naturally."
- Calibration block reorganized: PEOPLE/PLACES (ask first), MOMENTS
  (silent + use *_names), STATE-CHANGING TOOLS (confirmation flow),
  OTHER, RESPONSE STYLE.
- RESPONSE STYLE keeps the no-apologizing / no-option-menus /
  no-verbatim-repetition / match-user-length rules but drops the "be
  quiet, one short sentence" framing.

Net behavior:
- Open journal → LLM-generated prep prose with today's tasks/events/weather
- Reply → assistant responds conversationally like /chat, asks follow-ups,
  verifies details, uses tools
- Background: silently records moments via *_names, asks before creating
  new people/places

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 18:04:34 -04:00
bvandeusen 4668c0950b fix(journal): minimal prep + quieter persona
The prep was generating a multi-sentence recap of tasks/events/weather/
projects/recent moments via an LLM call. Per user direction, that's
redundant — the right-side widgets already show today's data — and the
verbosity made the journal feel chatty when the user wanted quiet.

Replaces the prep prose generator with a single phase-aware check-in
question (drawn from a static map: morning="How are you starting the
day?", midday="How's it going so far?", evening="How did the day shake
out?"). No LLM call. The structured `sections` are still gathered and
persisted on msg_metadata for provenance and possible future tooling
(e.g., search), they just don't render in the prep message.

Also pulls the journal persona way back. The prior framing pushed the
model toward stock therapy-template patterns ("I'm sorry you're feeling…"
+ numbered option lists). The new persona is "you are the user's journal —
listen, be quiet, stay out of the way." RESPONSE STYLE rules now lead the
calibration block and explicitly forbid:

- apologizing for the user's feelings
- offering to help / pitching tools
- multi-option menus
- verbatim repetition of prior replies
- padding short replies into paragraphs

Most replies should be one short sentence. Sometimes the right reply is
"Got it" + a record_moment tool call.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 17:48:41 -04:00
bvandeusen b728acd841 fix(journal): name-based entity resolution + tighter calibration + anti-repetition
Three real bugs surfaced from inspecting today's journal turns:

1. record_moment was getting fed hallucinated person_ids (the LLM passed
   [1, 2] instead of the IDs save_person had just returned). Result: the
   moment was linked to two random old test-data notes ("test task 2",
   "Tell a joke"), not the people the user actually mentioned.

2. The calibration rule "ask before save_person" was being silently
   ignored — model just called save_person on first mention of Victoria
   and Mother without asking the user.

3. The model produced a verbatim-identical reply to its previous turn when
   the user mentioned "overwhelmed" twice — same numbered-list of 4
   options, same closing line. The "warm listener / ask gentle questions"
   persona was pushing toward stock therapy-template patterns.

Fixes:

services/tools/journal.py — record_moment now accepts *_names parameters
(person_names, place_names, task_titles, note_titles). Server resolves
each name to a note ID via case-insensitive title match, scoped by
note_type or task-status. *_ids parameters still exist but are now
documented as DISCOURAGED. The LLM physically cannot invent the wrong ID
when using names — names with no match are silently dropped. Resolution
happens via _resolve_entity_ids_by_name helper.

services/journal_pipeline.py — JOURNAL_PERSONA tightened (no more
"warm/curious listener" framing that pushed toward stock comfort
patterns). JOURNAL_CALIBRATION rewritten as scannable sections with
imperative language: PEOPLE/PLACES require asking before save_person;
TASK/NOTE state changes use the confirmation flow; MOMENTS are silent
but MUST use *_names not *_ids; OTHER notes the no-set_rag_scope and
no-auto-notes invariants. Added a RESPONSE STYLE section that explicitly
forbids verbatim repetition and stock multi-option menus.

After deploy, force-regenerate today's prep via fable_trigger_journal_prep
to also pick up the tighter prep prompt from 590a07b.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 17:16:33 -04:00
bvandeusen c5498273c3 feat(mcp): replace briefing tools with journal tools
The MCP was still calling /api/briefing/* endpoints I deleted in the
journal hard-cut. Replaced the briefing tool surface with a journal
equivalent so external MCP clients can inspect and control the journal
the same way they could the briefing.

Changes:
- New fable_mcp/tools/journal.py — helpers for /api/journal/* endpoints
- Delete fable_mcp/tools/briefing.py — RSS endpoints are gone too
- server.py: drop fable_list_rss_feeds, fable_add_rss_feed, fable_remove_rss_feed,
  fable_list_briefings, fable_get_today_briefing, fable_get_briefing_messages,
  fable_trigger_briefing, fable_reset_today_briefing
- server.py: add fable_get_today_journal, fable_get_journal_day,
  fable_list_journal_days, fable_trigger_journal_prep, fable_get_journal_config,
  fable_list_moments
- server.py: fable_get_conversation now points at journal.get_conversation
  (same /api/chat/conversations/{id} endpoint, just lives under journal helpers)
- Update _INSTRUCTIONS to describe the journal model (replacing the RSS/briefing
  section) — explains the daily prep, moments, day payloads
- Update top-line docstring: "Fable Assistant" → "Fable Scribe"

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:19:53 -04:00
bvandeusen 590a07bc13 fix(journal): tighten prep prompt — direct briefing, not flowery letter
Previous system prompt asked for "warm, conversational, like a friend
writing a letter" which produced flowery preludes that buried the actual
data. Rewritten to:

- Lead with practical data (tasks, events, weather) — concrete and specific
- 4-7 sentences total, tight prose, no padding
- Recent moments / open threads mentioned briefly at the END as context,
  not as the lead
- Voice: "competent assistant briefing the user" not "friend writing a letter"
- Close with a short journal invitation under 8 words

Also dropped max_tokens 600 -> 400 to bias toward concision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:10:57 -04:00
bvandeusen c9f2134ad4 feat(journal): conversational LLM-generated daily prep (replaces card)
The structured prep card was data-rich but voiceless. Replaced with an
LLM-generated conversational opener — same shape the briefing's compilation
slot had — that renders as a normal assistant chat bubble at the top of
the day's conversation.

Backend (services/journal_prep.py):
- Renamed generate_daily_prep -> gather_daily_sections (still pure data
  fetching, no LLM); kept the old name as a backwards-compat alias.
- New _generate_prep_prose: hands the gathered sections to generate_completion
  with a warm-conversational system prompt; returns prose. Falls back to a
  plain greeting if the LLM call fails or no model is configured.
- ensure_daily_prep_message now persists the prep as role='assistant' with
  the prose as content. Structured sections stay on msg_metadata for
  provenance. Auto-upgrades legacy system-role preps in place on next call.

Frontend:
- Drop the <article class="daily-prep"> structured block from JournalView.
  The prep is now just the first chat bubble — picks up the existing
  Illuminated Transcript styling automatically.
- Drop dayMessages / prepMessage / prepSections / asArray helpers — no
  longer needed.
- ChatMessage hideMessage filter: comment refined to clarify it only
  catches LEGACY system-role prep rows. Current preps are assistant-role
  and render normally.

Net effect: open /journal -> first thing you see is a warm assistant bubble
that talks about your day -> input bar below to reply.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 15:42:30 -04:00
bvandeusen dda62265c9 fix(journal): drop dangling JournalSetupWizard import
The setup-wizard component was deleted in the RSS hard-cut (it was
briefing-config-shaped), but JournalView still imported it — breaking
the Vite build.

Removed the import + showWizard / wizardChecked / checkSetup /
onWizardDone plumbing. JournalView now mounts straight into the day view.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:53:06 -04:00
bvandeusen 297f2252a3 fix(test): repoint lookup tests to article_fetcher.fetch_article_text
The two test_lookup_tool.py cases were patching the now-deleted
fabledassistant.services.rss._fetch_full_article. The trafilatura URL→text
helper moved to services/article_fetcher in the RSS hard-cut.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:44:09 -04:00
bvandeusen dbd9f00061 refactor: hard-cut RSS infrastructure (scope C)
Removes the entire RSS feature surface — feeds, items, embeddings, reactions,
discussion-note flow, briefing news context, settings, env-vars, and DB
tables. Keeps the URL-generic article-reader (the read_article LLM tool)
under a clean module so the LLM can still fetch arbitrary article content
from URLs the user provides.

Backend:
- New services/article_fetcher.py — single source of trafilatura URL→text
- New services/tools/article.py — read_article tool (was nested under tools/rss)
- Delete services/rss.py, rss_classifier.py, rss_filtering.py, article_context.py
- Delete services/tools/rss.py
- Delete models/rss_feed.py (RssFeed, RssItem), models/rss_item_embedding.py
- services/embeddings.py: drop upsert/semantic_search/backfill RSS helpers
- services/llm.py: remove _build_briefing_article_context, briefing-conv branch,
  ARTICLE_DISCUSS_SEED skip-RAG branch; drop get_rss_items / add_rss_feed from
  the actions list
- services/generation_task.py: drop _maybe_save_article_discussion_note + caller
- routes/chat.py: drop /api/chat/from-article/<id> endpoint
- routes/journal.py: re-import via web.py refactor (article_fetcher path)
- services/tools/__init__.py: register `article`, drop `rss`
- services/tools/_registry.py: drop the requires=='rss' check
- app.py: drop backfill_rss_item_embeddings + backfill_rss_article_content tasks
- config.py: prose-only edit (no env var change — RSS env vars were never first-class)

Frontend:
- stores/settings.ts: drop rssEnabled
- SettingsView.vue: drop the RSS-classification mention
- api/client.ts: drop openArticleInChat (the from-article endpoint is gone)

Tests:
- Delete tests/test_rss_service.py, test_news_api.py, test_article_reading.py

Migration:
- 0042_drop_rss: DROP TABLE rss_item_embeddings, rss_item_reactions, rss_items,
  rss_feeds; DELETE settings rows for rss_enabled / briefing_*_topics

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:33:30 -04:00
bvandeusen cacfcac86a fix(journal): restore weather + events panels; hide daily-prep system msg in chat
The journal UI was over-stripped earlier — weather panel, current-conditions
poll, and the upcoming-events sidebar were all dropped. Restored those (calls
the new /api/journal/weather, /api/journal/weather/current,
/api/journal/weather/refresh, /api/journal/weather/geocode endpoints).

Also: the daily-prep system message was rendering as flat text inside
ChatPanel because there's no .role-system bubble styling. Added a hideMessage
guard in ChatMessage so daily-prep system messages don't render in the chat
stream — they're already shown above as a structured prep card.

News / RSS reactions / article-discuss are intentionally NOT in the journal
(scoped out per user direction). The broader RSS infrastructure cleanup is
a separate, larger task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 12:22:51 -04:00
bvandeusen 873e70c7ea feat(journal): add JournalView UI with /journal route + nav link
Restores a working browser surface for the journal feature, which was
left UI-less when Stage F of the original plan was deferred. JournalView
is a fresh write (not a rename of BriefingView) — drops the briefing-
specific weather panel, news cards, RSS reactions, article-discuss
button, and setup wizard, since none of those have journal-backend
equivalents.

What it does:
- Fetches /api/journal/today on mount; shows today's daily-prep card
  (rendered from msg_metadata.kind === 'daily_prep' sections)
- Day picker via /api/journal/days lets you switch to past days
- Refresh-prep button hits /api/journal/trigger-prep
- Center is the existing ChatPanel pinned to today's journal conversation
  (so the chat input + SSE + tool-call cards inherit unchanged)
- Right sidebar keeps the upcoming-events list (uses the generic
  /api/events endpoint, not a briefing-specific one)

Also:
- Replace the dead Briefing API client functions with Journal equivalents
  (getJournalConfig, saveJournalConfig, getJournalToday, getJournalDay,
  getJournalDays, triggerJournalPrep, list/update/deleteJournalMoment).
- Remove NewsView.vue — it was orphaned (no route, no nav) and depended
  on the deleted /api/briefing/* endpoints. If a standalone news surface
  is wanted later it'll need to be rebuilt against new endpoints.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 11:54:06 -04:00
bvandeusen 0c91ab026d docs: add FabledSword design system + Scribe iteration decisions
Captures the FabledSword baseline (verbatim from initial draft) plus the
Scribe-specific decisions made during the first iteration pass:

- Hybrid accent rule (accent reserved for brand moments + nav/cursor +
  tags/wikilinks/in-progress/focus rings; Moss/Bronze/Oxblood/Pewter
  for action buttons by default)
- Light mode warm parchment (specific hex values pinned)
- Status + priority palette extension table (status-paused added)
- Typography: Inter for body, doc scale verbatim, two weights
- Chat-bubble "Illuminated Transcript" pattern codified
- Voice and tone: adopt principles, defer formal audit
- Border philosophy: structural not decorative
- Iconography: Lucide as source, strict 16/24 scale, 1.5/1 stroke
- Warm gold accent dropped (dates → text-secondary, paused → Warning)

Doc lives at docs/design-system.md. Polish pass (applying the system to
existing UI) is a separate, deferred phase.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 10:51:38 -04:00
bvandeusen ce76a003f7 feat(journal): /api/journal/* routes blueprint + cosine helper unit tests
Endpoints:
- GET/PUT /api/journal/config — per-user journal config
- GET /api/journal/today — today's journal conversation, generates daily prep on demand
- GET /api/journal/day/<iso_date> — past day's journal
- GET /api/journal/days — list of dates with journal content
- POST /api/journal/trigger-prep — manual regeneration of prep
- GET /api/journal/moments — list/search moments with filters
- PATCH /api/journal/moments/<id> — edit content/tags/pinned + junctions
- DELETE /api/journal/moments/<id>

Blueprint registered in app.py.
tests/test_journal_search.py — cosine helper coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:40:56 -04:00
bvandeusen ac188c40a5 feat(journal): LLM tools (record_moment, search_journal) + system prompt wiring
- services/tools/journal.py — record_moment + search_journal tool handlers
- services/tools/_registry.py: add `journal` flag on ToolDef + tool() decorator
- get_tools_for_user(user_id, conversation_type='chat'|'journal') —
  exclude journal-only tools from chat sessions; exclude set_rag_scope
  from journal sessions
- services/tools/__init__.py: register the new journal module; drop the
  unused get_briefing_tools export
- services/llm.py build_context: short-circuit for journal conversations,
  using journal_pipeline.build_journal_system_prompt and skipping all
  notes-RAG injection (preserves the journal/notes isolation invariant)
- services/generation_task.py: pass conversation_type into get_tools_for_user

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:39:42 -04:00
bvandeusen d9ab538ef4 feat(journal): backend services (moments, search, prep, scheduler, pipeline)
- services/moments.py — CRUD with embedding sync + entity-link helpers
- services/journal_search.py — three-mode search (temporal / entity / semantic)
  with notes-RAG isolation; cosine-similarity helper unit-tested
- services/journal_prep.py — gathers tasks/events/weather/news/projects/
  recent-moments/open-threads into a structured prep block
- services/journal_scheduler.py — per-user APScheduler cron for daily prep,
  follows the BackgroundScheduler + threadsafe-async pattern from event_scheduler
- services/journal_pipeline.py — system prompt (persona + calibration rules)
  with last-48h ambient moments injection
- app.py: wire journal scheduler start/stop hooks
- routes/settings.py: re-add live-reschedule on timezone change (now via
  journal_scheduler.update_user_schedule)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:37:39 -04:00
bvandeusen 7602bf2293 feat(briefing): hard-cut tear-down
Backend:
- Delete briefing services (pipeline, scheduler, conversations, profile, tools)
- Delete routes/briefing.py + remove blueprint registration
- Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting)
- Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific)
- Strip briefing scheduler hooks from app.py
- Strip briefing scheduler call from routes/settings.py
- Update test imports (test_rss_service, test_tz_helpers)

Frontend:
- Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow
- Strip /briefing route + nav links (AppHeader, KnowledgeView)
- Strip Settings → Briefing tab + state + functions + imports
- Strip briefing-intermediate handling from ChatMessage
- Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree)
- Drop unused useSettingsStore from AppHeader

The Android BriefingScreen lives in a separate repo and is not touched here.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:33:37 -04:00
bvandeusen d352e9264b feat(models): Moment + MomentEmbedding + junctions; rename Conversation.briefing_date → day_date
Also rename services/tz.user_briefing_date → user_day_date with a backwards
compat alias (briefing modules using the old name will be deleted in the
upcoming briefing tear-down stage). Update services/chat.py to_dict to use
day_date.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 20:40:02 -04:00
bvandeusen 6752765e2b feat(db): migration 0041 — add moments + four junction tables (FK notes) + embeddings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 20:38:37 -04:00
bvandeusen 0fe7141949 feat(db): migration 0040 — rename briefing_date to day_date, hard cut briefing data
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 20:35:37 -04:00
bvandeusen b20d6dec66 chore(scripts): make pre_commit_fable_mcp.sh location-independent
Derive REPO_ROOT from the script's own location instead of hardcoding
the absolute project path, so the hook keeps working if the project
directory is renamed or moved.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 17:56:02 -04:00
bvandeusen e652dece9b refactor: rename Fabled Assistant to Fabled Scribe
Updates user-visible branding across frontend (PWA manifest, page title,
Settings, push fallback), backend (email templates and subjects, LLM
system prompt, CalDAV displayname, SMTP from-name default), README,
quickstart compose, and MCP server description.

Also updates the CI image path and quickstart image reference to
git.fabledsword.com/bvandeusen/fabledscribe in preparation for the
Forgejo repo rename. Internal Python package, env vars, and database
schema unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 16:28:25 -04:00
bvandeusen 96a44886ef Merge pull request 'feat(workspace): redesign project workspace with floating chat widget' (#42) from dev into main 2026-04-22 19:11:11 +00:00
bvandeusen f422d4158c style(workspace-notes): larger, more spacious note title
Title input bumps to 1.4rem with Fraunces serif (matches the
design language's heading treatment) and gains 0.9/1.1rem padding
on the title row for breathing room.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:52:58 -04:00
bvandeusen 70ec060b7a style(workspace-notes): move active-note indicator to right edge
Anchors it to the editor side rather than the tasks-panel side.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:43:59 -04:00
bvandeusen 3b41a45757 style(workspace-notes): drop border between toolbar and body
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 14:35:45 -04:00
bvandeusen 19e1ca505d style(workspace-notes): unify editor chrome, distinguish rail
- Drop border-bottom lines between header / title / tags / toolbar
  so they read as one continuous input surface.
- Add a single border-top above the editor body so the body's
  existing boundary stays clear.
- Notes rail gets --color-bg-card so it no longer visually merges
  with the tasks panel (both previously shared --color-surface).
  Dropped the rail's border-right since the tint now carries the
  division from the editor pane.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 13:30:17 -04:00
bvandeusen f75c40b417 refactor(workspace): match KnowledgeView mini-chat dock style
- Widget is now a bottom-anchored dock spanning the content width
  (max-width 1200px, rounded top corners, bg-secondary, upward shadow)
  instead of a floating corner box.
- Drop the closed state and ✕ button — widget is always present,
  matching KnowledgeView's pattern. Collapsed = input-only dock,
  expanded = full ChatPanel above the input.
- Grid ratio 1fr:2fr → 0.85fr:2.15fr so tasks get a touch less room
  and notes a touch more.
- Workspace note-rail (the notes picker column inside the editor)
  widens from 155px to 200px so note titles breathe.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 12:53:02 -04:00
bvandeusen 649c0f124b feat(workspace): history dropdown on project chat widget
Clicking the ▾ button reveals conversations where
rag_project_id === projectId, most-recent first. Selecting one
repoints the widget (and the workspace_conv_{pid} storage key) to
that conversation without deleting the previously active one.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:10:54 -04:00
bvandeusen 6026c24450 refactor(workspace): two-column grid; widget drives tool-call refresh
WorkspaceView now renders tasks (1fr) on the left and notes (2fr) on
the right with the floating WorkspaceChatWidget layered on top. The
widget owns the SSE streamingToolCalls watcher and emits note-changed
and task-changed events; the view listens and reloads the affected
panel refs.

Dropped from the view:
- ChatPanel mount, empty-state quick chips, and Chat header toggle
- The conversation lifecycle + SSE watcher (moved into the widget)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:09:50 -04:00
bvandeusen ae5c61a179 feat(workspace): floating project chat widget
Modeled on KnowledgeView's mini-chat. Three states (closed handle,
collapsed input-only, expanded full panel). Takes over the project
conversation lifecycle from WorkspaceView (resume-or-create on mount,
delete-if-empty-and-new on unmount) and adds a restart action. Reuses
ChatPanel for expanded view and ChatInputBar for collapsed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 11:08:25 -04:00
bvandeusen 7657f48f1e Merge pull request 'feat: unified lookup tool, Wikipedia integration, weather & briefing UX' (#41) from dev into main 2026-04-18 03:33:57 +00:00
bvandeusen 61e62a6904 fix: serialize article fetches in lookup to avoid lxml double-free
trafilatura.extract dispatched via run_in_executor isn't safe to run
concurrently — two parallel calls can crash the process with a
libxml2-level double free. The top-level Wikipedia+SearXNG gather is
fine; only the inner per-article extraction needs to stay sequential,
matching the pre-parallelization behavior.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 23:04:05 -04:00
bvandeusen 9a252c8dde feat: parallelize lookup (Wiki+SearXNG) and include Wikipedia thumbnails
Runs the Wikipedia summary and SearXNG search concurrently and returns
both when available, so current-event questions aren't masked by a
generic role article from Wikipedia. When the Wikipedia summary includes
a thumbnail, it is cached through the existing image pipeline and
surfaced as an embeddable markdown snippet alongside the extract.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-17 22:44:42 -04:00
bvandeusen 8db6b4d230 feat: add Wikipedia as research pipeline source alongside SearXNG
Runs wiki_search in parallel with SearXNG queries; Wikipedia results
(which already carry content via their extract field) are merged into
the source pool before outline generation, skipping a separate fetch
step. Also fixes a pre-existing F811 ruff violation in the test file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:59:35 -04:00
bvandeusen d5e6a8f6da refactor: update all search_web references to lookup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 21:57:46 -04:00
bvandeusen 06cd3493fd feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:56:31 -04:00
bvandeusen 6a8f0e9143 feat: add wikipedia service with summary lookup and search
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 21:54:44 -04:00
bvandeusen 6f8c2201ed docs: add unified lookup & Wikipedia implementation plan
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 21:20:47 -04:00
bvandeusen ec49e24c8d docs: add unified lookup tool & Wikipedia integration spec
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 21:17:48 -04:00
bvandeusen cac62c6caf feat(briefing): add 2-week upcoming events list below weather card
Fetch and display the next 14 days of events in the briefing right column,
grouped by day with color dots, time, and location. Fills the empty space
below the weather card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 17:14:20 -04:00
bvandeusen 619f069358 feat(weather): add refresh button and return card data from refresh endpoint
Add a refresh button (↻) to the weather section header in BriefingView so
users can manually re-fetch weather data (needed after deploying the hourly
precip changes to populate the cache). Update the existing POST
/api/briefing/weather/refresh endpoint to return full card data instead of
just location keys.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 16:40:57 -04:00
bvandeusen ddab0db781 feat(weather): add hourly precipitation summaries and peak timing to weather card
Fetch hourly precipitation probabilities from Open-Meteo alongside daily
forecasts. Generate human-readable precip summaries ("Rain likely 2–5 PM",
"Rain likely all day") for today and each forecast day. Display today's
summary as a styled callout and show peak precipitation hour in forecast rows.
Also fix briefing pipeline to parse all weather location rows (not just first).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 16:05:26 -04:00
bvandeusen 443b11f287 fix(tests): patch get_setting at source module, not lazy import site
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 15:15:01 -04:00
bvandeusen 3c9e473823 fix(tests): mock get_setting in calendar tools availability test
The rss_enabled check in _check_requires now calls get_setting, which
needs a database connection. Mock it in the test that exercises
get_tools_for_user.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 14:49:33 -04:00
bvandeusen 3ac32dc3bc feat: add rss_enabled user setting to toggle RSS functionality
RSS is now off by default. When disabled:
- Scheduler skips RSS feed sync during compilation slot
- Briefing pipeline skips RSS item gathering
- RSS LLM tools (get_rss_items, add_rss_feed) are hidden
- API routes return empty results for feeds/news
- Frontend hides News nav link, RSS Feeds and News Preferences in settings
- Briefing view hides news sidebar section

Toggle in Settings > Briefing > RSS / News.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 13:58:50 -04:00
bvandeusen 29ef17f4f3 fix(llm): frame RAG notes as reference material, remove RSS from chat context
Add framing preamble to auto-injected notes so the model treats them as
reference material rather than user input. Remove RSS semantic search
injection from all chat conversations — the discuss tool handles that need.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 08:17:10 -04:00
bvandeusen fc82f7a0cb Merge pull request 'Fix CSP for Google Fonts' (#40) from dev into main 2026-04-17 11:16:33 +00:00
bvandeusen b743754ec2 fix(csp): allow Google Fonts for Fraunces typeface
Add fonts.googleapis.com to style-src and fonts.gstatic.com to
font-src in Content-Security-Policy header.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:45:22 -04:00
bvandeusen c4ffe418b5 Merge pull request 'Fix CSP blocking Silero VAD WASM compilation' (#39) from dev into main 2026-04-17 00:23:59 +00:00
bvandeusen 6cf880506d fix(voice): allow WASM compilation in CSP for Silero VAD
Add 'wasm-unsafe-eval' to script-src and blob: to worker-src in
Content-Security-Policy header. Required by onnxruntime-web to compile
the Silero VAD ONNX model. Also surface VAD init errors as a toast
instead of silent console log.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 20:10:29 -04:00
bvandeusen aca34e2dfb Merge pull request 'Release v26.04.16.3 — Silero VAD + Briefing layout' (#38) from dev into main 2026-04-16 23:37:04 +00:00
bvandeusen c07a0f0f7e fix(chat): remove input bar border across all chat views
Remove border-top from .input-wrapper in ChatPanel directly instead
of overriding per-view. Applies to chat, workspace, and briefing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:25:48 -04:00
bvandeusen 776e5edb68 fix(briefing): responsive sidebar, remove dividers, scale forecast icons
Switch sidebar from fixed px to proportional 35% so it resizes with
the viewport. Remove border between chat and input bar and between
weather and news. Use container query units for weather/forecast icons
so they scale with column width.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 19:10:49 -04:00
bvandeusen 8b0dd732f7 feat(briefing): tabbed weather locations, sticky weather, wider sidebar
Add location tabs when multiple weather sources configured. Weather
section stays pinned at top of sidebar while news scrolls independently.
Bump sidebar width from 420px to 620px max for better readability.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:30:16 -04:00
bvandeusen f12d29563a fix(briefing): 2-column layout with table-based weather forecast
Collapse briefing from 3-column to 2-column grid (chat + sidebar).
Weather moves to top of news column, forecast uses an HTML table so
rows align cleanly. Drop verbose condition text from forecast days
(emoji already conveys it). Switch CI node_modules cache to npm
download cache to avoid tar size error from onnxruntime-web.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 18:10:46 -04:00
bvandeusen a4995606e5 chore(voice): remove amplitude-based silence detector (replaced by VAD)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:47:45 -04:00
bvandeusen 0a8a755909 feat(voice): replace silence detector with Silero VAD in ChatInputBar
Swaps useSilenceDetector for useVad. Adds no-speech guard: when the
user manually stops recording without VAD ever detecting speech, show
a toast and skip the Whisper round-trip instead of sending pure noise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:45:21 -04:00
bvandeusen 719de12a6c feat(voice): add useVad composable wrapping Silero VAD
Provides speech-start/speech-end detection via @ricky0123/vad-web.
Retains amplitude-based visual feedback for mic pulse animation but no
longer uses it for stop decisions. Exposes stopAndCheck() which fires
onNoSpeech when the user manually stops without VAD ever detecting
speech — avoids wasted Whisper round-trips on noise-only recordings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:43:17 -04:00
bvandeusen 72018aa389 feat(voice): add ONNX WASM idle preloader for Silero VAD
Configures vite-plugin-static-copy to serve @ricky0123/vad-web's ONNX model,
audio worklet, and onnxruntime-web WASM files from the site root.
useOnnxPreloader warms the model cache during page idle so the first mic
click is instant.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 17:41:17 -04:00
bvandeusen 1e73ea04f1 chore(frontend): add @ricky0123/vad-web dependency
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 13:37:37 -04:00
bvandeusen 72708475a3 Release v26.04.16.1 — Fix tool actions mismatch in system prompt 2026-04-16 12:39:02 +00:00
bvandeusen e07d8436b7 fix(llm): sync available actions list with actual registered tools
The system prompt listed phantom tools (create_task, delete_task, get_note)
that don't exist, causing the model to spiral when users asked to create
tasks under a project. Replaced the stale hardcoded string with a
dynamically-built actions list matching all registered tools, and added
conditional searxng/caldav extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 08:16:36 -04:00
435 changed files with 26926 additions and 36196 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"name": "scribe-plugin",
"owner": { "name": "Bryan Van Deusen" },
"description": "Scribe ships its own Claude Code plugin from this repo, versioned in lockstep with the app + the /api/plugin/context contract.",
"plugins": [
{
"name": "scribe",
"source": "./plugin",
"description": "Scribe second brain: MCP tools + session-start push channel + universal process-skills."
}
]
}
+1 -1
View File
@@ -1,4 +1,4 @@
POSTGRES_USER=fabled POSTGRES_USER=fabled
POSTGRES_PASSWORD=fabled POSTGRES_PASSWORD=fabled
POSTGRES_DB=fabledassistant POSTGRES_DB=scribe
SECRET_KEY=dev-secret-change-me SECRET_KEY=dev-secret-change-me
+144 -43
View File
@@ -1,12 +1,19 @@
# CI runs first; build only proceeds if all checks pass. # CI runs first; build only proceeds if all checks pass.
# #
# Push to dev: typecheck + lint + test + build :dev + :<sha> # Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version> # Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
# #
# main pushes are NOT gated here: a merge to main only happens after # Both dev and main are gated AND built. dev pushes move :dev; main pushes move
# dev has already passed CI, and the release tag is the sole trigger # :latest — main IS the production line, so :latest tracks main's tip and there
# for a production image. Re-running CI on the merge commit just burns # is no separate :main tag. Every push also gets an immutable :<sha> (the
# runner time without changing the outcome. # rollback point). A v* release tag additionally publishes the dated :<version>;
# since main already moved :latest, the release tag's distinct job is that
# :<version> marker (it refreshes :latest too, harmlessly).
#
# Successive pushes to the SAME ref supersede each other (see concurrency
# below), so rapid pushes don't stack identical work; dev and main runs are
# independent refs and never cancel one another.
# #
# To cut a release: # To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name. # Create a release via the Forgejo UI on main with a v* tag name.
@@ -16,11 +23,8 @@
# gating on branch push is already enough. # gating on branch push is already enough.
# #
# NOTE on the `if:` guards below: Forgejo Actions does not consistently # NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main # honor `on.push.branches` as a filter, so every job repeats the ref check
# still trigger the workflow, producing redundant runs on the same SHA # explicitly — permitting dev, main, and v* tags, rejecting anything else.
# that was already gated on dev. Every job therefore repeats the ref
# check so main pushes trigger the workflow but every job skips
# immediately (no runner time, no duplicate work).
# #
# Required secrets (repo → Settings → Secrets → Actions): # Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username # REGISTRY_USER — your Forgejo username
@@ -29,7 +33,7 @@ name: CI & Build
on: on:
push: push:
branches: [dev] branches: [dev, main]
tags: ["v*"] tags: ["v*"]
paths: paths:
- "src/**" - "src/**"
@@ -42,6 +46,11 @@ on:
- "assets/**" - "assets/**"
- "fable-mcp/**" - "fable-mcp/**"
- ".forgejo/workflows/ci.yml" - ".forgejo/workflows/ci.yml"
# Manual trigger from the Forgejo Actions UI. Useful when an image has
# been built but the deployment didn't pick it up, or when re-running
# against the same source produces different upstream behaviour
# (e.g. a transient HF download flake during the voice-bundle step).
workflow_dispatch: {}
# Cancel older runs on the same branch when a newer push lands. Tag runs # Cancel older runs on the same branch when a newer push lands. Tag runs
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and # get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
@@ -57,30 +66,32 @@ permissions:
env: env:
REGISTRY: git.fabledsword.com REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
jobs: jobs:
typecheck: typecheck:
name: TypeScript typecheck name: TypeScript typecheck
# Skip on main merge-commit pushes — see workflow header comment. # Gate dev, main, and v* tags; reject any other ref (see header note).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# Cache node_modules directly (not the npm download cache). - name: Cache npm download cache
# npm ci still has to extract + link every module even with a
# warm download cache, which is where the real time goes. Caching
# the output directory lets us skip npm ci entirely on hits.
- name: Cache node_modules
id: npm-cache
uses: actions/cache@v4 uses: actions/cache@v4
# Non-fatal: a transient cache-backend hiccup must NOT fail the whole
# typecheck job (it was skipping install + type check and reporting red
# on backend-only pushes — see issue task #828). On cache miss/error the
# job just installs without the cache.
continue-on-error: true
with: with:
path: frontend/node_modules path: ~/.npm
key: node-modules-${{ hashFiles('frontend/package-lock.json') }} key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-cache-
- name: Install dependencies - name: Install dependencies
if: steps.npm-cache.outputs.cache-hit != 'true'
run: npm ci run: npm ci
working-directory: frontend working-directory: frontend
@@ -90,20 +101,24 @@ jobs:
lint: lint:
name: Python lint name: Python lint
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
# ruff is pre-installed in the ci-runner base image — no install # ruff is pre-installed in the ci-python image — no install
# step needed, lint runs in ~2s. # step needed, lint runs in ~2s.
- name: Lint - name: Lint
run: ruff check src/ run: ruff check src/
test: test:
name: Python tests name: Python tests
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v6
@@ -127,17 +142,87 @@ jobs:
uv pip install --python /opt/venv/bin/python -e ".[dev]" uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Run tests - name: Run tests
run: /opt/venv/bin/python -m pytest tests/ -q # Integration tests (real Postgres) run in the `integration` job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real-Postgres lane (family rule 6). Exercises the async SQLAlchemy connection
# path the unit stubs can't reach — the un-awaited execution_options regression
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
# visibility and does NOT gate the build.
#
# Job key stays separator-free ("integration"): act_runner derives the service-
# container name from the (truncated) job display name and the discovery step
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
# Config + the module engine read these at import time. DATABASE_URL itself
# is built from the discovered service IP in the run step.
SECRET_KEY: ci_integration_placeholder
services:
postgres:
# pgvector image so `alembic upgrade head` can run migration 0067
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: scribe
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: scribe_test
options: >-
--health-cmd "pg_isready -U scribe"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
- name: Create virtual environment
run: uv venv /opt/venv
- name: Install package with dev deps
run: |
uv pip install --python /opt/venv/bin/python setuptools wheel
uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece
uv pip install --python /opt/venv/bin/python -e ".[dev]"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
# Wait for Postgres to accept connections (busybox sh — the runner
# default — has no bash /dev/tcp, so use Python).
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
try:
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
break
except OSError:
time.sleep(1)
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema; the maintenance tests then run
# VACUUM (ANALYZE) and read pg_stat_user_tables against it.
/opt/venv/bin/alembic upgrade head
/opt/venv/bin/python -m pytest tests/ -v -m integration
build: build:
name: Build & push image name: Build & push image
needs: [typecheck, lint, test] needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only. # Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
# Mirrors the ref guard on the gate jobs above — main merge-commit # tag → :latest + :<version>; every build also gets an immutable :<sha>.
# pushes skip here too, so no production image is ever built from a if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
# raw main push (only from the v* tag the release creates). runs-on: python-ci
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') container:
runs-on: ci-runner image: git.fabledsword.com/bvandeusen/ci-python:3.14
permissions: permissions:
contents: read contents: read
packages: write packages: write
@@ -146,15 +231,31 @@ jobs:
- name: Generate image tags and version - name: Generate image tags and version
id: tags id: tags
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
# silently — only the SHA tag got appended, so :dev / :latest
# never updated in the registry and the deployed stack kept
# pulling stale images. Verified via `[[: not found` lines in
# the runner log on commit 2a374d9.
run: | run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}" TAGS="${{ env.IMAGE }}:${{ github.sha }}"
BUILD_VERSION="dev" BUILD_VERSION="dev"
if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then case "${{ github.ref }}" in
TAGS="$TAGS,${{ env.IMAGE }}:dev" refs/heads/dev)
elif [[ "${{ github.ref }}" == refs/tags/* ]]; then TAGS="$TAGS,${{ env.IMAGE }}:dev"
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" ;;
BUILD_VERSION="${{ github.ref_name }}" refs/heads/main)
fi # main IS the production line: publish :latest (plus the :<sha>
# set above). No separate :main tag.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
BUILD_VERSION="main"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
BUILD_VERSION="${{ github.ref_name }}"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
+1 -1
View File
@@ -1 +1 @@
2298268 1425947
+4 -17
View File
@@ -8,29 +8,16 @@ COPY frontend/ .
RUN npm run build RUN npm run build
# Stage 2: Python runtime # Stage 2: Python runtime
FROM python:3.12-slim AS runtime # Tracks CI image (ci-python:3.14) so test results stay representative.
FROM python:3.14-slim AS runtime
WORKDIR /app WORKDIR /app
COPY pyproject.toml . COPY pyproject.toml .
COPY src/ src/ COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \ RUN --mount=type=cache,target=/root/.cache/pip \
pip install . pip install .
# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED
# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install torch --index-url https://download.pytorch.org/whl/cpu \
&& pip install faster-whisper kokoro soundfile \
&& python -m spacy download en_core_web_sm
# Build the fable-mcp wheel so it can be served for download COPY --from=build-frontend /build/dist/ src/scribe/static/
COPY fable-mcp/ fable-mcp/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install build hatchling \
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
&& pip uninstall -y build \
&& rm -rf fable-mcp/
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini . COPY alembic.ini .
COPY alembic/ alembic/ COPY alembic/ alembic/
@@ -43,4 +30,4 @@ ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION ENV APP_VERSION=$BUILD_VERSION
EXPOSE 5000 EXPOSE 5000
CMD ["sh", "-c", "alembic upgrade head && hypercorn 'fabledassistant.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"] CMD ["sh", "-c", "alembic upgrade head && hypercorn 'scribe.app:create_app()' --bind 0.0.0.0:5000 --keep-alive 600"]
+2 -3
View File
@@ -1,10 +1,10 @@
# Fabled Assistant # Fabled Scribe
A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware. A self-hosted second brain and project management application with integrated LLM capabilities. Write, organise, and act on your notes and tasks with the help of a local AI assistant — all running on your own hardware.
## Features ## Features
Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, an MCP server for external AI clients, and an Android companion app. Notes and tasks with a Markdown editor, sub-tasks, milestones, and kanban project workspaces. AI chat with streaming responses, RAG over your notes, and tool use (web search, calendar, weather). A daily briefing that digests your tasks, RSS feeds, and weather on a schedule. Knowledge graph, per-user/group sharing, PWA with push notifications, and an MCP server for external AI clients.
## Quick Start ## Quick Start
@@ -36,7 +36,6 @@ Open `http://localhost:5000`. The first user to register becomes admin. Go to **
| [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide | | [API Keys & MCP](docs/api-keys-and-mcp.md) | API key management and Fable MCP install guide |
| [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers | | [SSO / OAuth](docs/sso-oauth.md) | OIDC setup for Authentik, Keycloak, and other providers |
| [API Reference](docs/api-reference.md) | All REST API endpoints | | [API Reference](docs/api-reference.md) | All REST API endpoints |
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
## License ## License
+2 -2
View File
@@ -4,8 +4,8 @@ from logging.config import fileConfig
from alembic import context from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.ext.asyncio import create_async_engine
from fabledassistant.config import Config from scribe.config import Config
from fabledassistant.models import Base from scribe.models import Base
config = context.config config = context.config
if config.config_file_name is not None: if config.config_file_name is not None:
@@ -0,0 +1,26 @@
"""rename briefing_date to day_date and drop existing briefing conversations
Revision ID: 0040
Revises: 0039
Create Date: 2026-04-25
This is a hard-cut migration accompanying the Journal feature. The user
has accepted destruction of existing briefing data per the design spec.
"""
from alembic import op
revision = "0040"
down_revision = "0039"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DELETE FROM conversations WHERE conversation_type = 'briefing'")
op.alter_column("conversations", "briefing_date", new_column_name="day_date")
op.execute("DELETE FROM settings WHERE key = 'briefing_config'")
def downgrade() -> None:
op.alter_column("conversations", "day_date", new_column_name="briefing_date")
+128
View File
@@ -0,0 +1,128 @@
"""add moments + junction tables + moment embeddings
Revision ID: 0041
Revises: 0040
Create Date: 2026-04-25
People, Places, Tasks, and Notes all live in the `notes` table (distinguished
by note_type and is_task). The four junction tables FK to notes(id) but stay
separate so per-link-kind queries don't require a discriminator filter.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0041"
down_revision = "0040"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"moments",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"conversation_id",
sa.Integer,
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"source_message_id",
sa.Integer,
sa.ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("day_date", sa.Date, nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column(
"recorded_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("content", sa.Text, nullable=False),
sa.Column("raw_excerpt", sa.Text, nullable=True),
sa.Column(
"tags",
ARRAY(sa.Text),
nullable=False,
server_default=sa.text("'{}'::text[]"),
),
sa.Column(
"pinned",
sa.Boolean,
nullable=False,
server_default=sa.text("false"),
),
)
op.create_index("ix_moments_user_day", "moments", ["user_id", "day_date"])
op.create_index("ix_moments_user_occurred", "moments", ["user_id", "occurred_at"])
# Four junction tables, all FK to notes(id). Separate (vs. one merged
# table with a discriminator) so per-kind queries don't need a filter.
for table_name, fk_col in [
("moment_people", "person_id"),
("moment_places", "place_id"),
("moment_tasks", "task_id"),
("moment_notes", "note_id"),
]:
op.create_table(
table_name,
sa.Column(
"moment_id",
sa.Integer,
sa.ForeignKey("moments.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
fk_col,
sa.Integer,
sa.ForeignKey("notes.id", ondelete="CASCADE"),
primary_key=True,
),
)
op.create_index(f"ix_{table_name}_{fk_col}", table_name, [fk_col])
op.create_table(
"moment_embeddings",
sa.Column(
"moment_id",
sa.Integer,
sa.ForeignKey("moments.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("user_id", sa.Integer, nullable=False),
sa.Column("embedding", JSONB, nullable=False),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.create_index("ix_moment_embeddings_user", "moment_embeddings", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_moment_embeddings_user", table_name="moment_embeddings")
op.drop_table("moment_embeddings")
for table_name, fk_col in [
("moment_notes", "note_id"),
("moment_tasks", "task_id"),
("moment_places", "place_id"),
("moment_people", "person_id"),
]:
op.drop_index(f"ix_{table_name}_{fk_col}", table_name=table_name)
op.drop_table(table_name)
op.drop_index("ix_moments_user_occurred", table_name="moments")
op.drop_index("ix_moments_user_day", table_name="moments")
op.drop_table("moments")
+36
View File
@@ -0,0 +1,36 @@
"""drop RSS infrastructure (tables + leftover briefing settings)
Revision ID: 0042
Revises: 0041
Create Date: 2026-04-26
Hard-cut removal of the RSS feature. Drops rss_feeds, rss_items,
rss_item_reactions, rss_item_embeddings, and clears the leftover
briefing-era settings rows that referenced RSS topic preferences.
"""
from alembic import op
revision = "0042"
down_revision = "0041"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Drop in dependency order — children first.
op.execute("DROP TABLE IF EXISTS rss_item_embeddings CASCADE")
op.execute("DROP TABLE IF EXISTS rss_item_reactions CASCADE")
op.execute("DROP TABLE IF EXISTS rss_items CASCADE")
op.execute("DROP TABLE IF EXISTS rss_feeds CASCADE")
op.execute(
"DELETE FROM settings WHERE key IN "
"('rss_enabled', 'briefing_include_topics', 'briefing_exclude_topics')"
)
def downgrade() -> None:
# No downgrade — this is a destructive cleanup. The original RSS tables
# would have to be recreated by replaying migrations 0026, 0028, 0035,
# 0038, 0039 manually (and the data would not come back).
raise NotImplementedError("RSS feature is permanently removed in 0042")
@@ -0,0 +1,73 @@
"""replace events.end_dt with duration_minutes (Fable #160)
Revision ID: 0043
Revises: 0042
Create Date: 2026-04-29
Structural fix for the "end before start" bug class observed on prod
2026-04-29: an event landed with end_dt 32 days before start_dt due
to a tool-call mishap, then disappeared from upcoming-list filters.
Storing duration instead of end_dt makes the invalid state
inexpressible at the schema level (duration_minutes >= 0).
Backfill rules:
- end_dt valid (end_dt > start_dt) → duration_minutes = total minutes
- end_dt == start_dt → duration_minutes = 0 (zero-duration point)
- end_dt NULL OR end_dt < start_dt → duration_minutes = NULL (corrupt
or open-ended; treated as a point event from here on)
Existing API consumers continue to receive `end_dt` in responses — the
field is now derived from `start_dt + duration_minutes` in
``Event.to_dict()`` rather than stored.
"""
from alembic import op
import sqlalchemy as sa
revision = "0043"
down_revision = "0042"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"events",
sa.Column("duration_minutes", sa.Integer(), nullable=True),
)
op.create_check_constraint(
"events_duration_minutes_non_negative",
"events",
"duration_minutes IS NULL OR duration_minutes >= 0",
)
# Backfill: convert valid end_dt into a minute count; leave NULL for
# corrupt or absent end_dt. Bad rows (end_dt <= start_dt) collapse
# cleanly to point events instead of forcing a recovery guess.
op.execute(
"""
UPDATE events
SET duration_minutes = CAST(
EXTRACT(EPOCH FROM (end_dt - start_dt)) / 60 AS INTEGER
)
WHERE end_dt IS NOT NULL AND end_dt >= start_dt
"""
)
op.drop_column("events", "end_dt")
def downgrade() -> None:
op.add_column(
"events",
sa.Column("end_dt", sa.DateTime(timezone=True), nullable=True),
)
# Restore end_dt = start_dt + duration_minutes minutes for rows that
# had a duration. NULL duration → NULL end_dt (point event).
op.execute(
"""
UPDATE events
SET end_dt = start_dt + (duration_minutes || ' minutes')::interval
WHERE duration_minutes IS NOT NULL
"""
)
op.drop_constraint("events_duration_minutes_non_negative", "events")
op.drop_column("events", "duration_minutes")
@@ -0,0 +1,48 @@
"""add note.description and note.consolidated_at
Revision ID: 0044
Revises: 0043
Create Date: 2026-05-13
Adds two columns to the ``notes`` table to support the task-as-durable-record
design (spec 2026-05-13):
- ``description``: user-stated goal / initial context. Meaningful when
``is_task=true``; left NULL on knowledge notes.
- ``consolidated_at``: timestamp of the most recent auto-summary pass. NULL
until the first consolidation runs.
Backfill: for existing tasks we copy ``body`` into ``description`` so the
user's original goal text is preserved when ``body`` is later overwritten by
the auto-summary pipeline. The brief duplication window between
``body`` and ``description`` is harmless and resolves on the first
consolidation pass.
"""
from alembic import op
import sqlalchemy as sa
revision = "0044"
down_revision = "0043"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("description", sa.Text(), nullable=True))
op.add_column(
"notes",
sa.Column("consolidated_at", sa.DateTime(timezone=True), nullable=True),
)
# is_task is a Python property (status IS NOT NULL); there's no DB column
# of that name. Backfill description from body for everything that
# qualifies as a task at the model layer.
op.execute(
"UPDATE notes SET description = body "
"WHERE status IS NOT NULL AND body IS NOT NULL"
)
def downgrade() -> None:
op.drop_column("notes", "consolidated_at")
op.drop_column("notes", "description")
@@ -0,0 +1,35 @@
"""add pin_kind and pin_label to note_versions
Revision ID: 0045
Revises: 0044
Create Date: 2026-05-13
Two additive columns on note_versions to support tiered retention:
- pin_kind: NULL (rolling autosave), 'auto' (system-declared via stability
scan), 'manual' (user-declared with optional commit-note label).
- pin_label: NULL for rolling. Auto-generated for 'auto'; user-supplied
for 'manual' (may be NULL).
No backfill — every existing row stays rolling. The auto-pin scan
(services/version_pinning_scheduler.py, daily 03:00 UTC) will catch up on
the first scheduled run after deploy.
"""
from alembic import op
import sqlalchemy as sa
revision = "0045"
down_revision = "0044"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("note_versions", sa.Column("pin_kind", sa.Text(), nullable=True))
op.add_column("note_versions", sa.Column("pin_label", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("note_versions", "pin_label")
op.drop_column("note_versions", "pin_kind")
@@ -0,0 +1,103 @@
"""generation_tool_log: per-turn tool-call telemetry
Revision ID: 0046
Revises: 0045
Create Date: 2026-05-21
Captures one row per assistant turn, recording which tools the model
could have used, which it attempted, which fired successfully, and
which failed (with error details). The empirical surface for
evaluating model swaps and answering "does model X actually fire
record_moment when it should?" without relying on anecdote.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0046"
down_revision = "0045"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"generation_tool_log",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"conv_id",
sa.Integer,
sa.ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
),
# SET NULL (not CASCADE) so the telemetry row survives if the
# underlying assistant message is later deleted — we want to keep
# the per-turn outcome record for retrospective analysis.
sa.Column(
"assistant_message_id",
sa.Integer,
sa.ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("model", sa.Text, nullable=False),
sa.Column("think_enabled", sa.Boolean, nullable=False),
sa.Column(
"tools_available",
ARRAY(sa.Text),
nullable=False,
server_default=sa.text("'{}'::text[]"),
),
sa.Column(
"tools_attempted",
ARRAY(sa.Text),
nullable=False,
server_default=sa.text("'{}'::text[]"),
),
sa.Column(
"tools_succeeded",
ARRAY(sa.Text),
nullable=False,
server_default=sa.text("'{}'::text[]"),
),
# JSONB array of {name, error} objects so failed-tool details are
# queryable without a separate failure table.
sa.Column(
"tools_failed",
JSONB,
nullable=False,
server_default=sa.text("'[]'::jsonb"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
# Common query: "recent tool outcomes for this user, filterable by model."
op.create_index(
"ix_generation_tool_log_user_created",
"generation_tool_log",
["user_id", sa.text("created_at DESC")],
)
# Per-conversation lookup for the journal page's own retrospection.
op.create_index(
"ix_generation_tool_log_conv",
"generation_tool_log",
["conv_id"],
)
def downgrade() -> None:
op.drop_index("ix_generation_tool_log_conv", table_name="generation_tool_log")
op.drop_index(
"ix_generation_tool_log_user_created", table_name="generation_tool_log"
)
op.drop_table("generation_tool_log")
@@ -0,0 +1,38 @@
"""reset voice_tts_voice and voice_tts_blend settings for piper migration
Revision ID: 0047
Revises: 0046
Create Date: 2026-05-22
The TTS backend swapped from kokoro to piper. Kokoro voice IDs
(`af_heart`, `am_adam`, etc.) don't map to piper voice files
(`en_US-amy-medium`, etc.) and there's no sensible auto-translation.
Clear the stored voice selection so every user falls back to the piper
default the next time they synthesize.
`voice_tts_blend` goes away entirely — piper has no voice-blending
equivalent. The TTS service accepts the field for backward compat but
ignores it; clearing the rows makes the DB match reality.
Both settings re-default cleanly on read, so this migration just deletes
the rows without backfilling anything.
"""
from alembic import op
revision = "0047"
down_revision = "0046"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"DELETE FROM settings WHERE key IN ('voice_tts_voice', 'voice_tts_blend')"
)
def downgrade() -> None:
# No-op. The deleted settings just re-default on read; restoring the
# kokoro IDs wouldn't help anyway since kokoro is gone.
pass
@@ -0,0 +1,47 @@
"""conversations.last_curator_run_at — tracks scheduler progress per-conversation
Revision ID: 0048
Revises: 0047
Create Date: 2026-05-22
Phase 2 of the conversation+curator architecture (Fable #172). The
scheduler runs every 15 minutes and processes any journal conversation
with messages newer than its `last_curator_run_at` timestamp. NULL
means "never run; process all of today on first sweep."
"""
from alembic import op
import sqlalchemy as sa
revision = "0048"
down_revision = "0047"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"conversations",
sa.Column(
"last_curator_run_at",
sa.DateTime(timezone=True),
nullable=True,
),
)
# Indexed because the scheduler's selection query is
# WHERE conversation_type='journal' AND (last_curator_run_at IS NULL OR ...)
# which benefits from a partial index narrowed to journal rows.
op.create_index(
"ix_conversations_journal_last_curator",
"conversations",
["last_curator_run_at"],
postgresql_where=sa.text("conversation_type = 'journal'"),
)
def downgrade() -> None:
op.drop_index(
"ix_conversations_journal_last_curator",
table_name="conversations",
)
op.drop_column("conversations", "last_curator_run_at")
@@ -0,0 +1,36 @@
"""conversations.curator_summary — feeds curator output back to the chat model
Revision ID: 0049
Revises: 0048
Create Date: 2026-05-22
Phase 3 of the conversation+curator architecture (Fable #172). The
curator's final summary line (≤ 240 chars) is persisted here so the
NEXT chat turn can include it in the system prompt. This is the
feedback loop that closes the architecture — without it, the chat
model has no awareness of what the curator extracted, and conversations
risk circling back to topics already captured.
NULL means "no curator pass has produced a summary yet." The chat
pipeline reads this column when building the journal system prompt;
missing column = no injection, no failure.
"""
from alembic import op
import sqlalchemy as sa
revision = "0049"
down_revision = "0048"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"conversations",
sa.Column("curator_summary", sa.Text, nullable=True),
)
def downgrade() -> None:
op.drop_column("conversations", "curator_summary")
@@ -0,0 +1,27 @@
"""drop stored think_enabled rows — setting was removed
Revision ID: 0050
Revises: 0049
Create Date: 2026-05-23
The `think_enabled` user setting was retired with the chat+curator
architecture: chat has tools=[] and curator hardcodes think=False, so
the toggle was dead weight. Any rows already in `settings` for that
key are now unread by the app; this migration clears them.
"""
from alembic import op
revision = "0050"
down_revision = "0049"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DELETE FROM settings WHERE key = 'think_enabled'")
def downgrade() -> None:
# No-op. The setting is dead; restoring NULL rows wouldn't help.
pass
@@ -0,0 +1,98 @@
"""pending_curator_actions — curator-proposed mutations awaiting user approval
Revision ID: 0051
Revises: 0050
Create Date: 2026-05-23
Architecture: the curator runs unattended and can be confidently wrong.
Additive operations (create_*, record_moment, log_work, save_person)
land directly because adds are easily undone. Mutating operations
(update_*, delete_*) are proposed to this table instead — the user
sees them in a "Needs Review" panel and approves or rejects each one.
A proposed action stores:
- The action type (`update_task`, `delete_note`, etc.) — drives which
handler gets re-executed on approval.
- The target id + type for display ("update task 42", "delete note 17").
- The proposed arguments (`payload`) — what the curator wanted to do.
- A snapshot of the target's state at proposal time (`current_snapshot`)
— so the review UI can render a real before/after diff even if other
work modified the entity since.
- The status (`pending` / `approved` / `rejected`) and review timestamp.
Approval replays the original tool call via execute_tool with
authority="user" so the request bypasses the curator interceptor and
just runs.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0051"
down_revision = "0050"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"pending_curator_actions",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column(
"user_id",
sa.Integer,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"conv_id",
sa.Integer,
sa.ForeignKey("conversations.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("action_type", sa.Text, nullable=False),
sa.Column("target_type", sa.Text, nullable=True),
sa.Column("target_id", sa.Integer, nullable=True),
sa.Column("target_label", sa.Text, nullable=True),
sa.Column("payload", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")),
sa.Column(
"current_snapshot",
JSONB,
nullable=False,
server_default=sa.text("'{}'::jsonb"),
),
sa.Column(
"status",
sa.Text,
nullable=False,
server_default=sa.text("'pending'"),
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True),
sa.CheckConstraint(
"status IN ('pending', 'approved', 'rejected')",
name="pending_curator_actions_status_check",
),
)
# Pending-only index — the Needs Review panel fetches "user's pending"
# constantly, history rows just accumulate.
op.create_index(
"ix_pending_curator_actions_user_pending",
"pending_curator_actions",
["user_id", sa.text("created_at DESC")],
postgresql_where=sa.text("status = 'pending'"),
)
def downgrade() -> None:
op.drop_index(
"ix_pending_curator_actions_user_pending",
table_name="pending_curator_actions",
)
op.drop_table("pending_curator_actions")
@@ -0,0 +1,33 @@
"""clear note_embeddings for fastembed swap
Revision ID: 0052
Revises: 0051
Create Date: 2026-05-26
Embeddings stored in `note_embeddings.embedding` (JSONB) were generated by
Ollama's nomic-embed-text model (768-dim). The fastembed swap uses
BAAI/bge-small-en-v1.5 (384-dim). Mixed-dim vectors would break the
Python-side cosine similarity (length mismatch), so we wipe the table.
The startup hook `services.embeddings.backfill_note_embeddings` regenerates
everything at the new dimension on next boot. There's no column-type change
because the column is JSONB — dimensionless on the storage side.
Downgrade is the same operation: a fresh deployment of the prior code would
backfill with the old model. No data is lost that the next boot won't restore.
"""
from alembic import op
revision = "0052"
down_revision = "0051"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DELETE FROM note_embeddings")
def downgrade() -> None:
op.execute("DELETE FROM note_embeddings")
@@ -0,0 +1,82 @@
"""drop chat / journal / push / curator / weather tables
Revision ID: 0053
Revises: 0052
Create Date: 2026-05-27
Phase 9 of the MCP-first pivot. The Python models for these tables were
deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
SQL tables and cleans up dead per-user setting rows.
Hard-cutover: existing rows in these tables are discarded. The
accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
explicitly accepted this loss.
"""
from alembic import op
revision = "0053"
down_revision = "0052"
branch_labels = None
depends_on = None
# Order: junction tables first so the parent tables don't trip FK drops
# even with CASCADE — keeps the migration readable.
_DEAD_TABLES = [
# Moments + junction tables (journal entity links)
"moment_notes",
"moment_tasks",
"moment_places",
"moment_people",
"moment_embeddings",
"moments",
# Chat / generation telemetry
"generation_tool_log",
"messages",
"conversations",
# Curator approval queue
"pending_curator_actions",
# Push notifications
"push_subscriptions",
# Weather cache (journal-prep background fetcher)
"weather_cache",
# Legacy RSS-item embeddings (pre-MCP pivot, briefly experimented with)
"rss_item_embeddings",
]
# Specific setting keys to drop. Anything matching the LIKE patterns in
# upgrade() is also nuked; the explicit list catches one-off keys that
# don't fit the namespaced patterns.
_DEAD_SETTING_KEYS = [
"default_model",
"background_model",
"assistant_name",
"auto_consolidate_tasks",
"chat_retention_days",
"think_enabled",
"rag_default_scope",
]
def upgrade() -> None:
for table in _DEAD_TABLES:
op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
keys_csv = ",".join(f"'{k}'" for k in _DEAD_SETTING_KEYS)
op.execute(
"DELETE FROM settings WHERE "
"key LIKE 'voice_%' OR "
"key LIKE 'journal_%' OR "
"key LIKE 'briefing_%' OR "
"key LIKE 'curator_%' OR "
f"key IN ({keys_csv})"
)
def downgrade() -> None:
raise NotImplementedError(
"No downgrade — hard cutover per the MCP-first pivot spec. "
"Rolling back is not supported at the database layer."
)
+26
View File
@@ -0,0 +1,26 @@
"""drop image_cache table
Revision ID: 0054
Revises: 0053
Create Date: 2026-05-27
The image cache was wired into the LLM image-search tool (removed in Phase 8).
With no producer or consumer left, the table is dropped here.
"""
from alembic import op
revision = "0054"
down_revision = "0053"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("DROP TABLE IF EXISTS image_cache CASCADE")
def downgrade() -> None:
raise NotImplementedError(
"No downgrade — hard cutover per the MCP-first pivot spec."
)
+153
View File
@@ -0,0 +1,153 @@
"""rulebook tables: rulebooks, rulebook_topics, rules, project_rulebook_subscriptions
Revision ID: 0055
Revises: 0054
Create Date: 2026-05-27
Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
to Project. Rules carry a structural (statement, why, how_to_apply)
triple. Projects subscribe to Rulebooks (many-to-many). See the design
doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
"""
from alembic import op
import sqlalchemy as sa
revision = "0055"
down_revision = "0054"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rulebooks",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column(
"owner_user_id",
sa.BigInteger,
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.Text, nullable=False),
sa.Column("description", sa.Text),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
)
op.create_index("ix_rulebooks_owner", "rulebooks", ["owner_user_id"])
op.create_table(
"rulebook_topics",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column(
"rulebook_id",
sa.BigInteger,
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.Text, nullable=False),
sa.Column("description", sa.Text),
sa.Column(
"order_index", sa.Integer, nullable=False, server_default="0",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
)
op.create_index(
"ix_rulebook_topics_rulebook", "rulebook_topics", ["rulebook_id"],
)
op.create_table(
"rules",
sa.Column("id", sa.BigInteger, primary_key=True),
sa.Column(
"topic_id",
sa.BigInteger,
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("title", sa.Text, nullable=False),
sa.Column("statement", sa.Text, nullable=False),
sa.Column("why", sa.Text),
sa.Column("how_to_apply", sa.Text),
sa.Column(
"order_index", sa.Integer, nullable=False, server_default="0",
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
)
op.create_index("ix_rules_topic", "rules", ["topic_id"])
op.create_table(
"project_rulebook_subscriptions",
sa.Column(
"project_id",
sa.BigInteger,
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"rulebook_id",
sa.BigInteger,
sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("project_id", "rulebook_id"),
)
op.create_index(
"ix_project_rulebook_subs_rulebook",
"project_rulebook_subscriptions",
["rulebook_id"],
)
def downgrade() -> None:
op.drop_index(
"ix_project_rulebook_subs_rulebook",
table_name="project_rulebook_subscriptions",
)
op.drop_table("project_rulebook_subscriptions")
op.drop_index("ix_rules_topic", table_name="rules")
op.drop_table("rules")
op.drop_index("ix_rulebook_topics_rulebook", table_name="rulebook_topics")
op.drop_table("rulebook_topics")
op.drop_index("ix_rulebooks_owner", table_name="rulebooks")
op.drop_table("rulebooks")
+35
View File
@@ -0,0 +1,35 @@
"""task_kind column on notes: distinguishes plan-tasks from work-tasks
Revision ID: 0056
Revises: 0055
Create Date: 2026-05-28
A plan is a Task (a note with non-null status) marked task_kind='plan'.
The CHECK constraint lands in the same migration as the value set, per the
'new CHECK-enum values need a same-change migration' rule.
"""
from alembic import op
import sqlalchemy as sa
revision = "0056"
down_revision = "0055"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"notes",
sa.Column(
"task_kind", sa.Text(), nullable=False, server_default="work",
),
)
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
)
def downgrade() -> None:
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.drop_column("notes", "task_kind")
+38
View File
@@ -0,0 +1,38 @@
"""soft-delete: deleted_at + deleted_batch_id on 7 tables
Revision ID: 0057
Revises: 0056
Create Date: 2026-05-28
Recoverable deletes: each soft-deletable table gains a nullable deleted_at
timestamp (NULL = live) and a deleted_batch_id (text UUID) stamped per delete
operation so a cascaded delete restores as a unit. A daily cron purges rows
past the retention window.
"""
from alembic import op
import sqlalchemy as sa
revision = "0057"
down_revision = "0056"
branch_labels = None
depends_on = None
_TABLES = (
"notes", "events", "projects", "milestones",
"rulebooks", "rulebook_topics", "rules",
)
def upgrade() -> None:
for t in _TABLES:
op.add_column(t, sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True))
op.add_column(t, sa.Column("deleted_batch_id", sa.Text(), nullable=True))
op.create_index(f"ix_{t}_deleted_at", t, ["deleted_at"])
def downgrade() -> None:
for t in reversed(_TABLES):
op.drop_index(f"ix_{t}_deleted_at", table_name=t)
op.drop_column(t, "deleted_batch_id")
op.drop_column(t, "deleted_at")
@@ -0,0 +1,39 @@
"""rulebook always_on flag
Revision ID: 0058
Revises: 0057
Create Date: 2026-06-01
Adds a boolean `always_on` to the `rulebooks` table. Rules from rulebooks
flagged always_on are loaded at session start by the new
`list_always_on_rules` MCP tool — they apply regardless of which project
(if any) is in scope. Seeds the FabledSword family rulebook to always_on
because that's the cross-project standards rulebook by design.
"""
from alembic import op
import sqlalchemy as sa
revision = "0058"
down_revision = "0057"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rulebooks",
sa.Column(
"always_on",
sa.Boolean(),
nullable=False,
server_default=sa.text("false"),
),
)
op.execute(
"UPDATE rulebooks SET always_on = TRUE WHERE title = 'FabledSword family'"
)
def downgrade() -> None:
op.drop_column("rulebooks", "always_on")
@@ -0,0 +1,49 @@
"""project-scoped rules
Revision ID: 0059
Revises: 0058
Create Date: 2026-06-01
Rules can now belong to either a rulebook topic (cross-project standard) or
a single project (project-scoped). Adds `rules.project_id`, makes `topic_id`
nullable, and adds a CHECK constraint enforcing exactly-one. The previous
unique constraint on (topic_id, title) still applies because PostgreSQL
treats NULL as distinct — two project-scoped rules with the same title and
NULL topic_id remain unique.
"""
from alembic import op
import sqlalchemy as sa
revision = "0059"
down_revision = "0058"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rules",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=True,
),
)
op.alter_column("rules", "topic_id", nullable=True)
op.create_index("ix_rules_project_id", "rules", ["project_id"])
op.create_check_constraint(
"ck_rule_topic_xor_project",
"rules",
"(topic_id IS NULL) <> (project_id IS NULL)",
)
def downgrade() -> None:
op.drop_constraint("ck_rule_topic_xor_project", "rules", type_="check")
op.drop_index("ix_rules_project_id", table_name="rules")
# Any rule with NULL topic_id will block re-tightening. Operator must
# migrate or delete project-scoped rules before downgrading.
op.alter_column("rules", "topic_id", nullable=False)
op.drop_column("rules", "project_id")
@@ -0,0 +1,73 @@
"""project rule + topic suppressions
Revision ID: 0060
Revises: 0059
Create Date: 2026-06-01
Lets a project mute specific rules or whole topics from rulebooks it
subscribes to, without unsubscribing the rulebook. Two pure many-to-many
association tables; FKs CASCADE so removing a project / rule / topic
cleans the suppression rows automatically.
"""
from alembic import op
import sqlalchemy as sa
revision = "0060"
down_revision = "0059"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"project_rule_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"rule_id",
sa.BigInteger(),
sa.ForeignKey("rules.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
op.create_table(
"project_topic_suppressions",
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"topic_id",
sa.BigInteger(),
sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
primary_key=True,
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
)
def downgrade() -> None:
op.drop_table("project_topic_suppressions")
op.drop_table("project_rule_suppressions")
@@ -0,0 +1,49 @@
"""partial-unique topic/rule titles (ignore soft-deleted rows)
Revision ID: 0061
Revises: 0060
Create Date: 2026-06-02
Topics and rules are soft-deleted (SoftDeleteMixin), but uq_topic_per_rulebook
and uq_rule_per_topic were plain UNIQUE constraints. Trashing a topic/rule
named "X" then creating a new "X" — or restoring into a reused title slot —
collided with the dead row and raised an unhandled 500. Replace the full
UNIQUE constraints with partial unique indexes that only consider live
(deleted_at IS NULL) rows.
"""
from alembic import op
import sqlalchemy as sa
revision = "0061"
down_revision = "0060"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_constraint("uq_topic_per_rulebook", "rulebook_topics", type_="unique")
op.create_index(
"uq_topic_per_rulebook",
"rulebook_topics",
["rulebook_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
op.drop_constraint("uq_rule_per_topic", "rules", type_="unique")
op.create_index(
"uq_rule_per_topic",
"rules",
["topic_id", "title"],
unique=True,
postgresql_where=sa.text("deleted_at IS NULL"),
)
def downgrade() -> None:
op.drop_index("uq_rule_per_topic", table_name="rules")
op.create_unique_constraint("uq_rule_per_topic", "rules", ["topic_id", "title"])
op.drop_index("uq_topic_per_rulebook", table_name="rulebook_topics")
op.create_unique_constraint(
"uq_topic_per_rulebook", "rulebook_topics", ["rulebook_id", "title"]
)
@@ -0,0 +1,41 @@
"""drop dead UserProfile curator columns
Revision ID: 0062
Revises: 0061
Create Date: 2026-06-02
learned_summary, observations_raw, and observations_updated_at were written by
the curator/LLM-profile machinery removed in the Phase-8 MCP pivot. Nothing has
populated them since; the profile API returned permanently-empty fields. Drop
the columns.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0062"
down_revision = "0061"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("user_profiles", "learned_summary")
op.drop_column("user_profiles", "observations_raw")
op.drop_column("user_profiles", "observations_updated_at")
def downgrade() -> None:
op.add_column(
"user_profiles",
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("observations_raw", postgresql.JSONB(), nullable=True),
)
op.add_column(
"user_profiles",
sa.Column("learned_summary", sa.Text(), nullable=True),
)
@@ -0,0 +1,34 @@
"""drop dead Project.auto_summary columns
Revision ID: 0063
Revises: 0062
Create Date: 2026-06-03
auto_summary + summary_updated_at were written by generate_project_summary
(Ollama), removed in the MCP pivot. Nothing has populated them since; the
field was stale-or-NULL. Drop the columns.
"""
from alembic import op
import sqlalchemy as sa
revision = "0063"
down_revision = "0062"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.drop_column("projects", "auto_summary")
op.drop_column("projects", "summary_updated_at")
def downgrade() -> None:
op.add_column(
"projects",
sa.Column("summary_updated_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"projects",
sa.Column("auto_summary", sa.Text(), nullable=True),
)
+58
View File
@@ -0,0 +1,58 @@
"""repo -> project bindings
Revision ID: 0064
Revises: 0063
Create Date: 2026-06-10
Maps a git repository (by its normalized remote, `repo_key`) to the Scribe
project it represents, so the SessionStart hook can resolve the active project
from the working repo instead of a project id pinned in plugin config. FKs
CASCADE so deleting a user or project removes the stale binding automatically.
"""
from alembic import op
import sqlalchemy as sa
revision = "0064"
down_revision = "0063"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"repo_bindings",
sa.Column("id", sa.BigInteger(), primary_key=True, autoincrement=True),
sa.Column(
"user_id",
sa.BigInteger(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"project_id",
sa.BigInteger(),
sa.ForeignKey("projects.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("repo_key", sa.Text(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.UniqueConstraint("user_id", "repo_key", name="uq_repo_bindings_user_repo"),
)
op.create_index("ix_repo_bindings_user_id", "repo_bindings", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_repo_bindings_user_id", table_name="repo_bindings")
op.drop_table("repo_bindings")
+103
View File
@@ -0,0 +1,103 @@
"""issues + systems: task_kind=issue, systems, record_systems, arose_from_id
Revision ID: 0065
Revises: 0064
Create Date: 2026-06-14
Adds the corrective-work 'issue' task_kind (same-change CHECK expand per the
'new CHECK-enum values need a same-change migration' rule), a per-project
self-describing System entity, a many-to-many record<->system join (any
note/task/issue), and an issue->originating-task provenance FK.
"""
from alembic import op
import sqlalchemy as sa
revision = "0065"
down_revision = "0064"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. task_kind gains 'issue' (corrective work). DROP+ADD the CHECK in the
# same change that introduces the value.
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan','issue')",
)
# 2. Provenance: an issue can point back at the task/feature it arose from.
# Distinct from parent_id (sub-task hierarchy).
op.add_column("notes", sa.Column("arose_from_id", sa.Integer(), nullable=True))
op.create_foreign_key(
"fk_notes_arose_from_id", "notes", "notes",
["arose_from_id"], ["id"], ondelete="SET NULL",
)
op.create_index("ix_notes_arose_from_id", "notes", ["arose_from_id"])
# 3. systems: per-project, reusable, self-describing subsystem/area.
op.create_table(
"systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id", sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"project_id", sa.Integer(),
sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False,
),
sa.Column("name", sa.Text(), nullable=False, server_default=""),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("color", sa.Text(), nullable=True),
sa.Column("status", sa.Text(), nullable=False, server_default="active"),
sa.Column("order_index", sa.Integer(), nullable=False, server_default="0"),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("deleted_batch_id", sa.Text(), nullable=True),
)
op.create_index("ix_systems_project_id", "systems", ["project_id"])
op.create_check_constraint(
"systems_status_check", "systems", "status IN ('active','archived')",
)
# 4. record_systems: M2M join — any note/task/issue <-> system.
op.create_table(
"record_systems",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"note_id", sa.Integer(),
sa.ForeignKey("notes.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"system_id", sa.Integer(),
sa.ForeignKey("systems.id", ondelete="CASCADE"), nullable=False,
),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.UniqueConstraint("note_id", "system_id", name="uq_record_systems_note_system"),
)
op.create_index("ix_record_systems_note_id", "record_systems", ["note_id"])
op.create_index("ix_record_systems_system_id", "record_systems", ["system_id"])
def downgrade() -> None:
op.drop_table("record_systems")
op.drop_table("systems")
op.drop_index("ix_notes_arose_from_id", table_name="notes")
op.drop_constraint("fk_notes_arose_from_id", "notes", type_="foreignkey")
op.drop_column("notes", "arose_from_id")
op.drop_constraint("notes_task_kind_check", "notes", type_="check")
op.create_check_constraint(
"notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
)
+32
View File
@@ -0,0 +1,32 @@
"""milestone-as-plan-container: milestones.body holds the plan/design
Revision ID: 0066
Revises: 0065
Create Date: 2026-06-14
T3 of plan #819. The milestone becomes the plan container: its `body` holds
the design/intent/purpose (markdown), `description` stays the one-liner, and
individual steps live as first-class child tasks (milestone_id) instead of
checkboxes crammed into a kind=plan task body. start_planning is reworked to
create a milestone instead of a kind=plan task (hard retirement going forward;
the 'plan' task_kind enum value stays valid so the historical plan-tasks are
left readable in place — no body-shredding backfill).
Schema change is just one nullable column; no data migration.
"""
from alembic import op
import sqlalchemy as sa
revision = "0066"
down_revision = "0065"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("milestones", sa.Column("body", sa.Text(), nullable=True))
def downgrade() -> None:
op.drop_column("milestones", "body")
@@ -0,0 +1,73 @@
"""pgvector: note_embeddings.embedding JSONB -> vector(384) + HNSW index
Revision ID: 0067
Revises: 0066
Create Date: 2026-06-22
Moves semantic search off the full-table Python cosine scan onto a native
pgvector column so ranking + top-k run as an indexed `ORDER BY embedding <=> :q
LIMIT k` in Postgres (see services/embeddings.semantic_search_notes).
Requires a Postgres image that bundles the `vector` extension — the stack moved
from postgres:16-alpine to pgvector/pgvector:pg16 in the same change (compose +
CI). `CREATE EXTENSION IF NOT EXISTS vector` below is the in-db half.
Embeddings are DERIVED data (regenerated from note text by
backfill_note_embeddings at startup), so this migration is free to drop any row
it can't cleanly convert: only rows whose stored JSONB array is exactly 384-dim
are carried over (guarding against stale vectors from an earlier model — the
same mixed-dim hazard _cosine_similarity defended against). Dropped rows are
re-embedded on next boot.
"""
from alembic import op
revision = "0067"
down_revision = "0066"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS vector")
# New native-vector column, populated only from cleanly-convertible rows.
# A JSONB array like [0.1, 0.2, ...] renders to text that is exactly
# pgvector's input literal, so (embedding::text)::vector is a direct cast.
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_vec vector(384)")
op.execute(
"""
UPDATE note_embeddings
SET embedding_vec = (embedding::text)::vector
WHERE jsonb_array_length(embedding) = 384
"""
)
# Stale-dim rows (couldn't convert) are derived data — drop and let the
# startup backfill regenerate them at the current dimension.
op.execute("DELETE FROM note_embeddings WHERE embedding_vec IS NULL")
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_vec SET NOT NULL")
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_vec TO embedding")
# HNSW index for cosine distance — matches Vector.cosine_distance (`<=>`).
op.execute(
"""
CREATE INDEX ix_note_embeddings_embedding_hnsw
ON note_embeddings
USING hnsw (embedding vector_cosine_ops)
"""
)
def downgrade() -> None:
# Back to JSONB. pgvector renders a vector to a text literal that is a valid
# JSON array, so the reverse cast is symmetric. The `vector` extension is
# intentionally left installed (other objects may depend on it; dropping an
# extension is the riskier, rarely-wanted direction).
op.execute("DROP INDEX IF EXISTS ix_note_embeddings_embedding_hnsw")
op.execute("ALTER TABLE note_embeddings ADD COLUMN embedding_json jsonb")
op.execute("UPDATE note_embeddings SET embedding_json = (embedding::text)::jsonb")
op.execute("ALTER TABLE note_embeddings ALTER COLUMN embedding_json SET NOT NULL")
op.execute("ALTER TABLE note_embeddings DROP COLUMN embedding")
op.execute("ALTER TABLE note_embeddings RENAME COLUMN embedding_json TO embedding")
+60
View File
@@ -0,0 +1,60 @@
"""retrieval_logs: per-call semantic-retrieval telemetry for KB-injection tuning
Revision ID: 0068
Revises: 0067
Create Date: 2026-06-22
One row per semantic-retrieval call (MCP search tool, REST search route, and —
once it lands — the title-first auto-inject path). Captures the effective query
params and the score distribution of the results so the similarity threshold
and top-k can be tuned from real usage. FK-free on user_id (mirrors app_logs):
telemetry should outlive the row it describes.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0068"
down_revision = "0067"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"retrieval_logs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"created_at", sa.DateTime(timezone=True), nullable=False,
server_default=sa.text("now()"),
),
sa.Column("user_id", sa.Integer(), nullable=True),
sa.Column("source", sa.Text(), nullable=False),
sa.Column("query", sa.Text(), nullable=True),
sa.Column("threshold", sa.Float(), nullable=True),
sa.Column("limit_n", sa.Integer(), nullable=True),
sa.Column("project_id", sa.Integer(), nullable=True),
sa.Column("is_task", sa.Boolean(), nullable=True),
sa.Column("result_count", sa.Integer(), nullable=False, server_default="0"),
sa.Column("top_score", sa.Float(), nullable=True),
sa.Column("min_score", sa.Float(), nullable=True),
sa.Column("result_ids", JSONB(), nullable=False, server_default=sa.text("'[]'::jsonb")),
sa.Column("duration_ms", sa.Float(), nullable=True),
)
op.create_index("ix_retrieval_logs_created_at", "retrieval_logs", ["created_at"])
op.create_index("ix_retrieval_logs_user_id", "retrieval_logs", ["user_id"])
op.create_index("ix_retrieval_logs_source", "retrieval_logs", ["source"])
op.create_index(
"ix_retrieval_logs_source_created_at",
"retrieval_logs",
["source", sa.text("created_at DESC")],
)
def downgrade() -> None:
op.drop_index("ix_retrieval_logs_source_created_at", table_name="retrieval_logs")
op.drop_index("ix_retrieval_logs_source", table_name="retrieval_logs")
op.drop_index("ix_retrieval_logs_user_id", table_name="retrieval_logs")
op.drop_index("ix_retrieval_logs_created_at", table_name="retrieval_logs")
op.drop_table("retrieval_logs")
+49
View File
@@ -0,0 +1,49 @@
# CI Requirements — FabledScribe
> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
> in the CI-Runner repo.
## Runtime image
```
git.fabledsword.com/bvandeusen/ci-python:3.14
```
Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
lint (ruff), test (pytest), build (docker buildx).
## Image deps used
- python 3.14
- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
frontend builder stage inside the production `Dockerfile`)
- ruff (lint job runs `ruff check src/` with zero install overhead)
- docker CLI + buildx (build job pushes the production image to the
Forgejo registry)
## Per-job tool installs
Anything CI installs at job time that isn't in the image. Promotion
candidates if more than one project needs them.
- `uv` — installed inline in the test job (`curl -LsSf
https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
ci-python image so every consumer doesn't re-install on cold start.
Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
- `http-ece` is `--no-build-isolation`-installed before the editable
package install because http-ece doesn't declare `setuptools` as a
build dep and uv creates bare venvs without it. Not promotion-worthy
(one project, one wheel).
## Notes
- Production runtime image (`Dockerfile`) also tracks Python 3.14 — the
CI image and runtime image stay aligned by design so test results are
representative.
- Build wall time: dominated by `pytest` (full async test suite). Cold
ci-python pulls add ~30s; not a blocker.
- Registry-backed BuildKit layer cache (`type=registry,ref=…:cache,mode=max`)
gives ~80% speedup on warm builds — see the build job comment.
- `pyproject.toml` pins `requires-python = ">=3.14"` to match the CI +
runtime target; lockfile (`uv.lock`) is committed and resolves against
Python 3.14.
+22 -44
View File
@@ -1,16 +1,14 @@
services: services:
app: app:
image: git.fabledsword.com/bvandeusen/fabledassistant:latest image: git.fabledsword.com/bvandeusen/fabledscribe:latest
environment: environment:
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant" DATABASE_URL: "postgresql+asyncpg://scribe:${DB_PASSWORD}@db:5432/scribe"
SECRET_KEY: "${SECRET_KEY}" SECRET_KEY: "${SECRET_KEY}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
TRUST_PROXY_HEADERS: "true" TRUST_PROXY_HEADERS: "true"
SECURE_COOKIES: "true" SECURE_COOKIES: "true"
networks: networks:
- fabledassistant_backend - scribe_backend
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s interval: 30s
@@ -23,59 +21,39 @@ services:
max_attempts: 5 max_attempts: 5
db: db:
image: postgres:16-alpine # pgvector image (Debian/glibc, PG17) — bundles the `vector` extension that
# migration 0067 enables. Moved off postgres:16-alpine via logical
# dump/restore (which doubles as the PG16->PG17 major upgrade); see the
# TRANSITION runbook in the PR.
image: pgvector/pgvector:pg17
stop_grace_period: 120s
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: fabled POSTGRES_USER: scribe
POSTGRES_PASSWORD: "${DB_PASSWORD}" POSTGRES_PASSWORD: "${DB_PASSWORD}"
POSTGRES_DB: fabledassistant POSTGRES_DB: scribe
networks: networks:
- fabledassistant_backend - scribe_backend
# Lenient by design: a transient host exec/healthcheck stall (incident:
# runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"] test: ["CMD-SHELL", "pg_isready -U scribe"]
interval: 10s
timeout: 5s
retries: 5
deploy:
restart_policy:
condition: on-failure
max_attempts: 5
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
networks:
- fabledassistant_backend
environment:
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
healthcheck:
test: ["CMD-SHELL", "ollama list || exit 1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 10
start_period: 30s start_period: 180s
deploy: deploy:
placement:
constraints:
- node.role == worker
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
restart_policy: restart_policy:
condition: on-failure condition: on-failure
max_attempts: 5 delay: 10s
max_attempts: 0
window: 120s
volumes: volumes:
pgdata: pgdata:
ollama_models:
networks: networks:
fabledassistant_backend: scribe_backend:
driver: overlay driver: overlay
+16 -39
View File
@@ -1,4 +1,4 @@
# Fabled Assistant — Quick Start # Fabled Scribe — Quick Start
# #
# No build required. Pulls the latest pre-built image from the registry. # No build required. Pulls the latest pre-built image from the registry.
# #
@@ -6,29 +6,26 @@
# 1. Download this file # 1. Download this file
# 2. docker compose -f docker-compose.quickstart.yml up -d # 2. docker compose -f docker-compose.quickstart.yml up -d
# 3. Open http://localhost:5000 — the first account registered becomes admin # 3. Open http://localhost:5000 — the first account registered becomes admin
# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points) # 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
# bearer-token URL shown there.
# #
# Set SECRET_KEY via environment variable or a .env file alongside this file: # Set SECRET_KEY via environment variable or a .env file alongside this file:
# SECRET_KEY=your-random-secret-here # SECRET_KEY=your-random-secret-here
services: services:
app: app:
image: git.fabledsword.com/bvandeusen/fabledassistant:latest image: git.fabledsword.com/bvandeusen/fabledscribe:latest
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant" DATABASE_URL: "postgresql+asyncpg://scribe:scribe@db:5432/scribe"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}" SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}" LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes: volumes:
- app_data:/data - app_data:/data
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
ollama:
condition: service_healthy
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
@@ -38,45 +35,25 @@ services:
start_period: 30s start_period: 30s
db: db:
image: postgres:16-alpine # pgvector image (PG17) — bundles the `vector` extension (migration 0067).
image: pgvector/pgvector:pg17
stop_grace_period: 120s
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: fabled POSTGRES_USER: scribe
POSTGRES_PASSWORD: fabled POSTGRES_PASSWORD: scribe
POSTGRES_DB: fabledassistant POSTGRES_DB: scribe
# Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"] test: ["CMD-SHELL", "pg_isready -U scribe"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
environment:
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
healthcheck:
test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 5 retries: 10
start_period: 15s start_period: 180s
restart: unless-stopped restart: unless-stopped
# Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes: volumes:
app_data: app_data:
pgdata: pgdata:
ollama_models:
+15 -38
View File
@@ -6,25 +6,16 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
ollama:
condition: service_started
volumes: volumes:
- app_data:/data - app_data:/data
# To use a bind mount instead (gives direct host access to all app data): # To use a bind mount instead (gives direct host access to all app data):
# - ./data:/data # - ./data:/data
environment: environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}" DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-scribe}:${POSTGRES_PASSWORD:-scribe}@db:5432/${POSTGRES_DB:-scribe}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment and set to enable web research and image search via SearXNG: # Uncomment if you have a SearXNG instance you want to surface in the
# Integrations tab as a configured web-search backend:
# SEARXNG_URL: "http://searxng:8080" # SEARXNG_URL: "http://searxng:8080"
# IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
# IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
# Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 10s interval: 10s
@@ -34,37 +25,23 @@ services:
db: db:
image: postgres:16-alpine image: postgres:16-alpine
stop_grace_period: 120s
restart: unless-stopped
volumes: volumes:
- pgdata:/var/lib/postgresql/data - pgdata:/var/lib/postgresql/data
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-fabled} POSTGRES_USER: ${POSTGRES_USER:-scribe}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-scribe}
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant} POSTGRES_DB: ${POSTGRES_DB:-scribe}
# Lenient by design: a transient host exec/healthcheck stall must never
# escalate to killing the DB. Health here only gates app startup order.
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-scribe}"]
interval: 5s interval: 30s
timeout: 5s timeout: 10s
retries: 5 retries: 10
start_period: 180s
ollama:
image: ollama/ollama
volumes:
- ollama_models:/root/.ollama
environment:
OLLAMA_MAX_LOADED_MODELS: "2"
OLLAMA_NUM_PARALLEL: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
# GPU reservation commented out — no nvidia-container-toolkit on this host
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes: volumes:
pgdata: pgdata:
ollama_models:
app_data: app_data:
+3 -3
View File
@@ -12,7 +12,7 @@ A Python MCP (Model Context Protocol) server that lets Claude directly interface
The work is split into two sub-projects: The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication 1. **Fable API Key Feature** — additions to the main `scribe` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root 2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working. A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
@@ -134,7 +134,7 @@ The MCP tool reads tokens until it receives `type: "done"`, then returns `respon
### Location ### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable. `fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `scribe`. It will be extracted to its own Forgejo repo once stable.
### Package Structure ### Package Structure
@@ -256,7 +256,7 @@ Claude Code spawns the process over stdio automatically. No Docker, no daemon.
## Build & Repo Plan ## Build & Repo Plan
1. Implement and test within `fabledassistant/fable-mcp/` 1. Implement and test within `scribe/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`) 2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready 3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
+66 -66
View File
@@ -6,7 +6,7 @@
**Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done. **Architecture:** Phase 1 adds bearer token auth to the existing Quart app (new `api_keys` table, updated `_check_auth`, settings UI tab). Phase 2 is a standalone `fable-mcp/` Python package using the `mcp[cli]` SDK that calls the Fable HTTP API via `httpx`. The two phases are sequential — Phase 2 can be built/tested independently using a write-scoped API key once Phase 1 is done.
**Deployment decision:** `fable-mcp/` stays permanently inside the `fabledassistant` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance. **Deployment decision:** `fable-mcp/` stays permanently inside the `scribe` repo. It will always be versioned alongside the backend it targets. Future Task A will serve the package from the running Fable Docker image so users can install it directly from their instance.
**Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both. **Tech Stack:** Python 3.12, Quart (Phase 1); `mcp[cli]`, `httpx`, `python-dotenv` (Phase 2); Vue 3 + TypeScript (Settings UI); pytest for both.
@@ -23,16 +23,16 @@
| Action | File | Purpose | | Action | File | Purpose |
|--------|------|---------| |--------|------|---------|
| Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table | | Create | `alembic/versions/0027_add_api_keys.py` | DB migration for `api_keys` table |
| Create | `src/fabledassistant/models/api_key.py` | `ApiKey` SQLAlchemy model | | Create | `src/scribe/models/api_key.py` | `ApiKey` SQLAlchemy model |
| Modify | `src/fabledassistant/models/__init__.py` | Export `ApiKey` | | Modify | `src/scribe/models/__init__.py` | Export `ApiKey` |
| Create | `src/fabledassistant/services/api_keys.py` | create/list/revoke/lookup service functions | | Create | `src/scribe/services/api_keys.py` | create/list/revoke/lookup service functions |
| Modify | `src/fabledassistant/auth.py` | Add bearer token check before session fallback | | Modify | `src/scribe/auth.py` | Add bearer token check before session fallback |
| Create | `src/fabledassistant/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint | | Create | `src/scribe/routes/api_keys.py` | GET/POST/DELETE `/api/api-keys` blueprint |
| Modify | `src/fabledassistant/app.py` | Register `api_keys_bp` and `search_bp` | | Modify | `src/scribe/app.py` | Register `api_keys_bp` and `search_bp` |
| Modify | `src/fabledassistant/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` | | Modify | `src/scribe/services/chat.py:17-30` | Add `conversation_type` param to `create_conversation` |
| Modify | `src/fabledassistant/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` | | Modify | `src/scribe/services/chat.py:123-135` | Exclude `"mcp"` type from `cleanup_old_conversations` |
| Modify | `src/fabledassistant/routes/chat.py:73-79` | Pass `conversation_type` from POST body | | Modify | `src/scribe/routes/chat.py:73-79` | Pass `conversation_type` from POST body |
| Create | `src/fabledassistant/routes/search.py` | `GET /api/search` semantic search endpoint | | Create | `src/scribe/routes/search.py` | `GET /api/search` semantic search endpoint |
| Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab | | Modify | `frontend/src/views/SettingsView.vue` | Add "API Keys" tab |
| Create | `tests/test_api_keys.py` | Unit tests for service + auth | | Create | `tests/test_api_keys.py` | Unit tests for service + auth |
| Create | `tests/test_search_route.py` | Unit test for search endpoint | | Create | `tests/test_search_route.py` | Unit test for search endpoint |
@@ -42,13 +42,13 @@
### Task 1: ApiKey model + migration ### Task 1: ApiKey model + migration
**Files:** **Files:**
- Create: `src/fabledassistant/models/api_key.py` - Create: `src/scribe/models/api_key.py`
- Create: `alembic/versions/0027_add_api_keys.py` - Create: `alembic/versions/0027_add_api_keys.py`
- Modify: `src/fabledassistant/models/__init__.py` - Modify: `src/scribe/models/__init__.py`
- [ ] **Step 1: Write the model** - [ ] **Step 1: Write the model**
Create `src/fabledassistant/models/api_key.py`: Create `src/scribe/models/api_key.py`:
```python ```python
from datetime import datetime, timezone from datetime import datetime, timezone
@@ -56,8 +56,8 @@ from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base from scribe.models import Base
from fabledassistant.models.base import CreatedAtMixin from scribe.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin): class ApiKey(Base, CreatedAtMixin):
@@ -97,10 +97,10 @@ class ApiKey(Base, CreatedAtMixin):
- [ ] **Step 2: Export from models __init__** - [ ] **Step 2: Export from models __init__**
In `src/fabledassistant/models/__init__.py`, add after the last import line: In `src/scribe/models/__init__.py`, add after the last import line:
```python ```python
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401 from scribe.models.api_key import ApiKey # noqa: E402, F401
``` ```
- [ ] **Step 3: Write the migration** - [ ] **Step 3: Write the migration**
@@ -155,8 +155,8 @@ Expected: `Running upgrade 0026 -> 0027, add api_keys table`
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/fabledassistant/models/api_key.py \ git add src/scribe/models/api_key.py \
src/fabledassistant/models/__init__.py \ src/scribe/models/__init__.py \
alembic/versions/0027_add_api_keys.py alembic/versions/0027_add_api_keys.py
git commit -m "feat: add ApiKey model and migration 0027" git commit -m "feat: add ApiKey model and migration 0027"
``` ```
@@ -166,7 +166,7 @@ git commit -m "feat: add ApiKey model and migration 0027"
### Task 2: ApiKey service ### Task 2: ApiKey service
**Files:** **Files:**
- Create: `src/fabledassistant/services/api_keys.py` - Create: `src/scribe/services/api_keys.py`
- Create: `tests/test_api_keys.py` (service tests) - Create: `tests/test_api_keys.py` (service tests)
- [ ] **Step 1: Write the failing tests** - [ ] **Step 1: Write the failing tests**
@@ -181,7 +181,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fabledassistant.services.api_keys import ( from scribe.services.api_keys import (
_hash_key, _hash_key,
generate_key, generate_key,
create_api_key, create_api_key,
@@ -212,7 +212,7 @@ def test_hash_key_is_sha256():
def test_generate_key_prefix(): def test_generate_key_prefix():
key = "fmcp_abcdefghijklmnop" key = "fmcp_abcdefghijklmnop"
# prefix is first 12 chars of the full key # prefix is first 12 chars of the full key
from fabledassistant.services.api_keys import _key_prefix from scribe.services.api_keys import _key_prefix
assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars assert _key_prefix(key) == "fmcp_abcdefg" # first 12 chars
@@ -222,7 +222,7 @@ async def test_create_api_key_returns_full_key():
mock_key_obj.id = 1 mock_key_obj.id = 1
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"} mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -238,7 +238,7 @@ async def test_create_api_key_returns_full_key():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown(): async def test_lookup_key_returns_none_for_unknown():
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx: with patch("scribe.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock() mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session) mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False) mock_session.__aexit__ = AsyncMock(return_value=False)
@@ -262,7 +262,7 @@ Expected: `ImportError` or `ModuleNotFoundError` (service doesn't exist yet)
- [ ] **Step 3: Write the service** - [ ] **Step 3: Write the service**
Create `src/fabledassistant/services/api_keys.py`: Create `src/scribe/services/api_keys.py`:
```python ```python
import hashlib import hashlib
@@ -271,8 +271,8 @@ from datetime import datetime, timezone
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.models import async_session from scribe.models import async_session
from fabledassistant.models.api_key import ApiKey from scribe.models.api_key import ApiKey
def generate_key() -> str: def generate_key() -> str:
@@ -365,7 +365,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/fabledassistant/services/api_keys.py tests/test_api_keys.py git add src/scribe/services/api_keys.py tests/test_api_keys.py
git commit -m "feat: add ApiKey service with create/list/revoke/lookup" git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
``` ```
@@ -374,7 +374,7 @@ git commit -m "feat: add ApiKey service with create/list/revoke/lookup"
### Task 3: Auth middleware — bearer token support ### Task 3: Auth middleware — bearer token support
**Files:** **Files:**
- Modify: `src/fabledassistant/auth.py` - Modify: `src/scribe/auth.py`
- Modify: `tests/test_api_keys.py` (add auth middleware tests) - Modify: `tests/test_api_keys.py` (add auth middleware tests)
- [ ] **Step 1: Add auth middleware tests** - [ ] **Step 1: Add auth middleware tests**
@@ -400,7 +400,7 @@ def test_scope_validation():
async def test_bearer_token_path_sets_g_user(monkeypatch): async def test_bearer_token_path_sets_g_user(monkeypatch):
"""Valid bearer token authenticates and sets g.user and g.api_key.""" """Valid bearer token authenticates and sets g.user and g.api_key."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from fabledassistant.auth import _check_auth from scribe.auth import _check_auth
# Mock ApiKey object # Mock ApiKey object
fake_key = MagicMock() fake_key = MagicMock()
@@ -411,8 +411,8 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
fake_user = MagicMock() fake_user = MagicMock()
fake_user.role = "user" fake_user.role = "user"
monkeypatch.setattr("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)) monkeypatch.setattr("scribe.auth.lookup_key", AsyncMock(return_value=fake_key))
monkeypatch.setattr("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)) monkeypatch.setattr("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user))
called_with_user = {} called_with_user = {}
@@ -434,7 +434,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async with app.test_request_context("/test", method="GET", async with app.test_request_context("/test", method="GET",
headers={"Authorization": "Bearer fmcp_valid"}): headers={"Authorization": "Bearer fmcp_valid"}):
# Just verify _check_auth calls lookup_key with the right token # Just verify _check_auth calls lookup_key with the right token
import fabledassistant.auth as auth_module import scribe.auth as auth_module
auth_module.lookup_key.assert_called_with # callable auth_module.lookup_key.assert_called_with # callable
@@ -442,7 +442,7 @@ async def test_bearer_token_path_sets_g_user(monkeypatch):
async def test_read_only_key_blocked_on_post(): async def test_read_only_key_blocked_on_post():
"""Read-only API key returns 403 on non-GET requests.""" """Read-only API key returns 403 on non-GET requests."""
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
from fabledassistant.auth import _check_auth from scribe.auth import _check_auth
from quart import Quart from quart import Quart
fake_key = MagicMock() fake_key = MagicMock()
@@ -459,8 +459,8 @@ async def test_read_only_key_blocked_on_post():
headers={"Authorization": "Bearer fmcp_readonly"}), headers={"Authorization": "Bearer fmcp_readonly"}),
): ):
from unittest.mock import patch from unittest.mock import patch
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \ with patch("scribe.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)): patch("scribe.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
async def dummy(): async def dummy():
return "ok" return "ok"
@@ -481,15 +481,15 @@ docker compose run --rm app pytest tests/test_api_keys.py -v
- [ ] **Step 3: Update auth.py** - [ ] **Step 3: Update auth.py**
Replace `src/fabledassistant/auth.py` with: Replace `src/scribe/auth.py` with:
```python ```python
import functools import functools
from quart import g, jsonify, request, session from quart import g, jsonify, request, session
from fabledassistant.services.auth import get_user_by_id from scribe.services.auth import get_user_by_id
from fabledassistant.services.api_keys import lookup_key from scribe.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None): def _check_auth(f, required_role: str | None = None):
@@ -556,7 +556,7 @@ Expected: all existing tests still pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/fabledassistant/auth.py tests/test_api_keys.py git add src/scribe/auth.py tests/test_api_keys.py
git commit -m "feat: add bearer token auth to _check_auth, falls back to session" git commit -m "feat: add bearer token auth to _check_auth, falls back to session"
``` ```
@@ -565,18 +565,18 @@ git commit -m "feat: add bearer token auth to _check_auth, falls back to session
### Task 4: API key routes + app registration ### Task 4: API key routes + app registration
**Files:** **Files:**
- Create: `src/fabledassistant/routes/api_keys.py` - Create: `src/scribe/routes/api_keys.py`
- Modify: `src/fabledassistant/app.py` - Modify: `src/scribe/app.py`
- [ ] **Step 1: Write the routes** - [ ] **Step 1: Write the routes**
Create `src/fabledassistant/routes/api_keys.py`: Create `src/scribe/routes/api_keys.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key from scribe.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys") api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@@ -619,10 +619,10 @@ async def revoke_key_route(key_id: int):
- [ ] **Step 2: Register in app.py** - [ ] **Step 2: Register in app.py**
In `src/fabledassistant/app.py`, add the import alongside the other route imports: In `src/scribe/app.py`, add the import alongside the other route imports:
```python ```python
from fabledassistant.routes.api_keys import api_keys_bp from scribe.routes.api_keys import api_keys_bp
``` ```
And add the registration line after `app.register_blueprint(users_bp)`: And add the registration line after `app.register_blueprint(users_bp)`:
@@ -651,7 +651,7 @@ curl -s http://localhost:8080/api/auth/me \
- [ ] **Step 4: Commit** - [ ] **Step 4: Commit**
```bash ```bash
git add src/fabledassistant/routes/api_keys.py src/fabledassistant/app.py git add src/scribe/routes/api_keys.py src/scribe/app.py
git commit -m "feat: add API key CRUD routes and register blueprint" git commit -m "feat: add API key CRUD routes and register blueprint"
``` ```
@@ -660,14 +660,14 @@ git commit -m "feat: add API key CRUD routes and register blueprint"
### Task 5: Conversation type wiring ### Task 5: Conversation type wiring
**Files:** **Files:**
- Modify: `src/fabledassistant/services/chat.py` (lines 17-30 and 123-135) - Modify: `src/scribe/services/chat.py` (lines 17-30 and 123-135)
- Modify: `src/fabledassistant/routes/chat.py` (lines 73-79) - Modify: `src/scribe/routes/chat.py` (lines 73-79)
Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion. Note: `Conversation.conversation_type` already exists in the model. `list_conversations` already filters by `conv_type`. This task only wires up creation and retention exclusion.
- [ ] **Step 1: Update `create_conversation` service** - [ ] **Step 1: Update `create_conversation` service**
In `src/fabledassistant/services/chat.py`, change the function signature at line 17: In `src/scribe/services/chat.py`, change the function signature at line 17:
```python ```python
async def create_conversation( async def create_conversation(
@@ -692,7 +692,7 @@ async def create_conversation(
- [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"** - [ ] **Step 2: Update `cleanup_old_conversations` to exclude "mcp"**
In `src/fabledassistant/services/chat.py`, update the WHERE clause at line 130: In `src/scribe/services/chat.py`, update the WHERE clause at line 130:
```python ```python
result = await session.execute( result = await session.execute(
@@ -708,7 +708,7 @@ result = await session.execute(
- [ ] **Step 3: Update the POST route to accept conversation_type** - [ ] **Step 3: Update the POST route to accept conversation_type**
In `src/fabledassistant/routes/chat.py`, update `create_conversation_route` (around line 73): In `src/scribe/routes/chat.py`, update `create_conversation_route` (around line 73):
```python ```python
@chat_bp.route("/conversations", methods=["POST"]) @chat_bp.route("/conversations", methods=["POST"])
@@ -737,7 +737,7 @@ Expected: all tests pass
- [ ] **Step 5: Commit** - [ ] **Step 5: Commit**
```bash ```bash
git add src/fabledassistant/services/chat.py src/fabledassistant/routes/chat.py git add src/scribe/services/chat.py src/scribe/routes/chat.py
git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep" git commit -m "feat: wire conversation_type through create_conversation, exclude mcp from retention sweep"
``` ```
@@ -746,8 +746,8 @@ git commit -m "feat: wire conversation_type through create_conversation, exclude
### Task 6: Semantic search endpoint ### Task 6: Semantic search endpoint
**Files:** **Files:**
- Create: `src/fabledassistant/routes/search.py` - Create: `src/scribe/routes/search.py`
- Modify: `src/fabledassistant/app.py` - Modify: `src/scribe/app.py`
- Create: `tests/test_search_route.py` - Create: `tests/test_search_route.py`
- [ ] **Step 1: Write the failing test** - [ ] **Step 1: Write the failing test**
@@ -762,7 +762,7 @@ from unittest.mock import patch, AsyncMock
def test_content_type_mapping(): def test_content_type_mapping():
"""Verify content_type string maps to correct is_task value.""" """Verify content_type string maps to correct is_task value."""
from fabledassistant.routes.search import _content_type_to_is_task from scribe.routes.search import _content_type_to_is_task
assert _content_type_to_is_task("note") is False assert _content_type_to_is_task("note") is False
assert _content_type_to_is_task("task") is True assert _content_type_to_is_task("task") is True
assert _content_type_to_is_task("all") is None assert _content_type_to_is_task("all") is None
@@ -779,13 +779,13 @@ Expected: `ImportError` (module doesn't exist yet)
- [ ] **Step 3: Write the route** - [ ] **Step 3: Write the route**
Create `src/fabledassistant/routes/search.py`: Create `src/scribe/routes/search.py`:
```python ```python
from quart import Blueprint, jsonify, request from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id from scribe.auth import login_required, get_current_user_id
from fabledassistant.services.embeddings import semantic_search_notes from scribe.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search") search_bp = Blueprint("search", __name__, url_prefix="/api/search")
@@ -834,9 +834,9 @@ Note: check `services/embeddings.py` to confirm the return type of `semantic_sea
- [ ] **Step 4: Register in app.py** - [ ] **Step 4: Register in app.py**
Add to imports in `src/fabledassistant/app.py`: Add to imports in `src/scribe/app.py`:
```python ```python
from fabledassistant.routes.search import search_bp from scribe.routes.search import search_bp
``` ```
Add registration: Add registration:
@@ -855,8 +855,8 @@ Expected: all tests pass
- [ ] **Step 6: Commit** - [ ] **Step 6: Commit**
```bash ```bash
git add src/fabledassistant/routes/search.py \ git add src/scribe/routes/search.py \
src/fabledassistant/app.py \ src/scribe/app.py \
tests/test_search_route.py tests/test_search_route.py
git commit -m "feat: add GET /api/search semantic search endpoint" git commit -m "feat: add GET /api/search semantic search endpoint"
``` ```
@@ -1905,7 +1905,7 @@ async def send_message(
return f"Error: {e}" return f"Error: {e}"
``` ```
Note: verify the Fable message POST endpoint path by checking `src/fabledassistant/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs. Note: verify the Fable message POST endpoint path by checking `src/scribe/routes/chat.py` — search for the route that accepts a user message and triggers generation. Adjust `/api/chat/conversations/{conv_id}/messages` if the actual path differs.
- [ ] **Step 4: Run tests** - [ ] **Step 4: Run tests**
+9 -9
View File
@@ -39,20 +39,20 @@
## New Backend Files ## New Backend Files
### `src/fabledassistant/services/stt.py` ### `src/scribe/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API: Lazy singleton `WhisperModel` loader. Public API:
- `load_stt_model()` — called at startup via `asyncio.create_task` - `load_stt_model()` — called at startup via `asyncio.create_task`
- `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text - `transcribe(audio_bytes, mime_type) -> str` — runs in `run_in_executor`; writes bytes to `NamedTemporaryFile`, returns concatenated segment text
- `stt_available() -> bool` - `stt_available() -> bool`
### `src/fabledassistant/services/tts.py` ### `src/scribe/services/tts.py`
Lazy singleton `KPipeline` loader. Public API: Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup - `load_tts_model()` — called at startup
- `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono) - `synthesise(text, voice, speed) -> bytes` — runs in `run_in_executor`; returns WAV bytes (24kHz, 16-bit mono)
- `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels - `list_voices() -> list[dict]` — returns static list of known Kokoro voice IDs + labels
- `tts_available() -> bool` - `tts_available() -> bool`
### `src/fabledassistant/routes/voice.py` ### `src/scribe/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`. Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description | | Endpoint | Method | Description |
@@ -66,27 +66,27 @@ Blueprint at `/api/voice`, all routes `@login_required`.
## Modified Backend Files ## Modified Backend Files
### `src/fabledassistant/app.py` ### `src/scribe/app.py`
- Register `voice_bp` blueprint - Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED` - In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/fabledassistant/config.py` ### `src/scribe/config.py`
- Add 4 new env var attributes - Add 4 new env var attributes
- Add validation in `validate()` - Add validation in `validate()`
### `src/fabledassistant/services/llm.py` ### `src/scribe/services/llm.py`
- Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()` - Add `voice_mode: bool = False` and `voice_speech_style: str = "conversational"` to `build_context()`
- When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."* - When `voice_mode=True`, prepend: *"Respond naturally as if speaking aloud. No markdown, bullet points, headers, or code blocks. Complete sentences only."*
- Append style modifier based on `voice_speech_style` - Append style modifier based on `voice_speech_style`
### `src/fabledassistant/services/generation_task.py` ### `src/scribe/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()` - Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()` - Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/fabledassistant/routes/chat.py` ### `src/scribe/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist - Allow `"voice"` in `conversation_type` whitelist
### `src/fabledassistant/services/chat.py` ### `src/scribe/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention - Exclude `conversation_type == "voice"` from auto-cleanup retention
--- ---
-73
View File
@@ -1,73 +0,0 @@
# Android Companion App
The Android companion app lives in a separate repository at `/home/bvandeusen/Nextcloud/Projects/fabled_app`.
## Stack
- Flutter + Dart
- Riverpod (state management)
- GoRouter (navigation)
- Dio (HTTP client)
- PersistCookieJar (session persistence)
- SSE streaming via `fetch` + `ReadableStream` bridge
## Architecture
```
lib/
app.dart # GoRouter + _Shell + _QuickCaptureBar
core/constants.dart # Routes.*
data/
models/ # note.dart, task.dart, project.dart
api/ # notes_api.dart, tasks_api.dart, projects_api.dart
repositories/ # notes, tasks, projects repositories
providers/
api_client_provider.dart # all API + repository providers
notes_provider.dart # NotesNotifier
tasks_provider.dart # TasksNotifier
projects_provider.dart # ProjectsNotifier
screens/
notes/note_edit_screen.dart # chip tag input + ProjectSelector
tasks/task_edit_screen.dart # ProjectSelector
projects/project_list_screen.dart
widgets/
project_selector.dart # reusable DropdownButtonFormField
```
## Navigation
4-tab shell (Notes · Tasks · Projects · Chat):
- Phone: bottom `NavigationBar`
- Tablet/landscape: `NavigationRail`
Quick Capture bar persists across all tabs. Settings accessible from top-right icon.
## Feature Status
| Feature | Status | Notes |
|---------|--------|-------|
| Notes CRUD | ✅ | Tags chip input; project selector in editor |
| Tasks CRUD | ✅ | Project selector in editor |
| Projects list | ✅ | Active/archived sections; long-press status change; create dialog |
| Chat + SSE | ✅ | Full streaming |
| Quick Capture | ✅ | Offline queue with retry |
| Tags | ✅ | Chip input in NoteEditScreen; typed as `List<String>` |
| Project assignment | ✅ | `ProjectSelector` dropdown in Note + Task editors |
| Milestones | ❌ deferred | Too granular for mobile; web UI handles it |
| Push notifications | ❌ incompatible | Backend uses browser VAPID; Flutter needs FCM/APNs — separate implementation required |
| CalDAV settings | ❌ intentional | Server-side config only; not exposed in mobile app |
## API Compatibility Notes
- `GET /api/projects/:id` returns a flat JSON object (not `{project: ...}` wrapper); includes `summary` field.
- `POST /api/projects` returns the project dict directly (201).
- `PATCH /api/projects/:id` returns the updated project dict.
- Task body field is `body` (not `description`) — the app maps `description``body` on serialize.
## Self-Update
The app supports self-update via the Forgejo release API (`update_provider.dart`). It checks the latest release tag and prompts the user to download and install a new APK when one is available.
## CI
Builds are triggered from the Forgejo Actions pipeline in the `fabled_app` repository. The APK is attached to the release as a downloadable artifact.
+49 -94
View File
@@ -1,4 +1,4 @@
# API Keys and Fable MCP # API Keys and Scribe MCP
## API Keys ## API Keys
@@ -19,11 +19,10 @@ Admin-level operations (log access, user management) require a `write`-scoped ke
2. Enter a name (e.g. "Claude MCP", "Home Server") 2. Enter a name (e.g. "Claude MCP", "Home Server")
3. Choose scope 3. Choose scope
4. Click **Generate Key** 4. Click **Generate Key**
5. Copy the key immediately — it is shown only once 5. Copy the key immediately — it is shown only once (the token is `fmcp_`-prefixed)
After creation you can download: Paste the key into the `Authorization: Bearer <key>` header of your MCP client
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste config (see **Scribe MCP Server** below).
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
### Revoking a Key ### Revoking a Key
@@ -31,73 +30,35 @@ Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys
--- ---
## Fable MCP Server ## Scribe MCP Server
The Fable MCP server (`fable-mcp`) exposes Fable as a set of MCP tools that Claude (and other MCP clients) can use to read and write your notes, tasks, projects, and more. Scribe exposes itself as a set of MCP tools that Claude (and other MCP clients)
can use to read and write your notes, tasks, projects, rulebooks, and more. The
server is **built into the app** — it is mounted as a streamable-HTTP endpoint
at **`/mcp`** on the running Scribe instance (`src/scribe/mcp/server.py`). There
is nothing to install: no wheel, no separate package, no CLI. You connect a
client straight to the URL with a Bearer token.
### Download ### Authentication
The wheel is bundled into the Docker image at build time and available for download from **Settings → API Keys → Fable MCP** when you are logged in. Authenticate with an API key generated from **Settings → API Keys** (see above),
sent as `Authorization: Bearer fmcp_<key>`. A `read`-scoped key may call only the
You can also download it directly: read tools (`get_*`, `list_*`, `search`, `enter_project`); any write/delete tool
``` is rejected with `403`. A `write`-scoped key may call everything.
GET /api/fable-mcp/download
```
(Requires login — authenticated browser session or API key in `Authorization: Bearer <key>` header.)
### Installation
```bash
# Install the wheel
pip install fable_mcp-*.whl
# Verify
fable-mcp --help
```
### Configuration
The server reads two environment variables:
| Variable | Description |
|----------|-------------|
| `FABLE_URL` | Base URL of your Fable instance (e.g. `https://notes.example.com`) |
| `FABLE_API_KEY` | API key generated from Settings → API Keys |
Create a `.env` file in your working directory, or set them in your shell / MCP config.
### Claude Code (Global)
Add to `~/.claude.json`:
```json
{
"mcpServers": {
"fable": {
"type": "stdio",
"command": "fable-mcp",
"env": {
"FABLE_URL": "https://your-fable-instance.example.com",
"FABLE_API_KEY": "your-api-key"
}
}
}
}
```
### Claude Code (Project-scoped) ### Claude Code (Project-scoped)
Add a `.mcp.json` at the project root (same format as the global config). Project-scoped config takes precedence over global when the same server name is defined in both. This is useful for using a dev instance or admin key within a specific project. Add a `.mcp.json` at the project root. The server `type` is `http` and the URL is
your instance's `/mcp` endpoint:
```json ```json
{ {
"mcpServers": { "mcpServers": {
"fable": { "scribe": {
"type": "stdio", "type": "http",
"command": "fable-mcp", "url": "https://your-scribe-instance.example.com/mcp",
"env": { "headers": {
"FABLE_URL": "http://localhost:5000", "Authorization": "Bearer fmcp_your-api-key"
"FABLE_API_KEY": "your-dev-api-key"
} }
} }
} }
@@ -106,39 +67,33 @@ Add a `.mcp.json` at the project root (same format as the global config). Projec
Note: `.mcp.json` contains an API key and should be added to `.gitignore`. Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
### Claude Code (Global)
The same `mcpServers` block can live in `~/.claude.json` to make the server
available across all projects. A project-scoped `.mcp.json` takes precedence over
the global entry when both define the same server name — useful for pointing a
specific project at a dev instance or an admin key.
### Available Tools ### Available Tools
| Tool | Description | The tool surface is large (~70 tools) and evolves with the app, so the live
|------|-------------| registration in **`src/scribe/mcp/tools/`** is the source of truth rather than a
| `fable_list_notes` | List notes, filter by tag or search text | table here. The tools are grouped by family:
| `fable_get_note` | Fetch a note by ID |
| `fable_create_note` | Create a new note |
| `fable_update_note` | Update a note |
| `fable_delete_note` | Delete a note |
| `fable_list_tasks` | List tasks, filter by status or project |
| `fable_get_task` | Fetch a task by ID |
| `fable_create_task` | Create a new task |
| `fable_update_task` | Update a task |
| `fable_add_task_log` | Append a work log entry to a task |
| `fable_list_projects` | List all projects |
| `fable_get_project` | Fetch a project with milestone summary |
| `fable_create_project` | Create a project |
| `fable_update_project` | Update a project |
| `fable_list_milestones` | List milestones for a project |
| `fable_create_milestone` | Create a milestone |
| `fable_update_milestone` | Update a milestone |
| `fable_search` | Semantic search over notes and tasks |
| `fable_list_conversations` | List MCP chat conversations |
| `fable_send_message` | Send a message to Fable's LLM |
| `fable_get_app_logs` | Fetch application logs (admin key required) |
### Development Notes | Family | Examples | Purpose |
|--------|----------|---------|
| Notes | `create_note`, `get_note`, `update_note`, `delete_note`, `list_notes` | Free-form knowledge |
| Tasks | `create_task`, `update_task`, `add_task_log`, `start_planning` | Actionable work + plans |
| Projects / Milestones | `enter_project`, `get_project`, `create_milestone`, … | Containers and outcomes |
| Search / Recall | `search`, `get_recent`, `list_tags` | Semantic + structured recall |
| Typed entities | `create_person`, `create_place`, `create_list`, … | Structured records |
| Events | `create_event`, `list_events`, `update_event`, … | Calendar |
| Rulebooks | `list_always_on_rules`, `list_rules`, `create_rule`, `create_project_rule`, `subscribe_project_to_rulebook`, … | Engineering/workflow rules |
| Processes | `list_processes`, `get_process`, `create_process` | Saved prompts/workflows |
| Trash | `list_trash`, `restore`, `purge_trash` | Recoverable deletes |
| Admin | `get_app_logs` (write/admin key) | Diagnostics |
The `fable-mcp` package lives in `fable-mcp/` in this repository. The Docker build compiles it into a wheel at `/app/dist/` so it can be served for download without requiring the source tree at runtime. Server-level usage guidance — when to reach for each entity, the
recall-before-acting reflex, and the rulebook conventions — is delivered to the
To build the wheel locally: client automatically via the MCP server's `instructions` block (defined in
```bash `src/scribe/mcp/server.py`).
cd fable-mcp
pip install build hatchling
python -m build --wheel .
```
+17 -19
View File
@@ -145,23 +145,22 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` | | GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
## Briefing ## Journal
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| GET | `/api/briefing/config` | Get briefing configuration | | GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
| PUT | `/api/briefing/config` | Save briefing configuration | | PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
| GET | `/api/briefing/feeds` | List RSS feeds | | GET | `/api/journal/today` | Get/create today's journal conversation + messages |
| POST | `/api/briefing/feeds` | Add RSS feed `{url, name?, category?}` | | GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
| DELETE | `/api/briefing/feeds/:id` | Delete feed | | GET | `/api/journal/days` | List dates with journal content, newest first |
| POST | `/api/briefing/feeds/refresh` | Trigger immediate feed refresh → `{feeds_refreshed, new_items}` | | POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
| GET | `/api/briefing/weather` | Get weather configuration | | GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
| PUT | `/api/briefing/weather` | Save weather locations | | GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
| POST | `/api/briefing/weather/geocode` | Geocode address `{query}``{lat, lon, label}` | | POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
| POST | `/api/briefing/trigger` | Manually fire a briefing slot `{slot}` | | POST | `/api/journal/weather/geocode` | Geocode place name `{query}``{lat, lon, label}` |
| GET | `/api/briefing/conversations` | List past briefing conversations | | POST | `/api/journal/moments/:id/update` | Update a recorded moment |
| GET | `/api/briefing/conversations/today` | Get/create today's briefing conversation | | DELETE | `/api/journal/moments/:id` | Delete a moment |
| GET | `/api/briefing/conversations/:id/messages` | Get messages for a briefing conversation |
## Settings ## Settings
@@ -180,12 +179,11 @@ All endpoints require login (session cookie or `Authorization: Bearer <api-key>`
| POST | `/api/api-keys` | Create key `{name, scope}``{key, ...}` (key shown once) | | POST | `/api/api-keys` | Create key `{name, scope}``{key, ...}` (key shown once) |
| DELETE | `/api/api-keys/:id` | Revoke key | | DELETE | `/api/api-keys/:id` | Revoke key |
## Fable MCP Distribution ## Scribe MCP
| Method | Path | Description | The MCP tool surface is served at `POST /mcp` (streamable HTTP, Bearer auth) by
|--------|------|-------------| the in-app server in `src/scribe/mcp/`. It is not a REST surface — see
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` | [API Keys and Scribe MCP](api-keys-and-mcp.md) for client configuration.
| GET | `/api/fable-mcp/download` | Download wheel file |
## Notifications ## Notifications
+21 -24
View File
@@ -20,7 +20,7 @@
│ Docker Compose │ │ Docker Compose │
│ │ │ │
│ ┌──────────────────────┐ ┌────────────┐ │ │ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │ │ │ scribe │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │ │ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │ │ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │ │ │ │ ┌──────────┐ │ │ │ │ │
@@ -43,22 +43,20 @@
## Project Structure ## Project Structure
``` ```
fabledassistant/ scribe/
├── docker-compose.yml # Development stack ├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm) ├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python) ├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations ├── alembic/ # Database migrations
│ └── versions/ # Migration files (idempotent raw SQL) │ └── versions/ # Migration files (idempotent raw SQL)
├── fable-mcp/ # Fable MCP server package ├── src/scribe/
│ └── fable_mcp/
│ ├── server.py # FastMCP tool registrations
│ ├── client.py # FableClient (httpx wrapper)
│ └── tools/ # Tool modules (notes, tasks, projects, …)
├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration │ ├── app.py # Quart app factory + blueprint registration
│ ├── config.py # Config class (reads env vars) │ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks │ ├── auth.py # login_required decorator, session checks
│ ├── models/ # SQLAlchemy models │ ├── models/ # SQLAlchemy models
│ ├── mcp/ # In-app MCP server (FastMCP, mounted at /mcp)
│ │ ├── server.py # FastMCP instance + instructions + Quart mount
│ │ └── tools/ # Tool modules (notes, tasks, projects, rulebooks, …)
│ ├── routes/ # API blueprints (one file per resource) │ ├── routes/ # API blueprints (one file per resource)
│ ├── services/ # Business logic (access, llm, tools, sharing, …) │ ├── services/ # Business logic (access, llm, tools, sharing, …)
│ └── static/ # Built Vue SPA (generated at Docker build time) │ └── static/ # Built Vue SPA (generated at Docker build time)
@@ -133,11 +131,11 @@ Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
### Settings ### Settings
Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `briefing_enabled`, `briefing_locations`, `office_days`, etc. Composite PK `(user_id, key)`. Per-user key-value store. CRUD via `services/settings.py`. Used for: `default_model`, `assistant_name`, `journal_config` (JSON: locations, temp_unit, prep schedule), `user_timezone`, `voice_*`, etc.
### Conversations / Messages ### Conversations / Messages
`conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`briefing`/`mcp`), `briefing_date`, `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`. `conversations`: `id`, `title`, `model`, `user_id`, `conversation_type` (`chat`/`journal`/`mcp`), `day_date` (YYYY-MM-DD for journal conversations), `rag_project_id` (nullable int — RAG scope: NULL=orphan-only, -1=all, positive=project), `created_at`, `updated_at`.
`messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`. `messages`: `id`, `conversation_id` FK CASCADE, `role` (`user`/`assistant`), `content`, `status` (`done`/`generating`), `created_at`.
Title auto-generated by LLM on first exchange, re-generated every 10th message. Title auto-generated by LLM on first exchange, re-generated every 10th message.
@@ -155,11 +153,9 @@ Title auto-generated by LLM on first exchange, re-generated every 10th message.
Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission. Permission resolution is centralised in `services/access.py`. `get_project_permission(uid, project_id)` checks ownership → direct share → group-based share → note→project inheritance, returning the highest applicable permission.
### Briefing-Related Tables ### Journal-Related Tables
`rss_feeds`: `id`, `user_id`, `url`, `name`, `category`, `last_fetched_at`. `weather_cache`: per-user, per-`location_key` cache. Columns: `user_id`, `location_key` (`home`/`work`/etc.), `location_label`, `forecast_json` (Open-Meteo response), `previous_json` (last forecast, used to detect changes), `fetched_at`. Lat/lon are *not* stored on the cache row — they live in the user's `journal_config.locations.{home|work}` setting and are used at refresh time.
`rss_items`: `id`, `feed_id` FK, `guid`, `title`, `url`, `summary`, `pub_date`.
`weather_cache`: per-user cache with `lat`, `lon`, `location_name`, `forecast_json`, `fetched_at`.
### API Keys ### API Keys
@@ -171,7 +167,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
## Detailed File Reference ## Detailed File Reference
### Backend (`src/fabledassistant/`) ### Backend (`src/scribe/`)
| File | Responsibility | | File | Responsibility |
|------|---------------| |------|---------------|
@@ -188,14 +184,14 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `routes/api.py` | `/api` blueprint; `GET /api/health` (public) | | `routes/api.py` | `/api` blueprint; `GET /api/health` (public) |
| `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards | | `routes/auth.py` | Register, login, logout, me, password/email change, password reset, invite registration, OAuth login+callback; rate limiting; `LOCAL_AUTH_ENABLED` guards |
| `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) | | `routes/admin.py` | Backup, restore, user management, registration toggle, invitations, base URL, SMTP (admin only) |
| `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm; briefing conversation routes | | `routes/chat.py` | Conversations CRUD; SSE generation stream; model pull/delete/list/warm |
| `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts | | `routes/notes.py` | Notes CRUD + wikilinks + backlinks + assist + link suggestions + version history + drafts |
| `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) | | `routes/tasks.py` | Tasks CRUD; `POST` accepts `project` name string (resolved to `project_id`) |
| `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) | | `routes/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
| `routes/projects.py` | Projects CRUD + summary endpoint | | `routes/projects.py` | Projects CRUD + summary endpoint |
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` | | `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) | | `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
| `routes/briefing.py` | Briefing conversation + reply + history; RSS feed management | | `routes/journal.py` | Journal config CRUD; today/day/days conversation accessors; weather endpoints (cached/current/refresh/geocode); moments CRUD; trigger-prep |
| `routes/groups.py` | Group CRUD + membership management (admin) | | `routes/groups.py` | Group CRUD + membership management (admin) |
| `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares | | `routes/shares.py` | Share project/note with user or group; revoke; list incoming shares |
| `routes/in_app_notifications.py` | In-app notification list + mark-read + count | | `routes/in_app_notifications.py` | In-app notification list + mark-read + count |
@@ -204,7 +200,7 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `routes/images.py` | Serve cached images at `/api/images/<id>` | | `routes/images.py` | Serve cached images at `/api/images/<id>` |
| `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download | | `routes/export.py` | `GET /api/export` — personal Markdown ZIP or JSON array download |
| `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) | | `routes/api_keys.py` | API key CRUD (`GET/POST/DELETE /api/api-keys`) |
| `routes/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution | | `mcp/server.py` | Mounts the in-app FastMCP server at `/mcp` (streamable HTTP, Bearer auth) |
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation | | `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search | | `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens | | `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
@@ -227,10 +223,11 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/groups.py` | Group CRUD; membership management | | `services/groups.py` | Group CRUD; membership management |
| `services/notifications.py` | Create/read/mark-read in-app notifications | | `services/notifications.py` | Create/read/mark-read in-app notifications |
| `services/task_logs.py` | Append/list/delete work log entries on tasks | | `services/task_logs.py` | Append/list/delete work log entries on tasks |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → `GenerationBuffer` stream | | `services/journal_prep.py` | Deterministic data gather (tasks/events/weather/projects/recent moments/open threads) → LLM prose opener; persisted as the first assistant message of today's journal Conversation |
| `services/briefing_scheduler.py` | APScheduler `BackgroundScheduler`; slots with catch-up logic; async-safe via `asyncio.create_task` | | `services/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
| `services/briefing_conversations.py` | Briefing conversation persistence and history queries | | `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
| `services/briefing_profile.py` | Per-user profile note that the assistant updates over time | | `services/journal_search.py`, `services/moments.py` | Moment recording + search across journal history |
| `services/user_profile.py` | `build_profile_context()` consolidates profile + observations + learned_summary into a system-prompt block |
| `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes | | `services/research.py` | SearXNG research pipeline: sub-queries → parallel fetch → outline → section synthesis → executive summary → index note with linked section notes |
| `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools | | `services/events.py` | Internal events CRUD: `list_events`, `create_event`, `update_event`, `delete_event`, `get_event`; source of truth for all event LLM tools |
| `routes/events.py` | `/api/events` — event CRUD routes | | `routes/events.py` | `/api/events` — event CRUD routes |
@@ -293,8 +290,8 @@ Permission resolution is centralised in `services/access.py`. `get_project_permi
| `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management | | `services/generation_task.py` | SSE streaming, tool-call loop, GenerationBuffer management |
| `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry | | `services/tools/` | LLM tool implementations (38 tools across 11 modules); decorator-based registry |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` | | `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/briefing_pipeline.py` | Two-lane parallel gather → LLM synthesis → briefing output | | `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
| `services/briefing_scheduler.py` | APScheduler integration, catch-up logic for missed slots | | `services/journal_scheduler.py` | APScheduler integration, per-user prep job, catch-up for missed runs |
| `services/backup.py` | Full and per-user backup export/restore (version 2 format) | | `services/backup.py` | Full and per-user backup export/restore (version 2 format) |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache | | `services/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 | | `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
+2 -8
View File
@@ -8,7 +8,7 @@ Configuration is via environment variables. The `docker-compose.yml` file sets d
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string | | `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/scribe` | PostgreSQL async connection string |
| `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** | | `SECRET_KEY` | `dev-secret-change-me` | Session signing key — **change this in production** |
| `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) | | `SECRET_KEY_FILE` | — | Path to a Docker secret file containing the key (alternative to `SECRET_KEY`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) | | `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
@@ -58,12 +58,6 @@ See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning | | `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
| `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) | | `DATA_DIR` | `/data` | Root directory for persistent data (VAPID keys, backups) |
### Fable MCP Distribution
| Variable | Default | Description |
|----------|---------|-------------|
| `FABLE_MCP_DIST_DIR` | `/app/dist` | Directory where the bundled `fable-mcp` wheel is placed at build time |
## Docker Compose Setup ## Docker Compose Setup
### Development (`docker-compose.yml`) ### Development (`docker-compose.yml`)
@@ -91,7 +85,7 @@ The production compose file adds:
```bash ```bash
# Create Docker secrets # Create Docker secrets
echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key - echo "$(python3 -c 'import secrets; print(secrets.token_hex(32))')" | docker secret create fabled_secret_key -
echo "postgresql+asyncpg://fabled:strongpassword@db/fabledassistant" | docker secret create fabled_db_url - echo "postgresql+asyncpg://fabled:strongpassword@db/scribe" | docker secret create fabled_db_url -
# Deploy # Deploy
docker stack deploy -c docker-compose.prod.yml fabled docker stack deploy -c docker-compose.prod.yml fabled
+573
View File
@@ -0,0 +1,573 @@
# FabledSword Design System
A house-style design system for the FabledSword family of self-hosted applications. FabledSword is the umbrella identity; individual apps share a common visual language but each carries its own signature accent color.
## Brand model
FabledSword is a **house style**, not a single brand. Apps share:
- Typography
- Surfaces and dark-mode foundation
- Component shapes (pills, cards, buttons)
- Spacing system
- Semantic colors (success, warning, error, info)
- Action button colors (moss primary, bronze secondary)
- Voice and tone
Each public app has its own **signature accent** used for: the wordmark, the app icon, active nav state, "you are here" indicators, cursor/selection color, and key brand moments. Accents do **not** appear on action buttons — those stay system-wide.
## Aesthetic direction
Modern mythic with heraldic restraint. Tech-forward execution, but the visual language borrows from manuscripts, heraldry, and forged objects rather than from gaming or fantasy iconography. Dark-mode-first because that's where these apps live.
The reference points: a well-printed book, a well-kept armory, a steward's ledger. Not: a fantasy novel cover, a tabletop RPG character sheet, a Renaissance Faire poster.
---
## Color system
### Universal surfaces (dark mode foundation)
| Token | Hex | Usage |
|---|---|---|
| Obsidian | `#14171A` | Page background, deepest surface |
| Iron | `#1E2228` | Card surfaces, raised elements |
| Slate | `#2C313A` | Hovered surfaces, secondary elevation |
| Pewter | `#3F4651` | Borders, dividers, ghost button outlines |
### Universal text and parchment
| Token | Hex | Usage |
|---|---|---|
| Parchment | `#E8E4D8` | Primary text on dark surfaces |
| Vellum | `#C2BFB4` | Secondary text, captions |
| Ash | `#9C9A92` | Tertiary text, hints, metadata |
Parchment is intentionally not pure white — it's slightly warm to feel like aged paper. Pure white (#FFFFFF) is never used as text color.
### Universal action colors
| Token | Hex | Usage |
|---|---|---|
| Moss | `#4A5D3F` | Primary action buttons (Save, Submit, Confirm) |
| Bronze | `#8B7355` | Secondary action buttons (Cancel-but-not-destructive, alternate paths) |
| Pewter | `#3F4651` | Tertiary/ghost buttons |
**Critical rule:** Action button colors are universal across all apps. A Save button in Scribe and a Save button in Minstrel look identical. Per-app accents do not appear on buttons.
### Semantic colors
| Token | Hex | Usage |
|---|---|---|
| Success | `#4A5D3F` | Success states (same as Moss — they're aligned by design) |
| Warning | `#8B6F1E` | Warnings, caution states |
| Error | `#C04A1F` | Error messages, validation failures |
| Info | `#3D5A6E` | Informational callouts |
| Destructive | `#6B2118` | Destructive action buttons (Delete, Remove, irreversible) |
**Why error and destructive are different:** Error is the orange-red used in alerts and validation messages. Destructive (oxblood) is reserved for buttons that perform irreversible actions — it carries more weight precisely because it's used sparingly. Pair destructive buttons with an icon (trash, X) so color is reinforcement, not the only signal.
### Per-app signature accents
| App | Hex | Mood |
|---|---|---|
| Fabled Scribe | `#5B4A8A` | Dusty violet — ink, manuscripts |
| Minstrel | `#4A6B5C` | Forest teal — music, performance |
| Fabled Forge | `#8B5A2B` | Forge bronze — creation, craft |
| Roundtable | `#4A5D7E` | Slate blue — stewardship, infrastructure |
| FabledSword (umbrella) | `#6B2118` | Oxblood — house identity, ceremonial use only |
**Accent usage rules:**
- The accent appears on the app's wordmark and icon.
- The accent indicates active/current state in nav (the selected page, the active tab).
- The accent is the cursor color and text-selection color in long-form surfaces (Scribe notes, Forge story drafts).
- The accent does NOT appear on primary or secondary action buttons.
- The accent does NOT appear in body text or chrome.
- One accent per app. Don't mix accents within a single app.
### Color contrast
All text-on-surface combinations meet WCAG AA at minimum. Parchment on Obsidian is the maximum-contrast pairing; Vellum on Iron is the lowest-contrast pairing still considered acceptable for body text. Ash is for hints only — never load-bearing information.
---
## Typography
### Type families
| Family | Role | Source |
|---|---|---|
| Fraunces | Display, headings, wordmarks | Google Fonts |
| Inter | Body, UI, labels | Google Fonts |
| JetBrains Mono | Code, terminal output, monospaced data | Google Fonts |
### Why this pairing
Fraunces is a contemporary serif with personality — it has the warmth and authority of a book serif without feeling like costume. It signals "this is considered" without signaling "this is a fantasy product." Inter is the workhorse — neutral, ubiquitous, designed for screens, doesn't compete with the serif. JetBrains Mono is the natural choice for any developer-adjacent product and supports ligatures.
### Type scale
| Token | Size | Weight | Family | Usage |
|---|---|---|---|---|
| Display | 40px | 500 | Fraunces | App wordmark, hero text |
| H1 | 32px | 500 | Fraunces | Page titles |
| H2 | 24px | 500 | Fraunces | Section headings |
| H3 | 18px | 500 | Inter | Subsection headings, card titles |
| Body | 15px | 400 | Inter | Paragraphs, default text |
| Body small | 13px | 400 | Inter | Captions, metadata |
| Label | 12px | 500 | Inter | Buttons, form labels, badges |
| Code | 13px | 400 | JetBrains Mono | Inline code, code blocks |
| Tiny | 11px | 500 | Inter | Micro-labels (`UPPERCASE LETTERSPACED`) |
### Typography rules
- **Sentence case everywhere.** Never Title Case for headings, never ALL CAPS except for the Tiny micro-label style.
- **Two weights only:** 400 regular and 500 medium. Never 600 or 700 — they read heavy in dark mode.
- **Fraunces only at 18px and above.** Below that it loses too much detail and feels fragile. For h3 and below, use Inter.
- **Line height** 1.5 for body, 1.3 for headings, 1.7 for long-form reading surfaces (Scribe notes, Forge drafts).
- **Letter-spacing** at default for everything except the Tiny micro-label, which gets `0.08em` letter-spacing and uppercase styling.
---
## Spacing and layout
### Spacing scale (px)
`4, 8, 12, 16, 20, 24, 32, 48, 64, 96`
Use rem units for vertical rhythm in long-form content (paragraph spacing). Use px for component-internal spacing (padding, gaps).
### Border radius
| Token | Size | Usage |
|---|---|---|
| Small | 4px | Pills, tags, code spans |
| Medium | 8px | Buttons, inputs, small cards |
| Large | 12px | Cards, panels, modals |
| Extra large | 16px | Hero containers, major surfaces |
### Borders
- Default border: `0.5px solid Pewter` (#3F4651)
- Hovered/emphasized border: `0.5px solid Vellum` at 30% opacity
- Featured/active border: `2px solid [accent]` (only for emphasizing a selected card or active tab)
The 0.5px default is deliberate — it reads as a hairline at most pixel densities and avoids the heavy "boxed-in" feeling that 1px+ borders create on dark backgrounds.
---
## Components
### Buttons
| Variant | Background | Text | Border |
|---|---|---|---|
| Primary | Moss `#4A5D3F` | Parchment | None |
| Secondary | Bronze `#8B7355` | Parchment | None |
| Ghost | Transparent | Parchment | 0.5px Pewter |
| Destructive | Oxblood `#6B2118` | Parchment | None — pair with icon |
Padding: `8px 16px` for default, `6px 12px` for compact, `10px 20px` for prominent. Border-radius: 8px. Font: Inter 12px/500 with default letter-spacing.
### Pills and tags
Used for tags, hashtags, code spans, status badges. Background is the accent color at ~15% opacity, text is the accent at full strength. Border-radius 4px, padding `2px 8px`, Inter 11px/500.
In Scribe specifically, hashtags and tags use the dusty violet accent. In Minstrel, they'd use forest teal. The pattern is shared; the color follows the app.
### Cards
- Background: Iron (#1E2228)
- Border: 0.5px Pewter
- Border-radius: 12px
- Padding: 20px
For featured/selected cards, swap to a 2px solid accent border. Don't change the background.
### Inputs
- Background: Obsidian (#14171A) — darker than the page surface to feel "inset"
- Border: 0.5px Pewter
- Border-radius: 8px
- Padding: 8px 12px
- Focus state: 2px solid accent ring (using `box-shadow: 0 0 0 2px [accent]` to avoid layout shift)
### Code blocks
- Background: Obsidian (#14171A)
- Border: 0.5px Pewter
- Border-radius: 8px
- Padding: 12px 16px
- Font: JetBrains Mono 13px/400
- Inline code: same family, with 4px-radius pill background using the app accent at 15% opacity
---
## The FabledSword lockup
A small, persistent FS mark appears in the navigation chrome of every app — the way an Apple logo persists across macOS apps. This is the only place oxblood appears in normal app usage.
**Specification:**
- 16-20px height in nav contexts
- Oxblood (#6B2118) on dark surfaces
- Positioned in the bottom-left of nav rails or top-left when there's no rail
- Hover/click reveals a small menu: link to other apps in the family, link to FabledSword.com, version info
The lockup itself is a small heraldic mark — a stylized FS monogram — *not* a literal sword icon. We're avoiding sword imagery in app chrome because it would clash with the restrained, modern-mythic aesthetic. The wordmark "FabledSword" appears only on the umbrella site and in About/Settings dialogs.
---
## Voice and tone
The FabledSword voice is **understated mythic** — it borrows the register of stewardship, craft, and considered making, but never tips into roleplay or affectation.
### Do
- Use plain language for everything functional. ("Save", "Cancel", "Add note")
- Reserve flavored language for moments where the user is *waiting* or *failing* — loading states, empty states, error pages, 404s.
- Borrow vocabulary from craft and stewardship: "draft", "ledger", "kept", "set aside", "to come", "in progress", "abandoned".
- Be brief. The mythic register is undermined by verbosity.
### Don't
- Don't use thee/thou/thy or pseudo-archaic spelling.
- Don't address the user as "traveler", "wanderer", "adventurer", or any RPG-adjacent epithet.
- Don't use sword/blade/forge metaphors in error messages. ("Your save was forged successfully" — no.)
- Don't make the user feel like they're playing a game when they're just trying to use software.
### Examples
| Context | Plain | FabledSword voice |
|---|---|---|
| Empty list | "No items yet" | "Nothing kept here yet." |
| 404 | "Page not found" | "This page is not in the ledger." |
| Loading | "Loading..." | "Fetching..." (just keep it plain — the mythic note is reserved for moments with more space) |
| Save success | "Saved!" | "Saved." (plain — success doesn't need flavor) |
| Save error | "Error saving" | "Couldn't save. The change has been kept locally — try again in a moment." |
| Delete confirm | "Delete this?" | "Remove this from the ledger? This can't be undone." |
The pattern: action-adjacent language stays plain; absence/failure/waiting gets the flavor.
---
## Iconography
### Style
- Stroke-based, 1.5px stroke weight at 24px, 1px at 16px
- Rounded line caps and joins
- 24px or 16px grid
- Outline style by default; filled style only for active/selected states
Use **Lucide** (https://lucide.dev) as the base icon set — it matches this style exactly and is open-source. Only commission custom icons for app-specific concepts that Lucide doesn't cover.
### Don't
- No filled icons in default UI (reserve for active states)
- No icon styles that mix stroke and fill chaotically
- No literal medieval imagery (swords, scrolls with curls, banners) in functional UI
- No emoji as icons
---
## Per-app application
### Fabled Scribe (#5B4A8A — dusty violet)
A second-brain notes and task management tool. The accent appears in: the wordmark, hashtags and tag pills, the active nav item, text selection color, and the cursor in the editor. Notes are presented on Iron-surfaced cards with generous reading line-height. The hashtag system uses Scribe's accent for visual continuity.
### Minstrel (#4A6B5C — forest teal)
Self-hosted music. The accent appears on: now-playing indicators, active track highlights, the wordmark, equalizer/visualization elements. Album art dominates visually, so the accent should appear in chrome and metadata, never overlapping cover imagery.
### Fabled Forge (#8B5A2B — forge bronze)
Story-building and worldbuilding tool. The accent appears on: the wordmark, character/location/object markers in story trees, the editor cursor, "kept/canon" indicators distinguishing finalized story elements from drafts. This app benefits from Fraunces being used more aggressively — for entity titles, chapter headings, etc.
### Roundtable (#4A5D7E — slate blue)
Home server management. The accent appears on: the wordmark, healthy/online status indicators, the active dashboard panel border. Status colors here are critical — green for healthy, amber for warning, red (the orange-red error tone, not oxblood) for failed. The accent itself indicates "this is the panel I'm currently looking at."
**Naming note:** "Roundtable" leans the wrong direction — its connotation is *equal participants in discussion* rather than *one steward managing a domain*. Consider "Steward" or "Castellan" if you revisit naming. Castellan in particular is good — it specifically means "the officer in charge of a castle."
---
## Implementation notes
### CSS custom properties
```css
:root {
/* Surfaces */
--fs-obsidian: #14171A;
--fs-iron: #1E2228;
--fs-slate: #2C313A;
--fs-pewter: #3F4651;
/* Text */
--fs-parchment: #E8E4D8;
--fs-vellum: #C2BFB4;
--fs-ash: #9C9A92;
/* Action */
--fs-moss: #4A5D3F;
--fs-bronze: #8B7355;
/* Semantic */
--fs-warning: #8B6F1E;
--fs-error: #C04A1F;
--fs-info: #3D5A6E;
--fs-oxblood: #6B2118;
/* Typography */
--fs-font-display: 'Fraunces', Georgia, serif;
--fs-font-body: 'Inter', system-ui, sans-serif;
--fs-font-mono: 'JetBrains Mono', ui-monospace, monospace;
/* Layout */
--fs-radius-sm: 4px;
--fs-radius-md: 8px;
--fs-radius-lg: 12px;
--fs-radius-xl: 16px;
}
/* Per-app accent — set ONE of these on the root for each app */
[data-app="scribe"] { --fs-accent: #5B4A8A; }
[data-app="minstrel"] { --fs-accent: #4A6B5C; }
[data-app="forge"] { --fs-accent: #8B5A2B; }
[data-app="roundtable"]{ --fs-accent: #4A5D7E; }
```
### Tailwind integration
If using Tailwind, extend the theme with these tokens rather than relying on default colors. The default Tailwind palette will fight this system — you'll get drift back toward bright defaults if you don't lock down the palette explicitly.
---
## What this kit deliberately does NOT include
- **Logo files.** The lockup design is described conceptually but the actual mark needs to be drawn. Hire a designer or use Claude Design to iterate on a heraldic FS monogram.
- **Marketing site design.** This kit is for application UI. The umbrella marketing site (FabledSword.com) can use this system but will need additional patterns (hero layouts, feature grids, etc.).
- **Email templates.** Different constraints, different problem.
- **Print collateral.** Not in scope.
- **Mobile native app patterns.** This is web-first. iOS/Android conventions would override several choices here (button shapes, navigation patterns).
---
*Last updated: April 25, 2026. Iterate as the family of apps grows.*
---
# Scribe-specific decisions in progress
> This section tracks decisions made while adapting the FabledSword baseline above for Scribe specifically. Items here are *in progress* — once they feel solid, they get folded into the main body of the document (either as Scribe-specific extensions in the per-app section, or as updates to the universal rules where Scribe's needs reveal a gap in the baseline).
*Iteration started: 2026-04-26. Foundation pass shipped 2026-04-27 in `7a9a8b7` (palette, fonts, light mode, action tokens, hardcoded indigo cleanup, warm-gold deprecation). Surface phase shipped 2026-04-27 across `93a3beb` → `3c1ec40` (Lucide migration, Hybrid-rule button reclassification per surface, long-form line-height, two-weights-only). The system is now applied end-to-end; this section will fold into the main body once the result has had time to settle in real use.*
## Decisions made so far
### Accent footprint — Hybrid rule (not Strict)
The doc baseline says the per-app accent only appears on wordmark, active nav, cursor, and text selection — never on action buttons. Scribe currently uses indigo on essentially every interactive surface (CTAs, scrollbars, glows, borders, focus rings). Hard-cutting to the doc baseline would lose too much identity in one swing.
**Hybrid rule:** the accent reserves a slightly larger footprint than the doc baseline, but still much smaller than today.
- **Accent (dusty violet) lives on:** wordmark; active nav; cursor and text selection in editor surfaces; tags/pills/wikilinks; in-progress task badge; focus rings; **brand-moment CTAs** — chat Send, "Create note" empty-state CTA, journal Send, "Start journaling" empty-state.
- **Moss (sage-green primary) lives on:** Save / Submit / Confirm in forms and modals; generic affirmative actions where the button just means "do this thing" with no brand pretense.
- **Bronze (secondary):** Cancel-but-not-destructive, alternative paths.
- **Oxblood (destructive):** Delete / Remove (paired with an icon).
- **Pewter ghost:** tertiary actions, "later", "skip", "see also".
**Rule of thumb:** if the user is engaging with a *Scribe-feature moment* (sending a chat, opening a fresh note, jumping into the journal), accent. If they're just *operating the software* (saving an edit, confirming a dialog), Moss.
### Light mode — warm parchment, matched aesthetic
The doc is dark-only. Scribe today supports both light and dark, and we keep both. The light mode is derived to *match* the dark mode aesthetic rather than defaulting to system white-and-ink.
- Page background: in the `#F5F1E8` warm cream family (specific values TBD)
- Cards: near-white but slightly tinted
- Text: deep ink `#14171A` (mirroring Obsidian)
- Accent: same dusty violet `#5B4A8A` (works on both themes)
The metaphor stays consistent across themes: ink on aged paper (light) ↔ parchment text on graphite (dark). Light mode is *not* the system standard look.
**Known downside:** warm parchment backgrounds can fight with embedded color content. Mitigation: code blocks get a slight cool wash in light mode specifically, to keep syntax highlighting readable.
### Status and priority palette — extend the doc's semantic set
The doc's semantic colors (Success / Warning / Error / Info / Destructive) are leaner than what Scribe needs for task management. Rather than running a parallel palette, Scribe extends the doc by mapping its status/priority tokens onto doc primitives where they fit and defining new app-level tokens for the rest.
**Status (task lifecycle):**
| Token | Color | Source | Logic |
|---|---|---|---|
| `status-todo` | Pewter `#3F4651` | doc | Neutral, "not started yet" |
| `status-in-progress` | dusty violet `#5B4A8A` | accent | Active = brand moment per Hybrid |
| `status-done` | Moss `#4A5D3F` | doc Success | Affirmative completion |
| `status-cancelled` | Ash `#9C9A92` | doc | Faded, "let go" |
| `status-paused` | Warning `#8B6F1E` | doc | Stalled, needs attention — replaces the old warm gold treatment |
**Priority (loudness scale):**
| Token | Color | Source | Logic |
|---|---|---|---|
| `priority-low` | Info `#3D5A6E` | doc | Cool, FYI — quietest end of the spectrum |
| `priority-medium` | Warning `#8B6F1E` | doc | Golden, mid-attention |
| `priority-high` | Error `#C04A1F` | doc | Terracotta, urgent |
| `priority-none` | Vellum/Ash | doc | No signal |
The priority row reads as a clean cool→warm gradient (slate blue → golden brown → terracotta), which matches the semantic loudness — coherence the current ad-hoc palette doesn't have.
**Other functional tokens:**
| Token | Color | Logic |
|---|---|---|
| `wikilink` | dusty violet | Editorial brand moment per Hybrid |
| `overdue` | Error `#C04A1F` | Same as priority-high — overdue IS a priority signal |
| `toast-success` | Moss | doc semantic |
| `toast-error` | Error | doc semantic |
| `toast-info` | Info | doc semantic |
| `tag-bg` / `tag-text` | accent at 15% / accent | Per doc pill recipe |
Each token gets a `*-bg` companion at low alpha (matching the existing pattern in `theme.css`).
**Removed:** the warm gold accent (`--color-accent-warm: #b8860b`). Its two jobs split:
- Dates and timestamps (knowledge cards, event details, chat) → use `text-secondary` instead. Dates are metadata, not a brand surface; muted is the correct register.
- Paused project status → use the new `status-paused` (Warning `#8B6F1E`) row above. Same golden-brown family, semantically aligned.
### Typography — adopt the doc's stack and scale
Adopt the doc's type stack and scale verbatim, with one deferred verification (long-form line-height in practice).
- **Body font: Inter.** Replaces Scribe's current system-stack body font. Doc-defined; no Scribe-specific divergence.
- **Type scale:** as in the doc table — Display 40 / H1 32 / H2 24 / H3 18 / Body 15 / Body small 13 / Label 12 / Code 13 / Tiny 11.
- **Two weights only:** 400 regular, 500 medium. No 600/700 (reads heavy in dark mode and against the muted palette).
- **Family rules:** Fraunces at 18px+ only (Display, H1, H2). H3 and below = Inter. Code = JetBrains Mono.
- **Line height:** 1.5 body, 1.3 headings, **1.7 for long-form reading surfaces** (notes, journal entries).
- **Sentence case for everything**, except the Tiny micro-label style which gets uppercase + 0.08em letter-spacing.
Mechanical rollout — value swaps in `theme.css` plus loading Inter and JetBrains Mono from Google Fonts (Fraunces is already loaded).
### Light mode — concrete palette
Fills in the warm-parchment direction picked earlier. Treat these as starting values; tune in practice.
| Token | Hex | Role |
|---|---|---|
| Page bg | `#F5F1E8` | Warm cream — the "paper" |
| Card bg | `#FBF8F0` | Near-white, slightly warm — raised surfaces |
| Inset bg (inputs, code) | `#EFEAE0` | Slightly darker than page, "inset" feeling |
| Text primary | `#14171A` | Deep ink — Obsidian inverted |
| Text secondary | `#5A5852` | Warm mid-grey |
| Text muted | `#9A9890` | Warm light grey for hints/metadata |
| Border default | `#D9D6CE` | Warm light pewter, hairline weight |
**Code-block exception:** in light mode specifically, code blocks use a slight cool wash (e.g. `#EBEDF0`) instead of the warm inset bg, so syntax highlighting reads cleanly. This is the mitigation for the "warm bg fights colored content" downside.
The accent (`#5B4A8A` dusty violet), Moss, Bronze, Oxblood, and the semantic color set are **identical across themes** — only the surface and text palettes flip.
### Chat-bubble codification — keep the Illuminated Transcript pattern
The existing chat-bubble pattern (informally called "Illuminated Transcript") gets written into the design system as a documented chat component. Other apps in the family that add a chat surface inherit the pattern; Scribe's existing implementation continues to work with only color shifts.
**User bubble (whisper):**
- Background: transparent
- Border: 0.5px Pewter (was: indigo-tinted)
- Text color: secondary (Vellum dark / `#5A5852` light)
- Right-aligned, rounded except bottom-right (subtle "from-me" tail)
**Assistant bubble (lit):**
- Background: card surface (Iron dark / `#FBF8F0` light)
- Border: none on top/right/bottom; **2px solid accent (dusty violet) on left edge only**
- Box-shadow: accent-tinted glow + standard depth shadow (formula: `0 4px 28px rgba(<accent>, 0.14), 0 2px 8px rgba(0,0,0,0.4)` in dark; lower alphas in light)
- Text color: primary (Parchment dark / Obsidian-inverted light)
- Left-aligned, rounded except bottom-left
The 2px-accent left edge is the "illumination" — like an illuminated capital in a manuscript. The shadow is the lift. Together they make the assistant bubble read as the *primary* voice, while the user bubble is the *margin note*.
**Inline tool-call cards (`ToolCallCard`)** rendered inside an assistant bubble do NOT get their own border (per the border philosophy — the bubble already contains them). They use a slight surface tint to differentiate.
### Iconography — adopt Lucide, enforce a scale
Scribe currently hand-inlines SVG paths in 16+ Vue files, with 5 different stroke weights and 8+ different sizes. The visual style is already outline + rounded caps + `currentColor` stroke (matches the doc's intent), but there's no shared source and no scale discipline.
**Migration policy:**
1. **Install `lucide-vue-next`** as the icon source. Replace hand-inlined SVGs with imported components. Single source of truth.
2. **Strict size scale: 16px and 24px only.** Today's mix of 12/13/14/15/17/18/20 collapses to those two. 16 for inline-with-text and small affordances; 24 for nav and primary actions.
3. **Stroke weight per the doc: 1.5 at 24px, 1 at 16px.** Lighter than the current default of 2 — reads more refined, matches the muted palette philosophy. Overrides Lucide's default.
4. **Outline by default; filled only for active/selected state.** Introduces a new affordance Scribe doesn't currently use — bookmark/pin/star icons can switch outline → filled to indicate active state. Reserve filled style strictly for this.
5. **No emoji in chrome.** Replace the 3 files' emoji usage in UI labels/buttons/badges/empty states with Lucide equivalents. Emoji remain fine in *user content* (note bodies, chat messages the user typed).
Work cost: ~30-60 individual icon swaps across the 16 files. Mechanical; doesn't require redesign of any component.
### Voice and tone — adopt principles, defer formal audit
The doc's voice register applies to Scribe (understated mythic — plain for functional UI, flavored for empty/error/loading states). No formal sweep of every UI string yet.
**Approach:** apply the voice opportunistically as components are touched in the polish pass — when redesigning a settings tab, an empty state, or an error toast, rewrite the copy at the same time using the doc's register and examples table as the guide. A standalone audit pass is deferred unless drift becomes visible.
### Border philosophy — structural, not decorative
The doc treats borders as *structural* (Pewter neutral hairlines that say "boundary"), not decorative (Scribe today uses indigo-tinted borders that say "branded edge"). That principle suggests removing borders in places where surface tint and spacing already communicate separation.
**Borders to remove:**
- List rows (NotesListView, TasksListView, conversation history) — surface contrast + spacing should separate rows; current border reads as "boxed-in"
- Inline `ToolCallCard` inside chat bubbles — the bubble is already a container; an extra border feels like double-wrapping
- Filter chips and search-bar pills with a background tint — background does the work
- Empty-state callouts with dashed/bordered "nothing here yet" boxes — tinted background reads cleaner
**Borders to keep (genuinely structural):**
- Standalone card containers (Notes viewer, Task viewer, the new daily prep card)
- Modal / dialog edges
- Code blocks (separates content type, not just space)
- Focus rings (accessibility)
- Major section dividers within a panel
Border weight is not load-bearing for Scribe — happy to use the doc's 0.5px hairline default; the *placement* discipline matters more than the weight.
## Open threads (next iterations)
### Foundation pass — shipped 2026-04-27 (`7a9a8b7`)
Mechanical token + font + light-mode rewrite of `frontend/src/assets/theme.css`, plus a sweep of hardcoded indigo and `--color-accent-warm` references across ~14 component files. Action tokens (`--color-action-primary` Moss, `--color-action-secondary` Bronze, `--color-action-destructive` Oxblood, `--color-action-ghost-border` Pewter) are defined but not yet applied — buttons still flow through `--color-primary` and read as dusty-violet gradients in the meantime, by design. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-foundation-design.md` (gitignored, local-only).
### Surface phase — shipped 2026-04-27 (`93a3beb` → `3c1ec40`)
Bundled as Hybrid (option C from the brainstorm): Lucide cross-cutting first, then surface-by-surface for the judgment work. Spec lives at `docs/superpowers/specs/2026-04-27-design-system-polish-surface-design.md` (gitignored, local-only). Seven PRs landed on `dev`:
| PR | Commit | Surface | Notes |
|---|---|---|---|
| 1 | `93a3beb` | Lucide cross-cutting | 60 hand-inlined SVGs across 15 files → `lucide-vue-next`. Every chrome icon at 16 or 24. Emoji-as-icons (`✕`, `✓`, `🎤`, `📎`, `↻`, `↑`, `&times;`, `☀`/`☾`) swept across the chrome. AppLogo wordmark and the GraphView D3 mount kept as the legitimate exceptions. |
| 2 | `3d916d7` | Journal | Buttons audited; `↻` weather refresh → Lucide `RotateCcw`; assistant bubble line-height bumped; redundant `.journal-title` font-family dropped; dead `.news-section` CSS removed. |
| 3 | `4192a64` | Chat | `.message-content` long-form 1.7 line-height on assistant bubbles; ToolCallCard outer border removed (bubble already contains it; error state moved to a left-edge accent); bulk-delete recolored to Oxblood with Trash2 icon; `.bulk-link` accent → muted text. |
| 4 | `efb3534` | Knowledge cluster | `.prose` line-height bumped to 1.7 globally so all reading surfaces inherit. Save → Moss; Delete → Oxblood + Trash2; Edit / Advance → Moss; Convert / Share → Bronze. |
| 5 | `ff498ce` | Project + Workspace | Project save panel → Moss; milestone confirm/cancel → Moss/Bronze; modal-btn-danger and per-row delete affordances → Oxblood; "Open Workspace" stays accent (project-surface brand moment). |
| 6 | `541e2ed` | Settings | Densest button surface — every `.btn-save`, `.btn-primary`, `.btn-secondary`, `.btn-danger`, `.btn-danger-outline`, `.btn-toggle-open` reclassified per Hybrid; missing `.btn-danger` style block added (was unstyled before). |
| 7 | `3c1ec40` | Edge surfaces | Calendar `New Event` → Moss; EventSlideOver Save/Cancel/Delete reclassified; HomeView hero CTA stays accent (brand moment); two-weights-only sweep across Header/Home/Calendar/Graph. |
**Cross-cutting changes folded in as the work touched files:**
- Long-form 1.7 line-height on `.prose` (PR 4) — applies to Note viewer, Task viewer, chat assistant bubbles, anywhere markdown renders into a reading surface.
- Two-weights-only (400 + 500) — every `font-weight: 600` and `700` snapped to `500` across all surface PRs.
- Hardcoded `--color-danger` in destructive button contexts → `--color-action-destructive` (Oxblood). `--color-danger` (Error terracotta) preserved for validation/error messages, per the doc's distinction between Error and Destructive.
- Adjacent `&times;` / `✕` / unicode-arrow emoji swept opportunistically as files were touched (PRs 5, 7).
### Out of scope — deferred indefinitely
Items deliberately not addressed in this round; revisit when a real need surfaces:
- Lucide stroke-weight overrides (doc spec: 1.5 at 24, 1 at 16; current: Lucide default 2). Touched components if they read too heavy in practice.
- Filled-as-active icon state — no current affordance uses it; introduce when bookmark/pin/star toggles are added.
- Type-scale / spacing-scale CSS variables — components keep literal values.
- Token rename to `--fs-*` namespace — Scribe is the only FabledSword app sharing this codebase.
- FabledSword lockup placement — waiting on the actual heraldic mark to be drawn.
- Standalone voice/tone audit across every UI string — opportunistic-only; full sweep deferred unless drift becomes visible.
- A handful of editor utility buttons (`.btn-suggest-tags`, `.btn-link-all`, AI assist generate/proofread/accept/reject set, etc.) — currently ghost-styled and visually compliant; revisited only if they read off in practice.
### Open threads
*New threads will accumulate here as gaps surface in real use.*
+16 -10
View File
@@ -57,7 +57,9 @@ Migration conventions:
### Pipeline ### Pipeline
CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`): CI runs on Forgejo Actions, consuming the shared
[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
| Trigger | Jobs | Docker tags pushed | | Trigger | Jobs | Docker tags pushed |
|---------|------|--------------------| |---------|------|--------------------|
@@ -77,17 +79,21 @@ CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
git checkout dev && git merge main && git push origin dev git checkout dev && git merge main && git push origin dev
``` ```
### Custom Runner ### Runner
Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS). CI jobs schedule against the `python-ci` runner label and run inside the
Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`). shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
Runner compose: `infra/runner-compose.yml`. `ci-requirements.md` for what this project relies on from the image).
The runner deployment lives outside this repo; image bumps happen in
[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
To activate a new runner registration, copy `infra/act-runner-config.yml` to the runner's config directory, delete the `.runner` registration file in the runner container, and restart the stack. `infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
runner-host deployment shape; the source of truth is the deployed
config on the runner host.
### Docker Registry ### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant` Images pushed to: `git.fabledsword.com/bvandeusen/scribe`
Cache tag: `:cache` (reduces build time ~80%) Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions): Required secrets (repo → Settings → Secrets → Actions):
@@ -134,8 +140,8 @@ Current migration sequence (all idempotent raw SQL):
### Backend ### Backend
- Services: `async with async_session() as session:` — import from `fabledassistant.models` - Services: `async with async_session() as session:` — import from `scribe.models`
- No `fabledassistant.database` module - No `scribe.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc. - Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers - All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes - Permission checks via `services/access.py` — never inline ownership checks in routes
@@ -158,7 +164,7 @@ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
``` ```
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test` Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`) Scopes: feature area (e.g. `chat`, `journal`, `mcp`, `notes`)
## Testing ## Testing
+12 -11
View File
@@ -52,19 +52,22 @@ Full conversation history with SSE streaming. Features:
- **Bulk delete** — Select and delete multiple conversations. - **Bulk delete** — Select and delete multiple conversations.
- **Retention** — Conversations auto-pruned after configurable days (default 90). - **Retention** — Conversations auto-pruned after configurable days (default 90).
## Daily Briefing ## Daily Journal
`/briefing` is a scheduled, dialogue-based morning briefing. The assistant compiles tasks, calendar events, projects, weather forecast, and RSS digest at configurable times, then checks in throughout the day. You can reply interactively. `/journal` is a conversational daily surface — each day is a chat-style conversation seeded with an LLM-generated daily prep as the first assistant message. The prep pulls together today's tasks, calendar events, weather, recent moments, and active projects in flowing prose, then invites the user to continue the conversation throughout the day.
**Schedule**Configurable slots: morning (default 4am compile), midday (8am check-in), evening (12pm check-in), night (4pm). Scheduler catches up missed slots on startup. **Schedule**Daily prep generates at a configurable time (default 5:00am). The "day rollover hour" controls when the journal switches to a new day (default 4am — late-night entries 13am still count as the previous day). Scheduler catches up missed runs on startup.
**Configuration** — Settings → Briefing: enable toggle, location geocoding, office days, time slot toggles, RSS feed management, push notification toggle. **Right rail** — The journal view shows current weather conditions and upcoming events for the next two weeks alongside the conversation. Both surfaces draw from the same data the prep references.
**RSS feeds** — Add feed URLs with optional name and category. Feeds are fetched and cached; the briefing digest includes recent items. Category badges shown in the UI. Feeds can be manually refreshed. **Configuration** — Settings → Profile:
- *Locations* section: home and work place-name inputs (geocoded on blur via Nominatim) and a temperature unit toggle (C/F)
- *Journal* section: prep auto-generate toggle, prep generation time, day rollover hour
- *About You* / *Interests* / *Work Schedule* / *Response Preferences* feed personalization into the prep's system prompt
**Weather** — Location-based forecast via Open-Meteo. Multiple locations supported (home, work, or any city name). Geocoding via Nominatim. **Weather** — Location-based forecast via Open-Meteo. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
**Profile note** — The assistant maintains a profile note for each user that it updates based on briefing conversations, improving personalisation over time. **What the assistant has learned** — The assistant maintains a per-user observation log + consolidated summary, generated from journal and chat conversations. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you over time.
## Web Research ## Web Research
@@ -115,10 +118,10 @@ Settings are tabbed:
|-----|----------| |-----|----------|
| General | Assistant name, default model, model management (pull/delete) | | General | Assistant name, default model, model management (pull/delete) |
| Account | Email change, password change, session invalidation | | Account | Email change, password change, session invalidation |
| Notifications | Push notification subscription, briefing push toggle | | Notifications | Push notification subscription, journal prep push toggle |
| Profile | About you, response preferences, interests, work schedule, locations + temperature unit, journal prep schedule, learned observations |
| Integrations | CalDAV configuration, SearXNG status | | Integrations | CalDAV configuration, SearXNG status |
| Data | Personal export, backup/restore (admin) | | Data | Personal export, backup/restore (admin) |
| Briefing | Enable, location, office days, slots, RSS feeds, weather |
| API Keys | Create/revoke API keys, Fable MCP download and install | | API Keys | Create/revoke API keys, Fable MCP download and install |
| Config (admin) | Base URL, SMTP, OIDC settings | | Config (admin) | Base URL, SMTP, OIDC settings |
| Users (admin) | User list, invite links, registration toggle | | Users (admin) | User list, invite links, registration toggle |
@@ -129,8 +132,6 @@ Settings are tabbed:
- Email integration (read/send via IMAP/SMTP tools in chat) - Email integration (read/send via IMAP/SMTP tools in chat)
- Session invalidation on user deletion - Session invalidation on user deletion
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
- Flutter milestone support in project view
## Keyboard Shortcuts ## Keyboard Shortcuts
File diff suppressed because it is too large Load Diff
@@ -1,594 +0,0 @@
# Streaming TTS Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Start playing TTS audio during LLM generation by splitting responses into sentences and synthesizing each sentence as it completes, rather than waiting for the full response.
**Architecture:** A new `useStreamingTts` composable watches `streamingContent` for sentence boundaries, fires per-sentence `synthesiseSpeech` requests concurrently, and plays audio in strict insertion order using `useVoiceAudio`. ChatView, BriefingView, and WorkspaceView all use this composable, replacing their current post-stream speak logic.
**Tech Stack:** Vue 3 Composition API, TypeScript, `useVoiceAudio` (existing), `synthesiseSpeech` from `api/client.ts` (existing), no backend changes.
---
## File Map
| Action | File | Responsibility |
|--------|------|----------------|
| **Create** | `frontend/src/composables/useStreamingTts.ts` | All streaming TTS logic: sentence splitting, TTS queuing, ordered playback |
| **Modify** | `frontend/src/views/ChatView.vue` | Replace `speakLastAssistantMessage` + old watch with `useStreamingTts` |
| **Modify** | `frontend/src/views/BriefingView.vue` | Replace `speakText` + `listenToLatest` + old watch with `useStreamingTts` |
| **Modify** | `frontend/src/views/WorkspaceView.vue` | Add listen mode toggle button + `useStreamingTts` |
---
## Task 1: Create `useStreamingTts` composable
**Files:**
- Create: `frontend/src/composables/useStreamingTts.ts`
- [ ] **Step 1: Create the composable**
Create `frontend/src/composables/useStreamingTts.ts` with the full implementation:
```typescript
import { ref, watch, computed } from 'vue'
import type { Ref, ComputedRef } from 'vue'
import { synthesiseSpeech } from '@/api/client'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
/** Minimum stripped character count to bother synthesizing. */
const MIN_CHARS = 3
/** Matches sentence-terminal punctuation followed by whitespace or end-of-string. */
const SENTENCE_BOUNDARY = /[.!?]+(?=\s|$)/
function stripMarkdown(text: string): string {
return text
.replace(/```[\s\S]*?```/g, '')
.replace(/`[^`]+`/g, (m) => m.slice(1, -1))
.replace(/#{1,6}\s+/g, '')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/^\s*[-*+]\s+/gm, '')
.replace(/\n{2,}/g, ' ')
.trim()
}
/**
* Extract completed sentences from `text` using SENTENCE_BOUNDARY.
* Returns the sentences found and the unconsumed remainder.
*/
function extractSentences(text: string): { sentences: string[]; remainder: string } {
const sentences: string[] = []
let remaining = text
let match: RegExpExecArray | null
while ((match = SENTENCE_BOUNDARY.exec(remaining)) !== null) {
const boundary = match.index + match[0].length
const sentence = remaining.slice(0, boundary).trim()
if (sentence) sentences.push(sentence)
remaining = remaining.slice(boundary)
}
return { sentences, remainder: remaining }
}
export interface UseStreamingTtsOptions {
streamingContent: Ref<string> | ComputedRef<string>
streaming: Ref<boolean> | ComputedRef<boolean>
enabled: Ref<boolean> | ComputedRef<boolean>
}
export interface UseStreamingTtsReturn {
/** True while any synthesis request is in-flight or audio is playing. */
speaking: ComputedRef<boolean>
/** Cancel all in-flight synthesis/playback and clear the queue. */
stop: () => void
}
export function useStreamingTts(options: UseStreamingTtsOptions): UseStreamingTtsReturn {
const { streamingContent, streaming, enabled } = options
const audio = useVoiceAudio()
let sentenceBuffer = ''
let lastSeenLength = 0
let abortId = 0
let playQueue: Promise<void> = Promise.resolve()
const pendingCount = ref(0)
const speaking = computed(() => pendingCount.value > 0 || audio.playing.value)
function stop(): void {
abortId++
sentenceBuffer = ''
lastSeenLength = 0
playQueue = Promise.resolve()
audio.stop()
pendingCount.value = 0
}
async function enqueueSentence(sentence: string, myAbortId: number): Promise<void> {
const stripped = stripMarkdown(sentence)
if (stripped.length < MIN_CHARS) return
pendingCount.value++
let blob: Blob | null = null
try {
blob = await synthesiseSpeech(stripped)
} catch (e) {
console.warn('[StreamingTTS] Synthesis failed, retrying sentence', { sentence: stripped, error: e })
try {
blob = await synthesiseSpeech(stripped)
} catch (e2) {
console.warn('[StreamingTTS] Retry also failed, skipping sentence', { sentence: stripped, error: e2 })
}
} finally {
pendingCount.value--
}
if (!blob) return
// Capture blob for the closure — TS can't narrow after async gap
const resolvedBlob = blob
playQueue = playQueue.then(async () => {
if (abortId !== myAbortId) return
await audio.play(resolvedBlob)
})
}
function dispatchBuffer(flush: boolean): void {
if (!enabled.value) return
const myAbortId = abortId
const { sentences, remainder } = extractSentences(sentenceBuffer)
sentenceBuffer = flush ? '' : remainder
for (const sentence of sentences) {
enqueueSentence(sentence, myAbortId)
}
if (flush && remainder.trim().length >= MIN_CHARS) {
enqueueSentence(remainder.trim(), myAbortId)
}
}
// Watch accumulating content — extract new characters since last check
watch(streamingContent, (newContent) => {
if (!enabled.value) return
const delta = newContent.slice(lastSeenLength)
lastSeenLength = newContent.length
sentenceBuffer += delta
dispatchBuffer(false)
})
// Watch streaming flag — stop on new message start, flush on end
watch(streaming, (isStreaming) => {
if (!enabled.value) return
if (isStreaming) {
// New message starting — cancel previous response's audio
stop()
} else {
// Stream ended — flush any remaining fragment
dispatchBuffer(true)
lastSeenLength = 0
}
})
return { speaking, stop }
}
```
- [ ] **Step 2: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors mentioning `useStreamingTts.ts`.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/composables/useStreamingTts.ts
git commit -m "feat(tts): add useStreamingTts composable for sentence-level streaming"
```
---
## Task 2: Update ChatView
**Files:**
- Modify: `frontend/src/views/ChatView.vue`
Current TTS code to remove (lines ~3566):
```typescript
// REMOVE these:
const synthesising = ref(false);
async function speakLastAssistantMessage() { ... } // entire function
watch(() => store.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200));
await speakLastAssistantMessage();
}
});
```
Also remove the `synthesiseSpeech` import from `@/api/client` (it is no longer called directly in this file).
- [ ] **Step 1: Add import and replace TTS logic**
In `frontend/src/views/ChatView.vue`:
1. Add to imports at the top of `<script setup>`:
```typescript
import { useStreamingTts } from "@/composables/useStreamingTts";
```
2. Remove `synthesiseSpeech` from the `@/api/client` import line (keep other imports like `apiGet`, `transcribeAudio`).
3. Remove `const synthesising = ref(false);` (line ~35).
4. Remove the entire `speakLastAssistantMessage` function (lines ~3859).
5. Remove the `watch(() => store.streaming, ...)` block that called `speakLastAssistantMessage` (lines ~6166).
6. Add after `const listenMode = useListenMode();`:
```typescript
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
});
```
- [ ] **Step 2: Update template references**
In the ChatView template, replace every occurrence of `synthesising` with `tts.speaking.value`:
Find (line ~919):
```html
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': synthesising || audio.playing.value }"
```
Replace with:
```html
:class="{ 'btn-listen--active': listenMode, 'btn-listen--busy': tts.speaking.value }"
```
Find (line ~920):
```html
@click="listenMode = !listenMode; if (listenMode) speakLastAssistantMessage()"
```
Replace with:
```html
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
```
Find (line ~924):
```html
<svg v-if="!synthesising && !audio.playing.value" ...>
```
Replace with:
```html
<svg v-if="!tts.speaking.value" ...>
```
Note: the `audio` variable (`useVoiceAudio()`) is still used for the volume slider and PTT stop — do NOT remove it.
- [ ] **Step 3: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/ChatView.vue
git commit -m "feat(tts): wire useStreamingTts into ChatView"
```
---
## Task 3: Update BriefingView
**Files:**
- Modify: `frontend/src/views/BriefingView.vue`
Current TTS code to remove:
```typescript
// REMOVE:
const synthesising = ref(false)
async function speakText(text: string) { ... } // entire function
async function listenToLatest() { ... } // entire function
// REMOVE this watch block (the TTS one — keep the other streaming watch):
watch(() => chatStore.streaming, async (streaming) => {
if (!streaming && listenMode.value && voiceTtsEnabled.value) {
await new Promise((r) => setTimeout(r, 200))
await listenToLatest()
}
})
```
Note: BriefingView has **two** `watch(() => chatStore.streaming, ...)` blocks. Keep the first one (lines ~152156, which refreshes messages). Remove only the TTS one (lines ~327332).
Also remove the `synthesiseSpeech` import from `@/api/client`.
- [ ] **Step 1: Add import and replace TTS logic**
In `frontend/src/views/BriefingView.vue`:
1. Add to imports:
```typescript
import { useStreamingTts } from '@/composables/useStreamingTts'
```
2. Remove `synthesiseSpeech` from the `@/api/client` import line.
3. Remove `const synthesising = ref(false)`.
4. Remove the entire `speakText` function.
5. Remove the entire `listenToLatest` function.
6. Remove the TTS `watch(() => chatStore.streaming, ...)` block (the one that calls `listenToLatest`).
7. Add after `const listenMode = useListenMode()`:
```typescript
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
- [ ] **Step 2: Update template references**
Find the listen toggle button in the template. Replace `synthesising` references:
```html
<!-- Before -->
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': synthesising || audio.playing.value }"
@click="listenMode ? (listenMode = false) : (listenMode = true, listenToLatest())"
<!-- After -->
:class="{ 'btn-icon-active': listenMode, 'btn-icon-busy': tts.speaking.value }"
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
```
Find the stop button:
```html
<!-- Before -->
v-if="voiceTtsEnabled && (synthesising || audio.playing.value)"
@click="audio.stop(); synthesising = false"
<!-- After -->
v-if="voiceTtsEnabled && tts.speaking.value"
@click="tts.stop()"
```
Find the spinner SVG condition:
```html
<!-- Before -->
<svg v-if="!synthesising && !audio.playing.value" ...>
<!-- After -->
<svg v-if="!tts.speaking.value" ...>
```
- [ ] **Step 3: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/BriefingView.vue
git commit -m "feat(tts): wire useStreamingTts into BriefingView"
```
---
## Task 4: Add streaming TTS to WorkspaceView
**Files:**
- Modify: `frontend/src/views/WorkspaceView.vue`
WorkspaceView has no TTS today. We add: listen mode toggle, `useStreamingTts`, and the listen button in the chat input toolbar.
- [ ] **Step 1: Add imports and composable**
In `frontend/src/views/WorkspaceView.vue`, add to the import block at the top of `<script setup>`:
```typescript
import { useListenMode } from '@/composables/useListenMode'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio } from '@/composables/useVoiceAudio'
```
After the existing store setup code (after `const settingsStore = useSettingsStore()`), add:
```typescript
const listenMode = useListenMode()
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
const audio = useVoiceAudio()
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
- [ ] **Step 2: Add listen mode button to template**
In the `<div class="chat-input-area">` section (around line 365), add the listen button before the abort/send button:
```html
<div class="chat-input-area">
<textarea
ref="inputEl"
v-model="messageInput"
class="chat-input"
:placeholder="chatStore.streaming ? 'Type to queue next message… (Enter to queue)' : 'Message the agent… (Enter to send)'"
rows="1"
@keydown="onInputKeydown"
@input="autoResize"
></textarea>
<!-- Listen mode toggle (TTS) -->
<button
v-if="voiceTtsEnabled"
class="btn-listen-ws"
:class="{ 'btn-listen-ws--active': listenMode, 'btn-listen-ws--busy': tts.speaking.value }"
:title="listenMode ? 'Stop auto-read' : 'Read responses aloud'"
aria-label="Toggle listen mode"
@click="listenMode = !listenMode; if (!listenMode) tts.stop()"
>
<svg v-if="!tts.speaking.value" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C21.8 14.82 22 13.43 22 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</button>
<button
v-if="chatStore.streaming"
class="btn-abort"
title="Stop generation"
@click="chatStore.cancelGeneration()"
>
■ Stop
</button>
<button
v-else
class="btn-send"
:disabled="!messageInput.trim()"
@click="sendMessage"
>
Send
</button>
</div>
```
- [ ] **Step 3: Add CSS for the listen button**
In the `<style>` block, add:
```css
.btn-listen-ws {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.btn-listen-ws:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
.btn-listen-ws--active {
color: var(--color-primary);
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.btn-listen-ws--busy {
color: var(--color-primary);
animation: pulse 1.2s ease-in-out infinite;
}
```
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1 | head -40
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add frontend/src/views/WorkspaceView.vue
git commit -m "feat(tts): add streaming TTS listen mode to WorkspaceView"
```
---
## Task 5: Final integration check and push
- [ ] **Step 1: Full TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend
npx vue-tsc --noEmit 2>&1
```
Expected: zero errors.
- [ ] **Step 2: Verify no dead imports remain**
```bash
grep -n "synthesiseSpeech\|speakLastAssistantMessage\|speakText\|listenToLatest\|synthesising" \
frontend/src/views/ChatView.vue \
frontend/src/views/BriefingView.vue \
frontend/src/views/WorkspaceView.vue
```
Expected: no matches (all replaced).
- [ ] **Step 3: Manual smoke test**
1. Enable voice in Admin → Config
2. Open Chat, enable listen mode (speaker icon)
3. Send a message and watch: audio should begin playing the first sentence while the LLM is still streaming the response
4. Send another message mid-playback — previous audio should stop immediately
5. Toggle listen mode off mid-response — audio stops, `tts.stop()` called
6. Repeat in `/briefing` and `/workspace/:id`
- [ ] **Step 4: Push**
```bash
git push origin dev
```
---
## Self-Review
**Spec coverage check:**
- ✅ Starts playing during generation (sentence-level queue, fires on each boundary)
- ✅ Automatic when listen mode on (enabled computed = listenMode && voiceTtsEnabled)
- ✅ ChatView updated
- ✅ BriefingView updated
- ✅ WorkspaceView added
- ✅ One retry before skipping on failure
- ✅ Failures logged via `console.warn` with sentence text and error
-`stop()` on new message start (watch streaming → true)
- ✅ Flush remaining buffer on stream end (watch streaming → false)
- ✅ Fragments < 3 chars skipped
-`abortId` prevents stale playback after stop
**Type consistency:**
- `tts.speaking` is `ComputedRef<boolean>` — accessed as `tts.speaking.value` in templates ✅
- `tts.stop()` called consistently across all three views ✅
- `useStreamingTts` options match usage in all three call sites ✅
- `audio` variable kept in ChatView (used by volume slider) — not removed ✅
@@ -1,695 +0,0 @@
# Article Reading Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
---
## File map
| Action | Path | Responsibility |
|---|---|---|
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
---
## Task 1: Remove RSS content cap
**Files:**
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
- Modify: `tests/test_rss_service.py:19-26`
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
Trafilatura extracts only article body text, so content is naturally bounded.
- [ ] **Step 1: Update the truncation test to assert no truncation**
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
```python
def test_extract_item_does_not_truncate_content():
"""extract_item() should store content without truncation."""
from fabledassistant.services.rss import extract_item
long_text = "x" * 100_000
entry = MagicMock()
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
entry.content = []
entry.published_parsed = None
item = extract_item(entry)
assert len(item["content"]) == 100_000
```
- [ ] **Step 2: Run the test to confirm it fails**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
```
Expected: FAIL (current code truncates to 50_000).
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
In `src/fabledassistant/services/rss.py`:
Remove lines 1718:
```python
# Safety cap on stored content — effectively unlimited for typical articles
CONTENT_MAX_CHARS = 50_000
```
Change line 83 from:
```python
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
```
to:
```python
content = _html_to_text(content)
```
Change line 213 from:
```python
item.content = full_text[:CONTENT_MAX_CHARS]
```
to:
```python
item.content = full_text
```
- [ ] **Step 4: Run all rss tests**
```bash
make test ARGS="tests/test_rss_service.py -v"
```
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
git commit -m "feat(rss): remove article content character cap"
```
---
## Task 2: Add `read_article` tool
**Files:**
- Modify: `src/fabledassistant/services/tools.py`
- Create: `tests/test_article_reading.py`
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
- [ ] **Step 1: Write failing tests**
Create `tests/test_article_reading.py`:
```python
import json
import pytest
from unittest.mock import AsyncMock, patch
@pytest.mark.asyncio
async def test_read_article_success():
"""read_article tool returns article content on success."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value="Article text here."),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/article"},
)
assert result["success"] is True
assert result["type"] == "article_content"
assert result["url"] == "https://example.com/article"
assert result["content"] == "Article text here."
assert result["truncated"] is False
@pytest.mark.asyncio
async def test_read_article_fetch_failure():
"""read_article tool returns success=False when fetch returns None."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value=None),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/bad"},
)
assert result["success"] is False
assert "Could not fetch" in result["error"]
@pytest.mark.asyncio
async def test_read_article_truncates_at_40k():
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
from fabledassistant.services.tools import execute_tool
long_content = "x" * 50_000
with patch(
"fabledassistant.services.rss._fetch_full_article",
new=AsyncMock(return_value=long_content),
):
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": "https://example.com/long"},
)
assert result["success"] is True
assert len(result["content"]) == 40_000
assert result["truncated"] is True
@pytest.mark.asyncio
async def test_read_article_empty_url():
"""read_article tool returns success=False when url is empty."""
from fabledassistant.services.tools import execute_tool
result = await execute_tool(
user_id=1,
tool_name="read_article",
arguments={"url": ""},
)
assert result["success"] is False
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
make test ARGS="tests/test_article_reading.py -v"
```
Expected: all 4 fail with "read_article not handled" or AttributeError.
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
```python
_URL_TOOLS = [
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, or to get "
"the full content of a linked page. "
"Do NOT use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch and read"}
},
"required": ["url"],
},
},
}
]
```
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
```python
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool list for a user based on their configured integrations."""
tools = list(_CORE_TOOLS)
tools.extend(_URL_TOOLS)
tools.extend(_RAG_TOOLS)
tools.extend(_ENTITY_TOOLS)
if await is_caldav_configured(user_id):
tools.extend(_CALDAV_TOOLS)
if Config.searxng_enabled():
tools.extend(_SEARCH_TOOLS)
tools.extend(_RESEARCH_TOOLS)
tools.extend(_IMAGE_TOOLS)
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
```
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
```python
elif tool_name == "read_article":
from fabledassistant.services.rss import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
_TOOL_CONTENT_CAP = 40_000
truncated = len(content) > _TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:_TOOL_CONTENT_CAP],
"truncated": truncated,
}
```
- [ ] **Step 5: Run the tests**
```bash
make test ARGS="tests/test_article_reading.py -v"
```
Expected: all 4 pass.
- [ ] **Step 6: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
```
---
## Task 3: Fix history builder
**Files:**
- Modify: `src/fabledassistant/routes/chat.py:162-166`
- Modify: `tests/test_article_reading.py` (add history builder tests)
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
- [ ] **Step 1: Add history builder tests**
Append to `tests/test_article_reading.py`:
```python
def test_history_builder_plain_messages():
"""Messages without tool_calls are added as {role, content} unchanged."""
import json
messages = [
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
]
history = _build_history(messages)
assert history == [
{"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi"},
]
def test_history_builder_with_tool_calls():
"""Messages with tool_calls emit an assistant entry + tool result entries."""
import json
tool_calls_data = [
{
"function": "read_article",
"arguments": {"url": "https://example.com"},
"result": {"success": True, "content": "Article text"},
}
]
messages = [
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
type("M", (), {
"role": "assistant",
"content": "",
"tool_calls": tool_calls_data,
})(),
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
]
history = _build_history(messages)
assert history[0] == {"role": "user", "content": "read this"}
assert history[1]["role"] == "assistant"
assert history[1]["tool_calls"] == [
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
]
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
assert history[3] == {"role": "user", "content": "follow up"}
def _build_history(messages):
"""Inline copy of the fixed history builder for testing."""
import json
history = []
for msg in messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
return history
```
- [ ] **Step 2: Run the tests to confirm they pass**
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
```bash
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
```
Expected: both pass.
- [ ] **Step 3: Apply the fix to `chat.py`**
In `src/fabledassistant/routes/chat.py`, replace lines 162166:
```python
# Build history from existing messages (excluding system and the placeholder)
history = []
for msg in conv.messages:
if msg.role != "system":
history.append({"role": msg.role, "content": msg.content})
```
with:
```python
# Build history from existing messages (excluding system and the placeholder).
# Tool calls from prior turns are replayed as assistant tool_call + tool result
# messages so the LLM retains tool context on follow-up turns.
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
```
`json` is already imported at the top of `chat.py`.
- [ ] **Step 4: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
```
---
## Task 4: Extend `add_message` to accept `tool_calls`
**Files:**
- Modify: `src/fabledassistant/services/chat.py:183-207`
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
- [ ] **Step 1: Update `add_message` signature and body**
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183207):
```python
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
async with async_session() as session:
kwargs: dict = dict(
conversation_id=conversation_id,
role=role,
content=content,
context_note_id=context_note_id,
)
if status is not None:
kwargs["status"] = status
if tool_calls is not None:
kwargs["tool_calls"] = tool_calls
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
conv = await session.get(Conversation, conversation_id)
if conv:
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
```
- [ ] **Step 2: Run full test suite**
```bash
make test
```
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/services/chat.py
git commit -m "feat(chat): add tool_calls parameter to add_message"
```
---
## Task 5: Add Discuss endpoint and update frontend
**Files:**
- Modify: `src/fabledassistant/routes/briefing.py`
- Modify: `frontend/src/views/BriefingView.vue`
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
```python
from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.chat import add_message, get_conversation
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.settings import get_setting
```
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
```python
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
uid = g.user.id
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id is required"}), 400
# Verify article belongs to this user (via feed ownership)
async with async_session() as session:
result = await session.execute(
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalar_one_or_none()
if item is None:
return jsonify({"error": "Article not found"}), 404
# Verify conversation belongs to this user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
existing = get_buffer(conv_id)
if existing and existing.state == GenerationState.RUNNING:
return jsonify({"error": "Generation already in progress"}), 409
article_content = item.content or ""
# Store synthetic assistant message: read_article was already called with stored content
synthetic_tool_calls = [
{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}
]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
# Store user message
await add_message(conv_id, "user", "Please summarize and discuss this article.")
# Reload conversation so history includes the two new messages
conv = await get_conversation(uid, conv_id)
# Build history (using the fixed builder from chat.py logic — duplicated here)
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
from fabledassistant.config import Config as _Config
if not model:
model = _Config.OLLAMA_MODEL
# Create placeholder assistant message and generation buffer
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
try:
buf = create_buffer(conv_id, assistant_msg.id)
except RuntimeError:
return jsonify({"error": "Generation already in progress"}), 409
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title,
"Please summarize and discuss this article.",
think=True,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
```
- [ ] **Step 2: Run full test suite**
```bash
make test
```
Expected: all pass.
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
```typescript
async function discussArticle(item: NewsItem) {
if (!todayConvId.value || chatStore.streaming) return
if (!isToday.value) selectedConvId.value = todayConvId.value
await nextTick(() => {
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
try {
await apiPost<{ assistant_message_id: number }>(
`/api/briefing/articles/${item.id}/discuss`,
{ conv_id: todayConvId.value },
)
} catch {
return
}
// Reload conversation so the new messages appear (including the generating placeholder),
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
await chatStore.fetchConversation(todayConvId.value)
await chatStore.reconnectIfGenerating(todayConvId.value)
}
```
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
- [ ] **Step 4: TypeScript check**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
npm --prefix frontend run type-check
```
Expected: no errors.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
```
---
## Task 6: Final verification
- [ ] **Step 1: Run full test suite**
```bash
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
make test
```
Expected: all tests pass.
- [ ] **Step 2: TypeScript check**
```bash
npm --prefix frontend run type-check
```
Expected: no errors.
- [ ] **Step 3: Push**
```bash
git push origin dev
```
@@ -1,479 +0,0 @@
# Web Voice Overlay Polish — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it in `App.vue`, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection backed by a new `useSilenceDetector` composable.
**Architecture:** A new `useSilenceDetector` composable uses `AudioContext` + `AnalyserNode` to monitor amplitude from a live `MediaStream` and fires a callback after sustained silence. `VoiceOverlay` coordinates recording and silence detection, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds a Space bar handler that dispatches the existing `voice:ptt-toggle` custom event.
**Tech Stack:** Vue 3 Composition API, TypeScript, Web Audio API (`AudioContext`, `AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables.
---
## File Map
| Action | Path |
|--------|------|
| Create | `frontend/src/composables/useSilenceDetector.ts` |
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
| Modify | `frontend/src/components/VoiceOverlay.vue` |
| Modify | `frontend/src/App.vue` |
---
### Task 1: `useSilenceDetector` composable
**Files:**
- Create: `frontend/src/composables/useSilenceDetector.ts`
**Context:** The Web Audio API lets us pipe a `MediaStream` into an `AnalyserNode` and read frequency data as a byte array every 100 ms. RMS amplitude of that array gives a 01 loudness value; converting to dB lets us use the same `-40 dB` threshold as the Android app. The composable must be safe to call `stop()` on multiple times and must reset amplitude to 0 after stopping so the animated bars collapse.
- [ ] **Step 1: Create the file with full implementation**
`frontend/src/composables/useSilenceDetector.ts`:
```ts
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void): void {
stop()
audioCtx = new AudioContext()
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop(): void {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
```
- [ ] **Step 2: Verify TypeScript compiles**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/composables/useSilenceDetector.ts
git commit -m "feat: add useSilenceDetector composable with Web Audio API amplitude monitoring"
```
---
### Task 2: Expose `stream` ref from `useVoiceRecorder`
**Files:**
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
**Context:** Currently `stream` is a plain `let` variable inside the closure. `VoiceOverlay` needs to pass the live `MediaStream` to `useSilenceDetector.start()` after recording begins. Exposing it as a readonly `Ref<MediaStream | null>` is the minimal change — no other callers are broken because they don't currently read `stream` from the return value.
The current file is at `frontend/src/composables/useVoiceRecorder.ts`. Read it before editing — the key lines to change are:
1. Top of function body: `let stream: MediaStream | null = null``const streamRef = ref<MediaStream | null>(null)`
2. In `startRecording()`: `stream = await navigator.mediaDevices.getUserMedia({ audio: true })``streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })`
3. In `startRecording()` catch block: `stream = null` if present — replace with `streamRef.value = null` (if the catch sets stream to null; if not, skip)
4. In `mediaRecorder.onstop`: `stream?.getTracks().forEach((t) => t.stop())``streamRef.value?.getTracks().forEach((t) => t.stop())` then `streamRef.value = null`
5. Return object: add `stream: readonly(streamRef)`
- [ ] **Step 1: Add the `ref` import if not already present**
The file already imports `{ ref, readonly }` from `'vue'` — confirm this. If `ref` is missing from the import, add it.
- [ ] **Step 2: Replace the `stream` variable declaration**
Find:
```ts
let stream: MediaStream | null = null
```
Replace with:
```ts
const streamRef = ref<MediaStream | null>(null)
```
- [ ] **Step 3: Update all usages of `stream` in `startRecording`**
Find:
```ts
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
```
Replace with:
```ts
streamRef.value = await navigator.mediaDevices.getUserMedia({ audio: true })
```
- [ ] **Step 4: Update `onstop` handler**
Find:
```ts
stream?.getTracks().forEach((t) => t.stop())
stream = null
```
Replace with:
```ts
streamRef.value?.getTracks().forEach((t) => t.stop())
streamRef.value = null
```
- [ ] **Step 5: Add `stream` to the return object**
Find the return statement and add `stream: readonly(streamRef)`:
```ts
return {
recording: readonly(recording),
error: readonly(error),
isSupported,
startRecording,
stopRecording,
stream: readonly(streamRef),
}
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 7: Commit**
```bash
git add frontend/src/composables/useVoiceRecorder.ts
git commit -m "feat: expose live stream ref from useVoiceRecorder"
```
---
### Task 3: Update `VoiceOverlay` — silence detection, click-to-toggle, amplitude bars
**Files:**
- Modify: `frontend/src/components/VoiceOverlay.vue`
**Context:** `VoiceOverlay.vue` is a complete floating voice UI that was never mounted. It currently uses `@mousedown`/`@mouseup` for push-to-talk. This task switches it to click-to-toggle with automatic silence detection and adds animated amplitude bars during recording. Read the full file before making changes — the existing structure and style blocks must be preserved.
#### Script changes
- [ ] **Step 1: Import `useSilenceDetector`**
At the top of `<script setup>`, after the existing imports, add:
```ts
import { useSilenceDetector } from '@/composables/useSilenceDetector'
```
- [ ] **Step 2: Instantiate the composable**
After `const audio = useVoiceAudio()`, add:
```ts
const silenceDetector = useSilenceDetector()
```
- [ ] **Step 3: Update `startPtt` to start silence detection**
Find the `startPtt` function. After `phase.value = 'recording'`, add:
```ts
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
```
The complete `startPtt` after the change:
```ts
async function startPtt() {
if (!voiceEnabled.value || isBusy.value) return
audio.stop()
errorMsg.value = ''
open.value = true
await recorder.startRecording()
if (recorder.error.value) {
phase.value = 'error'
errorMsg.value = recorder.error.value
return
}
phase.value = 'recording'
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
}
```
- [ ] **Step 4: Update `stopPtt` to stop silence detection**
Add `silenceDetector.stop()` as the very first line of `stopPtt`:
```ts
async function stopPtt() {
silenceDetector.stop()
if (phase.value !== 'recording') return
// ... rest unchanged
```
- [ ] **Step 5: Update `cancelAll` to stop silence detection**
Add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`:
```ts
function cancelAll() {
silenceDetector.stop()
recorder.stopRecording().catch(() => {})
audio.stop()
phase.value = 'idle'
streamContent.value = ''
errorMsg.value = ''
}
```
- [ ] **Step 6: Add `onBtnClick` function**
Add this function after `cancelAll`:
```ts
function onBtnClick() {
if (phase.value === 'error') { phase.value = 'idle'; return }
if (phase.value === 'recording') { stopPtt(); return }
if (phase.value === 'idle') { startPtt() }
}
```
#### Template changes
- [ ] **Step 7: Replace PTT mouse/touch handlers with `@click`**
On `.voice-ptt-btn`, replace:
```html
@mousedown.prevent="startPtt"
@mouseup.prevent="stopPtt"
@touchstart.prevent="startPtt"
@touchend.prevent="stopPtt"
@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"
```
with:
```html
@click.prevent="onBtnClick"
```
- [ ] **Step 8: Update aria-label and title on the button**
Replace:
```html
:aria-label="phase === 'recording' ? 'Release to send' : 'Hold to speak'"
:title="phase === 'recording' ? 'Release to send' : 'Hold Space or tap to speak'"
```
with:
```html
:aria-label="phase === 'recording' ? 'Click to stop' : 'Click to speak'"
:title="phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'"
```
- [ ] **Step 9: Replace the static recording icon with amplitude bars**
Find:
```html
<!-- Recording: waveform / stop icon -->
<svg v-else-if="phase === 'recording'" width="22" height="22" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
</svg>
```
Replace with:
```html
<!-- Recording: amplitude bars -->
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
<span
v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
```
- [ ] **Step 10: Update the idle hint label**
Find:
```html
Hold <kbd>Space</kbd> or tap
```
Replace with:
```html
Tap or press <kbd>Space</kbd>
```
#### Style changes
- [ ] **Step 11: Add amplitude bar styles to `<style scoped>`**
Append inside the `<style scoped>` block:
```css
/* ─── Amplitude bars (recording state) ──────────────────────────────────── */
.voice-amp-bars {
display: flex;
gap: 3px;
align-items: center;
height: 22px;
}
.voice-amp-bar {
width: 4px;
height: 18px;
background: #fff;
border-radius: 2px;
transform-origin: center;
transition: transform 0.08s ease;
}
```
- [ ] **Step 12: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 13: Commit**
```bash
git add frontend/src/components/VoiceOverlay.vue
git commit -m "feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay"
```
---
### Task 4: Mount `VoiceOverlay` and wire Space bar in `App.vue`
**Files:**
- Modify: `frontend/src/App.vue`
**Context:** `App.vue` has a full `onGlobalKeydown` handler and a shortcuts overlay. The Space bar is already documented there as "Hold to speak (voice, when enabled)" but the handler was never added to `onGlobalKeydown`. `VoiceOverlay` uses `Teleport to="body"` so it renders at the document root regardless of where it's placed in the template — just needs to be inside the authenticated block.
#### Script changes
- [ ] **Step 1: Add `VoiceOverlay` import**
In `<script setup>`, after the existing component imports (after `ToastNotification`), add:
```ts
import VoiceOverlay from '@/components/VoiceOverlay.vue'
```
- [ ] **Step 2: Add Space bar case to `onGlobalKeydown`**
The existing handler has a `switch (e.key)` block. The guard `if (isInputActive() || e.ctrlKey || e.metaKey || e.altKey) return` already runs before the switch, so the Space case only fires when the user isn't typing.
Inside the `switch (e.key)` block, add this case after the existing `'c'` case:
```ts
case ' ':
e.preventDefault()
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
break
```
#### Template changes
- [ ] **Step 3: Mount `VoiceOverlay` in the authenticated template**
Find `<ToastNotification />` near the bottom of the authenticated template block and add `<VoiceOverlay />` directly above it:
```html
<VoiceOverlay />
<ToastNotification />
```
- [ ] **Step 4: Update Space bar description in shortcuts panel**
Find:
```html
<span class="shortcut-desc">Hold to speak (voice, when enabled)</span>
```
Replace with:
```html
<span class="shortcut-desc">Tap to speak (voice, when enabled)</span>
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
npx tsc --noEmit
```
Expected: no errors.
- [ ] **Step 6: Verify full build succeeds**
```bash
npm run build
```
Expected: build completes with no errors.
- [ ] **Step 7: Manual smoke test**
1. Start the dev server: `npm run dev`
2. Log in — confirm the floating mic button appears in the bottom-right corner
3. Ensure voice is enabled in Settings → Voice
4. Click the mic button — confirm it turns red with animated amplitude bars
5. Speak — bars should animate with your voice
6. Stop speaking — after ~1.5 s of silence, the button should switch to purple (transcribing), then green (speaking) as it plays back the response
7. Click the mic while recording — confirm it stops immediately
8. Press Space (not in an input field) — confirm it starts/stops recording
9. Press Space in the chat input — confirm it does NOT trigger voice
- [ ] **Step 8: Commit**
```bash
git add frontend/src/App.vue
git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut in App.vue"
```
@@ -1,746 +0,0 @@
# Knowledge View Task Consolidation — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Consolidate tasks into the Knowledge view as a fifth card type, deprecate `/notes` and `/tasks` list routes, and simplify navigation down to a single Knowledge hub.
**Architecture:** The backend knowledge service (`services/knowledge.py`) stops excluding tasks from queries and adds `type=task` filtering via the `is_task` property (`Note.status IS NOT NULL`). The knowledge route validation gains `"task"` as a valid type. The frontend KnowledgeView gains task card rendering with status/priority/due-date badges. Router redirects replace the deleted list views.
**Tech Stack:** Python/Quart backend (SQLAlchemy), Vue 3 + TypeScript frontend, Pinia stores, Vue Router.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `src/fabledassistant/services/knowledge.py` |
| Modify | `src/fabledassistant/routes/knowledge.py` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/router/index.ts` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/App.vue` |
| Delete | `frontend/src/views/NotesListView.vue` |
| Delete | `frontend/src/views/TasksListView.vue` |
---
### Task 1: Backend — Include tasks in knowledge queries
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `src/fabledassistant/routes/knowledge.py`
**Context:** The knowledge service currently excludes tasks by filtering `Note.status.is_(None)`. Every query function (`query_knowledge`, `query_knowledge_ids`, `_semantic_knowledge_search`, `get_knowledge_tags`, `get_knowledge_counts`) has this exclusion. Adding task support means: (1) removing the task exclusion from the "all types" queries, (2) adding `type=task` as a filter option that maps to `Note.status.isnot(None)`, (3) enriching `_note_to_item` with task-specific fields, (4) updating counts to include tasks.
- [ ] **Step 1: Add `"task"` to `_VALID_TYPES` in the route file**
In `src/fabledassistant/routes/knowledge.py`, change:
```python
_VALID_TYPES = {"note", "person", "place", "list"}
```
to:
```python
_VALID_TYPES = {"note", "person", "place", "list", "task"}
```
- [ ] **Step 2: Update `_note_to_item` to include task fields**
In `src/fabledassistant/services/knowledge.py`, the `_note_to_item` function builds the item dict. After the existing `elif note.entity_type == "list":` block (which ends around line 48), add a task branch. Find:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
return item
```
Replace with:
```python
elif note.entity_type == "list":
# Parse markdown task list syntax into structured items
body = note.body or ""
list_items = []
for line in body.split("\n"):
stripped = line.strip()
if stripped.startswith("- [ ] ") or stripped.startswith("- [x] ") or stripped.startswith("- [X] "):
checked_item = not stripped.startswith("- [ ] ")
list_items.append({"text": stripped[6:], "checked": checked_item})
item["list_items"] = list_items
item["item_count"] = len(list_items)
item["checked_count"] = sum(1 for i in list_items if i["checked"])
item["body"] = body
# Task fields — included for all items but only meaningful when is_task
if note.is_task:
item["note_type"] = "task"
item["status"] = note.status
item["priority"] = note.priority
item["due_date"] = note.due_date.isoformat() if note.due_date else None
return item
```
This overrides `note_type` to `"task"` for task items (since `entity_type` returns the `note_type` column which is `"note"` for tasks) and adds status/priority/due_date fields.
- [ ] **Step 3: Update `query_knowledge` to include tasks**
In the `query_knowledge` function, the "all types" filter currently excludes tasks. Change the base query and the `else` branch.
Find:
```python
base = (
select(Note)
.where(Note.user_id == user_id)
.where(Note.status.is_(None)) # exclude tasks
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
# Exclude tasks — already done above; also exclude any legacy nulls
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
# All types including tasks
pass
```
- [ ] **Step 4: Update `_semantic_knowledge_search` to include tasks**
Find:
```python
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=False,
)
```
Replace with:
```python
is_task_filter = True if note_type == "task" else (False if note_type else None)
candidates = await semantic_search_notes(
user_id=user_id,
query=q,
limit=min(200, limit * 8),
threshold=0.3,
is_task=is_task_filter,
)
```
Also update the type matching in the filter loop — find:
```python
for _score, note in candidates:
if note_type and note.entity_type != note_type:
continue
```
Replace with:
```python
for _score, note in candidates:
if note_type == "task" and not note.is_task:
continue
elif note_type and note_type != "task" and note.entity_type != note_type:
continue
```
- [ ] **Step 5: Update `query_knowledge_ids` to include tasks**
Find:
```python
base = (
select(Note.id)
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = select(Note.id).where(Note.user_id == user_id)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 6: Update `get_knowledge_tags` to include task tags**
Find:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
)
if note_type:
base = base.where(Note.note_type == note_type)
else:
base = base.where(Note.note_type.in_(["note", "person", "place", "list"]))
```
Replace with:
```python
base = (
select(func.unnest(Note.tags).label("tag"))
.where(Note.user_id == user_id)
)
if note_type == "task":
base = base.where(Note.status.isnot(None))
elif note_type:
base = base.where(Note.note_type == note_type).where(Note.status.is_(None))
else:
pass
```
- [ ] **Step 7: Update `get_knowledge_counts` to include tasks**
Find:
```python
async with async_session() as session:
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Ensure all types present even if zero
for t in ("note", "person", "place", "list"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list"))
return counts
```
Replace with:
```python
async with async_session() as session:
# Count non-task types
stmt = (
select(Note.note_type, func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.is_(None))
.where(Note.note_type.in_(["note", "person", "place", "list"]))
.group_by(Note.note_type)
)
if tags:
for tag in tags:
stmt = stmt.where(Note.tags.contains([tag]))
rows = list((await session.execute(stmt)).all())
counts = {row[0]: row[1] for row in rows}
# Count tasks separately (is_task = status IS NOT NULL)
task_stmt = (
select(func.count(Note.id))
.where(Note.user_id == user_id)
.where(Note.status.isnot(None))
)
if tags:
for tag in tags:
task_stmt = task_stmt.where(Note.tags.contains([tag]))
task_count: int = (await session.execute(task_stmt)).scalar_one()
counts["task"] = task_count
for t in ("note", "person", "place", "list", "task"):
counts.setdefault(t, 0)
counts["total"] = sum(counts[t] for t in ("note", "person", "place", "list", "task"))
return counts
```
- [ ] **Step 8: Verify backend changes**
```bash
cd /path/to/fabledassistant
make typecheck
make test
```
Expected: no errors.
- [ ] **Step 9: Commit**
```bash
git add src/fabledassistant/services/knowledge.py src/fabledassistant/routes/knowledge.py
git commit -m "feat(knowledge): include tasks in knowledge queries and counts"
```
---
### Task 2: Frontend — Task card rendering in KnowledgeView
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** `KnowledgeView.vue` has a `KnowledgeItem` interface and renders cards in a grid. Each card type has type-specific content (person shows relationship/email, list shows checkboxes, etc.). Task cards need status, priority, and due date display. The `activeType` ref controls filtering; it needs `"task"` as a valid value. The type filter sidebar needs a "Tasks" button. The new-note button interaction changes from split-button to toggle.
- [ ] **Step 1: Add `"task"` to the KnowledgeItem interface and filter type**
In the `<script setup>` section, find the `KnowledgeItem` interface and add task fields:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list";
// ... existing fields
```
Change to:
```ts
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list" | "task";
// ... existing fields
```
Also add the task-specific fields at the end of the interface (before the closing `}`):
```ts
// Task-specific
status?: string;
priority?: string;
due_date?: string;
```
Update the `activeType` ref type:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
```
Change to:
```ts
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
```
- [ ] **Step 2: Add "Tasks" to the type filter sidebar**
Find the type filter `v-for` in the template:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Replace with:
```html
<button
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
```
Update the type cast on the click handler. Find:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
```
Replace with:
```html
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
```
- [ ] **Step 3: Add task card content in the template**
In the card grid, find the note snippet section:
```html
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
Add a task-specific section above it:
```html
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
```
- [ ] **Step 4: Add `isOverdue` helper and update `openItem` for tasks**
In the `<script setup>`, add after the `formatDate` function:
```ts
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
```
Update `openItem` to route tasks to their editor:
```ts
function openItem(item: KnowledgeItem) {
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
```
- [ ] **Step 5: Update the "New note" button to toggle interaction**
Find the current new-note button markup:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Replace with:
```html
<div class="new-note-wrap">
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('task')">Task</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
</div>
</div>
```
Add a click-outside handler. In the `<script setup>`, add after the `createNew` function:
```ts
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
```
In `onMounted`, add:
```ts
document.addEventListener('click', onClickOutsideNewNote);
```
In `onUnmounted`, add:
```ts
document.removeEventListener('click', onClickOutsideNewNote);
```
- [ ] **Step 6: Add task card CSS**
Append to the `<style scoped>` block:
```css
/* ── Task card ──────────────────────────────────────────── */
.k-card--task { border-left: 3px solid #a78bfa; }
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
```
Also add the task type badge color. Find:
```css
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
```
Add after it:
```css
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
```
- [ ] **Step 7: Remove the chevron button CSS**
Find and delete:
```css
.btn-new-chevron {
padding: 7px 9px;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
transition: background 0.15s, transform 0.15s;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
```
Update `.btn-new-note` to have full border-radius now that the chevron is gone:
```css
.btn-new-note {
flex: 1;
padding: 7px 10px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
text-align: left;
transition: background 0.15s;
}
```
- [ ] **Step 8: Verify TypeScript compiles**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors (pre-existing TipTap errors are fine).
- [ ] **Step 9: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): add task cards with status/priority/due-date display"
```
---
### Task 3: Route redirects, navigation cleanup, dead code removal
**Files:**
- Modify: `frontend/src/router/index.ts`
- Modify: `frontend/src/components/AppHeader.vue`
- Modify: `frontend/src/App.vue`
- Delete: `frontend/src/views/NotesListView.vue`
- Delete: `frontend/src/views/TasksListView.vue`
**Context:** The router currently has `/notes` and `/tasks` pointing to list view components. These become redirects to `/`. The AppHeader has "Tasks" in both desktop and mobile nav. The `g+t` keyboard shortcut navigates to `/tasks` which should change to `/`. The stores (`notes.ts`, `tasks.ts`) are used by other views so they stay.
- [ ] **Step 1: Replace list view routes with redirects**
In `frontend/src/router/index.ts`, find:
```ts
{
path: "/notes",
name: "notes",
component: () => import("@/views/NotesListView.vue"),
},
```
Replace with:
```ts
{
path: "/notes",
redirect: "/",
},
```
Find:
```ts
{
path: "/tasks",
name: "tasks",
component: () => import("@/views/TasksListView.vue"),
},
```
Replace with:
```ts
{
path: "/tasks",
redirect: "/",
},
```
- [ ] **Step 2: Remove "Tasks" from AppHeader navigation**
In `frontend/src/components/AppHeader.vue`, find in the desktop nav-center:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
Find in the mobile dropdown menu:
```html
<router-link to="/tasks" class="nav-link">Tasks</router-link>
```
Delete this line.
- [ ] **Step 3: Update `g+t` keyboard shortcut in App.vue**
In `frontend/src/App.vue`, find in the `onGlobalKeydown` function, inside the `if (pendingPrefix === "g")` block:
```ts
case "t": router.push("/tasks"); break;
```
Replace with:
```ts
case "t": router.push("/"); break;
```
- [ ] **Step 4: Update shortcuts overlay text**
In `frontend/src/App.vue`, find in the shortcuts overlay template:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span>
```
Replace the description:
```html
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Knowledge (tasks)</span>
```
- [ ] **Step 5: Delete the deprecated list view files**
```bash
rm frontend/src/views/NotesListView.vue
rm frontend/src/views/TasksListView.vue
```
- [ ] **Step 6: Verify build**
```bash
cd /path/to/fabledassistant/frontend
npx tsc --noEmit
```
Expected: no new errors. The deleted files were only imported via lazy `() => import(...)` in the router, which we already replaced with redirects.
- [ ] **Step 7: Commit**
```bash
git add -A frontend/src/views/NotesListView.vue frontend/src/views/TasksListView.vue \
frontend/src/router/index.ts frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: deprecate /notes and /tasks routes; redirect to Knowledge view"
```
@@ -1,786 +0,0 @@
# Specialized Note Type Editors — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic note editor with type-specialized form-first views for Person, Place, and List, and fix tab navigation so focus flows logically from title through content fields, skipping the formatting toolbar.
**Architecture:** `NoteEditorView.vue` gains type-conditional template sections. When `noteType` is `person` or `place`, the main editor area renders a structured form with the TipTap editor in a secondary "Notes" section. When `noteType` is `list`, a dedicated list builder replaces TipTap as the primary interface. `MarkdownToolbar.vue` gets `tabindex="-1"` on buttons. Backend `_note_to_item` gains new person/place fields.
**Tech Stack:** Vue 3 Composition API, TipTap editor, TypeScript, scoped CSS.
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/views/NoteEditorView.vue` |
| Modify | `frontend/src/components/MarkdownToolbar.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `src/fabledassistant/services/knowledge.py` |
---
### Task 1: Tab navigation fix — toolbar tabindex + auto-focus
**Files:**
- Modify: `frontend/src/components/MarkdownToolbar.vue`
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** The MarkdownToolbar renders buttons via `v-for` in a single `<button>` element. Adding `tabindex="-1"` removes them from tab order while keeping them clickable. The NoteEditorView already has a `titleRef` — auto-focus on mount needs to call `.focus()` on it. The title placeholder should vary by note type.
- [ ] **Step 1: Add tabindex="-1" to toolbar buttons**
In `frontend/src/components/MarkdownToolbar.vue`, find:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
@mousedown.prevent="btn.command()"
>
```
Replace with:
```html
<button
v-for="btn in group"
:key="btn.id"
:class="['md-btn', { active: btn.isActive() }]"
:title="btn.title"
type="button"
tabindex="-1"
@mousedown.prevent="btn.command()"
>
```
- [ ] **Step 2: Add auto-focus on mount and type-dependent placeholder**
In `frontend/src/views/NoteEditorView.vue`, find the title input:
```html
<input
ref="titleRef"
v-model="title"
type="text"
placeholder="Title"
class="title-input"
```
Replace with:
```html
<input
ref="titleRef"
v-model="title"
type="text"
:placeholder="titlePlaceholder"
class="title-input"
```
Add the computed property in the `<script setup>` section, after the `isEditing` computed:
```ts
const titlePlaceholder = computed(() => {
switch (noteType.value) {
case 'person': return 'Name';
case 'place': return 'Place name';
case 'list': return 'List title';
default: return 'Title';
}
});
```
- [ ] **Step 3: Auto-focus title on mount**
In the `onMounted` callback, after all the data loading logic (after the draft restore try/catch block), add:
```ts
await nextTick();
titleRef.value?.focus();
```
- [ ] **Step 4: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add frontend/src/components/MarkdownToolbar.vue frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders"
```
---
### Task 2: Person editor — form-first layout
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** When `noteType === 'person'`, the main content area should render a contact card form instead of the TipTap-first editor. The person metadata fields (currently in the sidebar) move to the main area, and new fields (birthday, organization, address) are added. The TipTap editor becomes a collapsible "Notes" section below. The sidebar keeps project/tags/type/etc but loses the person-specific fields.
- [ ] **Step 1: Add the person form template**
In the template, find the `<!-- ── Main column ──` section. The current structure is:
```html
<div class="note-main" @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()">
<div class="body-tabs-row">
...
</div>
<!-- Streaming/Review/Normal editor templates -->
</div>
```
Wrap the existing main column content in a `v-if="noteType === 'note'"` (and also show it for any type not person/place/list), and add a person form block. Replace the opening of the main column content:
Find the `<div class="note-main"` line and the content inside it up to `</div>` that closes `.note-main`. Wrap all existing content inside:
```html
<div class="note-main">
<!-- ── Person form ──────────────────────────────────────── -->
<template v-if="noteType === 'person'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Relationship</label>
<input class="ef-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague, Family" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Birthday</label>
<input class="ef-input" type="date" v-model="entityMeta.birthday" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Email</label>
<input class="ef-input" type="email" v-model="entityMeta.email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Organization</label>
<input class="ef-input" v-model="entityMeta.organization" placeholder="Company or organization" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
<!-- ── Generic note editor (existing) ───────────────────── -->
<template v-else-if="noteType === 'note'">
<!-- ... existing TipTap-first editor content stays here ... -->
</template>
</div>
```
IMPORTANT: Do NOT duplicate the existing editor content. Wrap the existing content in `<template v-else-if="noteType === 'note'">` and place the person form as a sibling `<template>` above it. The place and list forms will be added in subsequent tasks.
- [ ] **Step 2: Add `notesExpanded` ref**
In the `<script setup>`, after the `sidebarOpen` ref, add:
```ts
const notesExpanded = ref(false);
```
Also initialize it based on whether the note has body content, in the onMounted data-loading section. After `Object.assign(entityMeta, store.currentNote.metadata || {});` add:
```ts
notesExpanded.value = !!(store.currentNote.body || '').trim();
```
- [ ] **Step 3: Remove person fields from sidebar**
In the sidebar template, find:
```html
<!-- Person metadata -->
<template v-if="noteType === 'person'">
<div class="sb-field">
<label class="sb-label">Relationship</label>
<input class="sb-input" v-model="entityMeta.relationship" placeholder="e.g. Friend, Colleague" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Email</label>
<input class="sb-input" v-model="entityMeta.email" type="email" placeholder="email@example.com" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
</template>
```
Delete this entire block.
- [ ] **Step 4: Add entity form CSS**
Add to the `<style scoped>` block:
```css
/* ── Entity form (Person / Place) ───────────────────────── */
.entity-form {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.ef-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.ef-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
}
.ef-input {
padding: 8px 12px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.ef-input:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.ef-input::placeholder {
color: var(--color-text-muted);
}
/* ── Notes section (collapsible TipTap) ─────────────────── */
.notes-section {
margin-top: 16px;
border-top: 1px solid var(--color-border);
padding-top: 12px;
}
.notes-toggle {
background: none;
border: none;
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.85rem;
cursor: pointer;
padding: 4px 0;
}
.notes-toggle:hover {
color: var(--color-text);
}
.notes-editor-wrap {
margin-top: 8px;
}
```
- [ ] **Step 5: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): person form-first layout with structured fields and collapsible notes"
```
---
### Task 3: Place editor + List builder
**Files:**
- Modify: `frontend/src/views/NoteEditorView.vue`
**Context:** Place uses the same entity form pattern as Person with different fields. List uses a dedicated checklist builder with Enter-to-add and Backspace-to-delete behavior. Both are additional `<template>` branches in the main column.
- [ ] **Step 1: Add place form template**
In the `note-main` div, after the person `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── Place form ───────────────────────────────────────── -->
<template v-else-if="noteType === 'place'">
<div class="entity-form">
<div class="ef-field">
<label class="ef-label">Address</label>
<input class="ef-input" v-model="entityMeta.address" placeholder="Street, City, State" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Phone</label>
<input class="ef-input" type="tel" v-model="entityMeta.phone" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Hours</label>
<input class="ef-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 9am5pm" @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Website</label>
<input class="ef-input" type="url" v-model="entityMeta.website" placeholder="https://..." @input="markDirty" />
</div>
<div class="ef-field">
<label class="ef-label">Category</label>
<input class="ef-input" v-model="entityMeta.category" placeholder="e.g. Restaurant, Office, Doctor" @input="markDirty" />
</div>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, wikilinks, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 2: Remove place fields from sidebar**
Find and delete:
```html
<!-- Place metadata -->
<template v-if="noteType === 'place'">
<div class="sb-field">
<label class="sb-label">Address</label>
<input class="sb-input" v-model="entityMeta.address" placeholder="Street, City" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Phone</label>
<input class="sb-input" v-model="entityMeta.phone" type="tel" placeholder="+1 555 000 0000" @input="markDirty" />
</div>
<div class="sb-field">
<label class="sb-label">Hours</label>
<input class="sb-input" v-model="entityMeta.hours" placeholder="e.g. MonFri 95" @input="markDirty" />
</div>
</template>
```
- [ ] **Step 3: Add list item types and state**
In the `<script setup>`, after the `notesExpanded` ref, add:
```ts
// ── List builder ─────────────────────────────────────────────────────────────
interface ListItem {
text: string;
checked: boolean;
}
const listItems = ref<ListItem[]>([]);
const listItemRefs = ref<(HTMLInputElement | null)[]>([]);
function parseListFromBody(bodyText: string): { items: ListItem[]; extra: string } {
const lines = bodyText.split('\n');
const items: ListItem[] = [];
const extraLines: string[] = [];
let pastList = false;
for (const line of lines) {
const stripped = line.trimStart();
if (!pastList && (stripped.startsWith('- [ ] ') || stripped.startsWith('- [x] ') || stripped.startsWith('- [X] '))) {
items.push({ text: stripped.slice(6), checked: !stripped.startsWith('- [ ] ') });
} else if (!pastList && stripped === '' && items.length > 0) {
pastList = true;
} else {
pastList = true;
extraLines.push(line);
}
}
return { items, extra: extraLines.join('\n').trim() };
}
function serializeListToBody(): string {
const listPart = listItems.value
.map(item => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
.join('\n');
const extraPart = body.value.trim();
return extraPart ? `${listPart}\n\n${extraPart}` : listPart;
}
function addListItem(afterIndex?: number) {
const idx = afterIndex !== undefined ? afterIndex + 1 : listItems.value.length;
listItems.value.splice(idx, 0, { text: '', checked: false });
markDirty();
nextTick(() => {
listItemRefs.value[idx]?.focus();
});
}
function removeListItem(index: number) {
if (listItems.value.length <= 1) return;
listItems.value.splice(index, 1);
markDirty();
nextTick(() => {
const focusIdx = Math.max(0, index - 1);
listItemRefs.value[focusIdx]?.focus();
});
}
function onListItemKeydown(e: KeyboardEvent, index: number) {
if (e.key === 'Enter') {
e.preventDefault();
addListItem(index);
} else if (e.key === 'Backspace' && listItems.value[index].text === '') {
e.preventDefault();
removeListItem(index);
}
}
function onListItemInput(index: number) {
markDirty();
}
function toggleListItemCheck(index: number) {
listItems.value[index].checked = !listItems.value[index].checked;
markDirty();
}
```
- [ ] **Step 4: Initialize list items on mount**
In the onMounted data-loading section, after `notesExpanded.value = !!(store.currentNote.body || '').trim();`, add:
```ts
if (noteType.value === 'list') {
const parsed = parseListFromBody(body.value);
listItems.value = parsed.items.length > 0 ? parsed.items : [{ text: '', checked: false }];
body.value = parsed.extra;
notesExpanded.value = !!parsed.extra;
}
```
And in the new-note branch (the `else` block after loading), after `noteType.value = qt as NoteType;`, add:
```ts
if (noteType.value === 'list') {
listItems.value = [{ text: '', checked: false }];
}
```
- [ ] **Step 5: Update save to serialize list**
In the `save` function, find where the body is prepared for the API call. Before the `apiPost` or `apiPatch` call that sends the note data, add list serialization. Find the save function's data construction. Add before the API call:
```ts
const finalBody = noteType.value === 'list' ? serializeListToBody() : body.value;
```
Then use `finalBody` instead of `body.value` in the API payload. Find all occurrences of `body: body.value` in the save function and replace with `body: finalBody`.
- [ ] **Step 6: Add list builder template**
In the `note-main` div, after the place `</template>` and before the generic note `<template v-else-if="noteType === 'note'">`, add:
```html
<!-- ── List builder ─────────────────────────────────────── -->
<template v-else-if="noteType === 'list'">
<div class="list-builder">
<div
v-for="(item, idx) in listItems"
:key="idx"
class="lb-item"
>
<input
type="checkbox"
:checked="item.checked"
@change="toggleListItemCheck(idx)"
class="lb-check"
tabindex="-1"
/>
<input
:ref="(el) => { listItemRefs[idx] = el as HTMLInputElement | null }"
v-model="item.text"
class="lb-text"
placeholder="List item..."
@keydown="onListItemKeydown($event, idx)"
@input="onListItemInput(idx)"
/>
<button class="lb-delete" tabindex="-1" @click="removeListItem(idx)" title="Remove item">&times;</button>
</div>
<button class="lb-add" @click="addListItem()">+ Add item</button>
</div>
<div class="notes-section">
<button class="notes-toggle" @click="notesExpanded = !notesExpanded">
{{ notesExpanded ? '▾' : '▸' }} Notes
</button>
<div v-if="notesExpanded" class="notes-editor-wrap">
<MarkdownToolbar v-show="!showPreview" :editor="tiptapEditor" />
<TiptapEditor
ref="editorRef"
:modelValue="body"
placeholder="Additional notes, context..."
@update:modelValue="onBodyUpdate"
@escape="titleRef?.focus()"
/>
</div>
</div>
</template>
```
- [ ] **Step 7: Add list builder CSS**
Add to the `<style scoped>` block:
```css
/* ── List builder ───────────────────────────────────────── */
.list-builder {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px 0;
}
.lb-item {
display: flex;
align-items: center;
gap: 8px;
}
.lb-check {
flex-shrink: 0;
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
.lb-text {
flex: 1;
padding: 7px 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s;
}
.lb-text:focus {
border-color: var(--color-primary);
box-shadow: var(--focus-ring);
}
.lb-text::placeholder {
color: var(--color-text-muted);
}
.lb-delete {
background: none;
border: none;
color: var(--color-text-muted);
font-size: 1.1rem;
cursor: pointer;
padding: 0 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.12s, color 0.12s;
}
.lb-item:hover .lb-delete,
.lb-text:focus ~ .lb-delete {
opacity: 1;
}
.lb-delete:hover {
color: var(--color-danger);
}
.lb-add {
background: none;
border: 1px dashed var(--color-border);
border-radius: 8px;
padding: 7px 12px;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
margin-top: 4px;
transition: border-color 0.15s, color 0.15s;
}
.lb-add:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
```
- [ ] **Step 8: Handle the generic note template wrapper**
Make sure the existing TipTap-first editor content is wrapped in `<template v-else>` (not `v-else-if="noteType === 'note'"`) so it serves as the default for any unrecognized type.
The final structure in `.note-main` should be:
```
<template v-if="noteType === 'person'"> ... </template>
<template v-else-if="noteType === 'place'"> ... </template>
<template v-else-if="noteType === 'list'"> ... </template>
<template v-else> ... existing TipTap editor ... </template>
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/NoteEditorView.vue
git commit -m "feat(editor): place form-first layout and list builder with Enter-to-add"
```
---
### Task 4: Backend — new person/place fields in knowledge cards
**Files:**
- Modify: `src/fabledassistant/services/knowledge.py`
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The knowledge card display should show the new fields (birthday, organization for person; website, category for place). The backend `_note_to_item` needs to include them. The frontend card rendering needs to display the useful ones.
- [ ] **Step 1: Update `_note_to_item` for person**
In `src/fabledassistant/services/knowledge.py`, find:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
```
Replace with:
```python
if note.entity_type == "person":
item["relationship"] = meta.get("relationship", "")
item["email"] = meta.get("email", "")
item["phone"] = meta.get("phone", "")
item["birthday"] = meta.get("birthday", "")
item["organization"] = meta.get("organization", "")
item["address"] = meta.get("address", "")
```
- [ ] **Step 2: Update `_note_to_item` for place**
Find:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
```
Replace with:
```python
elif note.entity_type == "place":
item["address"] = meta.get("address", "")
item["phone"] = meta.get("phone", "")
item["hours"] = meta.get("hours", "")
item["website"] = meta.get("website", "")
item["category"] = meta.get("category", "")
```
- [ ] **Step 3: Update KnowledgeItem interface**
In `frontend/src/views/KnowledgeView.vue`, find the `KnowledgeItem` interface and add the new fields:
After `phone?: string;` add:
```ts
birthday?: string;
organization?: string;
```
After `hours?: string;` add:
```ts
website?: string;
category?: string;
```
- [ ] **Step 4: Update person card display**
In the template, find the person card specifics:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
Replace with:
```html
<div v-if="item.note_type === 'person'" class="k-card-meta">
<span v-if="item.relationship" class="meta-chip">{{ item.relationship }}</span>
<span v-if="item.organization" class="meta-muted">{{ item.organization }}</span>
<span v-if="item.phone" class="meta-muted">{{ item.phone }}</span>
</div>
```
- [ ] **Step 5: Update place card display**
Find:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
Replace with:
```html
<div v-else-if="item.note_type === 'place'" class="k-card-meta">
<span v-if="item.category" class="meta-chip">{{ item.category }}</span>
<span v-if="item.address" class="meta-muted">{{ item.address }}</span>
<span v-if="item.hours" class="meta-muted">{{ item.hours }}</span>
</div>
```
- [ ] **Step 6: Verify TypeScript compiles and backend syntax**
```bash
cd frontend && npx tsc --noEmit
python -c "import ast; ast.parse(open('src/fabledassistant/services/knowledge.py').read()); print('OK')"
```
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/knowledge.py frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): show organization/birthday for person cards, category for place cards"
```
@@ -1,785 +0,0 @@
# Modern Fable Visual Identity — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the generic indigo dark-mode palette with a distinctive "Modern Fable" visual identity — deep violet + muted gold, signature card types, pill nav, Fraunces-as-narrator typography, and living micro-details.
**Architecture:** Pure frontend changes across theme CSS, AppHeader, AppLogo, KnowledgeView, ChatPanel, BriefingView, and CalendarView. No backend changes. Each task is independently deployable — palette first, then cards, then nav, then typography, then details.
**Tech Stack:** Vue 3 SFC (scoped CSS), CSS custom properties, Fraunces font (already loaded).
---
## File Map
| Action | Path |
|--------|------|
| Modify | `frontend/src/assets/theme.css` |
| Modify | `frontend/src/components/AppLogo.vue` |
| Modify | `frontend/src/components/AppHeader.vue` |
| Modify | `frontend/src/views/KnowledgeView.vue` |
| Modify | `frontend/src/components/ChatPanel.vue` |
| Modify | `frontend/src/views/BriefingView.vue` |
| Modify | `frontend/src/views/CalendarView.vue` |
| Modify | `frontend/src/App.vue` |
---
### Task 1: Color palette update + logo + scrollbar
**Files:**
- Modify: `frontend/src/assets/theme.css`
- Modify: `frontend/src/components/AppLogo.vue`
- [ ] **Step 1: Update the dark theme palette in theme.css**
In `frontend/src/assets/theme.css`, find the `[data-theme="dark"]` block and replace these values:
```css
[data-theme="dark"] {
--color-bg: #0f0f14;
--color-bg-secondary: #16161f;
--color-bg-card: #1a1a24;
--color-surface: #16161f;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(124, 58, 237, 0.12);
--color-input-border: rgba(124, 58, 237, 0.22);
--color-primary: #a78bfa;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #c4b5fd;
--color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50;
--color-toast-error: #f44336;
--color-status-todo: #9aa0a6;
--color-status-todo-bg: #2a2a35;
--color-status-in-progress: #a78bfa;
--color-status-in-progress-bg: #2a2a45;
--color-status-done: #4caf50;
--color-status-done-bg: #1b3a20;
--color-priority-low: #80cbc4;
--color-priority-low-bg: #1a3a38;
--color-priority-medium: #fdd835;
--color-priority-medium-bg: #3a3520;
--color-priority-high: #f44336;
--color-priority-high-bg: #3a1a1a;
--color-wikilink: #c4b5fd;
--color-wikilink-bg: #2a1a45;
--color-overdue: #f44336;
--color-code-bg: #12121a;
--color-code-inline-bg: #1a1a2a;
--color-table-stripe: #14141e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1a1a24;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04);
--color-bubble-user-border: rgba(255, 255, 255, 0.10);
--color-bubble-user-text: #b0b0c8;
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017;
--color-accent-warm-light: #e8c45a;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
Note: `--color-accent-warm`, `--color-accent-warm-light`, `--color-primary-solid`, and `--color-primary-deep` are new variables.
- [ ] **Step 2: Update the light theme palette**
In the `:root` block, update these values:
```css
:root {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
--color-bg-card: #ffffff;
--color-surface: #f0f0f8;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #7c3aed;
--color-danger: #d93025;
--color-tag-bg: #ede5ff;
--color-tag-text: #6d28d9;
--color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853;
--color-toast-error: #d93025;
--color-status-todo: #5f6368;
--color-status-todo-bg: #e8eaed;
--color-status-in-progress: #7c3aed;
--color-status-in-progress-bg: #ede5ff;
--color-status-done: #34a853;
--color-status-done-bg: #e6f4ea;
--color-priority-low: #5f9ea0;
--color-priority-low-bg: #e0f2f1;
--color-priority-medium: #f9a825;
--color-priority-medium-bg: #fff8e1;
--color-priority-high: #d93025;
--color-priority-high-bg: #fce8e6;
--color-wikilink: #7b1fa2;
--color-wikilink-bg: #f3e5f5;
--color-overdue: #d93025;
--color-code-bg: #f0f0f8;
--color-code-inline-bg: #eaeaf4;
--color-table-stripe: #f4f4fb;
--color-success: #22c55e;
--color-warning: #eab308;
--color-input-bar-bg: #eaeaf3;
--color-input-bar-text: #1a1a1a;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: rgba(0, 0, 0, 0.04);
--color-bubble-user-border: rgba(0, 0, 0, 0.10);
--color-bubble-user-text: #3a3a4a;
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
/* New brand variables */
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
}
```
- [ ] **Step 3: Update scrollbar color**
Find:
```css
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
}
```
Replace with:
```css
::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.25);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.45);
}
```
- [ ] **Step 4: Update focus ring**
Find:
```css
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
```
Replace with:
```css
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4);
```
- [ ] **Step 5: Update AppLogo gradient**
In `frontend/src/components/AppLogo.vue`, the logo uses `var(--color-primary)` which will automatically pick up the new violet value. No code change needed — the CSS variable update handles it.
However, add a gradient `<defs>` for the book fill to use the deep gradient instead of a flat color. Find the `<style scoped>` block:
```css
.logo-book {
fill: var(--color-primary);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
Replace with:
```css
.logo-book {
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
```
And add a gradient definition inside the `<svg>` element, before the `<!-- Book body -->` comment:
```html
<defs>
<linearGradient id="logo-gradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="var(--color-primary-solid)" />
<stop offset="100%" stop-color="var(--color-primary-deep)" />
</linearGradient>
</defs>
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/assets/theme.css frontend/src/components/AppLogo.vue
git commit -m "feat(theme): shift palette from indigo to deep violet + muted gold"
```
---
### Task 2: Signature header — pill nav + brand shortening + status pulse
**Files:**
- Modify: `frontend/src/components/AppHeader.vue`
- [ ] **Step 1: Update brand text in header**
Find:
```html
Fabled Assistant
```
Replace with:
```html
<span class="brand-text">Fabled</span>
```
- [ ] **Step 2: Wrap nav-center links in a pill container**
Find the `nav-center` div:
```html
<div class="nav-center">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
```
Replace with:
```html
<div class="nav-center">
<div class="nav-pill-bar">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
```
- [ ] **Step 3: Replace header and nav CSS**
Replace the entire `<style scoped>` from `.app-header` through `.nav-link.router-link-active` with:
```css
.app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.08);
position: relative;
}
.nav {
padding: 0.6rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Left — brand */
.nav-brand {
display: flex;
align-items: center;
gap: 0.45rem;
text-decoration: none;
flex-shrink: 0;
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
}
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: rgba(124, 58, 237, 0.06);
border-radius: 10px;
padding: 3px;
}
/* Right */
.nav-right {
display: flex;
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
}
.nav-link {
color: var(--color-text-muted);
text-decoration: none;
font-size: 0.82rem;
padding: 0.3rem 0.75rem;
border-radius: 8px;
transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
color: var(--color-text-secondary);
background: rgba(124, 58, 237, 0.08);
}
.nav-link.router-link-active {
color: #c4b5fd;
font-weight: 600;
background: rgba(124, 58, 237, 0.2);
box-shadow: 0 0 12px rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 4: Add status dot pulse animation for loaded state**
Find:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); }
```
Replace with:
```css
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; }
```
Find:
```css
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
```
Add after it:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74, 222, 128, 0.4); }
50% { box-shadow: 0 0 10px rgba(74, 222, 128, 0.6); }
}
```
- [ ] **Step 5: Update mobile menu active styling**
Find:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
}
```
Replace with:
```css
.mobile-menu .nav-link {
padding: 0.5rem 0.75rem;
min-height: 44px;
display: flex;
align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: rgba(124, 58, 237, 0.15);
box-shadow: none;
}
```
- [ ] **Step 6: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add frontend/src/components/AppHeader.vue
git commit -m "feat(header): pill nav bar, brand shortening, status pulse, header gradient"
```
---
### Task 3: Card type DNA — gradient bars, corner accents, hover bloom
**Files:**
- Modify: `frontend/src/views/KnowledgeView.vue`
**Context:** The cards currently have a left accent strip per type. The new design replaces this with top gradient bars (notes, tasks, lists) and corner accents (person, place), plus a unified violet hover bloom.
- [ ] **Step 1: Replace card accent strips with type-specific top bars and borders**
Find in the `<style scoped>`:
```css
/* Type accent strip */
.k-card--person { border-left: 3px solid #10b981; }
.k-card--place { border-left: 3px solid #f59e0b; }
.k-card--list { border-left: 3px solid #38bdf8; }
.k-card--note { border-left: 3px solid #6366f1; }
.k-card--task { border-left: 3px solid #a78bfa; }
```
Replace with:
```css
/* Type-specific card DNA */
.k-card--note { border-color: rgba(124, 58, 237, 0.12); }
.k-card--task { border-color: rgba(212, 160, 23, 0.10); }
.k-card--person { border-color: rgba(16, 185, 129, 0.10); }
.k-card--place { border-color: rgba(245, 158, 11, 0.10); }
.k-card--list { border-color: rgba(56, 189, 248, 0.10); }
/* Top gradient bars */
.k-card--note::before,
.k-card--task::before,
.k-card--list::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 3px;
border-radius: 14px 14px 0 0;
}
.k-card--note::before {
right: 0;
background: linear-gradient(90deg, #7c3aed, #a78bfa);
}
.k-card--task::before {
width: 50%;
background: linear-gradient(90deg, #d4a017, transparent);
}
.k-card--list::before {
right: 0;
background: linear-gradient(90deg, #38bdf8, #7dd3fc);
}
/* Corner accents for entity types */
.k-card--person::after,
.k-card--place::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 60px;
height: 60px;
border-radius: 0 14px 0 60px;
pointer-events: none;
}
.k-card--person::after { background: rgba(16, 185, 129, 0.06); }
.k-card--place::after { background: rgba(245, 158, 11, 0.06); }
```
- [ ] **Step 2: Update card hover to violet bloom**
Find:
```css
.k-card:hover {
border-color: rgba(255,255,255,0.14);
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
```
Replace with:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124, 58, 237, 0.15), 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: rgba(124, 58, 237, 0.2);
}
```
- [ ] **Step 3: Add sidebar section dividers**
Find:
```css
.filter-section { margin-bottom: 20px; }
```
Replace with:
```css
.filter-section { margin-bottom: 20px; }
.filter-section + .filter-section::before {
content: '· · ·';
display: block;
text-align: center;
color: rgba(124, 58, 237, 0.3);
font-size: 0.9rem;
letter-spacing: 0.4em;
padding: 4px 0 12px;
}
```
- [ ] **Step 4: Add scroll fade to card grid**
Find:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
```
Replace with:
```css
.card-grid {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 5: Update task card dates to use amber**
Find in the task card CSS:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
```
Replace with:
```css
.task-due {
font-size: 0.78rem;
color: var(--color-accent-warm);
}
```
- [ ] **Step 6: Add Fraunces view title and update sidebar labels**
Find the filter panel label CSS:
```css
.filter-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-muted);
margin-bottom: 6px;
padding: 0 4px;
}
```
Replace with:
```css
.filter-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.72rem;
letter-spacing: 0.02em;
color: var(--color-primary);
margin-bottom: 6px;
padding: 0 4px;
}
```
- [ ] **Step 7: Update empty state with Fraunces narrator voice**
Find:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p>Nothing here yet.</p>
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">Try clearing the filters.</p>
<p v-else class="empty-hint">Start by creating a note, saving a person or place, or making a list.</p>
</div>
```
Replace with:
```html
<div v-else-if="!loading && items.length === 0" class="knowledge-empty">
<p v-if="activeType || activeTag || searchQuery" class="empty-hint">No matches. Try clearing the filters.</p>
<p v-else class="empty-narrator">Your story is unwritten. Create your first note to begin.</p>
</div>
```
Add CSS:
```css
.empty-narrator {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 1rem;
color: var(--color-accent-warm);
opacity: 0.85;
}
```
- [ ] **Step 8: Update card date stamps to amber**
Find:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
```
Replace with:
```css
.k-card-date { font-size: 0.72rem; color: var(--color-accent-warm); white-space: nowrap; opacity: 0.7; }
```
- [ ] **Step 9: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 10: Commit**
```bash
git add frontend/src/views/KnowledgeView.vue
git commit -m "feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states"
```
---
### Task 4: ChatPanel + BriefingView + CalendarView — empty states + glow buttons
**Files:**
- Modify: `frontend/src/components/ChatPanel.vue`
- Modify: `frontend/src/views/BriefingView.vue`
- Modify: `frontend/src/views/CalendarView.vue`
- Modify: `frontend/src/App.vue`
- [ ] **Step 1: Update ChatPanel empty state**
In `frontend/src/components/ChatPanel.vue`, find:
```html
>Send a message to start the conversation.</p>
```
Replace with:
```html
>Start a conversation.</p>
```
Find the `.empty-msg` CSS:
```css
.empty-msg {
```
Add these properties (find the existing block and add to it):
```css
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: var(--color-accent-warm, #d4a017);
```
- [ ] **Step 2: Add scroll fade to ChatPanel messages**
Find the `.messages-container` CSS in ChatPanel.vue. Add:
```css
mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
-webkit-mask-image: linear-gradient(to bottom, transparent, black 20px, black calc(100% - 20px), transparent);
```
- [ ] **Step 3: Update CalendarView empty state**
In `frontend/src/views/CalendarView.vue`, find:
```html
return ListView(
children: const [
SizedBox(height: 80),
Center(child: Text('No events')),
],
);
```
Wait — that's the Flutter file. In the web CalendarView, there's no dedicated empty state text to update since it's a FullCalendar component. Skip this for the web CalendarView — it doesn't have a custom empty state.
- [ ] **Step 4: Add glow to primary action buttons in App.vue global styles**
In `frontend/src/App.vue`, find the `<style>` block (the global unscoped one). The `btn-send` styles are in `ChatInputBar.vue` which is scoped. Instead, add a global hover glow rule. Find the existing `.app-footer` style and add after it:
No — the glow should be on the specific button components. The `btn-send` in `ChatInputBar.vue` already has a hover shadow. Let me update it there.
In `frontend/src/components/ChatInputBar.vue`, find:
```css
.btn-send:hover { box-shadow: 0 0 12px rgba(99, 102, 241, 0.5); }
```
Replace with:
```css
.btn-send:hover { box-shadow: 0 0 16px rgba(124, 58, 237, 0.35); }
```
- [ ] **Step 5: Update KnowledgeView new-note button glow**
In `frontend/src/views/KnowledgeView.vue`, find:
```css
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
```
Replace with:
```css
.btn-new-note:hover { background: rgba(124, 58, 237, 0.2); box-shadow: 0 0 12px rgba(124, 58, 237, 0.25); }
```
- [ ] **Step 6: Update any remaining hardcoded indigo references in KnowledgeView**
Search for `99, 102, 241` in KnowledgeView.vue and replace with `124, 58, 237`. This covers all the rgba references in filter buttons, borders, today bar chips, etc.
Use find-and-replace across the file: `99, 102, 241``124, 58, 237`
- [ ] **Step 7: Update hardcoded indigo in BriefingView**
Search for `99, 102, 241` in BriefingView.vue and replace with `124, 58, 237`.
Search for `6366f1` in BriefingView.vue and replace with `7c3aed`.
- [ ] **Step 8: Update hardcoded indigo in AppHeader**
Search for `99, 102, 241` in AppHeader.vue and replace with `124, 58, 237` (for any remaining references not covered by Task 2).
- [ ] **Step 9: Update hardcoded indigo in App.vue shortcuts overlay**
Search for `99, 102, 241` in App.vue and replace with `124, 58, 237`.
Search for `6366f1` in App.vue and replace with `7c3aed`.
- [ ] **Step 10: Verify TypeScript compiles**
```bash
cd frontend && npx tsc --noEmit
```
- [ ] **Step 11: Commit**
```bash
git add frontend/src/components/ChatPanel.vue frontend/src/components/ChatInputBar.vue \
frontend/src/views/KnowledgeView.vue frontend/src/views/BriefingView.vue \
frontend/src/components/AppHeader.vue frontend/src/App.vue
git commit -m "feat: narrator empty states, scroll fades, glow buttons, violet color sweep"
```
@@ -1,216 +0,0 @@
# ChatPanel Unification Design
**Date:** 2026-04-03
## Goal
Replace the four divergent chat surfaces (ChatView, BriefingView, WorkspaceView, HomeView widget) with a single `ChatPanel` component that encapsulates all chat behaviour — streaming, TTS, PTT, tool calls, thinking blocks, abort — so that fixes and features automatically apply to every context.
---
## Background
The app currently has four independent chat implementations that have drifted significantly:
| Surface | File | Gap |
|---|---|---|
| Main chat | `ChatView.vue` | Canonical reference |
| Briefing | `BriefingView.vue` | Had separate TTS impl (now fixed), no PTT, streaming race bug |
| Workspace | `WorkspaceView.vue` | TTS missing until recently, different input wiring |
| Dashboard widget | `HomeView.vue` + `DashboardChatInput.vue` | Separate input component, response rendered manually in parent, no TTS, no PTT |
Every fix to chat has required touching 34 files. This design makes chat a first-class component.
---
## Architecture
### Component: `ChatPanel.vue`
A single Vue 3 component that owns the entire chat interaction loop for a given conversation context. Two variants controlled by a `variant` prop:
- **`full`** — full-height chat: message history, streaming bubble, input bar, all controls
- **`widget`** — compact embedded chat: input bar + compact response area, no history scroll
Both variants share identical internals: same composables, same store reads, same TTS/PTT/abort logic.
### Extracted Sub-components
| Component | Responsibility |
|---|---|
| `ChatInputBar.vue` | Unified input bar: textarea, note picker, PTT mic, send button, abort button |
| `ChatMessageList.vue` | Scrollable message history with auto-scroll, bulk-select (full variant only) |
| `ChatStreamingBubble.vue` | Live streaming content display + thinking block |
| `ChatToolCallList.vue` | Tool call cards, collapsed/expanded state |
### State Ownership
`ChatPanel` reads from `useChatStore` directly — it does not accept messages or streaming state as props. This mirrors how all current views work and avoids prop-drilling re-implementation.
The conversation being displayed is controlled via a `convId` prop. When `convId` is undefined, `ChatPanel` uses `chatStore.currentConversationId`. The parent view sets up the conversation (creates it if needed) and passes the ID down.
---
## Props & Emits Interface
```typescript
interface ChatPanelProps {
variant: 'full' | 'widget'
convId?: number // which conversation to display; undefined = store current
projectId?: number // workspace: pins RAG scope, passed to sendMessage
briefingMode?: boolean // briefing: hides RAG scope chip, enables briefing-specific send path
placeholder?: string // input placeholder text
autoFocus?: boolean // focus input on mount
}
interface ChatPanelEmits {
// Emitted when a new conversation is started from the widget (so parent can track convId)
(e: 'conversation-started', convId: number): void
}
```
All other behaviour (TTS, PTT, thinking, tool calls, streaming indicator, abort) is always on — not gated by props. The intentional differences between views are expressed only through the props above.
---
## Variant Behaviour
### `variant="full"` (ChatView, BriefingView, WorkspaceView)
Layout (top to bottom):
```
┌────────────────────────────────────────┐
│ [RAG scope chip / briefing header] │ ← shown unless briefingMode or projectId set
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatMessageList │
│ user bubble │
│ assistant bubble + tool calls │
│ thinking block (always shown) │
│ ... │
│ ChatStreamingBubble (while streaming) │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ChatInputBar │
│ [textarea] [note-picker] [mic] [▶] │
│ [listen toggle] [abort] │
└────────────────────────────────────────┘
```
### `variant="widget"` (HomeView dashboard)
Layout (top to bottom, compact):
```
┌────────────────────────────────────────┐
│ ChatInputBar (pill style) │
│ [textarea] [mic] [▶] │
├────────────────────────────────────────┤
│ [query text] (after send) │
│ [streaming / final response text] │
│ [tool call chips] │
│ [Continue in Chat →] │
└────────────────────────────────────────┘
```
The widget variant does NOT show full message history. It shows only the most recent exchange. Once a new conversation is started or the user navigates to `/chat/:id`, the full history is available.
The `.dashboard-response` section currently in `HomeView.vue` moves inside `ChatPanel` and is rendered when `variant="widget"` and a conversation exists.
---
## TTS / PTT Wiring
`ChatPanel` instantiates `useStreamingTts` and `useListenMode` internally. These are not passed as props.
```typescript
// Inside ChatPanel setup()
const listenMode = useListenMode()
const voiceTtsEnabled = computed(() => /* same check as current views */)
const tts = useStreamingTts({
streamingContent: computed(() => chatStore.streamingContent),
streaming: computed(() => !!chatStore.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
```
PTT is handled inside `ChatInputBar` via the existing `useVoiceRecorder` composable (already used in `DashboardChatInput`). On recording stop, the transcribed text is placed in the textarea and auto-submitted.
---
## Per-View Migration
### ChatView → `<ChatPanel variant="full">`
- Remove: all TTS/PTT/streaming/abort logic, scroll management, input bar template
- Keep: route wiring, conversation list sidebar, bulk-delete UI (sidebar stays in ChatView)
- ChatPanel replaces only the right-hand panel
### BriefingView → `<ChatPanel variant="full" briefingMode />`
- Remove: streaming watch, TTS, manual scroll, input bar, response persistence workaround
- Keep: history dropdown (today / past briefings), date header
- `briefingMode` hides the RAG scope chip
### WorkspaceView → `<ChatPanel variant="full" :projectId="projectId">`
- Remove: inline chat input, streaming watch, TTS wiring
- Keep: 3-panel grid layout, task panel, note editor panel
- ChatPanel takes the centre column
### HomeView → `<ChatPanel variant="widget">`
- Remove: `DashboardChatInput` import + usage, `.dashboard-response` section, all manual store wiring (`dashboardConvId`, `dashboardQuery`, `dashboardFinalContent`, `dashboardFinalToolCalls`, `onChatSubmit`)
- Keep: dashboard layout, projects/tasks/events sections
- `DashboardChatInput.vue` deleted
---
## Data Flow
```
Parent view
└─ <ChatPanel :convId="convId" variant="full|widget">
├─ reads: useChatStore (messages, streaming, streamingContent, currentConversation)
├─ ChatMessageList — renders history from store
├─ ChatStreamingBubble — renders chatStore.streamingContent while streaming
├─ ChatToolCallList — renders tool calls from streaming + finalized messages
├─ ChatInputBar
│ ├─ usePtt (mic → textarea → auto-send)
│ └─ emits: submit(content, contextNoteId)
├─ useStreamingTts (sentence-chunk TTS during streaming)
└─ useListenMode (shared global toggle)
```
---
## Files Created / Modified
**Created:**
- `frontend/src/components/ChatPanel.vue`
- `frontend/src/components/ChatInputBar.vue`
- `frontend/src/components/ChatMessageList.vue`
- `frontend/src/components/ChatStreamingBubble.vue`
- (no new composable needed — PTT uses existing `useVoiceRecorder.ts`)
**Modified:**
- `frontend/src/views/ChatView.vue` — use ChatPanel for the chat area
- `frontend/src/views/BriefingView.vue` — replace chat section with ChatPanel
- `frontend/src/views/WorkspaceView.vue` — replace inline chat with ChatPanel
- `frontend/src/views/HomeView.vue` — replace DashboardChatInput + response section with ChatPanel widget
**Deleted:**
- `frontend/src/components/DashboardChatInput.vue`
---
## CSS / Styling
- `ChatPanel` carries its own scoped CSS for both variants
- `ChatInputBar` replicates the pill style currently in `DashboardChatInput` and the flat style in `ChatView` — variant is controlled by a `pill` boolean prop (default false; widget sets it true)
- All existing UI design language tokens (`--color-primary`, `--radius-lg`, Fraunces labels, gradient send button) are preserved
---
## What Does NOT Change
- Chat store (`useChatStore`) — unchanged
- API client (`client.ts`) — unchanged
- Backend routes — unchanged
- WorkspaceTaskPanel and WorkspaceNoteEditor — unchanged
- Briefing history dropdown and date header — unchanged
- ChatView conversation sidebar and bulk-delete — unchanged
- RAG scope chip logic — moved inside ChatPanel, behaviour identical
@@ -1,101 +0,0 @@
# Streaming TTS Design
**Date:** 2026-04-03
**Status:** Approved
## Goal
Start playing TTS audio during LLM generation rather than waiting for the full response to finish. When listen mode is on, the first sentence plays as soon as Kokoro finishes synthesizing it — while the LLM is still streaming the rest of the response.
## Approach
Client-side sentence queuing composable. The frontend accumulates streaming tokens, detects sentence boundaries, fires per-sentence synthesis requests concurrently, and plays audio in strict insertion order. The existing `/api/voice/synthesise` backend endpoint is unchanged.
## Architecture
### `useStreamingTts` composable
**File:** `frontend/src/composables/useStreamingTts.ts`
**Inputs:**
- `streamingContent: Ref<string>` — the growing accumulated response text (e.g. `store.streamingContent`)
- `streaming: Ref<boolean>` — whether the LLM is currently generating
- `enabled: Ref<boolean>``true` when listen mode is on AND TTS is available
**Exports:**
- `speaking: Ref<boolean>``true` while any synthesis is in-flight or audio is playing
- `stop()` — cancels all pending synthesis/playback and clears the queue
**Internal state:**
- `sentenceBuffer: string` — accumulates characters since the last dispatched sentence
- `lastSeenLength: number` — tracks how far into `streamingContent` we've processed
- `abortId: number` — incremented on `stop()`; each queued promise checks the current id and bails if stale
- `playQueue: Promise<void>` — a chained promise that serializes audio playback in insertion order
**Sentence detection:**
- Regex: `/[.!?]+(?=\s|$)/` — handles `...`, `?!`, multi-punctuation
- Triggered on every `streamingContent` change and on `streaming` flipping `false` (flush)
- Fragments < 3 characters after markdown stripping are skipped
**Per-sentence pipeline:**
1. Strip markdown (same logic as current `speakLastAssistantMessage`)
2. Fire `synthesiseSpeech(sentence)` immediately — runs concurrently with other sentences
3. On failure: one immediate retry. If retry also fails, skip silently and advance the queue
4. Resolved blob is inserted into the playback queue at its original position
5. Playback queue plays blobs strictly in insertion order via `useVoiceAudio`
**Stream-end flush:**
- When `streaming` flips `false`, any remaining `sentenceBuffer` content (fragment without terminal punctuation) is dispatched as a final sentence — covers responses that end without a period
**Automatic reset:**
- When `streaming` flips `true` (new message starting), `stop()` is called automatically to cancel any in-flight audio from the previous response before starting fresh
### Views updated
| View | Change |
|------|--------|
| `ChatView.vue` | Replace `speakLastAssistantMessage()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
| `BriefingView.vue` | Replace `speakText()` + `watch(streaming)` + `synthesising` ref with `useStreamingTts` |
| `WorkspaceView.vue` | Add listen mode toggle button (same UI pattern as ChatView) + `useStreamingTts` wired to workspace chat stream |
In all three views: the `speaking` export from `useStreamingTts` replaces the old `synthesising || audio.playing.value` checks for button busy state.
### Backend
No changes. `/api/voice/synthesise` accepts shorter sentence-length strings without issue.
## Error Handling
| Scenario | Behavior |
|----------|----------|
| Synthesis fails for a sentence | One immediate retry; if retry fails, sentence is skipped, queue advances, and a `console.warn` is emitted with the sentence index and error |
| `stop()` called mid-queue | `abortId` incremented; all in-flight promises check id and discard their result |
| New message starts while audio playing | `watch(streaming, true → ...)` calls `stop()` before starting new queue |
| TTS unavailable or listen mode off | Composable is inert — watchers do nothing, no requests fired |
| Fragment < 3 chars after stripping | Skipped without a TTS request |
| Response ends without terminal punctuation | Remaining buffer flushed as final sentence on stream-end |
## Data Flow
```
LLM SSE chunks → store.streamingContent (grows)
useStreamingTts watcher
sentenceBuffer accumulation
sentence boundary detected? → synthesiseSpeech(sentence) [concurrent]
↓ ↓ (fail → 1 retry → skip)
playQueue.then(play blob) resolved blob
useVoiceAudio.play() [sequential]
audio output
```
## Files Changed
- **New:** `frontend/src/composables/useStreamingTts.ts`
- **Modified:** `frontend/src/views/ChatView.vue` — swap TTS logic for composable
- **Modified:** `frontend/src/views/BriefingView.vue` — swap TTS logic for composable
- **Modified:** `frontend/src/views/WorkspaceView.vue` — add listen mode + composable
@@ -1,227 +0,0 @@
# Article Reading Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
**Architecture:** Four self-contained changes — history reconstruction fix (prerequisite), `read_article` tool, Discuss endpoint, and content cap removal.
**Tech Stack:** Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
---
## Problem summary
Three interrelated issues observed in briefing conversations:
1. **Missing `read_article` tool** — when a user pastes a URL, the LLM calls `search_web` (a SearXNG text search), which returns generic site descriptions instead of article content.
2. **History reconstruction bug**`routes/chat.py:166` builds the `history` list with only `role` + `content`, silently dropping all `tool_calls` and their results from prior turns. Tool context is lost on every follow-up.
3. **Discuss button UX** — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
---
## Components
### 1. History reconstruction fix
**File:** `src/fabledassistant/routes/chat.py`
The loop at line ~164 that builds `history` must be updated to replay tool exchanges:
```python
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
```
The `tool_calls` JSONB column already stores `[{function, arguments, result}]` per call. No schema change needed.
### 2. `read_article` tool
**Files:** `src/fabledassistant/services/research.py`, `src/fabledassistant/services/tools.py`, `src/fabledassistant/services/rss.py`
Move `_fetch_full_article` from `rss.py` to `research.py` (imported back into `rss.py` to avoid breaking existing calls). This makes it available to `execute_tool` without a circular import.
Tool definition added to `_TOOLS` in `tools.py`:
```python
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, "
"or to get the full content of a linked page. "
"Do not use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch"}
},
"required": ["url"],
},
},
}
```
`execute_tool` handler:
```python
elif tool_name == "read_article":
from fabledassistant.services.research import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
TOOL_CONTENT_CAP = 40_000
truncated = len(content) > TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:TOOL_CONTENT_CAP],
"truncated": truncated,
}
```
### 3. `add_message` — add `tool_calls` parameter
**File:** `src/fabledassistant/services/chat.py`
`add_message` needs to accept and store `tool_calls` so the Discuss endpoint can create synthetic messages:
```python
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
```
Set `msg.tool_calls = tool_calls` when provided.
### 4. Discuss endpoint
**File:** `src/fabledassistant/routes/briefing.py`
New route: `POST /api/briefing/articles/<int:item_id>/discuss`
Request body: `{"conv_id": <int>}`
Steps:
1. Look up `rss_items` row by `item_id` — verify it belongs to the user via feed ownership. Return 404 if not found.
2. Look up conversation by `conv_id` — verify it belongs to the user. Return 404 if not found.
3. If generation already running for `conv_id` → return 409.
4. Fetch stored content: `article_content = item.content or item.snippet or ""`
5. Store synthetic assistant message (status=`"complete"`, role=`"assistant"`, content=`""`, tool_calls as below):
```python
synthetic_tool_calls = [{
"function": "read_article",
"arguments": {"url": item.url},
"result": {
"success": True,
"type": "article_content",
"url": item.url,
"content": article_content,
"truncated": False,
},
}]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
```
6. Store user message: `await add_message(conv_id, "user", "Please summarize and discuss this article.")`
7. Build `history` from `conv.messages` (using the fixed builder above).
8. Create assistant placeholder, create buffer, launch `run_generation` as normal.
9. Return `{"assistant_message_id": ..., "status": "generating"}` 202.
### 5. Frontend: BriefingView.vue
**File:** `frontend/src/views/BriefingView.vue`
Replace `discussArticle()`:
```typescript
async function discussArticle(item: NewsItem) {
if (!todayConvId.value) return
if (!isToday.value) selectedConvId.value = todayConvId.value
await nextTick(() => {
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
conv_id: todayConvId.value,
})
// Re-fetch conversation so the new messages appear, then start SSE streaming.
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
await chatStore.fetchConversation(todayConvId.value)
chatStore.startStreaming(todayConvId.value)
}
```
The exact method names (`fetchConversation`, `startStreaming`) should match what `BriefingView.vue` already uses for the reply flow — confirm during implementation.
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a `read_article` tool call card (already handled by `ToolCallCard.vue`).
### 6. Content cap removal
**File:** `src/fabledassistant/services/rss.py`
Remove `[:CONTENT_MAX_CHARS]` from:
- `content = _html_to_text(content)[:CONTENT_MAX_CHARS]` in `extract_item()`
- `item.content = full_text[:CONTENT_MAX_CHARS]` in the enrichment task
The `CONTENT_MAX_CHARS` constant can be removed entirely. Trafilatura extracts only article body text (typically 2K15K chars for news articles), so content is naturally bounded.
---
## Data flow
### User pastes a URL in chat
1. User sends message with a URL
2. LLM calls `read_article(url)`
3. `execute_tool` calls `_fetch_full_article(url)` → trafilatura extracts clean text
4. Tool result appended in-memory as `{role: "tool", content: json}`
5. LLM responds based on article content
6. Generation saves assistant message with `tool_calls=[{function:"read_article", arguments, result}]`
7. Follow-up turns: history builder replays tool_call + tool result → article stays in context
### User clicks Discuss on a briefing article
1. Frontend calls `POST /api/briefing/articles/{item_id}/discuss` with `{conv_id}`
2. Backend fetches stored article text from DB (no network request)
3. Backend stores synthetic assistant message with `read_article` tool result
4. Backend stores user message `"Please summarize and discuss this article."`
5. Generation runs — LLM sees pre-loaded article in history
6. Follow-ups retain context via fixed history builder
---
## Error handling
| Scenario | Behaviour |
|---|---|
| `_fetch_full_article` returns `None` (network/extraction failure) | Tool returns `{success: False, error: "Could not fetch article content from [url]"}` — LLM reports conversationally |
| Discuss: `item_id` not found or wrong user | 404 |
| Discuss: `conv_id` not found or wrong user | 404 |
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
| Discuss: generation already running | 409 |
| Messages with `tool_calls = None` | History builder unchanged — no regression for existing conversations |
---
## Tests
- **Unit:** `_fetch_full_article` returns `None` → `read_article` tool result has `success: False`
- **Unit:** History builder with a stored message that has `tool_calls` → output includes assistant tool_call dict + a `{role: "tool"}` dict
- **Unit:** History builder with messages where `tool_calls = None` → output unchanged from current behaviour
- **Integration:** `POST /api/briefing/articles/{item_id}/discuss` → two messages stored (synthetic assistant + user message), generation triggered, returns 202
@@ -1,162 +0,0 @@
# Research Pipeline — Multi-Note Redesign
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the single monolithic research note with a set of focused, topic-driven notes plus an index note that links them — making research output browsable, TTS-friendly, and well-organized.
**Architecture:** Two new LLM calls (outline generation + N parallel section syntheses) replace the single large synthesis call. Public API unchanged — callers receive the index note. Fallback to single-note behavior on any outline failure.
**Tech Stack:** Python/Quart backend, existing `research.py` service, asyncio.gather for parallelism.
---
## Problem
The current pipeline synthesizes one note with a minimum of 2500 words and 6 sections. This creates:
- Notes too large to read or listen to comfortably
- No way to navigate directly to a specific sub-topic
- TTS failures on long prose (8000-char route limit, unbounded sentence buffers)
---
## Pipeline Flow
Public signature unchanged:
```python
async def run_research_pipeline(
topic: str,
user_id: int,
model: str,
buf=None,
project_id: int | None = None,
) -> Note: # returns the index note
```
Execution order:
```
1. Generate sub-queries (unchanged)
2. Search + fetch sources (unchanged)
3. Generate topic outline (NEW — one LLM call → 37 section dicts)
4. Synthesize each section note (NEW — parallelized via asyncio.gather)
5. Create all section notes in DB (sequential, tagged ["research"], same project_id)
6. Create index note (NEW — links all sections)
7. Return index note
```
Status messages via `buf.append_event("status", ...)`:
- `"Generating outline…"`
- `"Writing: [Section Title]…"` (one per section, emitted before synthesis starts)
- `"Saving [N] notes…"`
No note content is streamed into chat. After the tool call resolves, the LLM writes a brief conversational summary citing the index note title and section count.
---
## Outline Generation
New function: `_generate_outline(topic, sources, model) -> list[dict]`
Sends all fetched sources to the model with a prompt requesting a JSON array:
```json
[
{"title": "Quantum Entanglement: Mechanisms", "focus": "How entanglement works at the physical level"},
{"title": "Quantum Computing Hardware", "focus": "Ion traps, superconducting qubits, photonic approaches"}
]
```
**Prompt requirements:**
- Produce 37 sections covering distinct aspects of the topic
- Titles must work as standalone note titles (no "Overview" or "Introduction" generics)
- No overlap between sections
- `focus` is one sentence describing what this section should specifically cover
**Guardrails:**
- Fewer than 3 sections parsed → fall back to single-note synthesis
- JSON parse failure → fall back to single-note synthesis
- More than 8 sections → truncate to 8
**Model params:** `max_tokens=400, num_ctx=16384` (outline is short)
---
## Section Synthesis
New function: `_synthesize_section(section_title, section_focus, sources, model) -> tuple[str, str]`
Returns `(title, body_markdown)`.
All sections receive all fetched sources. The `section_focus` field in the prompt directs the model to draw only what's relevant to that section's scope.
**Prompt requirements:**
- 300600 words of substantive prose
- Do NOT include a `# Title` heading (title is set separately)
- End with a brief `## Sources` list of relevant URLs from the provided sources
- Focus strictly on `section_focus` — ignore source material outside that scope
**Model params:** `num_predict=2048, num_ctx=16384` (reduced from 8192 — sufficient for 600 words, prevents rambling)
**Parallelism:** All section synthesis calls run via `asyncio.gather`. Wall-clock time stays close to a single synthesis call despite producing N notes.
---
## Note Creation and Index Note
**Section notes:**
- Tags: `["research"]`
- `project_id`: same as passed to pipeline (or None)
- Title: from outline `title` field
- Created sequentially (avoids DB contention)
**Index note:**
- Tags: `["research", "research-index"]`
- `project_id`: same as section notes
- Title: `"Research: [topic]"`
- Created last (after all section notes exist)
**Index note body format:**
```markdown
Research overview for **[topic]** — [YYYY-MM-DD]
Generated from [N] web sources across [M] sections.
## Sections
- **[Section 1 Title]** — [focus sentence]
- **[Section 2 Title]** — [focus sentence]
...
*Search for any section title to read it.*
```
The index note is what `run_research_pipeline` returns. The existing `research_topic` tool handler uses `note.id` and `note.title` — both remain valid with the index note.
---
## Error Handling
| Scenario | Behaviour |
|---|---|
| Outline generation raises | Fall back to single-note synthesis (current behaviour) |
| Outline JSON unparseable | Fall back to single-note synthesis |
| Outline returns < 3 sections | Fall back to single-note synthesis |
| Outline returns > 8 sections | Truncate to 8, continue |
| A section synthesis raises | Log warning, skip that section; continue with remaining |
| All section syntheses fail | Fall back to single-note synthesis |
| A section note DB save fails | Log warning, skip from index; index note still created |
| No sources fetched | Raise `ValueError` as today — unchanged |
The fallback in every case is the current single-note pipeline. Research never silently produces nothing.
---
## What Is NOT Changing
- Public function signature of `run_research_pipeline`
- Sub-query generation (`_generate_sub_queries`)
- SearXNG search and URL fetching
- `_search_searxng`, `_search_searxng_images`, `fetch_url_content`
- The `research_topic` tool definition and handler in `tools.py`
- The `quick_capture` research path
- Any frontend component
@@ -1,204 +0,0 @@
# Settings Consistency Pass — Design
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix five interrelated gaps in the settings UI — missing timezone field, SSO-unaware account tab, duplicated work schedule, ignored slot toggles, and timezone changes not propagating to the briefing scheduler.
**Architecture:** Primarily frontend cleanup with two focused backend hooks: settings PUT route gains a timezone→scheduler bridge; briefing scheduler gains slot-gating and work-day awareness.
**Tech Stack:** Vue 3 + TypeScript frontend; Python/Quart backend; APScheduler; `zoneinfo`.
---
## Problem summary
1. **No timezone field**`user_timezone` is read by the scheduler and the chat pipeline but is never exposed in the UI. The briefing tab displays the browser's detected timezone but never persists it. Scheduler falls back to UTC.
2. **Account tab ignores SSO** — "Email Address" and "Change Password" sections are shown to SSO users (`has_password = false`) even though they cannot change credentials here.
3. **Work schedule duplicated** — Profile tab has the canonical work schedule (days + start/end time, stored in `profile.work_schedule`). Briefing tab has a redundant "Office Days" section (`briefing_config.work_days`) that the backend never reads.
4. **Slot toggles are decorative** — The briefing tab's four slot checkboxes are saved to `briefing_config.slots` but `_add_user_jobs` schedules all four slots unconditionally.
5. **Timezone setting not propagated**`PUT /api/settings` saves `user_timezone` to the DB but does not call `update_user_schedule`, so the in-memory scheduler keeps the stale timezone until restart or briefing config re-save.
---
## Components
### 1. General tab — Timezone field
**File:** `frontend/src/views/SettingsView.vue`
New section in the General tab (after the Assistant section, before Model Management):
```html
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">Used to schedule briefings and format times in chat.</p>
<div class="field">
<label for="user-timezone">Your timezone</label>
<div style="display:flex; gap:0.5rem; align-items:center">
<input id="user-timezone" v-model="userTimezone" type="text"
class="input" placeholder="e.g. America/New_York" />
<button class="btn-secondary" type="button" @click="detectTimezone">Detect</button>
</div>
<p class="field-hint">IANA timezone name (e.g. America/Chicago, Europe/London).</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveTimezone" :disabled="savingTimezone">
{{ savingTimezone ? 'Saving…' : 'Save' }}
</button>
<span v-if="timezoneSaved" class="saved-msg">Saved!</span>
</div>
</section>
```
- `detectTimezone()` sets `userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone`
- `saveTimezone()` calls `PUT /api/settings` with `{ user_timezone: userTimezone }`
- Loaded in `onMounted` / general settings load alongside `assistantName`, `defaultModel`
The briefing tab's "Firing in timezone" hint changes from the live Intl API to reading the stored `user_timezone` value:
```
Firing in timezone: <strong>{{ userTimezone || 'UTC (not set)' }}</strong>
```
### 2. Account tab — SSO guard
**File:** `frontend/src/views/SettingsView.vue`
Wrap the Email and Password sections:
```html
<!-- SSO info banner (shown when no local password) -->
<section v-if="!authStore.user?.has_password" class="settings-section">
<h2>Account</h2>
<p class="section-desc">
Your account is managed by an external identity provider.
Email and password changes are made through your provider, not here.
</p>
</section>
<!-- Local-auth sections (hidden for SSO) -->
<template v-if="authStore.user?.has_password">
<section class="settings-section"> <!-- Email Address --> </section>
<section class="settings-section"> <!-- Change Password --> </section>
</template>
<!-- Active Sessions — always shown -->
<section class="settings-section"> ... </section>
```
No backend change needed — the API already rejects email/password changes for SSO accounts.
### 3. Briefing tab — Remove Office Days
**File:** `frontend/src/views/SettingsView.vue`
Delete the "Office Days" `<section>` (lines ~20682082). The `briefing_config.work_days` field can remain in the config object for backwards compatibility but the UI stops writing it.
The slot toggles section stays — it now actually drives scheduling (see §5).
### 4. Backend — settings PUT propagates timezone to scheduler
**File:** `src/fabledassistant/routes/settings.py`
After `set_settings_batch`, add:
```python
if "user_timezone" in to_save:
import json
from fabledassistant.services.briefing_scheduler import update_user_schedule
config_raw = await get_setting(uid, "briefing_config", "{}")
try:
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
except Exception:
config = {}
if config.get("enabled"):
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
```
### 5. Backend — scheduler respects slot toggles and work days
**File:** `src/fabledassistant/services/briefing_scheduler.py`
**5a. `_add_user_jobs` — only schedule enabled slots**
Change signature to accept `config: dict`:
```python
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
enabled_slots = (config or {}).get("slots", {})
for slot_name, hour, minute in SLOTS:
# Default True for compilation (always run); others respect toggle
if slot_name != "compilation" and not enabled_slots.get(slot_name, True):
jid = _job_id(user_id, slot_name)
if _scheduler and _scheduler.get_job(jid):
_scheduler.remove_job(jid)
continue
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(hour=hour, minute=minute, timezone=tz),
args=[user_id, slot_name],
id=_job_id(user_id, slot_name),
replace_existing=True,
misfire_grace_time=3600,
)
```
Update callers:
- `update_user_schedule(user_id, config, tz_override)` → pass `config` to `_add_user_jobs`
- `start_briefing_scheduler` startup loop → fetch full config to pass through
**5b. `_run_slot_for_user` — skip morning on non-work days**
For the `morning` slot, check today against `profile.work_schedule.days`:
```python
if slot == "morning":
from fabledassistant.services.user_profile import get_profile
from datetime import datetime
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_str)
except Exception:
user_tz = ZoneInfo("UTC")
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
profile = await get_profile(user_id)
work_days = (profile.work_schedule or {}).get("days", ["Mon","Tue","Wed","Thu","Fri"])
if today_abbr not in work_days:
logger.info("Skipping morning slot for user %d%s not a work day", user_id, today_abbr)
return
```
Note: `get_profile` must be importable from `user_profile.py` — confirm signature during implementation.
---
## Data flow
1. User opens Settings → General tab loads, reads `user_timezone` from `GET /api/settings`, populates the field
2. User clicks Detect → browser timezone fills the field
3. User clicks Save → `PUT /api/settings {user_timezone: "America/New_York"}` → backend saves and immediately calls `update_user_schedule` if briefing enabled
4. Briefing tab "Firing in timezone" now shows stored value instead of live browser API
5. Next 8am job: scheduler checks if `morning` is enabled in `briefing_config.slots`, then checks if today is in `profile.work_schedule.days` before running
---
## Error handling
| Scenario | Behaviour |
|---|---|
| `user_timezone` saved as empty string | `update_user_schedule` called with `tz_override=None` → falls back to `briefing_config.timezone` or UTC |
| Invalid IANA string saved | `_resolve_timezone` already falls back to UTC with a warning log |
| `profile.work_schedule` is None | `morning` slot defaults to MonFri |
| Slot toggles key missing from config | All non-compilation slots default to enabled (`True`) — no regression for existing users |
| SSO user visits Account tab | Sees info banner; email/password forms hidden; no API calls attempted |
---
## What is NOT changing
- Profile "Interests" and Briefing "News Preferences" remain separate — they serve different purposes (system-prompt personalisation vs RSS topic filtering)
- `briefing_config.work_days` field is not deleted from existing configs — just stops being written by the UI
- No migration needed — `profile.work_schedule.days` already exists; scheduler change is additive
@@ -1,278 +0,0 @@
# Web Voice Overlay Polish — Implementation Spec
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship the dormant `VoiceOverlay` component by mounting it, wiring the Space bar shortcut, and replacing push-to-talk with click-to-toggle silence detection.
**Architecture:** A new `useSilenceDetector` composable wraps the Web Audio API `AnalyserNode` and fires a callback when sustained silence is detected. `VoiceOverlay` coordinates `useVoiceRecorder` and `useSilenceDetector`, switching from hold-to-record to click-to-toggle. `App.vue` mounts the overlay and adds the Space bar handler.
**Tech Stack:** Vue 3 Composition API, Web Audio API (`AnalyserNode`), existing `useVoiceRecorder` / `useVoiceAudio` composables, TypeScript.
---
## File Map
| Action | Path |
|--------|------|
| Create | `frontend/src/composables/useSilenceDetector.ts` |
| Modify | `frontend/src/composables/useVoiceRecorder.ts` |
| Modify | `frontend/src/components/VoiceOverlay.vue` |
| Modify | `frontend/src/App.vue` |
---
## Task 1: `useSilenceDetector` composable
**Files:**
- Create: `frontend/src/composables/useSilenceDetector.ts`
### Interface
```ts
export interface SilenceDetectorOptions {
thresholdDb?: number // default -40
silenceDurationMs?: number // default 1500
minRecordingMs?: number // default 500
}
export function useSilenceDetector(options?: SilenceDetectorOptions): {
amplitude: Readonly<Ref<number>> // 01, for visualization
start(stream: MediaStream, onSilence: () => void): void
stop(): void
}
```
### Behaviour
- `start(stream, onSilence)`:
1. Creates `AudioContext`
2. `createMediaStreamSource(stream)` → connects to `AnalyserNode` (fftSize 256)
3. Records `startedAt = Date.now()`
4. Starts a `setInterval` at 100ms that:
- Calls `analyser.getByteFrequencyData(dataArray)`
- Computes RMS amplitude → maps to 01 range for `amplitude.value`
- Converts to approximate dB: `db = 20 * log10(rms)` (clamp to -100 when rms === 0)
- If `db < thresholdDb`: increments `silenceMs += 100`; else resets `silenceMs = 0`
- If `silenceMs >= silenceDurationMs` AND `Date.now() - startedAt >= minRecordingMs`: clears interval, fires `onSilence()`
- `stop()`: clears interval, closes `AudioContext`, resets `amplitude.value = 0`
- Safe to call `stop()` multiple times (guard with null check)
- `amplitude` resets to 0 after `stop()`
### Full implementation
```ts
import { ref, readonly } from 'vue'
export interface SilenceDetectorOptions {
thresholdDb?: number
silenceDurationMs?: number
minRecordingMs?: number
}
export function useSilenceDetector(options: SilenceDetectorOptions = {}) {
const {
thresholdDb = -40,
silenceDurationMs = 1500,
minRecordingMs = 500,
} = options
const amplitude = ref(0)
let audioCtx: AudioContext | null = null
let intervalId: ReturnType<typeof setInterval> | null = null
let silenceMs = 0
let startedAt = 0
function start(stream: MediaStream, onSilence: () => void) {
stop()
audioCtx = new AudioContext()
const source = audioCtx.createMediaStreamSource(stream)
const analyser = audioCtx.createAnalyser()
analyser.fftSize = 256
source.connect(analyser)
const data = new Uint8Array(analyser.frequencyBinCount)
silenceMs = 0
startedAt = Date.now()
intervalId = setInterval(() => {
analyser.getByteFrequencyData(data)
const rms = Math.sqrt(data.reduce((s, v) => s + v * v, 0) / data.length) / 255
amplitude.value = rms
const db = rms > 0 ? 20 * Math.log10(rms) : -100
if (db < thresholdDb) {
silenceMs += 100
if (silenceMs >= silenceDurationMs && Date.now() - startedAt >= minRecordingMs) {
stop()
onSilence()
}
} else {
silenceMs = 0
}
}, 100)
}
function stop() {
if (intervalId !== null) {
clearInterval(intervalId)
intervalId = null
}
if (audioCtx) {
audioCtx.close().catch(() => {})
audioCtx = null
}
amplitude.value = 0
silenceMs = 0
}
return { amplitude: readonly(amplitude), start, stop }
}
```
- [ ] Write the file exactly as above
- [ ] Verify TypeScript compiles: `cd frontend && npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/composables/useSilenceDetector.ts && git commit -m "feat: add useSilenceDetector composable"`
---
## Task 2: Expose `stream` from `useVoiceRecorder`
**Files:**
- Modify: `frontend/src/composables/useVoiceRecorder.ts`
Change the `stream` local variable to a `Ref<MediaStream | null>` and export it as readonly.
- [ ] Change `let stream: MediaStream | null = null` to `const streamRef = ref<MediaStream | null>(null)`
- [ ] Replace all `stream` assignments with `streamRef.value`:
- `stream = await navigator.mediaDevices.getUserMedia(...)``streamRef.value = await ...`
- `stream?.getTracks().forEach(...)``streamRef.value?.getTracks().forEach(...)`
- `stream = null``streamRef.value = null`
- [ ] Add `stream: readonly(streamRef)` to the return object
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/composables/useVoiceRecorder.ts && git commit -m "feat: expose stream ref from useVoiceRecorder"`
---
## Task 3: Wire `VoiceOverlay` — silence detection + click-to-toggle
**Files:**
- Modify: `frontend/src/components/VoiceOverlay.vue`
### Script changes
- [ ] Import `useSilenceDetector` at the top of `<script setup>`
- [ ] Add `const silenceDetector = useSilenceDetector()` after the existing composable instantiations
- [ ] In `startPtt()`: after `phase.value = 'recording'`, add:
```ts
if (recorder.stream.value) {
silenceDetector.start(recorder.stream.value, stopPtt)
}
```
- [ ] In `stopPtt()`: add `silenceDetector.stop()` as the first line (before the guard check)
- [ ] In `cancelAll()`: add `silenceDetector.stop()` after `recorder.stopRecording().catch(() => {})`
### Button: click-to-toggle
Replace the PTT mouse/touch handlers on `.voice-ptt-btn` with click-to-toggle logic:
- [ ] Remove `@mousedown.prevent="startPtt"` and `@mouseup.prevent="stopPtt"`
- [ ] Remove `@touchstart.prevent="startPtt"` and `@touchend.prevent="stopPtt"`
- [ ] Replace `@click.prevent="phase === 'error' ? (phase = 'idle') : undefined"` with:
```html
@click.prevent="onBtnClick"
```
- [ ] Add `onBtnClick` function in script:
```ts
function onBtnClick() {
if (phase.value === 'error') { phase.value = 'idle'; return }
if (phase.value === 'recording') { stopPtt(); return }
if (phase.value === 'idle') { startPtt() }
}
```
- [ ] Update `aria-label` and `title` on the button:
- `aria-label`: `phase === 'recording' ? 'Click to stop' : 'Click to speak'`
- `title`: `phase === 'recording' ? 'Click to stop or wait for silence' : 'Click or press Space to speak'`
### Amplitude visualization during recording
Inside the button, when `phase === 'recording'`, replace the static stop icon with animated amplitude bars:
- [ ] Replace the recording SVG block:
```html
<svg v-else-if="phase === 'recording'" ...>...</svg>
```
with:
```html
<span v-else-if="phase === 'recording'" class="voice-amp-bars">
<span
v-for="n in 3"
:key="n"
class="voice-amp-bar"
:style="{ transform: `scaleY(${0.3 + silenceDetector.amplitude.value * (0.4 + n * 0.15)})` }"
></span>
</span>
```
### Hint label
- [ ] Change the idle hint from `Hold <kbd>Space</kbd> or tap` to `Tap or press <kbd>Space</kbd>`
### CSS for amplitude bars
- [ ] Add to `<style scoped>`:
```css
.voice-amp-bars {
display: flex;
gap: 3px;
align-items: center;
height: 22px;
}
.voice-amp-bar {
width: 4px;
height: 18px;
background: #fff;
border-radius: 2px;
transform-origin: center;
transition: transform 0.08s ease;
}
```
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Commit: `git add frontend/src/components/VoiceOverlay.vue && git commit -m "feat: click-to-toggle silence detection in VoiceOverlay"`
---
## Task 4: Mount overlay and wire Space bar in `App.vue`
**Files:**
- Modify: `frontend/src/App.vue`
### Mount VoiceOverlay
- [ ] Add import at top of `<script setup>`:
```ts
import VoiceOverlay from '@/components/VoiceOverlay.vue'
```
- [ ] Add `<VoiceOverlay />` inside the `<template v-if="authStore.isAuthenticated">` block, just before `<ToastNotification />`:
```html
<VoiceOverlay />
<ToastNotification />
```
### Space bar handler
- [ ] In `onGlobalKeydown`, add a `Space` case inside the `switch (e.key)` block (after the existing cases), only fires when `!isInputActive()`:
```ts
case ' ':
e.preventDefault()
document.dispatchEvent(new CustomEvent('voice:ptt-toggle'))
break
```
### Shortcuts panel label
- [ ] Update the Space shortcut description from `Hold to speak (voice, when enabled)` to `Tap to speak (voice, when enabled)`
- [ ] Verify TypeScript: `npx tsc --noEmit`
- [ ] Verify full build: `npm run build`
- [ ] Commit: `git add frontend/src/App.vue && git commit -m "feat: mount VoiceOverlay and wire Space bar shortcut"`
@@ -1,144 +0,0 @@
# Knowledge View Task Consolidation — Design Spec
## Goal
Consolidate tasks into the Knowledge view as a card type, deprecate the standalone `/notes` and `/tasks` list views, and simplify navigation. The Knowledge view becomes the single hub for all content types: notes, tasks, people, places, and lists.
## Architecture
The Knowledge view already renders notes, people, places, and lists as typed cards in a filterable grid with a sidebar. Tasks are added as a fifth card type using the same two-tier pagination system (ID pre-fetch → content batch). The backend knowledge endpoints (`/api/knowledge/ids`, `/api/knowledge/batch`, `/api/knowledge/counts`) are extended to include tasks. No changes to the note/task CRUD API.
## Task Cards
Task cards follow the same layout as other knowledge cards:
- **Left accent strip**: distinct color for tasks (e.g. `#a78bfa` purple to differentiate from note indigo)
- **Type badge**: "Task" in top-right corner
- **Card body**:
- Title (2-line clamp)
- Status badge: `todo` / `in_progress` / `done` / `cancelled` — styled with existing status colors from theme (`--color-status-*`)
- Priority indicator: shown only when priority is not `none` — uses existing priority colors (`--color-priority-*`)
- Due date: shown when set, with overdue styling (`--color-overdue`) when past and status is not `done`/`cancelled`
- **Card footer**: tags (up to 3) + last-modified date — identical to other card types
Clicking a task card navigates to `/tasks/:id/edit` (same as today).
## Filter Sidebar Changes
The type filter section gains a "Tasks" button:
```
Type
──────────
[All] 127
[Notes] 84
[Tasks] 22
[People] 8
[Places] 5
[Lists] 8
```
The filter value for tasks is `type=task`. The backend already stores tasks as notes with `is_task=True`; the knowledge endpoints need to map the `type=task` filter to `is_task=True`.
## New Note Button Interaction
Current: click "New note" to create a note; chevron expands a dropdown with Note/Person/Place/List.
New behavior:
1. **Click "New note"** (when collapsed) → expands to reveal type options: Task, Person, Place, List. The main button label does not change.
2. **Click "New note"** again (when expanded) → navigates to `/notes/new` (generic note).
3. **Click any type option** → navigates to `/notes/new?type=<type>` (for task: `/notes/new?type=task`, which is equivalent to `/tasks/new`).
4. **Click outside** → collapses the dropdown.
This replaces the current chevron split-button pattern with a simpler toggle. The dropdown items are: Task, Person, Place, List (no "Note" item in the dropdown — clicking the button itself creates a note).
## Route Changes
### Redirects
| Old route | New behavior |
|-----------|-------------|
| `/notes` | 302 redirect → `/` (Knowledge view) |
| `/tasks` | 302 redirect → `/` (Knowledge view) |
### Preserved routes (no change)
| Route | Purpose |
|-------|---------|
| `/notes/:id` | Note viewer |
| `/notes/:id/edit` | Note editor |
| `/notes/new` | New note (with optional `?type=` param) |
| `/tasks/:id/edit` | Task editor |
| `/tasks/new` | New task |
### Router implementation
Add redirect entries in the router config:
```ts
{ path: '/notes', redirect: '/' },
{ path: '/tasks', redirect: '/' },
```
### Navigation
Remove from `AppHeader.vue`:
- "Tasks" nav link (`<router-link to="/tasks">`)
- The `/tasks` entry in both desktop nav-center and mobile menu
Remove from `AppHeader.vue` (already done — `/notes` was removed in a prior change, but verify).
### Deleted files
- `frontend/src/views/NotesListView.vue`
- `frontend/src/views/TasksListView.vue`
- `frontend/src/stores/notes.ts` (if only used by NotesListView)
- `frontend/src/stores/tasks.ts` (if only used by TasksListView)
Verify no other components import from these before deleting. The note/task viewer and editor screens import from `api/client.ts` directly, not from the list stores.
## Backend Changes
### `/api/knowledge/ids`
Accept `type=task` as a valid filter. When `type=task`, query `notes` table with `is_task = True`. When `type` is not set (all), include tasks in results alongside notes/people/places/lists.
### `/api/knowledge/batch`
Return task-specific fields for items where `is_task = True`:
- `status`: todo / in_progress / done / cancelled
- `priority`: none / low / normal / high
- `due_date`: ISO date string or null
These are already columns on the `Note` model — just include them in the batch response when the item is a task.
### `/api/knowledge/counts`
Add `task` to the counts response:
```json
{ "note": 84, "task": 22, "person": 8, "place": 5, "list": 8, "total": 127 }
```
### `/api/knowledge/tags`
No change — tasks already have tags on the same `Note` model.
## Keyboard Shortcuts
Remove from `App.vue` `onGlobalKeydown`:
- `case "t": router.push("/tasks/new")` — keep this, it still works
- `case "g"` sequence `case "t": router.push("/tasks")` — change to `router.push("/")` (direct navigation, don't rely on redirect)
Update shortcuts overlay panel text if it references "Tasks list".
## No API Endpoint Changes
All existing REST endpoints remain:
- `GET/POST /api/notes` — notes CRUD
- `GET/POST /api/tasks` — tasks CRUD
- `PATCH /api/notes/:id`, `PATCH /api/tasks/:id`
- `DELETE /api/notes/:id`, `DELETE /api/tasks/:id`
MCP tools (`fable_create_task`, `fable_list_tasks`, etc.) are unaffected.
@@ -1,204 +0,0 @@
# Specialized Note Type Editors — Design Spec
## Goal
Replace the one-size-fits-all note editor with type-specialized views for Person, Place, and List. Each type gets a form-first layout where structured fields are the main content, with a secondary notes area for free text. Fix tab navigation across all note types so focus flows logically from title through fields to body, skipping the formatting toolbar.
## Architecture
The existing `NoteEditorView.vue` remains the single editor component but renders different layouts based on `noteType`. When `noteType` is `person`, `place`, or `list`, the main editor area switches from TipTap-first to form-first. The TipTap editor moves to a secondary "Notes" section below the form fields. The sidebar metadata fields for person/place move into the main content area. The `note_type` field, entity metadata storage, and API contract are unchanged.
## Person Editor
When `noteType === 'person'`, the main content area renders a contact card form instead of the TipTap editor.
### Fields (in order, all in main content area)
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Name" | `title` |
| Relationship | text input | "e.g. Friend, Colleague, Family" | `entityMeta.relationship` |
| Birthday | date input | — | `entityMeta.birthday` (new field) |
| Email | email input | "email@example.com" | `entityMeta.email` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Organization | text input | "Company or organization" | `entityMeta.organization` (new field) |
| Address | text input | "Street, City, State" | `entityMeta.address` (new field for person) |
### Notes section
Below the form fields, a collapsible "Notes" section with the TipTap editor for free-text content. This is where wikilinks, tags, and general context go. The section starts expanded if the note already has body content, collapsed if empty on a new note.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ Name: [________________________________] │
│ │
│ Relationship: [________________________] │
│ Birthday: [____date picker________] │
│ Email: [________________________] │
│ Phone: [________________________] │
│ Organization: [________________________] │
│ Address: [________________________] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (markdown body) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
### Data migration
Existing person notes may have structured data written as plain text in the body (e.g. "Relationship: daughter Birthday: 2013-12-13"). No automatic migration — the body content stays as-is in the Notes section. Users can move data to the structured fields manually.
## Place Editor
When `noteType === 'place'`, same form-first pattern.
### Fields
| Field | Type | Placeholder | Source |
|-------|------|-------------|--------|
| Name | text input (title) | "Place name" | `title` |
| Address | text input | "Street, City, State" | `entityMeta.address` |
| Phone | tel input | "+1 555 000 0000" | `entityMeta.phone` |
| Hours | text input | "e.g. MonFri 9am5pm" | `entityMeta.hours` |
| Website | url input | "https://..." | `entityMeta.website` (new field) |
| Category | text input | "e.g. Restaurant, Office, Doctor" | `entityMeta.category` (new field) |
### Notes section
Same as Person — collapsible TipTap editor below the form.
## List Editor
When `noteType === 'list'`, the main content area renders a checklist builder instead of the TipTap editor.
### List builder
Each list item is a row with:
- Checkbox (toggle checked state)
- Text input (item text, fills available width)
- Delete button (× icon, right side)
Below the items: an "Add item" button.
### Behavior
- **Enter** in any item input: creates a new item below and focuses it
- **Backspace** on an empty item: deletes the item and focuses the previous one
- **Checkbox toggle**: updates the item's checked state
- **Delete button**: removes the item
### Serialization
On save, list items are serialized to markdown checkbox format in the body:
```markdown
- [ ] Buy groceries
- [x] Call dentist
- [ ] Pick up prescription
```
On load, the body is parsed back into structured items (same parser already exists in `knowledge.py` and `KnowledgeView.vue`).
### Notes section
Same collapsible TipTap "Notes" section below the list builder, for additional context that isn't a list item.
### Layout
```
┌──────────────────────────────────────────┐
│ [← Knowledge] [Save] [Delete] │
│ │
│ List title: [____________________________│
│ │
│ [ ] Buy groceries [×] │
│ [x] Call dentist [×] │
│ [ ] Pick up prescription [×] │
│ │
│ [+ Add item] │
│ │
│ ▾ Notes │
│ ┌──────────────────────────────────────┐ │
│ │ TipTap editor (additional context) │ │
│ └──────────────────────────────────────┘ │
│ │
│ [sidebar: project/tags/etc] │
└──────────────────────────────────────────┘
```
## Tab Navigation & Auto-Focus
### All note types
1. **On page load**: focus the title/name input automatically
2. **Tab from title**: skip the formatting toolbar entirely, go to the first content field:
- Note: TipTap editor body
- Person: Relationship field
- Place: Address field
- List: first list item (or "Add item" button if empty)
3. **Tab through fields**: natural order through all form fields
4. **Tab from last form field**: enter the Notes section (TipTap editor)
### Implementation
Set `tabindex="-1"` on all MarkdownToolbar buttons so they are clickable but not in the tab order. The toolbar remains fully functional via mouse/touch — it's just skipped when tabbing.
### Title placeholder by type
| Type | Placeholder |
|------|-------------|
| Note | "Title" |
| Person | "Name" |
| Place | "Place name" |
| List | "List title" |
| Task | "Title" (unchanged, task editor is separate) |
## Sidebar changes
When editing a Person or Place, the type-specific metadata fields (Relationship, Email, Phone, etc.) **move from the sidebar to the main content area**. The sidebar keeps: Project, Milestone, Tags, Suggest Tags, Type selector, Link Suggestions, Writing Assistant, Version History.
The Type selector remains in the sidebar so users can change the type if needed. Changing type switches the layout.
## Backend changes
### New entity metadata fields
The `entity_meta` JSON column on the Note model already stores arbitrary key-value pairs. No schema migration needed — just store the new keys:
- Person: `birthday`, `organization`, `address` (new; `relationship`, `email`, `phone` existing)
- Place: `website`, `category` (new; `address`, `phone`, `hours` existing)
### Knowledge service
Update `_note_to_item` in `services/knowledge.py` to include the new fields in the response for person and place cards:
- Person: add `birthday`, `organization`, `address`
- Place: add `website`, `category`
### Knowledge card display
Update `KnowledgeView.vue` card rendering to show the new fields where useful (e.g. organization on person cards, category on place cards).
## Files changed
| File | Change |
|------|--------|
| `frontend/src/views/NoteEditorView.vue` | Type-conditional layouts, form fields, list builder, tab navigation, auto-focus, title placeholders |
| `frontend/src/views/KnowledgeView.vue` | Card display for new person/place fields |
| `frontend/src/components/MarkdownToolbar.vue` | `tabindex="-1"` on all buttons |
| `src/fabledassistant/services/knowledge.py` | New fields in `_note_to_item` for person/place |
## What does NOT change
- Note model / database schema (entity_meta is already a JSON column)
- API endpoints (same CRUD)
- Task editor (`TaskEditorView.vue`) — separate component, unchanged
- Generic note editing — TipTap-first layout stays for `noteType === 'note'`
- Backend storage format — entity_meta key-value pairs
@@ -1,249 +0,0 @@
# Modern Fable — Visual Identity Design Spec
## Goal
Replace the generic "competent dark-mode Vue app" aesthetic with a distinctive visual identity that is unmistakably Fabled Assistant. The design language evolves from "Illuminated Transcript" to "Modern Fable" — keeping the scholarly DNA but adding personality through color, typography, interaction, and card design that no other app has.
## Color Palette
Shift from indigo (`#6366f1`) to deep violet + muted gold.
### Dark theme
| Role | Old | New | Usage |
|------|-----|-----|-------|
| Primary | `#818cf8` | `#a78bfa` | Text accents, active states, tags, links |
| Primary solid | `#6366f1` | `#7c3aed` | Buttons, gradients, accent strips |
| Primary deep | `#4f46e5` | `#5b21b6` | Gradient endpoints, hover states |
| Accent (warm) | — | `#d4a017` | Due dates, event times, counts, temporal data |
| Accent light | — | `#e8c45a` | Amber hover states |
| Background | `#111113` | `#0f0f14` | Slightly deeper, more dramatic |
| Surface | `#1a1b22` | `#16161f` | Cards, panels |
| Card bg | `#1e1e27` | `#1a1a24` | Card interiors |
| Border | `rgba(99,102,241,0.10)` | `rgba(124,58,237,0.12)` | Violet-tinted borders |
| Text | `#e4e4f0` | `#e4e4f0` | Unchanged |
| Text muted | `#52526a` | `#52526a` | Unchanged |
### Light theme
| Role | Old | New |
|------|-----|-----|
| Primary | `#6366f1` | `#7c3aed` |
| Primary text | `#4f46e5` | `#5b21b6` |
| Accent | — | `#b8860b` (darker gold for light bg) |
| Tag bg | `#ede9fe` | `#ede5ff` |
| Tag text | `#4f46e5` | `#6d28d9` |
### Semantic color rules
- **Violet = structural** — navigation, type badges, status indicators, card accents, CTA buttons
- **Amber/gold = temporal** — due dates, event times, countdown values, "overdue" states, calendar dot, relative timestamps
- This duality is a core brand principle: violet organizes, amber marks time
### Logo update
Update `AppLogo.vue` SVG fill to use the new violet gradient (`#7c3aed``#5b21b6`) instead of the current indigo values.
## Card Design — Type DNA
Each content type gets a distinct visual signature recognizable at a glance without reading the badge.
### Shared card structure
- Background: `var(--color-surface)`
- Border: `1px solid` with type-tinted color at low opacity
- Border-radius: `var(--radius-lg)` (14px)
- Padding: 14px
- Hover: translateY(-2px) + violet shadow bloom (`0 8px 24px rgba(124,58,237,0.15)`)
### Type-specific signatures
**Note** (`note`)
- Top edge: full-width 3px gradient bar (`#7c3aed``#a78bfa`)
- Border tint: `rgba(124,58,237,0.12)`
- Badge color: `#a78bfa`
**Task** (`task`)
- Top edge: half-width 3px gradient bar (`#d4a017` → transparent`), left-aligned — partial bar suggests "in progress"
- Border tint: `rgba(212,160,23,0.10)`
- Badge color: `#d4a017`
- Status badge inline with type badge row
- Due date in amber; overdue in `--color-overdue` (red)
**Person** (`person`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(16,185,129,0.06)`)
- Border tint: `rgba(16,185,129,0.10)`
- Badge color: `#34d399`
**Place** (`place`)
- Top edge: none
- Corner accent: subtle 60px quarter-circle in top-right (`rgba(245,158,11,0.06)`)
- Border tint: `rgba(245,158,11,0.10)`
- Badge color: `#fbbf24`
**List** (`list`)
- Top edge: full-width 3px gradient bar (`#38bdf8``#7dd3fc`)
- Border tint: `rgba(56,189,248,0.10)`
- Badge color: `#7dd3fc`
- Progress bar beneath checkboxes
### Card hover state
All cards share the same hover treatment:
```css
.k-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(124,58,237,0.15), 0 2px 8px rgba(0,0,0,0.3);
border-color: rgba(124,58,237,0.2);
}
```
## Navigation — Signature Header
Replace the flat nav links with a pill-grouped tab bar.
### Structure
```
[Logo + "Fabled"] [ Knowledge | Chat | Briefing | Calendar | News | Projects ] [status · ? · ☀ · ⚙ · user]
```
### Brand in header
- Logo: `AppLogo` SVG at 28px with new violet gradient
- Text: "Fabled" only (not "Fabled Assistant") — Fraunces italic, `#c4b0f0`, 15px
- The full name "Fabled Assistant" appears on the login page and Settings; the header uses the short form
### Tab bar
- Container: `rgba(124,58,237,0.06)` background, `border-radius: 10px`, 3px padding
- Inactive tabs: transparent background, `color: var(--color-text-muted)`
- Active tab: `rgba(124,58,237,0.2)` background, `border-radius: 8px`, `color: #c4b5fd`, soft box-shadow glow `0 0 12px rgba(124,58,237,0.2)`
- Hover (inactive): `rgba(124,58,237,0.08)` background
- Transition: background 0.15s, color 0.15s
### Header background
Subtle gradient: `linear-gradient(180deg, var(--color-surface), var(--color-bg))` with a bottom border of `rgba(124,58,237,0.08)`. Creates depth without being heavy.
### Mobile
On mobile (< 768px), the pill bar collapses into the existing hamburger dropdown menu. The dropdown gets the same violet active styling.
## Typography — Fraunces as Narrator
Fraunces italic becomes the "narrator's voice" of the application — the assistant speaking through the UI. System UI font remains for body text and interactive elements.
### Where Fraunces is used
| Element | Style | Example |
|---------|-------|---------|
| View titles | Fraunces italic, 20-24px, `#c4b0f0` | *Knowledge*, *Chat*, *Briefing* |
| Sidebar section labels | Fraunces italic, 11px, `var(--color-primary)` | *Filter*, *Tags*, *Sort* |
| Empty states | Fraunces italic, 13-15px, `#d4a017` | *"Every story starts with a blank page."* |
| Card headings (h1/h2/h3) | Fraunces, non-italic, 600 weight | Existing behavior, unchanged |
| Briefing greeting | Fraunces italic, 16px | *"Good morning, Bryan"* |
### Where Fraunces is NOT used
- Navigation tab labels (system font, 12-13px)
- Buttons and form labels
- Card body text, snippets, metadata
- Toast messages, error text
### Empty state voice
Each major view gets a distinctive empty state message in Fraunces italic, amber color:
- Knowledge: *"Your story is unwritten. Create your first note to begin."*
- Chat: *"Start a conversation."*
- Calendar: *"No events ahead. A quiet chapter."*
- Briefing (no briefing yet): *"Your daily briefing will appear here each morning."*
## Living Details
Small touches that accumulate into a distinctive feel.
### Glow interactions
- **Buttons**: Primary buttons (`btn-send`, `btn-new-note`, CTAs) get a violet glow on hover: `box-shadow: 0 0 16px rgba(124,58,237,0.35)`
- **Focus ring**: Change from current `color-mix` to a violet glow: `0 0 0 2px rgba(124,58,237,0.4)`
- **Active nav tab**: Soft glow behind the active pill (see Navigation section)
### Amber for temporal data
Consistently use `#d4a017` (dark theme) for all time-related information:
- Due dates on task cards
- Event times on calendar chips
- "3d ago" timestamps on cards
- Overdue badge in the today bar
- Countdown/relative time in briefing
This creates a visual language: when you see amber, it's about *when*.
### Card hover bloom
Cards lift and emit a violet shadow on hover (see Card Design section). The shadow color matches the card's type accent at very low opacity for a subtle differentiation.
### Status dot pulse
The Ollama status indicator in the header gains a CSS pulse animation when the model is loaded:
```css
@keyframes status-pulse {
0%, 100% { box-shadow: 0 0 4px rgba(74,222,128,0.4); }
50% { box-shadow: 0 0 10px rgba(74,222,128,0.6); }
}
```
Pulse only when status is "loaded" (green). Offline (red) and loading (amber) are static.
### Scroll edge fades
Top and bottom edges of scrollable areas (card grid, chat messages, sidebar tag list) get a gradient mask that fades content into the background. 20px height, using `mask-image: linear-gradient(...)`.
### Sidebar section dividers
Replace flat `border-bottom` between filter sections with a centered ornamental divider:
```css
.filter-section + .filter-section::before {
content: '·';
display: block;
text-align: center;
color: rgba(124,58,237,0.3);
font-size: 1.2rem;
letter-spacing: 0.5em;
padding: 8px 0;
}
```
Three centered dots (` · · · `) in faint violet. Subtle but distinctive.
### Scrollbar
Keep the current thin scrollbar but update the color from indigo to violet:
```css
::-webkit-scrollbar-thumb {
background: rgba(124,58,237,0.25);
}
```
## Files Changed
| File | Change |
|------|--------|
| `frontend/src/assets/theme.css` | Full palette update (both light and dark), scrollbar color |
| `frontend/src/components/AppLogo.vue` | SVG fill gradient update |
| `frontend/src/components/AppHeader.vue` | Pill-grouped nav tabs, brand shortening, header gradient, status pulse |
| `frontend/src/views/KnowledgeView.vue` | Card type DNA (gradient bars, corner accents), hover bloom, section dividers, empty state text, scroll fades, Fraunces view title |
| `frontend/src/components/ChatPanel.vue` | Scroll fade on messages, empty state text |
| `frontend/src/views/CalendarView.vue` | Empty state text, amber event times |
| `frontend/src/views/BriefingView.vue` | Empty state text, Fraunces greeting |
| `frontend/src/views/ChatView.vue` | (uses ChatPanel — inherits changes) |
| `frontend/src/App.vue` | Update any global styles referencing old indigo values |
## What Does NOT Change
- Overall layout structure (sidebar + content + optional graph panel)
- Chat bubble design (user transparent, assistant border-left + shadow)
- TipTap editor styling
- Settings view layout
- Backend — zero changes
- Mobile layout patterns
-2
View File
@@ -1,2 +0,0 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
-126
View File
@@ -1,126 +0,0 @@
"""Async HTTP client for the Fable Assistant API."""
from __future__ import annotations
import os
from typing import Any, AsyncIterator
import httpx
class FableAPIError(Exception):
"""Raised when the Fable API returns a non-2xx response."""
def __init__(self, status_code: int, message: str) -> None:
self.status_code = status_code
super().__init__(f"Fable API error {status_code}: {message}")
class FableClient:
"""Async wrapper around httpx for the Fable REST API."""
def __init__(self) -> None:
url = os.environ.get("FABLE_URL", "").rstrip("/")
key = os.environ.get("FABLE_API_KEY", "")
if not url:
raise ValueError("FABLE_URL environment variable is required")
if not key:
raise ValueError("FABLE_API_KEY environment variable is required")
self.base_url = url
self._headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "FableClient":
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0),
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("FableClient must be used as an async context manager")
return self._client
@staticmethod
def _raise_for_status(response: httpx.Response) -> None:
if response.is_error:
try:
message = response.json().get("error", response.text)
except Exception:
message = response.text
raise FableAPIError(response.status_code, message)
async def get(self, path: str, **kwargs: Any) -> Any:
response = await self._http().get(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def post(self, path: str, **kwargs: Any) -> Any:
response = await self._http().post(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def patch(self, path: str, **kwargs: Any) -> Any:
response = await self._http().patch(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def put(self, path: str, **kwargs: Any) -> Any:
response = await self._http().put(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def delete(self, path: str, **kwargs: Any) -> Any:
response = await self._http().delete(path, **kwargs)
if response.status_code == 204:
return None
self._raise_for_status(response)
if response.content:
return response.json()
return None
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
if response.is_error:
await response.aread()
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_client: FableClient | None = None
def init_client() -> FableClient:
"""Initialise the module-level FableClient singleton (reads env vars)."""
global _client
_client = FableClient()
return _client
def get_client() -> FableClient:
"""Return the singleton client; raises RuntimeError if not initialised."""
if _client is None:
raise RuntimeError("Call init_client() before get_client()")
return _client
def _reset_client() -> None:
"""Reset the singleton — used by tests only."""
global _client
_client = None
-765
View File
@@ -1,765 +0,0 @@
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
from fable_mcp.client import FableClient
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
load_dotenv()
_INSTRUCTIONS = """
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
## Data model
The hierarchy is: Project → Milestone → Task/Note.
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
Do not use note tools to manipulate tasks or vice versa.
- **Projects** group related work. A project has a title, description, goal, status, and an
auto-generated summary used for semantic search. Status values: `active`, `archived`.
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `none` (default), `low`, `medium`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
## Tags
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
leaves existing tags unchanged on updates.
## Integer-or-none fields
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
## Search
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
similarity with id, title, a body snippet, and tags.
## Chat / LLM delegation
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
handles its own tool use, RAG context injection, and conversation history internally.
Use `fable_send_message` when:
- The request is conversational or requires Fable's internal reasoning across many records
- You want Fable's RAG to surface relevant notes automatically
Use the direct CRUD tools when:
- You know exactly what to create/read/update/delete
- You need structured data back (IDs, field values) for further processing
- You are populating Fable programmatically from another system
## Task logs
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## RSS / Briefing
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
## Admin logs
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
"""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes (non-task documents) stored in Fable.
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
"""
async with FableClient() as client:
return await notes.list_notes(
client,
limit=limit,
offset=offset,
tag=tag or None,
search=search_text or None,
)
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@mcp.tool()
async def fable_create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
async with FableClient() as client:
return await notes.create_note(
client,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association (0 = remove from project). Omit to leave unchanged.
"""
async with FableClient() as client:
return await notes.update_note(
client,
note_id=note_id,
title=title or None,
body=body or None,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Permanently delete a Fable note by ID. This cannot be undone."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
Results are ordered by last-updated descending.
"""
async with FableClient() as client:
return await tasks.list_tasks(
client,
limit=limit,
offset=offset,
status=status or None,
project_id=project_id or None,
)
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@mcp.tool()
async def fable_create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
Returns the created task object including its assigned id.
"""
async with FableClient() as client:
return await tasks.create_task(
client,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
)
@mcp.tool()
async def fable_update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: none, low, medium, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
async with FableClient() as client:
return await tasks.update_task(
client,
task_id=task_id,
title=title or None,
body=body or None,
status=status or None,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
)
@mcp.tool()
async def fable_add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
overwriting the task's main body. Each entry is stored separately and shown
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, content=content)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project.
"""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each milestone's
id, title, status, and task counts.
"""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@mcp.tool()
async def fable_create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
Returns the created project object including its assigned id.
"""
async with FableClient() as client:
return await projects.create_project(
client,
title=title,
description=description,
goal=goal or None,
status=status,
color=color or None,
)
@mcp.tool()
async def fable_update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
color: New hex colour, or omit to leave unchanged.
"""
async with FableClient() as client:
return await projects.update_project(
client,
project_id=project_id,
title=title or None,
description=description or None,
goal=goal or None,
status=status or None,
color=color or None,
)
# ---------------------------------------------------------------------------
# Milestones
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, and task counts.
"""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@mcp.tool()
async def fable_create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
Returns the created milestone including its assigned id.
"""
async with FableClient() as client:
return await milestones.create_milestone(
client,
project_id=project_id,
title=title,
description=description,
status=status,
)
@mcp.tool()
async def fable_update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to.
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
async with FableClient() as client:
return await milestones.update_milestone(
client,
project_id=project_id,
milestone_id=milestone_id,
title=title or None,
description=description or None,
status=status or None,
order_index=order_index if order_index >= 0 else None,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_search(
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks using embedding similarity.
Finds content by meaning rather than exact keywords. Use this to discover
relevant records when you don't know the exact title or tags.
Args:
q: Natural-language query string.
content_type: "note", "task", or "all" (default).
limit: Maximum number of results (default 10).
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List chat conversations stored in Fable, ordered by last activity.
Returns id, title, message_count, created_at, updated_at for each conversation.
Use the id with fable_send_message to continue a specific conversation.
"""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@mcp.tool()
async def fable_send_message(
message: str,
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG context injection, and conversation history internally.
The LLM can create/update notes and tasks, search, manage projects, and more — all
driven by natural language without you needing to call individual tools.
Use this when:
- The request is conversational or exploratory
- You want Fable's RAG to automatically surface relevant notes as context
- The task is complex enough to benefit from Fable's internal reasoning
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
structured data back or are performing bulk/programmatic operations.
Args:
message: The user message to send.
conversation_id: Continue an existing conversation by passing its id.
Omit to start a new conversation.
think: Enable extended reasoning mode for complex multi-step requests.
Returns conversation_id (for follow-up messages), the assistant response text,
and a list of any tool_call events that fired during generation.
"""
async with FableClient() as client:
return await chat.send_message(
client,
message=message,
conversation_id=conversation_id or None,
think=think,
)
# ---------------------------------------------------------------------------
# Admin / observability
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_app_logs(
category: str = "error",
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin-scoped API key.
Args:
category: Log category — "error" (default), "audit", "usage", or "generation".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
Returns a list of log entries ordered by most recent first.
Regular user API keys will receive a 403 — only admin keys are accepted.
"""
async with FableClient() as client:
return await admin.get_app_logs(
client,
category=category,
limit=limit,
search=search or None,
)
# ---------------------------------------------------------------------------
# Briefing / RSS
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS/Atom feeds configured in Fable for the current user.
Returns id, title, url, category, and last_fetched_at for each feed.
These feeds are summarised in the user's daily briefing.
"""
async with FableClient() as client:
return await briefing.list_rss_feeds(client)
@mcp.tool()
async def fable_add_rss_feed(
url: str,
title: str = "",
category: str = "",
) -> dict:
"""Add an RSS or Atom feed to Fable's daily briefing.
Args:
url: The RSS/Atom feed URL (required).
title: Optional display name. If omitted, auto-populated from feed metadata.
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
Returns the created feed object including its assigned id.
"""
async with FableClient() as client:
return await briefing.add_rss_feed(
client,
url=url,
title=title or None,
category=category or None,
)
@mcp.tool()
async def fable_remove_rss_feed(feed_id: int) -> dict:
"""Remove an RSS feed from Fable by its ID."""
async with FableClient() as client:
return await briefing.remove_rss_feed(client, feed_id=feed_id)
# ---------------------------------------------------------------------------
# Briefing introspection & control
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_briefings() -> dict:
"""List the user's briefing conversations, newest first.
Each briefing has an associated date and conversation id. Use
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
the id to pull the actual briefing text and tool-call receipts.
Returns a dict with a ``conversations`` list.
"""
async with FableClient() as client:
return await briefing.list_briefing_conversations(client)
@mcp.tool()
async def fable_get_today_briefing() -> dict:
"""Fetch today's briefing conversation, creating it if needed.
Returns the full conversation object including all messages — the
scheduled briefing assistant turns, any tool calls the agentic path
made, and any chat replies the user has sent in the briefing thread.
Use this to inspect what a briefing actually said (and what tool
results grounded it) without having to query by id.
"""
async with FableClient() as client:
return await briefing.get_today_briefing(client)
@mcp.tool()
async def fable_get_briefing_messages(conversation_id: int) -> dict:
"""Fetch all messages for a specific briefing conversation.
Args:
conversation_id: The briefing conversation id from fable_list_briefings.
Returns a dict with a ``messages`` list. Each message includes
role, content, tool_calls (with results), and metadata — the
metadata carries ``briefing_slot`` tags on agentic briefing turns.
"""
async with FableClient() as client:
return await briefing.get_briefing_messages(
client, conversation_id=conversation_id,
)
@mcp.tool()
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
"""Manually run a briefing slot for the current user.
Fires the same data refresh the scheduler does (RSS, weather),
runs the agentic briefing pipeline, and writes the result into
today's briefing conversation. Use this to test prompt changes
without waiting for the next scheduled slot.
Args:
slot: One of ``compilation`` (full morning, default), ``morning``,
``midday``, or ``afternoon``.
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
"""
async with FableClient() as client:
return await briefing.trigger_briefing(client, slot=slot)
@mcp.tool()
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
"""Wipe today's briefing and (optionally) regenerate from scratch.
Deletes every message in today's briefing conversation — the
conversation row itself is kept so its id stays stable for any
open UI sessions. If ``run_compilation`` is True (the default),
immediately fires the compilation slot afterward so a fresh
briefing lands in place of the deleted content.
Use this when iterating on briefing prompts or tools and you want
to start from a clean slate rather than append another slot update
on top of stale output.
Args:
run_compilation: When True, fire ``POST /api/briefing/trigger``
for the ``compilation`` slot immediately after wiping.
Set False to only wipe without regenerating.
Returns a dict with ``reset`` (the delete result: deleted count +
conversation id) and ``triggered`` (the new message payload, or
null if regeneration was skipped).
"""
async with FableClient() as client:
return await briefing.reset_today_briefing(
client, run_compilation=run_compilation,
)
# ---------------------------------------------------------------------------
# Generic conversation access
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_conversation(conversation_id: int) -> dict:
"""Fetch any conversation (chat or briefing) with its full message list.
Returns conversation metadata plus an ordered ``messages`` array.
Each message includes role, content, tool_calls (with results),
context_note_id, and msg_metadata. Tool calls are in the stored
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
Useful for debugging agentic briefings, inspecting chat history,
or verifying that a tool actually ran with the expected arguments.
"""
async with FableClient() as client:
return await briefing.get_conversation(
client, conversation_id=conversation_id,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
# Validate env vars at startup — raises ValueError with a clear message if missing.
FableClient()
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
-20
View File
@@ -1,20 +0,0 @@
"""Admin tools: application log access (requires admin API key)."""
from __future__ import annotations
from fable_mcp.client import FableClient
async def get_app_logs(
client: FableClient,
category: str = "error",
limit: int = 20,
search: str | None = None,
) -> dict:
"""Fetch application logs from Fable. Requires an admin-scoped API key.
category: "error" | "audit" | "usage" | "generation" (default: "error")
"""
params: dict = {"category": category, "limit": limit}
if search:
params["search"] = search
return await client.get("/api/admin/logs", params=params)
-98
View File
@@ -1,98 +0,0 @@
"""MCP tools for Fable briefings and RSS feed management."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
# ── RSS feeds ────────────────────────────────────────────────────────────────
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
"""List the user's RSS feeds."""
return await client.get("/api/briefing/feeds")
async def add_rss_feed(
client: FableClient,
*,
url: str,
title: str | None = None,
category: str | None = None,
) -> dict[str, Any]:
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
payload: dict[str, Any] = {"url": url}
if title:
payload["title"] = title
if category:
payload["category"] = category
return await client.post("/api/briefing/feeds", json=payload)
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
"""Remove an RSS feed by ID."""
return await client.delete(f"/api/briefing/feeds/{feed_id}")
# ── Briefings ────────────────────────────────────────────────────────────────
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
"""List the user's briefing conversations, newest first."""
return await client.get("/api/briefing/conversations")
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
"""Fetch today's briefing conversation with all messages."""
return await client.get("/api/briefing/conversations/today")
async def get_briefing_messages(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch messages for a specific briefing conversation."""
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
async def trigger_briefing(
client: FableClient,
*,
slot: str = "compilation",
) -> dict[str, Any]:
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
Slot is one of: compilation (full morning), morning, midday, afternoon.
"""
return await client.post("/api/briefing/trigger", json={"slot": slot})
async def reset_today_briefing(
client: FableClient,
*,
run_compilation: bool = True,
) -> dict[str, Any]:
"""Delete all messages in today's briefing and optionally regenerate.
Wipes the messages in today's briefing conversation (keeping the
conversation row), then, if ``run_compilation`` is True, fires the
compilation slot so a fresh briefing lands in its place.
"""
reset = await client.post("/api/briefing/reset-today")
if not run_compilation:
return {"reset": reset, "triggered": None}
triggered = await client.post(
"/api/briefing/trigger", json={"slot": "compilation"}
)
return {"reset": reset, "triggered": triggered}
# ── Generic conversations ───────────────────────────────────────────────────
async def get_conversation(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
return await client.get(f"/api/chat/conversations/{conversation_id}")
-95
View File
@@ -1,95 +0,0 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from fable_mcp.client import FableClient, FableAPIError
async def list_conversations(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
"""List MCP chat conversations."""
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
return await client.get("/api/chat/conversations", params=params)
async def send_message(
client: FableClient,
*,
message: str,
conversation_id: str | None = None,
think: bool = False,
) -> dict[str, Any]:
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Posts the user message to start generation, then streams the SSE buffer.
SSE event format:
id: <N>
event: <type> # chunk | done | tool_call | status | ...
data: <json>
Returns:
Dict with:
- "conversation_id": str
- "response": str (full assistant message)
- "tool_calls": list of any tool_call events observed
"""
if conversation_id is None:
conv = await client.post(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = str(conv["id"])
# Start generation
await client.post(
f"/api/chat/conversations/{conversation_id}/messages",
json={"content": message, "think": think},
)
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream"
# Retry connecting to the stream briefly — the background task may not have
# created the generation buffer by the time we issue the GET.
for attempt in range(10):
try:
event_type: str | None = None
async for raw_line in client.stream_get(stream_path):
if raw_line.startswith("event: "):
event_type = raw_line[len("event: "):].strip()
elif raw_line.startswith("data: "):
payload_str = raw_line[len("data: "):]
try:
data = json.loads(payload_str)
except json.JSONDecodeError:
continue
if event_type == "chunk":
tokens.append(data.get("chunk", ""))
elif event_type == "tool_call":
tool_calls.append(data.get("tool_call", data))
elif event_type == "done":
break
event_type = None # reset after consuming data line
break # stream completed successfully
except FableAPIError as exc:
if exc.status_code == 404 and attempt < 9:
await asyncio.sleep(0.3)
continue
raise
return {
"conversation_id": conversation_id,
"response": "".join(tokens),
"tool_calls": tool_calls,
}
-53
View File
@@ -1,53 +0,0 @@
"""MCP tools for Fable milestones."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""List milestones for a project."""
return await client.get(f"/api/projects/{project_id}/milestones")
async def create_milestone(
client: FableClient,
*,
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict[str, Any]:
"""Create a milestone within a project."""
payload: dict[str, Any] = {
"title": title,
"description": description,
"status": status,
}
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
async def update_milestone(
client: FableClient,
*,
project_id: int,
milestone_id: int,
title: str | None = None,
description: str | None = None,
status: str | None = None,
order_index: int | None = None,
) -> dict[str, Any]:
"""Update an existing milestone."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if order_index is not None:
payload["order_index"] = order_index
return await client.patch(
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
)
-72
View File
@@ -1,72 +0,0 @@
"""MCP tools for Fable notes."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_notes(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
tag: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List notes with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag:
params["tag"] = tag
if search:
params["search"] = search
return await client.get("/api/notes", params=params)
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
"""Fetch a single note by ID."""
return await client.get(f"/api/notes/{note_id}")
async def create_note(
client: FableClient,
*,
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Create a new note."""
payload: dict[str, Any] = {"title": title, "body": body}
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.post("/api/notes", json=payload)
async def update_note(
client: FableClient,
*,
note_id: int,
title: str | None = None,
body: str | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing note."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.patch(f"/api/notes/{note_id}", json=payload)
async def delete_note(client: FableClient, *, note_id: int) -> None:
"""Delete a note by ID."""
await client.delete(f"/api/notes/{note_id}")
-59
View File
@@ -1,59 +0,0 @@
"""MCP tools for Fable projects."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_projects(client: FableClient) -> dict[str, Any]:
"""List all projects for the authenticated user."""
return await client.get("/api/projects")
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""Fetch a project by ID (includes milestone summary)."""
return await client.get(f"/api/projects/{project_id}")
async def create_project(
client: FableClient,
*,
title: str,
description: str = "",
goal: str | None = None,
status: str = "active",
color: str | None = None,
) -> dict[str, Any]:
"""Create a new project."""
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
if goal is not None:
payload["goal"] = goal
if color is not None:
payload["color"] = color
return await client.post("/api/projects", json=payload)
async def update_project(
client: FableClient,
*,
project_id: int,
title: str | None = None,
description: str | None = None,
goal: str | None = None,
status: str | None = None,
color: str | None = None,
) -> dict[str, Any]:
"""Update an existing project."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if goal is not None:
payload["goal"] = goal
if status is not None:
payload["status"] = status
if color is not None:
payload["color"] = color
return await client.patch(f"/api/projects/{project_id}", json=payload)
-30
View File
@@ -1,30 +0,0 @@
"""MCP tool for semantic search across Fable notes and tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def search(
client: FableClient,
*,
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict[str, Any]:
"""Semantic search over notes and/or tasks.
Args:
q: Search query string.
content_type: One of "note", "task", or "all" (default).
limit: Maximum number of results to return.
Returns:
Dict with "results" list and "total" count.
Each result has: id, title, body, is_task, tags, similarity.
"""
params: dict[str, Any] = {"q": q, "limit": limit}
if content_type != "all":
params["content_type"] = content_type
return await client.get("/api/search", params=params)
-93
View File
@@ -1,93 +0,0 @@
"""MCP tools for Fable tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_tasks(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
status: str | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""List tasks with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if project_id is not None:
params["project_id"] = project_id
return await client.get("/api/tasks", params=params)
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
"""Fetch a single task by ID (includes parent_title)."""
return await client.get(f"/api/tasks/{task_id}")
async def create_task(
client: FableClient,
*,
title: str,
body: str = "",
status: str = "todo",
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
parent_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new task."""
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
if parent_id is not None:
payload["parent_id"] = parent_id
if tags is not None:
payload["tags"] = tags
return await client.post("/api/tasks", json=payload)
async def update_task(
client: FableClient,
*,
task_id: int,
title: str | None = None,
body: str | None = None,
status: str | None = None,
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing task."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if status is not None:
payload["status"] = status
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
return await client.patch(f"/api/tasks/{task_id}", json=payload)
async def add_task_log(
client: FableClient,
*,
task_id: int,
content: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
-24
View File
@@ -1,24 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.2.6"
description = "MCP server for Fabled Assistant"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
[project.scripts]
fable-mcp = "fable_mcp.server:main"
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
-12
View File
@@ -1,12 +0,0 @@
"""Shared pytest fixtures for fable-mcp tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_client():
"""A mock FableClient that returns empty responses by default."""
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
return client
-142
View File
@@ -1,142 +0,0 @@
"""Tests for FableClient."""
import os
import pytest
import respx
import httpx
from unittest.mock import patch
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
@pytest.fixture(autouse=True)
def reset_singleton():
"""Reset the module-level client singleton between tests."""
_reset_client()
yield
_reset_client()
@pytest.fixture
def base_env(monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
class TestFableClientInit:
def test_missing_fable_url_raises(self, monkeypatch):
monkeypatch.delenv("FABLE_URL", raising=False)
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
with pytest.raises(ValueError, match="FABLE_URL"):
FableClient()
def test_missing_api_key_raises(self, monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.delenv("FABLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="FABLE_API_KEY"):
FableClient()
def test_trailing_slash_stripped(self, base_env):
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
client = FableClient()
assert client.base_url == "http://fable.test"
def test_auth_header_set(self, base_env):
client = FableClient()
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
class TestFableClientHTTP:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(200, json={"notes": []})
)
async with FableClient() as client:
data = await client.get("/api/notes")
assert data == {"notes": []}
@respx.mock
@pytest.mark.asyncio
async def test_non_2xx_raises_fable_api_error(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert exc_info.value.status_code == 401
@respx.mock
@pytest.mark.asyncio
async def test_post_sends_json(self, base_env):
respx.post("http://fable.test/api/notes").mock(
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
)
async with FableClient() as client:
data = await client.post("/api/notes", json={"title": "Test"})
assert data["id"] == 1
@respx.mock
@pytest.mark.asyncio
async def test_patch_sends_json(self, base_env):
respx.patch("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
)
async with FableClient() as client:
data = await client.patch("/api/notes/1", json={"title": "Updated"})
assert data["title"] == "Updated"
@respx.mock
@pytest.mark.asyncio
async def test_delete_returns_none_on_204(self, base_env):
respx.delete("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(204)
)
async with FableClient() as client:
result = await client.delete("/api/notes/1")
assert result is None
@respx.mock
@pytest.mark.asyncio
async def test_fable_api_error_includes_message(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(404, json={"error": "Not found"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert "Not found" in str(exc_info.value)
assert exc_info.value.status_code == 404
class TestSingleton:
def test_get_client_before_init_raises(self):
with pytest.raises(RuntimeError, match="init_client"):
get_client()
def test_init_client_returns_instance(self, base_env):
client = init_client()
assert isinstance(client, FableClient)
def test_get_client_after_init_returns_same(self, base_env):
init_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
class TestStreamGet:
@respx.mock
@pytest.mark.asyncio
async def test_stream_get_yields_lines(self, base_env):
sse_body = b"data: line1\n\ndata: line2\n\n"
respx.get("http://fable.test/api/stream").mock(
return_value=httpx.Response(200, content=sse_body)
)
lines = []
async with FableClient() as client:
async for line in client.stream_get("/api/stream"):
lines.append(line)
assert "data: line1" in lines
assert "data: line2" in lines
-58
View File
@@ -1,58 +0,0 @@
"""Tests for chat MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_send_message_creates_conversation_and_streams(client):
from fable_mcp.tools.chat import send_message
client.post = AsyncMock(return_value={"id": "conv-1"})
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Hello"}',
'data: {"type": "token", "content": " world"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id=None)
assert result["conversation_id"] == "conv-1"
assert "Hello world" in result["response"]
@pytest.mark.asyncio
async def test_send_message_reuses_existing_conversation(client):
from fable_mcp.tools.chat import send_message
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Reply"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id="existing-conv")
# Should not call post to create a conversation
client.post.assert_not_called()
assert result["conversation_id"] == "existing-conv"
@pytest.mark.asyncio
async def test_list_conversations_calls_get(client):
from fable_mcp.tools.chat import list_conversations
client.get = AsyncMock(return_value={"conversations": []})
await list_conversations(client)
client.get.assert_called_once()
assert "/api/chat/conversations" in client.get.call_args[0][0]
-35
View File
@@ -1,35 +0,0 @@
"""Tests for milestones MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_milestones_calls_correct_endpoint(client):
from fable_mcp.tools.milestones import list_milestones
client.get = AsyncMock(return_value={"milestones": []})
await list_milestones(client, project_id=3)
client.get.assert_called_once_with("/api/projects/3/milestones")
@pytest.mark.asyncio
async def test_create_milestone_posts_to_project_endpoint(client):
from fable_mcp.tools.milestones import create_milestone
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
await create_milestone(client, project_id=3, title="M1")
path = client.post.call_args[0][0]
assert path == "/api/projects/3/milestones"
assert client.post.call_args[1]["json"]["title"] == "M1"
@pytest.mark.asyncio
async def test_update_milestone_patches(client):
from fable_mcp.tools.milestones import update_milestone
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
path = client.patch.call_args[0][0]
assert "/api/projects/3/milestones/10" in path
-56
View File
@@ -1,56 +0,0 @@
"""Tests for notes MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_notes_calls_get(client):
from fable_mcp.tools.notes import list_notes
client.get = AsyncMock(return_value={"notes": [], "total": 0})
result = await list_notes(client, limit=10)
client.get.assert_called_once()
call_args = client.get.call_args
assert "/api/notes" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_note_calls_correct_endpoint(client):
from fable_mcp.tools.notes import get_note
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
result = await get_note(client, note_id=42)
client.get.assert_called_once_with("/api/notes/42")
assert result["id"] == 42
@pytest.mark.asyncio
async def test_create_note_posts_payload(client):
from fable_mcp.tools.notes import create_note
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
result = await create_note(client, title="My Note", body="Content")
client.post.assert_called_once()
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "My Note"
assert call_kwargs["json"]["body"] == "Content"
@pytest.mark.asyncio
async def test_update_note_patches_payload(client):
from fable_mcp.tools.notes import update_note
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
result = await update_note(client, note_id=1, title="Updated")
client.patch.assert_called_once()
path = client.patch.call_args[0][0]
assert "/api/notes/1" in path
@pytest.mark.asyncio
async def test_delete_note_calls_delete(client):
from fable_mcp.tools.notes import delete_note
client.delete = AsyncMock(return_value=None)
await delete_note(client, note_id=5)
client.delete.assert_called_once_with("/api/notes/5")
-42
View File
@@ -1,42 +0,0 @@
"""Tests for projects MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_projects_calls_get(client):
from fable_mcp.tools.projects import list_projects
client.get = AsyncMock(return_value={"projects": []})
await list_projects(client)
client.get.assert_called_once_with("/api/projects")
@pytest.mark.asyncio
async def test_get_project_calls_correct_endpoint(client):
from fable_mcp.tools.projects import get_project
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
result = await get_project(client, project_id=2)
client.get.assert_called_once_with("/api/projects/2")
@pytest.mark.asyncio
async def test_create_project_posts_payload(client):
from fable_mcp.tools.projects import create_project
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
await create_project(client, title="New", description="Desc")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New"
assert call_kwargs["json"]["description"] == "Desc"
@pytest.mark.asyncio
async def test_update_project_patches(client):
from fable_mcp.tools.projects import update_project
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
await update_project(client, project_id=4, status="active")
assert "/api/projects/4" in client.patch.call_args[0][0]
-54
View File
@@ -1,54 +0,0 @@
"""Tests for tasks MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_tasks_calls_get(client):
from fable_mcp.tools.tasks import list_tasks
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
await list_tasks(client)
client.get.assert_called_once()
assert "/api/tasks" in client.get.call_args[0][0]
@pytest.mark.asyncio
async def test_get_task_calls_correct_endpoint(client):
from fable_mcp.tools.tasks import get_task
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
result = await get_task(client, task_id=7)
client.get.assert_called_once_with("/api/tasks/7")
@pytest.mark.asyncio
async def test_create_task_posts_payload(client):
from fable_mcp.tools.tasks import create_task
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
await create_task(client, title="New task")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New task"
@pytest.mark.asyncio
async def test_update_task_patches(client):
from fable_mcp.tools.tasks import update_task
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
await update_task(client, task_id=3, status="done")
client.patch.assert_called_once()
assert "/api/tasks/3" in client.patch.call_args[0][0]
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
await add_task_log(client, task_id=9, content="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["content"] == "progress"
+2 -2
View File
@@ -9,8 +9,8 @@
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Fabled"> <meta name="apple-mobile-web-app-title" content="Scribe">
<title>Fabled Assistant</title> <title>Fabled Scribe</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+405 -481
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "fabledassistant-frontend", "name": "scribe-frontend",
"private": true, "private": true,
"version": "0.1.0", "version": "0.1.0",
"type": "module", "type": "module",
@@ -14,6 +14,7 @@
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20", "@fullcalendar/vue3": "^6.1.20",
"@ricky0123/vad-web": "^0.0.30",
"@tiptap/core": "^3.0.0", "@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0", "@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0", "@tiptap/extension-list": "^3.0.0",
@@ -24,6 +25,7 @@
"@tiptap/vue-3": "^3.0.0", "@tiptap/vue-3": "^3.0.0",
"d3": "^7", "d3": "^7",
"dompurify": "^3.1.0", "dompurify": "^3.1.0",
"lucide-vue-next": "^0.469.0",
"marked": "^17.0.0", "marked": "^17.0.0",
"pinia": "^3.0.0", "pinia": "^3.0.0",
"vue": "3.5.30", "vue": "3.5.30",
@@ -35,6 +37,7 @@
"@vitejs/plugin-vue": "^6.0.0", "@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.0", "typescript": "~5.9.0",
"vite": "^7.0.0", "vite": "^7.0.0",
"vite-plugin-static-copy": "^4.0.1",
"vue-tsc": "^3.0.0" "vue-tsc": "^3.0.0"
} }
} }
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "Fabled Assistant", "name": "Fabled Scribe",
"short_name": "Fabled", "short_name": "Scribe",
"description": "Your self-hosted second brain with AI assistance", "description": "Your self-hosted second brain with AI assistance",
"start_url": "/", "start_url": "/",
"display": "standalone", "display": "standalone",
+8 -42
View File
@@ -1,45 +1,11 @@
// Service Worker for push notifications and PWA support // Service Worker — PWA installability shell only.
self.addEventListener('push', event => { //
if (!event.data) return; // The push and notificationclick listeners were removed alongside the chat
const data = event.data.json(); // generation pipeline (they only fired when the internal LLM finished a
const notifUrl = data.url || '/'; // response). The empty fetch handler is preserved as the installability
// surface required for "Add to Home Screen" in some browsers; offline
// caching is intentionally not implemented yet.
event.waitUntil( self.addEventListener('fetch', () => {
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
// Suppress notification if the user already has the relevant page focused
for (const client of clientList) {
if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) {
return;
}
}
return self.registration.showNotification(data.title || 'Fabled Assistant', {
body: data.body || '',
icon: '/favicon.ico',
badge: '/favicon.ico',
data: { url: notifUrl },
});
})
);
});
self.addEventListener('notificationclick', event => {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => {
const url = event.notification.data.url;
for (const client of clientList) {
if (client.url.includes(url) && 'focus' in client) {
return client.focus();
}
}
if (clients.openWindow) {
return clients.openWindow(url);
}
})
);
});
// PWA: serve cached assets for offline support (minimal)
self.addEventListener('fetch', event => {
// Pass-through — no offline caching in v1 // Pass-through — no offline caching in v1
}); });
+7 -46
View File
@@ -6,7 +6,6 @@ import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts"; import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings"; import { useSettingsStore } from "@/stores/settings";
import { apiGet, apiPut } from "@/api/client"; import { apiGet, apiPut } from "@/api/client";
@@ -15,29 +14,17 @@ useTheme();
const router = useRouter(); const router = useRouter();
const appVersion = ref("dev"); const appVersion = ref("dev");
const authStore = useAuthStore(); const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore(); const settingsStore = useSettingsStore();
const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts(); const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() { function startAppServices() {
chatStore.startStatusPolling();
settingsStore.fetchSettings(); settingsStore.fetchSettings();
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load. // Sync browser timezone to the server on every login/page load.
apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {}); apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {});
// Re-check voice status when the tab becomes visible again (model may
// have finished loading while the user was away).
document.addEventListener("visibilitychange", onVisibilityChange);
}
function onVisibilityChange() {
if (document.visibilityState === "visible" && authStore.isAuthenticated) {
settingsStore.checkVoiceStatus();
}
} }
function stopAppServices() { function stopAppServices() {
chatStore.stopStatusPolling(); // no-op for now; kept as a hook for future per-session teardown
} }
function isInputActive(): boolean { function isInputActive(): boolean {
@@ -92,9 +79,10 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "n": router.push("/notes"); break; case "n": router.push("/notes"); break;
case "t": router.push("/"); break; case "t": router.push("/"); break;
case "p": router.push("/projects"); break; case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break; case "r": router.push("/rules"); break;
case "g": router.push("/graph"); break; case "g": router.push("/graph"); break;
case "l": router.push("/calendar"); break; case "l": router.push("/calendar"); break;
case "x": router.push("/trash"); break;
} }
return; return;
} }
@@ -122,13 +110,6 @@ function onGlobalKeydown(e: KeyboardEvent) {
e.preventDefault(); e.preventDefault();
document.dispatchEvent(new CustomEvent("shortcut:focus-search")); document.dispatchEvent(new CustomEvent("shortcut:focus-search"));
break; break;
case "c":
if (router.currentRoute.value.name === "home") {
document.dispatchEvent(new CustomEvent("shortcut:focus-chat"));
} else {
router.push("/chat");
}
break;
} }
} }
@@ -159,7 +140,6 @@ watch(
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown); document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("visibilitychange", onVisibilityChange);
stopAppServices(); stopAppServices();
}); });
</script> </script>
@@ -213,14 +193,14 @@ onUnmounted(() => {
<div class="shortcut-row"> <div class="shortcut-row">
<kbd class="shortcut-key">g</kbd> <kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span> <span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">c</kbd> <kbd class="shortcut-key">l</kbd>
<span class="shortcut-desc">Chat</span> <span class="shortcut-desc">Calendar</span>
</div> </div>
<div class="shortcut-row"> <div class="shortcut-row">
<kbd class="shortcut-key">g</kbd> <kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span> <span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">l</kbd> <kbd class="shortcut-key">x</kbd>
<span class="shortcut-desc">Calendar</span> <span class="shortcut-desc">Trash</span>
</div> </div>
<div class="shortcut-row"> <div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd> <kbd class="shortcut-key">Esc</kbd>
@@ -263,23 +243,6 @@ onUnmounted(() => {
<span class="shortcut-desc">Edit current item (viewer)</span> <span class="shortcut-desc">Edit current item (viewer)</span>
</div> </div>
</div> </div>
<div class="shortcuts-section">
<div class="shortcuts-section-title">Chat</div>
<div class="shortcut-row">
<kbd class="shortcut-key">c</kbd>
<span class="shortcut-desc">Focus chat (home) / go to chat</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">Send message</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Shift</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">Enter</kbd>
<span class="shortcut-desc">New line</span>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -324,8 +287,6 @@ onUnmounted(() => {
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
} }
.app-content:has(.workspace-root),
.app-content:has(.chat-page),
.app-content:has(.knowledge-root) { .app-content:has(.knowledge-root) {
overflow: hidden; overflow: hidden;
} }
+19 -240
View File
@@ -299,152 +299,6 @@ export function apiSSEStream(
return { close: () => controller.abort(), done }; return { close: () => controller.abort(), done };
} }
// ---------------------------------------------------------------------------
// Briefing
// ---------------------------------------------------------------------------
export interface BriefingLocation {
label: string;
address: string;
lat?: number;
lon?: number;
}
export interface BriefingSlots {
compilation: boolean;
morning: boolean;
midday: boolean;
afternoon: boolean;
}
export interface BriefingConfig {
enabled: boolean;
locations: {
home?: BriefingLocation;
work?: BriefingLocation;
};
use_caldav_event_locations: boolean;
work_days: number[];
slots: BriefingSlots;
notifications: boolean;
temp_unit: 'C' | 'F';
}
export interface BriefingFeed {
id: number;
title: string;
url: string;
category: string | null;
last_fetched_at: string | null;
}
export interface BriefingConversation {
id: number;
title: string;
briefing_date: string | null;
message_count: number;
created_at: string;
}
export interface BriefingMessage {
id: number;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
metadata?: Record<string, unknown> | null;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
try {
const data = await apiGet<BriefingConfig>('/api/briefing/config');
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
} catch {
return { ...DEFAULT_BRIEFING_CONFIG };
}
}
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
await apiPut('/api/briefing/config', config);
}
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
return data;
}
export async function createBriefingFeed(url: string, category?: string): Promise<BriefingFeed> {
const body: Record<string, string> = { url };
if (category?.trim()) body.category = category.trim();
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
return { ...data, last_fetched_at: null };
}
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
return apiPost('/api/briefing/feeds/refresh', {});
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
}
export async function getBriefingConvMessages(id: number): Promise<BriefingMessage[]> {
const data = await apiGet<{ messages: BriefingMessage[] }>(`/api/briefing/conversations/${id}/messages`);
return data.messages;
}
export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
}
export async function postRssReaction(
rssItemId: number,
reaction: 'up' | 'down'
): Promise<{ ok: boolean; action: string }> {
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
}
export async function deleteRssReaction(rssItemId: number): Promise<void> {
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
}
export async function openArticleInChat(
itemId: number,
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
return apiPost(`/api/chat/from-article/${itemId}`, {});
}
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
try {
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/briefing/weather/geocode', { query: address });
return { lat: r.lat, lon: r.lon, display_name: r.label };
} catch {
return null;
}
}
export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> {
return apiGet('/api/fable-mcp/info');
}
export async function apiStreamPost( export async function apiStreamPost(
path: string, path: string,
body: unknown, body: unknown,
@@ -562,7 +416,7 @@ export interface EventUpdatePayload {
description?: string; description?: string;
location?: string; location?: string;
color?: string; color?: string;
recurrence?: string; recurrence?: string | null;
project_id?: number; project_id?: number;
} }
@@ -604,93 +458,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`) export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
// ─── News ─────────────────────────────────────────────────────────────────────
import type { NewsItem } from '@/types/news'
export interface GetNewsItemsParams {
days?: number
limit?: number
offset?: number
feed_id?: number | null
}
export function getNewsItems(params: GetNewsItemsParams = {}) {
const p = new URLSearchParams()
if (params.days != null) p.set('days', String(params.days))
if (params.limit != null) p.set('limit', String(params.limit))
if (params.offset != null) p.set('offset', String(params.offset))
if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
`/api/briefing/news?${p}`
)
}
// ─── Voice ────────────────────────────────────────────────────────────────────
export interface VoiceStatusResult {
enabled: boolean
stt: boolean
tts: boolean
stt_model?: string
tts_backend?: string
}
export interface VoiceEntry {
id: string
label: string
}
export interface VoiceBlendEntry {
voice: string
weight: number
}
export const getVoiceStatus = () => apiGet<VoiceStatusResult>('/api/voice/status')
export const getVoiceList = () =>
apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
const form = new FormData()
form.append('audio', blob, 'audio.webm')
if (context) form.append('context', context)
const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new ApiError(res.status, err)
}
return res.json()
}
export async function synthesiseSpeech(
text: string,
voice?: string,
speed?: number,
voiceBlend?: VoiceBlendEntry[]
): Promise<Blob> {
// Only send voice/speed/blend when explicitly provided — omitting them lets
// the server auto-load the user's saved voice settings (voice, speed, blend).
const body: Record<string, unknown> = { text }
if (voiceBlend && voiceBlend.length >= 2) {
body.voice_blend = voiceBlend
if (speed !== undefined) body.speed = speed
} else if (voice !== undefined || speed !== undefined) {
body.voice = voice ?? 'af_heart'
body.speed = speed ?? 1.0
}
const res = await fetch('/api/voice/synthesise', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
throw new ApiError(res.status, err)
}
return res.blob()
}
// ── User Profile ───────────────────────────────────────────────────────────── // ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile { export interface UserProfile {
@@ -702,14 +469,26 @@ export interface UserProfile {
tone: 'casual' | 'professional' | 'technical' tone: 'casual' | 'professional' | 'technical'
interests: string[] interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string } work_schedule: { days?: string[]; start?: string; end?: string }
learned_summary: string
observations_count: number
observations_updated_at: string | null
} }
export const getProfile = () => apiGet<UserProfile>('/api/profile') export const getProfile = () => apiGet<UserProfile>('/api/profile')
export const updateProfile = (data: Partial<UserProfile>) => export const updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data) apiPut<UserProfile>('/api/profile', data)
export const consolidateProfile = () =>
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
export const clearProfileObservations = () => apiDelete('/api/profile/observations') // ── Note Versions (pinning) ──────────────────────────────────────────────────
import type { NoteVersion } from '../types/task'
/** Mark a note version as manually pinned, optionally with a commit-note
* label. Re-calling with a different label updates the label. */
export const pinNoteVersion = (noteId: number, versionId: number, label?: string | null) =>
apiPost<NoteVersion>(
`/api/notes/${noteId}/versions/${versionId}/pin`,
{ label: label ?? null },
)
/** Downgrade a manually-pinned version back to rolling. Does NOT delete
* the row — older rows may be FIFO-pruned by the next autosave. */
export const unpinNoteVersion = (noteId: number, versionId: number) =>
apiDelete(`/api/notes/${noteId}/versions/${versionId}/pin`)
+183
View File
@@ -0,0 +1,183 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
export interface Rulebook {
id: number;
owner_user_id: number;
title: string;
description: string;
always_on: boolean;
created_at: string | null;
updated_at: string | null;
}
export interface RulebookTopic {
id: number;
rulebook_id: number;
title: string;
description: string;
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export interface Rule {
id: number;
topic_id: number | null;
project_id: number | null;
title: string;
statement: string;
why: string;
how_to_apply: string;
order_index: number;
created_at: string | null;
updated_at: string | null;
}
export interface RuleHeader {
id: number;
title: string;
statement: string;
topic_id: number | null;
}
export interface ApplicableRules {
rules: {
id: number;
title: string;
statement: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
project_rules: {
id: number;
title: string;
statement: string;
}[];
suppressed_rules: {
id: number;
title: string;
topic_id: number;
topic_title: string;
rulebook_id: number;
rulebook_title: string;
}[];
suppressed_topics: {
id: number;
title: string;
rulebook_id: number;
rulebook_title: string;
}[];
truncated: boolean;
subscribed_rulebooks: { id: number; title: string }[];
}
// ── Rulebooks ───────────────────────────────────────────────────────
export async function listRulebooks(): Promise<Rulebook[]> {
const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
return data.rulebooks;
}
export async function getRulebook(id: number): Promise<Rulebook & { topics: RulebookTopic[] }> {
return apiGet(`/api/rulebooks/${id}`);
}
export async function createRulebook(data: { title: string; description?: string }): Promise<Rulebook> {
return apiPost("/api/rulebooks", data);
}
export async function updateRulebook(id: number, data: Partial<{ title: string; description: string; always_on: boolean }>): Promise<Rulebook> {
return apiPatch(`/api/rulebooks/${id}`, data);
}
export async function deleteRulebook(id: number): Promise<void> {
return apiDelete(`/api/rulebooks/${id}`);
}
// ── Topics ─────────────────────────────────────────────────────────
export async function listTopics(rulebookId: number): Promise<RulebookTopic[]> {
const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
return data.topics;
}
export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise<RulebookTopic> {
return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
}
export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise<RulebookTopic> {
return apiPatch(`/api/rulebook-topics/${id}`, data);
}
export async function deleteTopic(id: number): Promise<void> {
return apiDelete(`/api/rulebook-topics/${id}`);
}
// ── Rules ──────────────────────────────────────────────────────────
export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise<Rule[]> {
const params = new URLSearchParams();
if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
if (filters.project_id) params.set("project_id", String(filters.project_id));
const qs = params.toString();
const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
return data.rules;
}
export async function getRule(id: number): Promise<Rule> {
return apiGet(`/api/rules/${id}`);
}
export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise<Rule> {
return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
}
export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise<Rule> {
return apiPatch(`/api/rules/${id}`, data);
}
export async function deleteRule(id: number): Promise<void> {
return apiDelete(`/api/rules/${id}`);
}
// ── Subscriptions ──────────────────────────────────────────────────
export async function subscribeProject(projectId: number, rulebookId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
}
export async function unsubscribeProject(projectId: number, rulebookId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
}
export async function getProjectApplicableRules(projectId: number): Promise<ApplicableRules> {
return apiGet(`/api/projects/${projectId}/rules`);
}
export async function createProjectRule(
projectId: number,
data: { statement: string; title?: string; why?: string; how_to_apply?: string },
): Promise<Rule> {
return apiPost(`/api/projects/${projectId}/rules`, data);
}
// ── Suppressions ───────────────────────────────────────────────────
export async function suppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/rules/${ruleId}`, {});
}
export async function unsuppressRuleForProject(projectId: number, ruleId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/rules/${ruleId}`);
}
export async function suppressTopicForProject(projectId: number, topicId: number): Promise<void> {
await apiPost(`/api/projects/${projectId}/suppressions/topics/${topicId}`, {});
}
export async function unsuppressTopicForProject(projectId: number, topicId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/suppressions/topics/${topicId}`);
}
+64
View File
@@ -0,0 +1,64 @@
import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
export interface System {
id: number;
project_id: number;
name: string;
description: string;
color: string | null;
status: "active" | "archived";
order_index: number;
open_issue_count: number;
created_at: string | null;
updated_at: string | null;
}
export async function listSystems(projectId: number): Promise<System[]> {
const data = await apiGet<{ systems: System[] }>(`/api/projects/${projectId}/systems`);
return data.systems;
}
export async function createSystem(
projectId: number,
data: { name: string; description?: string; color?: string },
): Promise<System> {
return apiPost(`/api/projects/${projectId}/systems`, data);
}
export async function updateSystem(
projectId: number,
systemId: number,
data: Partial<{
name: string;
description: string;
color: string | null;
status: "active" | "archived";
order_index: number;
}>,
): Promise<System> {
return apiPatch(`/api/projects/${projectId}/systems/${systemId}`, data);
}
export async function deleteSystem(projectId: number, systemId: number): Promise<void> {
return apiDelete(`/api/projects/${projectId}/systems/${systemId}`);
}
// Lightweight issue shape returned by the project-issues list endpoint.
export interface TaskLike {
id: number;
title: string;
status: string;
priority: string;
systems?: System[];
updated_at?: string | null;
}
export async function getProjectIssues(
projectId: number,
openOnly = true,
): Promise<TaskLike[]> {
const data = await apiGet<{ issues: TaskLike[] }>(
`/api/projects/${projectId}/issues?open_only=${openOnly}`,
);
return data.issues;
}
+28
View File
@@ -0,0 +1,28 @@
import { apiGet, apiPost, apiDelete } from "@/api/client";
export interface TrashItem {
type: string;
id: number;
title: string;
}
export interface TrashBatch {
batch_id: string;
deleted_at: string | null;
summary: string;
count: number;
items: TrashItem[];
}
export async function listTrash(): Promise<TrashBatch[]> {
const data = await apiGet<{ batches: TrashBatch[] }>("/api/trash");
return data.batches;
}
export async function restoreBatch(batchId: string): Promise<void> {
await apiPost(`/api/trash/${batchId}/restore`, {});
}
export async function purgeBatch(batchId: string): Promise<void> {
return apiDelete(`/api/trash/${batchId}`);
}
+22 -18
View File
@@ -50,34 +50,42 @@
border-color: var(--color-primary); border-color: var(--color-primary);
color: var(--color-primary); color: var(--color-primary);
} }
/* Save: Moss action-primary per the Hybrid rule. Saving is "operating
the software" — not a brand moment. Accent gradient is reserved for
Send / empty-state CTAs. */
.btn-save { .btn-save {
padding: 0.45rem 1.1rem; padding: 0.45rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5); background: var(--color-action-primary);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
font-weight: 600; font-weight: 500;
font-size: 0.875rem; font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28); transition: background 0.15s, opacity 0.15s;
transition: box-shadow 0.15s, opacity 0.15s;
} }
.btn-save:hover:not(:disabled) { .btn-save:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42); background: var(--color-action-primary-hover);
opacity: 0.95;
} }
.btn-save:disabled { .btn-save:disabled {
opacity: 0.55; opacity: 0.55;
cursor: default; cursor: default;
} }
/* Delete: Oxblood action-destructive per Hybrid rule. Should be paired
with a Trash icon at the call site to reinforce intent. */
.btn-delete { .btn-delete {
padding: 0.45rem 1rem; padding: 0.45rem 1rem;
background: var(--color-danger); background: var(--color-action-destructive);
color: #fff; color: #fff;
border: none; border: none;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-weight: 500;
} }
.btn-delete:hover { background: var(--color-action-destructive-hover); }
.btn-assist-toggle { .btn-assist-toggle {
margin-left: auto; margin-left: auto;
padding: 0.4rem 0.9rem; padding: 0.4rem 0.9rem;
@@ -99,7 +107,7 @@
border-bottom: 1.5px solid var(--color-border); border-bottom: 1.5px solid var(--color-border);
border-radius: 0; border-radius: 0;
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 700; font-weight: 500;
font-family: "Fraunces", Georgia, serif; font-family: "Fraunces", Georgia, serif;
background: transparent; background: transparent;
color: var(--color-text); color: var(--color-text);
@@ -112,7 +120,6 @@
} }
.title-input::placeholder { .title-input::placeholder {
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic;
font-weight: 400; font-weight: 400;
} }
.editor-tabs { .editor-tabs {
@@ -225,7 +232,7 @@
.assist-panel-title { .assist-panel-title {
flex: 1; flex: 1;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 700; font-weight: 500;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -269,7 +276,7 @@
/* Section list */ /* Section list */
.assist-sections-label { .assist-sections-label {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 700; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
color: var(--color-text-muted); color: var(--color-text-muted);
@@ -362,7 +369,6 @@
.assist-streaming-label { .assist-streaming-label {
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-secondary); color: var(--color-text-secondary);
font-style: italic;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -391,7 +397,6 @@
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic;
text-align: center; text-align: center;
} }
@@ -411,7 +416,7 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 500;
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.btn-toggle-view { .btn-toggle-view {
@@ -454,7 +459,7 @@
width: 1rem; width: 1rem;
text-align: center; text-align: center;
user-select: none; user-select: none;
font-weight: 700; font-weight: 500;
} }
.diff-text { .diff-text {
flex: 1; flex: 1;
@@ -464,7 +469,6 @@
.diff-empty { .diff-empty {
padding: 0.5rem; padding: 0.5rem;
color: var(--color-text-muted); color: var(--color-text-muted);
font-style: italic;
font-size: 0.82rem; font-size: 0.82rem;
} }
.assist-actions { .assist-actions {
@@ -562,7 +566,7 @@
border: none; border: none;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
font-size: 0.85rem; font-size: 0.85rem;
font-weight: 600; font-weight: 500;
color: var(--color-text-secondary); color: var(--color-text-secondary);
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
@@ -581,7 +585,7 @@
} }
.sb-label { .sb-label {
font-size: 0.78rem; font-size: 0.78rem;
font-weight: 600; font-weight: 500;
color: var(--color-text-secondary); color: var(--color-text-secondary);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.04em; letter-spacing: 0.04em;
+4 -1
View File
@@ -1,5 +1,8 @@
.prose { .prose {
line-height: 1.6; /* Long-form reading line-height per the design system —
reading surfaces (notes, tasks, journal, assistant bubbles) want 1.7
so prose breathes like a book, not a UI snippet. */
line-height: 1.7;
} }
.prose h1 { .prose h1 {
+149 -114
View File
@@ -1,131 +1,147 @@
@import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,300..900;1,9..144,300..900&family=Inter:ital,wght@0,400;0,500;1,400&family=JetBrains+Mono:ital,wght@0,400;1,400&display=swap');
:root { :root {
--color-bg: #f5f5fb; /* Light mode — warm parchment palette */
--color-bg-secondary: #ededf5; --color-bg: #F5F1E8;
--color-bg-card: #ffffff; --color-bg-secondary: #FBF8F0;
--color-surface: #f0f0f8; --color-bg-card: #FBF8F0;
--color-text: #1a1a1a; --color-surface: #EFEAE0;
--color-text-secondary: #666666; --color-text: #14171A;
--color-text-muted: #999999; --color-text-secondary: #5A5852;
--color-border: #dddde8; --color-text-muted: #9A9890;
--color-input-border: #c8c8d8; --color-border: #D9D6CE;
--color-primary: #7c3aed; --color-input-border: #D9D6CE;
--color-danger: #d93025; --color-primary: #5B4A8A;
--color-tag-bg: #ede5ff; --color-danger: #C04A1F;
--color-tag-text: #6d28d9; --color-tag-bg: rgba(91, 74, 138, 0.12);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.08); --color-shadow: rgba(0, 0, 0, 0.08);
--color-toast-success: #34a853; --color-toast-success: #4A5D3F;
--color-toast-error: #d93025; --color-toast-error: #C04A1F;
--color-status-todo: #5f6368; --color-status-todo: #3F4651;
--color-status-todo-bg: #e8eaed; --color-status-todo-bg: rgba(63, 70, 81, 0.10);
--color-status-in-progress: #7c3aed; --color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: #ede5ff; --color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
--color-status-done: #34a853; --color-status-done: #4A5D3F;
--color-status-done-bg: #e6f4ea; --color-status-done-bg: rgba(74, 93, 63, 0.12);
--color-priority-low: #5f9ea0; --color-priority-low: #3D5A6E;
--color-priority-low-bg: #e0f2f1; --color-priority-low-bg: rgba(61, 90, 110, 0.12);
--color-priority-medium: #f9a825; --color-priority-medium: #8B6F1E;
--color-priority-medium-bg: #fff8e1; --color-priority-medium-bg: rgba(139, 111, 30, 0.12);
--color-priority-high: #d93025; --color-priority-high: #C04A1F;
--color-priority-high-bg: #fce8e6; --color-priority-high-bg: rgba(192, 74, 31, 0.12);
--color-wikilink: #7b1fa2; --color-wikilink: #5B4A8A;
--color-wikilink-bg: #f3e5f5; --color-wikilink-bg: rgba(91, 74, 138, 0.12);
--color-overdue: #d93025; --color-overdue: #C04A1F;
--color-code-bg: #f0f0f8; --color-code-bg: #EBEDF0;
--color-code-inline-bg: #eaeaf4; --color-code-inline-bg: #EBEDF0;
--color-table-stripe: #f4f4fb; --color-table-stripe: rgba(20, 23, 26, 0.025);
--color-success: #22c55e; --color-success: #4A5D3F;
--color-warning: #eab308; --color-warning: #8B6F1E;
--color-input-bar-bg: #eaeaf3; --color-input-bar-bg: #EFEAE0;
--color-input-bar-text: #1a1a1a; --color-input-bar-text: #14171A;
--color-input-bar-placeholder: rgba(0, 0, 0, 0.4); --color-input-bar-placeholder: rgba(20, 23, 26, 0.4);
--color-overlay: rgba(0, 0, 0, 0.45); --color-overlay: rgba(0, 0, 0, 0.45);
--color-bubble-user-bg: rgba(0, 0, 0, 0.04); --color-bubble-user-bg: transparent;
--color-bubble-user-border: rgba(0, 0, 0, 0.10); --color-bubble-user-border: #D9D6CE;
--color-bubble-user-text: #3a3a4a; --color-bubble-user-text: #5A5852;
--color-bubble-asst-shadow: 0 2px 16px rgba(124, 58, 237, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06); --color-bubble-asst-shadow: 0 2px 14px rgba(91, 74, 138, 0.06), 0 1px 4px rgba(0, 0, 0, 0.05);
--color-primary-solid: #5B4A8A;
--color-primary-deep: #3F3560;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(91, 74, 138, 0.35);
--glow-cta-hover: 0 4px 20px rgba(91, 74, 138, 0.55);
--glow-soft: 0 0 16px rgba(91, 74, 138, 0.35);
--color-primary-faint: rgba(91, 74, 138, 0.08);
--color-primary-tint: rgba(91, 74, 138, 0.12);
--color-primary-wash: rgba(91, 74, 138, 0.20);
/* Action color set — Hybrid rule: action buttons use these, accent reserved for brand moments */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
--radius-sm: 6px; --radius-sm: 6px;
--radius-md: 12px; --radius-md: 12px;
--radius-lg: 18px; --radius-lg: 18px;
--radius-pill: 9999px; --radius-pill: 9999px;
--focus-ring: 0 0 0 2px rgba(124, 58, 237, 0.4); --focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
/* Layout */ /* Layout */
--page-max-width: 1200px; --page-max-width: 1200px;
--page-padding-x: 1rem; --page-padding-x: 1rem;
--sidebar-width: 260px; --sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%); --chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px; --chat-context-sidebar-width: 220px;
--color-accent-warm: #b8860b;
--color-accent-warm-light: #d4a017;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
/* Reusable brand patterns */
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 10px rgba(124, 58, 237, 0.35);
--glow-cta-hover: 0 4px 20px rgba(124, 58, 237, 0.55);
--glow-soft: 0 0 16px rgba(124, 58, 237, 0.35);
--color-primary-faint: rgba(124, 58, 237, 0.08);
--color-primary-tint: rgba(124, 58, 237, 0.12);
--color-primary-wash: rgba(124, 58, 237, 0.20);
} }
[data-theme="dark"] { [data-theme="dark"] {
--color-bg: #0f0f14; /* Dark mode — Obsidian / Iron / Pewter */
--color-bg-secondary: #16161f; --color-bg: #14171A;
--color-bg-card: #1a1a24; --color-bg-secondary: #1E2228;
--color-surface: #16161f; --color-bg-card: #1E2228;
--color-text: #e4e4f0; --color-surface: #2C313A;
--color-text-secondary: #8888a8; --color-text: #E8E4D8;
--color-text-muted: #52526a; --color-text-secondary: #C2BFB4;
--color-border: rgba(124, 58, 237, 0.22); --color-text-muted: #9C9A92;
--color-input-border: rgba(124, 58, 237, 0.35); --color-border: #3F4651;
--color-primary: #a78bfa; --color-input-border: #3F4651;
--color-danger: #f44336; --color-primary: #5B4A8A;
--color-tag-bg: #2a2a45; --color-danger: #C04A1F;
--color-tag-text: #c4b5fd; --color-tag-bg: rgba(91, 74, 138, 0.15);
--color-tag-text: #5B4A8A;
--color-shadow: rgba(0, 0, 0, 0.4); --color-shadow: rgba(0, 0, 0, 0.4);
--color-toast-success: #4caf50; --color-toast-success: #4A5D3F;
--color-toast-error: #f44336; --color-toast-error: #C04A1F;
--color-status-todo: #9aa0a6; --color-status-todo: #3F4651;
--color-status-todo-bg: #2a2a35; --color-status-todo-bg: rgba(63, 70, 81, 0.18);
--color-status-in-progress: #a78bfa; --color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: #2a2a45; --color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
--color-status-done: #4caf50; --color-status-done: #4A5D3F;
--color-status-done-bg: #1b3a20; --color-status-done-bg: rgba(74, 93, 63, 0.18);
--color-priority-low: #80cbc4; --color-priority-low: #3D5A6E;
--color-priority-low-bg: #1a3a38; --color-priority-low-bg: rgba(61, 90, 110, 0.18);
--color-priority-medium: #fdd835; --color-priority-medium: #8B6F1E;
--color-priority-medium-bg: #3a3520; --color-priority-medium-bg: rgba(139, 111, 30, 0.18);
--color-priority-high: #f44336; --color-priority-high: #C04A1F;
--color-priority-high-bg: #3a1a1a; --color-priority-high-bg: rgba(192, 74, 31, 0.18);
--color-wikilink: #c4b5fd; --color-wikilink: #5B4A8A;
--color-wikilink-bg: #2a1a45; --color-wikilink-bg: rgba(91, 74, 138, 0.18);
--color-overdue: #f44336; --color-overdue: #C04A1F;
--color-code-bg: #12121a; --color-code-bg: #14171A;
--color-code-inline-bg: #1a1a2a; --color-code-inline-bg: #1E2228;
--color-table-stripe: #14141e; --color-table-stripe: rgba(255, 255, 255, 0.025);
--color-success: #4ade80; --color-success: #4A5D3F;
--color-warning: #facc15; --color-warning: #8B6F1E;
--color-input-bar-bg: #1a1a24; --color-input-bar-bg: #1E2228;
--color-input-bar-text: #e4e4f0; --color-input-bar-text: #E8E4D8;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35); --color-input-bar-placeholder: rgba(232, 228, 216, 0.35);
--color-overlay: rgba(0, 0, 0, 0.65); --color-overlay: rgba(0, 0, 0, 0.65);
--color-bubble-user-bg: rgba(255, 255, 255, 0.04); --color-bubble-user-bg: transparent;
--color-bubble-user-border: rgba(255, 255, 255, 0.10); --color-bubble-user-border: #3F4651;
--color-bubble-user-text: #b0b0c8; --color-bubble-user-text: #C2BFB4;
--color-bubble-asst-shadow: 0 4px 28px rgba(124, 58, 237, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4); --color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-accent-warm: #d4a017; --color-primary-solid: #5B4A8A;
--color-accent-warm-light: #e8c45a; --color-primary-deep: #3F3560;
--color-primary-solid: #7c3aed;
--color-primary-deep: #5b21b6;
--gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep)); --gradient-cta: linear-gradient(135deg, var(--color-primary-solid), var(--color-primary-deep));
--glow-cta: 0 2px 12px rgba(124, 58, 237, 0.45); --glow-cta: 0 2px 12px rgba(91, 74, 138, 0.45);
--glow-cta-hover: 0 4px 24px rgba(124, 58, 237, 0.65); --glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
--glow-soft: 0 0 18px rgba(124, 58, 237, 0.4); --glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
--color-primary-faint: rgba(124, 58, 237, 0.10); --color-primary-faint: rgba(91, 74, 138, 0.10);
--color-primary-tint: rgba(124, 58, 237, 0.14); --color-primary-tint: rgba(91, 74, 138, 0.14);
--color-primary-wash: rgba(124, 58, 237, 0.22); --color-primary-wash: rgba(91, 74, 138, 0.22);
/* Action color set — identical across themes */
--color-action-primary: #4A5D3F;
--color-action-primary-hover: #5A6F4D;
--color-action-secondary: #8B7355;
--color-action-secondary-hover: #A0876A;
--color-action-destructive: #6B2118;
--color-action-destructive-hover: #7E2A1F;
--color-action-ghost-border: #3F4651;
} }
*, *,
@@ -138,15 +154,34 @@ body {
margin: 0; margin: 0;
background: var(--color-bg); background: var(--color-bg);
color: var(--color-text); color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif; "Segoe UI", Roboto, sans-serif;
font-feature-settings: "cv11";
line-height: 1.5; line-height: 1.5;
transition: background-color 0.2s, color 0.2s; transition: background-color 0.2s, color 0.2s;
} }
h1, h2, h3 { h1, h2 {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto; font-optical-sizing: auto;
font-weight: 500;
line-height: 1.3;
}
h3 {
font-family: 'Inter', system-ui, sans-serif;
font-weight: 500;
line-height: 1.3;
}
code, pre, kbd, samp {
font-family: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-feature-settings: "liga", "calt";
}
::selection {
background: rgba(91, 74, 138, 0.3);
color: var(--color-text);
} }
input:focus-visible, input:focus-visible,
@@ -184,7 +219,7 @@ button:not(:disabled):active,
} }
} }
/* Thin indigo-tinted scrollbars */ /* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 4px; width: 4px;
height: 4px; height: 4px;
@@ -193,11 +228,11 @@ button:not(:disabled):active,
background: transparent; background: transparent;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(124, 58, 237, 0.25); background: var(--color-border);
border-radius: 9999px; border-radius: 9999px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(124, 58, 237, 0.45); background: var(--color-text-muted);
} }
/* Floating inline assist button (teleported to body, cannot be scoped) */ /* Floating inline assist button (teleported to body, cannot be scoped) */
+34 -66
View File
@@ -4,49 +4,20 @@ import { useRouter, useRoute } from "vue-router";
import { useTheme } from "@/composables/useTheme"; import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts"; import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue"; import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue"; import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings, Trash2 } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts(); const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore(); const authStore = useAuthStore();
const chatStore = useChatStore();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
const isChatActive = computed(() => route.path.startsWith("/chat")) const isKnowledgeActive = computed(() => route.path === "/knowledge");
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
const mobileMenuOpen = ref(false); const mobileMenuOpen = ref(false);
const statusShortLabel = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "Offline";
if (chatStore.ollamaStatus === "checking") return "Checking";
if (chatStore.modelStatus === "not_found") return "No model";
if (chatStore.modelStatus === "cold") return "Cold";
if (chatStore.modelStatus === "loaded") return "Ready";
return "Checking";
});
const statusLabel = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
if (chatStore.ollamaStatus === "checking") return "Checking...";
if (chatStore.modelStatus === "not_found") return "Model not installed";
if (chatStore.modelStatus === "cold") return "Model cold — first response may be slow";
if (chatStore.modelStatus === "loaded") return "Model loaded · ready";
return "Checking...";
});
const statusClass = computed(() => {
if (chatStore.ollamaStatus === "unavailable") return "status-red";
if (chatStore.ollamaStatus === "checking") return "status-gray";
if (chatStore.modelStatus === "not_found") return "status-orange";
if (chatStore.modelStatus === "cold") return "status-yellow";
if (chatStore.modelStatus === "loaded") return "status-green";
return "status-gray";
});
function toggleMobileMenu() { function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value; mobileMenuOpen.value = !mobileMenuOpen.value;
} }
@@ -68,42 +39,39 @@ router.afterEach(() => {
<!-- Left: brand --> <!-- Left: brand -->
<router-link to="/" class="nav-brand"> <router-link to="/" class="nav-brand">
<AppLogo :size="34" /> <AppLogo :size="34" />
<span class="brand-text">Fabled</span> <span class="brand-text">Scribe</span>
</router-link> </router-link>
<!-- Center: primary navigation (desktop) --> <!-- Center: primary navigation (desktop) -->
<div class="nav-center"> <div class="nav-center">
<div class="nav-pill-bar"> <div class="nav-pill-bar">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link> <router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link> <router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/rules" class="nav-link">Rulebooks</router-link>
</div> </div>
</div> </div>
<!-- Right: status + utilities + gear + user --> <!-- Right: utilities + gear + user -->
<div class="nav-right"> <div class="nav-right">
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
<span class="status-text">{{ statusShortLabel }}</span>
</span>
<NotificationBell /> <NotificationBell />
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button> <button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
<button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`"> <button class="btn-icon" @click="toggleTheme" :title="`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`">
{{ theme === "dark" ? "\u2600" : "\u263E" }} <Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" />
</button> </button>
<!-- Trash link -->
<router-link to="/trash" class="btn-icon" aria-label="Trash" title="Trash">
<Trash2 :size="16" />
</router-link>
<!-- Settings link --> <!-- Settings link -->
<router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings"> <router-link to="/settings" class="btn-icon" aria-label="Settings" title="Settings">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <Settings :size="16" />
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</router-link> </router-link>
<div class="user-info"> <div class="user-info">
@@ -123,23 +91,20 @@ router.afterEach(() => {
<!-- Mobile dropdown --> <!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu"> <div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</router-link> <router-link to="/dashboard" class="nav-link">Dashboard</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link> <router-link to="/knowledge" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Browse</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link> <router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link> <router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/news" class="nav-link">News</router-link> <router-link to="/rules" class="nav-link">Rulebooks</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link> <router-link to="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div> <div class="mobile-divider"></div>
<router-link to="/trash" class="nav-link">Trash</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link> <router-link to="/settings" class="nav-link">Settings</router-link>
<div class="mobile-divider"></div> <div class="mobile-divider"></div>
<div class="mobile-actions"> <div class="mobile-actions">
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
<span class="status-text">{{ statusShortLabel }}</span>
</span>
<button class="btn-icon" @click="toggleShortcuts">?</button> <button class="btn-icon" @click="toggleShortcuts">?</button>
<button class="btn-icon" @click="toggleTheme">{{ theme === "dark" ? "\u2600" : "\u263E" }}</button> <button class="btn-icon" @click="toggleTheme"><Sun v-if="theme === 'dark'" :size="16" />
<Moon v-else :size="16" /></button>
</div> </div>
<div class="mobile-user"> <div class="mobile-user">
<span class="username">{{ authStore.user?.username }}</span> <span class="username">{{ authStore.user?.username }}</span>
@@ -153,7 +118,7 @@ router.afterEach(() => {
<style scoped> <style scoped>
.app-header { .app-header {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg)); background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(124, 58, 237, 0.18); border-bottom: 1px solid rgba(91, 74, 138, 0.18);
position: relative; position: relative;
} }
.nav { .nav {
@@ -174,9 +139,8 @@ router.afterEach(() => {
} }
.brand-text { .brand-text {
font-family: 'Fraunces', Georgia, serif; font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-optical-sizing: auto; font-optical-sizing: auto;
font-weight: 600; font-weight: 500;
font-size: 1rem; font-size: 1rem;
letter-spacing: -0.01em; letter-spacing: -0.01em;
color: #c4b0f0; color: #c4b0f0;
@@ -221,9 +185,9 @@ router.afterEach(() => {
} }
.nav-link.router-link-active { .nav-link.router-link-active {
color: var(--color-primary-solid); color: var(--color-primary-solid);
font-weight: 600; font-weight: 500;
background: rgba(124, 58, 237, 0.25); background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(124, 58, 237, 0.3); box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
} }
/* Status indicator */ /* Status indicator */
@@ -245,10 +209,14 @@ router.afterEach(() => {
font-weight: 500; font-weight: 500;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.status-green .status-dot { background: var(--color-success, #2ecc71); animation: status-pulse 2.5s ease-in-out infinite; } /* Status dots are indicator lights, not semantic-palette buttons —
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; } they want to read as vital (Moss/Warning/Error are too muted for
a "ready" indicator). Hardcoded bright values; the rest of the
system still uses the semantic tokens. */
.status-green .status-dot { background: #4ade80; animation: status-pulse 2.5s ease-in-out infinite; }
.status-yellow .status-dot { background: #facc15; animation: pulse-dot 2s infinite; }
.status-orange .status-dot { background: #f97316; } .status-orange .status-dot { background: #f97316; }
.status-red .status-dot { background: var(--color-danger, #e74c3c); } .status-red .status-dot { background: #ef4444; }
.status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; } .status-gray .status-dot { background: var(--color-text-muted); animation: pulse-dot 2s infinite; }
@keyframes pulse-dot { @keyframes pulse-dot {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
@@ -296,7 +264,7 @@ router.afterEach(() => {
} }
.admin-badge { .admin-badge {
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 500;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
color: var(--color-primary); color: var(--color-primary);

Some files were not shown because too many files have changed in this diff Show More