Compare commits

...

491 Commits

Author SHA1 Message Date
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
bvandeusen 1711ee6a1f Release v26.04.15.1 — Chat UI overhaul + silent gen fix 2026-04-16 05:52:47 +00:00
bvandeusen 369cf0a7c9 fix(chat): default context sidebar to hidden
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 23:29:14 -04:00
bvandeusen 3f2813d16f feat(chat): Phase D empty-state + overlay context sidebar
Empty-state rethink: replace "Start a conversation." with a richer
landing showing recent conversations (top 5 as resume links) and a
voice-mode entry button. Gated to full variant only; widget/briefing
keep the simple message.

Context sidebar: switch from grid column (4-col layout) to absolute
overlay on the right gutter (3-col layout). The reading column is now
always symmetrically centered regardless of whether the sidebar is
visible — eliminates the leftward shift that occurred when context
notes appeared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 22:35:39 -04:00
bvandeusen fddac2aa2f fix(chat): always think on qwen3, drop content-based classifier
Content-based gating (_should_think) was introduced in 87fcaa6 to cut
TTFT on simple prompts, but it has no way to tell that short prompts
like "create a task titled X" are going to trigger a tool call — and
qwen3:14b's tool-call template is unreliable at think=False, producing
intermittent silent generations where output tokens burn but nothing
parses into content or tool_calls.

Reverting to always-on thinking restores the pre-87fcaa6 reliability
of tool emission at the cost of TTFT latency on short conversational
prompts. This also lets us delete the silent-round retry loop (which
can no longer fire) along with its bookkeeping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 21:09:16 -04:00
bvandeusen 1261e93ede chore(generation): track CTX_DIAG per attempt not per round
Retry attempts were previously conflated with the initial call,
making prompt_tokens and headroom look cumulative and useless for
diagnosing the silent-round behavior. Move start-of-attempt captures
inside the retry loop and emit attempt_start / attempt_end lines so
each attempt's numbers stand alone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:39:49 -04:00
bvandeusen b6165e56e5 chore(generation): add CTX_DIAG logs for silent-round investigation
Log num_ctx, message count, prompt/output tokens, headroom, and a
silent flag per round so we can correlate silent generations against
context pressure on the dev instance.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 20:09:08 -04:00
bvandeusen 5e281c534a fix(chat): add bottom padding to messages container to clear input bar
Streaming bubble (Generating response…) was flush against the input
wrapper top border. Give the scroll column 0.75rem of breathing room
so the assistant bubble sits visibly separate from the input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:29:14 -04:00
bvandeusen 9082281225 feat(chat): Phase C — collapsible context sidebar with header toggle
Context sidebar sections (Auto-included / Suggested / In Context) are
now collapsible with chevrons, and collapsed state persists per
conversation via localStorage. A new Context (N) button in the chat
header toggles the whole sidebar — open by default on wide screens
(>=1200px), closed by default below. Visual differentiation: auto-
included notes get a muted background with an AUTO pill, in-context
notes get an active-chip style with a primary-colored left border,
and each section header shows its item count.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 19:12:11 -04:00
bvandeusen 058d6089b1 fix(chat): retry silent rounds with think=True before falling back
Qwen3's tool-call tokens sometimes fail to parse into either content
or tool_calls, burning output tokens and producing empty bubbles.
Detect the signature within a round (empty content, no tool calls,
eval_count > 0) and re-run the same round once with reasoning mode
enabled, which emits more reliable output. The post-loop fallback
remains as the final catch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:22:28 -04:00
bvandeusen ba0cb07c91 fix(chat): surface silent generations instead of empty bubbles
Qwen3:14b sometimes burns output tokens on tool-calling attempts whose
emission doesn't parse into any field we read — eval_count > 0 but no
thinking/content/tool_calls ever stream to the caller. Generation
completes "successfully," the user sees an empty assistant bubble, and
no error is logged. Seen in conv 220 today.

Two safety rails:

- stream_chat_with_tools now tracks whether it yielded anything; when
  Ollama's done frame reports eval_count > 0 with zero yields, log a
  warning including the last ~5 raw frames so the next occurrence leaves
  breadcrumbs for diagnosis.

- run_generation checks the same post-condition after the tool loop
  exits and, if content is empty with no tool calls but output_tokens
  > 0, substitutes a visible fallback message and streams it as a chunk
  so the user gets something readable instead of a blank bubble.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 17:35:30 -04:00
bvandeusen 4228e9a384 feat(chat): move scope chip to header, consolidate voice controls into input bar
Phase B of chat view refinement. Bumps chat reading column to 1200px,
lifts the RAG scope chip out of the footer and into the ChatView header
(next to the title), and collapses the listen toggle / volume slider /
stop-playback buttons into a single speaker popover on the input bar.
Listen mode + TTS are now available in briefing and workspace chats too,
since the controls live on the shared ChatInputBar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:44:16 -04:00
bvandeusen 035ec0c0dc feat(chat): Phase A chrome cleanup — kebabs, search, full-width new chat
Header:
- Remove Research button + modal (skill fires reliably from normal chat)
- Move Summarize-as-Note into a header kebab (disabled until the
  conversation has messages)

Left sidebar:
- Full-width "+ New Chat" primary button
- Search input filters conversations by title (case-insensitive
  substring) with a "no matches" empty state
- Move Select into a sidebar kebab next to search

Shared .btn-kebab + .kebab-menu styles give future conv-level actions
a home. Document mousedown + Escape dismiss both kebabs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 09:28:48 -04:00
bvandeusen f0c93ffa3b fix(chat): drop display:flex from .chat-panel-fill so grid layout wins
The inherited .chat-panel-fill class lands on ChatPanel's new .chat-full
root via attribute inheritance, and its display:flex was overriding the
grid layout — stacking messages, sidebar, and input bar vertically
inside the reading column instead of placing the sidebar in grid col 4.
Layout is ChatPanel's responsibility now; .chat-panel-fill only sizes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:40:44 -04:00
bvandeusen ad4fe3d783 feat(chat): center chat reading column and full-height context sidebar
Replaces the full-variant flex layout with a single CSS grid owning the
messages column, input bar, and context sidebar. Messages and input bar
share a centered ~820px reading track (--chat-reading-width token) so
the mic button can't get pushed off-screen on wide monitors, and the
context sidebar spans grid-row 1/-1 so it runs the full height from the
chat header down to the bottom edge instead of stopping above the input
bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 07:11:08 -04:00
bvandeusen dcbe018297 Merge pull request 'Release v26.04.15.1 — Dynamic voice silence threshold + filled mic halo' (#35) from dev into main 2026-04-15 04:38:27 +00:00
bvandeusen 36fb71699b feat(voice): dynamic silence threshold + filled red mic halo
The web silence detector previously ran RMS over getByteFrequencyData
bytes (which are already dB-scaled) and re-log'd the result, producing
numbers that didn't line up with real dBFS. Combined with a static
-40 dB threshold that sat right on top of room ambient, silence
detection rarely fired and the user had to click stop manually.

- Rewrite useSilenceDetector to use getFloatTimeDomainData for honest
  linear RMS → dBFS.
- Silence threshold is now dynamic: track session peak dBFS and treat
  "silent" as 15 dB below peak. Auto-calibrates per mic/room.
- Grace period (1500 ms) at start so the user can begin speaking
  before checks arm; static -45 dB fallback until peak clears -25 dB
  so dead-silent sessions don't spin forever.
- Silence duration bumped 1500 → 2000 ms for breathing room.

Visual: detached red radial-gradient disc sits behind the mic in a
wrapper; the button no longer scales, so the white mic icon stays
legible on top while the halo grows from 1x to ~2.6x with live
amplitude. Much more obvious "you are live" signal than the prior
subtle box-shadow pulse.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 00:26:52 -04:00
bvandeusen 027fbec606 Merge pull request 'Release v26.04.14.3 — Live amplitude mic pulse' (#34) from dev into main 2026-04-15 02:54:42 +00:00
bvandeusen 730dbfaf7b feat(voice): pulse mic button with live amplitude
The mic button now scales and glows proportional to the silence
detector's RMS amplitude instead of sitting static. Gives obvious
feedback that audio is actually being picked up — silence still
breathes via a 0.1 floor, loud input caps at ~1.18x scale so it
doesn't punch through the input bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 22:46:37 -04:00
bvandeusen 267a975455 Merge pull request 'Release v26.04.14.2 — Discuss failure mode fixes' (#33) from dev into main 2026-04-15 01:45:36 +00:00
bvandeusen 7bd1548f71 fix(discuss): hard-fail empty articles and skip RAG on seed turn
Discuss flow was hallucinating unrelated content when article
extraction returned empty or RAG pulled in orphan notes that looked
more relevant than the generic seed prompt.

- seed_article_discussion raises EmptyArticleError on empty body;
  briefing and /news routes return 422 instead of staging an empty
  synthetic tool result.
- build_context skips RAG auto-injection when user_message matches
  ARTICLE_DISCUSS_SEED so the article IS the context on turn one;
  follow-up turns keep RAG on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:17 -04:00
bvandeusen 9f3b3450fa Merge pull request 'Release v26.04.14.1' (#32) from dev into main 2026-04-14 12:02:13 +00:00
bvandeusen ba90ad8132 feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG
Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.

First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:54:24 -04:00
bvandeusen 9157740069 ci: gate typecheck/lint/test on ref so main merge commits skip work
Forgejo Actions doesn't consistently honor `on.push.branches` as a
filter — the merge commit landing on main after a release PR was still
triggering the full CI workflow on a SHA that had already passed on
dev, burning ~1 minute of runner time for no reason.

The `build` job already had a ref guard, so no duplicate images were
being pushed, but typecheck/lint/test ran unconditionally. Add the
same guard (`refs/heads/dev` or `refs/tags/v*`) to those three so main
pushes trigger the workflow but every job skips immediately with zero
runner time.

Also tightened the build job's tag filter from `refs/tags/` to
`refs/tags/v` for consistency with the new guards and to avoid ever
building from a non-version tag.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 23:40:41 -04:00
bvandeusen fd885c1bc8 Merge pull request 'Release v26.04.13.3' (#31) from dev into main 2026-04-14 03:36:09 +00:00
bvandeusen 8205590f8d feat(briefing): cache + map-reduce article context for rich discuss chats
The Discuss button on news cards was producing one-shot replies because
the model got the whole trafilatura blob dropped into history with a
canned "summarize and discuss this article" prompt — no length guard, no
prep, no invitation to converse. Large articles got silently truncated by
Ollama; small articles got a tepid reply.

This reworks discuss_article around a three-layer cache:

  context_prepared  →  content_full  →  fresh trafilatura fetch

First click on a small article fetches once, writes through to both
caches, and passes the body straight into the synthetic read_article
tool-result. First click on a large article additionally runs a parallel
map step (services/article_context.py) that chunks the body on paragraph
boundaries, summarizes each ~8k chunk to ~300 words of dense factual
prose via the background model, and concatenates the summaries under
section headers — all pinned to num_ctx=16384 so the map step doesn't
itself fall victim to silent truncation. Repeat clicks on either path
skip straight to the chat turn.

The canned summary prompt is replaced with a conversational seed that
invites the user into an actual discussion rather than a one-shot
synopsis, matching the goal of "have a conversation about an article,
not just read it."

discuss_topic is intentionally left untouched — it's the multi-article
aggregation path and needs a separate rework. Follow-up task will decide
whether to retire it or rework it on the cached-context approach.

Closes task #106.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 20:52:00 -04:00
bvandeusen 939b910372 Merge pull request 'Release v26.04.13.2' (#30) from dev into main 2026-04-13 23:00:33 +00:00
bvandeusen 70cea78c2f fix(llm): default generate_completion num_ctx to Config.OLLAMA_NUM_CTX
Non-streaming generate_completion was the only LLM entry point that
didn't default num_ctx — stream_chat and stream_chat_with_tools both
fall back to Config.OLLAMA_NUM_CTX (16384). When a caller omitted the
argument, Ollama silently used the model's default window (~4k on
qwen3) and truncated the prompt.

That footgun was masked by fallback paths in the research pipeline:
_generate_outline's prompt carries ~12 sources × 2000 chars (~6k
tokens) of source material plus a system prompt, so the prompt got
chopped, the model never saw the sources, JSON parsing failed twice,
and run_research_pipeline dropped into the single-note "monolith"
fallback (research.py:251). The user reported chat 215 producing such
a monolith note for a multi-source research topic.

Two-layer fix:
- Default num_ctx to Config.OLLAMA_NUM_CTX inside generate_completion,
  matching the streaming entry points. Any current or future caller
  that forgets the argument stops silently losing input.
- Pin num_ctx=16384 explicitly in _generate_outline and
  _generate_executive_summary with comments pointing at the failure
  mode, so a refactor of the generate_completion default can't
  silently regress the research pipeline.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 18:20:58 -04:00
bvandeusen 4e4dbb8783 fix(chat): feed title model raw turns instead of post-build_context messages
_generate_title was receiving the full messages list from build_context,
which prepends RAG snippets, RSS excerpts, URL content, and briefing
article dumps INTO the user-role message string. The role=="user" filter
inside _generate_title then handed that composite blob (capped at 300
chars) to gemma3:4b as "the user's message", so the background model
was titling conversations based on article excerpts instead of what the
user actually typed — producing wildly wrong titles like "Briefing
Profile Preferences & Schedule" for a plain calendar query. See #109.

Pass the raw history + user_content + assistant reply instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 17:15:39 -04:00
bvandeusen e4e1d1da49 fix(tz): interpret calendar and briefing dates in user's local timezone
Two related bugs where the server defaulted naive datetimes to UTC instead
of the configured user timezone, causing all-day events to land on the
previous day and briefings to "disappear" at UTC midnight.

- New services/tz.py helpers: get_user_tz, user_today, user_briefing_date
  (the briefing day flips at 4am local to align with the compilation slot,
  so the 00:00-04:00 local window still shows yesterday's briefing until
  the new one is generated).
- calendar create/list/update tools now parse naive datetimes in the
  user's TZ before converting to UTC for storage, and tool descriptions
  tell the model to pass plain local dates.
- briefing_conversations.get_or_create_today_conversation and the
  reset-today route use user_briefing_date so the in-progress briefing
  doesn't get replaced at 19:00 NY / UTC midnight.
- _run_profile_closeout targets user-local "yesterday" for consistency.

Regression tests added for the TZ helpers and the calendar tool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 15:35:27 -04:00
bvandeusen af6f81e0a7 Merge pull request 'Release v26.04.13.1' (#29) from dev into main 2026-04-13 05:13:41 +00:00
bvandeusen 734ccc337f test(llm): lock in _should_think classifier; drop briefing think overrides
Adds 38 parametrized tests for the _should_think classifier covering the
explicit-override path, empty/whitespace content, short/medium/long length
boundaries, case-insensitive keyword matching, and a chatty-message negative
set. These pin the content-based semantics so future tweaks to the keyword
list or length thresholds surface regressions immediately instead of going
unnoticed behind subtle latency changes.

Also drops the `think=True` overrides from the briefing /discuss-article
and /discuss-topic entry points. With `"discuss"` added to _THINK_KEYWORDS,
those canned prompts trip the classifier naturally, so the overrides were
redundant — keeping a uniform "classifier is authoritative" rule makes the
code easier to reason about.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 01:04:18 -04:00
bvandeusen 87fcaa6a0d fix(chat): gate qwen3 thinking on message content instead of always-on
The frontend hardcoded think=true on every chat send (ChatPanel full +
widget variants, KnowledgeView minichat), which defeated the _should_think
gate on the backend and made qwen3:14b spend 5-20s on chain-of-thought
reasoning for every turn — even "hi". This was the root cause of the
warm-path TTFT variance tracked in followup_ttft_variance.md: the logged
ttft_ms was really prefill + full thinking phase, bouncing with the depth
of the model's reasoning, not with cache or eviction.

All three frontend callers now pass think=false and let _should_think be
authoritative. The classifier is now a real content-based gate: explicit
think_requested=True still forces on as an override (briefing discuss
actions, future UI toggles, MCP callers), otherwise messages <80 chars
without reasoning keywords skip thinking, messages >=400 chars or
containing keywords like why/explain/analyze/debug/review/etc. get it.

Generation timing now separately records think_requested, the final
think decision, first_token_ms (first any chunk), and thinking_ms
(duration of the thinking phase). ttft_ms keeps its existing semantic
(first content token) so existing log analysis still works. The timing
log line surfaces all four fields so the old "just a big ttft number"
ambiguity is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 00:53:47 -04:00
bvandeusen 782f36ed51 fix(llm): surface Ollama error body; refresh pre-gemma3 summaries
Two small hardening fixes from the mistral-nemo testing round:

1. stream_chat / stream_chat_with_tools now read the Ollama response
   body and log it before raising on non-2xx. Previously all we saw
   was 'HTTP 400 Bad Request' — the gemma3-no-tools failure would
   have been diagnosed in one step if we'd been logging the body,
   which says e.g. 'model does not support tools'.

2. backfill_project_summaries() now also targets summaries stamped
   before 2026-04-12 (the gemma3:4b cutover). The remaining projects
   still carrying the broken qwen2.5:3b output (token repetition,
   hallucinated topics) will regenerate on next startup on the
   better model.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 22:13:21 -04:00
bvandeusen 9a851de624 fix(llm): normalize Ollama model tags to lowercase
Ollama's /api/tags returns whatever casing was used at pull time
(e.g. 'gemma3:12B' if the user ran 'ollama pull gemma3:12B'), but
/api/chat rejects mixed-case tags with a 400. The two code paths
are inconsistent, which surfaces the capitalized tag in the model
dropdown and then silently kills every chat request against it.

Lowercase on read (get_installed_models), on settings write
(update_settings_route), and on ensure_model() input so a legacy
mixed-case user setting can't trigger a spurious re-pull at
startup. The dropdown and stored settings are now always in the
form Ollama will actually accept.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 18:02:52 -04:00
bvandeusen 95135a665b fix(llm): switch default background model to gemma3:4b
qwen2.5:3b produced broken auto-summaries (misspellings, token repetition,
hallucinated topics) — its synthesis ceiling is too low for free-form
summarization. Gemma 3 4B is stronger on summarization at similar size
and still fits comfortably alongside the main chat model in VRAM, so it
preserves the KV-cache-separation strategy that keeps chat TTFT fast.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:45:22 -04:00
bvandeusen 46c6a9f174 Revert "fix(projects): route auto-summary through main model"
This reverts commit ec3853a78a.
2026-04-12 15:40:04 -04:00
bvandeusen ec3853a78a fix(projects): route auto-summary through main model
Project auto-summaries were using the 3B background model, but the
task — synthesizing a coherent paragraph over ~10 notes — is well past
what 3B can do reliably. Evidence on dev: "doging conversation
hygiene", "MCPview). MCP).", trailing stray quotes, and hallucinated
topics ("AI regulation").

Route through the user's default chat model instead. Project summary
regeneration is rare (only when a project changes) so the KV cache
eviction cost on the main model is negligible.

Title generation, tag suggestions, and RSS classification continue to
use the background model — those tasks are within what 3B handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:38:00 -04:00
bvandeusen 0becc1439b fix(llm): correct context sizing, honor think requests, broaden delete
Three related fixes uncovered while benchmarking qwen3:14b against 8b:

- pick_num_ctx was only counting message content, missing the ~15K
  tokens of tool schemas. num_ctx=8192 was being selected while actual
  prompt_tokens hit 14K+, causing silent prompt truncation on every
  tool-using request. Now includes json.dumps(tools) in the estimate.
  KV cache priming in app.py and routes/settings.py also fetches tools
  so the primed num_ctx matches what real chat requests will use.

- _should_think's heuristic classifier was overriding explicit
  think=true requests from the frontend toggle and MCP, gating on
  message length and regex patterns. Now a pass-through — the caller
  is the source of truth. quick_capture hardcodes think=False since
  it's a fast classification path that was relying on the old gating.

- delete_note description only mentioned "note or task", so the model
  refused to call it for entries created by save_person / save_place /
  create_list. Description now explicitly lists all five note_types it
  handles.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 15:32:52 -04:00
bvandeusen a6fe1c0d7c docs: update architecture and features for tool consolidation
Update tools.py references to tools/ package, remove stale intent
router section, update research pipeline to multi-note output,
fix create_task references now merged into create_note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:56:13 -04:00
bvandeusen 77339d5c58 refactor(tools): consolidate LLM tools from 42 to 38
Merge create_task into create_note (set status='todo' for tasks, omit
for notes), merge delete_task into delete_note, consolidate entity
tools (create/update_person → save_person, create/update_place →
save_place), rename get_note → read_note with clearer descriptions,
move calculate out of rag.py into utility.py, and extract shared
duplicate detection into check_duplicate() helper.

Updates all downstream references in generation_task.py, quick_capture.py,
ToolCallCard.vue, and WorkspaceView.vue.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 13:38:45 -04:00
bvandeusen e95ad90055 fix(research): show exception type when error message is empty
Some exceptions (e.g. connection errors) produce empty str(e),
resulting in "Research failed: " with no explanation. Fall back to
the exception class name when the message is blank.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:28:57 -04:00
bvandeusen 1d6905ccc8 feat(research): linked section notes + executive summary in index
Research pipeline now produces an index note with:
- Executive summary (2-3 paragraphs synthesized from sections)
- Clickable links to each section note (/notes/{id})
- Section notes have parent_id pointing to the index

Also improves outline resilience: lowered minimum sections from 3
to 2, retries once on failure before falling back to monolithic note.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:18:31 -04:00
bvandeusen 403eb49de0 fix(chat): prevent New Chat button from stretching full height
The .btn-new-conv class has flex:1 for the sidebar row layout, but
when reused inside .no-conversation (a column flex), it stretched
vertically to fill the entire viewport — appearing as a giant
purple rectangle. Override with flex:none in the no-conversation
context.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 12:06:44 -04:00
bvandeusen 717ac1b5d7 fix(ci): make registry cache export non-fatal
The Forgejo registry occasionally returns 400 on large cache layer
blob uploads, failing the entire build even though the image itself
pushed successfully. Adding ignore-error=true to cache-to so cache
failures don't block deployments.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:36:27 -04:00
bvandeusen 8276e5050a fix(chat): let backend auto-generate conversation titles
KnowledgeView and WorkspaceView were passing explicit titles
("Knowledge chat", "Project — Workspace") to createConversation(),
which prevented the backend's auto-title generation from firing
(condition: `not conv_title`). Pass no title so the background
title generator names conversations after their first message.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 11:33:53 -04:00
bvandeusen 436e339c48 fix(knowledge): task cards showed 'LIST' badge instead of 'Task'
The type-badge template had no v-else-if for task, so tasks fell
through to the v-else which renders 'List'.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:07:23 -04:00
bvandeusen c32c75f77e fix(test): update mock path for tool registry refactor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:02:34 -04:00
bvandeusen ce2d76447c refactor(tools): decorator-based tool registry replaces monolithic tools.py
Split 2566-line tools.py into a tools/ package with @tool decorator
registration. Each tool's schema, metadata, and implementation live
together. Briefing eligibility is now a briefing=True flag instead of
a separate frozenset allowlist. Conditional inclusion (CalDAV, SearXNG)
uses requires= metadata. Public API (get_tools_for_user, execute_tool)
unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-12 10:01:00 -04:00
bvandeusen 5275be8588 fix(ci): install setuptools before http-ece for uv
uv creates bare venvs without setuptools (unlike pip). http-ece
doesn't declare setuptools as a build dependency but needs it when
built with --no-build-isolation.
2026-04-12 00:10:45 -04:00
bvandeusen 8d07b6c79e ci: new ci-runner base image + uv/ruff/node_modules cache
Runner base image (infra/Dockerfile.runner-base):
- Added uv (fast Python package installer, ~10x faster than pip)
- Added ruff (baked in, lint job drops from ~13s to ~2s)
- Added jq + tzdata
- Renamed tag from py3.12-node22 to ci-runner (purpose over contents)

Workflow (ci.yml):
- typecheck: cache node_modules directly instead of npm download cache;
  skip npm ci entirely on cache hits (9m41s → ~30s expected)
- lint: just `ruff check src/` — no venv, no install
- test: uv venv + uv pip install replaces pip (~10x faster resolves)
- all jobs: runs-on ci-runner label

Baseline: typecheck 9m41s, lint 13s, test 1m42s
2026-04-11 23:59:57 -04:00
bvandeusen 02138f5728 fix(ci): use venv for ruff install — pipx not on runner image
The py3.12-node22 runner doesn't ship pipx, so the previous commit's
pipx install ruff failed with command not found. Switched to the same
venv pattern the test job uses.
2026-04-11 16:31:36 -04:00
bvandeusen 61f95fb9ed style(knowledge): align task card theming with note/list pattern
Task card was the outlier in the Knowledge view: half-width fade-to-
transparent gradient bar while note/list use full-width two-stop
gradients, and the type badge was purple while the border+bar were gold.

- k-card--task::before: full width with gold→amber gradient (#d4a017
  → #fbbf24) matching the two-stop pattern
- badge--task: switched from purple to amber so the badge matches
  the card chrome
2026-04-11 16:30:36 -04:00
bvandeusen cc74901761 ci: add concurrency, registry cache, pipx ruff, least-privilege perms
- concurrency: cancel in-progress runs on new pushes (non-tag only);
  tag runs are never cancelled so releases can't kill themselves
- permissions: contents: read default, packages: write scoped to build
- docker build: explicit cache-from/cache-to against :cache tag so
  BuildKit warmth survives local runner cleanup
- lint: pipx install ruff instead of --break-system-packages
- actions/cache@v3 → @v4
- triggers main branch pushes for gate runs (build stays dev/tag only)
- comments added on the non-obvious bits (http-ece isolation, docker
  prune two-step, registry cache mode=max)
2026-04-11 16:11:27 -04:00
bvandeusen 9f6608fc8f fix(briefing): stale weather auto-refresh + task filter + overdue prompt
Three briefing quality fixes surfaced by reading today's 2026-04-11
compilation output:

- **Stale weather**: get_weather was returning 48h-old cache data
  after a missed scheduler run. Tool now auto-refreshes any cached
  location older than 6h (fetching fresh data from Open-Meteo), and
  stamps each location with cache_age_hours + is_stale so the model
  can hedge instead of faithfully relaying old numbers.

- **Cancelled tasks leaking into prose**: briefing loop now defaults
  list_tasks calls to status=["todo","in_progress"] when the model
  doesn't specify, so cancelled/done items stop showing up in the
  summary. Localized to the briefing path — chat still sees full
  history.

- **Overdue in-progress tasks missed by midday check-in**: tightened
  the check-in prompt to explicitly require two list_tasks calls —
  one for in_progress (catches items dragging past their due date)
  and one filtered by due date — so long-running tasks stop getting
  silently dropped.
2026-04-11 13:32:52 -04:00
bvandeusen e63b4634ea feat(briefing): surface news items in compilation via get_rss_items
The compilation prompt mentioned "news themes" but didn't name the
tool, and the model was never calling get_rss_items. Result: today's
briefing had zero news coverage despite the tool being wired up and
in the allowlist.

- Explicitly list the tools to call in the compilation prompt so
  get_rss_items gets invoked alongside list_tasks/list_events/get_weather.
- When the model calls get_rss_items during a compilation run,
  intercept and return the already-scored/filtered items (topic prefs
  + reaction-weighted) instead of the raw feed dump execute_tool
  would return. Aligns the model's view of news with the sidebar's
  rss_item_ids metadata.
2026-04-11 13:30:26 -04:00
bvandeusen 593d737e26 feat(briefing): UI polish for agentic briefing messages
Three visual improvements for the briefing conversation:

1. Intermediate tool-call messages (empty content, briefing_intermediate:
   true) now render as a compact dashed status row with per-tool pills
   showing result counts, instead of an empty assistant bubble followed
   by a stack of ToolCallCards. Click to expand the full cards.

2. Slot badge on non-intermediate briefing messages — "Full Briefing"
   for compilation, "Morning/Midday/Afternoon Update" for slot
   injections. Slot updates get a softer bubble treatment (transparent
   background, muted border, smaller text) so the compilation stays
   visually dominant.

3. Slot separator now triggers on briefing_slot metadata (not the
   compilation-only rss_item_ids), and uses a look-behind so it only
   fires when there's a prior slot message — no separator above the
   first briefing of the day.

New component: BriefingToolStatusRow.vue handles the intermediate
pill row and delegates to ToolCallCard when expanded.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 22:10:31 -04:00
bvandeusen 06dbc0e27a test(briefing): drop tests for removed legacy helpers
format_task, compute_task_hash, and split_changed_tasks were deleted
in 9eba6ac when the legacy one-shot briefing path was ripped out.
Their tests went with them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:48:37 -04:00
bvandeusen 7e0938fe7d feat(fable-mcp): add reset_today_briefing tool
Wipes today's briefing messages (keeps the conversation row) and
optionally re-fires the compilation slot to regenerate. Pairs with
the new POST /api/briefing/reset-today backend route.

Also drops the stale briefing_mode reference from trigger_briefing's
docstring now that legacy mode is gone.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:48 -04:00
bvandeusen 9eba6ac107 refactor(briefing)!: remove legacy one-shot synthesis, agentic-only
Deletes ~760 lines of legacy briefing code: format_task, compute_task_hash,
upsert_task_snapshots, _gather_internal, _gather_weekly_review,
_llm_synthesise, and the unified prompt helpers. run_compilation and
run_slot_injection are now agentic-tool-use-loop only.

briefing_scheduler and user_profile migrated from the deleted helper to
services.llm.generate_completion (retry + keep_alive baked in).

routes/briefing.manual_trigger now persists agentic tool-call receipts
via _persist_agentic_messages (previously silently dropped them) and
adds POST /api/briefing/reset-today to wipe today's briefing messages.

BREAKING: briefing_mode setting no longer honored; no legacy fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:42 -04:00
bvandeusen 66f906a629 fix(briefing): exclude paused-project tasks from briefing tooling
list_notes gains exclude_paused_projects; list_tasks tool sets it when
no explicit project filter is given. Paused projects no longer leak
tasks into briefings or list_tasks results.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-10 20:42:33 -04:00
Bryan Van Deusen 5f4759a5e8 feat(chat): ground factual claims in tool results, be honest when empty
Applies the grounding discipline from the agentic briefing work to the
main chat system prompt. The regular chat pipeline was already agentic
(it uses stream_chat_with_tools), but its system prompt never told the
model "only assert facts from tool results" or "if a tool returns
nothing, say so honestly." That left room for the same class of
hallucinations the briefings had — calling list_events, getting an
empty array, and then confidently mentioning a meeting anyway.

Adds two new static rules to the tool guidance block in llm.build_context:

GROUNDING — when the user asks about their own data, call the relevant
tool to see what exists. Never assert from memory or assumption.

HONESTY WHEN EMPTY — if a tool returns empty results, tell the user
plainly. No fabricated example items, no invented meetings, no generic
suggestions dressed up as real data.

Both rules are in the static (KV-cache-stable) portion of the system
prompt so they cost nothing on repeated requests for the same user.

Carries the hallucination fix from the briefing work directly into
every chat turn, not just chat that happens inside a briefing thread.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:54:01 -04:00
Bryan Van Deusen 25c767ddf2 fix(test): update run_slot_injection mock for new tuple return
PR 1.5 (commit 4168167) changed run_slot_injection's signature from
returning str to returning tuple[str, dict] so the scheduler can get
at the agentic message list for receipt persistence. The
test_run_slot_morning_runs_on_work_day mock was still returning the
old plain-string value, which unpacked to a ValueError at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:22:06 -04:00
Bryan Van Deusen 7e06e58ab6 chore(fable-mcp): bump version to 0.2.6 for briefing introspection tools
Manual bump because the pre-commit hook that normally handles this
didn't run — its scripts had lost their executable bits earlier in
the session. Permissions have been restored in this commit's tree,
so subsequent commits touching fable-mcp will auto-bump again.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:07:54 -04:00
Bryan Van Deusen 2fd914916f feat(fable-mcp): briefing introspection and manual trigger tools
Adds the MCP tools needed to debug the agentic briefing pipeline
without waiting for scheduled slots to fire:

- fable_list_briefings — list briefing conversations newest first
- fable_get_today_briefing — fetch today's briefing with all messages
- fable_get_briefing_messages(conv_id) — message list for a specific
  briefing conversation, including tool_calls with embedded results
- fable_trigger_briefing(slot) — manually run a slot via
  POST /api/briefing/trigger (fires RSS/weather refresh, same as
  the scheduler)
- fable_get_conversation(conv_id) — generic conversation read for
  chat or briefing threads, full messages + tool_calls + metadata

All of these hit existing REST endpoints (/api/briefing/conversations,
/api/briefing/conversations/<id>/messages, /api/briefing/trigger,
/api/chat/conversations/<id>) so no API surface changed — the gap
was purely on the MCP side. Bearer token auth works across both
blueprints because login_required already accepts API keys.

Enables a full "trigger → inspect → tune prompt → repeat" loop from
Claude without touching the UI, which is necessary for iterating on
agentic briefing prompts when the scheduled slot is hours away.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:06:55 -04:00
Bryan Van Deusen 4168167f24 feat(briefing): agentic slot injections + persist tool-call receipts
PR 1.5 + PR 2 of the agentic briefing rollout. Extends the agentic
path to cover the morning/midday/afternoon check-in slots in addition
to the 4am compilation, and persists the full tool-call sequence
into the briefing conversation so chat follow-ups see the receipts.

run_slot_injection now honors the briefing_mode setting the same way
run_compilation does: agentic mode routes through run_agentic_briefing
with a check-in system prompt variant, legacy falls back automatically
if the new path returns empty. Signature changed from str to
(str, dict) to surface the agentic message list to the caller.

_agentic_system_prompt now takes the user's local-day window and
timezone, pre-computed by run_agentic_briefing, and embeds them in
the prompt. This eliminates a whole class of "wrong day" bugs that
would otherwise happen when the model tried to translate "today" into
ISO 8601 ranges without knowing the user's timezone.

briefing_scheduler._run_slot_for_user now calls _persist_agentic_messages
after each slot, which walks the agentic message list and stores
every intermediate assistant turn (with its tool calls and results
folded into the flat storage format the existing chat loader expects)
as a real message row. The synthetic "[Midday briefing update]"
user-role messages are no longer created — final prose is written
with metadata.briefing_slot so the UI can still identify it as a
scheduled entry. The agentic user-trigger ("Generate my morning
briefing…") is deliberately skipped so it doesn't recreate the same
fake-user-message problem we're trying to remove.

briefing_conversations.post_message now accepts tool_calls, matching
the schema the Message model already supports. This lets scheduled
briefings write structured tool-use history without reaching into
the model layer.

Net effect with briefing_mode="agentic" on:
- All four slots are grounded in tool results, no more hallucinated
  events or tasks
- Chat follow-ups in the briefing conversation see morning's
  list_events → [] receipt (and everything else), so "what meeting?"
  gets an honest "nothing on the calendar" reply grounded in data
- No more fake [Briefing update] user messages in the chat scroll

Still to come (PR 3): UI polish — tool-call status row, inline cards
for list_events/list_tasks results, visual treatment for slot messages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:01:23 -04:00
Bryan Van Deusen 22a13636e8 fix(events): null end_dt no longer matches events outside the window
services/events.py::list_events treated any event with end_dt IS NULL
as perpetually matching, so a point-event created last week would be
returned as "happening today" whenever the briefing or list_events
tool queried today's window. That manifested as briefings claiming
past events were scheduled for the current day.

Split the null-end branch: point events now only match when start_dt
falls within [date_from, date_to]. Events with an end_dt continue to
use standard overlap logic.

This bug affected both the legacy briefing synthesis and the agentic
list_events tool path, since both call the same events service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:01:01 -04:00
Bryan Van Deusen aebb6baa2c feat(briefing): agentic compilation path behind feature flag (PR 1/N)
First cut of the agentic briefing redesign. Morning compilation can now
route through a tool-call loop that grounds every factual claim in an
actual tool result, eliminating the hallucinated meetings, tasks, and
news items the legacy one-shot path was producing. Behind a per-user
`briefing_mode` setting (default "legacy"); falls back to the legacy
path automatically if the new path returns empty (e.g. model too weak
to drive tool calls reliably).

New: services/briefing_tools.py — explicit read-only allowlist of 10
tools (tasks, events, weather, rss, projects, notes). New tools added
to tools.py must be opted in by name. Excludes all mutating tools and
external search tools (search_images, search_web, research_topic) which
are neither useful nor safe for a scheduled background job.

New: briefing_pipeline.run_agentic_briefing — wraps the existing
stream_chat_with_tools loop with slot-specific system prompts that tell
the model to only assert facts from tool results and to be honest when
tools return nothing. Max 8 rounds, per-round exception handling,
returns the full message list so tool-call receipts can be persisted
alongside the prose in a later PR.

Sentence-count floors bumped: compilation 6–10 (was 4–8), check-ins
3–5 (was 2–3). Weekly review 5–8.

Design doc: docs/2026-04-10-agentic-briefing-design.md

Out of scope for this PR (future PRs): slot-injection migration,
persisting tool-call receipts into the conversation so chat follow-ups
see them, UI polish for tool-call status, sidecar storage for
briefings. See the design doc's migration path for details.

Enable on an account with:
  UPDATE settings SET value='agentic'
  WHERE user_id=<id> AND key='briefing_mode';
or insert the row if missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:55 -04:00
Bryan Van Deusen 3f3156db07 feat(ollama): configurable per-model keep_alive durations
Replace the hardcoded "2h" keep_alive everywhere with a helper that
returns OLLAMA_KEEP_ALIVE_MAIN (default 30m) for the interactive model
and OLLAMA_KEEP_ALIVE_BACKGROUND (default 10m) for the background
model. Lets the main model release VRAM during long idle periods
while keeping it warm enough for bursty chat use, and stops the
sporadic background model from camping VRAM it rarely needs.

Seven call sites updated to route through llm.keep_alive_for(model):
the streaming helpers, generate_completion, the two startup warmers,
the settings KV-cache primer, and the chat warmer endpoint.

Override via env vars: OLLAMA_KEEP_ALIVE_MAIN, OLLAMA_KEEP_ALIVE_BACKGROUND.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 14:13:32 -04:00
Bryan Van Deusen 102c0b74a0 feat(settings): tabbed MCP install instructions with Claude Code flow
Replace the hand-edit-JSON instructions in Settings → API Keys with a
tabbed UI (Claude Code / Claude Desktop / Other). The Claude Code tab
leads with the `claude mcp add` command, pre-filled with FABLE_URL and
the most recently generated API key, plus copy-to-clipboard buttons on
every snippet. Recommend `uv tool install` or `pipx install` over bare
`pip install` so fable-mcp reliably lands on PATH under PEP 668.

Also fix incorrect priority values in fable-mcp tool docstrings — the
enum is `none|low|medium|high`, not `low|normal|high`.

DRY pass: extract shared `copyToClipboard()` helper used by both
`copyApiKey` and the new snippet buttons; reuse existing
`btn btn-secondary btn-sm` for the copy buttons instead of a bespoke
class.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 12:35:50 -04:00
bvandeusen 8fb81bc1ed Merge pull request 'Release v26.04.10.1' (#28) from dev into main 2026-04-10 15:05:15 +00:00
bvandeusen c1bc73da8e fix(briefing): remove redundant current-conditions block; patch live temp into WeatherCard
The standalone current-conditions div showed just a bare temperature with no
forecast context, making the weather panel look incomplete. Now the live
temperature from /weather/current is patched directly into the WeatherCard's
current_temp so it stays fresh without a second display block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:43:30 -04:00
bvandeusen 3b8d40fea3 feat: overall completion bar on project cards; auto-collapse done milestones
ProjectListView: add an overall task completion bar above the per-milestone
bars showing the percentage of all project tasks that are done.

ProjectView: milestones where every task is complete now start collapsed
by default, keeping the view clean for projects with many finished stages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:38:02 -04:00
bvandeusen 2f5abc034e style: update email templates to Modern Fable visual identity
Replace indigo (#6366f1) with deep violet (#7c3aed) gradient header,
violet-tinted card border and background, refined border radius and
spacing, and violet-accented footer to match the app's design language.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:31:16 -04:00
bvandeusen 7a01733334 fix: coerce null project fields to empty string to avoid NOT NULL violation 2026-04-09 21:34:14 -04:00
bvandeusen 7d5611d00b fix: add 'paused' tab to project list filter 2026-04-09 19:33:50 -04:00
bvandeusen e5821ef3f8 fix: allow 'paused' in project status validation 2026-04-09 18:17:48 -04:00
bvandeusen 43231f44d2 fix(search): hybrid keyword + semantic search — exact title/body matches rank first 2026-04-09 12:28:55 -04:00
bvandeusen ab0b9c3199 fix(lint): add timezone import, use timedelta directly, remove unused variable 2026-04-09 08:46:34 -04:00
bvandeusen d12fc5d282 feat: paused project status with auto-pause (14 days), auto-reactivate on activity; fix back button context 2026-04-09 08:37:08 -04:00
bvandeusen 4c3d67994b fix: back button returns to project when note/task has a project, otherwise to Knowledge 2026-04-09 08:18:46 -04:00
bvandeusen 126bad7d99 fix(header): darken nav text in light mode — use text-secondary for inactive, primary-solid for active 2026-04-08 23:43:25 -04:00
bvandeusen 28fae75258 Merge pull request 'Release v26.04.09.1 — Briefing intelligence overhaul' (#27) from dev into main 2026-04-09 03:30:17 +00:00
bvandeusen 058bd76719 feat(briefing): multi-article topic discussion endpoint for deep thematic analysis 2026-04-08 22:53:23 -04:00
bvandeusen d56b57ee40 feat(briefing): cluster-level preference filtering — rank themes by user interest, suppress disliked topics 2026-04-08 22:51:13 -04:00
bvandeusen 8762552234 feat(briefing): project continuity — yesterday's activity and stale project warnings 2026-04-08 22:19:38 -04:00
bvandeusen 6cbf9be052 feat(briefing): weekly review — 7-day recap with task/note/project summary and upcoming preview 2026-04-08 22:18:19 -04:00
bvandeusen 3687fbeeb9 feat(briefing): differentiated check-in slots — midday progress nudge, afternoon wrap-up 2026-04-08 22:09:18 -04:00
bvandeusen a6543c1dc5 feat(briefing): cluster news by topic for thematic synthesis instead of flat headlines 2026-04-08 22:07:24 -04:00
bvandeusen 4558dd578a feat(briefing): weather × calendar cross-reference — rain warnings for outdoor events 2026-04-08 22:03:18 -04:00
bvandeusen 8647f52fbc feat(weather): live current conditions endpoint + polling display in briefing view 2026-04-08 21:33:27 -04:00
bvandeusen 304affb837 feat(briefing): daily planning — prioritized focus list and calendar gap analysis 2026-04-08 20:59:06 -04:00
bvandeusen 2e3f90384d feat(briefing): actionable intelligence — days overdue, project context, suggest next steps 2026-04-08 20:33:16 -04:00
bvandeusen 16b9ed9392 Merge pull request 'Release v26.04.08.3 — Type editors, calendar UX, DRY theme, contrast' (#26) from dev into main 2026-04-08 18:52:25 +00:00
bvandeusen f9c6802939 feat(calendar): linked start/end times, 1h default duration, smart rounding, past event hint 2026-04-08 13:47:15 -04:00
bvandeusen f2debd8a5b feat(knowledge): show organization for person cards, category for place cards
Exposes birthday, organization, address (person) and website, category (place)
from entity_meta in the knowledge API response; updates KnowledgeView cards
to display organization and category as visible meta chips/text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:32:15 -04:00
bvandeusen f8f14eea0f feat(editor): place form-first layout and list builder with Enter-to-add
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:30:26 -04:00
bvandeusen 64c19932a7 feat(editor): person form-first layout with structured fields and collapsible notes
When note type is 'person', replace the main TipTap editor with a contact card form
(Relationship, Birthday, Email, Phone, Organization, Address). The TipTap editor moves
to a collapsible 'Notes' section below the form, auto-expanded when existing body content
is present. Person fields are removed from the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:27:22 -04:00
bvandeusen 317d8f25d0 feat(editor): skip toolbar in tab order; auto-focus title; type-dependent placeholders
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 13:25:07 -04:00
bvandeusen 87f74b1cd5 docs: add specialized note type editors implementation plan 2026-04-08 13:21:20 -04:00
bvandeusen f7fda0adca docs: add specialized note type editors design spec 2026-04-08 13:08:01 -04:00
bvandeusen 0ca39a2e34 feat(knowledge): redesign new item button as gradient CTA with icon menu for all 5 types 2026-04-08 12:34:52 -04:00
bvandeusen 16d9af8b96 fix(theme): increase dark mode contrast for borders, glows, and card edges; DRY color values into CSS variables 2026-04-08 12:22:03 -04:00
bvandeusen 2ac894b5d1 refactor(theme): DRY hardcoded violet values into CSS custom properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:19:38 -04:00
bvandeusen 7e81e50e3e feat(theme): sweep indigo to violet across all remaining views and components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 12:12:46 -04:00
bvandeusen d8945536c4 Merge pull request 'Release v26.04.08.2 — Knowledge consolidation + Modern Fable identity' (#25) from dev into main 2026-04-08 15:40:12 +00:00
bvandeusen f30e90ef8d feat: narrator empty states, scroll fades, glow buttons, violet color sweep 2026-04-08 11:17:06 -04:00
bvandeusen 00f82f8cba feat(knowledge): card type DNA, violet hover bloom, amber timestamps, narrator empty states
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:14:31 -04:00
bvandeusen f953a36b86 feat(header): pill nav bar, brand shortening, status pulse, header gradient 2026-04-08 11:11:35 -04:00
bvandeusen 4bc7f9eaac feat(theme): shift palette from indigo to deep violet + muted gold
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:09:23 -04:00
bvandeusen 670de547ad docs: add Modern Fable visual identity implementation plan 2026-04-08 10:43:20 -04:00
bvandeusen a684d84edf feat: deprecate /notes and /tasks routes; redirect to Knowledge view 2026-04-08 10:35:21 -04:00
bvandeusen 34729cf1cf feat(knowledge): add task cards with status/priority/due-date display 2026-04-08 10:32:51 -04:00
bvandeusen dd16b39218 feat(knowledge): include tasks in knowledge queries and counts 2026-04-08 10:27:31 -04:00
bvandeusen 0bd362f582 docs: add Knowledge task consolidation implementation plan 2026-04-08 10:11:25 -04:00
bvandeusen 0afa2d8771 docs: add Modern Fable visual identity design spec 2026-04-08 10:04:55 -04:00
bvandeusen 93af4e6bab docs: add Knowledge view task consolidation design spec 2026-04-08 09:47:11 -04:00
bvandeusen 6e3de91a6f Merge pull request 'fix: resume AudioContext for silence detection' (#24) from dev into main 2026-04-08 12:37:16 +00:00
bvandeusen 6e0c528126 fix(voice): resume AudioContext to prevent suspended state blocking silence detection 2026-04-07 22:54:41 -04:00
bvandeusen dd3b59ed36 Merge pull request 'Release v26.04.07.3' (#23) from dev into main 2026-04-08 00:43:11 +00:00
bvandeusen 56b687c9f4 fix(voice): show mic button on HTTP; graceful error for non-secure contexts 2026-04-07 20:06:57 -04:00
bvandeusen 6d57840dc7 fix(voice): re-check voice status on tab visibility change and after saving voice settings 2026-04-07 20:01:47 -04:00
bvandeusen 7f37cee49f refactor(voice): integrate click-to-toggle mic into ChatInputBar; remove VoiceOverlay 2026-04-07 13:17:25 -04:00
bvandeusen 39e554d938 fix: rewrite title generator to only use user messages; bump background model to qwen2.5:3b 2026-04-07 12:53:33 -04:00
bvandeusen b3cf42863a fix: reinforce no-project-inference in system prompt; filter tool messages from title generation 2026-04-07 12:45:08 -04:00
bvandeusen d290bebad2 feat(stt): pass conversation context as Whisper initial_prompt to reduce mishearings 2026-04-07 08:48:27 -04:00
bvandeusen 58e5c6bc60 Merge pull request 'Release v26.04.07.2' (#22) from dev into main 2026-04-07 12:39:52 +00:00
bvandeusen d3170e5545 fix(chat): fetch full article content in from-article endpoint 2026-04-07 08:13:40 -04:00
bvandeusen 814f44c3fb fix(push): fix VAPID key format and add regenerate endpoint + UI button 2026-04-07 06:59:06 -04:00
bvandeusen 1d0cf4828b fix(briefing): fetch full article content via trafilatura in discuss endpoint 2026-04-07 06:55:15 -04:00
bvandeusen 56f1c44b8e Release v26.04.07.1 2026-04-07 02:40:27 +00:00
bvandeusen a5e35f7c72 feat: mount VoiceOverlay and wire Space bar shortcut in App.vue 2026-04-06 20:20:19 -04:00
bvandeusen 853dc810ff feat: click-to-toggle silence detection and amplitude bars in VoiceOverlay 2026-04-06 20:01:44 -04:00
bvandeusen ac7dde472f feat: expose live stream ref from useVoiceRecorder 2026-04-06 20:00:22 -04:00
bvandeusen 84926d4ba2 feat: add useSilenceDetector composable with Web Audio API amplitude monitoring 2026-04-06 19:48:32 -04:00
bvandeusen 9b69e38aff docs: add web voice overlay polish implementation plan 2026-04-06 19:40:56 -04:00
bvandeusen 1b68559bfe docs: add web voice overlay polish spec 2026-04-06 19:39:27 -04:00
bvandeusen edf0a9063e fix: prevent model from inferring project names on create_task/create_note
The model was hallucinating project names from task/note content (e.g.
inferring "Vehicle Maintenance" from "purchase wheel bearings"). Added
explicit guidance to both project field descriptions: only set if the
user explicitly named a project, never infer from content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 19:17:43 -04:00
bvandeusen f2c2117b25 Merge pull request 'feat: add News link to main navigation header' (#20) from dev into main 2026-04-06 22:20:27 +00:00
bvandeusen b9d0716b01 feat: add News link to main navigation header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 17:49:22 -04:00
bvandeusen 9af8ab8f70 fix(briefing): use briefing context for follow-ups; add slot separator
- build_context: when conversation_type is 'briefing', inject a system
  prompt instruction telling the model to answer from conversation history
  and article context instead of searching the web
- Consolidate briefing conversation type detection to one DB query (was
  being checked twice — once for the system prompt addition, once for
  article context injection)
- ChatPanel: render a visual 'New Briefing Update' separator line before
  2nd+ briefing slot messages (identified by metadata.rss_item_ids)
- types/chat.ts: add metadata field to Message interface

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 06:15:18 -04:00
bvandeusen a171210224 feat(tools): confirmed guard for deletes, update_person/place, get/update_profile, calculate
- delete_note / delete_task: add confirmed parameter + requires_confirmation guard
  (find the note first, then ask, consistent with create_note/task pattern)
- get_note: description now mentions notes AND tasks
- update_person / update_place: new tools to update existing entity notes in-place
- get_profile / update_profile: surface and edit the user's stored profile
  (expertise, tone, response style, job title, interests)
- calculate: eval math expressions via Python math module; solves precision issues
  on multi-step arithmetic and supports sqrt/log/trig/etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:58:14 -04:00
bvandeusen eb92b2a976 feat(research): multi-note pipeline — outline + parallel section synthesis + index note
Replaces the single monolithic research note with topic-driven section notes
plus an index note. Two new LLM calls: _generate_outline (JSON outline, 3-8
sections) and _synthesize_section (300-600 word focused note per section,
parallelised via asyncio.gather). Public signature of run_research_pipeline
unchanged; falls back to single-note synthesis on outline failure or if all
sections fail.

Also extracts _build_sources_block helper and adds full test suite.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:53:14 -04:00
bvandeusen be805073a7 docs: add research multi-note redesign spec 2026-04-05 22:42:51 -04:00
bvandeusen e4c812a603 feat(voice): improve TTS logging for root-cause diagnosis
- Route now logs every synthesis request (char count, voice, speed)
- Route logs char count + text preview when the 8000-char limit is hit
- Route logs empty audio with preview (helps spot no-chunk-produced edge case)
- Route logs success with byte count and duration
- Kokoro synthesise() logs per-call: samples produced, elapsed, chars/s
- Kokoro synthesise() logs warning when zero audio chunks returned with preview
- Kokoro synthesise() catches and logs pipeline-internal errors with preview
- Frontend: console.warn now includes char count + 80-char preview on failure and retry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:36:43 -04:00
bvandeusen 8f7590d322 Release v26.04.06.1 — Article reading, quick capture rewrite, settings consistency 2026-04-06 02:20:59 +00:00
bvandeusen 3bdadaeca8 fix(embeddings): remove stale CONTENT_MAX_CHARS import from rss.py
CONTENT_MAX_CHARS was removed from rss.py when the article content cap
was lifted. backfill_rss_article_content still referenced it, causing an
ImportError on startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:04:10 -04:00
bvandeusen c8c0de3b04 feat(settings): SSO account guard + remove redundant Office Days
- Account tab: SSO users see an info banner instead of email/password forms
- Briefing tab: remove Office Days section (work days now come from Profile)
- Remove unused toggleWorkDay function

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:41:26 -04:00
bvandeusen 1357046160 feat(settings): add timezone field to General tab
- New Timezone section with text input + Detect button
- Detect auto-fills from browser Intl API
- Save calls PUT /api/settings (which now propagates to scheduler)
- Briefing tab firing timezone hint reads stored value instead of live browser API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:39:08 -04:00
bvandeusen 8ec91ceea7 feat(settings): propagate user_timezone to briefing scheduler on save
When user_timezone is saved via PUT /api/settings, immediately call
update_user_schedule if briefing is enabled so the scheduler picks up
the new timezone without requiring a restart.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:35:00 -04:00
bvandeusen 0ec030cb8f feat(scheduler): slot gating + morning work-day gate
- _add_user_jobs now accepts config dict and skips disabled slots
- _get_briefing_enabled_users returns 3-tuple (user_id, tz, config)
- update_user_schedule passes config through to _add_user_jobs
- _run_slot_for_user skips morning slot on non-work days via profile.work_schedule
- Add tests for slot gating and work-day gate

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 21:34:29 -04:00
bvandeusen f4aca40562 docs: add settings consistency pass design spec 2026-04-05 21:10:15 -04:00
bvandeusen 68eee57c9b refactor(quick-capture): replace intent router with native tool-calling
Removes the custom classify_capture_intent + _process_note two-pass
approach. The LLM now picks the right tool directly via Ollama's native
tool_calls API (same path as the main chat pipeline). _should_think
decides whether extended reasoning is needed based on input length/
complexity. intent.py deleted — no longer needed.

Android app and response format unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:49:44 -04:00
bvandeusen 284dcd1c63 feat(briefing): add article Discuss endpoint with synthetic tool exchange
POST /api/briefing/articles/<id>/discuss injects stored article content
as a persisted read_article tool exchange before triggering generation.
The LLM sees the article as already read; follow-ups retain context via
the fixed history builder. Frontend Discuss button now calls the new
endpoint instead of inlining article text in the user message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:36:45 -04:00
bvandeusen 7dc5af2e88 feat(chat): add tool_calls param to add_message for synthetic messages
Needed by the Discuss endpoint to persist synthetic read_article
tool exchanges before triggering generation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:54 -04:00
bvandeusen eeb671872a fix(chat): replay tool_calls in history so tool context survives follow-ups
The history builder was silently dropping tool_calls from prior turns,
causing the LLM to lose article/search context on every follow-up.
Now reconstructs the assistant tool_call dict + per-call tool result
entries. Messages without tool_calls are unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:31 -04:00
bvandeusen 278927ec40 feat(tools): add read_article tool for fetching full article content
Adds read_article tool definition and execute_tool handler. Uses
_fetch_full_article (trafilatura) from rss.py, caps tool output at
40K chars to keep context window manageable. Always registered
(not gated on SearXNG). Tests cover success, failure, truncation,
empty URL, and history builder replay.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:33:11 -04:00
bvandeusen f7d54a15c0 feat(rss): remove article content character cap
Trafilatura extracts only article body text (typically 2K–15K chars),
so storing the full content is safe without an artificial ceiling.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 19:32:17 -04:00
bvandeusen 9dd4178774 docs: add article reading implementation plan 2026-04-05 15:32:54 -04:00
bvandeusen eed2f6c23a docs: add article reading design spec 2026-04-05 15:26:07 -04:00
bvandeusen db092b113e fix: rss classifier think-tag stripping, briefing calendar dict access, embed empty-string guard
- rss_classifier: strip <think>...</think> blocks (qwen3 reasoning output)
  before JSON parse; use strict=False for control chars; bump timeout 30s→120s
- briefing_pipeline: list_events returns dicts not Event objects — fix
  attribute access (.all_day/.start_dt/.title) to dict access
- embeddings: guard upsert_note_embedding and semantic_search_notes against
  empty-string input to prevent Ollama embed 400 errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:14:55 -04:00
bvandeusen b5106441dd fix(tests): add missing start_dt/end_dt to mock event to_dict in test_list_events 2026-04-05 00:05:27 -04:00
bvandeusen 94d21c4512 fix(settings): audit pass — model auto-pull on startup, background_model empty-string bug, base URL validation
- Startup now pulls Config.OLLAMA_MODEL (system default chat model) — previously only
  embedding and background models were pulled; the primary chat model was skipped
- _warm_user_models expanded to also pull user-configured default_model and
  background_model overrides that are missing from Ollama, rather than logging and
  skipping them; pulls run before warm/KV-cache priming
- Add background_model to _MODEL_KEYS in settings route so clearing the dropdown
  deletes the DB row instead of saving "", which caused Ollama failures in tag
  suggestions, title generation, project summaries, and RSS classification
- Add http/https scheme validation to PUT /api/admin/base-url matching the CalDAV
  route pattern; a bad value no longer silently breaks invite/password-reset links
- Update admin voice config description: "Reload models" button exists to avoid
  a server restart, so the old "restart required" text was misleading

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:08:14 -04:00
bvandeusen d9bd16633f fix(tasks): audit pass — permission checks, tool gaps, hard reload
- PUT/PATCH/DELETE /api/tasks/:id now use get_note_for_user + can_write_note
  so shared project editors can mutate tasks; owners unaffected
- PATCH /api/tasks/:id/status gets same treatment
- All write routes call update_note/delete_note with note.user_id (owner)
  not the accessing user's uid, matching the milestone fix pattern
- create_task tool gains tags (array) and status (enum) parameters;
  handler now passes tags to create_note and respects initial status
- create_task tool response now includes milestone_id and parent_id
- update_note tool gains milestone parameter; handler resolves the
  milestone by title within the note's current (or newly set) project,
  clears milestone_id when project is cleared
- list_tasks tool gains q keyword search parameter; passed through
  to list_notes
- TaskEditorView: replace window.location.reload() with
  router.push('/tasks/:id') after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:48:07 -04:00
bvandeusen c5191837fb fix(projects): audit pass — 8 correctness and consistency fixes
- Project.to_dict() now includes user_id and auto_summary
- Status validation unified to (active/completed/archived) on both
  create and update project routes; update route previously had none
- Milestone routes: replace get_project (ownership-only) with
  get_project_for_user so shared viewers/editors can access milestones
- Add get_milestone_in_project() to milestones service for project-
  scoped lookup without user_id filter; all milestone routes use it
- Milestone PATCH now validates status as 'active'|'done'; fix tool
  enum which was wrongly ['active','completed','cancelled']
- Write mutation routes (POST/PATCH/DELETE milestones) now check
  can_write_project() and return 403 for read-only shared users
- update_project tool now exposes title and color fields so projects
  can be renamed or recolored via chat
- create_project tool now exposes color field
- GET /api/projects?include_summary=true embeds summaries in one
  backend pass; ProjectListView switches to this, eliminating N+1
  per-project fetches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:14:42 -04:00
bvandeusen ed715dcc23 feat(knowledge): note types, counts, new-note button, audit fixes
- Add note_type (note/person/place/list) selector + entity metadata fields
  (relationship, email, phone / address, hours) to NoteEditorView
- Pre-select type via ?type= query param from KnowledgeView new-note dropdown
- KnowledgeView: add split "New note / ▾" button with type dropdown
- KnowledgeView: show per-type counts on sidebar filter buttons (when > 1)
- Fix: filter-btn now flex layout so count badge aligns to right edge
- Fix: list_notes count_query was missing parent_id filter (inflated totals)
- Fix: PATCH /api/notes/:id now fires upsert_note_embedding (workspace autosave)
- Fix: get_knowledge_counts endpoint for per-type counts
- Fix: get_knowledge_tags was silently discarding note_type filter (double-stmt bug)
- Fix: NoteEditorView onMounted stray brace from edit session

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:53:09 -04:00
bvandeusen 738245af5c fix(chat): audit fixes — retention, rag_project_id, cleanup scheduler, tool rounds
- cleanup_old_conversations now excludes briefing conversations (was
  silently deleting briefing history after the retention window)
- list_conversations response now includes rag_project_id, matching the
  shape returned by the single-conversation GET endpoint
- create_conversation_from_article: removed duplicate async_session import
  (_session2 was a copy of the same import); consolidated into one
- MAX_TOOL_ROUNDS fixed from 5→6 to match the actual range(6) loop;
  loop updated to range(MAX_TOOL_ROUNDS) so the constant is accurate
- Chat retention cleanup moved from per-request (every GET /conversations)
  to a daily scheduled job in event_scheduler.py; route no longer runs
  a DB write on every read

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:30:41 -04:00
bvandeusen edfed6b5bb feat(events): recurring expansion, CalDAV pull sync, past search, reminders
- RRULE expansion: list_events now expands recurring events into
  individual occurrences within the query window using python-dateutil
- CalDAV pull sync: new caldav_sync.py + POST /api/events/sync route;
  imports remote events into the internal store by caldav_uid
- Past event search: search_events accepts include_past=true to search
  historical events; exposed in the LLM tool definition
- Internal reminders: migration 0037 adds reminder_minutes +
  reminder_sent_at columns; event_scheduler.py checks every 5 min and
  fires push notifications; CalDAV sync job runs hourly
- reminder_minutes now stored and returned in create/update routes + tools

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:15:37 -04:00
bvandeusen 358534efbf fix(events): audit pass — 7 correctness fixes across the events system
Backend:
- tools.py: apply UTC normalization to update_event datetime fields
  (matched create_event which already did this)
- events.py service: allow end_dt/recurrence/project_id to be cleared
  via update_event by permitting None for nullable fields
- events.py service: find_events_by_query now returns upcoming events
  first, falling back to past — prevents AI tools from mutating stale
  past events when a future match exists
- events.py service: list_events now uses overlap logic (start <= to
  AND end >= from) so multi-day events spanning the query boundary
  are included; previously only start_dt was checked

Frontend:
- ToolCallCard: fire fable:calendar-changed on created/updated/deleted
  so CalendarView refetches without requiring a manual page refresh
- KnowledgeView: replace raw apiGet('/api/events') with listEvents()
  client function; also fix today bar which was reading .events off a
  flat array (always empty) — now correctly receives EventEntry[]
- HomeView: use full ISO strings for event date range instead of naive
  UTC-midnight strings; deduplicate inline date math via _dateRange()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 12:02:10 -04:00
bvandeusen 7677ab4028 fix(calendar): use date-only strings for all-day events to prevent timezone shift
UTC midnight passed to FullCalendar's timeZone:'local' was being
converted to local time, shifting all-day events back by 1+ days for
users in UTC-X zones. The edit form had the same bug via new Date().

Fix: pass YYYY-MM-DD slices (UTC date) for all-day events in both
toFcEvent and EventSlideOver resetForm, bypassing timezone conversion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 11:01:51 -04:00
bvandeusen 90afbec4c2 feat(knowledge): two-tier pagination — ID pre-fetch + content batch loading
Backend:
- GET /api/knowledge/ids: returns up to 100 note IDs cheaply (no body
  parsing), supports same filters as /api/knowledge, includes has_more
- GET /api/knowledge/batch?ids=...: fetches full items for given IDs in
  order; used by frontend to load content in controlled batches

Frontend (KnowledgeView):
- Fetch 100 IDs upfront, load first 50 as content on mount
- IntersectionObserver sentinel (root: null) triggers 24-item content
  batches as user scrolls
- Proactive ID refill when queue drops below 48 unloaded IDs
- fetchGen counter invalidates stale in-flight responses on filter reset
- IDs claimed before async fetch to prevent double-loading
- sentinelVisible ref drives post-load re-check when content doesn't
  push sentinel off screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:44:50 -04:00
bvandeusen 5495fd1500 fix(knowledge): debounce scroll handler with rAF to prevent duplicate page loads
Coalesces rapid scroll events into one check per animation frame so
that page++ can only fire once per frame, eliminating the window where
multiple events slip through before loading=true is observed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 09:25:52 -04:00
bvandeusen 1fc0004e93 fix(knowledge): replace IntersectionObserver with scroll event; increase card height
- Swap IntersectionObserver (race-prone, fired immediately on creation)
  for a passive scroll listener on the grid container — eliminates
  duplicate page loads caused by observer re-creation after DOM updates
- Increase card min-height 100px → 160px so tags + snippet are visible
- Increase snippet line-clamp 3 → 4 for more content preview

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:24:19 -04:00
bvandeusen 968e536d3a fix(events): normalize naive datetimes to UTC in HTTP route
All datetime parsing now uses _parse_dt() which adds UTC tzinfo when
none is present, matching the fix already applied in tools.py. This
prevents asyncpg errors when comparing naive datetimes against
TIMESTAMPTZ columns — the root cause of events not appearing in the
calendar view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 00:00:43 -04:00
bvandeusen 3c38c04ad4 fix(knowledge): sentinel DOM ordering + restore tag visibility
Sentinel was first in DOM with order:9999, causing layout recalculation to
trigger IntersectionObserver multiple times (intersecting→not→intersecting)
as items were appended, producing duplicate pages. Move sentinel to AFTER
the v-for items so it's naturally last in both DOM and visual order.

Remove overflow:hidden from .k-card-tags — .k-card overflow:hidden already
clips escaping content; the extra overflow on the tags container was
collapsing its height to zero and hiding all tag pills.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 22:54:35 -04:00
bvandeusen 4ac26d9326 fix(knowledge): clip card overflow so tags don't escape card boundary
Added overflow:hidden to .k-card so wrapped tags are clipped within the
card border-radius. Added min-width:0 + overflow:hidden on .k-card-tags
so the flex item can shrink properly and doesn't push past the card width.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:57:28 -04:00
bvandeusen 00abfcf4db fix(knowledge): remove backup load check causing duplicate items on scroll
The IntersectionObserver fires as soon as it's created (sentinel immediately
intersecting after page 1 renders), while the removed backup check also fires
in the same tick — two concurrent fetchItems(page 2) calls produced duplicate
cards. With sentinel now properly inside the scrolling root, the observer
alone handles progressive loading.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:55:11 -04:00
bvandeusen 3887cab66e fix: knowledge infinite scroll + list_events timezone handling
KnowledgeView: sentinel was OUTSIDE the card-grid div, making
IntersectionObserver (root: cardGridEl) never fire since the target must
be a descendant of the root. Moved sentinel inside card-grid with
grid-column:1/-1 + order:9999 so it spans all columns and sits at the
bottom. Fixed backup check to compare against container bounds not viewport.

tools.py list_events: apply same UTC normalization as create_event (treat
naive datetimes as UTC, handle Z suffix). Update tool description to
explicitly request full-day UTC ranges so the LLM doesn't send local time
without offsets, which caused the recall query to miss UTC-stored events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 21:10:43 -04:00
bvandeusen aeb778f35a feat(calendar): month/year picker popover on title click
Click the month name in the FullCalendar toolbar to open a popover with
prev/next year arrows and a 4×3 month grid. Clicking a month jumps the
calendar to that month via gotoDate(). Current month highlighted. Picker
closes on outside click. Title gains hover highlight + pointer cursor.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:27:01 -04:00
bvandeusen eda9c5ce43 feat: migrate KnowledgeView mini-chat to ChatPanel + enable note picker everywhere
- KnowledgeView: replace custom mini-chat (voice, PTT, manual scroll, message
  rendering) with ChatPanel variant="full"; gains RAG scope chip, TTS listen
  mode, volume control, and note picker automatically (-218 lines)
- ChatInputBar: remove briefingMode guard on note picker so attach/search works
  in briefing, workspace, widget, and knowledge chat surfaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 20:16:36 -04:00
bvandeusen d624d38412 fix: KnowledgeView infinite scroll root + calendar event refresh
- KnowledgeView: IntersectionObserver was watching the viewport instead
  of the card-grid scroll container, causing infinite scroll to stop
  loading after only ~29 items. Pass card-grid element as `root`.
- CalendarView: listen for 'fable:calendar-changed' custom event and
  call refetchEvents() so tool-created events appear without navigation.
- ChatPanel: dispatch 'fable:calendar-changed' when create_event,
  update_event, or delete_event tool calls succeed.
- tools.py: normalize naive datetimes to UTC before storing events so
  timezone comparisons in list_events queries are always consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 19:28:33 -04:00
bvandeusen 86e718dda1 refactor: migrate HomeView to ChatPanel widget, delete DashboardChatInput
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 18:36:13 -04:00
bvandeusen 0b1ed2afe5 refactor: migrate WorkspaceView chat to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 17:47:17 -04:00
bvandeusen 2c446be83a refactor: migrate BriefingView chat to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:50:18 -04:00
bvandeusen f36398f892 refactor: migrate ChatView to use ChatPanel component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:41:22 -04:00
bvandeusen 927f137aaf feat: rewrite ChatPanel with full and widget variants
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:37:20 -04:00
bvandeusen 7eaf4d9dca feat: add ChatStreamingBubble extracted streaming state component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 16:30:05 -04:00
bvandeusen 89a9088b94 feat: add ChatInputBar unified input bar component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:57:51 -04:00
bvandeusen c89586dcd5 docs: add ChatPanel unification implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:42:01 -04:00
bvandeusen fb26507123 docs: add ChatPanel unification design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 15:24:30 -04:00
bvandeusen 0a913045a8 fix(tts): play() must return a promise that resolves when audio finishes
Without this, await audio.play() resolves immediately after source.start(),
so the playQueue chains the next sentence before the current one finishes,
causing overlapping / interrupted playback.
2026-04-03 14:21:44 -04:00
bvandeusen c81a499e6e fix(tts): add speak() for complete responses; fix briefing message commit race 2026-04-03 14:18:34 -04:00
bvandeusen 8024706870 fix(knowledge): fill viewport on load when sentinel stays visible 2026-04-03 14:07:52 -04:00
bvandeusen 22003788f5 fix(generation): compute num_ctx in run_assist_generation 2026-04-03 13:35:47 -04:00
bvandeusen 9d519054ee feat(tts): add streaming TTS listen mode to WorkspaceView 2026-04-03 13:15:28 -04:00
bvandeusen b4f5a935b2 feat(tts): wire useStreamingTts into BriefingView 2026-04-03 13:11:12 -04:00
bvandeusen fda6a7acc1 feat(tts): wire useStreamingTts into ChatView 2026-04-03 13:09:52 -04:00
bvandeusen 7ffd412603 feat(tts): add useStreamingTts composable for sentence-level streaming 2026-04-03 12:54:05 -04:00
bvandeusen b92c6b1487 docs: streaming TTS implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:43:28 -04:00
bvandeusen b8cd2e5ed7 docs: streaming TTS design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 12:16:56 -04:00
bvandeusen ef55bcb560 feat(llm): adaptive num_ctx tiers + fix KV cache priming num_ctx mismatch
Adds pick_num_ctx() which selects the smallest context window tier
(8192, 16384, 32768) that fits the current messages with 25% headroom,
capped at OLLAMA_NUM_CTX. Threads num_ctx through generation_task.py so
every chat request uses the computed tier rather than a fixed 16384.

Fixes a critical cache miss bug: KV cache priming in app.py and
settings.py was sending requests without num_ctx, so Ollama sized the
cache at its model default (different from the 16384 real requests used),
forcing a full model reload on the first real user message. Both priming
sites now call pick_num_ctx() and pass the matching value.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:47:39 -04:00
bvandeusen a6888953dc perf(prompts): trim tool schema descriptions to reduce prompt token count
Removes verbose redundant text from tool descriptions and system prompt
guidance: multi-line recurrence_rule JSON examples, CAPS warnings that
duplicate system prompt instructions, and wordy descriptions that don't
add model understanding.

Saves ~990 tokens per request (~17% reduction, 5,639 → ~4,650 tokens),
reducing prefill time on cache misses and lowering KV memory pressure.
No functional changes — parameter names, types, enums, and required
fields are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 11:01:05 -04:00
bvandeusen c9065c4481 perf(settings): prime KV cache when user changes chat model
When a user saves a new default_model in Settings, fire a background
cache-prime request so the first message with the new model is fast
rather than paying the full cold-start prefill cost.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:51:17 -04:00
bvandeusen 4792e6459b perf(startup): prime Ollama KV cache with system prompt on warm-up
After loading each user's chat model into VRAM, send a minimal chat
request with the real system prompt (num_predict=1) to populate the
KV cache. The first real user message then only needs to process its
own tokens rather than the full ~5,600-token system prompt, reducing
cold-start TTFT from ~25s to <1s.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:47:59 -04:00
bvandeusen 3bd0dc6879 feat(settings): add background model picker with KV cache performance warning
Exposes OLLAMA_BACKGROUND_MODEL as a per-user setting in General settings,
alongside the Chat Model selector. Includes an inline warning when the same
model is selected for both, explaining the KV cache performance impact.

All background task callers (title generation, tag suggestions, project
summaries, RSS classification) now read background_model from user settings,
falling back to OLLAMA_BACKGROUND_MODEL env var.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:44:08 -04:00
bvandeusen fa38978745 fix(lint): remove unused model variable and get_setting import in chat.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 10:17:35 -04:00
bvandeusen 750a91898a perf(llm): route background tasks to dedicated model to preserve KV cache
Background tasks (title generation, tag suggestions, project summaries,
RSS classification) were using qwen3:8b and wiping its KV cache after
every response, preventing prefix cache hits on subsequent user messages.

Adds OLLAMA_BACKGROUND_MODEL (default: qwen2.5:0.5b) config var and
routes all background LLM calls to it, keeping qwen3:8b's KV cache
warm between user messages for consistent sub-second TTFT.

Also adds infinite scroll to KnowledgeView (replaces load-more button)
and bakes spaCy en_core_web_sm into the Docker image to eliminate the
pip install on every startup.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 01:33:54 -04:00
bvandeusen 888b736ecd feat(weather): default to today only, add days parameter for multi-day requests
get_weather now returns 1 day by default (today) instead of a full 7-day
forecast. A new optional `days` parameter (1–8) lets the model request
more days when the user explicitly asks for a weekly forecast or specific
date range. Tool description updated to guide the model accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:55:40 -04:00
bvandeusen a473f6e039 fix: minichat markdown rendering and weather temperature unit preference
- KnowledgeView minichat: render assistant messages through renderMarkdown
  so headers, bold, lists etc. display correctly instead of raw markdown
- get_weather tool: read user's temp_unit from briefing_config and convert
  temperatures to °F when preferred; also include temp_unit in the
  returned payload so the model can label values correctly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:52:54 -04:00
bvandeusen 07f4956550 fix: center minichat widget and fix weather tool success status
- KnowledgeView minichat: add margin-inline: auto so the widget centers
  within the content area when max-width is reached on wide screens
- weather get_weather tool: return success: true on both the arbitrary
  location path and the cached locations path so ToolCallCard shows
  the correct success state instead of always flagging as error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:50:42 -04:00
bvandeusen b4be1f0799 perf(llm): move retrieval context to user turn for stable system prompt
RAG notes, RSS news, current note, URL content, and briefing articles
are now prepended to the user message rather than appended to the system
message. The system message now contains only stable content (persona,
tool guidance, date, profile, workspace, history summary), making its
token sequence identical across consecutive requests and allowing
Ollama's KV prefix cache to fire reliably every time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-03 00:44:17 -04:00
bvandeusen 36634919cc fix(home): center quick chat widget and constrain width
Chat section and inline response now have max-width 720px centered
within the page, and quick action chips are centered. Prevents the
widget from stretching the full 1200px content width on wide screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:26:53 -04:00
bvandeusen 8a10eb9dbd feat(generation): add conditional thinking classifier
Routes simple/conversational messages to think=false automatically,
even when the user has thinking enabled. Patterns checked: word count
thresholds, complexity keywords, code blocks, skip patterns for greetings
and simple CRUD. Workspace mode (think=true from frontend) still benefits
from the classifier on short messages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 23:19:02 -04:00
bvandeusen 2422946b4f perf: remove model-load polling before generation
wait_for_model_loaded() polled /api/ps for up to 180s waiting for the
model to appear as loaded. But Ollama lazy-loads models on the first
/api/chat request, so the poll will never succeed — it just blocks for
the full 180s after every Ollama restart before proceeding.

Removed the wait entirely. Ollama handles on-demand loading correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 22:36:31 -04:00
bvandeusen b416fec292 perf: reduce OLLAMA_NUM_CTX default from 65536 to 16384
65536 was causing Ollama to allocate a ~50GB KV cache, spilling 77% of
the model to CPU RAM and making prefill extremely slow (35-125s TTFT).

16384 covers 30+ message conversations comfortably while keeping the KV
cache small enough to stay on GPU.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:49:55 -04:00
bvandeusen d20320b664 fix(fable-mcp): raise read timeout to 300s for cold model load
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 19:45:51 -04:00
bvandeusen c3665ddda5 perf: restructure system prompt for Ollama KV-cache prefix reuse
Move static content (persona + tool guidance) to a fixed prefix and
append all dynamic content (date, timezone, profile, entities) as a tail.

Ollama prefix caching requires byte-for-byte token match from the start
of the prompt. Previously, Today's date + user profile were embedded
mid-prompt, invalidating the cache on every request/day and causing
~20s TTFT regardless of model warmth.

With this change the static prefix (~5500 tokens) should be cached
after the first request each session, reducing TTFT to ~2-5s for the
~200-token dynamic tail only.

Also removed inline user_timezone from tool_lines (timezone is now
stated once in the dynamic tail, which the model reads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 17:29:26 -04:00
bvandeusen a7160772bf fix(fable-mcp): fix SSE parser and add stream retry for race condition
- Parse multi-line SSE format correctly (event: on separate line, chunk in data.chunk)
- Retry stream GET up to 10x with 300ms backoff when 404 (buffer not ready yet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:10:12 -04:00
bvandeusen 746b21fa4c fix(fable-mcp): fix send_message to use correct two-step API flow
POST .../messages to start generation, then stream from .../generation/stream.
The previous implementation used a non-existent /stream endpoint.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 16:02:44 -04:00
bvandeusen 8a54daf3c9 fix(fable-mcp): fix streaming error handling and bump timeout for SSE
- stream_get now reads error responses before calling _raise_for_status
  (httpx raises on .json()/.text access inside an unread stream context)
- Raise read timeout to 120s (was 30s) so generation streams don't timeout
- Document "generation" as a valid category for fable_get_app_logs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:46:39 -04:00
bvandeusen 25d448f896 feat: add generation metrics to logs (think, rounds, tokens)
Log think flag, round count, prompt/output token counts per generation.
Change log category from 'usage' to 'generation' for clean MCP filtering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 14:14:41 -04:00
bvandeusen 3e42992f67 fix: stop CI from filling runner disk
Three sources of unbounded growth removed:
- Drop cache-from/cache-to registry: on a persistent self-hosted runner the
  local BuildKit layer cache already provides between-run reuse; the registry
  cache was redundant and pushed ~2 GB of torch layers on every build
- Switch docker system prune -f → -af so old :SHA-tagged images are removed,
  not just dangling ones (-f alone never touched named tags)
- Add docker builder prune --keep-storage 5g to bound the local BuildKit
  cache; pip mount cache (torch etc.) is recently-used so survives, stale
  intermediate layers are evicted first

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 13:44:09 -04:00
bvandeusen 749a60b9fd feat: interactive checkboxes in list note viewer
- renderMarkdown() accepts interactiveCheckboxes option: removes disabled=""
  and stamps data-task-index on each checkbox in the marked HTML output
- NoteViewerView detects list notes by body content (- [ ] / - [x] pattern)
  and passes interactiveCheckboxes: true when rendering
- onBodyChange() handles checkbox change events: toggles the matching line
  in the body, optimistically updates the store, then PATCHes the note
- prose.css adds .prose--checklist rules for marked output: no bullet,
  flex row, accent-color, line-through on checked items via :has()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 09:31:37 -04:00
bvandeusen aec7a910f0 fix: disable provenance attestation for Forgejo registry compatibility
build-push-action@v7 generates OCI attestation manifests by default.
Forgejo's registry doesn't support OCI image index format with attestations,
causing the push to fail with "unknown". provenance: false disables this.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 23:11:29 -04:00
bvandeusen 9549eb85bc feat: live card refresh, list checkboxes, minichat width cap
KnowledgeView:
- Watch streamingToolCalls; call fetchItems+fetchTags on create/update
  note or task so the card grid reflects changes made via the minichat
- Cap minichat to max-width: var(--page-max-width) so it matches chat column width

WorkspaceView + WorkspaceNoteEditor:
- Expose reload() from WorkspaceNoteEditor via defineExpose
- Call noteEditorRef.reload() alongside taskPanelRef.reload() when
  create_note/update_note tools succeed in the SSE watcher

KnowledgeView list cards:
- Backend: parse markdown task list into list_items [{text, checked}] + body
- Card renders up to 6 items with real checkboxes; toggleListItem()
  does an optimistic update then PATCHes /api/notes/:id
- Progress bar kept below items; "+N more" shown when list is long

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 21:30:28 -04:00
bvandeusen 23a7ed7822 refactor: DRY layout bounds via --page-max-width and --sidebar-width CSS vars
Add --page-max-width (1200px), --page-padding-x (1rem), and --sidebar-width (260px)
to theme.css so all views share a single source of truth.

- HomeView: 1100px → var(--page-max-width) (aligns with all other views)
- NotesListView, TasksListView, ProjectListView, ProjectView, CalendarView: var(--page-max-width)
- ChatView: sidebar + context sidebar → var(--sidebar-width); inner message/input
  column max-widths → var(--page-max-width)
- KnowledgeView: filter panel 180px → var(--sidebar-width); minichat left offset updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:56:33 -04:00
bvandeusen d5771a3d5c fix: remove view max-width constraints; widen graph panel
- Drop max-width from .knowledge-root so the graph panel can use the full
  viewport width without hitting a cap
- Drop max-width from .chat-page (message bubbles are already self-constraining)
- Increase normal graph panel width 420px → 500px
- Increase expanded width to min(960px, 60vw) so it scales with the viewport
  and updates minichat right-offset to match

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 20:39:21 -04:00
bvandeusen c95afa4558 fix: install CPU-only torch to avoid 2GB+ CUDA packages on CI runner
kokoro and transformers pull full nvidia CUDA wheels by default (~2 GB),
exhausting the runner disk. Pre-installing torch from the CPU wheel index
satisfies the dependency and prevents pip from selecting the CUDA variant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 19:02:09 -04:00
bvandeusen 8140bc022c fix: resolve ENOSPC on CI by using BuildKit pip cache and registry layer cache
- Add `# syntax=docker/dockerfile:1` to enable BuildKit cache mounts
- Replace `--no-cache-dir` pip installs with `--mount=type=cache,target=/root/.cache/pip`
  so torch/CUDA wheels are reused across builds instead of re-downloaded every run
- Add `docker system prune -f` step before build to free dangling image/layer space
- Add `cache-from`/`cache-to` pointing to `:cache` tag so unchanged layers
  (including the heavy voice-deps layer) are pulled from registry instead of rebuilt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:23:59 -04:00
bvandeusen 0d12d01115 feat: constrain Chat/Knowledge width + persistent expandable graph panel
Layout:
- Chat and Knowledge views now max out at 1600px and center on wide
  screens, consistent with the rest of the app; app-content overflow
  is set to hidden for both so they manage their own scroll

Graph panel (Knowledge):
- Open/closed state persisted to localStorage (fa_knowledge_graph_open)
  — stays open across navigation and page refreshes
- Expanded state persisted (fa_knowledge_graph_expanded): chevron button
  in the panel header toggles between 420px (normal) and 700px (expanded)
- Minichat right offset follows the panel width with a matching transition

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 16:01:18 -04:00
bvandeusen d96895c276 fix: briefing refresh — weather not populated + workspace conv bleed
Two bugs:

1. Manual 'Refresh' button didn't refresh weather/RSS before compiling.
   The daily scheduler calls refresh_all_feeds + refresh_location_cache
   before run_compilation; the manual trigger route called run_compilation
   directly, reading a stale cache. Manual trigger now mirrors the
   scheduler: refresh feeds and all configured weather locations first.

2. Navigating to WorkspaceView during a long briefing compilation caused
   the workspace chat to show the briefing content. triggerNow awaits
   ~30-60s; on completion it called loadAll() → chatStore.fetchConversation
   which overwrote the workspace's currentConversation in the shared store.
   Fixed with a _mounted flag — all post-async state writes in BriefingView
   are now guarded so they no-op if the component has been unmounted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:46:53 -04:00
bvandeusen 145c18d8a3 refactor: DRY calendar additions + define --color-surface in theme
- Define --color-surface in theme.css (light: #f0f0f8, dark: #1a1b22)
  — was used across 10+ components with no-op fallbacks; now properly
  defined alongside --color-bg and --color-bg-card
- Extract shared date formatters into utils/dateFormat.ts:
  fmtTime, fmtDateTime, fmtRelativeDateTime, fmtDayLabel, fmtCompact
- Replace duplicate inline formatters in CalendarView, HomeView
  (formatUpcomingTime), and KnowledgeView (formatEventDate)
- CalendarView: replace hardcoded rgba(99,102,241,0.4) hover colour
  with color-mix(in srgb, var(--color-primary) 40%, transparent);
  fix --color-input-bg fallback to use var(--color-bg); remove
  hardcoded hex fallbacks now that --color-surface is defined

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:27:23 -04:00
bvandeusen f96013a4bc feat: calendar event popover + upcoming events strip
B — Event popover: clicking a calendar event shows a compact overlay with
    full details (title, time range, location, description, color accent)
    and Edit/Close actions; positioned relative to the click, closes on
    outside click; Edit opens the existing EventSlideOver

C — Upcoming strip: scrollable section below the calendar showing the
    next 4 weeks of events grouped by day (Today/Tomorrow/date label),
    each card with color accent bar, title, time, location, description
    snippet; clicking a card opens EventSlideOver for edit

Both features stay in sync with create/update/delete operations.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 12:10:16 -04:00
bvandeusen 30981a3121 feat: persist listen mode across views via localStorage
Extract listen mode into a shared useListenMode() composable backed by
localStorage ('fa_listen_mode'). ChatView and BriefingView both use it,
so toggling auto-read on in one view keeps it on after navigation or
page refresh — no need to re-enable it each visit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:53:03 -04:00
bvandeusen 71b8c5965c fix: briefing discuss button — full article content + scroll + optimistic UI
Three bugs in discussArticle():
- Scroll selector was '.briefing-chat' (doesn't exist) → '.briefing-center';
  the panel never scrolled into view so the response was invisible until refresh
- Only 300-char snippet was sent to the LLM; now passes the full stored
  content (up to 2000 chars) from the backend
- User message wasn't shown until streaming ended; now added optimistically
  to messages[] immediately on click so it appears straight away

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 11:46:23 -04:00
bvandeusen 5924e565b1 fix: floating mini-chat overlay style + weather precip fallback
- KnowledgeView mini-chat: replace harsh border-top with upward box-shadow
  and rounded top corners (16px); remove padding-bottom from content area
  so widget truly overlays cards without pushing layout; add collapse
  toggle (chevron) that hides messages without closing the conversation
- WeatherCard: show precip_mm as fallback when precipitation_probability_max
  is null but actual rainfall is expected (Open-Meteo omits probability for
  some forecast days even when rain is shown)
- Pass precip_mm through weather service → frontend type definitions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 09:04:47 -04:00
bvandeusen a0620c4949 fix: knowledge view — chat input styling, autofocus, graph component (no iframe)
- Mini-chat input bar now uses shared --color-input-bar-* CSS variables and
  the same chat-input-bar pill pattern as all other chat interfaces
- chatInputEl focused on mount (autofocus on page load)
- Graph panel: replaced iframe (blocked by X-Frame-Options: DENY) with
  inline GraphView component; CSS :deep override constrains its height
  to fill the panel instead of 100vh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:49:02 -04:00
bvandeusen 95056d5be7 fix: rename Note.metadata → entity_meta (reserved by SQLAlchemy Declarative API)
SQLAlchemy reserves 'metadata' as a class attribute on declarative models.
Renamed to 'entity_meta' with explicit column name 'metadata' so the DB
column is unchanged but the Python attribute no longer conflicts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:13:47 -04:00
bvandeusen 80f30b705d feat: Knowledge view + entity types (People, Places, Lists)
Data model:
- Migration 0036: adds note_type TEXT (default 'note') and metadata JSONB
  to the notes table; index on note_type
- Note model: entity_type property, note_type/metadata in to_dict()
- create_note() accepts note_type and metadata params

Backend:
- /api/knowledge — unified paginated endpoint: type/tag/sort/q filters,
  semantic search via embeddings, excludes tasks
- /api/knowledge/tags — distinct tags across knowledge objects
- New LLM tools: create_person, create_place, create_list, add_to_list,
  clear_checked_items — all wired into execute_tool()
- People and places auto-injected as compact summary into LLM system prompt

Frontend:
- KnowledgeView replaces HomeView at /; left filter panel (type+tag),
  toolbar (search, sort, graph toggle), card grid with type-aware cards
  (indigo=note, emerald=person, amber=place, sky=list), load-more pagination
- Today bar: upcoming events, overdue task count, Briefing/Chat links
- Floating mini-chat sticky to bottom: creates/continues a conversation
  inline, message history expands upward, close button ends session
- Graph panel: toggles as a 420px right panel at full viewport width
- AppHeader: Knowledge, Chat, Briefing, Calendar, Tasks, Projects
- Router: / → KnowledgeView; /knowledge redirect; HomeView import removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 18:01:03 -04:00
bvandeusen 425d307180 feat: automatic Kokoro offline mode + daily update check
- On first load: model runs online (downloads .pt files), then stores the
  current HF commit SHA to /data/kokoro_commit_hash.txt and switches the
  process to offline mode (HF_HUB_OFFLINE) for all future requests
- On subsequent restarts: presence of the commit file triggers offline mode
  before the pipeline loads, skipping all HuggingFace network validation
- Daily at 03:00 UTC: scheduler temporarily lifts offline mode, fetches the
  latest commit SHA from HF, and only reloads the pipeline if the model has
  actually changed — then restores offline mode
- Removed HF_HUB_OFFLINE from docker-compose.yml; behaviour is now automatic
  and not a hoster/user concern

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:06:13 -04:00
bvandeusen f2dd25737a feat: add HF_HUB_OFFLINE env var to skip HuggingFace cache validation on startup
Once Kokoro voice .pt files are cached locally, setting HF_HUB_OFFLINE=1
prevents HEAD requests to HuggingFace on each restart, making voice pre-warming
fully offline and faster.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 17:00:50 -04:00
bvandeusen 882ea176b2 fix: briefing discuss prompt — suppress research tool to prevent full research note
Explicitly instruct the LLM to respond conversationally and not use any
research/search tools when summarizing a shared article excerpt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:54:18 -04:00
bvandeusen baeb0b14e5 feat: listen mode + volume knob in chat; briefing discuss auto-send; fix LLM proactive note search
- ChatView: listen mode toggle (auto-reads new responses via TTS), volume popup
  with range slider persisted per-device in localStorage via GainNode
- useVoiceAudio: shared module-level _volume ref with localStorage persistence,
  GainNode for volume control, exported setVoiceVolume()
- tts.py: pre-warm all Kokoro voices at pipeline load to eliminate HuggingFace
  HEAD requests at synthesis time (reduces TTS latency)
- BriefingView: discuss article button now auto-sends instead of just filling input;
  prompt capped to 15 sentences; send() accepts optional overrideText
- llm.py: instruct LLM not to proactively search notes or comment on note absence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 16:52:11 -04:00
bvandeusen ab397e78f3 fix: briefing TTS now uses saved voice/speed/blend settings
synthesiseSpeech() called without explicit params now omits voice/speed/
blend from the request body. The backend detects this and auto-loads all
three from the user's saved settings (voice_tts_voice, voice_tts_speed,
voice_tts_blend), so briefing listen mode respects the voice the user
configured in Settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:46:52 -04:00
bvandeusen ea23f16bd7 feat: weather card — precip probability %, condition text, unit-aware wind
- Fetch precipitation_probability_max from Open-Meteo (replaces precip_sum
  in the card display — probability is more useful at a glance than mm)
- Show WMO condition description text on each forecast day
- Convert wind speed to mph when temp unit is F; pass wind_unit in response
- Display 💧 X% chance of rain; 💨 X mph/km/h wind per day

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 13:02:36 -04:00
bvandeusen c1fcb1e287 feat: discuss article in briefing chat via 💬 button on news cards
Clicking 💬 on a news card in the briefing panel pre-fills the briefing
chat input with the article title, snippet, and source so the user can
ask the briefing LLM to summarize or discuss it directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:57:25 -04:00
bvandeusen c31cf11767 fix: use AudioContext for voice previews to bypass browser autoplay policy
new Audio().play() after an async await loses the user gesture context and
is silently blocked. Creating AudioContext synchronously before the fetch
preserves the permission, then decode/play through it after the await.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 12:55:07 -04:00
bvandeusen 71ca0ecb5c fix: voice status global store, per-view mic reactivity, single-voice preview
- Move voice status into settings store (voiceSttReady, voiceTtsReady),
  checked once at login and refreshed after admin model reload
- ChatView, BriefingView, DashboardChatInput now use computed refs from
  the store — mic buttons appear reactively without needing a page reload
- BriefingView: separate STT-only guard for mic PTT vs TTS-only guard for
  listen mode / speak buttons
- Add ▶ Preview button to Voice & Speed section in Settings for single-
  voice testing without enabling blend mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:53:06 -04:00
bvandeusen b4b4b0d9d6 feat: weather precip/wind, dashboard mic, remove global voice overlay
- WeatherCard: show precipitation (mm) and max wind speed per forecast day
- DashboardChatInput: add PTT mic button (transcribe-to-input, voice-gated)
- Remove global VoiceOverlay floating button and Space PTT shortcut from
  App.vue — inline mic buttons in chat/briefing/dashboard are the right UX;
  global overlay had focus/latency/context issues

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:00:06 -04:00
bvandeusen 76c3dbc4b7 fix: stop TTS playback when PTT is activated
Pressing push-to-talk now immediately stops any ongoing TTS audio before
opening the microphone, preventing the assistant from hearing itself.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:43:15 -04:00
bvandeusen 1460863e82 feat: Kokoro voice blending — blend builder UI + weighted tensor synthesis
Add voice blend support to TTS pipeline and settings UI. Users can mix
2–5 Kokoro voices with per-voice weight sliders; the blended style tensor
replaces the single voice when enabled. Settings persist as JSON and auto-
load on synthesis when no explicit voice is supplied in the request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:10:22 -04:00
bvandeusen 98b3cdb593 feat: add weather condition icons to WeatherCard
Maps WMO condition strings to emoji icons for current conditions and
forecast days. No external dependencies — pure emoji lookup by condition text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:08:18 -04:00
bvandeusen c2d81e04b9 fix: update tests for briefing rewrite and raised content cap
- Remove test_slot_greeting — slot_greeting() was removed in the
  conversational briefing rewrite
- Update test_extract_item_truncates_content to use CONTENT_MAX_CHARS
  rather than a hardcoded 2000 (cap raised to 50000 for full articles)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 17:42:48 -04:00
bvandeusen e613485474 feat: full article fetching with trafilatura + html2text cleanup
- Add trafilatura + html2text to dependencies
- Replace custom HTMLStripper with html2text for RSS feed content
- Fetch full article text via httpx + trafilatura after each new item is stored;
  falls back to RSS-provided content if fetch/extraction fails
- Raise CONTENT_MAX_CHARS from 2000 to 50000 (TEXT column, no migration needed)
- Re-embed items with full article content once enrichment completes
- Startup backfill enriches existing items with short content (<1000 chars)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:33:27 -04:00
bvandeusen 0b05b03987 fix: strip HTML from RSS item content during ingestion
feedparser returns HTML in content/summary fields for many feeds.
Raw tags were being stored in the DB and passed to the LLM/embeddings.
Added a stdlib HTMLParser-based stripper in extract_item() — block elements
become newlines, script/style content is dropped, plain text passes through.
No new dependencies required.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:15:49 -04:00
bvandeusen a773c11aa0 feat: RSS embeddings, semantic news in chat, article-to-chat, richer briefings
- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
  when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
  the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
  to the LLM and uses 8192 num_ctx to accommodate the larger prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 15:12:38 -04:00
bvandeusen dba41879ed feat: structured user profile with LLM-learned preferences
Replaces the freeform briefing-profile note with a DB-backed user_profiles
table. Users can edit job/industry/expertise/response preferences/interests/
work schedule via a new Settings → Profile tab. The LLM appends nightly
observations; at 14+ entries they are auto-consolidated into a learned_summary.
Profile context is injected into both briefing and chat system prompts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 14:17:30 -04:00
bvandeusen 9f3b9e45c6 feat: rewrite briefing pipeline to conversational prose
Replace two-pass structured LLM synthesis (## Your Day / ## The World
sections with bullets and formatted news cards) with a single
conversational pass. The new prompt instructs the model to write 3-5
flowing sentences covering weather, today's tasks/events, and 1-2 news
highlights — no markdown, no headers, no lists. Full news detail stays
in the right panel; weather detail stays in the weather card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 13:37:47 -04:00
bvandeusen 2a8c0cfa56 fix: set keep_alive to 2h on all Ollama requests
Prevents models from sitting in VRAM indefinitely. Applies to both
streaming chat calls and the non-streaming generate_completion path,
as well as the startup warm-up request.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 10:21:59 -04:00
bvandeusen dd304bb556 feat: unify voice PTT across briefing and chat views
- ChatView: add PTT mic button in input bar (hold to speak → transcribe → send)
- BriefingView: restyle input bar to match ChatView (floating pill, circle send)
- BriefingView: move listen/mic controls into input bar, remove from header
- BriefingView: consistent icon-button style for speaker/mic matching ChatView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 21:58:20 -04:00
bvandeusen f146485df3 feat: hot-reload voice models without server restart
Voice enabled/STT model are now DB-backed (admin settings), not env
vars. Added reload_stt_model()/reload_tts_model() that clear singletons
under lock and re-trigger loading. POST /api/admin/voice/reload triggers
both in background tasks. Settings UI polls /api/voice/status every 2.5s
until models are ready, with spinner feedback.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:52:07 -04:00
bvandeusen eaf70500b8 feat: move voice enable/model config to admin UI
Replace VOICE_ENABLED env var gate with DB-backed admin setting.

- services/voice_config.py: reads voice_enabled + voice_stt_model from
  admin user's settings row (falls back to env var defaults)
- routes/admin.py: GET/PUT /api/admin/voice for admin configuration
- routes/voice.py, services/stt.py, services/tts.py: read enabled/model
  from DB via voice_config instead of Config directly
- app.py: always schedule model loaders at startup; they self-gate on
  the DB setting so no conditional needed at the call site
- SettingsView.vue: Voice section in Admin → Config tab (enable toggle +
  STT model dropdown); user Voice tab now points to admin panel when disabled

No env var required to test — enable via Settings → Admin → Config → Voice.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:22:18 -04:00
bvandeusen 6f84d90dff feat: voice S2S — faster-whisper STT, Kokoro TTS, PTT overlay
Implements full speech-to-speech pipeline (all 4 phases):

Backend (Phase 1):
- services/stt.py: lazy WhisperModel singleton, run_in_executor transcription
- services/tts.py: lazy KPipeline singleton, WAV synthesis at 24kHz/16-bit
- routes/voice.py: /api/voice/status, /voices, /transcribe, /synthesise
- config.py: VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND env vars
- app.py: load STT/TTS models at startup when VOICE_ENABLED=true
- llm.py: voice_mode + voice_speech_style params inject speak-naturally prefix
- generation_task.py: voice_mode passed through from chat route
- chat.py: "voice" conversation type allowed + excluded from retention cleanup
- pyproject.toml + Dockerfile: faster-whisper, kokoro, soundfile dependencies

Frontend (Phases 2–4):
- composables/useVoiceRecorder.ts: MediaRecorder PTT wrapper
- composables/useVoiceAudio.ts: AudioContext WAV playback wrapper
- BriefingView.vue: Listen button (TTS read-aloud), auto-TTS mode, mic PTT
- VoiceOverlay.vue: global floating PTT button; creates/reuses voice conv;
  full record→transcribe→stream→TTS flow; Space bar hold-to-talk via App.vue
- SettingsView.vue: Voice tab (status badge, speech style, voice/speed)
- App.vue: mounts VoiceOverlay; Space keydown/keyup fires voice:ptt-toggle
- api/client.ts: getVoiceStatus, getVoiceList, transcribeAudio, synthesiseSpeech

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 20:03:38 -04:00
bvandeusen 3581cc1582 docs: add voice S2S design spec
Covers STT (faster-whisper), TTS (Kokoro), per-user settings,
all new/modified files, audio format decisions, and 4-phase
implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 18:17:58 -04:00
bvandeusen 3dd879640a chore(fable-mcp): bump version to 0.2.0
Reflects all changes since initial 0.1.0 release: RSS tools, task log
content/body fix, project and milestone status on create, and various
other fixes. Auto-bump hook will handle patch increments from here.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 16:01:12 -04:00
bvandeusen f8b7b51832 Merge pull request 'Release v26.03.29.1 — security fixes and MCP bug fixes' (#18) from dev into main
Release v26.03.29.1 — security fixes and MCP bug fixes
2026-03-29 19:40:35 +00:00
bvandeusen 0cbeb6b7ac fix: honour status param on project and milestone creation
create_project and create_milestone hardcoded status="active" and
ignored any value passed by the MCP or API callers. Route, service,
and model construction now all thread the status field through.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:19:50 -04:00
bvandeusen 218f946e48 fix: fable_add_task_log sends content not body to match API
The MCP tool was sending {"body": ...} but the task logs API route
expects {"content": ...}, causing 400 errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 15:16:13 -04:00
bvandeusen 00643c778e security: fix 10 vulnerabilities from security audit
- SSRF: block private/internal URLs in image cache fetch
- SSRF: block private/internal URLs in RSS feed fetch (scheme guard)
- SSRF: block private/internal URLs in CalDAV URL setting
- Auth: require login for GET /api/images/<id> (was unauthenticated)
- Auth: restrict Ollama model pull/delete to admin users only
- Info disclosure: remove email from /api/users/search response
- OAuth: skip email-based account linking when email_verified is false
- Config: raise hard error on default SECRET_KEY when SECURE_COOKIES=true
- Rate limit: document proxy header requirement; add startup warning
- XSS: remove src/alt from global DOMPurify ADD_ATTR allowlist

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-29 00:37:13 -04:00
bvandeusen 20e2333b63 Merge pull request 'Release v26.03.28.2 — bug fixes and add_rss_feed tool' (#17) from dev into main
Release v26.03.28.2 — bug fixes and add_rss_feed tool
2026-03-28 18:02:59 +00:00
bvandeusen 024075329d feat: add add_rss_feed LLM tool so users can add feeds via chat
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 13:50:13 -04:00
bvandeusen 697d99cc4d fix: pass temp_unit from API to WeatherCard so F/C label matches converted values 2026-03-28 13:21:30 -04:00
bvandeusen 164c55e845 fix: increase contrast on app version display in settings 2026-03-28 13:16:15 -04:00
bvandeusen c77c13684f fix: cast feed_id parameter to integer to resolve asyncpg AmbiguousParameterError 2026-03-28 12:56:33 -04:00
bvandeusen 7b95150101 fix: read temp_unit from briefing_config not a nonexistent temperature_unit setting 2026-03-28 12:49:36 -04:00
bvandeusen 96a07690a8 fix: use COALESCE(published_at, fetched_at) in news query — articles with no publish date were silently dropped 2026-03-28 12:32:19 -04:00
bvandeusen 6da4b098e3 fix: /api/briefing/weather returns card-format data for WeatherCard component 2026-03-28 12:30:11 -04:00
bvandeusen 7fdb2ee39d chore: ignore docs/superpowers, docs/plans, docs/specs — keep only root docs 2026-03-28 12:16:23 -04:00
bvandeusen 83cee46078 feat: auto-bump fable-mcp patch version on commit via Claude Code hook
- scripts/bump_fable_mcp_version.sh: increments patch in pyproject.toml and stages it
- scripts/pre_commit_fable_mcp.sh: PreToolUse Bash hook — fires before git commits,
  bumps version if fable-mcp files (other than pyproject.toml itself) are staged
- .claude/settings.json: registers the PreToolUse hook

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 02:14:20 -04:00
bvandeusen 534e0a3a34 v26.03.28.1 — News/briefing redesign, task enhancements, recurrence 2026-03-28 05:38:26 +00:00
bvandeusen 51d2fd9d0a fix: migration 0031 no-op — status column is TEXT not a PG enum 2026-03-28 01:17:00 -04:00
bvandeusen ac90548823 feat: task management enhancements (cancelled status, recurrence, timestamps)
- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard,
  TaskEditorView, TaskViewerView, TasksListView
- Add RecurrenceEditor component (none / interval / calendar rules)
- TaskEditorView: wire RecurrenceEditor, show started_at/completed_at
  timestamps read-only, include recurrence_rule in save payload
- TaskViewerView: show recurrence summary, timestamps in meta row
- tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule
  to updateTask and createTask payloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:36 -04:00
bvandeusen 37e2420192 feat: add /news archive view with feed filter and pagination
- NewsView.vue: 90-day article archive, feed filter dropdown,
  load-more pagination, topic pills, thumbs up/down reactions
- Router: add /news route
- AppHeader: add News nav link (desktop + mobile)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:51:24 -04:00
bvandeusen f81c38df84 feat: redesign BriefingView into 3-column layout (weather · chat · news)
- Left column: weather loaded independently via /api/briefing/weather
- Center column: chat messages with input bar (unchanged behavior)
- Right column: news panel loaded from /api/briefing/news (last 2 days)
- Auto-scroll to bottom on mount and after streaming
- Background refresh also refreshes news panel
- Responsive: stacks to single column on narrow screens
- Fix TaskCard.vue to include 'cancelled' in status records

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:38:04 -04:00
bvandeusen 0a6e57e698 feat: add NewsItem type and getNewsItems() API client function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:34:34 -04:00
bvandeusen 260103d533 feat: inject briefing article content for deep article Q&A
Add _build_briefing_article_context() helper to llm.py that reads
rss_item_ids from briefing message metadata and injects article content
into the system prompt. Pass conv_id through build_context() and
generation_task.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:31:43 -04:00
bvandeusen 35f57e0d3e feat: add GET /api/briefing/news unified news endpoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:59 -04:00
bvandeusen 57bf63e576 feat: extend RSS item retention from 14 to 90 days
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:29:32 -04:00
bvandeusen aa18f4f527 docs: add news briefing redesign spec and plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 00:13:40 -04:00
bvandeusen 3391825550 docs: add news feed & briefing redesign spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 23:59:34 -04:00
bvandeusen 3d7c953627 feat: add recurrence_rule to LLM tools; list_tasks status accepts list + cancelled 2026-03-27 23:02:30 -04:00
bvandeusen 6557fef6a2 feat: support multi-value status and priority filters in list_notes 2026-03-27 23:01:24 -04:00
bvandeusen 0dc3dfa539 feat: add recurrence_rule validation and recurrence-preview endpoint in tasks routes 2026-03-27 22:54:11 -04:00
bvandeusen 3179b60eac feat: wire recurrence into create_note/update_note and add daily APScheduler job 2026-03-27 22:53:20 -04:00
bvandeusen a24257aeed feat: add recurrence service (validate, calculate_next_due, spawn_recurring_tasks) 2026-03-27 22:52:18 -04:00
bvandeusen c271f1b41f feat: add recurrence_rule and recurrence_next_spawn_at columns to notes 2026-03-27 22:49:59 -04:00
bvandeusen c92f4944cc feat: auto-set started_at/completed_at on task status transitions 2026-03-27 22:49:21 -04:00
bvandeusen 1125c8e107 feat: add started_at/completed_at columns to notes 2026-03-27 22:37:55 -04:00
bvandeusen 7888788d42 Merge pull request 'Release v26.03.27.2' (#15) from dev into main
Release v26.03.27.2
2026-03-27 21:23:53 +00:00
bvandeusen 7a12cba4d5 feat: add 'cancelled' task status; fix 500 on invalid status/priority
TaskStatus enum was missing 'cancelled' — the LLM tried to use it and
hit TaskStatus("cancelled") raising ValueError → 500. Added the value,
a migration to extend the task_status Postgres enum, and proper 400
validation guards on both create and update task routes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 09:18:16 -04:00
bvandeusen 78791175a1 Merge pull request 'Release v26.03.27.1' (#14) from dev into main
Release v26.03.27.1
2026-03-27 04:12:27 +00:00
bvandeusen 2a1644e571 feat: news story cards in briefing — backend embeds structured RSS items in metadata
Previously metadata only stored rss_item_ids (integers); the full item data
was discarded after LLM synthesis. Now rss_items (id, title, url, source,
snippet, published_at) is also stored so clients can render per-story cards
without additional API calls.

Web BriefingView: replaces bare reaction-row buttons with news cards showing
source, headline (linked), 2-line snippet, and 👍/👎 per card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-27 00:08:21 -04:00
bvandeusen bc5f1679d5 fix: accept PATCH on /api/tasks/:id (MCP update_task compatibility)
The MCP fable_update_task tool calls PATCH /api/tasks/{id} but the route
only declared PUT. Adding PATCH to the same handler fixes the 405.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 23:44:20 -04:00
bvandeusen 22634aa0c9 refactor: DRY pass on backend — pagination helper and sharing utilities
- Add parse_pagination() to routes/utils.py; replace 6 duplicate limit/offset extractions in notes, tasks, chat, projects, milestones routes
- Extract _enrich_shares() in sharing.py; eliminates identical 12-line loop in list_project_shares and list_note_shares
- Extract _deduplicate_by_permission() in sharing.py; eliminates identical deduplication blocks in list_shared_with_me for projects and notes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:52:39 -04:00
bvandeusen 699e525cb9 refactor: DRY pass on frontend — shared palette, composable, and tab loader
- Extract milestoneColor to utils/palette.ts; remove duplicate in HomeView + ProjectListView
- Create useBackgroundRefresh composable; wire into HomeView + BriefingView (removes manual setInterval/clearInterval boilerplate)
- Extract _loadTabContent() in SettingsView so watch and onMounted share one tab→loader mapping
- Move raw fetch() api-key calls to typed helpers in api/client.ts (listApiKeys, createApiKey, revokeApiKey)
- Drop unused onUnmounted import from BriefingView

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 22:43:59 -04:00
bvandeusen 2b2e5c666a fix: load all pre-selected settings tabs correctly on mount
The watch(activeTab) handler loads tab data on navigation, but not on
initial mount when localStorage restores a tab. Three more gaps:

- briefing: was inside the isAdmin guard — non-admin users who last
  visited the Briefing tab would see an empty form
- users: no onMounted equivalent — admin user list never loaded
- logs: no onMounted equivalent — admin log viewer never loaded

Moves briefing outside the admin guard and adds users/logs inside it.
2026-03-26 22:32:28 -04:00
bvandeusen 26a8fb5c51 fix: load API keys and MCP info on mount when tab is pre-selected
fetchApiKeys() and loadMcpInfo() were only wired to the activeTab watcher,
which fires on changes but not on initial mount. If localStorage had
'apikeys' as the last tab, both calls were skipped entirely — causing
an empty key list and no whl download button.
2026-03-26 22:24:37 -04:00
bvandeusen 3431719ff3 Merge pull request 'dev → main' (#13) from dev into main 2026-03-26 23:22:23 +00:00
bvandeusen 916cfa50df fix: add missing onUnmounted import in BriefingView 2026-03-26 18:50:25 -04:00
bvandeusen 190664366d Merge pull request 'dev → main' (#12) from dev into main
dev → main
2026-03-26 22:08:03 +00:00
bvandeusen 08d738ddfb feat: silent background polling for dashboard and briefing views
HomeView: setInterval every 90s refreshes events, orphan tasks/notes,
and hero next-up task. Never touches loading ref, so the page content
stays stable — only the data silently swaps when the fetch completes.
Skips when document.hidden or initial load is still in progress.

BriefingView: setInterval every 60s refetches today's messages, but
only when viewing today's conversation and not currently streaming.
Compares message count and last message content before updating to
avoid unnecessary re-renders.

Both timers are cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:43:21 -04:00
bvandeusen a63e498067 fix: weather card spacing and sizing consistency
- Increase temp size to 2rem and add explicit font-size to condition
- Uniform 0.5rem spacing below current temp (was 0.35rem)
- Today row font-size explicit at 0.85rem
- Forecast gap 0.75rem (was 0.5rem), min-width 4.5rem (was 3.5rem)
- Padding-top on forecast strip 0.75rem to match overall rhythm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 17:37:27 -04:00
bvandeusen 48d1d9e64f fix: remove npm self-update in Docker build; use npm ci
npm install -g npm@latest corrupts npm's own module tree inside Alpine,
breaking subsequent installs. Use npm ci instead (faster, deterministic).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:19:01 -04:00
bvandeusen 538b67e57d docs(fable-mcp): add server instructions and detailed tool docstrings
Adds a comprehensive instructions block to the FastMCP server covering
the data model hierarchy, valid enum values, tag format, integer-or-none
conventions, when to use fable_send_message vs direct CRUD tools, and
admin key requirements.

All tool docstrings expanded with full argument descriptions, valid
values, and return shape notes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 08:03:31 -04:00
bvandeusen 383a4430f1 fix: convert calendar event times to user timezone in briefing
The briefing was formatting event start_dt directly in UTC instead of
converting to the user's local timezone. Also, the day_start/day_end
query window was naive (UTC), so events at the edges of the user's day
could be missed or included incorrectly.

Now reads user_timezone setting, uses it for today's date boundary and
for converting each event's start_dt before formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-26 06:34:22 -04:00
bvandeusen 4aacd093e5 Merge pull request 'fix: decode HTML entities in wikilinks before re-escaping' (#11) from dev into main 2026-03-26 02:50:00 +00:00
bvandeusen aab478359b fix: decode HTML entities in wikilinks before re-escaping
marked encodes " to &quot; in text nodes, causing linkifyWikilinks to
double-escape it (& → &amp;) so the visible link text showed &quot;
instead of the actual character.

Decode marked's entities on the matched title/label before running
escapeHtmlAttr so the output is correct in both the href attribute
and the visible link text.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 22:40:25 -04:00
bvandeusen 6214666942 Merge pull request 'refactor: centralise user timezone as standalone setting' (#10) from dev into main 2026-03-26 00:38:46 +00:00
bvandeusen 62dbb8d496 refactor: centralise user timezone as a standalone setting
Browser timezone is now synced to user_settings["user_timezone"] on
every login/page load (App.vue). The briefing scheduler and LLM context
both read from this single source, falling back to the legacy
briefing_config.timezone for existing users during migration.

- App.vue: PUT /api/settings with browser IANA timezone on startAppServices
- routes/chat.py: fall back to stored user_timezone when not sent in request
- briefing_scheduler: read user_timezone setting; briefing_config.timezone
  kept as fallback only
- routes/briefing.py: pass tz_override from user_timezone to live-patched scheduler
- Remove timezone field from BriefingConfig interface and all briefing UI

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:17:51 -04:00
bvandeusen fd05c65018 fix(calendar): correct event timezone handling
- Frontend sends user_timezone (IANA, from Intl.DateTimeFormat) with
  every message POST; threaded through route → generation_task → build_context
- System prompt now tells the LLM the user's timezone so it creates
  events with the correct UTC offset (e.g. 15:00+01:00 not 15:00Z)
- Calendar tool guidance updated to require UTC offset in all event
  datetimes
- EventSlideOver: dateFromIso/timeFromIso now use JS Date to convert
  stored UTC times to local time for display; toIso includes local
  timezone offset when saving so the correct UTC time is stored

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 20:06:09 -04:00
bvandeusen c8aa5834fa feat: internal calendar, RAG scoping, and project summarization 2026-03-25 22:45:40 +00:00
bvandeusen 87c55691fb docs: update architecture and features for calendar + RAG scoping
- architecture.md: add Event model, services/events.py, routes/events.py,
  CalendarView.vue, EventSlideOver.vue; update Conversation and Project
  data model tables with new columns; update RAG Pipeline section with
  three-value scope system, search_projects/set_rag_scope tools, and
  project summary background job; fix dead-code note for models/event.py;
  update execute_tool() signature docs; add services/projects.py entry
- features.md: replace CalDAV section with full Calendar section covering
  internal store, AI tools, HomeView widget, and optional CalDAV sync;
  update AI Chat scope chip description; remove done "Calendar view" item
  from roadmap

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 18:16:41 -04:00
bvandeusen ebc79b34f9 feat(rag): RAG scoping and context isolation controls
- Migration 0030: add conversations.rag_project_id (NULL=orphan-only,
  -1=all notes, positive=project), projects.auto_summary and
  projects.summary_updated_at
- Three-value scope semantics thread from build_context() → semantic
  search and keyword fallback via orphan_only + effective_project_id
- Project summarization background job (generate_project_summary,
  backfill_project_summaries) called via Ollama; triggered on project
  update and note saves (debounced 1h); runs at startup
- New LLM tools: search_projects (SequenceMatcher scoring on
  title+description+auto_summary) and set_rag_scope (persists to DB,
  workspace-guarded, emits new_rag_scope in SSE done event)
- execute_tool() accepts conv_id + workspace_project_id; generation_task
  passes both and captures scope changes for SSE done enrichment
- Frontend: Conversation type gets rag_project_id; chat store adds
  ragProjectId computed + updateRagScope(); SSE done handler syncs scope
- ChatView: replace sidebar ProjectSelector with a scope chip pill above
  the input bar, animated dropdown, pulse on model-driven scope change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:44:39 -04:00
bvandeusen 1e0d11c907 docs: update RAG scoping spec with explicit wiring details
Add exact code snippets for orphan_only logic, conv_id threading,
SSE new_rag_scope wiring, workspace guard, and frontend store changes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 17:01:30 -04:00
bvandeusen 17db511119 docs: add RAG scoping and context isolation design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 16:56:25 -04:00
bvandeusen e0c3836fab feat: event cards in chat/briefing, upcoming events on dashboard, briefing uses internal store
- ToolCallCard: event list items replaced with rich clickable cards (color dot,
  title, time, location); clicking opens EventSlideOver for edit/delete; single
  create/update events in header are also clickable; updated all event types to
  use start_dt/end_dt fields from internal store
- HomeView: new upcoming events widget shows today + next 7 days as a card grid
  above the hero project; clicking any card opens EventSlideOver inline
- briefing_pipeline: _gather_internal now queries the internal events store for
  today's events; CalDAV events are still appended (deduped) if configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 15:39:13 -04:00
bvandeusen df1194ee96 fix(tools): remove duplicate datetime import in get_weather handler
The local `from datetime import datetime, timezone as _tz` inside
execute_tool() shadowed the module-level datetime import for the entire
function scope, causing UnboundLocalError in all calendar tool handlers
(list_events, create_event, update_event). Fixed by importing only
timezone as _tz — datetime is already available at module level.

Also removes the now-unnecessary noqa: F823 suppression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:59:05 -04:00
bvandeusen 0277f5744f fix(events): use g.user.id instead of g.user_id in route helper
auth.py sets g.user (the User object), not g.user_id directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 14:12:12 -04:00
bvandeusen 809d8e0008 fix(lint): suppress ruff F823 false positive in create_event handler
Ruff 0.15.7 incorrectly flags datetime.fromisoformat() as a
"local variable referenced before assignment" inside a try block
within execute_tool(). datetime is imported at module level and
is not a local variable. Added noqa comment at the specific line.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:45:25 -04:00
bvandeusen c3e201d26a feat(calendar): internal event store with FullCalendar UI and CalDAV push sync
- AI calendar tools now always available (moved from _CALDAV_TOOLS to _CORE_TOOLS);
  create/list/search/update/delete events go through the internal DB store first,
  with fire-and-forget CalDAV push sync when the user has CalDAV configured
- Add EventEntry interface and typed API helpers (listEvents, createEvent,
  getEvent, updateEvent, deleteEvent) to client.ts
- Install @fullcalendar/vue3, daygrid, timegrid, interaction, core packages
- Add EventSlideOver.vue: create/edit/delete slide-over with title, start/end,
  all-day toggle, location, description, color picker, and project selector
- Add CalendarView.vue: month/week/day FullCalendar with drag-drop and resize
  wired to PATCH /api/events/:id; click empty date opens create slide-over
- Wire /calendar route, Calendar nav link in AppHeader, g+l keyboard shortcut

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 13:38:09 -04:00
bvandeusen 8d330afc6d feat(calendar): events REST API blueprint and registration
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:53:52 -04:00
bvandeusen 90ca667df2 feat(calendar): implement events service with CalDAV push
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:49:57 -04:00
bvandeusen b547f47f54 feat(calendar): update Event model and patch caldav.create_event with uid param
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:48:40 -04:00
bvandeusen 57f837984c feat(calendar): migration 0029 — add caldav_uid and color to events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:42:32 -04:00
bvandeusen da55e32a1a docs(calendar): implementation plan for internal calendar with CalDAV sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 12:16:25 -04:00
bvandeusen 651bc1ba7b docs: revise internal calendar spec based on reviewer feedback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:47:52 -04:00
bvandeusen 0e27be5b63 docs: add internal calendar with CalDAV sync design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 11:41:14 -04:00
bvandeusen fe6afbad17 docs: update architecture and development docs with recent additions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:56:44 -04:00
bvandeusen e57ac26749 docs: add quickstart compose file; condense README features section
Adds docker-compose.quickstart.yml that pulls the pre-built image from
the registry so users can get started without a local build. Updates
README Quick Start to use the new file as the default path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:55:18 -04:00
bvandeusen 940dd0c08e feat(fable-mcp): add RSS feed management tools (list/add/remove)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:48:57 -04:00
bvandeusen 5f6107bbf8 feat(briefing): add News Preferences section with topic include/exclude inputs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:46:59 -04:00
bvandeusen 06cb7cc86d feat(briefing): render WeatherCard and RSS reaction buttons from message metadata
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:45:16 -04:00
bvandeusen a691fc043d feat(briefing): add WeatherCard.vue component
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:44:02 -04:00
bvandeusen aa46551ccf feat(briefing): add RSS reaction and fable-mcp info API helpers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:42:39 -04:00
bvandeusen 0c2d9c2f6c feat(briefing): add POST/DELETE /api/briefing/rss-reactions endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:55 -04:00
bvandeusen 359b5f0545 feat(briefing): trigger RSS classification after new items are stored
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:39:08 -04:00
bvandeusen dc93e0d39f feat(briefing): wire pre-processing pipeline; run_compilation returns (text, metadata)
- Task change detection via snapshot diff
- RSS scoring/filtering via briefing_preferences
- Weather card via parse_weather_card_data (staleness-gated)
- News card markdown format with ordering constraint
- Metadata stored on Message record via post_message()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:38:28 -04:00
bvandeusen e3c1e97cfa feat(briefing): add task change detection helpers and task_id to _gather_internal
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:36:52 -04:00
bvandeusen 3b71549b91 feat(briefing): add briefing_preferences service for RSS scoring and filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:35:17 -04:00
bvandeusen 2ad07b5e06 feat(briefing): add rss_classifier service for LLM-based topic tagging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:34:37 -04:00
bvandeusen 9e1615bd32 feat(briefing): add past_days/current_weather to Open-Meteo; add parse_weather_card_data()
Also adds get_cached_weather_rows() for parallel gather in briefing pipeline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:32:59 -04:00
bvandeusen e44eb185d5 feat(briefing): extend post_message() to accept optional metadata dict
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:31:25 -04:00
bvandeusen 5665f484ed feat(briefing): add Message.msg_metadata and RssItem.topics/classified_at columns
msg_metadata maps to the 'metadata' DB column ('metadata' is reserved
by SQLAlchemy Declarative API). to_dict() exposes the key as 'metadata'
for frontend compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 10:30:37 -04:00
bvandeusen 8fa850534c feat(briefing): add migration 0028 — briefing improvements schema
Also comments out nvidia GPU reservation in docker-compose.yml
(no nvidia-container-toolkit on this host).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:59:20 -04:00
bvandeusen fa200fd528 docs(briefing): add implementation plan for briefing improvements
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:48:41 -04:00
bvandeusen 24bd80b5d7 docs(briefing): add briefing improvements design spec
Covers task deduplication, RSS classification and preference filtering,
weather card with staleness gate, news cards with reactions, topic
preference settings UI, and Fable MCP RSS feed tools.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 09:31:39 -04:00
bvandeusen bf292e6019 docs: remove extraneous content — pipeline internals moved to architecture, changelog removed
- features.md: remove SQL impl detail from tasks section, sw.js reference from PWA section,
  and entire "LLM Chat — Internal Pipeline" section (moved to architecture.md)
- architecture.md: add "LLM Pipeline Internals" section (intent routing, tool loop, duplicate
  guards, context window, research pipeline, image cache)
- development.md: remove site-specific NFS path from custom runner instructions
- Remove changelog.md (duplicates git history)
- README.md: remove changelog link

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 08:38:44 -04:00
bvandeusen e2133529a0 docs: add missing content from summary.md (API reference, Android app, file structure, LLM internals)
- docs/api-reference.md: complete REST API endpoint reference (~60+ routes)
- docs/android-app.md: Flutter companion app stack, architecture, feature status
- docs/architecture.md: detailed file-by-file reference for all backend services and frontend components
- docs/features.md: LLM pipeline internals (intent routing, tool loop, duplicate guards, image search, research pipeline), roadmap
- docs/development.md: full migration chain (0001–0026) with naming and caveats
- README.md: link to new api-reference and android-app docs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:25:12 -04:00
bvandeusen 47e248d9ac docs: restructure documentation into docs/ directory, slim README, add project .mcp.json
- README.md: reduced to overview + quick start + links to docs/
- docs/architecture.md: stack, design decisions, data models, key services
- docs/configuration.md: all env vars, docker-compose setup, production + security
- docs/development.md: dev workflow, CI/CD, migrations, release process
- docs/features.md: detailed feature breakdown + keyboard shortcuts
- docs/api-keys-and-mcp.md: API key management + Fable MCP install guide
- docs/sso-oauth.md: OAuth/OIDC setup (replaces docs/oauth-setup.md)
- docs/changelog.md: development history from summary.md
- Remove summary.md (content distributed across docs/)
- Remove docs/oauth-setup.md (superseded by docs/sso-oauth.md)
- .gitignore: add .mcp.json

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 21:03:15 -04:00
bvandeusen 553c38200a feat(fable-mcp): build wheel in Docker image, serve download, add admin log tool, and Settings install UI
- Dockerfile: build fable-mcp wheel into /app/dist/ during image build
- routes/fable_mcp_dist.py: GET /api/fable-mcp/info + /download endpoints
- app.py: register fable_mcp_dist_bp
- fable_mcp/tools/admin.py: get_app_logs() hitting /api/admin/logs
- fable_mcp/server.py: fable_get_app_logs MCP tool
- SettingsView: "Fable MCP" section in API Keys tab with download button and install instructions
- client.ts: getFableMcpInfo() helper
- ci.yml: add fable-mcp/** to trigger paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 17:26:05 -04:00
bvandeusen 6564a03c0e feat(settings): model management UI — pull, delete, VRAM status
Backend:
- Enrich GET /api/chat/models to also hit /api/ps and return loaded:bool
  and modified_at alongside name/size, using parallel gather

Frontend (Settings → General):
- Model list: each row shows name (monospace), size (GB/MB), 'in VRAM' badge
  if currently loaded, 'default' badge if it's the configured default
- Delete button per row; disabled while deletion in progress
- Pull form: text input (Enter submits) + Pull button
- Suggestion chips for qwen3:7b/14b/4b, llama3.1:8b, nomic-embed-text;
  disabled if already installed
- Progress display during pull: status text + determinate bar when
  Ollama reports total/completed, indeterminate animation otherwise
- Refresh button reloads the list; list auto-refreshes after pull/delete
- Link to ollama.com/library for model discovery

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:52:31 -04:00
bvandeusen 9cd0de3883 fix(briefing): deadlock in scheduler startup
start_briefing_scheduler was called from before_serving (event loop thread)
and used run_coroutine_threadsafe(...).result() which blocks the calling
thread waiting for the coroutine to complete — but since the calling thread
IS the event loop, the coroutine could never run, causing a 10s timeout and
zero jobs scheduled.

Fix: make start_briefing_scheduler async and await _get_briefing_enabled_users()
directly. Also use asyncio.create_task for the catch-up rather than
run_coroutine_threadsafe. The background thread jobs (_run_user_slot_sync)
continue to use run_coroutine_threadsafe correctly since they run on the
APScheduler thread, not the event loop thread.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 02:18:30 -04:00
bvandeusen 0db5dd126c fix(task-viewer): esc capture phase to prevent App.vue handler conflict
Register the Esc keydown listener in capture phase (useCapture=true) and
call stopPropagation() so App.vue's document-level handler never fires.
Without this, both handlers ran: App.vue pushed "/" and the component
pushed "/projects/:id", with non-deterministic winner. Also fixes the
blur-then-navigate issue where App.vue blurring an input caused the
component's handler to see body as the active element and navigate
immediately instead of stopping at the blur step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 01:05:12 -04:00
bvandeusen a2ba90160c feat: kanban status buttons, task back-nav, RSS UI, weather search, briefing fixes
Project view:
- Add inline status advance buttons on kanban task cards (todo→in_progress,
  in_progress→done); buttons reveal on hover, stop link navigation

Task viewer:
- Back button navigates to task's project instead of /tasks when project_id set
- Esc key navigates to project (or /tasks); blurs focused element first

Quick capture:
- Use user's configured model instead of hardcoded Config.OLLAMA_MODEL
- Remove create_project from classifier prompt (tool not offered, caused
  task-shaped inputs to silently fall through to note fallback)

Briefing scheduler:
- Fix get_event_loop() → get_running_loop() so background thread uses the
  correct hypercorn event loop (jobs were scheduling but never executing)
- Suppress bare greeting when both LLM synthesis lanes return empty

RSS feed UI (SettingsView):
- Show last-fetched age, category badge, and feed URL per row
- Category input field when adding a feed
- Refresh all button: fetches latest items, reloads list, toasts with count
- Enter key submits add-feed form; better empty-state hint with example feeds

Weather tool:
- Accept any city/region name in addition to 'home'/'work'/'all'
- Geocodes via Nominatim + fetches live from Open-Meteo for arbitrary queries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-24 00:42:01 -04:00
bvandeusen a9414cf949 chore: merge main into dev, resolve tools.py conflict
Accepted main's semantic duplicate threshold (>= 80 chars) over dev's
(>= 200 chars) for both note and task body checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 23:16:09 -04:00
bvandeusen e407043713 Merge pull request 'feat: API key auth, fable-mcp package, and semantic search endpoint' (#8) from dev into main
feat: API key auth, fable-mcp package, and semantic search endpoint
2026-03-24 03:05:16 +00:00
bvandeusen 00dee5ea0e ci: remove redundant pull_request trigger
All code goes through dev first where CI runs on push. PR runs
(dev→main) were duplicating already-validated checks and causing
queue contention. Build job was already gated to push-only events.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:53:32 -04:00
bvandeusen 55e260b392 fix(settings): fallback clipboard copy for http dev environments
navigator.clipboard.writeText() requires a secure context (HTTPS) and
silently fails on http://. Add an execCommand fallback so the API key
copy button works on non-secure dev instances.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 22:39:55 -04:00
bvandeusen 359cb24d9a feat(fable-mcp): implement FableClient, all tool modules, and server.py
- client.py: async httpx wrapper, FableAPIError, stream_get() SSE generator,
  singleton init_client()/get_client() and _reset_client() for tests
- tools/: notes, tasks, projects, milestones, search, chat — thin async
  functions that accept FableClient and call Fable REST endpoints
- server.py: FastMCP entry point with 20 tools registered via @mcp.tool(),
  each opening a fresh FableClient context per call; validates env vars at startup
- tests: 34 tests covering client HTTP behaviour, error handling, singleton,
  SSE streaming, and all tool modules

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:51:32 -04:00
bvandeusen 6180ee65c3 feat: add .env and Claude config downloads to API key reveal
After creating a key, two download buttons appear:
- 'Download .env' — pre-filled FABLE_URL + FABLE_API_KEY
- 'Download Claude config' — ready-to-paste mcpServers JSON block

Also adds Future Task A (in-app install instructions) and Future Task B
(Forgejo MCP) to the implementation plan.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:44:34 -04:00
bvandeusen 66571e16c1 feat: scaffold fable-mcp Python package
pyproject.toml with mcp[cli]/httpx/python-dotenv deps, entry point
fable-mcp=fable_mcp.server:main, dev deps for testing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:21:55 -04:00
bvandeusen 73eb34d722 feat: add API Keys tab to Settings UI
Create/revoke API keys with read/write scope selector. One-time key
reveal with copy button. Keys table showing prefix, scope badge, last
used. Inline revoke confirmation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:15:24 -04:00
bvandeusen 47b4af281b feat: API key routes, search endpoint, conversation type wiring
- GET/POST/DELETE /api/api-keys blueprint registered
- GET /api/search?q=&content_type=&limit= semantic search endpoint
- create_conversation gains conversation_type param (default "chat")
- cleanup_old_conversations excludes mcp type from retention sweep
- POST /api/chat/conversations accepts conversation_type body field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 21:01:04 -04:00
bvandeusen e8a4ca915a feat: add bearer token auth to _check_auth, falls back to session
Checks Authorization: Bearer header first, hashes token, looks up in
api_keys. Read-only keys get 403 on non-GET. Admin routes inaccessible
to non-admin key owners. Session path unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:57:26 -04:00
bvandeusen 583a140485 feat: add ApiKey service with create/list/revoke/lookup
SHA-256 hashed keys, fmcp_ prefix, soft-delete revocation. Tests cover
pure logic (hash, prefix, format, uniqueness, scope validation) and
auth middleware bearer token path (pending auth.py update).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:56:51 -04:00
bvandeusen 7358909432 feat: add ApiKey model and migration 0027
Adds api_keys table with user_id FK, key_hash, key_prefix, scope
(read/write), last_used_at, revoked_at. SHA-256 hashed, soft-delete.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:52:25 -04:00
bvandeusen c24b21f259 docs: add Fable MCP implementation plan
17-task TDD plan covering Phase 1 (API key auth, search endpoint,
conversation type wiring, Settings UI) and Phase 2 (fable-mcp Python
package with all tool modules, tests, and Claude Code registration).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:24:51 -04:00
bvandeusen e54d172487 docs: fix 8 spec review issues in Fable MCP design
- Add migration down_revision = "0026"
- Document create_conversation service + route changes for conversation_type
- Document SSE wire format (endpoints, event schema, termination event)
- Add retention sweep exclusion for mcp conversation type
- Rename type→content_type in search, document is_task mapping
- Clarify API keys cannot access admin-protected routes
- Document POST /api/chat/conversations accepting conversation_type body field
- Add delete_milestone to milestones tool list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:03:53 -04:00
bvandeusen f9973e22f1 docs: add Fable MCP design spec
Design for two sub-projects: API key auth support in fabledassistant
(bearer tokens, read/write scope, Settings UI) and a standalone Python
MCP server at fable-mcp/ exposing notes, tasks, projects, milestones,
semantic search, and chat tools to Claude Code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-23 20:00:26 -04:00
bvandeusen e9ddab7368 Merge pull request 'fix: remove registry build cache — Forgejo rejects large layer blobs' (#7) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#7
2026-03-16 17:37:43 +00:00
Bryan Van Deusen 16b2b5c68e fix: remove registry build cache — Forgejo rejects large layer blobs
Even mode=min still hits the registry's blob size limit (400 Bad Request).
The local runner's Docker daemon layer cache is sufficient for fast
incremental builds without needing a separate registry cache tag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:53:02 -04:00
bvandeusen ce5b2ffaeb Merge pull request 'fix: downgrade buildx cache export from mode=max to mode=min' (#6) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#6
2026-03-16 16:41:38 +00:00
Bryan Van Deusen c232a7d079 fix: downgrade buildx cache export from mode=max to mode=min
mode=max exports all intermediate layer blobs which exceeded the Forgejo
registry's blob upload size limit (400 Bad Request). mode=min only exports
the final image layers, keeping cache entries small enough for the registry.
The actual image build and push was succeeding; only the cache write failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 12:32:06 -04:00
bvandeusen 1c3f0ab7f7 Merge pull request 'Dev to Main: bug fixes' (#5) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#5
2026-03-16 16:00:47 +00:00
bvandeusen 33f52081e5 docs: update summary.md for 2026-03-16 session
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:29:04 -04:00
bvandeusen a58dbe79f2 fix: fuzzy project resolution and guard against accidental project creation
- _resolve_project now uses reverse-substring + SequenceMatcher (≥0.55)
  so partial names like "Famous Supply" match "Famous Supply Co." reliably
- create_project runs similar-project checks before the confirmed gate so
  confirmed=true can't bypass them; threshold lowered to 0.55 to catch
  abbreviated names
- Error messages on project-not-found no longer suggest create_project,
  directing the model to list_projects first
- Tool description updated to explicitly prohibit calling create_project
  in response to a lookup failure
- tasks.py: fire-and-forget embedding update on create/update
- briefing_pipeline.py: await is_caldav_configured (was sync call)
- notes.py: remove erroneous duplicate count_query filter

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 07:44:27 -04:00
bvandeusen 0d41b8be34 Fix briefing settings not loading when tab is active on mount
The watch(activeTab) only fires on changes, not the initial value.
When localStorage had settings_tab="briefing", onMounted skipped
loadBriefingTab() so the form always showed default empty values.
Follows the same pattern already used for the groups panel (line 338).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:05:10 -04:00
bvandeusen 46672725a1 Skip semantic duplicate check for bare-title tasks/notes
The semantic similarity check was flagging unrelated short-title tasks
as duplicates (e.g. "Lore: Shell 0" matching "Lore: Reinitialization 0"
at 91%) because with no body, the embedding is purely title-based and
co-domain tasks in the same project share a tight embedding neighborhood.

Only run the semantic check when the body is ≥ 80 chars — enough
content to make a meaningful comparison. The fuzzy title check already
covers exact/near-exact title duplicates.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:42:07 -04:00
bvandeusen 4d55d9d82a Fix scanner probes returning 200 via SPA catch-all
The 404 handler was unconditionally serving index.html (200) for all
non-API, non-static paths, including scanner probes for .php, .asp, .cgi
etc. Added _SPA_EXTENSIONS set so paths with unknown extensions get a
real 404 instead of a misleading 200.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-09 18:28:25 -04:00
272 changed files with 40895 additions and 14132 deletions
+84 -39
View File
@@ -1,13 +1,27 @@
# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test build :dev + :<sha>
# Tag v* (release): typecheck + lint + test build :latest + :<sha> + :<version>
# Pull request: typecheck + lint + test only (no build)
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<sha> + :<version>
#
# main pushes are NOT gated here: a merge to main only happens after
# dev has already passed CI, and the release tag is the sole trigger
# for a production image. Re-running CI on the merge commit just burns
# runner time without changing the outcome.
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
# The tag push triggers this workflow; build job pushes :latest + :<version>.
#
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
# gating on branch push is already enough.
#
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
# honor `on.push.branches` as a filter — merge commits landing on main
# still trigger the workflow, producing redundant runs on the same SHA
# 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):
# REGISTRY_USER — your Forgejo username
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
@@ -26,31 +40,40 @@ on:
- "alembic.ini"
- "Dockerfile"
- "assets/**"
- "fable-mcp/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- ".forgejo/workflows/ci.yml"
# 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
# are never cancelled, so a release build can't kill itself mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. Jobs that need more (build pushes to the
# registry) upgrade explicitly.
permissions:
contents: read
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
jobs:
typecheck:
name: TypeScript typecheck
runs-on: py3.12-node22
# Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- name: Cache npm download cache
uses: actions/cache@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
path: ~/.npm
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-cache-
- name: Install dependencies
run: npm ci
@@ -62,52 +85,57 @@ jobs:
lint:
name: Python lint
runs-on: py3.12-node22
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
- name: Install ruff
run: pip install --break-system-packages ruff
# ruff is pre-installed in the ci-runner base image — no install
# step needed, lint runs in ~2s.
- name: Lint
run: ruff check src/
test:
name: Python tests
runs-on: py3.12-node22
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
steps:
- uses: actions/checkout@v6
# Python 3.12 is pre-installed in the runner-base image (py3.12-node22).
- name: Cache pip wheels
uses: actions/cache@v3
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ hashFiles('pyproject.toml') }}
restore-keys: pip-
path: ~/.cache/uv
key: uv-${{ hashFiles('pyproject.toml') }}
restore-keys: uv-
- name: Create virtual environment
run: python3.12 -m venv /opt/venv
run: uv venv /opt/venv
- name: Install package with dev deps
run: |
/opt/venv/bin/pip install --upgrade pip setuptools wheel
/opt/venv/bin/pip install --no-build-isolation http-ece
/opt/venv/bin/pip install -e ".[dev]"
# http-ece doesn't declare setuptools as a build dep, and uv
# creates bare venvs without it. Install setuptools first so
# --no-build-isolation can find it.
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: Run tests
run: /opt/venv/bin/python -m pytest tests/ -v
run: /opt/venv/bin/python -m pytest tests/ -q
build:
name: Build & push image
needs: [typecheck, lint, test]
# Build on dev branch pushes and version tag pushes only.
# main branch pushes run CI for safety but do not build —
# the release tag (v*) is the sole trigger for a production image.
if: |
github.event_name == 'push' &&
(github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/'))
runs-on: py3.12-node22
# Mirrors the ref guard on the gate jobs above — main merge-commit
# pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
runs-on: ci-runner
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
@@ -125,6 +153,17 @@ jobs:
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
- name: Free disk space
# Self-hosted runner housekeeping. Two-step cleanup:
# 1. Prune dangling containers/images globally (stops the runner
# from accumulating cruft from past failed builds).
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
# mount cache survives but old intermediate layers don't
# accumulate indefinitely.
run: |
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -140,7 +179,13 @@ jobs:
with:
context: .
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
# Registry-backed layer cache. Pull from :cache to prime
# BuildKit, push updated layers back to :cache so the next
# build starts warm even if the runner's local cache was
# pruned. `mode=max` exports all intermediate layers, not
# just the final image, which is what gives the ~80% speedup.
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
+3
View File
@@ -23,6 +23,8 @@ settings.local.json
# Claude Code
.claude/
docs/superpowers/
docs/plans/
docs/specs/
# Environment
.env
@@ -34,3 +36,4 @@ docker-compose.override.yml
*.log
.DS_Store
.superpowers/
.mcp.json
+1
View File
@@ -0,0 +1 @@
2298268
+18 -2
View File
@@ -1,8 +1,9 @@
# syntax=docker/dockerfile:1
# Stage 1: Build Vue frontend
FROM node:22-alpine AS build-frontend
WORKDIR /build
COPY frontend/package.json frontend/package-lock.json* ./
RUN npm install -g npm@latest --quiet && npm install
RUN npm ci --quiet
COPY frontend/ .
RUN npm run build
@@ -12,7 +13,22 @@ WORKDIR /app
COPY pyproject.toml .
COPY src/ src/
RUN pip install --no-cache-dir .
RUN --mount=type=cache,target=/root/.cache/pip \
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 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 .
+26 -195
View File
@@ -1,211 +1,42 @@
# 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.
## What It Does
## Features
**Notes with inline formatting** — Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks without leaving the keyboard.
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.
**Task tracking** — Notes convert freely to tasks (and back). Tasks carry status (`todo``in_progress``done`), priority, due date, sub-tasks, milestone assignment, and work logs with time tracking.
## Quick Start
**Projects and milestones** — Group related notes and tasks into projects. Milestones give projects a timeline and show completion progress. A kanban-style project view groups tasks by milestone.
**Prerequisites:** Docker and Docker Compose. 8 GB+ RAM recommended for LLM inference.
**Project Workspace**`/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically.
**Wikilinks and backlinks** — Link notes with `[[Title]]` syntax. Click a wikilink to navigate to (or create) the referenced note. Each note shows what links to it. The editor suggests existing note titles as candidate links.
**Tag organisation** — Tags are first-class columns (stored as a PostgreSQL array, not extracted from body text). Tag autocomplete, tag-based filtering, and a force-directed graph view show how notes cluster.
**Knowledge graph**`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; project hub nodes (invisible) attract project members. Click any node to open a slide-in peek panel.
**AI chat** — Full conversation history with SSE streaming. The assistant automatically retrieves semantically relevant notes (RAG) and injects them as context. Attach specific notes by paperclip for focused discussions. Useful replies can be saved directly as notes.
**AI writing assistant** — Select a passage in the editor, give an instruction ("make this more concise", "add examples"), and stream a diff-style suggestion you can accept or reject.
**Web research** — The assistant can search the web (SearXNG), fetch pages, and synthesise findings. Research results are saved as notes. A lightweight `search_web` tool answers quick questions inline.
**Collaboration and sharing** — Share any project or note with other users or groups. Three permission levels: `viewer`, `editor`, `admin`. A "Shared with me" page lists all incoming shares. In-app notifications (with push) alert recipients when items are shared or when they are added to a group.
**Groups** — Admins can create platform-wide groups, assign users roles (`member` / `owner`), and share resources with the group in one action.
**In-app notifications** — A bell icon in the nav shows unread notification count with a 60-second polling interval. Clicking a notification navigates to the relevant resource and marks it read.
**CalDAV calendar** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) and have the assistant create, list, search, update, and delete calendar events via natural language.
**Push notifications** — Web Push (VAPID) notifies you when AI generation completes, even in another tab. Configurable per-user from the Notifications settings tab.
**PWA** — Installable as a desktop or mobile app. Service worker caches the shell; push is handled by `public/sw.js`.
**Data export and backup** — Export your data as a Markdown ZIP (with YAML frontmatter) or a JSON array from the Data settings tab. Admins can export/restore full application backups (version 2 includes projects, milestones, task logs, AI drafts, note versions, push subscriptions) with proper ID remapping on restore.
**OAuth / OIDC login** — Supports PKCE-based OIDC in addition to local username/password auth. `LOCAL_AUTH_ENABLED` can disable local login entirely for SSO-only deployments.
**Multi-user with isolation** — All data is scoped to the owning user. Access to shared resources is resolved through `services/access.py` using the permission rank system. The first registered user becomes admin.
**Daily Briefing** — A scheduled, dialogue-based morning briefing accessible at `/briefing`. The assistant compiles your tasks, calendar events, projects, weather forecast (Open-Meteo), and RSS feed digest at 4am, then checks in at 8am, 12pm, and 4pm. You can reply interactively — the briefing is a real conversation, not a widget. The assistant learns your preferences over time via a profile note it maintains. Configure locations, work schedule, RSS feeds, and active slots from Settings → Briefing.
**Dark and light themes** — Defaults to dark (slate-indigo palette). One-click toggle in the header.
**Keyboard shortcuts**`g`+`h/n/t/p/c/g` navigate sections; `n` new note; `t` new task; `?` shows the shortcut panel. Full list available in-app.
---
## Getting Started
### Prerequisites
- [Docker](https://docs.docker.com/get-docker/) and Docker Compose
- A machine with enough RAM to run an LLM (8 GB+ recommended for smaller models like `llama3.2`)
### Quick Start
1. Clone the repository:
```bash
git clone https://github.com/your-username/fabledassistant.git
cd fabledassistant
```
2. Start the application:
```bash
docker compose up --build
```
3. Open `http://localhost:5000` in your browser.
4. Register the first user account — this account becomes the admin.
5. Go to **Settings → General** to pull an LLM model (`llama3.2` at 2 GB is a good starting point).
### Day-to-Day Usage
- **Create a note** from the Notes page. Use Markdown — formatting renders live.
- **Link notes** by typing `[[` for a wikilink autocomplete dropdown.
- **Tag your notes** — add tags in the sidebar tag input; autocomplete suggests existing tags.
- **Use the AI writing assistant** — select text in the editor, write an instruction, stream a suggestion.
- **Chat with the AI** — the assistant finds relevant notes automatically. Attach specific notes for focused context.
- **Convert notes ↔ tasks** from the viewer toolbar.
- **Open a project workspace** from the project page — chat, tasks, and note editor in one view.
- **Share a project or note** — click Share in the project/note/task viewer toolbar, search for users or pick a group.
- **Manage groups** (admins) — Settings → Groups tab.
- **Backup your data** — Settings → Data tab for personal export; admin section for full application backup.
---
## Configuration
Configuration is via environment variables. See `docker-compose.yml` for defaults.
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://...` | PostgreSQL connection string |
| `SECRET_KEY` | (required) | Session signing key |
| `OLLAMA_BASE_URL` | `http://ollama:11434` | Ollama API endpoint |
| `DEFAULT_MODEL` | `llama3.1` | LLM model to warm on startup |
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG |
| `SECURE_COOKIES` | `false` | Set `true` behind TLS |
| `LOG_LEVEL` | `INFO` | Logging verbosity |
| `OIDC_ISSUER` | — | OIDC issuer URL for SSO login |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local login |
| `SEARXNG_URL` | — | SearXNG base URL for web search |
| `BASE_URL` | — | Public URL (used in email links and OIDC redirect) |
For production, `docker-compose.prod.yml` supports Docker secrets (`SECRET_KEY_FILE`, `DATABASE_URL_FILE`) and includes network isolation, health checks, and resource limits.
---
## Production Deployment
### Reverse Proxy (Required)
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- **Rate-limit auth endpoints** — recommended: ≤5 req/min per IP on `/api/auth/login` and `/api/auth/register`
- **Set CSP headers** — recommended: `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'`
### Security Checklist
- **Strong `SECRET_KEY`** — generate with `python -c "import secrets; print(secrets.token_hex(32))"` or use Docker secrets via `SECRET_KEY_FILE`
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions.
- **Keep Ollama on an internal network** — both compose files keep it off the host network.
---
## Technical Overview
### Stack
| Layer | Technology |
|-------|------------|
| Frontend | Vue 3, TypeScript, Vite, Pinia, Vue Router |
| Editor | Tiptap (ProseMirror) with custom slash-command extension |
| Backend | Python 3.12, Quart (async ASGI) |
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic |
| LLM | Ollama (local) — any OpenAI-compatible API |
| Search | SearXNG (optional, self-hosted) |
| Push | Web Push / VAPID (pywebpush 2.x) |
| Deployment | Docker Compose |
### Architecture
The app runs as a single container serving the Vue SPA and REST API (`/api/`). The frontend is built by Vite during the Docker image build and served as static files by Quart.
LLM interactions stream via Server-Sent Events (SSE). Chat generation runs in background `asyncio` tasks with an in-memory event buffer supporting client reconnection without data loss. An abort mechanism lets users cancel in-flight generations.
Semantic search (RAG) uses `nomic-embed-text` via Ollama to generate embeddings stored in PostgreSQL. Notes above 0.60 cosine similarity are auto-injected into the system prompt; notes between 0.450.60 appear as sidebar suggestions.
Permission resolution is centralised in `services/access.py`. All resource access goes through `get_project_permission` / `get_note_permission`, which check ownership, direct shares, group membership shares, and note→project inheritance in order, returning the highest applicable permission.
### Database
PostgreSQL with SQLAlchemy 2.0 async. Tasks are notes with non-null `status` (unified `Note` model). Tags are stored as a `ARRAY[text]` column. Migrations run automatically on startup via Alembic.
### Project Structure
```
fabledassistant/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations
├── src/fabledassistant/
│ ├── app.py # Quart app factory + blueprint registration
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API blueprints
│ ├── services/ # Business logic (access, sharing, groups, …)
│ └── static/ # Built frontend (generated at build time)
└── frontend/
└── src/
├── views/ # Page-level components
├── components/ # Reusable UI components
├── composables/ # Vue composables (autosave, shortcuts, …)
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
└── api/ # Typed API client (client.ts)
```
### Development
All development is done via Docker. No local dependency installation required.
Download [`docker-compose.quickstart.yml`](docker-compose.quickstart.yml) from this repo, then:
```bash
# Start the dev stack (hot-reload not included — rebuild on changes)
docker compose up --build
# Optional but recommended — set a secret key
export SECRET_KEY=your-random-secret-here
# Reset the database
docker compose down -v && docker compose up --build
# Lint, format, typecheck, test (runs inside Docker via Makefile)
make check
docker compose -f docker-compose.quickstart.yml up -d
```
CI runs on Forgejo Actions with a custom runner image (`py3.12-node22`) that has Python 3.12 and Node 22 pre-installed. Pushes to `dev` build a `:dev` image; merges to `main` build `:latest`.
Open `http://localhost:5000`. The first user to register becomes admin. Go to **Settings → General** to pull an LLM model — `qwen3:8b` or `llama3.1:8b` are good starting points.
---
> **GPU:** Ollama runs CPU-only by default. See the comments in `docker-compose.quickstart.yml` to enable NVIDIA GPU passthrough.
> **Development:** To build from source, see [Development](docs/development.md).
## Documentation
| Doc | Contents |
|-----|----------|
| [Architecture](docs/architecture.md) | Stack, design decisions, data models, key services |
| [Configuration](docs/configuration.md) | Environment variables, Docker Compose, production setup, security |
| [Features](docs/features.md) | Detailed feature breakdown and keyboard shortcuts |
| [Development](docs/development.md) | Dev workflow, CI/CD, migrations, release process |
| [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 |
| [API Reference](docs/api-reference.md) | All REST API endpoints |
| [Android App](docs/android-app.md) | Flutter companion app architecture and feature status |
## License
+34
View File
@@ -0,0 +1,34 @@
"""add api_keys table
Revision ID: 0027
Revises: 0026
Create Date: 2026-03-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
sa.Column("key_prefix", sa.Text(), nullable=False),
sa.Column("scope", sa.Text(), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
def downgrade() -> None:
op.drop_table("api_keys")
@@ -0,0 +1,55 @@
"""Add briefing improvements: rss_items topics/classified_at, messages metadata,
rss_item_reactions, briefing_task_snapshot."""
from alembic import op
revision = "0028"
down_revision = "0027"
def upgrade() -> None:
op.execute("""
ALTER TABLE rss_items
ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ
""")
op.execute("""
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS metadata JSONB
""")
op.execute("""
CREATE TABLE IF NOT EXISTS rss_item_reactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, rss_item_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id "
"ON rss_item_reactions(user_id)"
)
op.execute("""
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
snapshot_hash TEXT NOT NULL,
last_briefed TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, task_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id "
"ON briefing_task_snapshot(user_id)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS briefing_task_snapshot")
op.execute("DROP TABLE IF EXISTS rss_item_reactions")
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics")
@@ -0,0 +1,19 @@
"""Add caldav_uid and color columns to events table."""
from alembic import op
revision = "0029"
down_revision = "0028"
def upgrade() -> None:
op.execute("""
ALTER TABLE events
ADD COLUMN IF NOT EXISTS caldav_uid TEXT DEFAULT '',
ADD COLUMN IF NOT EXISTS color TEXT DEFAULT ''
""")
def downgrade() -> None:
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS color")
op.execute("ALTER TABLE events DROP COLUMN IF EXISTS caldav_uid")
+23
View File
@@ -0,0 +1,23 @@
"""Add rag_project_id to conversations; auto_summary columns to projects."""
from alembic import op
revision = "0030"
down_revision = "0029"
def upgrade() -> None:
op.execute("""
ALTER TABLE conversations
ADD COLUMN IF NOT EXISTS rag_project_id INTEGER DEFAULT NULL
""")
op.execute("""
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS auto_summary TEXT DEFAULT NULL,
ADD COLUMN IF NOT EXISTS summary_updated_at TIMESTAMPTZ DEFAULT NULL
""")
def downgrade() -> None:
op.execute("ALTER TABLE conversations DROP COLUMN IF EXISTS rag_project_id")
op.execute("ALTER TABLE projects DROP COLUMN IF EXISTS auto_summary, DROP COLUMN IF EXISTS summary_updated_at")
@@ -0,0 +1,21 @@
"""Add 'cancelled' task status.
The status column is plain TEXT (not a PostgreSQL enum type), so no DDL
change is required — the application layer already accepts the new value.
"""
from alembic import op
revision = "0031"
down_revision = "0030"
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: status is stored as TEXT; the new value is valid without DDL changes.
pass
def downgrade() -> None:
pass
@@ -0,0 +1,19 @@
"""Add started_at and completed_at to notes."""
from alembic import op
import sqlalchemy as sa
revision = "0032"
down_revision = "0031"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("started_at", sa.DateTime(timezone=True), nullable=True))
op.add_column("notes", sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("notes", "completed_at")
op.drop_column("notes", "started_at")
@@ -0,0 +1,30 @@
"""Add recurrence_rule and recurrence_next_spawn_at to notes."""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
revision = "0033"
down_revision = "0032"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("notes", sa.Column("recurrence_rule", JSONB(), nullable=True))
op.add_column(
"notes",
sa.Column("recurrence_next_spawn_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index(
"ix_notes_recurrence_next_spawn_at",
"notes",
["recurrence_next_spawn_at"],
postgresql_where=sa.text("recurrence_next_spawn_at IS NOT NULL"),
)
def downgrade() -> None:
op.drop_index("ix_notes_recurrence_next_spawn_at", table_name="notes")
op.drop_column("notes", "recurrence_next_spawn_at")
op.drop_column("notes", "recurrence_rule")
@@ -0,0 +1,53 @@
"""Add user_profiles table for structured per-user preferences."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
revision = "0034"
down_revision = "0033"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"user_profiles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
unique=True,
),
sa.Column("display_name", sa.Text(), nullable=True),
sa.Column("job_title", sa.Text(), nullable=True),
sa.Column("industry", sa.Text(), nullable=True),
sa.Column("expertise_level", sa.Text(), nullable=True),
sa.Column("response_style", sa.Text(), nullable=True),
sa.Column("tone", sa.Text(), nullable=True),
sa.Column("interests", ARRAY(sa.Text()), nullable=True),
sa.Column("work_schedule", JSONB(), nullable=True),
sa.Column("learned_summary", sa.Text(), nullable=True),
sa.Column("observations_raw", JSONB(), nullable=True),
sa.Column("observations_updated_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_user_profiles_user_id", "user_profiles", ["user_id"], unique=True)
def downgrade() -> None:
op.drop_index("ix_user_profiles_user_id", table_name="user_profiles")
op.drop_table("user_profiles")
@@ -0,0 +1,28 @@
"""Add rss_item_embeddings table for semantic news search."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0035"
down_revision = "0034"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"rss_item_embeddings",
sa.Column("rss_item_id", sa.Integer(), sa.ForeignKey("rss_items.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.func.now()),
)
op.create_index("ix_rss_item_embeddings_user_id", "rss_item_embeddings", ["user_id"])
op.create_index("ix_rss_item_embeddings_rss_item_id", "rss_item_embeddings", ["rss_item_id"])
def downgrade() -> None:
op.drop_index("ix_rss_item_embeddings_rss_item_id", table_name="rss_item_embeddings")
op.drop_index("ix_rss_item_embeddings_user_id", table_name="rss_item_embeddings")
op.drop_table("rss_item_embeddings")
@@ -0,0 +1,28 @@
"""Add note_type and metadata columns to notes table for entity types."""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision = "0036"
down_revision = "0035"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"notes",
sa.Column("note_type", sa.Text(), nullable=False, server_default="note"),
)
op.add_column(
"notes",
sa.Column("metadata", JSONB(), nullable=True),
)
op.create_index("ix_notes_note_type", "notes", ["note_type"])
def downgrade() -> None:
op.drop_index("ix_notes_note_type", table_name="notes")
op.drop_column("notes", "metadata")
op.drop_column("notes", "note_type")
@@ -0,0 +1,29 @@
"""Add reminder_minutes and reminder_sent_at to events.
Revision ID: 0037
Revises: 0036
Create Date: 2026-04-04
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0037"
down_revision = "0036"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("events", sa.Column("reminder_minutes", sa.Integer(), nullable=True))
op.add_column(
"events",
sa.Column("reminder_sent_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("events", "reminder_sent_at")
op.drop_column("events", "reminder_minutes")
@@ -0,0 +1,34 @@
"""Add content_full and context_prepared caches to rss_items.
Revision ID: 0038
Revises: 0037
Create Date: 2026-04-13
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0038"
down_revision = "0037"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True))
op.add_column(
"rss_items",
sa.Column("context_prepared", sa.Text(), nullable=True),
)
op.add_column(
"rss_items",
sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_column("rss_items", "content_fetched_at")
op.drop_column("rss_items", "context_prepared")
op.drop_column("rss_items", "content_full")
@@ -0,0 +1,38 @@
"""Link rss_items to their discussion-summary note.
Revision ID: 0039
Revises: 0038
Create Date: 2026-04-14
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0039"
down_revision = "0038"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"rss_items",
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
)
op.create_foreign_key(
"fk_rss_items_discussion_note_id",
"rss_items",
"notes",
["discussion_note_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
op.drop_constraint(
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
)
op.drop_column("rss_items", "discussion_note_id")
@@ -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")
+82
View File
@@ -0,0 +1,82 @@
# Fabled Scribe — Quick Start
#
# No build required. Pulls the latest pre-built image from the registry.
#
# Usage:
# 1. Download this file
# 2. docker compose -f docker-compose.quickstart.yml up -d
# 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)
#
# Set SECRET_KEY via environment variable or a .env file alongside this file:
# SECRET_KEY=your-random-secret-here
services:
app:
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
ports:
- "5000:5000"
environment:
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
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}"
volumes:
- app_data:/data
depends_on:
db:
condition: service_healthy
ollama:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
db:
image: postgres:16-alpine
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: fabled
POSTGRES_PASSWORD: fabled
POSTGRES_DB: fabledassistant
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"]
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
timeout: 10s
retries: 5
start_period: 15s
restart: unless-stopped
# Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
app_data:
pgdata:
ollama_models:
+9 -8
View File
@@ -15,7 +15,7 @@ services:
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
OLLAMA_URL: "http://ollama:11434"
OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
# Uncomment and set to enable web research and image search via SearXNG:
# SEARXNG_URL: "http://searxng:8080"
@@ -55,13 +55,14 @@ services:
OLLAMA_NUM_PARALLEL: "2"
OLLAMA_KEEP_ALIVE: "30m"
OLLAMA_FLASH_ATTENTION: "1"
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
# GPU reservation commented out — no nvidia-container-toolkit on this host
# deploy:
# resources:
# reservations:
# devices:
# - driver: nvidia
# count: all
# capabilities: [gpu]
volumes:
pgdata:
+271
View File
@@ -0,0 +1,271 @@
# Fable MCP — Design Spec
**Date:** 2026-03-23
**Status:** Approved
**Author:** bvandeusen + Claude
---
## Overview
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
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.
---
## Sub-project 1: Fable API Key Feature
### Motivation
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
### Database
New table `api_keys` via migration `0027_add_api_keys.py`:
- `down_revision = "0026"` (the briefing tables migration)
- `revision = "0027"`
| Column | Type | Notes |
|--------|------|-------|
| `id` | Integer PK | |
| `user_id` | Integer FK → users | CASCADE delete |
| `name` | Text | Human-readable label |
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
| `scope` | Text | `"read"` or `"write"` |
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
| `created_at` | Timestamp | |
| `revoked_at` | Timestamp | Nullable — soft delete |
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
### Auth Middleware (`auth.py`)
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
4. If header absent: existing session logic runs unchanged
Session auth is untouched — no regression risk for the web UI.
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
### Routes (`/api/api-keys`)
New blueprint `api_keys_bp`, registered in `app.py`:
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
### Settings UI
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
- Create form: name input + read/write radio/toggle + "Generate Key" button
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
- Keys table: columns for name, scope badge, prefix, last used, revoke button
- Revoke shows inline confirmation before calling DELETE
### New Search Endpoint
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
- `content_type=note``is_task=False`
- `content_type=task``is_task=True`
- `content_type=all` (default) → `is_task=None`
Returns:
```json
{
"results": [
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
],
"total": 5
}
```
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
### Conversation Type: `"mcp"`
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
MCP conversations:
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
- Are excluded from the default chat list
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
### Chat SSE Wire Format
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
- **Create conversation:** `POST /api/chat/conversations``{id, title, ...}`
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages``{message_id, ...}`; then connect to:
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
SSE event format (each line is `data: <json>`):
- `{"type": "token", "content": "..."}` — streaming token
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
---
## Sub-project 2: Fable MCP Server
### 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.
### Package Structure
```
fable-mcp/
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
README.md
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
fable_mcp/
__init__.py
server.py # FastMCP instance, imports + registers all tool modules
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
tools/
__init__.py
notes.py # list_notes, get_note, create_note, update_note, delete_note
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
# patch_task_status, add_task_log
projects.py # list_projects, get_project, create_project, update_project,
# delete_project, get_project_summary
milestones.py # list_milestones, get_milestone, create_milestone,
# update_milestone, delete_milestone
search.py # semantic_search — calls GET /api/search
chat.py # send_message — creates/continues conversation, returns response
```
### Dependencies
```toml
[project]
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
```
### `client.py`
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
### `server.py`
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
### Tool Modules
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
**`notes.py`** tools:
- `list_notes(query, tags, project_id, limit, offset)`
- `get_note(note_id)`
- `create_note(title, body, tags, project_id)`
- `update_note(note_id, title, body, tags, project_id)`
- `delete_note(note_id)`
**`tasks.py`** tools:
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
- `get_task(task_id)`
- `create_task(title, body, project_id, milestone_id, priority, status)`
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
- `delete_task(task_id)`
- `patch_task_status(task_id, status)`
- `add_task_log(task_id, content)`
**`projects.py`** tools:
- `list_projects()`
- `get_project(project_id)` — includes milestone summary
- `create_project(title, description, goal, color)`
- `update_project(project_id, title, description, goal, status, color)`
- `delete_project(project_id)`
**`milestones.py`** tools:
- `list_milestones(project_id, status)`
- `get_milestone(project_id, milestone_id)`
- `create_milestone(project_id, title, description, order_index)`
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
- `delete_milestone(project_id, milestone_id)`
**`search.py`** tools:
- `semantic_search(query, content_type, limit)``content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
**`chat.py`** tools:
- `send_message(message, conversation_name)`:
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
- Posts the message via `POST /api/chat/conversations/:id/messages`
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
- MCP conversations are excluded from the normal chat UI list
### Error Handling
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
### Claude Code Registration
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
```json
{
"mcpServers": {
"fable": {
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:8080",
"FABLE_API_KEY": "fmcp_your_key_here"
}
}
}
}
```
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
---
## Build & Repo Plan
1. Implement and test within `fabledassistant/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
---
## Out of Scope (v1)
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
- Forgejo MCP — follow-on spec
- Rate limiting on API key endpoints
- Key expiry / TTL
- Per-resource scope (e.g., key restricted to one project)
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
# Speech-to-Speech (S2S) Design Spec
**Branch:** `feature/voice-s2s`
**Date:** 2026-03-29
**Status:** Approved for implementation
---
## Decisions
- **STT:** faster-whisper (in-process Python, model size configurable via `STT_MODEL` env var)
- **TTS:** Kokoro TTS (in-process Python, voice/speed/style configurable per-user)
- **Input mode:** Push-to-talk (Phase 1); VAD deferred
- **No browser STT/TTS fallbacks** — self-hosted only, data stays on-server
- **No Android app** — web only for this implementation
---
## New Env Vars (`config.py`)
| Var | Default | Description |
|---|---|---|
| `VOICE_ENABLED` | `false` | Feature flag — opt-in |
| `STT_BACKEND` | `faster-whisper` | Only supported value currently |
| `STT_MODEL` | `base.en` | `tiny.en` / `base.en` / `small.en` / `medium.en` |
| `TTS_BACKEND` | `kokoro` | Only supported value currently |
---
## Per-User Settings (stored in `settings` table)
| Key | Default | Description |
|---|---|---|
| `voice_tts_voice` | `af_heart` | Kokoro voice ID |
| `voice_tts_speed` | `1.0` | Speech rate, 0.71.3 |
| `voice_speech_style` | `conversational` | `conversational` / `concise` / `detailed` |
---
## New Backend Files
### `src/fabledassistant/services/stt.py`
Lazy singleton `WhisperModel` loader. Public API:
- `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
- `stt_available() -> bool`
### `src/fabledassistant/services/tts.py`
Lazy singleton `KPipeline` loader. Public API:
- `load_tts_model()` — called at startup
- `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
- `tts_available() -> bool`
### `src/fabledassistant/routes/voice.py`
Blueprint at `/api/voice`, all routes `@login_required`.
| Endpoint | Method | Description |
|---|---|---|
| `/api/voice/status` | GET | STT/TTS availability; `enabled` false if `VOICE_ENABLED=false` |
| `/api/voice/voices` | GET | List available Kokoro voices |
| `/api/voice/transcribe` | POST | multipart `audio` field → `{"transcript": "...", "duration_ms": 123}` |
| `/api/voice/synthesise` | POST | `{"text", "voice", "speed"}` → WAV bytes |
---
## Modified Backend Files
### `src/fabledassistant/app.py`
- Register `voice_bp` blueprint
- In `startup()`: `asyncio.create_task(load_stt_model())` + `asyncio.create_task(load_tts_model())` when `VOICE_ENABLED`
### `src/fabledassistant/config.py`
- Add 4 new env var attributes
- Add validation in `validate()`
### `src/fabledassistant/services/llm.py`
- 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."*
- Append style modifier based on `voice_speech_style`
### `src/fabledassistant/services/generation_task.py`
- Add `voice_mode: bool = False` to `run_generation()`
- Read `voice_speech_style` from settings when voice_mode; pass both to `build_context()`
### `src/fabledassistant/routes/chat.py`
- Allow `"voice"` in `conversation_type` whitelist
### `src/fabledassistant/services/chat.py`
- Exclude `conversation_type == "voice"` from auto-cleanup retention
---
## New Frontend Files
### `frontend/src/composables/useVoiceRecorder.ts`
Wraps `MediaRecorder`. Exports: `recording`, `error`, `isSupported`, `startRecording()`, `stopRecording() -> Promise<Blob>`.
### `frontend/src/composables/useVoiceAudio.ts`
Wraps `AudioContext`. Exports: `playing`, `isSupported`, `play(blob)`, `stop()`.
### `frontend/src/components/VoiceOverlay.vue`
Floating PTT button (fixed bottom-right). Creates/reuses a `"voice"` conversation. Full flow: record → transcribe → send → stream → synthesise → play. Space bar hotkey (from `App.vue`). Mounted globally in `App.vue`.
---
## Modified Frontend Files
### `frontend/src/api/client.ts`
Add: `transcribeAudio(blob)`, `synthesiseSpeech(text, voice?, speed?)`, `getVoiceStatus()`, `getVoiceList()`
### `frontend/src/views/BriefingView.vue`
- "Listen" button: reads latest assistant message aloud via TTS
- Mic button in input bar: PTT → transcribe → auto-fill input → send
- Auto-TTS on assistant response when in listen mode
### `frontend/src/views/SettingsView.vue`
- New "Voice" tab: voice dropdown, speed slider, speech style radio
- Loads from `/api/settings`, saves via `PUT /api/settings`
### `frontend/src/App.vue`
- Mount `<VoiceOverlay />`
- Space bar → `"voice:ptt-toggle"` custom event
---
## Audio Format
| Direction | Format | Rationale |
|---|---|---|
| Browser → Server | WebM/Opus | Native `MediaRecorder` output; no re-encoding |
| Server → Browser | WAV (24kHz, 16-bit mono) | Kokoro native; no re-encoding; `decodeAudioData` compatible |
---
## Dependencies to Add (`pyproject.toml`)
```toml
[project.optional-dependencies]
voice = [
"faster-whisper>=1.0",
"kokoro>=0.9",
"soundfile>=0.12",
]
```
Install unconditionally in Docker (activated by `VOICE_ENABLED` at runtime):
```dockerfile
RUN pip install faster-whisper kokoro soundfile
```
---
## Database Migration
No schema changes required. `conversation_type` is unconstrained TEXT. Voice settings use existing key-value `settings` table. Optional no-op migration `0034_voice_conversation_type.py` for audit trail.
---
## Implementation Phases
### Phase 1 — Backend services + routes
1. Add env vars to `config.py`
2. Create `services/stt.py` (faster-whisper)
3. Create `services/tts.py` (Kokoro)
4. Create `routes/voice.py` (4 endpoints)
5. Wire model loading into `app.py` startup
6. Add `voice_mode` to `build_context()` + `run_generation()`
7. Allow `"voice"` conversation type in chat route + cleanup exclusion
### Phase 2 — BriefingView listen + voice follow-up
1. Create `useVoiceRecorder.ts`
2. Create `useVoiceAudio.ts`
3. Add voice API functions to `client.ts`
4. Add "Listen" button + mic button to `BriefingView.vue`
### Phase 3 — VoiceOverlay for general voice chat
1. Create `VoiceOverlay.vue`
2. Mount in `App.vue` + Space bar hotkey
### Phase 4 — Settings UI
1. Add "Voice" tab to `SettingsView.vue`
---
## Kokoro Voice Reference
| ID | Character |
|---|---|
| `af_heart` | American female, warm (recommended default) |
| `af_bella` | American female, expressive |
| `af_nicole` | American female, breathy/intimate |
| `af_sarah` | American female, clear |
| `af_sky` | American female, bright |
| `am_adam` | American male, neutral |
| `am_michael` | American male, deeper |
| `bf_emma` | British female |
| `bf_isabella` | British female, formal |
| `bm_george` | British male |
| `bm_lewis` | British male, casual |
+216
View File
@@ -0,0 +1,216 @@
# Agentic Briefing — Design Spec
**Date:** 2026-04-10
**Status:** Proposed
**Author:** bvandeusen + Claude
---
## Problem
The current briefing pipeline hallucinates calendar events, tasks, and news items that do not exist in the user's actual data. Observed examples from production:
- A morning briefing asserting "your dentist appointment is still in progress" when no such event existed
- An 8am check-in mentioning "a quick meeting at 10:30 AM" with no backing calendar entry
- A midday check-in inventing "a team huddle at 2:30 PM" and "the Q2 budget draft due by Friday"
- The model calling `search_images` in response to a clarifying question about a fabricated meeting
These are not bugs in data retrieval — the data gathering code (`_gather_internal`, `_gather_external`) returns correct values. They are a structural consequence of how the briefing context is assembled.
## Root cause — the "no receipt" problem
The current `run_compilation` pipeline does this:
1. Python code gathers data (tasks, events, weather, news) from the database
2. Python formats the data into a structured text blob as the user-role message: `TODAY'S EVENTS: ...`, `DUE TODAY: ...`, etc.
3. The LLM is called **once** with `[system_prompt, user_prompt]` and produces prose
4. Only the prose reply is written to the conversation. **The underlying data is never persisted in the conversation history.**
When the user later chats in the briefing conversation, the chat endpoint loads the full conversation history — which contains the model's *prose* from earlier but not the data that prose was derived from. The model's own prior output becomes the only source of "truth" available for follow-up questions. If that prose asserted a fact (real or hallucinated), the model has no way to distinguish it from ground truth when generating the next reply, and it will double down.
Compounding factors:
- **Empty sections are silently omitted.** If `calendar_events` is empty, the user prompt contains no `TODAY'S EVENTS:` line at all. The model has no explicit "zero events" signal — combined with an imperative system prompt ("note calendar events and tasks"), it interprets the silence as "I should mention some" and fabricates.
- **Scheduled slot injections append synthetic turns.** `run_slot_injection` writes a fake `[Midday briefing update]` user message and the assistant reply into the persistent conversation. By evening, the chat history contains three separate briefings, each potentially with errors, all treated as equal-weight context on follow-up.
- **The `search_images` tool is available during briefing chat**, with a negative-instruction description ("Not for factual questions"). Small and mid-sized models frequently ignore negative guidance in tool descriptions and call the tool anyway.
## Solution — agentic briefing (the receipt model)
Replace the one-shot synthesis with a tool-call loop. The briefing is no longer "a text blob synthesized from pre-gathered data." It is **a scheduled agent run**: the LLM is given a system prompt, a curated set of read-only data tools, and a trigger ("generate the morning briefing"). The model must call tools to see what exists. Every tool call and tool result becomes part of the conversation history, where it lives as a permanent, structured receipt.
### Why this fixes the hallucination
When the model calls `list_events(today) → []`, that empty array is now a persistent message in the conversation. On a follow-up question like "what meeting?", the chat endpoint loads the conversation and the model sees its own tool result from the morning showing no events. Answering "you had a meeting at 10:30" would require the model to directly contradict a tool result sitting two messages back — something LLMs are much more reliable at avoiding than contradicting their own prior prose.
In short: **the model cannot fabricate what has a visible receipt proving it does not exist.**
### What the model sees — before vs after
**Before** — the model sees one structured blob and produces prose. The blob is discarded.
```
[system] You are a personal assistant... weave together what matters...
[user] Date: 2026-04-10
WEATHER: Home — partly cloudy, 816°C
DUE TODAY: Fix briefing hallucinations
OVERDUE (2 tasks): ...
[assistant] Morning! Looks like a quiet day with two overdue items...
```
**After** — the model is given tools and iterates to ground truth.
```
[system] You are the user's personal assistant giving their morning briefing.
Use tools to see what's relevant. Only mention things you learned from
tool calls. If a tool returns nothing, say so honestly.
[user] Generate the morning briefing.
[assistant] tool_call: list_tasks(filter="due_today")
[tool] [{"id": 81, "title": "Fix briefing hallucinations", ...}]
[assistant] tool_call: list_tasks(filter="overdue")
[tool] [{"id": 42, ...}, {"id": 43, ...}]
[assistant] tool_call: list_events(today)
[tool] []
[assistant] tool_call: get_weather(location="home")
[tool] {"description": "partly cloudy", "temp_min": 8, "temp_max": 16}
[assistant] tool_call: get_rss_items(max=8)
[tool] [...]
[assistant] Morning! Partly cloudy today, 8 to 16 — nothing on the calendar
so it's a clean run at the desk. Two things to keep in mind...
```
The conversation now contains verifiable receipts: `list_events(today)` returned `[]`, and that result sits in context forever (until pruned). Follow-up questions operate against those receipts.
## Architecture
### Existing infrastructure (reused, not rebuilt)
The codebase already has the agentic primitives — they're used for regular chat:
- **`llm.py::stream_chat_with_tools`** — the streaming tool-use loop that talks to Ollama with a `tools` parameter and yields tool-call chunks
- **`generation_task.py::_stream_with_retry`** — wraps `stream_chat_with_tools` with retry-on-500 behavior for cold-model races
- **`tools.py`** — defines 40+ tool schemas and an execution dispatcher
The briefing bypasses all of this and calls a one-shot `_llm_synthesise` helper. The refactor is mainly "route briefings through the same pipeline regular chat already uses."
### New modules
**`briefing_tools.py`** — a small wrapper exposing a curated read-only subset of `tools.py` for briefing runs. This is an **explicit allowlist**, not a blocklist, so newly-added tools must be opted in:
| Tool | Purpose |
|---|---|
| `list_tasks` (with filter args) | See what's actionable today, overdue, high priority |
| `list_events` (today / upcoming) | Know what's on the calendar |
| `get_weather` | Current/forecast weather |
| `get_rss_items` | Pull news themes filtered by user preferences |
| `list_projects` | Understand project context |
| `search_projects` | Surface active project summaries |
| `list_notes` (recent) | Capture follow-ups from yesterday |
**Explicitly omitted** from the briefing tool set:
- `search_images`, `search_web`, `research_topic`, `read_article` — external search is not a briefing concern, and `search_images` is the source of the "Peter Kyle Science Secretary" image-search bug
- All `create_*`, `update_*`, `delete_*` — briefings are read-only; a scheduled background job must not decide to mutate the user's data on its own
- `set_rag_scope`, `calculate` — not relevant to briefing content
### New briefing function
**`briefing_pipeline.py::run_agentic_briefing(user_id, slot, model, conversation_id)`** replaces `run_compilation`'s body (and eventually `run_slot_injection`). Internally:
1. Build a slot-specific system prompt (see below)
2. Load the curated briefing tools from `briefing_tools.py`
3. Seed messages with `[system, user]` where the user message is a simple trigger like `"Generate the morning briefing."`
4. Enter a tool-call loop (max 8 iterations):
- Call `stream_chat_with_tools`
- If the model returns `tool_calls`, execute them via the existing dispatcher, append tool results, continue
- If the model returns a final assistant message with no pending tool calls, break
5. Return `(final_prose, full_message_list, metadata)`
The full message list is important: it's written to the conversation along with the final prose, so the tool-call receipts become part of the persistent record.
### Slot-specific system prompts
Compilation (full morning briefing):
```
You are the user's personal assistant giving their full morning briefing.
Use the tools available to see what's actually relevant today — tasks due,
overdue items, events on the calendar, weather, news themes, project state
— and weave it into a warm, natural-sounding summary.
Rules:
- Call tools to see the data. Never assert facts you didn't learn from a tool.
- If a tool returns nothing (no events today, no overdue tasks), say so
honestly. Don't fabricate items to fill space.
- Write flowing prose. No markdown, no headers, no bullet points.
- Aim for 610 sentences. Skip topics that have nothing interesting.
- Close on one or two concrete, actionable suggestions.
User profile (for tone and preferences):
{profile_body}
```
Check-ins (midday, afternoon):
```
You are the user's personal assistant giving a brief {slot} check-in.
Use tools to see what's changed since this morning. Focus on progress
and what's still unaddressed.
Rules:
- Call tools to see current state. Never assert facts without tool results.
- If nothing meaningful has changed, say so briefly — don't invent progress.
- 35 sentences, natural prose, no markdown.
```
### Conversation hygiene — removing the fake user messages
The current scheduler appends two fake messages on every slot injection:
```python
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
```
Under the agentic model, we drop the fake `"user"` message entirely. Slot updates become plain assistant messages tagged with `metadata.briefing_slot = "midday"`. The chat endpoint's message loader is updated to filter these when building the LLM context on follow-ups, so a user chatting in the briefing conversation doesn't see three earlier briefings smashed into their history. The UI continues to show them as visible timeline entries.
(This is the Option A filter from the earlier discussion — a small, surgical change compared to migrating briefings to a separate sidecar table. Sidecar storage remains a possible future step.)
### Ollama setup compatibility
The existing Ollama deployment uses non-parallel mode across two GPUs, with a concern about context duplication between cards. This is the correct setup for agentic briefings:
- Each tool-call iteration shares the same KV cache on the same GPU, so appending tool results is cheap
- There is no race where a second iteration could land on a different card with a different cache state
- The trade-off is serialization: a briefing in progress will block a concurrent user chat request until it finishes, but scheduled briefings are rare enough (4× per day) that this is acceptable
If GPU contention becomes a problem later, the right lever is pinning specific models to specific GPUs (e.g., background tasks on GPU 1, interactive models on GPU 0) — not enabling parallelism.
## Cost & trade-offs
- **Latency:** a briefing now makes 57 inference calls (one per tool-call decision plus the final prose) instead of 1. On a local Ollama with a 7B model, expect 1540s per briefing vs the current 510s. Acceptable for a background job; if it becomes painful at the user-facing check-in slots, investigate letting the model batch independent tool calls in a single turn (Ollama supports this).
- **Model requirements:** the default model must be reliable at tool calling. `qwen2.5:7b`, `llama3.1:8b`, `mistral-small-3:24b`, and similar handle it well. Models in the ≤3B class typically fail — they either emit no tool calls and return empty prose, or hallucinate invalid tool arguments. If a user's `default_model` is too small, agentic mode should fall back to legacy mode with a log warning.
- **Context growth:** tool results bloat the conversation. At 8 tool calls per compilation × 4 slots per day, a daily briefing conversation can reach 20-30 KB of JSON-heavy history. Fine for a day; aged briefings should be archived/rolled up after 7 days.
- **Tool subset drift:** someone adds a new mutating tool to `tools.py` and forgets to update the briefing allowlist. Mitigated by the allowlist model — the default for new tools is "not exposed to briefings."
- **Infinite loop safety:** a buggy model could tool-call forever. Hard cap at 8 iterations, log a warning if hit, return whatever prose was last produced (or a fallback message).
## Migration path
Ship incrementally, each step independently reversible:
**PR 1 — Agentic compilation behind a feature flag.** Add `briefing_tools.py`, add `run_agentic_briefing`, add a per-user setting `briefing_mode: "legacy" | "agentic"` (default legacy). Route only the 4am compilation through the new path when the flag is set. Keep slot-injection on the legacy path. Enable the flag on the author's account first, validate output quality over several days, then flip the default for all users. No DB migration required — the setting lives in the existing `settings` table.
**PR 2 — Agentic slot injections + conversation hygiene.** Migrate midday/afternoon check-ins to the same pipeline. Remove the fake `[Midday briefing update]` user-role message; slot updates become plain assistant messages tagged with `metadata.briefing_slot`. Add a chat-message-loader filter that excludes slot-tagged messages from the LLM context on follow-ups (they remain visible in the UI).
**PR 3 — UI polish.** Collapsed tool-call status row in the briefing card ("✓ checked calendar · ✓ looked at tasks · ✓ pulled weather"), expanding to show tool results on click. Tool-result cards (weather, news, task list) rendered inline where useful.
**PR 4 (optional) — Sidecar storage for briefing snapshots.** If the chat-filter approach in PR 2 feels too hacky, migrate briefings out of the conversations table and into a dedicated `briefing_snapshots` table. Frontend renders them as pinned timeline cards separate from chat. Larger refactor; defer until PR 13 prove the approach works.
## Secondary win — tightening the main chat system prompt
The regular chat is already agentic (it uses `stream_chat_with_tools`), but its system prompt does not explicitly require the model to ground factual claims in tool results. The prompt discipline introduced for briefings — *"Never assert facts you didn't learn from a tool. If a tool returns nothing, say so honestly."* — is worth applying to the main chat system prompt in a follow-up PR. The mechanism already works; only the framing needs tightening.
## Out of scope
- The Android Flutter client's failure to render `search_images` tool-result cards. This is a separate rendering gap in the mobile app and does not affect the server-side fix. Tracked separately.
- Re-evaluating the Ollama parallelism setting. The current non-parallel config is correct for this work.
- Replacing the background model for title/summary/observation extraction. That model's role is unrelated to briefings.
+73
View File
@@ -0,0 +1,73 @@
# 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.
+144
View File
@@ -0,0 +1,144 @@
# API Keys and Fable MCP
## API Keys
API keys let external tools access your Fable data without a browser session. Each key is scoped to a single user — it can only access data that user owns or has been shared with them.
### Scopes
| Scope | Permissions |
|-------|-------------|
| `read` | GET endpoints only — list, search, fetch content |
| `write` | Full read + create, update, delete |
Admin-level operations (log access, user management) require a `write`-scoped key from an admin account.
### Creating a Key
1. Go to **Settings → API Keys**
2. Enter a name (e.g. "Claude MCP", "Home Server")
3. Choose scope
4. Click **Generate Key**
5. Copy the key immediately — it is shown only once
After creation you can download:
- **`.env` file** — `FABLE_URL` + `FABLE_API_KEY` ready to paste
- **Claude config JSON** — `mcpServers` block ready to merge into `~/.claude.json`
### Revoking a Key
Click **Revoke** next to the key in the API Keys table and confirm. Revoked keys are deleted immediately.
---
## Fable 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.
### Download
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.
You can also download it directly:
```
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)
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.
```json
{
"mcpServers": {
"fable": {
"type": "stdio",
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:5000",
"FABLE_API_KEY": "your-dev-api-key"
}
}
}
}
```
Note: `.mcp.json` contains an API key and should be added to `.gitignore`.
### Available Tools
| Tool | Description |
|------|-------------|
| `fable_list_notes` | List notes, filter by tag or search text |
| `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
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.
To build the wheel locally:
```bash
cd fable-mcp
pip install build hatchling
python -m build --wheel .
```
+243
View File
@@ -0,0 +1,243 @@
# API Reference
All endpoints require login (session cookie or `Authorization: Bearer <api-key>`) unless marked **(public)**.
## Health
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/health` | Health check **(public)** |
## Auth
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/auth/status` | `{has_users, registration_open, oauth_enabled, local_auth_enabled}` **(public)** |
| POST | `/api/auth/register` | Register new user (first user becomes admin; 403 if registration closed or local auth disabled) |
| POST | `/api/auth/login` | Login with username/password (403 if local auth disabled) |
| POST | `/api/auth/logout` | Clear session |
| GET | `/api/auth/me` | Current user info (includes `has_password: bool`) |
| PUT | `/api/auth/password` | Change password `{current_password, new_password}` |
| PUT | `/api/auth/email` | Change email `{email, password?}` (password required only for local-auth users) |
| POST | `/api/auth/invalidate-sessions` | Bump `session_version` — evicts all other sessions, keeps current alive |
| POST | `/api/auth/forgot-password` | Send password reset email `{email}` |
| POST | `/api/auth/reset-password` | Reset password with token `{token, new_password}` |
| GET | `/api/auth/oauth/login` | Initiate OIDC PKCE flow → redirect to provider |
| GET | `/api/auth/oauth/callback` | OIDC callback — exchange code, find/create user, redirect to `/` |
| GET | `/api/auth/invitation/:token` | Validate invitation token **(public)** |
| POST | `/api/auth/register-with-invite` | Register with token `{token, username, password}` **(public)** |
## Notes
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/notes` | List notes. Params: `q`, `tag`, `sort`, `order`, `limit`, `offset`, `project_id`, `milestone_id`, `parent_id`, `type` (`note`/`task`/`all`) |
| POST | `/api/notes` | Create note `{title, body, tags?, status?, priority?, due_date?, project_id?, milestone_id?, parent_id?}` |
| GET | `/api/notes/tags` | All tags (param: `q` for filter) |
| POST | `/api/notes/suggest-tags` | LLM tag suggestions `{title, body, current_tags?}``{suggested_tags}` |
| POST | `/api/notes/link-suggestions` | Detect note titles as plain text in body `{body, project_id, exclude_note_id}``[{note_id, title, count}]` |
| GET | `/api/notes/by-title` | Resolve note by exact title (param: `title`) |
| POST | `/api/notes/resolve-title` | Get-or-create note by title `{title}` (wikilink click) |
| GET | `/api/notes/:id` | Get single note |
| PUT | `/api/notes/:id` | Full update |
| PATCH | `/api/notes/:id` | Partial update (same fields as PUT) |
| DELETE | `/api/notes/:id` | Delete note |
| POST | `/api/notes/:id/convert-to-task` | Set `status='todo'`, `priority='none'` |
| POST | `/api/notes/:id/convert-to-note` | Clear `status`, `priority`, `due_date` |
| POST | `/api/notes/:id/append-tag` | Add tag `{tag}` → updated note |
| GET | `/api/notes/:id/backlinks` | Notes/tasks with `[[Title]]` references to this note |
| GET | `/api/notes/:id/versions` | List note version history |
| GET | `/api/notes/:id/versions/:vid` | Get a specific version |
| GET | `/api/notes/:id/draft` | Get current AI draft |
| PUT | `/api/notes/:id/draft` | Save AI draft |
| DELETE | `/api/notes/:id/draft` | Delete AI draft |
| POST | `/api/notes/assist` | Launch AI assist generation → 202 `{body, target_section?, instruction, whole_doc?}` |
| GET | `/api/notes/assist/stream` | SSE stream for assist (Last-Event-ID reconnect; events: `chunk`, `done`, `error`) |
## Tasks
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/tasks` | List tasks. Params: `q`, `tag`, `status`, `priority`, `due_before`, `due_after`, `sort`, `order`, `limit`, `offset` |
| POST | `/api/tasks` | Create task (accepts `project` name string → resolved to `project_id`) |
| GET | `/api/tasks/:id` | Get task (includes `parent_title`) |
| PUT | `/api/tasks/:id` | Full update |
| PATCH | `/api/tasks/:id/status` | Quick status update `{status}` |
| DELETE | `/api/tasks/:id` | Delete task |
| GET | `/api/tasks/:id/logs` | List work logs |
| POST | `/api/tasks/:id/logs` | Create log `{content, duration_minutes?}` |
| PATCH | `/api/tasks/:id/logs/:log_id` | Update log |
| DELETE | `/api/tasks/:id/logs/:log_id` | Delete log |
## Projects & Milestones
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects` | List projects (owned + shared) |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/:id` | Get project with `milestone_summary` |
| PATCH | `/api/projects/:id` | Update project |
| DELETE | `/api/projects/:id` | Delete project |
| GET | `/api/projects/:id/notes` | Notes + tasks in this project |
| GET | `/api/projects/:id/milestones` | List milestones |
| POST | `/api/projects/:id/milestones` | Create milestone |
| PATCH | `/api/projects/:id/milestones/:mid` | Update milestone |
| DELETE | `/api/projects/:id/milestones/:mid` | Delete milestone |
## Sharing
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects/:id/shares` | List project shares |
| POST | `/api/projects/:id/shares` | Create project share `{user_id?, group_id?, permission}` |
| PATCH | `/api/projects/:id/shares/:sid` | Update permission |
| DELETE | `/api/projects/:id/shares/:sid` | Remove share |
| GET | `/api/notes/:id/shares` | List note shares |
| POST | `/api/notes/:id/shares` | Create note share |
| PATCH | `/api/notes/:id/shares/:sid` | Update permission |
| DELETE | `/api/notes/:id/shares/:sid` | Remove share |
| GET | `/api/shared-with-me` | All resources shared with the current user |
## Groups
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/groups` | List all groups (admin only) |
| POST | `/api/groups` | Create group `{name, description?}` |
| PATCH | `/api/groups/:id` | Update group |
| DELETE | `/api/groups/:id` | Delete group |
| GET | `/api/groups/:id/members` | List members |
| POST | `/api/groups/:id/members` | Add member `{user_id, role}` |
| PATCH | `/api/groups/:id/members/:uid` | Update member role |
| DELETE | `/api/groups/:id/members/:uid` | Remove member |
## Chat
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/chat/conversations` | List conversations (params: `limit`, `offset`) |
| POST | `/api/chat/conversations` | Create conversation `{title?, model?}` |
| POST | `/api/chat/conversations/bulk-delete` | Delete multiple conversations `{ids: number[]}` |
| GET | `/api/chat/conversations/:id` | Get conversation with all messages |
| PATCH | `/api/chat/conversations/:id` | Update title or model |
| DELETE | `/api/chat/conversations/:id` | Delete conversation (cascades to messages) |
| POST | `/api/chat/conversations/:id/messages` | Start generation → 202. Body: `{content, context_note_id?, include_note_ids?, rag_project_id?, workspace_project_id?, think?}` |
| GET | `/api/chat/conversations/:id/generation/stream` | SSE stream (Last-Event-ID reconnect; events: `context`, `chunk`, `tool_call`, `status`, `done`, `error`) |
| POST | `/api/chat/conversations/:id/generation/cancel` | Cancel active generation |
| POST | `/api/chat/messages/:id/save-as-note` | Save assistant message as note |
| POST | `/api/chat/conversations/:id/summarize` | Summarize conversation → note |
| GET | `/api/chat/status` | Ollama availability + model state `{ollama, model, default_model}` |
| GET | `/api/chat/models` | List installed Ollama models (includes `loaded: bool`, `modified_at`) |
| POST | `/api/chat/models/pull` | Pull model (SSE NDJSON progress) `{model}` |
| POST | `/api/chat/models/delete` | Delete model `{model}` |
| GET | `/api/chat/ps` | Currently loaded (hot) models |
| POST | `/api/chat/warm` | Pre-load model into VRAM `{model}` → 202 |
## Quick Capture
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/quick-capture` | Classify + create item from natural language `{text}``{success, type, message, data}` |
## Search
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/search` | Semantic + keyword search across notes and tasks. Params: `q`, `type` (`note`/`task`/`all`), `limit` |
## Journal
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/journal/config` | Get journal configuration (locations, temp_unit, prep schedule) |
| PUT | `/api/journal/config` | Save journal configuration; live-reschedules the prep job |
| GET | `/api/journal/today` | Get/create today's journal conversation + messages |
| GET | `/api/journal/day/:iso` | Get a specific day's journal conversation (read-only) |
| GET | `/api/journal/days` | List dates with journal content, newest first |
| POST | `/api/journal/trigger-prep` | Force-regenerate today's prep (or `{date}` for a specific day) |
| GET | `/api/journal/weather` | Cached weather rows; auto-refreshes stale rows in the background |
| GET | `/api/journal/weather/current` | Live current conditions for the primary configured location |
| POST | `/api/journal/weather/refresh` | Manual refresh of all configured locations |
| POST | `/api/journal/weather/geocode` | Geocode place name `{query}``{lat, lon, label}` |
| POST | `/api/journal/moments/:id/update` | Update a recorded moment |
| DELETE | `/api/journal/moments/:id` | Delete a moment |
## Settings
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/settings` | All settings as `{key: value}` |
| PUT | `/api/settings` | Update settings `{key: value, ...}` |
| GET | `/api/settings/models` | Installed models + defaults |
| GET | `/api/settings/search` | Proxy SearXNG search (params: `q`) |
## API Keys
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/api-keys` | List user's API keys |
| POST | `/api/api-keys` | Create key `{name, scope}``{key, ...}` (key shown once) |
| DELETE | `/api/api-keys/:id` | Revoke key |
## Fable MCP Distribution
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/fable-mcp/info` | `{available: bool, filename: string\|null}` |
| GET | `/api/fable-mcp/download` | Download wheel file |
## Notifications
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/notifications` | List notifications |
| GET | `/api/notifications/count` | Unread count |
| POST | `/api/notifications/:id/read` | Mark read |
| POST | `/api/notifications/read-all` | Mark all read |
## Push
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/push/vapid-public-key` | VAPID public key for subscription |
| POST | `/api/push/subscribe` | Register push subscription |
| DELETE | `/api/push/subscribe` | Unregister push subscription |
## Images
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/images/:id` | Serve cached image **(no auth required — IDs are opaque SHA-256)** |
## Users
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/users/search` | Search users by username/email prefix (param: `q`, min 2 chars, excludes self) |
## Export
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/export` | Export data. Params: `format=markdown` (ZIP with `.md` + YAML frontmatter) or `format=json` |
## Admin
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/admin/backup` | Export backup (`?scope=user` for own data; full requires admin) |
| POST | `/api/admin/restore` | Restore from JSON backup |
| GET | `/api/admin/users` | List all users |
| DELETE | `/api/admin/users/:id` | Delete user (cannot delete self) |
| GET | `/api/admin/registration` | Get registration open/closed state |
| PUT | `/api/admin/registration` | Toggle registration `{open: bool}` |
| POST | `/api/admin/invitations` | Create invitation `{email}` → sends email |
| GET | `/api/admin/invitations` | List pending invitations |
| DELETE | `/api/admin/invitations/:id` | Revoke invitation |
| GET | `/api/admin/logs` | Log entries. Params: `category`, `user_id`, `search`, `date_from`, `date_to`, `limit`, `offset` |
| GET | `/api/admin/logs/stats` | Log category counts |
| GET | `/api/admin/base-url` | Get base URL setting |
| PUT | `/api/admin/base-url` | Set base URL `{base_url}` |
| GET | `/api/admin/smtp` | Get SMTP config (password masked) |
| PUT | `/api/admin/smtp` | Save SMTP config |
| POST | `/api/admin/smtp/test` | Send test email `{recipient}` |
+382
View File
@@ -0,0 +1,382 @@
# Architecture
## Stack
| Layer | Technology | Notes |
|-------|-----------|-------|
| Frontend | Vue 3 + TypeScript + Vite + Pinia + Vue Router | SPA served from the same container as the API |
| Editor | Tiptap (ProseMirror) with custom slash-command extension | |
| Backend | Python 3.12, Quart (async ASGI) | Serves both API and built frontend static files |
| Database | PostgreSQL 16, SQLAlchemy 2.0 async, Alembic | asyncpg driver |
| LLM | Ollama (local) | Any OpenAI-compatible API also works |
| Search | SearXNG (optional, self-hosted) | Web search + image search |
| Push | Web Push / VAPID (pywebpush 2.x) | |
| Deployment | Docker Compose | Single-container app + separate DB + LLM service |
## High-Level Component Diagram
```
┌─────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────────────┐ ┌────────────┐ │
│ │ fabledassistant │ │ ollama │ │
│ │ ┌────────────────┐ │ │ │ │
│ │ │ Quart Server │ │ │ LLM API │ │
│ │ │ ┌──────────┐ │ │ │ │ │
│ │ │ │ Vue SPA │ │ │ └────────────┘ │
│ │ │ │ (static) │ │ │ ▲ │
│ │ │ └──────────┘ │ │ │ │
│ │ │ ┌──────────┐ │ │ HTTP/REST │
│ │ │ │ /api/* │──┼──┼─────────┘ │
│ │ │ └──────────┘ │ │ │
│ │ │ │ │ │ ┌────────────┐ │
│ │ │ ▼ │ │ │ PostgreSQL │ │
│ │ │ ┌──────────┐ │ │ │ 16 │ │
│ │ │ │ asyncpg │──┼──┼──▶ │ │
│ │ │ └──────────┘ │ │ └────────────┘ │
│ │ └────────────────┘ │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────┘
```
## Project Structure
```
fabledassistant/
├── docker-compose.yml # Development stack
├── docker-compose.prod.yml # Production stack (Docker Swarm)
├── Dockerfile # Multi-stage build (Node → Python)
├── alembic/ # Database migrations
│ └── versions/ # Migration files (idempotent raw SQL)
├── fable-mcp/ # Fable MCP server package
│ └── 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
│ ├── config.py # Config class (reads env vars)
│ ├── auth.py # login_required decorator, session checks
│ ├── models/ # SQLAlchemy models
│ ├── routes/ # API blueprints (one file per resource)
│ ├── services/ # Business logic (access, llm, tools, sharing, …)
│ └── static/ # Built Vue SPA (generated at Docker build time)
└── frontend/
└── src/
├── views/ # Page-level Vue components
├── components/ # Reusable UI components
├── composables/ # Vue composables (autosave, shortcuts, …)
├── stores/ # Pinia stores (auth, chat, notes, notifications, …)
└── api/ # Typed API client (client.ts)
```
## Key Design Decisions
**Single container for frontend + API.** Quart serves the Vue.js production build as static files and exposes the REST API under `/api/`. The SPA is built by Vite during the Docker image build.
**SPA routing via 404 handler.** `app.py` uses `@app.errorhandler(404)` (not a catch-all route) to serve static files or fall back to `index.html`. API routes (`/api/*`) always return JSON 404. This avoids a catch-all `/<path:path>` route intercepting API GETs.
**Unified note/task model.** A task is just a note with task attributes enabled. `status IS NOT NULL` means it's a task. "Convert to task" sets `status='todo'`; "convert to note" clears `status`, `priority`, `due_date`. No separate table, no cascade complexity.
**First-class tag column.** Tags live in a `tags ARRAY[text]` column and are explicitly set by the client — not auto-extracted from body text. Hierarchical tags (`project/webapp`) supported via SQL `unnest + LIKE` prefix matching.
**Background generation architecture.** LLM streaming runs in a detached `asyncio.Task` that writes into an in-memory `GenerationBuffer`. SSE clients tail the buffer and can reconnect mid-stream without data loss. Buffer has a `cancel_event` for user-initiated stop. Completed buffers are cleaned up after 60s grace period. Periodic DB flushes every 5s preserve partial content. Both chat and AI Assist use this architecture.
**SSE over WebSockets for LLM streaming.** SSE clients connect via `GET /api/chat/conversations/:id/generation/stream` with `Last-Event-ID` reconnection support. Frontend uses `fetch()` + `ReadableStream`.
**Context building is server-side.** Backend fetches URL content and searches notes. Frontend sends the message text + optional context note IDs. `build_context()` returns `(messages, context_meta)`; metadata includes auto-found note IDs/titles sent to frontend via a `context` SSE event before streaming begins.
**No blocking long-running operations.** Any slow operation (model pulls, LLM calls, URL fetching) must never block app startup or freeze the UI. Backend uses SSE streaming for incremental responses. Model pulls stream NDJSON progress to the frontend.
**SSRF protection.** `services/llm.py` blocks requests to loopback, private, link-local, reserved, and multicast addresses before fetching. `follow_redirects=False` prevents redirect-based bypasses.
**Session cookie security.** `HttpOnly` and `SameSite=Lax` always set. `Secure` flag controlled by `SECURE_COOKIES` env var.
**Rate limiting.** In-memory sliding-window rate limiter (`rate_limit.py`) applied to auth endpoints: login (10/60s), register (5/300s), forgot-password (5/300s), reset-password (10/60s). Keys are per-IP. `TRUST_PROXY_HEADERS` env var enables `X-Forwarded-For` / `X-Real-IP` when behind a reverse proxy.
**Idempotent migrations.** All Alembic migrations use raw SQL with `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`, and `DO $$ BEGIN CREATE TYPE ... EXCEPTION WHEN duplicate_object` to allow safe re-runs.
## Data Model
### Users
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `username` | text UNIQUE NOT NULL | |
| `email` | text nullable | |
| `password_hash` | text nullable | NULL for OAuth-only accounts |
| `oauth_sub` | text UNIQUE nullable | OIDC subject identifier |
| `role` | text NOT NULL DEFAULT 'user' | First user auto-assigned 'admin' |
| `session_version` | int NOT NULL DEFAULT 1 | Bumped on password change to evict sessions |
| `created_at` | timestamptz | |
### Notes (unified — includes tasks)
| Column | Type | Notes |
|--------|------|-------|
| `id` | int PK | |
| `title` | text | |
| `body` | text | Markdown |
| `tags` | ARRAY[text] | GIN indexed; explicitly set by client |
| `parent_id` | int FK self nullable | Sub-tasks / sub-notes |
| `user_id` | int FK users nullable | CASCADE |
| `project_id` | int FK projects nullable | |
| `milestone_id` | int FK milestones nullable | |
| `status` | text nullable | `todo`/`in_progress`/`done` — non-null = task |
| `priority` | text nullable | `none`/`low`/`medium`/`high` |
| `due_date` | date nullable | |
| `created_at`, `updated_at` | timestamptz | |
Indexes: GIN on `tags`, B-tree on `status`, B-tree on `title`.
### Settings
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`: `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`.
Title auto-generated by LLM on first exchange, re-generated every 10th message.
### Projects / Milestones
`projects`: `id`, `user_id`, `title`, `description`, `goal`, `status` (`active`/`completed`/`archived`), `color`, `auto_summary` (nullable text — LLM-generated summary for `search_projects` scoring), `summary_updated_at` (nullable timestamptz), timestamps.
`milestones`: `id`, `user_id`, `project_id` FK CASCADE, `title`, `description`, `status`, `order_index`, timestamps.
### Sharing & Access
`project_shares`, `note_shares`: each has `shared_with_user_id` OR `shared_with_group_id` (exclusive), `permission` (`viewer`/`editor`/`admin`), `invited_by`.
`groups`, `group_memberships`: platform-wide groups with `member`/`owner` roles.
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.
### Journal-Related Tables
`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.
### API Keys
`api_keys`: `id`, `user_id` FK CASCADE, `prefix` (first 8 chars, displayed in UI), `key_hash` (SHA-256 of full key — full key never stored), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at`.
### App Logs
`category` (`audit`/`usage`/`error`), `user_id` FK nullable (SET NULL on delete), `username` (denormalised), `action`, `endpoint`, `method`, `status_code`, `duration_ms`, `error_type`, `error_message`, `traceback`, `details` JSONB.
## Detailed File Reference
### Backend (`src/fabledassistant/`)
| File | Responsibility |
|------|---------------|
| `app.py` | Quart app factory; SPA via 404 handler; JSON 404/500 for API; request logging; security headers in `after_request` |
| `auth.py` | `login_required`, `admin_required`, `get_current_user_id` — shared `_check_auth()` helper; accepts session cookie or `Authorization: Bearer <key>` |
| `config.py` | All config from env vars + Docker secret file support (`_read_secret`); `SECURE_COOKIES`, `TRUST_PROXY_HEADERS`, `OLLAMA_NUM_CTX`; `oidc_enabled()`, `searxng_enabled()` classmethods |
| `rate_limit.py` | In-memory sliding-window rate limiter (`asyncio.Lock` + `defaultdict`); `is_rate_limited(key, max, window)` |
| `models/note.py` | Unified Note model (notes + tasks) |
| `models/user.py` | `id`, `username`, `email`, `password_hash` (nullable — NULL for OAuth-only), `oauth_sub` (unique nullable), `role`, `session_version` |
| `models/conversation.py` | `Conversation` + `Message` models |
| `models/app_log.py` | `AppLog` with `category`, denormalised `username`, `details` JSONB |
| `models/api_key.py` | `ApiKey`: `id`, `user_id`, `prefix`, `key_hash` (SHA-256), `name`, `scope` (`read`/`write`), `created_at`, `last_used_at` |
| `models/event.py` | Internal events store (`Event` model: `id`, `user_id`, `title`, `description`, `start_dt`, `end_dt`, `all_day`, `location`, `caldav_uid` nullable, `color`, timestamps) |
| `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/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 |
| `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/task_logs.py` | Task work log CRUD (`GET/POST/DELETE /api/tasks/:id/logs`) |
| `routes/projects.py` | Projects CRUD + summary endpoint |
| `routes/milestones.py` | Milestones CRUD under `/api/projects/:id/milestones` |
| `routes/settings.py` | Per-user settings key-value (`GET/PUT /api/settings/:key`) |
| `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/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/push.py` | Web Push subscription subscribe/unsubscribe; VAPID public key |
| `routes/users.py` | User profile; admin user list + delete |
| `routes/images.py` | Serve cached images at `/api/images/<id>` |
| `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/fable_mcp_dist.py` | `GET /api/fable-mcp/info` + `GET /api/fable-mcp/download` — package distribution |
| `routes/quick_capture.py` | `POST /api/quick-capture` — single-shot natural language item creation |
| `routes/search.py` | `GET /api/search` — semantic + keyword hybrid search |
| `services/auth.py` | `create_user`, `authenticate`, user lookups, password reset tokens, invitation tokens |
| `services/oauth.py` | OIDC discovery (cached), PKCE auth URL, code exchange, `find_or_create_oauth_user` |
| `services/api_keys.py` | `generate_key()`, `create_api_key()`, `list_api_keys()`, `revoke_api_key()`, `lookup_key()` (SHA-256 hash lookup) |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation, `stream_chat_with_tools()`, URL fetching, SSRF guard |
| `services/generation_task.py` | `run_generation()` — full chat pipeline: intent routing, tool loop, SSE fan-out, push notification; `run_assist_generation()` |
| ~~`services/intent.py`~~ | Removed — intent routing eliminated; the main model handles all tool routing directly |
| `services/tools/` | LLM tool package — decorator-based registry (`_registry.py`), shared helpers (`_helpers.py`), and one module per domain: `notes.py` (create/update/delete/search/list/read), `tasks.py` (list/log_work), `entities.py` (save_person/save_place/lists), `projects.py`, `calendar.py`, `web.py`, `rag.py`, `profile.py`, `rss.py`, `weather.py`, `utility.py` (calculate). `execute_tool()` and `get_tools_for_user()` are the public API. |
| `services/projects.py` | Project CRUD + `generate_project_summary()` (Ollama, fire-and-forget) + `backfill_project_summaries()` (startup) |
| `services/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes(orphan_only=False)` (pgvector cosine similarity) |
| `services/generation_buffer.py` | In-memory SSE event buffer; `cancel_event`; 60s cleanup; supports both chat (int keys) and assist (string keys) |
| `services/notes.py` | Note CRUD, wikilink resolution, backlink queries, tag management |
| `services/note_versions.py` | Version snapshot on save; restore-from-version; diff metadata |
| `services/note_drafts.py` | Per-user per-note draft persistence (AI Assist pending state) |
| `services/settings.py` | `get_setting()`, `set_setting()`, `get_all_settings()` — key-value per user |
| `services/tag_suggestions.py` | `/api/notes/suggest-tags` — LLM-generated tag suggestions for note body |
| `services/access.py` | Permission resolution for all shared resources |
| `services/sharing.py` | Create/revoke shares; list shares; `get_shared_with_me()` |
| `services/groups.py` | Group CRUD; membership management |
| `services/notifications.py` | Create/read/mark-read in-app notifications |
| `services/task_logs.py` | Append/list/delete work log entries on tasks |
| `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/journal_pipeline.py` | System-prompt builder for journal conversations; calls `build_profile_context()` so the LLM sees the user's profile + learned summary |
| `services/journal_scheduler.py` | APScheduler `BackgroundScheduler`; per-user prep job; live-reschedule via `update_user_schedule()`; catch-up logic for missed runs |
| `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/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 |
| `services/caldav.py` | Optional CalDAV sync — user-configured external server; syncs to/from internal store via `caldav_uid` FK; `is_caldav_configured()` guards tool activation |
| `services/calendar_sync.py` | Dead code — Radicale sync service; was trialled and removed |
| `services/images.py` | `fetch_and_store_image()` — SHA-256 dedup, content-type validation, 5 MB cap |
| `services/backup.py` | `export_full_backup()`, `export_user_backup()`, `restore_full_backup()` (version 2 with ID maps) |
| `services/push.py` | VAPID key auto-generation; `send_push_notification()` fire-and-forget; 410 Gone cleanup |
| `services/logging.py` | `log_audit`, `log_usage`, `log_error`, `start_log_retention_loop` (hourly cleanup) |
| `services/email.py` | SMTP email sending for password reset; reads `SMTP_*` env vars |
| `services/weather.py` | Nominatim geocoding + Open-Meteo forecast + per-user DB cache |
| `services/rss.py` | feedparser fetch; per-feed DB cache; prune-to-100 items |
| `services/assist.py` | `build_assist_messages()` — section/whole-doc modes; project context injection |
### Frontend (`frontend/src/`)
| File | Responsibility |
|------|---------------|
| `App.vue` | App shell (`100dvh` flex column); global keyboard shortcuts (`g`+key nav, `n/t/c/e///?`); starts status polling + loads settings on mount |
| `api/client.ts` | `ApiError`, `apiGet/Post/Put/Patch/Delete`, `apiSSEStream` (fetch + ReadableStream), auto 401→login redirect |
| `stores/auth.ts` | User, `isAuthenticated`, `isAdmin`, `oauthEnabled`, `localAuthEnabled`, login/register/logout/checkAuth |
| `stores/chat.ts` | Conversation CRUD; `sendMessage()` SSE streaming; `reconnectIfGenerating()`; message queue (localStorage); `streamingStatus` |
| `stores/notes.ts` | CRUD + tag filter; `resolveTitle`; `convertToTask/Note`; `fetchBacklinks`; `fetchAllTags` |
| `stores/tasks.ts` | CRUD + status/priority filter; `patchStatus` |
| `stores/settings.ts` | `assistantName`, `defaultModel`, `installedModels`; `pullModel()`, `deleteModel()` |
| `stores/push.ts` | `isSupported`, `permission`, `isSubscribed`, `subscribe/unsubscribe` |
| `stores/notifications.ts` | `count`, `items`, `fetchCount`, `fetchAll`, `markRead`, `markAll` |
| `views/HomeView.vue` | Chat-first dashboard; 6 task sections (Overdue → Other); 8 recent notes; inline streaming response |
| `views/ChatView.vue` | `/chat` — SSE streaming; context sidebar (Suggested/In Context); note picker; scope chip (RAG project scope pill above input); message queue; bulk delete |
| `views/CalendarView.vue` | `/calendar` — FullCalendar v6 month/week/day views; click to create event; click event to open EventSlideOver |
| `components/EventSlideOver.vue` | Reusable slide-over for event create/edit/delete; used in ToolCallCard, HomeView, CalendarView |
| `views/WorkspaceView.vue` | `/workspace/:id` — 3-panel (tasks/chat/notes); SSE tool-call watcher; conv persisted to localStorage |
| `views/GraphView.vue` | `/graph` — D3 force-directed; tag/note/project-hub nodes; physics panel; peek panel |
| `views/ProjectView.vue` | Kanban grouped by milestone; advance buttons; milestone management |
| `views/SettingsView.vue` | Tabbed settings (11 tabs); tab state in localStorage |
| `views/NoteEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; diff view; version history; autosave |
| `views/TaskEditorView.vue` | Tiptap editor; 2-column layout; AI assist panel; task log section; sub-tasks |
| `components/ToolCallCard.vue` | Tool call results; `requires_confirmation` inline confirm/deny; direct POST on "Create anyway" |
| `components/ChatMessage.vue` | Message bubble; markdown rendering; tool call cards; "Save as Note" |
| `components/TagInput.vue` | Chip-based tag input; Enter/comma to confirm; autocomplete from `/api/notes/tags` |
| `components/TiptapEditor.vue` | Tiptap wrapper; markdown↔HTML round-trip; selection change emit; WikilinkDecoration; TagDecoration |
| `components/ShareDialog.vue` | `<Teleport>` modal; user tab + group tab; current shares list |
| `components/WorkspaceTaskPanel.vue` | Milestone-grouped task list; detail slide-over |
| `components/WorkspaceNoteEditor.vue` | List ↔ TipTap editor with autosave |
| `composables/useAssist.ts` | AI assist: section parsing, SSE streaming, accept/reject, LCS diff, persistent draft |
| `composables/useAutoSave.ts` | Interval-based autosave (5 min) with dirty/saving guards |
| `composables/useEditorGuards.ts` | Ctrl+S, `beforeunload` warning, route-leave confirm |
| `composables/useTagSuggestions.ts` | `/api/notes/suggest-tags` call + suggestion state |
| `composables/useListKeyboardNavigation.ts` | j/k/Enter list navigation; focus-in-input guard |
| `extensions/WikilinkDecoration.ts` | ProseMirror decoration plugin highlighting `[[wikilinks]]` |
| `extensions/TagDecoration.ts` | ProseMirror decoration plugin highlighting `#tags` |
| `extensions/WikilinkSuggestion.ts` | `@tiptap/suggestion` extension for `[[` autocomplete |
## Key Services
| Service | Responsibility |
|---------|---------------|
| `services/access.py` | Permission resolution for all shared resources |
| `services/llm.py` | `build_context()`, RAG injection, history summarisation |
| `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/embeddings.py` | `upsert_note_embedding()`, `semantic_search_notes()` |
| `services/journal_prep.py` | Data gather → LLM prose opener; persisted into today's journal conversation |
| `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/weather.py` | Nominatim geocoding + Open-Meteo forecast fetch + DB cache |
| `services/rss.py` | feedparser-based fetch, per-feed DB cache, prune-to-100 |
## Authentication
**Session cookies**`HttpOnly`, `SameSite=Lax`, optionally `Secure` (`SECURE_COOKIES` env var). Session includes `session_version`; mismatch with DB value (after password change) results in 401 and session clear.
**Bearer token / API keys**`Authorization: Bearer <key>` accepted by `_check_auth()` as an alternative to session cookies. The raw key is SHA-256 hashed and looked up via `services/api_keys.py`. Keys with `scope=read` are rejected on non-safe methods (`POST`/`PATCH`/`DELETE`). Used by Fable MCP and any external API consumers.
**Local auth + OIDC/OAuth (PKCE)**`services/oauth.py` handles discovery and `find_or_create_oauth_user`. On OAuth login: checks existing `oauth_sub` → matching email → creates new user.
See [sso-oauth.md](sso-oauth.md) for provider-specific setup instructions.
## LLM Pipeline Internals
### Tool Routing
No separate intent router — the main model handles all tool routing directly via Ollama's structured tool-calling output. The model receives the full tool schema list and decides whether to call a tool or respond conversationally. Extended reasoning (`think=True`) is always on for qwen3-class models: content-based gating was tried but exposed tool-call template fragility on short tool-intent prompts.
### Tool Loop
Multi-round tool loop (max 5 rounds). All implementations in `services/tools/` (decorator-based registry); `execute_tool(user_id, tool_name, arguments, conv_id=None, workspace_project_id=None)` is the dispatcher. `conv_id` and `workspace_project_id` are threaded in from `run_generation()` so tools like `set_rag_scope` can write to the current conversation.
**Unified `create_note` tool** — creates both notes and tasks. Setting `status` (e.g. `"todo"`) creates a task; omitting it creates a knowledge note. All task fields (due_date, priority, milestone, parent_task, recurrence_rule) are available on the single tool.
**Duplicate protection on `create_note`:**
1. Exact title match (case-insensitive) → hard block, redirect to `update_note`
2. Fuzzy title match (SequenceMatcher ≥ 82%; punctuation stripped before candidate search) → hard block
3. Semantic content similarity (threshold 0.90, body ≥ 80 chars) → soft block with `requires_confirmation: true`
**Project resolution** (`_helpers.resolve_project`): 4-step lookup — (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55.
### Context Window and Summarisation
`OLLAMA_NUM_CTX` (default 16384) controls the context window for all generation calls. Intent classification always uses `num_ctx=4096` to reduce VRAM pressure.
History summarisation threshold: 30 messages. Keeps 8 recent messages. Summary max 400 tokens.
### Web Research Pipeline
`services/research.py` implements a full autonomous research pipeline:
1. Intent model generates 5 focused sub-queries
2. All 5 SearXNG queries run in parallel (200ms stagger to avoid rate limiter)
3. Up to 15 unique URLs fetched in parallel
4. LLM generates an outline (28 sections with title + focus)
5. Each section synthesised in parallel from relevant sources
6. Executive summary generated from all section content
7. Index note created with executive summary + links to section notes; section notes linked back via `parent_id`
8. Falls back to single-note synthesis if outline generation fails
SearXNG tip: add the app server IP to `botdetection.ip_lists.pass_ip` in SearXNG `settings.yml` to bypass the rate limiter for trusted backend requests.
### Image Cache
`search_images` tool fetches images server-side via SearXNG, stores them on disk (SHA-256 dedup, content-type validation, 5 MB cap), and serves from `/api/images/<id>`. The user's browser never contacts the original image host.
Config: `IMAGE_CACHE_DIR` (default `/data/images`), `IMAGE_MAX_BYTES` (default 5 MB).
## RAG Pipeline
1. `semantic_search_notes()` — cosine similarity via pgvector, threshold configurable per call; accepts `orphan_only` and `project_id` scope flags.
2. Notes ≥ 0.60 similarity auto-injected into system prompt (up to 3, 800 chars each).
3. Notes 0.450.60 surfaced in chat sidebar as "Suggested" (user clicks to include).
4. Explicitly included notes delivered full-body.
5. `excluded_note_ids` prevents the current note from being injected as its own context.
### RAG Scope (three-value system)
`conversations.rag_project_id` controls which notes are eligible for retrieval:
| Value | Behaviour |
|-------|-----------|
| `NULL` (default) | Orphan notes only — notes with `project_id IS NULL` |
| `-1` | All notes — opt-in to global search |
| Positive int | That project's notes only |
`build_context()` derives `orphan_only` + `effective_project_id` from this value before calling both the semantic and keyword search paths.
**Scope tools** — Two LLM tools let the model discover and switch scope mid-conversation:
- `search_projects` — SequenceMatcher scoring over title + description + `auto_summary`; returns top 5 matching projects.
- `set_rag_scope` — persists the new `rag_project_id` to DB immediately; blocked in workspace view; causes the SSE `done` event to include `new_rag_scope` + `new_rag_scope_label` so the frontend chip updates reactively.
**Project summaries**`generate_project_summary()` calls Ollama (fire-and-forget) and stores the result in `projects.auto_summary`. Triggered on project update and note saves (debounced 1h). `backfill_project_summaries()` runs at startup.
Embedding model: `nomic-embed-text` via Ollama. Backfill runs 30s after startup (background task).
+155
View File
@@ -0,0 +1,155 @@
# Configuration
Configuration is via environment variables. The `docker-compose.yml` file sets defaults for local development; override them in a `.env` file (gitignored).
## Environment Variables
### Core
| Variable | Default | Description |
|----------|---------|-------------|
| `DATABASE_URL` | `postgresql+asyncpg://fabled:fabled@db/fabledassistant` | PostgreSQL async connection string |
| `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`) |
| `LOG_LEVEL` | `INFO` | Logging verbosity (`DEBUG`, `INFO`, `WARNING`, `ERROR`) |
| `SECURE_COOKIES` | `false` | Set `true` when running behind TLS |
| `BASE_URL` | — | Public URL (e.g. `https://notes.example.com`) — required for OIDC redirect URIs and email links |
### LLM / Ollama
| Variable | Default | Description |
|----------|---------|-------------|
| `OLLAMA_URL` | `http://ollama:11434` | Ollama API base URL |
| `OLLAMA_MODEL` | `llama3.2` | Default LLM model (used as fallback; per-user setting overrides this) |
| `EMBEDDING_MODEL` | `nomic-embed-text` | Model used for semantic search / RAG embeddings |
| `OLLAMA_NUM_CTX` | `16384` | Context window size passed to Ollama for all generation calls |
### Authentication / OIDC
| Variable | Default | Description |
|----------|---------|-------------|
| `LOCAL_AUTH_ENABLED` | `true` | Set `false` to disable local username/password login (SSO-only mode) |
| `OIDC_ISSUER` | — | OIDC issuer URL (e.g. `https://auth.example.com/application/o/fabled/`) |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `OIDC_CLIENT_SECRET_FILE` | — | Docker secret file alternative to `OIDC_CLIENT_SECRET` |
| `OIDC_SCOPES` | `openid profile email` | Space-separated OIDC scopes to request |
See [sso-oauth.md](sso-oauth.md) for provider-specific setup.
### Security / Proxy
| Variable | Default | Description |
|----------|---------|-------------|
| `TRUST_PROXY_HEADERS` | `false` | Set `true` when behind a trusted reverse proxy to read real IP from `X-Forwarded-For` / `X-Real-IP` |
### Web Search / Images
| Variable | Default | Description |
|----------|---------|-------------|
| `SEARXNG_URL` | — | SearXNG base URL for web search and image search tools |
| `IMAGE_CACHE_DIR` | `/data/images` | Directory for cached search images |
| `IMAGE_MAX_BYTES` | `5242880` (5 MB) | Maximum size for cached images |
### Data / Logging
| Variable | Default | Description |
|----------|---------|-------------|
| `LOG_RETENTION_DAYS` | `90` | Days to keep app logs before automatic pruning |
| `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
### Development (`docker-compose.yml`)
```bash
# Copy the example env file
cp .env.example .env
# Edit .env to set a real SECRET_KEY
# Start the stack
docker compose up --build
# App available at http://localhost:5000
```
The first user to register becomes admin. Registration auto-closes after that (re-enable from Settings → Users).
### Production (`docker-compose.prod.yml`)
The production compose file adds:
- Docker Secrets for `SECRET_KEY_FILE` and `DATABASE_URL_FILE`
- Network isolation (internal bridge for DB + Ollama; only the app is externally accessible)
- Health checks and resource limits
```bash
# Create Docker secrets
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 -
# Deploy
docker stack deploy -c docker-compose.prod.yml fabled
```
## Production Deployment
### Reverse Proxy (Required)
Fabled Assistant does **not** handle SSL/TLS. Run it behind a reverse proxy:
- **Nginx**, **Traefik**, or **Caddy** in front of the app container
- Terminate TLS at the proxy; forward to port 5000
- **Do not expose port 5000 directly to the internet**
- Rate-limit auth endpoints: ≤ 5 req/min per IP on `/api/auth/login` and `/api/auth/register`
Example Nginx location block:
```nginx
location / {
proxy_pass http://127.0.0.1:5000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Required for SSE streaming
proxy_buffering off;
proxy_read_timeout 600s;
proxy_send_timeout 600s;
}
```
If using `TRUST_PROXY_HEADERS=true`, ensure your proxy strips any client-supplied `X-Forwarded-For` headers before adding its own.
### Security Checklist
- **Strong `SECRET_KEY`** — generate with:
```bash
python3 -c "import secrets; print(secrets.token_hex(32))"
```
Or use Docker Secrets via `SECRET_KEY_FILE`.
- **`SECURE_COOKIES=true`** — must be set when running behind TLS.
- **`BASE_URL`** — set to your public URL; required for OIDC redirects and email links.
- **Registration** — auto-closes after the first user (admin). Re-enable from Settings → Users or send invite links.
- **Session invalidation** — changing or resetting a password bumps `session_version`, evicting all other active sessions. Button also available in Settings → Account.
- **Keep Ollama on an internal network** — both compose files keep Ollama off the host network. Never expose the Ollama port publicly.
- **Default SECRET_KEY warning** — the app logs a `WARNING` on startup if the default dev key is in use.
## Model Management
Models are managed through Settings → General → Model Management in the web UI. You can:
- Pull new models by name (streams progress via SSE)
- View installed models with size and loaded/unloaded state
- Delete unused models
The app auto-warms user-preferred models on startup (only models already installed — never auto-pulls). The embedding model (`nomic-embed-text`) is auto-pulled on startup if missing.
Recommended models for 2× 8 GB GPU:
- **`qwen3:8b`** — strong reasoning + tools, fits in 8 GB, supports thinking mode
- **`qwen2.5:7b`** — fast, tool-capable, smaller context
- **`llama3.1:8b`** — reliable baseline, widely tested with tools
+584
View File
@@ -0,0 +1,584 @@
# 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.
### Flutter app port — shipped 2026-04-28
The companion mobile app (`fabled_app` / FabledApp repo) tracks the same design system. Two commits:
- **Foundation port** — `0f05f47`. `lib/core/theme.dart` rewritten with the Obsidian/Iron/Pewter dark palette, warm parchment light palette, dusty violet `#5B4A8A` primary. Inter loaded for body, JetBrains Mono available at call sites, Fraunces for headlines ≥18px. New `ActionColors` ThemeExtension exposes Moss/Bronze/Oxblood/Pewter outside the `ColorScheme` (Material's primary/secondary/tertiary slots all carry brand accent, so action tokens need their own home). `GradientButton` recolored to dusty-violet gradient.
- **Surface phase** — `b9e68e3`. `lucide_icons ^0.257.0` installed; 107 `Icons.*` references across 21 files swapped to `LucideIcons.*`. Input border radius 24 → 8 in both themes. ChatMessageBubble Illuminated Transcript fixes — neutral border on user bubbles, `surface`/Iron bg on assistant bubbles, asymmetric corner restoration (only bottom-left clipped, not both left corners), accent-tinted glow shadow added. 5 destructive confirm buttons across notes / tasks / chat / calendar wired to `ActionColors.destructive`. Calendar event Save wired to `ActionColors.primary` as the reference Moss site. 4 hardcoded indigo Color literals → dusty-violet equivalents.
The Flutter port doesn't decompose into 7 PRs the way web did because Flutter's centralized `theme.dart` means most palette/font work happens in one file. Per-screen Save / Cancel reclassification beyond the calendar event Save is opportunistic — the wiring pattern (`Theme.of(context).extension<ActionColors>()!.primary`) is established and applied incrementally as files are touched.
Pattern reference for downstream screens: see `lib/screens/calendar/event_form_sheet.dart` for `ActionColors.primary` usage on Save buttons; see the dialog spots in `note_edit_screen.dart` / `task_edit_screen.dart` / `note_detail_screen.dart` / `conversations_tab_screen.dart` for `ActionColors.destructive` on confirm-Delete buttons.
### Open threads
*New threads will accumulate here as gaps surface in real use.*
+173
View File
@@ -0,0 +1,173 @@
# Development
## Workflow
All development is Docker-based. Do not install Python or Node dependencies locally.
```bash
# Start the full stack (app + PostgreSQL + Ollama)
docker compose up --build
# Rebuild after backend changes (frontend changes require rebuild too)
docker compose up --build app
# Reset everything (wipes database)
docker compose down -v && docker compose up --build
# Run checks (lint, format, typecheck, tests)
make check
# Individual checks
make lint # ruff check src/
make fmt # ruff format src/
make typecheck # vue-tsc --noEmit
make test # pytest tests/
```
## Frontend Hot Reload
The Docker setup does not include Vite's hot-reload dev server. After frontend changes, rebuild the image. For faster iteration during active frontend work, you can run Vite locally:
```bash
cd frontend
npm install
npm run dev # Vite dev server at http://localhost:5173
```
Point the Vite dev server at the backend by setting `VITE_API_BASE_URL=http://localhost:5000` (or configure `vite.config.ts` proxy).
## Database Migrations
Alembic migrations run automatically on container startup (`alembic upgrade head` in the `CMD`).
To create a new migration:
```bash
# Inside the running app container
docker compose exec app alembic revision -m "description_of_change"
# Edit the generated file in alembic/versions/
```
Migration conventions:
- Use raw SQL with `IF NOT EXISTS` guards for idempotency
- Use `DO $$ BEGIN CREATE TYPE … EXCEPTION WHEN duplicate_object THEN NULL; END $$` for enum types
- Number migrations sequentially (e.g. `0027_add_something.py`)
- Always provide both `upgrade()` and `downgrade()`
## CI/CD
### Pipeline
CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
| Trigger | Jobs | Docker tags pushed |
|---------|------|--------------------|
| Push to `dev` | typecheck + lint + test → build | `:dev`, `:<sha>` |
| Tag `v*` on `main` | typecheck + lint + test → build | `:latest`, `:<version>`, `:<sha>` |
| Push to `main` | typecheck + lint + test | (no build) |
### Release Process
1. Work on `dev` branch — CI validates on every push
2. When ready, open a PR from `dev``main` in Forgejo
3. Merge the PR
4. Create a release via the Forgejo UI on `main` with a `v*` tag (e.g. `v26.03.23.1` — CalVer: `YY.MM.DD.N`)
5. The tag push triggers CI → build job pushes `:latest` + `:<version>` Docker images
6. After merging to main, sync dev back:
```bash
git checkout dev && git merge main && git push origin dev
```
### Custom Runner
Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS).
Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`).
Runner compose: `infra/runner-compose.yml`.
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.
### Docker Registry
Images pushed to: `git.fabledsword.com/bvandeusen/fabledassistant`
Cache tag: `:cache` (reduces build time ~80%)
Required secrets (repo → Settings → Secrets → Actions):
- `REGISTRY_USER` — Forgejo username
- `REGISTRY_TOKEN` — Forgejo PAT with `write:packages` scope
## Migration Chain
Current migration sequence (all idempotent raw SQL):
```
0001 create_notes_table
0002 create_tasks_table
0003 task_note_companion (data migration)
0004 merge_tasks_into_notes
0005 add_chat_tables
0006 add_settings_table
0007 add_title_and_updated_at_indexes
0008 add_users_and_user_id
0009 add_message_status
0010 add_app_logs_table
0011 add_password_reset_tokens
0012 add_invitation_tokens
0013 add_tool_calls_to_messages
0014 add_note_embeddings
0015 add_oauth_fields
0016 add_image_cache
0017 add_projects
0018 add_push_subscriptions
0019 add_events (dead code — internal CalDAV/Radicale table; Radicale was removed)
0020 add_milestones
0021 add_task_logs
0022 add_note_versions_and_drafts
0023 add_tags_to_note_versions
0024 add_session_version
0025 add_sharing_and_notifications
0026 add_briefing_tables
0027 add_api_keys
```
**Important:** Do NOT use `op.create_table()` or `sa.Enum()` — SQLAlchemy's event system can fire `CREATE TYPE` even with `create_type=False`, causing failures on re-run. Always use raw SQL with `IF NOT EXISTS` / `DO $$ BEGIN ... EXCEPTION WHEN duplicate_object` guards.
## Project Conventions
### Backend
- Services: `async with async_session() as session:` — import from `fabledassistant.models`
- No `fabledassistant.database` module
- Blueprint per resource: `routes/notes.py`, `routes/tasks.py`, etc.
- All business logic in `services/`; routes are thin wrappers
- Permission checks via `services/access.py` — never inline ownership checks in routes
### Frontend
- API calls via `frontend/src/api/client.ts` typed helpers (`apiGet`, `apiPost`, `apiPatch`, `apiDelete`)
- Pinia stores for shared state; local `ref()` for component-only state
- Composables in `composables/` for reusable behaviour (autosave, keyboard nav, tag suggestions, …)
- Views are page-level components in `views/`; reusable UI in `components/`
### Commit Style
```
type(scope): short description
Longer body if needed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
```
Types: `feat`, `fix`, `refactor`, `docs`, `chore`, `test`
Scopes: feature area (e.g. `chat`, `briefing`, `fable-mcp`, `notes`)
## Testing
```bash
# Run all tests
make test
# Run specific test file
docker compose exec app /opt/venv/bin/pytest tests/test_auth.py -v
```
Tests are in `tests/`. They run against a real PostgreSQL instance in CI (not mocked). Keep tests integration-style where possible — mock failures have historically masked real migration bugs.
+157
View File
@@ -0,0 +1,157 @@
# Features
## Notes
Write in Markdown with a live-preview editor (Tiptap/ProseMirror). Headings, bold, italic, lists, code blocks, and task checklists render inline. A slash-command menu (`/`) inserts common blocks.
**Wikilinks** — Link notes with `[[Title]]` or `[[Title|Display Text]]` syntax. Clicking a wikilink navigates to (or auto-creates) the referenced note. The editor suggests existing note titles as candidate links while typing `[[`. Backlinks appear in the note viewer sidebar.
**Tags** — First-class `ARRAY[text]` column. Tag autocomplete in the editor sidebar suggests existing tags. Hierarchical tags (`project/webapp`) supported — filtering by `project` matches all `project/*` children. Tags are browsable via the knowledge graph.
**Version history** — Every body edit snapshots a version (up to 20 per note). Browse and restore from the editor's History panel. Diff view shows changes against the current body.
**AI writing assist** — Select a passage or work on the full document. Give an instruction ("make this more concise", "add examples"). The assistant streams a proposal; a diff view shows changes to accept or reject. Drafts persist across page loads.
**Link suggestions** — The editor detects note titles appearing as plain text in the body and suggests converting them to wikilinks.
## Tasks
Tasks carry status (`todo``in_progress``done`), priority (`none`/`low`/`medium`/`high`), due date, milestone assignment, and a parent task (sub-tasks).
**Task work logs** — Append progress log entries to a task with optional duration. Time tracking is visible in the task editor sidebar.
**Sub-tasks** — Any task can have child tasks via `parent_id`. The task viewer shows sub-tasks inline.
**Convert freely** — Convert a note to a task (sets `status=todo`) or a task back to a note from the viewer toolbar.
## Projects and Milestones
**Projects** — Group related notes and tasks. Each project has a title, description, goal, status (`active`/`completed`/`archived`), and a colour.
**Milestones** — Ordered stages within a project. Tasks are assigned to milestones. Milestone completion percentage shown on the project page.
**Kanban view**`/projects/:id` groups tasks by milestone in a kanban-style column layout with status-advance buttons directly on cards (→ advance, ✓ complete).
**Project Workspace**`/workspace/:projectId` opens a three-panel environment (tasks / chat / notes) locked to a project. The AI assistant creates and updates content directly in the workspace; new notes auto-load in the editor and the task list refreshes automatically after tool calls.
## Knowledge Graph
`/graph` renders all notes, tasks, and tags as a D3 force-directed graph. Tag nodes cluster notes that share tags; invisible project hub nodes attract project members. Physics controls: repulsion, link distance, link strength, hub pull, gravity. Click any node to open a slide-in peek panel. Click a tag node to filter the notes list.
## AI Chat
Full conversation history with SSE streaming. Features:
- **RAG** — Semantically relevant notes (≥ 0.60 cosine similarity) auto-injected as context. Notes 0.450.60 shown in sidebar as "Suggested."
- **Attach notes** — Paperclip icon to include specific notes in context.
- **RAG scope chip** — Pill above the input bar shows the current note scope. Click to switch: "Orphan notes only" (default — project notes stay out of general chat), any active project, or "All notes." Scope is persisted per conversation. The AI can also call `search_projects` and `set_rag_scope` mid-conversation to switch scope automatically; the chip pulses when this happens.
- **Tool calls** — The assistant can create/update notes, tasks, projects, milestones, search the web, check weather, read RSS, query calendar events, and more. Tool calls display inline with confirm/deny for creates.
- **Thinking mode** — Toggle extended reasoning for complex questions.
- **Abort** — Stop button cancels in-flight generation.
- **Message queue** — Messages sent while generation is in progress are queued and drained sequentially.
- **Save to note** — Save any assistant reply directly as a note.
- **Bulk delete** — Select and delete multiple conversations.
- **Retention** — Conversations auto-pruned after configurable days (default 90).
## Daily Journal
`/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** — 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.
**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.
**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. Up to two named locations (home, work). Cached rows auto-refresh in the background when the journal page loads.
**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
The assistant can search the web (SearXNG) and fetch pages, synthesising findings into a structured multi-note research output: an index note with an executive summary and links to focused section notes. Each section covers a distinct aspect of the topic with cited sources. Falls back to a single note when outline generation fails. A lightweight `search_web` tool answers quick questions inline without saving. Requires `SEARXNG_URL` to be configured.
## Calendar
`/calendar` shows a full FullCalendar view (month, week, day). Click an empty slot to create an event; click an existing event to edit or delete it via a slide-over panel.
**Internal events store** — Events are stored in the app database (`events` table), making them available without any external calendar. Fields: title, description, start/end datetime, all-day toggle, location, colour.
**AI tools**`create_event`, `list_events`, `search_events`, `update_event`, `delete_event` all operate on the internal store. Tool-call result cards in chat are clickable and open the same EventSlideOver for editing.
**HomeView widget** — The dashboard shows today's and the next 7 days' events as clickable cards above the hero project.
**CalDAV sync (optional)** — Connect an external CalDAV server (Nextcloud, Radicale, etc.) in Settings → Integrations. Events sync bidirectionally via a `caldav_uid` field.
## Sharing and Collaboration
**Share** — Share any project or note/task with users or groups at `viewer`/`editor`/`admin` permission levels. Share button in the viewer/project toolbar opens a dialog.
**Groups** — Admins create platform-wide groups and assign users `member`/`owner` roles. Share a resource with a group in one action.
**Shared with me**`/shared` lists all incoming shared projects and notes with permission badges.
**Notifications** — Bell icon in nav shows unread count (60s polling). Notifications generated for: project shared, note shared, added to group. Click navigates to the resource.
**Push notifications** — Web Push (VAPID) notifies when AI generation completes, even in another tab. Works over HTTPS only. Configurable per-user.
## Quick Capture
Quick capture from the Android app routes to the intent classifier. It creates notes, tasks, or projects based on content — using the user's configured model, not the hardcoded default.
## Data Export and Backup
- **Personal export** — Settings → Data: download all notes/tasks as a Markdown ZIP (with YAML frontmatter) or JSON array.
- **Admin backup** — Full application backup (version 2): includes projects, milestones, task logs, AI drafts, note versions, push subscriptions. ID remapping on restore for cross-instance migration.
## PWA
Installable as a desktop or mobile app. Service worker caches the shell; push notifications are suppressed when the relevant tab is already focused. Works over HTTPS only in Firefox.
## Settings
Settings are tabbed:
| Tab | Contents |
|-----|----------|
| General | Assistant name, default model, model management (pull/delete) |
| Account | Email change, password change, session invalidation |
| 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 |
| Data | Personal export, backup/restore (admin) |
| API Keys | Create/revoke API keys, Fable MCP download and install |
| Config (admin) | Base URL, SMTP, OIDC settings |
| Users (admin) | User list, invite links, registration toggle |
| Logs (admin) | Error, audit, and usage logs with search |
| Groups (admin) | Create/manage groups and membership |
## Roadmap
- Email integration (read/send via IMAP/SMTP tools in chat)
- Session invalidation on user deletion
- Flutter push notifications (requires FCM/APNs — separate from web VAPID)
- Flutter milestone support in project view
## Keyboard Shortcuts
| Key | Action |
|-----|--------|
| `g` + `h` | Go to Home |
| `g` + `n` | Go to Notes |
| `g` + `t` | Go to Tasks |
| `g` + `p` | Go to Projects |
| `g` + `c` | Go to Chat |
| `g` (bare) | Go to Graph |
| `n` | New note |
| `t` | New task |
| `c` | Focus chat input |
| `e` | Edit current item |
| `/` | Search |
| `?` | Show shortcuts panel |
| `j` / `k` | Navigate list items |
| `Enter` | Open selected item |
| `Escape` | Close panel / blur / go home (progressive) |
| `Ctrl+S` | Save in editor |
-538
View File
@@ -1,538 +0,0 @@
# Backup Service Rewrite Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Rewrite `services/backup.py` so that full and user backup/restore correctly includes every model added since the original implementation (projects, milestones, task logs, drafts, versions), with correct FK re-mapping on restore.
**Architecture:** Bump backup JSON format to version 2. Export is additive — all new tables exported. Restore builds an ID map for each table and patches FK references in the correct dependency order. V1 backups continue to restore via the existing code path.
**Tech Stack:** Python/SQLAlchemy 2.0 async, no new dependencies.
**Spec:** `docs/superpowers/specs/2026-03-11-backup-rewrite-design.md`
**Dependency note:** This plan can be split into Part A (pre-sharing models) and Part B (sharing models). Part A is independent and should be done first.
---
## Task 1: Extend export — full backup
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Read the current `export_full_backup()` function**
Understand what is currently exported: users, notes (partial fields), conversations+messages, settings.
- [ ] **Step 2: Rewrite `export_full_backup()` to version 2**
Add all missing models to the import list at the top of `backup.py`:
```python
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.task_log import TaskLog
from fabledassistant.models.note_draft import NoteDraft
from fabledassistant.models.note_version import NoteVersion
from fabledassistant.models.push_subscription import PushSubscription
```
Rewrite `export_full_backup()`:
```python
async def export_full_backup() -> dict:
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
projects = (await session.execute(select(Project))).scalars().all()
milestones = (await session.execute(select(Milestone))).scalars().all()
notes = (await session.execute(select(Note))).scalars().all()
task_logs = (await session.execute(select(TaskLog))).scalars().all()
note_drafts = (await session.execute(select(NoteDraft))).scalars().all()
note_versions = (await session.execute(select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.version_number))).scalars().all()
conversations = (await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
return {
"version": 2,
"scope": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": (
"This backup contains hashed passwords and push subscription keys. "
"Store it securely and restrict access."
),
"users": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"password_hash": u.password_hash,
"oauth_sub": u.oauth_sub,
"role": u.role,
"session_version": u.session_version,
"created_at": u.created_at.isoformat(),
}
for u in users
],
"projects": [
{
"id": p.id,
"user_id": p.user_id,
"title": p.title,
"description": p.description,
"goal": p.goal,
"status": p.status,
"color": p.color,
"created_at": p.created_at.isoformat(),
"updated_at": p.updated_at.isoformat(),
}
for p in projects
],
"milestones": [
{
"id": m.id,
"user_id": m.user_id,
"project_id": m.project_id,
"title": m.title,
"description": m.description,
"status": m.status,
"order_index": m.order_index,
"created_at": m.created_at.isoformat(),
"updated_at": m.updated_at.isoformat(),
}
for m in milestones
],
"notes": [
{
"id": n.id,
"user_id": n.user_id,
"title": n.title,
"body": n.body,
"tags": n.tags or [],
"parent_id": n.parent_id,
"project_id": n.project_id,
"milestone_id": n.milestone_id,
"is_task": n.is_task,
"status": n.status,
"priority": n.priority,
"due_date": n.due_date.isoformat() if n.due_date else None,
"is_starred": getattr(n, "is_starred", False),
"is_pinned": getattr(n, "is_pinned", False),
"created_at": n.created_at.isoformat(),
"updated_at": n.updated_at.isoformat(),
}
for n in notes
],
"task_logs": [
{
"id": tl.id,
"user_id": tl.user_id,
"note_id": tl.note_id,
"description": tl.description,
"duration_minutes": tl.duration_minutes,
"logged_at": tl.logged_at.isoformat() if tl.logged_at else None,
"created_at": tl.created_at.isoformat(),
}
for tl in task_logs
],
"note_drafts": [
{
"id": nd.id,
"user_id": nd.user_id,
"note_id": nd.note_id,
"title": nd.title,
"body": nd.body,
"saved_at": nd.saved_at.isoformat() if nd.saved_at else None,
}
for nd in note_drafts
],
"note_versions": [
{
"id": nv.id,
"user_id": nv.user_id,
"note_id": nv.note_id,
"title": nv.title,
"body": nv.body,
"version_number": nv.version_number,
"created_at": nv.created_at.isoformat(),
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"context_note_id": m.context_note_id,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"push_subscriptions": [
{
"user_id": ps.user_id,
"endpoint": ps.endpoint,
"p256dh": ps.p256dh,
"auth": ps.auth,
"created_at": ps.created_at.isoformat(),
}
for ps in push_subs
],
}
```
Check what exact field names exist on each model before assuming. Use `getattr(obj, "field", default)` for fields that may not exist on older DB versions.
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 full export with projects, milestones, task_logs, drafts, versions"
```
---
## Task 2: Extend export — user backup
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Rewrite `export_user_backup(user_id)`**
Same structure as full backup but filtered to `WHERE user_id = uid`. Apply same field additions. Omit sensitive fields (password_hash, oauth_sub) from user self-export.
```python
async def export_user_backup(user_id: int) -> dict:
async with async_session() as session:
user = await session.get(User, user_id)
projects = (await session.execute(select(Project).where(Project.user_id == user_id))).scalars().all()
milestones = (await session.execute(select(Milestone).where(Milestone.user_id == user_id))).scalars().all()
notes = (await session.execute(select(Note).where(Note.user_id == user_id))).scalars().all()
task_logs = (await session.execute(select(TaskLog).where(TaskLog.user_id == user_id))).scalars().all()
note_drafts = (await session.execute(select(NoteDraft).where(NoteDraft.user_id == user_id))).scalars().all()
note_versions = (await session.execute(
select(NoteVersion).where(NoteVersion.user_id == user_id)
.order_by(NoteVersion.note_id, NoteVersion.version_number)
)).scalars().all()
conversations = (await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
.where(Conversation.user_id == user_id)
)).scalars().all()
settings = (await session.execute(select(Setting).where(Setting.user_id == user_id))).scalars().all()
return {
"version": 2,
"scope": "user",
"exported_at": datetime.now(timezone.utc).isoformat(),
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"role": user.role,
"created_at": user.created_at.isoformat(),
} if user else None,
"projects": [...], # same as full but user-filtered
"milestones": [...],
"notes": [...],
"task_logs": [...],
"note_drafts": [...],
"note_versions": [...],
"conversations": [...],
"settings": [...],
}
```
- [ ] **Step 2: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 user backup with all models"
```
---
## Task 3: Rewrite restore for v2
**Files:**
- Modify: `src/fabledassistant/services/backup.py`
- [ ] **Step 1: Add version dispatch to `restore_full_backup`**
```python
async def restore_full_backup(data: dict) -> dict:
version = data.get("version", 1)
if version == 1:
return await _restore_v1(data)
return await _restore_v2(data)
```
Move existing restore code into `_restore_v1(data)` with no changes.
- [ ] **Step 2: Implement `_restore_v2(data)` with full FK re-mapping**
Restore order (respects FK dependencies):
1. Users → build `user_id_map`
2. Projects (fk: user_id) → build `project_id_map`
3. Milestones (fk: user_id, project_id) → build `milestone_id_map`
4. Notes — first pass: insert without `parent_id` (fk: user_id, project_id, milestone_id) → build `note_id_map`
5. Notes — second pass: patch `parent_id` using `note_id_map`
6. TaskLogs (fk: user_id, note_id via note_id_map)
7. NoteDrafts (fk: user_id, note_id via note_id_map)
8. NoteVersions (fk: user_id, note_id via note_id_map) — export only, no restore by default (skip unless flag set)
9. Conversations (fk: user_id) → Messages (fk: conversation_id, context_note_id via note_id_map)
10. Settings (fk: user_id)
```python
async def _restore_v2(data: dict) -> dict:
from datetime import date, datetime, timezone
stats = {
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "conversations": 0,
"messages": 0, "settings": 0
}
async with async_session() as session:
user_id_map: dict[int, int] = {}
project_id_map: dict[int, int] = {}
milestone_id_map: dict[int, int] = {}
note_id_map: dict[int, int] = {}
# 1. Users
for u_data in data.get("users", []):
old_id = u_data["id"]
user = User(
username=u_data["username"],
email=u_data.get("email"),
password_hash=u_data.get("password_hash"),
oauth_sub=u_data.get("oauth_sub"),
role=u_data.get("role", "user"),
session_version=u_data.get("session_version", 1),
created_at=datetime.fromisoformat(u_data["created_at"]) if u_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(user)
await session.flush()
user_id_map[old_id] = user.id
stats["users"] += 1
# 2. Projects
for p_data in data.get("projects", []):
mapped_uid = user_id_map.get(p_data.get("user_id", 0))
if mapped_uid is None:
continue
proj = Project(
user_id=mapped_uid,
title=p_data.get("title", ""),
description=p_data.get("description"),
goal=p_data.get("goal"),
status=p_data.get("status", "active"),
color=p_data.get("color"),
created_at=datetime.fromisoformat(p_data["created_at"]) if p_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(p_data["updated_at"]) if p_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(proj)
await session.flush()
project_id_map[p_data["id"]] = proj.id
stats["projects"] += 1
# 3. Milestones
for m_data in data.get("milestones", []):
mapped_uid = user_id_map.get(m_data.get("user_id", 0))
mapped_pid = project_id_map.get(m_data.get("project_id", 0))
if mapped_uid is None:
continue
ms = Milestone(
user_id=mapped_uid,
project_id=mapped_pid,
title=m_data.get("title", ""),
description=m_data.get("description"),
status=m_data.get("status", "open"),
order_index=m_data.get("order_index", 0),
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(m_data["updated_at"]) if m_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(ms)
await session.flush()
milestone_id_map[m_data["id"]] = ms.id
stats["milestones"] += 1
# 4. Notes — first pass (no parent_id)
note_objects: list[tuple[int, int]] = [] # (old_parent_id, new_note_id)
for n_data in data.get("notes", []):
mapped_uid = user_id_map.get(n_data.get("user_id", 0))
if mapped_uid is None:
continue
due = None
if n_data.get("due_date"):
due = date.fromisoformat(n_data["due_date"])
note = Note(
user_id=mapped_uid,
title=n_data.get("title", ""),
body=n_data.get("body", ""),
tags=n_data.get("tags", []),
parent_id=None, # patched in second pass
project_id=project_id_map.get(n_data.get("project_id")) if n_data.get("project_id") else None,
milestone_id=milestone_id_map.get(n_data.get("milestone_id")) if n_data.get("milestone_id") else None,
is_task=n_data.get("is_task", False),
status=n_data.get("status"),
priority=n_data.get("priority"),
due_date=due,
created_at=datetime.fromisoformat(n_data["created_at"]) if n_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(n_data["updated_at"]) if n_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(note)
await session.flush()
note_id_map[n_data["id"]] = note.id
if n_data.get("parent_id"):
note_objects.append((n_data["parent_id"], note.id))
stats["notes"] += 1
# 5. Notes — second pass: patch parent_id
for old_parent_id, new_note_id in note_objects:
new_parent_id = note_id_map.get(old_parent_id)
if new_parent_id:
note_row = await session.get(Note, new_note_id)
if note_row:
note_row.parent_id = new_parent_id
# 6. TaskLogs
for tl_data in data.get("task_logs", []):
mapped_uid = user_id_map.get(tl_data.get("user_id", 0))
mapped_nid = note_id_map.get(tl_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
from fabledassistant.models.task_log import TaskLog
tl = TaskLog(
user_id=mapped_uid,
note_id=mapped_nid,
description=tl_data.get("description", ""),
duration_minutes=tl_data.get("duration_minutes"),
logged_at=datetime.fromisoformat(tl_data["logged_at"]) if tl_data.get("logged_at") else None,
created_at=datetime.fromisoformat(tl_data["created_at"]) if tl_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(tl)
stats["task_logs"] += 1
# 7. NoteDrafts
for nd_data in data.get("note_drafts", []):
mapped_uid = user_id_map.get(nd_data.get("user_id", 0))
mapped_nid = note_id_map.get(nd_data.get("note_id", 0))
if mapped_uid is None or mapped_nid is None:
continue
from fabledassistant.models.note_draft import NoteDraft
nd = NoteDraft(
user_id=mapped_uid,
note_id=mapped_nid,
title=nd_data.get("title", ""),
body=nd_data.get("body", ""),
saved_at=datetime.fromisoformat(nd_data["saved_at"]) if nd_data.get("saved_at") else None,
)
session.add(nd)
stats["note_drafts"] += 1
# 8. Conversations + Messages
for c_data in data.get("conversations", []):
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
if mapped_uid is None:
continue
conv = Conversation(
user_id=mapped_uid,
title=c_data.get("title", ""),
created_at=datetime.fromisoformat(c_data["created_at"]) if c_data.get("created_at") else datetime.now(timezone.utc),
updated_at=datetime.fromisoformat(c_data["updated_at"]) if c_data.get("updated_at") else datetime.now(timezone.utc),
)
session.add(conv)
await session.flush()
stats["conversations"] += 1
for m_data in c_data.get("messages", []):
msg = Message(
conversation_id=conv.id,
role=m_data["role"],
content=m_data.get("content", ""),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
created_at=datetime.fromisoformat(m_data["created_at"]) if m_data.get("created_at") else datetime.now(timezone.utc),
)
session.add(msg)
stats["messages"] += 1
# 9. Settings
for s_data in data.get("settings", []):
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
if mapped_uid is None:
continue
setting = Setting(user_id=mapped_uid, key=s_data["key"], value=s_data.get("value", ""))
session.add(setting)
stats["settings"] += 1
await session.commit()
logger.info("Restored v2 backup: %s", stats)
return stats
```
- [ ] **Step 3: Add missing imports at top of file**
```python
from datetime import datetime, timezone
from fabledassistant.models.project import Project
from fabledassistant.models.milestone import Milestone
```
- [ ] **Step 4: Test restore round-trip**
```bash
# Export
curl -s -b cookies.txt "http://localhost:5000/api/admin/backup?scope=full" -o /tmp/backup_v2.json
# Inspect structure
python3 -c "import json; d=json.load(open('/tmp/backup_v2.json')); print(list(d.keys()))"
# Expected keys: version, scope, exported_at, users, projects, milestones, notes, task_logs, ...
```
- [ ] **Step 5: Typecheck**
```bash
docker compose exec app python -m py_compile src/fabledassistant/services/backup.py
```
Expected: No errors.
- [ ] **Step 6: Commit**
```bash
git add src/fabledassistant/services/backup.py
git commit -m "feat(backup): v2 restore with full FK re-mapping; v1 restore preserved"
```
---
## Task 4: Verification
- [ ] **Step 1: Full round-trip test**
1. Export full backup as admin
2. Verify JSON has `"version": 2` and all expected top-level keys
3. Verify notes have `project_id`, `milestone_id`, `is_task` fields
4. Verify `task_logs` array has entries (create a task log entry first if needed)
5. Restore backup to a clean test instance (or verify restore code path runs without error in dry-run)
- [ ] **Step 2: V1 backward compat test**
Take an old v1 backup JSON (or construct one without the `version` key) and run restore. Verify it takes the v1 path and doesn't error.
- [ ] **Step 3: Commit final verification note**
```bash
git commit --allow-empty -m "chore(backup): v2 backup rewrite verified"
```
File diff suppressed because it is too large Load Diff
+20 -34
View File
@@ -1,10 +1,8 @@
# OAuth / OIDC SSO Setup (Authentik)
# OAuth / OIDC SSO Setup
Fabled Assistant supports single sign-on via any OpenID Connect provider.
This guide covers Authentik, but the same pattern works with Keycloak, Authelia, Zitadel, etc.
---
## 1. Create the provider in Authentik
1. Log in to the Authentik admin UI.
@@ -22,8 +20,6 @@ This guide covers Authentik, but the same pattern works with Keycloak, Authelia,
https://auth.example.com/application/o/fabled-assistant/
```
---
## 2. Configure Fabled Assistant
Add the following environment variables to the `app` service in `docker-compose.yml`:
@@ -44,12 +40,11 @@ services:
# Disable local username/password login once SSO is working
# LOCAL_AUTH_ENABLED: "false"
# Make sure BASE_URL matches the redirect URI you registered in Authentik
# Make sure BASE_URL matches the redirect URI you registered
BASE_URL: "https://your-fabled-domain"
```
> **Docker Secrets alternative:** Instead of `OIDC_CLIENT_SECRET`, you can use
> `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file.
> **Docker Secrets alternative:** Use `OIDC_CLIENT_SECRET_FILE` pointing to a Docker secret file instead of `OIDC_CLIENT_SECRET`.
Rebuild and restart:
@@ -57,53 +52,44 @@ Rebuild and restart:
docker compose up --build -d
```
---
## 3. Verify
1. Open `/api/auth/status` — it should return:
```json
{ "oauth_enabled": true, "local_auth_enabled": true, ... }
```
2. Go to the login page — you should see a **"Login with Authentik"** button.
3. Click it → you are redirected to Authentik → authenticate → redirected back to Fabled → logged in.
2. Go to the login page — you should see a **"Login with [Provider]"** button.
3. Click it → redirected to provider → authenticate → redirected back → logged in.
4. Check `/api/auth/me` to confirm your user record.
---
## 4. Account linking
## 4. Account Linking
When a user logs in via OAuth for the first time, Fabled checks in this order:
1. **Existing OAuth sub** — returns that user immediately.
2. **Matching email** — if a local account already exists with the same email address, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
3. **New user** — a fresh account is created. The username defaults to the `preferred_username` claim from the provider; if taken, `_2`, `_3`, etc. is appended.
2. **Matching email** — if a local account already exists with the same email, the OAuth identity is linked to it automatically. The user retains all their notes and tasks.
3. **New user** — a fresh account is created. Username defaults to `preferred_username` from the provider; if taken, `_2`, `_3`, etc. is appended.
---
## 5. Disable Local Login (Optional)
## 5. Disable local login (optional)
Once everyone is using SSO you can hide the username/password form:
Once everyone is using SSO:
```yaml
LOCAL_AUTH_ENABLED: "false"
```
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a `403`. The login page will only show the SSO button.
The backend will reject any `POST /api/auth/login` or `POST /api/auth/register` request with a 403. The login page will only show the SSO button.
> **Warning:** Make sure at least one account has been linked via OAuth before disabling local login, or you will be locked out.
---
## 6. Other Providers
## 6. Other providers
| Provider | Issuer URL format |
|----------|------------------|
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
| Authelia | `https://auth.example.com` |
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
| Provider | Issuer URL format |
|------------|----------------------------------------------------------|
| Authentik | `https://auth.example.com/application/o/<app-slug>/` |
| Keycloak | `https://keycloak.example.com/realms/<realm>` |
| Authelia | `https://auth.example.com` |
| Zitadel | `https://your-instance.zitadel.cloud` |
| Google | `https://accounts.google.com` |
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be
publicly reachable from the Fabled container (server-to-server call).
The OIDC discovery endpoint (`<issuer>/.well-known/openid-configuration`) must be publicly reachable from the Fabled container (server-to-server call at login time).
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,594 @@
# 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 ✅
@@ -0,0 +1,695 @@
# 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
```
@@ -0,0 +1,479 @@
# 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"
```
@@ -0,0 +1,746 @@
# 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"
```
@@ -0,0 +1,786 @@
# 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"
```
@@ -0,0 +1,785 @@
# 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"
```
@@ -0,0 +1,782 @@
# Unified Lookup Tool & Wikipedia Integration — 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 `search_web` with a unified `lookup` tool (Wikipedia-first, SearXNG fallback) and add Wikipedia as a source in the research pipeline.
**Architecture:** New `wikipedia.py` service with `wiki_summary` and `wiki_search`. `lookup` tool in `web.py` replaces `search_web`. Research pipeline in `research.py` gains Wikipedia sources alongside SearXNG. All `search_web` references across the codebase are updated.
**Tech Stack:** Python 3.12, httpx, pytest, asyncio
---
### Task 1: Wikipedia Service Module
**Files:**
- Create: `src/fabledassistant/services/wikipedia.py`
- Create: `tests/test_wikipedia.py`
- [ ] **Step 1: Write failing tests for `wiki_summary`**
```python
# tests/test_wikipedia.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
@pytest.mark.asyncio
async def test_wiki_summary_returns_extract():
from fabledassistant.services.wikipedia import wiki_summary
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"type": "standard",
"title": "Python (programming language)",
"extract": "Python is a high-level programming language.",
"content_urls": {
"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}
},
}
mock_response.raise_for_status = MagicMock()
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value = mock_client
result = await wiki_summary("Python programming language")
assert result is not None
assert result["title"] == "Python (programming language)"
assert "high-level" in result["extract"]
assert "wikipedia.org" in result["url"]
@pytest.mark.asyncio
async def test_wiki_summary_returns_none_on_404():
from fabledassistant.services.wikipedia import wiki_summary
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=httpx.HTTPStatusError(
"Not Found", request=MagicMock(), response=MagicMock(status_code=404)
))
mock_client_cls.return_value = mock_client
result = await wiki_summary("xyznonexistenttopic123")
assert result is None
@pytest.mark.asyncio
async def test_wiki_summary_returns_none_on_disambiguation():
from fabledassistant.services.wikipedia import wiki_summary
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"type": "disambiguation",
"title": "Python",
"extract": "Python may refer to...",
}
mock_response.raise_for_status = MagicMock()
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(return_value=mock_response)
mock_client_cls.return_value = mock_client
result = await wiki_summary("Python")
assert result is None
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_wikipedia.py -v`
Expected: FAIL with `ModuleNotFoundError: No module named 'fabledassistant.services.wikipedia'`
- [ ] **Step 3: Implement `wiki_summary`**
```python
# src/fabledassistant/services/wikipedia.py
"""Wikipedia API: lightweight topic lookups and article search."""
import logging
from urllib.parse import quote as url_quote
import httpx
logger = logging.getLogger(__name__)
_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
_TIMEOUT = 5.0
_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
async def wiki_summary(query: str) -> dict | None:
"""Look up a topic by title via the Wikipedia REST summary endpoint.
Returns {"title", "extract", "url"} on hit, None on miss.
"""
encoded = url_quote(query.replace(" ", "_"), safe="")
try:
async with httpx.AsyncClient(
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
) as client:
resp = await client.get(f"{_SUMMARY_URL}/{encoded}", follow_redirects=True)
resp.raise_for_status()
data = resp.json()
except Exception:
logger.debug("Wikipedia summary lookup failed for %r", query, exc_info=True)
return None
if data.get("type") == "disambiguation":
return None
extract = data.get("extract", "").strip()
if not extract:
return None
url = (
data.get("content_urls", {}).get("desktop", {}).get("page")
or f"https://en.wikipedia.org/wiki/{encoded}"
)
return {"title": data.get("title", query), "extract": extract, "url": url}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `uv run pytest tests/test_wikipedia.py -v`
Expected: 3 passed
- [ ] **Step 5: Write failing tests for `wiki_search`**
Add to `tests/test_wikipedia.py`:
```python
@pytest.mark.asyncio
async def test_wiki_search_returns_results():
from fabledassistant.services.wikipedia import wiki_search
search_response = MagicMock()
search_response.status_code = 200
search_response.json.return_value = {
"query": {
"search": [
{"title": "QUIC"},
{"title": "HTTP/3"},
]
}
}
search_response.raise_for_status = MagicMock()
summary_response = MagicMock()
summary_response.status_code = 200
summary_response.json.return_value = {
"type": "standard",
"title": "QUIC",
"extract": "QUIC is a transport layer protocol.",
"content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/QUIC"}},
}
summary_response.raise_for_status = MagicMock()
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=[search_response, summary_response, summary_response])
mock_client_cls.return_value = mock_client
results = await wiki_search("QUIC protocol", limit=2)
assert len(results) >= 1
assert results[0]["title"] == "QUIC"
assert "transport" in results[0]["extract"]
@pytest.mark.asyncio
async def test_wiki_search_returns_empty_on_failure():
from fabledassistant.services.wikipedia import wiki_search
with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=False)
mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection failed"))
mock_client_cls.return_value = mock_client
results = await wiki_search("anything")
assert results == []
```
- [ ] **Step 6: Implement `wiki_search`**
Add to `src/fabledassistant/services/wikipedia.py`:
```python
async def wiki_search(query: str, limit: int = 3) -> list[dict]:
"""Search Wikipedia for articles matching a query.
Returns [{"title", "extract", "url"}, ...] (may be empty).
"""
try:
async with httpx.AsyncClient(
timeout=_TIMEOUT, headers={"User-Agent": _USER_AGENT}
) as client:
resp = await client.get(_SEARCH_URL, params={
"action": "query",
"list": "search",
"srsearch": query,
"srlimit": str(limit),
"format": "json",
})
resp.raise_for_status()
hits = resp.json().get("query", {}).get("search", [])
if not hits:
return []
results: list[dict] = []
for hit in hits:
title = hit.get("title", "")
if not title:
continue
encoded = url_quote(title.replace(" ", "_"), safe="")
try:
summary_resp = await client.get(
f"{_SUMMARY_URL}/{encoded}", follow_redirects=True,
)
summary_resp.raise_for_status()
data = summary_resp.json()
except Exception:
continue
if data.get("type") == "disambiguation":
continue
extract = data.get("extract", "").strip()
if not extract:
continue
url = (
data.get("content_urls", {}).get("desktop", {}).get("page")
or f"https://en.wikipedia.org/wiki/{encoded}"
)
results.append({"title": data.get("title", title), "extract": extract, "url": url})
return results
except Exception:
logger.debug("Wikipedia search failed for %r", query, exc_info=True)
return []
```
- [ ] **Step 7: Run all wikipedia tests**
Run: `uv run pytest tests/test_wikipedia.py -v`
Expected: 5 passed
- [ ] **Step 8: Commit**
```bash
git add src/fabledassistant/services/wikipedia.py tests/test_wikipedia.py
git commit -m "feat: add wikipedia service with summary lookup and search"
```
---
### Task 2: Lookup Tool (replaces search_web)
**Files:**
- Modify: `src/fabledassistant/services/tools/web.py`
- Create: `tests/test_lookup_tool.py`
- [ ] **Step 1: Write failing tests for `lookup`**
```python
# tests/test_lookup_tool.py
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
@pytest.mark.asyncio
async def test_lookup_wikipedia_hit():
"""lookup returns wikipedia source when wiki_summary succeeds."""
wiki_data = {
"title": "QUIC",
"extract": "QUIC is a transport layer protocol.",
"url": "https://en.wikipedia.org/wiki/QUIC",
}
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=wiki_data):
from fabledassistant.services.tools.web import lookup_tool
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
assert result["success"] is True
assert result["type"] == "lookup"
assert result["source"] == "wikipedia"
assert result["data"]["title"] == "QUIC"
assert "transport" in result["data"]["extract"]
@pytest.mark.asyncio
async def test_lookup_wikipedia_miss_searxng_fallback():
"""lookup falls back to SearXNG + article fetch when Wikipedia misses."""
searxng_results = [
{"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
]
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
patch("fabledassistant.services.tools.web.Config") as mock_config, \
patch("fabledassistant.services.tools.web._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
patch("fabledassistant.services.tools.web._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
mock_config.searxng_enabled.return_value = True
from fabledassistant.services.tools.web import lookup_tool
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
assert result["success"] is True
assert result["type"] == "lookup"
assert result["source"] == "web"
assert result["data"]["results"]
@pytest.mark.asyncio
async def test_lookup_wikipedia_miss_no_searxng():
"""lookup returns no-results when Wikipedia misses and SearXNG is not configured."""
with patch("fabledassistant.services.tools.web.wiki_summary", new_callable=AsyncMock, return_value=None), \
patch("fabledassistant.services.tools.web.Config") as mock_config:
mock_config.searxng_enabled.return_value = False
from fabledassistant.services.tools.web import lookup_tool
result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
assert result["success"] is True
assert result["source"] == "none"
@pytest.mark.asyncio
async def test_lookup_always_available():
"""lookup tool must appear in get_tools_for_user regardless of SearXNG config."""
with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
from fabledassistant.services.tools import get_tools_for_user
tools = await get_tools_for_user(user_id=1)
tool_names = {t["function"]["name"] for t in tools}
assert "lookup" in tool_names
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `uv run pytest tests/test_lookup_tool.py -v`
Expected: FAIL (no `lookup_tool` function)
- [ ] **Step 3: Replace `search_web` with `lookup` in `web.py`**
Replace the `search_web_tool` function (lines 1236 of `src/fabledassistant/services/tools/web.py`) with:
```python
@tool(
name="lookup",
description=(
"Look up a topic, concept, or factual question. Returns a concise answer from "
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
"'how does Y work', current events, or version numbers. No note is saved. "
"For comprehensive written reports saved as notes, use research_topic instead."
),
parameters={
"query": {"type": "string", "description": "The topic or question to look up"},
},
required=["query"],
)
async def lookup_tool(*, user_id, arguments, **_ctx):
from fabledassistant.config import Config
from fabledassistant.services.wikipedia import wiki_summary
query = arguments.get("query", "")
# 1. Try Wikipedia first
wiki = await wiki_summary(query)
if wiki:
return {
"success": True,
"type": "lookup",
"source": "wikipedia",
"data": wiki,
}
# 2. Fall back to SearXNG + article fetch
if Config.searxng_enabled():
from fabledassistant.services.research import _search_searxng
from fabledassistant.services.rss import _fetch_full_article
results = await _search_searxng(query)
if results:
articles: list[dict] = []
for r in results[:2]:
url = r.get("url", "")
if not url:
continue
content = await _fetch_full_article(url)
articles.append({
"url": url,
"title": r.get("title", url),
"snippet": r.get("snippet", ""),
"content": (content or "")[:4000],
})
if articles:
return {
"success": True,
"type": "lookup",
"source": "web",
"data": {"query": query, "results": articles},
}
# 3. No sources available
return {
"success": True,
"type": "lookup",
"source": "none",
"data": {
"query": query,
"message": "No results found. You can answer from your own knowledge.",
},
}
```
- [ ] **Step 4: Run lookup tests to verify they pass**
Run: `uv run pytest tests/test_lookup_tool.py -v`
Expected: 4 passed
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/tools/web.py tests/test_lookup_tool.py
git commit -m "feat: replace search_web with unified lookup tool (Wikipedia + SearXNG fallback)"
```
---
### Task 3: Update All `search_web` References
**Files:**
- Modify: `src/fabledassistant/services/tools/web.py:45` (research_topic description)
- Modify: `src/fabledassistant/services/tools/web.py:67` (search_images description)
- Modify: `src/fabledassistant/services/tools/rss.py:75` (read_article description)
- Modify: `src/fabledassistant/services/generation_task.py:133` (status label map)
- Modify: `src/fabledassistant/services/llm.py:608` (action list)
- [ ] **Step 1: Update `research_topic` description**
In `src/fabledassistant/services/tools/web.py`, change the `research_topic` description from:
```python
"For a quick factual answer without saving a note, use search_web."
```
to:
```python
"For a quick factual answer without saving a note, use lookup."
```
- [ ] **Step 2: Update `search_images` description**
In `src/fabledassistant/services/tools/web.py`, change the `search_images` description from:
```python
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use search_web for those.",
```
to:
```python
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
```
- [ ] **Step 3: Update `read_article` description**
In `src/fabledassistant/services/tools/rss.py`, change:
```python
"Do NOT use search_web for URLs — use this tool instead."
```
to:
```python
"Do NOT use lookup for URLs — use this tool instead."
```
- [ ] **Step 4: Update status label map in `generation_task.py`**
In `src/fabledassistant/services/generation_task.py`, line 133, change:
```python
"search_web": "Searching the web",
```
to:
```python
"lookup": "Looking up information",
```
- [ ] **Step 5: Update action list in `llm.py`**
In `src/fabledassistant/services/llm.py`, line 608, change:
```python
actions.extend(["search_web", "research_topic", "search_images"])
```
to:
```python
actions.extend(["lookup", "research_topic", "search_images"])
```
- [ ] **Step 6: Run full test suite to check for regressions**
Run: `uv run pytest tests/ -v`
Expected: All tests pass (no test references `search_web` by name in assertions)
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/tools/web.py src/fabledassistant/services/tools/rss.py src/fabledassistant/services/generation_task.py src/fabledassistant/services/llm.py
git commit -m "refactor: update all search_web references to lookup"
```
---
### Task 4: Add Wikipedia Sources to Research Pipeline
**Files:**
- Modify: `src/fabledassistant/services/research.py`
- Modify: `tests/test_research_pipeline.py`
- [ ] **Step 1: Write failing test for Wikipedia in research pipeline**
Add to `tests/test_research_pipeline.py`:
```python
@pytest.mark.asyncio
async def test_pipeline_includes_wikipedia_sources():
"""run_research_pipeline should merge Wikipedia results into the source pool."""
from unittest.mock import MagicMock
wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
outline = [
{"title": "Section A", "focus": "Focus A"},
{"title": "Section B", "focus": "Focus B"},
]
note_id_counter = iter(range(30, 40))
def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
n = MagicMock()
n.id = next(note_id_counter)
n.title = title
return n
with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
from fabledassistant.services.research import run_research_pipeline
await run_research_pipeline("test topic", user_id=1, model="test-model")
# The sources passed to _generate_outline should include the Wikipedia article
sources_arg = mock_outline.call_args[0][1] # second positional arg
source_urls = [s["url"] for s in sources_arg]
assert "https://en.wikipedia.org/wiki/Topic" in source_urls
```
- [ ] **Step 2: Run test to verify it fails**
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
Expected: FAIL (no `wiki_search` import in research.py)
- [ ] **Step 3: Add Wikipedia sources to the research pipeline**
In `src/fabledassistant/services/research.py`, add the import at the top (after existing imports):
```python
from fabledassistant.services.wikipedia import wiki_search
```
Then modify Step 2 (the parallel search section, around lines 208246). Replace:
```python
# Step 2: Search all queries in parallel (200 ms stagger to avoid hammering SearXNG)
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
if i > 0:
await asyncio.sleep(0.2 * i)
_status(f"Searching: {query}...")
results = await _search_searxng(query)
logger.info("Research: query '%s'%d results", query, len(results))
return query, results
search_results = await asyncio.gather(
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
)
# Deduplicate URLs across all queries
seen_urls: set[str] = set()
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
for query, results in search_results:
for result in results[:PAGES_PER_QUERY]:
url = result.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
url_tasks.append((url, result, query))
# Fetch all unique URLs in parallel
async def _fetch_source(url: str, result: dict, query: str) -> dict:
title = result.get("title", url)
_status(f"Reading: {title[:60]}...")
content = await fetch_url_content(url)
return {
"url": url,
"title": title,
"query": query,
"snippet": result.get("snippet", ""),
"content": content,
}
all_sources: list[dict] = list(await asyncio.gather(
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
))
```
with:
```python
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
if i > 0:
await asyncio.sleep(0.2 * i)
_status(f"Searching: {query}...")
results = await _search_searxng(query)
logger.info("Research: query '%s'%d results", query, len(results))
return query, results
async def _wiki_for_query(query: str) -> list[dict]:
return await wiki_search(query, limit=1)
searxng_task = asyncio.gather(
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
)
wiki_task = asyncio.gather(
*[_wiki_for_query(q) for q in queries]
)
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
# Deduplicate URLs across all queries
seen_urls: set[str] = set()
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
for query, results in search_results:
for result in results[:PAGES_PER_QUERY]:
url = result.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
url_tasks.append((url, result, query))
# Add Wikipedia results (they already have content via extract)
for query, wiki_hits in zip(queries, wiki_results):
for hit in wiki_hits:
url = hit.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
wiki_sources.append({
"url": url,
"title": hit["title"],
"query": query,
"snippet": hit["extract"][:200],
"content": hit["extract"],
})
# Fetch all unique SearXNG URLs in parallel
async def _fetch_source(url: str, result: dict, query: str) -> dict:
title = result.get("title", url)
_status(f"Reading: {title[:60]}...")
content = await fetch_url_content(url)
return {
"url": url,
"title": title,
"query": query,
"snippet": result.get("snippet", ""),
"content": content,
}
fetched_sources: list[dict] = list(await asyncio.gather(
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
))
all_sources = wiki_sources + fetched_sources
```
- [ ] **Step 4: Run the new test to verify it passes**
Run: `uv run pytest tests/test_research_pipeline.py::test_pipeline_includes_wikipedia_sources -v`
Expected: PASS
- [ ] **Step 5: Run the full research test suite for regressions**
Run: `uv run pytest tests/test_research_pipeline.py -v`
Expected: All tests pass
- [ ] **Step 6: Run full test suite**
Run: `uv run pytest tests/ -v`
Expected: All tests pass
- [ ] **Step 7: Commit**
```bash
git add src/fabledassistant/services/research.py tests/test_research_pipeline.py
git commit -m "feat: add Wikipedia as research pipeline source alongside SearXNG"
```
---
### Task 5: Lint, Typecheck, Final Verification
**Files:**
- All modified files
- [ ] **Step 1: Run ruff lint**
Run: `uv run ruff check src/fabledassistant/services/wikipedia.py src/fabledassistant/services/tools/web.py src/fabledassistant/services/research.py tests/test_wikipedia.py tests/test_lookup_tool.py`
Expected: All checks passed (fix any issues if not)
- [ ] **Step 2: Run typecheck**
Run: `cd frontend && npx vue-tsc --noEmit` (frontend unchanged, but verify nothing broke)
Expected: Clean
- [ ] **Step 3: Run full test suite one last time**
Run: `uv run pytest tests/ -v`
Expected: All tests pass
- [ ] **Step 4: Commit any lint fixes if needed**
```bash
git add -u
git commit -m "fix: lint cleanup for lookup/wikipedia changes"
```
@@ -0,0 +1,216 @@
# 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
@@ -0,0 +1,101 @@
# 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
@@ -0,0 +1,227 @@
# 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
@@ -0,0 +1,162 @@
# 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
@@ -0,0 +1,204 @@
# 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
@@ -0,0 +1,278 @@
# 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"`
@@ -0,0 +1,144 @@
# 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.
@@ -0,0 +1,204 @@
# 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
@@ -0,0 +1,249 @@
# 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
@@ -0,0 +1,119 @@
# Unified Lookup Tool & Wikipedia Integration
## Goal
Replace the fragmented `search_web` tool with a single `lookup` tool that checks Wikipedia first and falls back to SearXNG web search. Add Wikipedia as an additional source in the research pipeline. Result: one lightweight tool for factual questions (always available, no config required), and richer research output.
## Architecture
Two changes to the search/knowledge stack:
1. **New `lookup` tool** replaces `search_web`. Tries Wikipedia REST API summary endpoint first (~200ms, reliable, no config). Falls back to SearXNG + trafilatura article fetch when Wikipedia misses and SearXNG is configured. Always available (no `requires` field).
2. **Wikipedia sources in research pipeline.** During sub-query execution, `wiki_search` runs alongside `_search_searxng`. Wikipedia articles merge into the source pool and get deduplicated by URL.
Shared Wikipedia logic lives in a new `wikipedia.py` service module.
## Components
### `src/fabledassistant/services/wikipedia.py` (new)
Two async functions:
**`wiki_summary(query: str) -> dict | None`**
- Direct title lookup via `https://en.wikipedia.org/api/rest_v1/page/summary/{title}`
- Returns `{"title": str, "extract": str, "url": str}` on hit
- Returns `None` on 404, disambiguation pages (`"type": "disambiguation"`), network errors, or empty extracts
- 5-second timeout
- User-Agent: `"FabledAssistant/1.0 (https://fabledsword.com)"`
**`wiki_search(query: str, limit: int = 3) -> list[dict]`**
- Search via `https://en.wikipedia.org/w/api.php?action=query&list=search&srsearch={query}&srlimit={limit}&format=json`
- For each search result, fetch its summary via the summary endpoint to get the extract
- Returns `[{"title": str, "extract": str, "url": str}, ...]`
- Returns `[]` on any failure
- Same timeout and User-Agent as above
### `src/fabledassistant/services/tools/web.py` (modified)
**Remove:** `search_web_tool`
**Add:** `lookup_tool`
```
@tool(
name="lookup",
description="Look up a topic, concept, or factual question. Returns a concise
answer from Wikipedia or web sources. Use for definitions,
explanations, 'what is X', 'how does Y work'. For comprehensive
written reports saved as notes, use research_topic instead.",
parameters={
"query": {"type": "string", "description": "The topic or question to look up"},
},
required=["query"],
)
```
No `requires` field — always available.
**Logic:**
1. Call `wiki_summary(query)`
2. If Wikipedia returns a result: return `{"success": True, "type": "lookup", "source": "wikipedia", "data": {"title": ..., "extract": ..., "url": ...}}`
3. If Wikipedia misses and `Config.searxng_enabled()`:
- Call `_search_searxng(query)` to get search results
- Fetch top 1-2 result URLs via `_fetch_full_article` (from `rss.py`, trafilatura-based)
- Return `{"success": True, "type": "lookup", "source": "web", "data": {"query": ..., "results": [...], "content": ...}}`
4. If Wikipedia misses and no SearXNG: return `{"success": True, "type": "lookup", "source": "none", "data": {"query": ..., "message": "No results found. You can answer from your own knowledge."}}`
### `src/fabledassistant/services/research.py` (modified)
**In Step 2 (parallel search):**
- For each sub-query, run `wiki_search(query, limit=1)` concurrently with `_search_searxng(query)`
- Merge Wikipedia results into the per-query result list
**In Step 3 (deduplication):**
- When deduplicating URLs, Wikipedia URLs (`wikipedia.org`) are checked against SearXNG results
- If a Wikipedia article URL already appears in SearXNG results, skip the duplicate
**Wikipedia article content for synthesis:**
- The `extract` from `wiki_search` is used as the source content (no additional fetch needed, unlike SearXNG URLs which require `fetch_url_content`)
- This means Wikipedia sources are available immediately without an HTTP fetch step
## Error Handling
- All Wikipedia API failures (network, timeout, malformed JSON) return `None`/`[]` silently
- `lookup` never raises — always returns a response the model can work with
- In the research pipeline, Wikipedia is purely additive; its failure never degrades existing SearXNG-based research
- Disambiguation pages are detected via `"type": "disambiguation"` in the summary response and treated as a miss
## Testing
### `tests/test_wikipedia.py` (new)
- `test_wiki_summary_returns_extract` — mock successful summary response, verify return shape
- `test_wiki_summary_returns_none_on_404` — mock 404, verify `None`
- `test_wiki_summary_returns_none_on_disambiguation` — mock disambiguation response, verify `None`
- `test_wiki_search_returns_results` — mock search API + summary fetches, verify list
- `test_wiki_search_returns_empty_on_failure` — mock network error, verify `[]`
### `tests/test_lookup_tool.py` (new)
- `test_lookup_wikipedia_hit` — mock `wiki_summary` returning data, verify tool returns wikipedia source
- `test_lookup_wikipedia_miss_searxng_fallback` — mock `wiki_summary` returning None, SearXNG returning results + article fetch, verify web source
- `test_lookup_wikipedia_miss_no_searxng` — mock both missing, verify graceful "no results" response
- `test_lookup_always_available` — verify the tool appears in `get_tools_for_user` regardless of SearXNG config
### `tests/test_research_pipeline.py` (add to existing)
- `test_research_includes_wikipedia_sources` — mock `wiki_search` alongside SearXNG, verify Wikipedia results appear in source pool
All tests mock HTTP calls — no live API hits.
## What Doesn't Change
- `read_article` tool — stays as-is (explicit URL fetch, different purpose)
- `research_topic` tool definition — stays as-is (same name, description, parameters)
- `generation_task.py` research interception — stays as-is
- `search_images` tool — stays as-is
- `_search_searxng` and `_search_searxng_images` — stay as-is
- `_fetch_full_article` in `rss.py` — stays as-is, reused by `lookup` for SearXNG fallback
+2
View File
@@ -0,0 +1,2 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
View File
+126
View File
@@ -0,0 +1,126 @@
"""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
+757
View File
@@ -0,0 +1,757 @@
"""Fable MCP server — exposes Fable Scribe 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, journal
load_dotenv()
_INSTRUCTIONS = """
Fabled Scribe 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.
## Journal
Fable Scribe runs a per-day Journal — a conversational surface where the user narrates
their day. Each day has its own conversation. The first assistant message in a day's
conversation is the **daily prep**: an LLM-generated briefing covering today's tasks,
calendar events, weather, active projects, and recent journal context. Subsequent turns
are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured
extractions) via the `record_moment` tool during the conversation.
Use `fable_get_today_journal` to inspect today's prep + conversation. Use
`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured
journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate
today's prep prose.
## 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,
)
# ---------------------------------------------------------------------------
# Journal — daily prep, day payloads, moments
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_today_journal() -> dict:
"""Fetch today's Journal day payload.
Creates today's journal conversation and generates the daily prep
message if neither exists yet. Returns:
{
"day_date": "YYYY-MM-DD",
"conversation": { id, title, conversation_type, day_date, ... },
"messages": [ ... ordered list of messages ... ]
}
The first assistant message is the daily prep — a conversational
opener generated by the LLM from gathered tasks/events/weather/
projects/recent moments/open threads. Its ``msg_metadata.sections``
carries the underlying structured data for inspection.
"""
async with FableClient() as client:
return await journal.get_today_journal(client)
@mcp.tool()
async def fable_get_journal_day(iso_date: str) -> dict:
"""Fetch a specific day's Journal payload by ISO date.
Args:
iso_date: YYYY-MM-DD format date.
Returns the same shape as fable_get_today_journal. If no journal
exists for that day, ``conversation`` and ``messages`` will be
null/empty respectively.
"""
async with FableClient() as client:
return await journal.get_journal_day(client, iso_date=iso_date)
@mcp.tool()
async def fable_list_journal_days() -> dict:
"""List dates that have journal content for the current user, newest first.
Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query
specific days via ``fable_get_journal_day``.
"""
async with FableClient() as client:
return await journal.list_journal_days(client)
@mcp.tool()
async def fable_trigger_journal_prep(iso_date: str = "") -> dict:
"""Force-regenerate the daily prep prose for today (or a specific day).
The prep is the first assistant message in a day's journal — a
conversational LLM-generated briefing built from tasks/events/weather/
projects/recent moments/open threads. Use this to iterate on the prep
prompt or refresh after data changes.
Args:
iso_date: Optional YYYY-MM-DD. If empty, regenerates today.
Returns ``{"ok": true, "message_id": ...}``.
"""
async with FableClient() as client:
return await journal.trigger_journal_prep(
client, iso_date=iso_date or None,
)
@mcp.tool()
async def fable_get_journal_config() -> dict:
"""Fetch the user's journal config.
Includes prep schedule (prep_enabled / prep_hour / prep_minute),
day rollover hour, phase boundaries, and any locations / temp_unit
used for the prep's weather section.
"""
async with FableClient() as client:
return await journal.get_journal_config(client)
@mcp.tool()
async def fable_list_moments(
query: str = "",
person_id: int = 0,
place_id: int = 0,
tag: str = "",
date_from: str = "",
date_to: str = "",
pinned_only: bool = False,
limit: int = 50,
) -> dict:
"""Search/list journal Moments — small structured extractions the LLM emits during journaling.
All filters are optional and combinable. Without a query, returns
moments ordered by occurred_at DESC. With a query, returns
semantically-ranked moments above the similarity threshold.
Args:
query: Optional semantic query string.
person_id: Filter to moments mentioning this person (0 = no filter).
place_id: Filter to moments mentioning this place (0 = no filter).
tag: Filter to moments with this tag.
date_from: ISO YYYY-MM-DD lower bound (inclusive).
date_to: ISO YYYY-MM-DD upper bound (inclusive).
pinned_only: If True, only return pinned moments.
limit: Max results, default 50.
Returns ``{"moments": [...]}`` where each moment has id, day_date,
occurred_at, content, raw_excerpt, tags, people, places, task_ids,
note_ids, pinned, and (when query set) score.
"""
async with FableClient() as client:
return await journal.list_moments(
client,
query=query or None,
person_id=person_id if person_id else None,
place_id=place_id if place_id else None,
tag=tag or None,
date_from=date_from or None,
date_to=date_to or None,
pinned_only=pinned_only,
limit=limit,
)
# ---------------------------------------------------------------------------
# Generic conversation access
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_conversation(conversation_id: int) -> dict:
"""Fetch any conversation (chat or journal) 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 inspecting journal preps, chat history, or verifying
that a tool actually ran with the expected arguments.
"""
async with FableClient() as client:
return await journal.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
@@ -0,0 +1,20 @@
"""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)
+95
View File
@@ -0,0 +1,95 @@
"""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,
}
+96
View File
@@ -0,0 +1,96 @@
"""MCP tools for inspecting and controlling the Fable Scribe Journal."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
# ── Day payloads ─────────────────────────────────────────────────────────────
async def get_today_journal(client: FableClient) -> dict[str, Any]:
"""Fetch today's journal day payload (creates today's conversation + prep if needed)."""
return await client.get("/api/journal/today")
async def get_journal_day(client: FableClient, *, iso_date: str) -> dict[str, Any]:
"""Fetch a specific day's journal payload by ISO date (YYYY-MM-DD)."""
return await client.get(f"/api/journal/day/{iso_date}")
async def list_journal_days(client: FableClient) -> dict[str, Any]:
"""List dates that have journal content for the current user."""
return await client.get("/api/journal/days")
# ── Daily prep ───────────────────────────────────────────────────────────────
async def trigger_journal_prep(
client: FableClient,
*,
iso_date: str | None = None,
) -> dict[str, Any]:
"""Force-regenerate the daily prep for today (or a specific day)."""
payload: dict[str, Any] = {}
if iso_date:
payload["date"] = iso_date
return await client.post("/api/journal/trigger-prep", json=payload)
# ── Config ───────────────────────────────────────────────────────────────────
async def get_journal_config(client: FableClient) -> dict[str, Any]:
"""Fetch the user's journal config (prep schedule, day rollover, locations, etc.)."""
return await client.get("/api/journal/config")
# ── Moments ──────────────────────────────────────────────────────────────────
async def list_moments(
client: FableClient,
*,
query: str | None = None,
person_id: int | None = None,
place_id: int | None = None,
tag: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
pinned_only: bool = False,
limit: int = 50,
) -> dict[str, Any]:
"""List/search journal moments with optional filters.
All params are optional. Returns a dict with a ``moments`` array.
"""
params: dict[str, str] = {"limit": str(limit)}
if query:
params["query"] = query
if person_id is not None:
params["person_id"] = str(person_id)
if place_id is not None:
params["place_id"] = str(place_id)
if tag:
params["tag"] = tag
if date_from:
params["date_from"] = date_from
if date_to:
params["date_to"] = date_to
if pinned_only:
params["pinned_only"] = "true"
return await client.get("/api/journal/moments", params=params)
# ── Generic conversation access (still works for journal conversations) ───────
async def get_conversation(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch any conversation (chat or journal) with its full message list."""
return await client.get(f"/api/chat/conversations/{conversation_id}")
+53
View File
@@ -0,0 +1,53 @@
"""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
@@ -0,0 +1,72 @@
"""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
@@ -0,0 +1,59 @@
"""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
@@ -0,0 +1,30 @@
"""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
@@ -0,0 +1,93 @@
"""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
@@ -0,0 +1,24 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.3.0"
description = "MCP server for Fabled Scribe"
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"]
View File
+12
View File
@@ -0,0 +1,12 @@
"""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
@@ -0,0 +1,142 @@
"""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
@@ -0,0 +1,58 @@
"""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
@@ -0,0 +1,35 @@
"""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
@@ -0,0 +1,56 @@
"""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
@@ -0,0 +1,42 @@
"""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
@@ -0,0 +1,54 @@
"""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"
+771
View File
@@ -0,0 +1,771 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
name = "fable-mcp"
version = "0.2.6"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "python-dotenv" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "respx" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
]
provides-extras = ["dev"]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mcp"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sse-starlette"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
+2 -2
View File
@@ -9,8 +9,8 @@
<meta name="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-title" content="Fabled">
<title>Fabled Assistant</title>
<meta name="apple-mobile-web-app-title" content="Scribe">
<title>Fabled Scribe</title>
</head>
<body>
<div id="app"></div>
+455 -467
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -9,6 +9,12 @@
"preview": "vite preview"
},
"dependencies": {
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
"@fullcalendar/vue3": "^6.1.20",
"@ricky0123/vad-web": "^0.0.30",
"@tiptap/core": "^3.0.0",
"@tiptap/extension-link": "^3.0.0",
"@tiptap/extension-list": "^3.0.0",
@@ -19,6 +25,7 @@
"@tiptap/vue-3": "^3.0.0",
"d3": "^7",
"dompurify": "^3.1.0",
"lucide-vue-next": "^0.469.0",
"marked": "^17.0.0",
"pinia": "^3.0.0",
"vue": "3.5.30",
@@ -30,6 +37,7 @@
"@vitejs/plugin-vue": "^6.0.0",
"typescript": "~5.9.0",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^4.0.1",
"vue-tsc": "^3.0.0"
}
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "Fabled Assistant",
"short_name": "Fabled",
"name": "Fabled Scribe",
"short_name": "Scribe",
"description": "Your self-hosted second brain with AI assistance",
"start_url": "/",
"display": "standalone",
+1 -1
View File
@@ -12,7 +12,7 @@ self.addEventListener('push', event => {
return;
}
}
return self.registration.showNotification(data.title || 'Fabled Assistant', {
return self.registration.showNotification(data.title || 'Scribe', {
body: data.body || '',
icon: '/favicon.ico',
badge: '/favicon.ico',
+30 -4
View File
@@ -5,13 +5,17 @@ import AppHeader from "@/components/AppHeader.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useOnnxPreloader } from "@/composables/useOnnxPreloader";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import { apiGet } from "@/api/client";
import { apiGet, apiPut } from "@/api/client";
useTheme();
const { schedulePreload: scheduleVadPreload } = useOnnxPreloader();
scheduleVadPreload();
const router = useRouter();
const appVersion = ref("dev");
const authStore = useAuthStore();
@@ -22,6 +26,18 @@ const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts();
function startAppServices() {
chatStore.startStatusPolling();
settingsStore.fetchSettings();
settingsStore.checkVoiceStatus();
// Sync browser timezone to the server on every login/page load.
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() {
@@ -78,10 +94,11 @@ function onGlobalKeydown(e: KeyboardEvent) {
switch (e.key) {
case "h": router.push("/"); break;
case "n": router.push("/notes"); break;
case "t": router.push("/tasks"); break;
case "t": router.push("/"); break;
case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break;
case "l": router.push("/calendar"); break;
}
return;
}
@@ -146,6 +163,7 @@ watch(
onUnmounted(() => {
document.removeEventListener("keydown", onGlobalKeydown);
document.removeEventListener("visibilitychange", onVisibilityChange);
stopAppServices();
});
</script>
@@ -188,7 +206,7 @@ onUnmounted(() => {
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">t</kbd>
<span class="shortcut-desc">Tasks</span>
<span class="shortcut-desc">Knowledge (tasks)</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
@@ -202,6 +220,12 @@ onUnmounted(() => {
<kbd class="shortcut-key">c</kbd>
<span class="shortcut-desc">Chat</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">g</kbd>
<span class="shortcut-key-sep">+</span>
<kbd class="shortcut-key">l</kbd>
<span class="shortcut-desc">Calendar</span>
</div>
<div class="shortcut-row">
<kbd class="shortcut-key">Esc</kbd>
<span class="shortcut-desc">Unfocus field go home</span>
@@ -304,7 +328,9 @@ onUnmounted(() => {
min-height: 0;
overflow-y: auto;
}
.app-content:has(.workspace-root) {
.app-content:has(.workspace-root),
.app-content:has(.chat-page),
.app-content:has(.knowledge-root) {
overflow: hidden;
}
+273 -71
View File
@@ -123,7 +123,6 @@ export interface NotificationEntry {
export interface UserSearchResult {
id: number
username: string
email: string | null
}
// --- User search ---
@@ -301,125 +300,134 @@ export function apiSSEStream(
}
// ---------------------------------------------------------------------------
// Briefing
// Journal
// ---------------------------------------------------------------------------
export interface BriefingLocation {
export interface JournalLocation {
label: string;
address: string;
lat?: number;
lon?: number;
}
export interface BriefingSlots {
compilation: boolean;
morning: boolean;
midday: boolean;
afternoon: boolean;
export interface JournalConfig {
prep_enabled: boolean;
prep_hour: number;
prep_minute: number;
day_rollover_hour: number;
morning_end_hour?: number;
midday_end_hour?: number;
// Ambient-context fields (carried forward from the briefing config schema)
locations?: { home?: JournalLocation; work?: JournalLocation };
temp_unit?: 'C' | 'F';
use_caldav_event_locations?: boolean;
enabled?: boolean;
[key: string]: unknown;
}
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';
timezone: string;
}
export interface BriefingFeed {
export interface JournalConversation {
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;
model: string;
conversation_type: string;
day_date: string | null;
rag_project_id: number | null;
message_count: number;
created_at: string;
updated_at: string;
}
export interface BriefingMessage {
export interface JournalMessage {
id: number;
conversation_id: number;
role: 'user' | 'assistant' | 'system';
content: string;
status: string;
context_note_id: number | null;
tool_calls: unknown[] | null;
metadata: Record<string, unknown> | null;
created_at: string;
}
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',
timezone: '',
};
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 interface JournalDayPayload {
day_date: string;
conversation: JournalConversation | null;
messages: JournalMessage[];
}
export async function saveBriefingConfig(config: BriefingConfig): Promise<void> {
await apiPut('/api/briefing/config', config);
export interface JournalMoment {
id: number;
user_id: number;
conversation_id: number | null;
source_message_id: number | null;
day_date: string;
occurred_at: string;
recorded_at: string;
content: string;
raw_excerpt: string | null;
tags: string[];
pinned: boolean;
people: { id: number; title: string }[];
places: { id: number; title: string }[];
task_ids: number[];
note_ids: number[];
score?: number;
}
export async function getBriefingFeeds(): Promise<BriefingFeed[]> {
const data = await apiGet<BriefingFeed[]>('/api/briefing/feeds');
return data;
export async function getJournalConfig(): Promise<JournalConfig> {
return apiGet<JournalConfig>('/api/journal/config');
}
export async function createBriefingFeed(url: string): Promise<BriefingFeed> {
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', { url });
return { ...data, last_fetched_at: null };
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
await apiPut('/api/journal/config', config);
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
export async function getJournalToday(): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>('/api/journal/today');
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
export async function getJournalDays(): Promise<string[]> {
const data = await apiGet<{ days: string[] }>('/api/journal/days');
return data.days;
}
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 triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
}
export async function triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
export async function listJournalMoments(params: Record<string, string | number | boolean> = {}): Promise<JournalMoment[]> {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`);
return data.moments;
}
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
}
export async function deleteJournalMoment(id: number): Promise<void> {
await apiDelete(`/api/journal/moments/${id}`);
}
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 });
const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/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(
path: string,
body: unknown,
@@ -494,3 +502,197 @@ export async function apiStreamPost(
}
}
}
// ---------------------------------------------------------------------------
// Calendar events
// ---------------------------------------------------------------------------
export interface EventEntry {
id: number;
uid: string;
title: string;
start_dt: string;
end_dt: string | null;
all_day: boolean;
description: string;
location: string;
color: string;
recurrence: string | null;
caldav_uid: string;
project_id: number | null;
user_id: number;
created_at: string | null;
updated_at: string | null;
}
export interface EventCreatePayload {
title: string;
start_dt: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export interface EventUpdatePayload {
title?: string;
start_dt?: string;
end_dt?: string;
all_day?: boolean;
description?: string;
location?: string;
color?: string;
recurrence?: string;
project_id?: number;
}
export async function listEvents(from: string, to: string): Promise<EventEntry[]> {
return apiGet<EventEntry[]>(`/api/events?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
}
export async function createEvent(payload: EventCreatePayload): Promise<EventEntry> {
return apiPost<EventEntry>('/api/events', payload);
}
export async function getEvent(id: number): Promise<EventEntry> {
return apiGet<EventEntry>(`/api/events/${id}`);
}
export async function updateEvent(id: number, payload: EventUpdatePayload): Promise<EventEntry> {
return apiPatch<EventEntry>(`/api/events/${id}`, payload);
}
export async function deleteEvent(id: number): Promise<void> {
return apiDelete(`/api/events/${id}`);
}
// ─── API Keys ─────────────────────────────────────────────────────────────────
export interface ApiKeyEntry {
id: number
name: string
scope: string
key_prefix: string
last_used_at: string | null
}
export const listApiKeys = () =>
apiGet<{ api_keys: ApiKeyEntry[] }>('/api/api-keys').then(r => r.api_keys)
export const createApiKey = (name: string, scope: 'read' | 'write') =>
apiPost<{ key: string; api_key: ApiKeyEntry }>('/api/api-keys', { name, scope })
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 ─────────────────────────────────────────────────────────────
export interface UserProfile {
display_name: string
job_title: string
industry: string
expertise_level: 'novice' | 'intermediate' | 'expert'
response_style: 'concise' | 'balanced' | 'detailed'
tone: 'casual' | 'professional' | 'technical'
interests: 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 updateProfile = (data: Partial<UserProfile>) =>
apiPut<UserProfile>('/api/profile', data)
export const consolidateProfile = () =>
apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
export const clearProfileObservations = () => apiDelete('/api/profile/observations')
+22 -18
View File
@@ -50,34 +50,42 @@
border-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 {
padding: 0.45rem 1.1rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 600;
font-weight: 500;
font-size: 0.875rem;
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.28);
transition: box-shadow 0.15s, opacity 0.15s;
transition: background 0.15s, opacity 0.15s;
}
.btn-save:hover:not(:disabled) {
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.42);
opacity: 0.95;
background: var(--color-action-primary-hover);
}
.btn-save:disabled {
opacity: 0.55;
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 {
padding: 0.45rem 1rem;
background: var(--color-danger);
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: var(--radius-sm);
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 {
margin-left: auto;
padding: 0.4rem 0.9rem;
@@ -99,7 +107,7 @@
border-bottom: 1.5px solid var(--color-border);
border-radius: 0;
font-size: 1.5rem;
font-weight: 700;
font-weight: 500;
font-family: "Fraunces", Georgia, serif;
background: transparent;
color: var(--color-text);
@@ -112,7 +120,6 @@
}
.title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
font-weight: 400;
}
.editor-tabs {
@@ -225,7 +232,7 @@
.assist-panel-title {
flex: 1;
font-size: 0.8rem;
font-weight: 700;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -269,7 +276,7 @@
/* Section list */
.assist-sections-label {
font-size: 0.72rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
@@ -362,7 +369,6 @@
.assist-streaming-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-style: italic;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -391,7 +397,6 @@
padding: 0.5rem 0.75rem;
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
}
@@ -411,7 +416,7 @@
align-items: center;
justify-content: space-between;
font-size: 0.8rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
}
.btn-toggle-view {
@@ -454,7 +459,7 @@
width: 1rem;
text-align: center;
user-select: none;
font-weight: 700;
font-weight: 500;
}
.diff-text {
flex: 1;
@@ -464,7 +469,6 @@
.diff-empty {
padding: 0.5rem;
color: var(--color-text-muted);
font-style: italic;
font-size: 0.82rem;
}
.assist-actions {
@@ -562,7 +566,7 @@
border: none;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
text-align: left;
@@ -581,7 +585,7 @@
}
.sb-label {
font-size: 0.78rem;
font-weight: 600;
font-weight: 500;
color: var(--color-text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
+32 -1
View File
@@ -1,5 +1,8 @@
.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 {
@@ -180,6 +183,34 @@
color: var(--color-text-muted);
}
/* Interactive checkboxes — marked output in the list-note viewer */
.prose--checklist ul {
list-style: none;
padding-left: 0.25rem;
}
.prose--checklist li {
display: flex;
align-items: baseline;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.prose--checklist li input[type="checkbox"] {
flex-shrink: 0;
accent-color: var(--color-primary);
cursor: pointer;
width: 0.95em;
height: 0.95em;
margin: 0;
}
.prose--checklist li:has(input[type="checkbox"]:checked) > p,
.prose--checklist li:has(input[type="checkbox"]:checked) {
text-decoration: line-through;
color: var(--color-text-muted);
}
.prose--checklist li:has(input[type="checkbox"]:checked) input[type="checkbox"] {
text-decoration: none; /* don't strike through the checkbox itself */
}
/* Tiptap editor */
.tiptap-editor .ProseMirror {
outline: none;
+156 -90
View File
@@ -1,100 +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 {
--color-bg: #f5f5fb;
--color-bg-secondary: #ededf5;
--color-bg-card: #ffffff;
--color-text: #1a1a1a;
--color-text-secondary: #666666;
--color-text-muted: #999999;
--color-border: #dddde8;
--color-input-border: #c8c8d8;
--color-primary: #6366f1;
--color-danger: #d93025;
--color-tag-bg: #ede9fe;
--color-tag-text: #4f46e5;
/* Light mode — warm parchment palette */
--color-bg: #F5F1E8;
--color-bg-secondary: #FBF8F0;
--color-bg-card: #FBF8F0;
--color-surface: #EFEAE0;
--color-text: #14171A;
--color-text-secondary: #5A5852;
--color-text-muted: #9A9890;
--color-border: #D9D6CE;
--color-input-border: #D9D6CE;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.12);
--color-tag-text: #5B4A8A;
--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: #6366f1;
--color-status-in-progress-bg: #ede9fe;
--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-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.10);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.12);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.12);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.12);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.12);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.12);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.12);
--color-overdue: #C04A1F;
--color-code-bg: #EBEDF0;
--color-code-inline-bg: #EBEDF0;
--color-table-stripe: rgba(20, 23, 26, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #EFEAE0;
--color-input-bar-text: #14171A;
--color-input-bar-placeholder: rgba(20, 23, 26, 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(99, 102, 241, 0.10), 0 1px 4px rgba(0, 0, 0, 0.06);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #D9D6CE;
--color-bubble-user-text: #5A5852;
--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-md: 12px;
--radius-lg: 18px;
--radius-pill: 9999px;
--focus-ring: 0 0 0 2px color-mix(in srgb, var(--color-primary) 40%, transparent);
--focus-ring: 0 0 0 2px rgba(91, 74, 138, 0.5);
/* Layout */
--page-max-width: 1200px;
--page-padding-x: 1rem;
--sidebar-width: 260px;
--chat-reading-width: min(1200px, 100%);
--chat-context-sidebar-width: 220px;
}
[data-theme="dark"] {
--color-bg: #111113;
--color-bg-secondary: #18181f;
--color-bg-card: #1e1e27;
--color-text: #e4e4f0;
--color-text-secondary: #8888a8;
--color-text-muted: #52526a;
--color-border: rgba(99, 102, 241, 0.10);
--color-input-border: rgba(99, 102, 241, 0.22);
--color-primary: #818cf8;
--color-danger: #f44336;
--color-tag-bg: #2a2a45;
--color-tag-text: #a5b4fc;
/* Dark mode — Obsidian / Iron / Pewter */
--color-bg: #14171A;
--color-bg-secondary: #1E2228;
--color-bg-card: #1E2228;
--color-surface: #2C313A;
--color-text: #E8E4D8;
--color-text-secondary: #C2BFB4;
--color-text-muted: #9C9A92;
--color-border: #3F4651;
--color-input-border: #3F4651;
--color-primary: #5B4A8A;
--color-danger: #C04A1F;
--color-tag-bg: rgba(91, 74, 138, 0.15);
--color-tag-text: #5B4A8A;
--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: #818cf8;
--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: #16161d;
--color-code-inline-bg: #1e1e2d;
--color-table-stripe: #16161e;
--color-success: #4ade80;
--color-warning: #facc15;
--color-input-bar-bg: #1e1e27;
--color-input-bar-text: #e4e4f0;
--color-input-bar-placeholder: rgba(228, 228, 240, 0.35);
--color-toast-success: #4A5D3F;
--color-toast-error: #C04A1F;
--color-status-todo: #3F4651;
--color-status-todo-bg: rgba(63, 70, 81, 0.18);
--color-status-in-progress: #5B4A8A;
--color-status-in-progress-bg: rgba(91, 74, 138, 0.18);
--color-status-done: #4A5D3F;
--color-status-done-bg: rgba(74, 93, 63, 0.18);
--color-priority-low: #3D5A6E;
--color-priority-low-bg: rgba(61, 90, 110, 0.18);
--color-priority-medium: #8B6F1E;
--color-priority-medium-bg: rgba(139, 111, 30, 0.18);
--color-priority-high: #C04A1F;
--color-priority-high-bg: rgba(192, 74, 31, 0.18);
--color-wikilink: #5B4A8A;
--color-wikilink-bg: rgba(91, 74, 138, 0.18);
--color-overdue: #C04A1F;
--color-code-bg: #14171A;
--color-code-inline-bg: #1E2228;
--color-table-stripe: rgba(255, 255, 255, 0.025);
--color-success: #4A5D3F;
--color-warning: #8B6F1E;
--color-input-bar-bg: #1E2228;
--color-input-bar-text: #E8E4D8;
--color-input-bar-placeholder: rgba(232, 228, 216, 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(99, 102, 241, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--color-bubble-user-bg: transparent;
--color-bubble-user-border: #3F4651;
--color-bubble-user-text: #C2BFB4;
--color-bubble-asst-shadow: 0 4px 28px rgba(91, 74, 138, 0.14), 0 2px 8px rgba(0, 0, 0, 0.4);
--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 12px rgba(91, 74, 138, 0.45);
--glow-cta-hover: 0 4px 24px rgba(91, 74, 138, 0.65);
--glow-soft: 0 0 18px rgba(91, 74, 138, 0.4);
--color-primary-faint: rgba(91, 74, 138, 0.10);
--color-primary-tint: rgba(91, 74, 138, 0.14);
--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;
}
*,
@@ -107,15 +154,34 @@ body {
margin: 0;
background: var(--color-bg);
color: var(--color-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", Roboto, sans-serif;
font-feature-settings: "cv11";
line-height: 1.5;
transition: background-color 0.2s, color 0.2s;
}
h1, h2, h3 {
h1, h2 {
font-family: 'Fraunces', Georgia, serif;
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,
@@ -153,7 +219,7 @@ button:not(:disabled):active,
}
}
/* Thin indigo-tinted scrollbars */
/* Neutral hairline scrollbars — chrome is structural, not branded */
::-webkit-scrollbar {
width: 4px;
height: 4px;
@@ -162,11 +228,11 @@ button:not(:disabled):active,
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(99, 102, 241, 0.25);
background: var(--color-border);
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(99, 102, 241, 0.45);
background: var(--color-text-muted);
}
/* Floating inline assist button (teleported to body, cannot be scoped) */
+68 -45
View File
@@ -7,6 +7,7 @@ import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
@@ -15,7 +16,8 @@ const chatStore = useChatStore();
const router = useRouter();
const route = useRoute();
const isChatActive = computed(() => route.path.startsWith("/chat"));
const isChatActive = computed(() => route.path.startsWith("/chat"))
const isKnowledgeActive = computed(() => route.path === "/knowledge");
const mobileMenuOpen = ref(false);
@@ -67,18 +69,18 @@ router.afterEach(() => {
<!-- Left: brand -->
<router-link to="/" class="nav-brand">
<AppLogo :size="34" />
Fabled Assistant
<span class="brand-text">Fabled</span>
</router-link>
<!-- Center: primary navigation (desktop) -->
<div class="nav-center">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" :class="['nav-link', { 'router-link-active': isChatActive }]">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/shared" class="nav-link">Shared</router-link>
<div class="nav-pill-bar">
<router-link to="/knowledge" 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="/journal" class="nav-link">Journal</router-link>
<router-link to="/calendar" class="nav-link">Calendar</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
<!-- Right: status + utilities + gear + user -->
@@ -93,15 +95,13 @@ router.afterEach(() => {
<button class="btn-icon" @click="toggleShortcuts" title="Keyboard shortcuts (?)">?</button>
<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>
<!-- Settings link -->
<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">
<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>
<Settings :size="16" />
</router-link>
<div class="user-info">
@@ -121,12 +121,11 @@ router.afterEach(() => {
<!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/knowledge" 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="/graph" class="nav-link">Graph</router-link>
<router-link to="/briefing" class="nav-link">Briefing</router-link>
<router-link to="/journal" class="nav-link">Journal</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="/shared" class="nav-link">Shared</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -137,7 +136,8 @@ router.afterEach(() => {
<span class="status-text">{{ statusShortLabel }}</span>
</span>
<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 class="mobile-user">
<span class="username">{{ authStore.user?.username }}</span>
@@ -150,40 +150,50 @@ router.afterEach(() => {
<style scoped>
.app-header {
background: var(--color-bg-secondary);
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
border-bottom: 1px solid rgba(91, 74, 138, 0.18);
position: relative;
}
.nav {
padding: 0.75rem 1.5rem;
padding: 0.6rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
position: relative;
}
/* Left */
/* Left — brand */
.nav-brand {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 600;
font-size: 1.15rem;
letter-spacing: -0.01em;
color: var(--color-primary);
gap: 0.45rem;
text-decoration: none;
flex-shrink: 0;
}
.brand-text {
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-weight: 500;
font-size: 1rem;
letter-spacing: -0.01em;
color: #c4b0f0;
}
/* Center — absolutely positioned so it's truly centered regardless of side widths */
/* Center — pill bar */
.nav-center {
position: absolute;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.1rem;
}
.nav-pill-bar {
display: flex;
align-items: center;
gap: 2px;
background: var(--color-primary-faint);
border-radius: 10px;
padding: 3px;
}
/* Right */
@@ -197,20 +207,20 @@ router.afterEach(() => {
.nav-link {
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.9rem;
padding: 0.3rem 0.6rem;
border-radius: var(--radius-sm);
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-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
color: var(--color-text);
background: var(--color-primary-tint);
}
.nav-link.router-link-active {
color: var(--color-primary);
font-weight: 600;
box-shadow: inset 0 -2px 0 var(--color-primary);
border-radius: 0;
color: var(--color-primary-solid);
font-weight: 500;
background: rgba(91, 74, 138, 0.25);
box-shadow: 0 0 16px rgba(91, 74, 138, 0.3);
}
/* Status indicator */
@@ -232,15 +242,23 @@ router.afterEach(() => {
font-weight: 500;
color: var(--color-text-muted);
}
.status-green .status-dot { background: var(--color-success, #2ecc71); }
.status-yellow .status-dot { background: var(--color-warning, #f59e0b); animation: pulse-dot 2s infinite; }
/* Status dots are indicator lights, not semantic-palette buttons —
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-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; }
@keyframes pulse-dot {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
@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); }
}
/* Icon buttons (?, theme, gear) */
.btn-icon {
@@ -279,7 +297,7 @@ router.afterEach(() => {
}
.admin-badge {
font-size: 0.65rem;
font-weight: 700;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-primary);
@@ -367,6 +385,11 @@ router.afterEach(() => {
min-height: 44px;
display: flex;
align-items: center;
border-radius: 8px;
}
.mobile-menu .nav-link.router-link-active {
background: var(--color-primary-wash);
box-shadow: none;
}
.mobile-user .btn-logout {
min-height: 36px;
+7 -1
View File
@@ -11,6 +11,12 @@ defineProps<{ size?: number }>();
:height="size ?? 24"
aria-hidden="true"
>
<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>
<!-- Book body -->
<path class="logo-book" d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z" stroke-width="0.5"/>
<!-- Book spine -->
@@ -37,7 +43,7 @@ defineProps<{ size?: number }>();
<style scoped>
.logo-book {
fill: var(--color-primary);
fill: url(#logo-gradient);
stroke: color-mix(in srgb, var(--color-primary) 70%, transparent);
}
.logo-spine {
@@ -1,414 +0,0 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import {
saveBriefingConfig,
geocodeAddress,
createBriefingFeed,
type BriefingConfig,
} from '@/api/client'
const emit = defineEmits<{ done: [] }>()
const step = ref(1)
const TOTAL_STEPS = 4
const config = reactive<BriefingConfig>({
enabled: true,
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',
timezone: '',
})
// Step 2 — locations
const homeAddress = ref('')
const workAddress = ref('')
const geocodingHome = ref(false)
const geocodingWork = ref(false)
const homeConfirmed = ref<string | null>(null)
const workConfirmed = ref<string | null>(null)
const homeError = ref('')
const workError = ref('')
async function lookupHome() {
if (!homeAddress.value.trim()) return
geocodingHome.value = true
homeError.value = ''
try {
const r = await geocodeAddress(homeAddress.value.trim())
if (r) {
config.locations.home = { label: 'Home', address: homeAddress.value.trim(), lat: r.lat, lon: r.lon }
homeConfirmed.value = r.display_name
} else {
homeError.value = 'Location not found'
}
} finally {
geocodingHome.value = false
}
}
async function lookupWork() {
if (!workAddress.value.trim()) return
geocodingWork.value = true
workError.value = ''
try {
const r = await geocodeAddress(workAddress.value.trim())
if (r) {
config.locations.work = { label: 'Work', address: workAddress.value.trim(), lat: r.lat, lon: r.lon }
workConfirmed.value = r.display_name
} else {
workError.value = 'Location not found'
}
} finally {
geocodingWork.value = false
}
}
// Step 3 — work days
function toggleDay(d: number) {
const idx = config.work_days.indexOf(d)
if (idx === -1) config.work_days.push(d)
else config.work_days.splice(idx, 1)
config.work_days.sort()
}
// Step 4 — RSS feeds
const newFeedUrl = ref('')
const pendingFeeds = ref<Array<{ url: string }>>([])
function addPendingFeed() {
if (!newFeedUrl.value.trim()) return
pendingFeeds.value.push({ url: newFeedUrl.value.trim() })
newFeedUrl.value = ''
}
function removePendingFeed(i: number) { pendingFeeds.value.splice(i, 1) }
// Finish
const finishing = ref(false)
async function finish() {
finishing.value = true
try {
await saveBriefingConfig(config)
for (const f of pendingFeeds.value) {
try { await createBriefingFeed(f.url) } catch { /* best-effort */ }
}
emit('done')
} finally {
finishing.value = false
}
}
</script>
<template>
<Teleport to="body">
<div class="wizard-overlay">
<div class="wizard-card" role="dialog" aria-label="Briefing Setup">
<!-- Progress bar -->
<div class="wizard-progress">
<div class="wizard-progress-fill" :style="{ width: `${(step / TOTAL_STEPS) * 100}%` }"></div>
</div>
<div class="wizard-step-label">Step {{ step }} of {{ TOTAL_STEPS }}</div>
<!-- Step 1: Welcome -->
<div v-if="step === 1" class="wizard-body">
<h2 class="wizard-title">Good morning.</h2>
<p class="wizard-text">
The Briefing is a daily conversation that summarises your day tasks, calendar,
weather, and news and checks in a few times throughout the day.
</p>
<p class="wizard-text">
It learns from your responses over time and adapts to your schedule.
Let's take a minute to set it up.
</p>
<div class="wizard-actions">
<button class="btn-wizard-primary" @click="step = 2">Get started</button>
</div>
</div>
<!-- Step 2: Locations -->
<div v-else-if="step === 2" class="wizard-body">
<h2 class="wizard-title">Where are you?</h2>
<p class="wizard-text">
Enter your home and work addresses. The briefing uses these to fetch weather
for the right places. You can skip either one.
</p>
<div class="wizard-field">
<label class="wizard-label">Home address</label>
<div class="wizard-input-row">
<input v-model="homeAddress" class="wizard-input" placeholder="e.g. 123 Main St, Springfield" @keydown.enter="lookupHome" />
<button class="btn-wizard-secondary" @click="lookupHome" :disabled="geocodingHome">
{{ geocodingHome ? '' : 'Look up' }}
</button>
</div>
<div v-if="homeConfirmed" class="wizard-confirmed">✓ {{ homeConfirmed }}</div>
<div v-if="homeError" class="wizard-error">{{ homeError }}</div>
</div>
<div class="wizard-field">
<label class="wizard-label">Work address</label>
<div class="wizard-input-row">
<input v-model="workAddress" class="wizard-input" placeholder="e.g. 456 Office Ave, Portland" @keydown.enter="lookupWork" />
<button class="btn-wizard-secondary" @click="lookupWork" :disabled="geocodingWork">
{{ geocodingWork ? '' : 'Look up' }}
</button>
</div>
<div v-if="workConfirmed" class="wizard-confirmed">✓ {{ workConfirmed }}</div>
<div v-if="workError" class="wizard-error">{{ workError }}</div>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 1">Back</button>
<button class="btn-wizard-primary" @click="step = 3">Continue</button>
</div>
</div>
<!-- Step 3: Work schedule -->
<div v-else-if="step === 3" class="wizard-body">
<h2 class="wizard-title">When do you go to the office?</h2>
<p class="wizard-text">
The 8am slot is labelled "you're at the office" on days you have marked as office days.
Toggle any days you typically commute.
</p>
<div class="wizard-days">
<button
v-for="(day, idx) in ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']"
:key="idx"
:class="['wizard-day-btn', { active: config.work_days.includes(idx) }]"
@click="toggleDay(idx)"
type="button"
>{{ day }}</button>
</div>
<div class="wizard-actions">
<button class="btn-wizard-ghost" @click="step = 2">Back</button>
<button class="btn-wizard-primary" @click="step = 4">Continue</button>
</div>
</div>
<!-- Step 4: RSS feeds -->
<div v-else-if="step === 4" class="wizard-body">
<h2 class="wizard-title">What do you follow?</h2>
<p class="wizard-text">
Add RSS or Atom feeds and the briefing will summarise recent items each morning.
You can add or remove feeds any time in Settings.
</p>
<div v-if="pendingFeeds.length" class="wizard-feeds-list">
<div v-for="(f, i) in pendingFeeds" :key="i" class="wizard-feed-row">
<span class="wizard-feed-url">{{ f.url }}</span>
<button class="btn-wizard-remove" @click="removePendingFeed(i)">✕</button>
</div>
</div>
<div class="wizard-add-feed">
<input v-model="newFeedUrl" class="wizard-input" placeholder="https://…/feed.xml" style="flex: 1" />
<button class="btn-wizard-secondary" @click="addPendingFeed" :disabled="!newFeedUrl.trim()">Add</button>
</div>
<div class="wizard-actions" style="margin-top: 1.5rem">
<button class="btn-wizard-ghost" @click="step = 3">Back</button>
<button class="btn-wizard-ghost" @click="finish" :disabled="finishing">Skip</button>
<button class="btn-wizard-primary" @click="finish" :disabled="finishing">
{{ finishing ? 'Setting up…' : 'Enable Briefing' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.wizard-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.wizard-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, 18px);
width: 520px;
max-width: 94vw;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.wizard-progress {
height: 3px;
background: var(--color-border);
}
.wizard-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6366f1, #4f46e5);
transition: width 0.3s ease;
}
.wizard-step-label {
font-size: 0.72rem;
color: var(--color-text-muted);
text-align: right;
padding: 0.5rem 1.5rem 0;
}
.wizard-body {
padding: 1.5rem 2rem 2rem;
}
.wizard-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.4rem;
font-weight: 700;
margin: 0 0 0.75rem;
color: var(--color-text);
}
.wizard-text {
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.6;
margin: 0 0 0.75rem;
}
.wizard-field {
margin-bottom: 1rem;
}
.wizard-label {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text-muted);
display: block;
margin-bottom: 0.3rem;
}
.wizard-input-row {
display: flex;
gap: 0.5rem;
}
.wizard-input {
flex: 1;
padding: 0.5rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
}
.wizard-input:focus { border-color: var(--color-primary); }
.wizard-confirmed {
font-size: 0.78rem;
color: var(--color-success, #22c55e);
margin-top: 0.25rem;
}
.wizard-error {
font-size: 0.78rem;
color: var(--color-danger, #ef4444);
margin-top: 0.25rem;
}
.wizard-days {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin: 1rem 0 1.5rem;
}
.wizard-day-btn {
padding: 0.4rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
font-family: inherit;
}
.wizard-day-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.wizard-feeds-list {
margin-bottom: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.wizard-feed-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.6rem;
background: var(--color-bg-secondary);
border-radius: 6px;
}
.wizard-feed-url {
flex: 1;
font-size: 0.82rem;
color: var(--color-text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.btn-wizard-remove {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
border-radius: 4px;
}
.btn-wizard-remove:hover { color: var(--color-danger, #ef4444); }
.wizard-add-feed {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.wizard-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
.btn-wizard-primary {
padding: 0.5rem 1.25rem;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
font-family: inherit;
}
.btn-wizard-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-secondary {
padding: 0.5rem 0.9rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.btn-wizard-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-wizard-ghost {
padding: 0.5rem 1rem;
border: none;
background: none;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border-radius: 6px;
font-family: inherit;
}
.btn-wizard-ghost:hover { background: var(--color-bg-secondary); }
.btn-wizard-ghost:disabled { opacity: 0.5; cursor: not-allowed; }
</style>
+668
View File
@@ -0,0 +1,668 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue'
import { apiGet, transcribeAudio } from '@/api/client'
import { useVoiceRecorder } from '@/composables/useVoiceRecorder'
import { useVad } from '@/composables/useVad'
import { useStreamingTts } from '@/composables/useStreamingTts'
import { useVoiceAudio, setVoiceVolume } from '@/composables/useVoiceAudio'
import { useListenMode } from '@/composables/useListenMode'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { useToastStore } from '@/stores/toast'
import type { Note } from '@/types/note'
import {
Paperclip,
Volume2,
VolumeX,
Square,
Mic,
Loader2,
ArrowUp,
} from 'lucide-vue-next'
const props = withDefaults(defineProps<{
/** Textarea placeholder */
placeholder?: string
/** When true, hides the note picker (briefing mode) */
briefingMode?: boolean
/** Pill shape — compact rounded style for widget */
pill?: boolean
}>(), {
placeholder: 'Type a message… (Enter to send, Shift+Enter for new line)',
briefingMode: false,
pill: false,
})
const emit = defineEmits<{
submit: [payload: { content: string; contextNoteId?: number }]
abort: []
}>()
const store = useChatStore()
const settingsStore = useSettingsStore()
const voiceEnabled = computed(() => settingsStore.voiceSttReady)
const voiceTtsEnabled = computed(() => settingsStore.voiceTtsReady)
// ── Streaming TTS (listen mode) ───────────────────────────────────────────────
const listenMode = useListenMode()
const audio = useVoiceAudio()
const speakerPopoverOpen = ref(false)
const tts = useStreamingTts({
streamingContent: computed(() => store.streamingContent),
streaming: computed(() => store.streaming),
enabled: computed(() => listenMode.value && voiceTtsEnabled.value),
})
function lastAssistantContent(): string {
const msgs = store.currentConversation?.messages ?? []
return [...msgs].reverse().find((m) => m.role === 'assistant')?.content ?? ''
}
function toggleListen() {
listenMode.value = !listenMode.value
if (listenMode.value) {
tts.speak(lastAssistantContent())
} else {
tts.stop()
}
}
// ── Core input ────────────────────────────────────────────────────────────────
const messageInput = ref('')
const inputEl = ref<HTMLTextAreaElement | null>(null)
const wrapperEl = ref<HTMLElement | null>(null)
function autoResize() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 150) + 'px'
}
function resetTextareaHeight() {
const el = inputEl.value
if (!el) return
el.style.height = 'auto'
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
onSubmit()
}
}
function onSubmit() {
const content = messageInput.value.trim()
if (!content) return
emit('submit', { content, contextNoteId: attachedNote.value?.id })
messageInput.value = ''
attachedNote.value = null
resetTextareaHeight()
}
// ── Note picker ───────────────────────────────────────────────────────────────
const attachedNote = ref<{ id: number; title: string } | null>(null)
const showNotePicker = ref(false)
const noteSearchQuery = ref('')
const noteSearchResults = ref<{ id: number; title: string }[]>([])
const noteSearchLoading = ref(false)
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value
if (showNotePicker.value) {
noteSearchQuery.value = ''
noteSearchResults.value = []
nextTick(() => {
(wrapperEl.value?.querySelector('.note-picker-search') as HTMLInputElement)?.focus()
})
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer)
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim()
if (!q) { noteSearchResults.value = []; return }
noteSearchLoading.value = true
try {
const data = await apiGet<{ notes: Note[] }>(`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`)
noteSearchResults.value = data.notes.map((n) => ({ id: n.id, title: n.title }))
} catch {
noteSearchResults.value = []
} finally {
noteSearchLoading.value = false
}
}, 250)
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note
showNotePicker.value = false
}
function removeAttachedNote() {
attachedNote.value = null
}
// ── Voice (click-to-toggle + VAD speech detection) ─────────────────────────
const transcribingVoice = ref(false)
const recorder = useVoiceRecorder()
// Tracks whether VAD detected speech during the current recording session.
// Used to skip the Whisper round-trip when the user manually stops without
// ever speaking — the recording is ambient noise and will transcribe to "".
const vadSawSpeech = ref(false)
const vad = useVad({
onSpeechEnd: () => {
// VAD auto-stop: speech was detected and the user has paused. Proceed
// to transcribe the MediaRecorder output.
void stopRecording(false)
},
onNoSpeech: () => {
useToastStore().show('No speech detected', 'warning')
},
onVadError: (msg) => {
useToastStore().show(`VAD failed: ${msg}`, 'error')
},
})
// Live mic halo. A solid red disc sits behind the mic button and scales
// with `vad.amplitude` (0..1 RMS, already amplified for visibility).
const micGlowStyle = computed(() => {
if (!recorder.recording.value) return { display: 'none' }
const pulse = 0.2 + Math.min(vad.amplitude.value, 1) * 0.8
return {
transform: `translate(-50%, -50%) scale(${1 + pulse * 1.6})`,
opacity: String(0.45 + pulse * 0.4),
}
})
// vad.speaking flips true on speech-start. Watch it once per session to
// capture that speech was detected at some point, without clearing when
// speech ends. This drives the no-speech guard in stopRecording.
watch(() => vad.speaking.value, (on) => {
if (on) vadSawSpeech.value = true
})
async function toggleVoice() {
if (transcribingVoice.value) return
if (recorder.recording.value) {
await stopRecording(true)
} else {
await startRecording()
}
}
async function startRecording() {
if (!voiceEnabled.value || recorder.recording.value) return
if (!recorder.isSupported) {
useToastStore().show('Microphone requires HTTPS or localhost', 'error')
return
}
vadSawSpeech.value = false
await recorder.startRecording()
if (recorder.error.value) {
useToastStore().show(recorder.error.value, 'error')
return
}
if (recorder.stream.value) {
await vad.start(recorder.stream.value)
}
}
async function stopRecording(manual: boolean) {
if (manual) {
await vad.stopAndCheck()
} else {
await vad.stop()
}
if (!recorder.recording.value) return
transcribingVoice.value = true
try {
const blob = await recorder.stopRecording()
// No-speech guard: user manually stopped without VAD seeing speech.
// Skip Whisper — the toast was already shown by onNoSpeech.
if (manual && !vadSawSpeech.value) {
return
}
// Pass last assistant message as context to reduce STT mishearings
const msgs = store.currentConversation?.messages ?? []
const lastAssistant = [...msgs].reverse().find(m => m.role === 'assistant')?.content
const { transcript } = await transcribeAudio(blob, lastAssistant)
if (transcript.trim()) {
messageInput.value = transcript.trim()
await nextTick()
autoResize()
onSubmit()
}
} catch { /* transcription failed silently */ }
finally { transcribingVoice.value = false }
}
// ── Click-outside to dismiss speaker popover ──────────────────────────────────
function onDocumentMousedown(e: MouseEvent) {
if (!speakerPopoverOpen.value) return
const target = e.target as HTMLElement | null
if (!target?.closest('.speaker-wrapper')) speakerPopoverOpen.value = false
}
onMounted(() => document.addEventListener('mousedown', onDocumentMousedown))
onUnmounted(() => document.removeEventListener('mousedown', onDocumentMousedown))
// ── Exposed interface ─────────────────────────────────────────────────────────
function focus() {
inputEl.value?.focus()
}
function prefill(text: string) {
messageInput.value = text
nextTick(() => {
autoResize()
inputEl.value?.focus()
})
}
defineExpose({ focus, prefill })
</script>
<template>
<div ref="wrapperEl" class="chat-input-bar" :class="{ 'chat-input-bar--pill': pill }">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" aria-label="Remove" @click="removeAttachedNote">&times;</button>
</span>
</div>
<div class="input-row">
<!-- Note picker -->
<div class="note-picker-wrapper">
<button
class="btn-icon"
@click="toggleNotePicker"
:disabled="!store.chatReady"
title="Attach a note"
>
<Paperclip :size="16" />
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>{{ note.title || 'Untitled' }}</div>
<div v-if="noteSearchLoading" class="note-picker-empty">Searching...</div>
<div v-else-if="noteSearchQuery && !noteSearchResults.length" class="note-picker-empty">No notes found</div>
</div>
</div>
</div>
<!-- Textarea -->
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="!store.chatReady ? 'Chat unavailable' : store.streaming ? 'Type to queue… (Enter to queue)' : placeholder"
:disabled="!store.chatReady"
rows="1"
class="input-textarea"
></textarea>
<!-- Speaker / listen-mode popover -->
<div v-if="voiceTtsEnabled" class="speaker-wrapper">
<button
class="btn-icon btn-speaker"
:class="{ 'speaker-active': listenMode, 'speaker-busy': tts.speaking.value }"
@click="speakerPopoverOpen = !speakerPopoverOpen"
:title="listenMode ? 'Listen mode on' : 'Listen / volume'"
aria-label="Listen and volume settings"
>
<Volume2 v-if="!tts.speaking.value" :size="16" />
<VolumeX v-else :size="16" />
</button>
<div v-if="speakerPopoverOpen" class="speaker-popover">
<button
class="speaker-row speaker-row--toggle"
:class="{ 'speaker-row--active': listenMode }"
@click="toggleListen"
>
<span class="speaker-row-label">{{ listenMode ? 'Listening' : 'Read aloud' }}</span>
<span class="speaker-switch" :class="{ on: listenMode }"></span>
</button>
<div class="speaker-row">
<span class="speaker-row-label">Volume</span>
<input
type="range" min="0" max="1" step="0.05"
:value="audio.volume.value"
@input="setVoiceVolume(+($event.target as HTMLInputElement).value)"
class="speaker-volume-range"
aria-label="Volume"
/>
<span class="speaker-volume-pct">{{ Math.round(audio.volume.value * 100) }}%</span>
</div>
<button
v-if="tts.speaking.value"
class="speaker-row speaker-row--stop"
@click="tts.stop()"
>
<Square :size="16" fill="currentColor" />
<span>Stop playback</span>
</button>
</div>
</div>
<!-- PTT mic (with live amplitude halo behind) -->
<div v-if="voiceEnabled" class="mic-wrapper">
<div class="mic-glow" :style="micGlowStyle" aria-hidden="true"></div>
<button
class="btn-icon btn-mic"
:class="{ 'mic-recording': recorder.recording.value, 'mic-transcribing': transcribingVoice }"
@click.prevent="toggleVoice"
:disabled="transcribingVoice || !store.chatReady"
:title="recorder.recording.value ? 'Click to stop (or wait for silence)' : 'Click to speak'"
:aria-label="recorder.recording.value ? 'Stop recording' : 'Start recording'"
>
<Mic v-if="!transcribingVoice" :size="16" />
<Loader2 v-else :size="16" class="mic-loader" />
</button>
</div>
<!-- Abort (streaming) or Send -->
<button
v-if="store.streaming"
class="btn-abort-inline"
@click="emit('abort')"
title="Stop generation"
>
<Square :size="16" fill="currentColor" />
</button>
<button
v-else
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
><ArrowUp :size="16" /></button>
</div>
</div>
</template>
<style scoped>
.chat-input-bar {
width: 100%;
}
.attached-note {
padding: 0.25rem 0.5rem;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.2rem;
background: var(--color-primary);
color: #fff;
border-radius: 10px;
padding: 0.15rem 0.4rem;
font-size: 0.75rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0 0.1rem;
}
.attached-note-remove:hover { color: #fff; }
.input-row {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 12px;
box-shadow: 0 2px 8px var(--color-shadow);
}
.chat-input-bar--pill .input-row {
border-radius: 24px;
padding: 0.6rem 0.6rem 0.6rem 1rem;
}
.input-textarea {
flex: 1;
resize: none;
padding: 0.35rem 0.5rem;
border: none;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
font-family: inherit;
font-size: 0.9rem;
max-height: 150px;
overflow-y: auto;
}
.input-textarea::placeholder { color: var(--color-input-bar-placeholder); }
.input-textarea:disabled { opacity: 0.5; }
.btn-icon {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.btn-icon:hover { opacity: 1; }
.btn-icon:disabled { opacity: 0.3; cursor: default; }
.btn-mic.mic-recording {
opacity: 1;
/* White icon sits on top of the red halo so it stays legible at any
pulse size. */
color: #fff;
position: relative;
z-index: 1;
}
.btn-mic.mic-transcribing { opacity: 0.5; }
.mic-loader { animation: mic-spin 1s linear infinite; }
@keyframes mic-spin { to { transform: rotate(360deg); } }
/* Mic wrapper + live amplitude halo. The glow is a filled red disc
absolutely positioned behind the button, scaled/faded by the
computed `micGlowStyle` so the user gets unmistakable feedback
that their voice is being picked up while the mic icon itself
stays put and readable on top. */
.mic-wrapper {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.mic-glow {
position: absolute;
top: 50%;
left: 50%;
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(
circle,
rgba(239, 68, 68, 0.9) 0%,
rgba(239, 68, 68, 0.6) 55%,
rgba(239, 68, 68, 0) 100%
);
transform: translate(-50%, -50%) scale(1);
transform-origin: center;
pointer-events: none;
/* Smooth between amplitude samples (~100ms) so the pulse feels continuous
rather than stepping. */
transition: transform 0.12s ease-out, opacity 0.12s ease-out;
z-index: 0;
}
.note-picker-wrapper { position: relative; }
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 260px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 20;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.45rem 0.65rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.85rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results { max-height: 180px; overflow-y: auto; }
.note-picker-item {
padding: 0.4rem 0.65rem;
cursor: pointer;
font-size: 0.85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover { background: var(--color-bg-secondary); }
.note-picker-empty { padding: 0.4rem 0.65rem; color: var(--color-text-muted); font-size: 0.8rem; }
.btn-send {
width: 30px;
min-width: 30px;
height: 30px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--gradient-cta);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1rem;
flex-shrink: 0;
transition: box-shadow 0.15s;
}
.btn-send:hover { box-shadow: 0 0 16px rgba(91, 74, 138, 0.35); }
.btn-send:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
.btn-abort-inline {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 50%;
cursor: pointer;
flex-shrink: 0;
}
.btn-abort-inline:hover { border-color: #ef4444; color: #ef4444; }
/* Speaker / listen-mode popover */
.speaker-wrapper { position: relative; display: inline-flex; flex-shrink: 0; }
.btn-speaker.speaker-active { opacity: 1; color: var(--color-primary); }
.btn-speaker.speaker-busy { opacity: 1; color: #f59e0b; }
.speaker-popover {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
min-width: 220px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
padding: 0.35rem;
z-index: 30;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.speaker-row {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
background: none;
border: none;
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.85rem;
cursor: default;
text-align: left;
width: 100%;
}
.speaker-row--toggle { cursor: pointer; }
.speaker-row--toggle:hover { background: var(--color-bg-secondary); }
.speaker-row--active { color: var(--color-primary); }
.speaker-row--stop {
cursor: pointer;
color: var(--color-text-muted);
justify-content: flex-start;
}
.speaker-row--stop:hover { color: #ef4444; background: var(--color-bg-secondary); }
.speaker-row-label { flex: 1; }
.speaker-volume-range { flex: 1; min-width: 0; }
.speaker-volume-pct {
font-size: 0.75rem;
color: var(--color-text-muted);
min-width: 2.5rem;
text-align: right;
}
.speaker-switch {
position: relative;
width: 28px;
height: 16px;
border-radius: 9999px;
background: var(--color-border);
transition: background 0.15s;
flex-shrink: 0;
}
.speaker-switch::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--color-bg-card);
transition: transform 0.15s;
}
.speaker-switch.on { background: var(--color-primary); }
.speaker-switch.on::after { transform: translateX(12px); }
</style>
+21 -5
View File
@@ -41,6 +41,16 @@ function formatMs(ms: number | null | undefined): string {
return `${(ms / 1000).toFixed(1)}s`;
}
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
// Hide LEGACY system-role daily_prep messages from earlier versions. The
// current journal prep is a normal assistant message (renders with proper
// bubble styling). Old system-role rows lurking in existing conversations
// would render as a flat unstyled block — filter them.
const hideMessage = computed(() =>
props.message.role === "system" && metadata.value.kind === "daily_prep"
);
const timingParts = computed((): string[] => {
const t = props.message.timing;
if (!t) return [];
@@ -57,7 +67,7 @@ const timingParts = computed((): string[] => {
</script>
<template>
<div class="chat-message" :class="`role-${message.role}`">
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
<div class="message-group">
<div class="message-bubble">
<div class="message-header">
@@ -145,7 +155,7 @@ const timingParts = computed((): string[] => {
}
.role-label {
font-size: 0.75rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
@@ -157,7 +167,6 @@ const timingParts = computed((): string[] => {
color: var(--color-primary);
font-family: 'Fraunces', Georgia, serif;
font-optical-sizing: auto;
font-style: italic;
font-size: 0.8rem;
text-transform: none;
letter-spacing: 0;
@@ -165,14 +174,14 @@ const timingParts = computed((): string[] => {
.thinking-block {
margin-bottom: 0.5rem;
border-left: 2px solid rgba(129, 140, 248, 0.35);
border-left: 2px solid rgba(91, 74, 138, 0.35);
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
@@ -205,6 +214,12 @@ details[open] .thinking-summary::before {
overflow-y: auto;
background: var(--color-bg-secondary);
}
/* Long-form line-height (1.7) on assistant bubbles per the design system —
chat is a reading surface, not a snippet stream. User bubbles stay tighter. */
.role-assistant .message-content {
line-height: 1.7;
}
.message-content {
font-size: 0.95rem;
line-height: 1.55;
@@ -281,4 +296,5 @@ details[open] .thinking-summary::before {
margin-right: 0.2rem;
opacity: 0.5;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import { renderMarkdown } from '@/utils/markdown'
import ToolCallCard from '@/components/ToolCallCard.vue'
import ToolConfirmCard from '@/components/ToolConfirmCard.vue'
const store = useChatStore()
const settingsStore = useSettingsStore()
const streamingRendered = computed(() => {
if (!store.streamingContent) return ''
return renderMarkdown(store.streamingContent)
})
</script>
<template>
<div class="chat-message role-assistant">
<div class="message-bubble streaming-bubble">
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<div v-if="store.streamingToolCalls.length" class="streaming-tool-calls">
<ToolCallCard
v-for="(tc, i) in store.streamingToolCalls"
:key="i"
:tool-call="tc"
/>
</div>
<ToolConfirmCard
v-if="store.streamingPendingTool"
:pending-tool="store.streamingPendingTool"
@accept="store.confirmTool(true)"
@decline="store.confirmTool(false)"
/>
<div v-if="store.streamingStatus" class="streaming-status-line">
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<details
v-if="store.streamingThinking"
class="thinking-block"
:open="!store.streamingContent"
>
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
</details>
<div class="message-content prose" v-html="streamingRendered"></div>
<span
v-if="!store.streamingStatus && !store.streamingThinking && !store.streamingContent"
class="typing-indicator"
></span>
</div>
</div>
</template>
@@ -1,351 +0,0 @@
<script setup lang="ts">
import { ref, nextTick } from "vue";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import type { Note } from "@/types/note";
const emit = defineEmits<{
submit: [payload: { content: string; contextNoteId?: number }];
}>();
const store = useChatStore();
const messageInput = ref("");
const inputEl = ref<HTMLTextAreaElement | null>(null);
// Note picker state
const attachedNote = ref<{ id: number; title: string } | null>(null);
const showNotePicker = ref(false);
const noteSearchQuery = ref("");
const noteSearchResults = ref<{ id: number; title: string }[]>([]);
const noteSearchLoading = ref(false);
let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
function onSubmit() {
const content = messageInput.value.trim();
if (!content) return;
emit("submit", {
content,
contextNoteId: attachedNote.value?.id,
});
messageInput.value = "";
attachedNote.value = null;
resetTextareaHeight();
}
function onInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSubmit();
}
}
function autoResize() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 120) + "px";
}
function resetTextareaHeight() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
}
// Note picker
function toggleNotePicker() {
showNotePicker.value = !showNotePicker.value;
if (showNotePicker.value) {
noteSearchQuery.value = "";
noteSearchResults.value = [];
nextTick(() => {
const el = document.querySelector(
".dashboard-chat .note-picker-search"
) as HTMLInputElement;
el?.focus();
});
}
}
function onNoteSearchInput() {
if (noteSearchTimer) clearTimeout(noteSearchTimer);
noteSearchTimer = setTimeout(async () => {
const q = noteSearchQuery.value.trim();
if (!q) {
noteSearchResults.value = [];
return;
}
noteSearchLoading.value = true;
try {
const data = await apiGet<{ notes: Note[] }>(
`/api/notes?q=${encodeURIComponent(q)}&all=true&limit=5`
);
noteSearchResults.value = data.notes.map((n) => ({
id: n.id,
title: n.title,
}));
} catch {
noteSearchResults.value = [];
} finally {
noteSearchLoading.value = false;
}
}, 250);
}
function selectNote(note: { id: number; title: string }) {
attachedNote.value = note;
showNotePicker.value = false;
}
function removeAttachedNote() {
attachedNote.value = null;
}
function focus() {
inputEl.value?.focus();
}
defineExpose({ focus });
</script>
<template>
<div class="dashboard-chat">
<!-- Attached note pill -->
<div v-if="attachedNote" class="attached-note">
<span class="attached-note-pill">
{{ attachedNote.title }}
<button class="attached-note-remove" aria-label="Remove attached note" @click="removeAttachedNote">
&times;
</button>
</span>
</div>
<div class="chat-input-bar">
<div class="note-picker-wrapper">
<button
class="btn-attach"
@click="toggleNotePicker"
:disabled="!store.chatReady"
title="Attach a note"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"
/>
</svg>
</button>
<div v-if="showNotePicker" class="note-picker-dropdown">
<input
class="note-picker-search"
v-model="noteSearchQuery"
@input="onNoteSearchInput"
placeholder="Search notes..."
/>
<div class="note-picker-results">
<div
v-for="note in noteSearchResults"
:key="note.id"
class="note-picker-item"
@click="selectNote(note)"
>
{{ note.title || "Untitled" }}
</div>
<div v-if="noteSearchLoading" class="note-picker-empty">
Searching...
</div>
<div
v-else-if="noteSearchQuery && !noteSearchResults.length"
class="note-picker-empty"
>
No notes found
</div>
</div>
</div>
</div>
<textarea
ref="inputEl"
v-model="messageInput"
@keydown="onInputKeydown"
@input="autoResize"
:placeholder="
!store.chatReady ? 'Chat unavailable'
: store.streaming ? 'Type to queue… (Enter to queue)'
: 'Start a new chat… (Enter to send)'
"
:disabled="!store.chatReady"
rows="1"
></textarea>
<button
class="btn-send"
@click="onSubmit"
:disabled="!messageInput.trim() || !store.chatReady"
>
&uarr;
</button>
</div>
</div>
</template>
<style scoped>
.dashboard-chat {
margin-top: 0.75rem;
}
.attached-note {
padding: 0.25rem 0;
}
.attached-note-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: var(--color-primary);
color: #fff;
border-radius: 12px;
padding: 0.2rem 0.5rem;
font-size: 0.8rem;
}
.attached-note-remove {
background: none;
border: none;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 1rem;
line-height: 1;
padding: 0 0.15rem;
}
.attached-note-remove:hover {
color: #fff;
}
.chat-input-bar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.5rem 0.5rem 0.75rem;
background: var(--color-input-bar-bg);
border-radius: 20px;
box-shadow: 0 2px 12px var(--color-shadow);
}
.chat-input-bar textarea {
flex: 1;
resize: none;
padding: 0.4rem 0.5rem;
border: none;
border-radius: 12px;
font-family: inherit;
font-size: 0.95rem;
background: transparent;
color: var(--color-input-bar-text);
outline: none;
max-height: 120px;
overflow-y: auto;
}
.chat-input-bar textarea::placeholder {
color: var(--color-input-bar-placeholder);
}
.chat-input-bar textarea:disabled {
opacity: 0.5;
}
/* Note picker */
.note-picker-wrapper {
position: relative;
}
.btn-attach {
background: none;
border: none;
cursor: pointer;
color: var(--color-input-bar-text);
opacity: 0.6;
padding: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
}
.btn-attach:hover {
opacity: 1;
}
.btn-attach:disabled {
opacity: 0.3;
cursor: default;
}
.note-picker-dropdown {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
width: 280px;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow);
z-index: 10;
overflow: hidden;
}
.note-picker-search {
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: var(--color-text);
font-size: 0.9rem;
outline: none;
font-family: inherit;
box-sizing: border-box;
}
.note-picker-results {
max-height: 200px;
overflow-y: auto;
}
.note-picker-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.note-picker-item:hover {
background: var(--color-bg-secondary);
}
.note-picker-empty {
padding: 0.5rem 0.75rem;
color: var(--color-text-muted);
font-size: 0.85rem;
}
.btn-send {
width: 34px;
min-width: 34px;
height: 34px;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: 50%;
cursor: pointer;
font-size: 1.1rem;
flex-shrink: 0;
}
.btn-send:disabled {
opacity: 0.35;
cursor: default;
}
</style>
-2
View File
@@ -124,7 +124,6 @@ function markerFor(type: DiffLine['type']): string {
padding: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
font-style: italic;
}
.diff-line {
@@ -153,7 +152,6 @@ function markerFor(type: DiffLine['type']): string {
.diff-collapse {
color: var(--color-text-muted);
opacity: 0.6;
font-style: italic;
border-top: 1px dashed var(--color-border);
border-bottom: 1px dashed var(--color-border);
padding-top: 0.2rem;
+609
View File
@@ -0,0 +1,609 @@
<script setup lang="ts">
import { Trash2, X } from "lucide-vue-next";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import { createEvent, updateEvent, deleteEvent, type EventEntry, type EventCreatePayload, type EventUpdatePayload } from "@/api/client";
import ProjectSelector from "@/components/ProjectSelector.vue";
import { useToastStore } from "@/stores/toast";
const props = defineProps<{
// null = create mode; EventEntry = edit mode
event: EventEntry | null;
// pre-filled date string for create mode (YYYY-MM-DD or ISO)
initialDate?: string;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "created", event: EventEntry): void;
(e: "updated", event: EventEntry): void;
(e: "deleted", id: number): void;
}>();
const toast = useToastStore();
const isEditMode = computed(() => !!props.event);
const saving = ref(false);
const deleting = ref(false);
const deleteConfirm = ref(false);
// Form fields
const title = ref("");
const startDate = ref("");
const startTime = ref("");
const endDate = ref("");
const endTime = ref("");
const allDay = ref(false);
const description = ref("");
const location = ref("");
const color = ref("");
const projectId = ref<number | null>(null);
function dateFromIso(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(0, 10);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function timeFromIso(iso: string): string {
if (!iso.includes("T")) return "09:00";
const d = new Date(iso);
if (isNaN(d.getTime())) return iso.slice(11, 16);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
}
function toIso(date: string, time: string): string {
if (!time) return `${date}T00:00:00`;
// Include local timezone offset so the server stores the correct UTC time
const local = new Date(`${date}T${time}:00`);
const off = -local.getTimezoneOffset();
const sign = off >= 0 ? "+" : "-";
const h = String(Math.floor(Math.abs(off) / 60)).padStart(2, "0");
const min = String(Math.abs(off) % 60).padStart(2, "0");
return `${date}T${time}:00${sign}${h}:${min}`;
}
// ── Time helpers ──────────────────────────────────────────────────────────────
/** Round up to next 30-minute boundary */
function nextRoundedTime(): string {
const now = new Date();
let h = now.getHours();
let m = now.getMinutes();
if (m <= 30) { m = 30; }
else { m = 0; h = (h + 1) % 24; }
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** Add hours to a time string (HH:MM), returns HH:MM */
function addHours(time: string, hours: number): string {
const [h, m] = time.split(":").map(Number);
const totalMin = h * 60 + m + hours * 60;
const nh = Math.floor(totalMin / 60) % 24;
const nm = totalMin % 60;
return `${String(nh).padStart(2, "0")}:${String(nm).padStart(2, "0")}`;
}
/** Duration in minutes between two time strings */
function durationMin(start: string, end: string): number {
const [sh, sm] = start.split(":").map(Number);
const [eh, em] = end.split(":").map(Number);
return (eh * 60 + em) - (sh * 60 + sm);
}
/** True if a date+time is in the past */
const isPastEvent = computed(() => {
if (allDay.value || !startDate.value || !startTime.value) return false;
const dt = new Date(`${startDate.value}T${startTime.value}:00`);
return dt.getTime() < Date.now();
});
// Track duration so end moves with start
let _lastDurationMin = 60;
function resetForm() {
if (props.event) {
title.value = props.event.title;
allDay.value = props.event.all_day;
startDate.value = props.event.all_day ? props.event.start_dt.slice(0, 10) : dateFromIso(props.event.start_dt);
startTime.value = props.event.all_day ? "" : timeFromIso(props.event.start_dt);
endDate.value = props.event.end_dt ? (props.event.all_day ? props.event.end_dt.slice(0, 10) : dateFromIso(props.event.end_dt)) : startDate.value;
endTime.value = props.event.end_dt && !props.event.all_day ? timeFromIso(props.event.end_dt) : addHours(startTime.value || "09:00", 1);
description.value = props.event.description || "";
location.value = props.event.location || "";
color.value = props.event.color || "";
projectId.value = props.event.project_id;
_lastDurationMin = !allDay.value && startTime.value && endTime.value ? durationMin(startTime.value, endTime.value) : 60;
if (_lastDurationMin <= 0) _lastDurationMin = 60;
} else {
title.value = "";
allDay.value = false;
const base = props.initialDate ? dateFromIso(props.initialDate) : new Date().toISOString().slice(0, 10);
const roundedStart = nextRoundedTime();
startDate.value = base;
startTime.value = roundedStart;
endDate.value = base;
endTime.value = addHours(roundedStart, 1);
description.value = "";
location.value = "";
color.value = "";
projectId.value = null;
_lastDurationMin = 60;
}
deleteConfirm.value = false;
}
// When start time changes, move end time to preserve duration
watch(startTime, (newStart) => {
if (allDay.value || !newStart) return;
endTime.value = addHours(newStart, _lastDurationMin / 60);
});
// When start date changes, move end date to match (preserve same-day or multi-day gap)
watch(startDate, (newDate) => {
if (!newDate) return;
endDate.value = newDate;
});
// When end time changes manually, update the tracked duration (but guard against end < start)
watch(endTime, (newEnd) => {
if (allDay.value || !newEnd || !startTime.value) return;
const dur = durationMin(startTime.value, newEnd);
if (dur <= 0) {
// Snap back to start + 1 hour
endTime.value = addHours(startTime.value, 1);
_lastDurationMin = 60;
} else {
_lastDurationMin = dur;
}
});
// All-day toggle: clear/restore times
watch(allDay, (isAllDay) => {
if (isAllDay) {
startTime.value = "";
endTime.value = "";
} else {
const rounded = nextRoundedTime();
startTime.value = rounded;
endTime.value = addHours(rounded, 1);
_lastDurationMin = 60;
}
});
watch(() => props.event, resetForm, { immediate: true });
watch(() => props.initialDate, resetForm);
function handleKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (deleteConfirm.value) {
// Esc cancels the delete-confirm rather than closing the modal —
// gives the user a clear way out of the destructive prompt.
deleteConfirm.value = false;
return;
}
attemptClose();
}
}
onMounted(() => document.addEventListener("keydown", handleKeydown));
onUnmounted(() => document.removeEventListener("keydown", handleKeydown));
// ── Close / save flow ─────────────────────────────────────────────────────────
//
// All exit paths (X button, Esc, backdrop click) funnel through `attemptClose`.
// The Save button is gone — explicit-commit is replaced with auto-save-on-close.
//
// Validity-aware behavior:
// - Form valid → save (PATCH for edit, POST for create), then close.
// - Form invalid in EDIT mode → discard the in-memory change and close.
// A toast tells the user what happened so they don't think their edit
// silently landed.
// - Form invalid in CREATE mode → close silently (nothing existed to begin
// with; no need to call this out).
function isFormValid(): { valid: boolean; reason?: string } {
if (!title.value.trim()) {
return { valid: false, reason: "Title required" };
}
if (!startDate.value) {
return { valid: false, reason: "Start date required" };
}
if (!allDay.value && !startTime.value) {
return { valid: false, reason: "Start time required" };
}
return { valid: true };
}
let _closing = false;
async function attemptClose() {
if (_closing) return;
_closing = true;
try {
const validity = isFormValid();
if (!validity.valid) {
if (isEditMode.value) {
toast.show(`${validity.reason} — change discarded`, "warning");
}
// Create mode + invalid: silent close. Nothing was committed.
emit("close");
return;
}
await save();
emit("close");
} finally {
_closing = false;
}
}
async function save() {
const start_dt = allDay.value ? `${startDate.value}T00:00:00` : toIso(startDate.value, startTime.value);
const end_dt = endDate.value
? (allDay.value ? `${endDate.value}T00:00:00` : toIso(endDate.value, endTime.value))
: undefined;
saving.value = true;
try {
if (isEditMode.value && props.event) {
const payload: EventUpdatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
};
const updated = await updateEvent(props.event.id, payload);
emit("updated", updated);
} else {
const payload: EventCreatePayload = {
title: title.value.trim(),
start_dt,
end_dt,
all_day: allDay.value,
description: description.value,
location: location.value,
color: color.value,
project_id: projectId.value ?? undefined,
};
const created = await createEvent(payload);
emit("created", created);
}
} catch {
toast.show("Failed to save event", "error");
} finally {
saving.value = false;
}
}
async function doDelete() {
if (!props.event) return;
deleting.value = true;
try {
await deleteEvent(props.event.id);
toast.show("Event deleted", "success");
emit("deleted", props.event.id);
} catch {
toast.show("Failed to delete event", "error");
deleting.value = false;
}
}
</script>
<template>
<Teleport to="body">
<div class="modal-backdrop" @click.self="attemptClose">
<div class="modal-panel" role="dialog" aria-modal="true">
<!-- Header: trash + close (or inline delete-confirm) -->
<div class="modal-header">
<template v-if="!deleteConfirm">
<h2 class="modal-title">{{ isEditMode ? "Edit Event" : "New Event" }}</h2>
<div class="header-actions">
<button
v-if="isEditMode"
class="header-btn header-btn-danger"
@click="deleteConfirm = true"
title="Delete event"
aria-label="Delete event"
><Trash2 :size="16" /></button>
<button
class="header-btn"
@click="attemptClose"
title="Close"
aria-label="Close"
><X :size="16" /></button>
</div>
</template>
<template v-else>
<span class="delete-confirm-prompt">Delete this event?</span>
<div class="header-actions">
<button
type="button"
class="btn-danger"
:disabled="deleting"
@click="doDelete"
>{{ deleting ? "Deleting…" : "Yes, delete" }}</button>
<button
type="button"
class="btn-confirm-cancel"
@click="deleteConfirm = false"
>No</button>
</div>
</template>
</div>
<!-- Body: form (scrolls if it gets long) -->
<form class="modal-form" @submit.prevent="attemptClose">
<!-- Title -->
<div class="form-field">
<label class="form-label">Title <span class="required">*</span></label>
<input v-model="title" class="form-input" placeholder="Event title" autofocus />
</div>
<!-- All-day toggle -->
<div class="form-field form-field-row">
<label class="form-label form-label-inline">All day</label>
<button
type="button"
:class="['toggle-btn', { active: allDay }]"
@click="allDay = !allDay"
>{{ allDay ? "Yes" : "No" }}</button>
</div>
<!-- Start -->
<div class="form-field">
<label class="form-label">Start <span class="required">*</span></label>
<div class="dt-row">
<input v-model="startDate" type="date" class="form-input dt-date" required />
<input v-if="!allDay" v-model="startTime" type="time" class="form-input dt-time" required />
</div>
<p v-if="isPastEvent" class="form-past-hint">This event is in the past</p>
</div>
<!-- End -->
<div class="form-field">
<label class="form-label">End</label>
<div class="dt-row">
<input v-model="endDate" type="date" class="form-input dt-date" :min="startDate" />
<input v-if="!allDay" v-model="endTime" type="time" class="form-input dt-time" />
</div>
</div>
<!-- Location -->
<div class="form-field">
<label class="form-label">Location <span class="form-hint">(optional)</span></label>
<input v-model="location" class="form-input" placeholder="Location" />
</div>
<!-- Description -->
<div class="form-field">
<label class="form-label">Description <span class="form-hint">(optional)</span></label>
<textarea v-model="description" class="form-input form-textarea" placeholder="Description" rows="3" />
</div>
<!-- Color -->
<div class="form-field form-field-row">
<label class="form-label form-label-inline">Color</label>
<div class="color-row">
<input v-model="color" type="color" class="color-picker" title="Pick event color" />
<input v-model="color" class="form-input color-hex" placeholder="#5B4A8A" />
<button v-if="color" type="button" class="btn-clear-color" @click="color = ''"><X :size="16" /></button>
</div>
</div>
<!-- Project -->
<div class="form-field">
<label class="form-label">Project <span class="form-hint">(optional)</span></label>
<ProjectSelector v-model="projectId" />
</div>
<!-- A hidden submit so Enter inside text inputs triggers attemptClose,
matching the no-explicit-Save-button intent: Enter commits. -->
<button type="submit" class="hidden-submit" :disabled="saving" tabindex="-1" aria-hidden="true" />
</form>
</div>
</div>
</Teleport>
</template>
<style scoped>
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.25rem;
}
.modal-panel {
background: var(--color-surface, #1a1b1e);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
width: min(480px, 100%);
max-height: calc(100vh - 2.5rem);
display: flex;
flex-direction: column;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem 0.85rem 1.5rem;
border-bottom: 1px solid var(--color-border, #2a2b30);
background: var(--color-surface, #1a1b1e);
min-height: 3rem;
}
.modal-title {
font-size: 1.05rem;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
.header-actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.header-btn {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 0.4rem;
border-radius: 6px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.header-btn:hover {
background: var(--color-hover, rgba(255,255,255,0.06));
color: var(--color-text, #e8e9f0);
}
/* Trash in header: subtle until hover, then Oxblood. Lower visual weight
than Save would have been — destructive actions shouldn't loom. */
.header-btn-danger:hover {
background: var(--color-action-destructive);
color: #fff;
}
/* Inline delete-confirm prompt replaces the title row */
.delete-confirm-prompt {
font-size: 0.95rem;
color: var(--color-text, #e8e9f0);
font-weight: 500;
}
/* Form scrolls inside the panel when content overflows */
.modal-form {
padding: 1.25rem 1.5rem 1.5rem;
display: flex;
flex-direction: column;
gap: 1.1rem;
overflow-y: auto;
}
.form-field { display: flex; flex-direction: column; gap: 0.35rem; }
.form-field-row { flex-direction: row; align-items: center; gap: 0.75rem; }
.form-label {
font-size: 0.78rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.form-label-inline { flex-shrink: 0; margin: 0; }
.form-hint { font-weight: 400; text-transform: none; letter-spacing: 0; opacity: 0.7; }
.required { color: #f87171; }
.form-input {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text, #e8e9f0);
border-radius: 6px;
padding: 0.5rem 0.65rem;
font-size: 0.9rem;
width: 100%;
box-sizing: border-box;
transition: border-color 0.15s;
}
.form-input:focus { outline: none; border-color: var(--color-primary); }
.form-textarea { resize: vertical; min-height: 5rem; font-family: inherit; }
.dt-row { display: flex; gap: 0.5rem; }
.dt-date { flex: 1; }
.dt-time { width: 7.5rem; flex-shrink: 0; }
.form-past-hint {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.toggle-btn {
background: var(--color-input-bg, #111113);
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
border-radius: 6px;
padding: 0.3rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.color-row { display: flex; align-items: center; gap: 0.5rem; flex: 1; }
.color-picker { width: 2.4rem; height: 2.2rem; border: none; padding: 0; border-radius: 4px; cursor: pointer; flex-shrink: 0; }
.color-hex { flex: 1; }
.btn-clear-color {
background: none;
border: none;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 0.2rem 0.3rem;
font-size: 0.85rem;
}
/* Confirm-delete buttons (only shown during the inline confirm flow) */
.btn-danger {
background: var(--color-action-destructive);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-danger:hover:not(:disabled) { background: var(--color-action-destructive-hover); }
.btn-danger:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-confirm-cancel {
background: var(--color-action-secondary);
border: none;
color: #fff;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-confirm-cancel:hover { background: var(--color-action-secondary-hover); }
/* Hidden submit lets Enter-in-text-input trigger the same close-with-save
path as X / Esc / backdrop. No visible Save button needed. */
.hidden-submit {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0,0,0,0);
border: 0;
}
</style>

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