Compare commits

...

33 Commits

Author SHA1 Message Date
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
82 changed files with 4882 additions and 7053 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ permissions:
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledassistant
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
jobs:
typecheck:
+1 -1
View File
@@ -1,4 +1,4 @@
# 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.
@@ -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")
+2 -2
View File
@@ -1,4 +1,4 @@
# Fabled Assistant — Quick Start
# Fabled Scribe — Quick Start
#
# No build required. Pulls the latest pre-built image from the registry.
#
@@ -13,7 +13,7 @@
services:
app:
image: git.fabledsword.com/bvandeusen/fabledassistant:latest
image: git.fabledsword.com/bvandeusen/fabledscribe:latest
ports:
- "5000:5000"
environment:
+534
View File
@@ -0,0 +1,534 @@
# 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.*
## 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)
*All initial-iteration threads resolved. Next phase is the polish pass — applying the system to existing UI, component by component. New threads will accumulate here as we discover gaps in the polish pass.*
+127 -135
View File
@@ -1,16 +1,16 @@
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
"""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, briefing
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, journal
load_dotenv()
_INSTRUCTIONS = """
Fable Assistant is a self-hosted second-brain and project management system with LLM integration.
Fabled Scribe is a self-hosted second-brain and project management system with LLM integration.
## Data model
@@ -68,10 +68,19 @@ Use the direct CRUD tools when:
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## RSS / Briefing
## Journal
Fable runs a daily briefing that summarises tasks, calendar events, and RSS feed items.
Use `fable_add_rss_feed` / `fable_remove_rss_feed` to manage the feeds included in that briefing.
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
@@ -582,148 +591,131 @@ async def fable_get_app_logs(
# ---------------------------------------------------------------------------
# Briefing / RSS
# Journal — daily prep, day payloads, moments
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS/Atom feeds configured in Fable for the current user.
async def fable_get_today_journal() -> dict:
"""Fetch today's Journal day payload.
Returns id, title, url, category, and last_fetched_at for each feed.
These feeds are summarised in the user's daily briefing.
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 briefing.list_rss_feeds(client)
return await journal.get_today_journal(client)
@mcp.tool()
async def fable_add_rss_feed(
url: str,
title: str = "",
category: str = "",
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:
"""Add an RSS or Atom feed to Fable's daily briefing.
"""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:
url: The RSS/Atom feed URL (required).
title: Optional display name. If omitted, auto-populated from feed metadata.
category: Optional category label to group feeds (e.g. "news", "tech", "finance").
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 the created feed object including its assigned id.
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 briefing.add_rss_feed(
return await journal.list_moments(
client,
url=url,
title=title or None,
category=category or None,
)
@mcp.tool()
async def fable_remove_rss_feed(feed_id: int) -> dict:
"""Remove an RSS feed from Fable by its ID."""
async with FableClient() as client:
return await briefing.remove_rss_feed(client, feed_id=feed_id)
# ---------------------------------------------------------------------------
# Briefing introspection & control
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_briefings() -> dict:
"""List the user's briefing conversations, newest first.
Each briefing has an associated date and conversation id. Use
``fable_get_briefing_messages`` or ``fable_get_conversation`` with
the id to pull the actual briefing text and tool-call receipts.
Returns a dict with a ``conversations`` list.
"""
async with FableClient() as client:
return await briefing.list_briefing_conversations(client)
@mcp.tool()
async def fable_get_today_briefing() -> dict:
"""Fetch today's briefing conversation, creating it if needed.
Returns the full conversation object including all messages — the
scheduled briefing assistant turns, any tool calls the agentic path
made, and any chat replies the user has sent in the briefing thread.
Use this to inspect what a briefing actually said (and what tool
results grounded it) without having to query by id.
"""
async with FableClient() as client:
return await briefing.get_today_briefing(client)
@mcp.tool()
async def fable_get_briefing_messages(conversation_id: int) -> dict:
"""Fetch all messages for a specific briefing conversation.
Args:
conversation_id: The briefing conversation id from fable_list_briefings.
Returns a dict with a ``messages`` list. Each message includes
role, content, tool_calls (with results), and metadata — the
metadata carries ``briefing_slot`` tags on agentic briefing turns.
"""
async with FableClient() as client:
return await briefing.get_briefing_messages(
client, conversation_id=conversation_id,
)
@mcp.tool()
async def fable_trigger_briefing(slot: str = "compilation") -> dict:
"""Manually run a briefing slot for the current user.
Fires the same data refresh the scheduler does (RSS, weather),
runs the agentic briefing pipeline, and writes the result into
today's briefing conversation. Use this to test prompt changes
without waiting for the next scheduled slot.
Args:
slot: One of ``compilation`` (full morning, default), ``morning``,
``midday``, or ``afternoon``.
Returns a dict with ``conversation_id``, ``message_id``, and ``slot``.
"""
async with FableClient() as client:
return await briefing.trigger_briefing(client, slot=slot)
@mcp.tool()
async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
"""Wipe today's briefing and (optionally) regenerate from scratch.
Deletes every message in today's briefing conversation — the
conversation row itself is kept so its id stays stable for any
open UI sessions. If ``run_compilation`` is True (the default),
immediately fires the compilation slot afterward so a fresh
briefing lands in place of the deleted content.
Use this when iterating on briefing prompts or tools and you want
to start from a clean slate rather than append another slot update
on top of stale output.
Args:
run_compilation: When True, fire ``POST /api/briefing/trigger``
for the ``compilation`` slot immediately after wiping.
Set False to only wipe without regenerating.
Returns a dict with ``reset`` (the delete result: deleted count +
conversation id) and ``triggered`` (the new message payload, or
null if regeneration was skipped).
"""
async with FableClient() as client:
return await briefing.reset_today_briefing(
client, run_compilation=run_compilation,
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,
)
@@ -734,18 +726,18 @@ async def fable_reset_today_briefing(run_compilation: bool = True) -> dict:
@mcp.tool()
async def fable_get_conversation(conversation_id: int) -> dict:
"""Fetch any conversation (chat or briefing) with its full message list.
"""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 debugging agentic briefings, inspecting chat history,
or verifying that a tool actually ran with the expected arguments.
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 briefing.get_conversation(
return await journal.get_conversation(
client, conversation_id=conversation_id,
)
-98
View File
@@ -1,98 +0,0 @@
"""MCP tools for Fable briefings and RSS feed management."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
# ── RSS feeds ────────────────────────────────────────────────────────────────
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
"""List the user's RSS feeds."""
return await client.get("/api/briefing/feeds")
async def add_rss_feed(
client: FableClient,
*,
url: str,
title: str | None = None,
category: str | None = None,
) -> dict[str, Any]:
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
payload: dict[str, Any] = {"url": url}
if title:
payload["title"] = title
if category:
payload["category"] = category
return await client.post("/api/briefing/feeds", json=payload)
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
"""Remove an RSS feed by ID."""
return await client.delete(f"/api/briefing/feeds/{feed_id}")
# ── Briefings ────────────────────────────────────────────────────────────────
async def list_briefing_conversations(client: FableClient) -> dict[str, Any]:
"""List the user's briefing conversations, newest first."""
return await client.get("/api/briefing/conversations")
async def get_today_briefing(client: FableClient) -> dict[str, Any]:
"""Fetch today's briefing conversation with all messages."""
return await client.get("/api/briefing/conversations/today")
async def get_briefing_messages(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch messages for a specific briefing conversation."""
return await client.get(f"/api/briefing/conversations/{conversation_id}/messages")
async def trigger_briefing(
client: FableClient,
*,
slot: str = "compilation",
) -> dict[str, Any]:
"""Manually trigger a briefing slot — fires data refresh and runs the pipeline.
Slot is one of: compilation (full morning), morning, midday, afternoon.
"""
return await client.post("/api/briefing/trigger", json={"slot": slot})
async def reset_today_briefing(
client: FableClient,
*,
run_compilation: bool = True,
) -> dict[str, Any]:
"""Delete all messages in today's briefing and optionally regenerate.
Wipes the messages in today's briefing conversation (keeping the
conversation row), then, if ``run_compilation`` is True, fires the
compilation slot so a fresh briefing lands in its place.
"""
reset = await client.post("/api/briefing/reset-today")
if not run_compilation:
return {"reset": reset, "triggered": None}
triggered = await client.post(
"/api/briefing/trigger", json={"slot": "compilation"}
)
return {"reset": reset, "triggered": triggered}
# ── Generic conversations ───────────────────────────────────────────────────
async def get_conversation(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch any conversation (chat or briefing) with all messages and tool calls."""
return await client.get(f"/api/chat/conversations/{conversation_id}")
+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}")
+1 -1
View File
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.2.6"
description = "MCP server for Fabled Assistant"
description = "MCP server for Fabled Scribe"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0",
+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>
+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',
+71 -88
View File
@@ -300,141 +300,124 @@ 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';
}
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;
metadata?: Record<string, unknown> | null;
}
const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
enabled: false,
locations: {},
use_caldav_event_locations: false,
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
try {
const data = await apiGet<BriefingConfig>('/api/briefing/config');
return { ...DEFAULT_BRIEFING_CONFIG, ...data };
} catch {
return { ...DEFAULT_BRIEFING_CONFIG };
}
export 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, category?: string): Promise<BriefingFeed> {
const body: Record<string, string> = { url };
if (category?.trim()) body.category = category.trim();
const data = await apiPost<{ id: number; url: string; title: string; category: string | null }>('/api/briefing/feeds', body);
return { ...data, last_fetched_at: null };
export async function saveJournalConfig(config: JournalConfig): Promise<void> {
await apiPut('/api/journal/config', config);
}
export async function refreshBriefingFeeds(): Promise<{ feeds_refreshed: number; new_items: number }> {
return apiPost('/api/briefing/feeds/refresh', {});
export async function getJournalToday(): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>('/api/journal/today');
}
export async function deleteBriefingFeed(id: number): Promise<void> {
await apiDelete(`/api/briefing/feeds/${id}`);
export async function getJournalDay(isoDate: string): Promise<JournalDayPayload> {
return apiGet<JournalDayPayload>(`/api/journal/day/${isoDate}`);
}
export async function getBriefingConversations(): Promise<BriefingConversation[]> {
const data = await apiGet<{ conversations: BriefingConversation[] }>('/api/briefing/conversations');
return data.conversations;
export async function getJournalDays(): Promise<string[]> {
const data = await apiGet<{ days: string[] }>('/api/journal/days');
return data.days;
}
export async function getBriefingToday(): Promise<{ id: number; title: string; messages: BriefingMessage[] }> {
return apiGet('/api/briefing/conversations/today');
export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
return apiPost('/api/journal/trigger-prep', date ? { date } : {});
}
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 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 triggerBriefingSlot(slot: string): Promise<void> {
await apiPost('/api/briefing/trigger', { slot });
export async function updateJournalMoment(id: number, patch: Partial<JournalMoment>): Promise<JournalMoment> {
return apiPatch<JournalMoment>(`/api/journal/moments/${id}`, patch);
}
export async function postRssReaction(
rssItemId: number,
reaction: 'up' | 'down'
): Promise<{ ok: boolean; action: string }> {
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
}
export async function deleteRssReaction(rssItemId: number): Promise<void> {
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
}
export async function openArticleInChat(
itemId: number,
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
return apiPost(`/api/chat/from-article/${itemId}`, {});
export async function 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;
+5 -9
View File
@@ -5,7 +5,6 @@ import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
import { useChatStore } from "@/stores/chat";
import { useSettingsStore } from "@/stores/settings";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
@@ -13,12 +12,11 @@ const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore();
const chatStore = useChatStore();
const settingsStore = useSettingsStore();
const router = useRouter();
const route = useRoute();
const isChatActive = computed(() => route.path.startsWith("/chat"))
const isKnowledgeActive = computed(() => route.path === "/" || route.path === "/knowledge");
const isKnowledgeActive = computed(() => route.path === "/knowledge");
const mobileMenuOpen = ref(false);
@@ -76,11 +74,10 @@ router.afterEach(() => {
<!-- Center: primary navigation (desktop) -->
<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="/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="/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 v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
</div>
</div>
@@ -125,12 +122,11 @@ router.afterEach(() => {
<!-- Mobile dropdown -->
<div v-if="mobileMenuOpen" class="mobile-menu">
<router-link to="/" class="nav-link" :class="{ 'router-link-active': isKnowledgeActive }">Knowledge</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="/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 v-if="settingsStore.rssEnabled" to="/news" class="nav-link">News</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>
@@ -1,413 +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',
})
// 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: var(--gradient-cta);
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: var(--gradient-cta);
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>
@@ -1,168 +0,0 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import type { ToolCallRecord } from "@/types/chat";
const props = defineProps<{
toolCalls: ToolCallRecord[];
}>();
const expanded = ref(false);
function shortName(fn: string): string {
return fn
.replace(/^(list|get|search|fetch)_/, "")
.replace(/_/g, " ");
}
interface Pill {
key: string;
name: string;
count: string | null;
state: "ok" | "empty" | "error";
}
const pills = computed<Pill[]>(() =>
props.toolCalls.map((tc, i) => {
const ok = tc.result?.success !== false && tc.status !== "error";
const data = tc.result?.data as Record<string, unknown> | undefined;
let count: string | null = null;
let state: Pill["state"] = ok ? "ok" : "error";
if (ok && data) {
const raw =
(typeof data.count === "number" && data.count) ||
(typeof data.total === "number" && data.total);
if (typeof raw === "number") {
count = String(raw);
if (raw === 0) state = "empty";
}
}
return {
key: `${tc.function}-${i}`,
name: shortName(tc.function),
count,
state,
};
}),
);
const errorCount = computed(() => pills.value.filter((p) => p.state === "error").length);
</script>
<template>
<div class="briefing-status" :class="{ expanded }">
<button class="briefing-status-toggle" @click="expanded = !expanded">
<span class="briefing-status-label">Gathered</span>
<span
v-for="p in pills"
:key="p.key"
class="briefing-status-pill"
:class="`state-${p.state}`"
>
{{ p.name }}<span v-if="p.count != null" class="pill-count">{{ p.count }}</span>
</span>
<span v-if="errorCount" class="briefing-status-errcount">{{ errorCount }} failed</span>
<span class="briefing-status-chevron" :class="{ open: expanded }"></span>
</button>
<div v-if="expanded" class="briefing-status-detail">
<ToolCallCard
v-for="(tc, i) in toolCalls"
:key="i"
:tool-call="tc"
/>
</div>
</div>
</template>
<style scoped>
.briefing-status {
margin: 0.25rem 0 0.5rem;
max-width: 80%;
}
.briefing-status-toggle {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
width: 100%;
padding: 0.35rem 0.55rem;
background: transparent;
border: 1px dashed var(--color-border);
border-radius: 8px;
cursor: pointer;
font: inherit;
color: var(--color-text-muted);
transition: border-color 0.15s, background 0.15s;
}
.briefing-status-toggle:hover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 4%, transparent);
}
.briefing-status-label {
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
font-size: 0.78rem;
color: var(--color-primary);
opacity: 0.85;
margin-right: 0.15rem;
}
.briefing-status-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.08rem 0.45rem;
font-size: 0.72rem;
line-height: 1.45;
border-radius: 999px;
border: 1px solid var(--color-border);
color: var(--color-text-secondary);
background: var(--color-bg-card);
white-space: nowrap;
}
.briefing-status-pill.state-empty {
opacity: 0.55;
}
.briefing-status-pill.state-error {
border-color: color-mix(in srgb, var(--color-danger, #ef4444) 60%, var(--color-border));
color: var(--color-danger, #ef4444);
}
.pill-count {
font-weight: 600;
font-variant-numeric: tabular-nums;
padding: 0 0.25rem;
border-radius: 4px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-text);
}
.state-error .pill-count {
background: color-mix(in srgb, var(--color-danger, #ef4444) 18%, transparent);
}
.briefing-status-errcount {
font-size: 0.7rem;
color: var(--color-danger, #ef4444);
font-weight: 600;
}
.briefing-status-chevron {
margin-left: auto;
font-size: 0.65rem;
opacity: 0.5;
transition: transform 0.15s;
}
.briefing-status-chevron.open {
transform: rotate(90deg);
}
.briefing-status-detail {
margin-top: 0.4rem;
padding-left: 0.55rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
</style>
+8 -69
View File
@@ -3,16 +3,8 @@ import { computed } from "vue";
import { renderMarkdown } from "@/utils/markdown";
import { useSettingsStore } from "@/stores/settings";
import ToolCallCard from "@/components/ToolCallCard.vue";
import BriefingToolStatusRow from "@/components/BriefingToolStatusRow.vue";
import type { Message } from "@/types/chat";
const SLOT_LABELS: Record<string, string> = {
compilation: "Full Briefing",
morning: "Morning Update",
midday: "Midday Update",
afternoon: "Afternoon Update",
};
const settingsStore = useSettingsStore();
const props = defineProps<{
@@ -51,26 +43,12 @@ function formatMs(ms: number | null | undefined): string {
const metadata = computed(() => (props.message.metadata ?? {}) as Record<string, unknown>);
const isBriefingIntermediate = computed(
() => props.message.role === "assistant" && metadata.value.briefing_intermediate === true,
);
const briefingSlot = computed(() => {
const slot = metadata.value.briefing_slot;
return typeof slot === "string" ? slot : null;
});
const slotLabel = computed(() => {
const slot = briefingSlot.value;
if (!slot) return null;
return SLOT_LABELS[slot] ?? slot;
});
const isSlotUpdate = computed(
() =>
!isBriefingIntermediate.value &&
briefingSlot.value != null &&
briefingSlot.value !== "compilation",
// 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[] => {
@@ -89,16 +67,11 @@ const timingParts = computed((): string[] => {
</script>
<template>
<!-- Briefing intermediate: compact status row, no bubble -->
<div v-if="isBriefingIntermediate" class="chat-message role-assistant briefing-intermediate-row">
<BriefingToolStatusRow :tool-calls="message.tool_calls ?? []" />
</div>
<div v-else class="chat-message" :class="[`role-${message.role}`, { 'slot-update': isSlotUpdate }]">
<div v-if="!hideMessage" class="chat-message" :class="`role-${message.role}`">
<div class="message-group">
<div class="message-bubble" :class="{ 'bubble-slot': isSlotUpdate }">
<div class="message-bubble">
<div class="message-header">
<span class="role-label">{{ roleLabel }}</span>
<span v-if="slotLabel" class="slot-badge" :class="{ 'slot-badge-update': isSlotUpdate }">{{ slotLabel }}</span>
<div class="message-actions" v-if="message.role === 'assistant' && !isStreaming">
<button class="btn-save" @click="emit('saveAsNote', message.id)" title="Save as note">
Save as Note
@@ -319,38 +292,4 @@ details[open] .thinking-summary::before {
opacity: 0.5;
}
/* ── Briefing intermediate row (no bubble) ──────────────── */
.briefing-intermediate-row {
margin-bottom: 0.5rem;
}
/* ── Slot badge (morning/midday/afternoon/full) ─────────── */
.slot-badge {
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 14%, transparent);
color: var(--color-primary);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
margin-left: 0.5rem;
}
.slot-badge-update {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
border-color: var(--color-border);
}
/* ── Slot update bubbles: softer treatment than compilation ── */
.slot-update .message-bubble.bubble-slot {
background: transparent;
border-left: 2px solid var(--color-border);
box-shadow: none;
}
.slot-update .message-bubble.bubble-slot .message-content {
font-size: 0.88rem;
color: var(--color-text-secondary);
}
</style>
@@ -0,0 +1,390 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted, onUnmounted, watch } from 'vue';
import { useChatStore } from '@/stores/chat';
import ChatPanel from '@/components/ChatPanel.vue';
import ChatInputBar from '@/components/ChatInputBar.vue';
const props = defineProps<{ projectId: number }>();
const emit = defineEmits<{
'note-changed': [noteId: number];
'task-changed': [];
}>();
const chatStore = useChatStore();
// SSE watcher — emit refresh events when tool calls succeed during streaming
const processedCount = ref(0);
watch(
() => chatStore.streamingToolCalls,
(calls) => {
for (let i = processedCount.value; i < calls.length; i++) {
const tc = calls[i];
if (tc.status !== 'success') continue;
// Note-editor refresh: any successful create_note/update_note
if (['create_note', 'update_note'].includes(tc.function) && tc.result?.data?.id) {
emit('note-changed', tc.result.data.id as number);
}
// Task-panel refresh: milestone changes, or note changes where the note is a task (has status)
const isTaskNoteChange =
['create_note', 'update_note'].includes(tc.function) &&
tc.result?.data?.status !== undefined;
const isMilestoneChange = ['create_milestone', 'update_milestone'].includes(tc.function);
if (isTaskNoteChange || isMilestoneChange) {
emit('task-changed');
}
}
processedCount.value = calls.length;
},
{ deep: true }
);
watch(
() => chatStore.streaming,
(s) => {
if (!s) processedCount.value = 0;
}
);
type WidgetState = 'collapsed' | 'expanded';
const widgetState = ref<WidgetState>('collapsed');
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const inputBarRef = ref<InstanceType<typeof ChatInputBar> | null>(null);
const workspaceConvId = ref<number | null>(null);
let isNewConv = false;
const historyOpen = ref(false);
const projectConversations = computed(() =>
chatStore.conversations
.filter((c) => c.rag_project_id === props.projectId)
.slice()
.sort((a, b) => (b.updated_at || '').localeCompare(a.updated_at || ''))
);
async function pickConversation(convId: number) {
historyOpen.value = false;
if (convId === workspaceConvId.value) return;
await chatStore.fetchConversation(convId);
workspaceConvId.value = convId;
// The picked conversation already exists on the server; not ours to clean up.
isNewConv = false;
localStorage.setItem(_storageKey(props.projectId), String(convId));
}
function toggleHistory() {
historyOpen.value = !historyOpen.value;
}
function conversationLabel(c: { id: number; title?: string | null }): string {
return (c.title && c.title.trim()) || `Conversation #${c.id}`;
}
function _storageKey(pid: number) {
return `workspace_conv_${pid}`;
}
function expand() {
widgetState.value = 'expanded';
nextTick(() => chatPanelRef.value?.focus());
}
function collapse() {
widgetState.value = 'collapsed';
nextTick(() => inputBarRef.value?.focus());
}
async function restart() {
const conv = await chatStore.createConversation();
workspaceConvId.value = conv.id;
isNewConv = true;
localStorage.setItem(_storageKey(props.projectId), String(conv.id));
await chatStore.fetchConversation(conv.id);
}
/** Auto-expand and send — used when user submits from collapsed input. */
async function onCollapsedSubmit(payload: { content: string; contextNoteId?: number }) {
widgetState.value = 'expanded';
await chatStore.sendMessage(payload.content, payload.contextNoteId, undefined, false);
}
function prefill(text: string) {
if (widgetState.value === 'expanded') {
chatPanelRef.value?.prefill(text);
} else {
inputBarRef.value?.prefill(text);
}
}
onMounted(async () => {
if (chatStore.conversations.length === 0) {
await chatStore.fetchConversations();
}
const key = _storageKey(props.projectId);
const storedId = localStorage.getItem(key);
if (storedId) {
const existingId = Number(storedId);
try {
await chatStore.fetchConversation(existingId);
workspaceConvId.value = existingId;
isNewConv = false;
chatStore.reconnectIfGenerating(existingId);
} catch {
localStorage.removeItem(key);
}
}
if (workspaceConvId.value === null) {
const conv = await chatStore.createConversation();
workspaceConvId.value = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
localStorage.setItem(key, String(conv.id));
}
});
onUnmounted(async () => {
if (workspaceConvId.value !== null && isNewConv) {
const id = workspaceConvId.value;
const conv = chatStore.conversations.find((c) => c.id === id);
if (conv && conv.message_count === 0) {
await chatStore.deleteConversation(id);
localStorage.removeItem(_storageKey(props.projectId));
}
}
});
defineExpose({ prefill });
</script>
<template>
<!-- Bottom-anchored chat dock: collapsed (input only) or expanded (full panel) -->
<div
class="ws-chat-widget"
:class="{ 'ws-chat-widget--expanded': widgetState === 'expanded' }"
>
<!-- Header (expanded only) -->
<div v-if="widgetState === 'expanded'" class="ws-chat-header">
<span class="ws-chat-title">Project chat</span>
<div class="ws-chat-header-actions">
<div class="ws-chat-history-wrap">
<button
class="btn-icon-sm"
:class="{ active: historyOpen }"
title="History"
@click="toggleHistory"
></button>
<div v-if="historyOpen" class="ws-chat-history-menu">
<div v-if="projectConversations.length === 0" class="ws-chat-history-empty">
No past conversations
</div>
<button
v-for="c in projectConversations"
:key="c.id"
class="ws-chat-history-item"
:class="{ current: c.id === workspaceConvId }"
@click="pickConversation(c.id)"
>
<span class="ws-chat-history-title">{{ conversationLabel(c) }}</span>
</button>
</div>
</div>
<button class="btn-icon-sm" title="Restart conversation" @click="restart"></button>
<button class="btn-icon-sm" title="Collapse" @click="collapse">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
</div>
<!-- Expanded body: full ChatPanel (includes its own input bar) -->
<div v-if="widgetState === 'expanded'" class="ws-chat-body">
<ChatPanel
ref="chatPanelRef"
variant="full"
:projectId="projectId"
placeholder="Message the agent…"
/>
</div>
<!-- Collapsed: standalone input bar with expand affordance -->
<div v-else class="ws-chat-collapsed">
<button class="ws-chat-expand-btn" title="Expand chat" @click="expand">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="18 15 12 9 6 15"/>
</svg>
</button>
<div class="ws-chat-input-row">
<ChatInputBar
ref="inputBarRef"
placeholder="Message the agent…"
@submit="onCollapsedSubmit"
@abort="chatStore.cancelGeneration()"
/>
</div>
</div>
</div>
</template>
<style scoped>
.ws-chat-widget {
position: absolute;
bottom: 0;
left: 0;
right: 0;
max-width: var(--page-max-width);
margin-inline: auto;
z-index: 20;
display: flex;
flex-direction: column;
background: var(--color-bg-secondary);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 32px rgba(0, 0, 0, 0.35), 0 -1px 0 rgba(255, 255, 255, 0.05);
overflow: hidden;
transition: height 0.2s ease;
}
.ws-chat-widget--expanded {
height: 500px;
}
.ws-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
}
.ws-chat-title {
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-text-muted);
}
.ws-chat-header-actions {
display: flex;
gap: 4px;
align-items: center;
}
.btn-icon-sm {
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 2px 6px;
font-size: 0.9rem;
line-height: 1;
border-radius: 4px;
}
.btn-icon-sm:hover:not(:disabled) {
color: var(--color-text);
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.btn-icon-sm:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-icon-sm.active {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.ws-chat-history-wrap {
position: relative;
}
.ws-chat-history-menu {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
min-width: 220px;
max-height: 260px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
z-index: 30;
padding: 4px;
}
.ws-chat-history-empty {
padding: 10px 12px;
font-size: 0.82rem;
color: var(--color-text-muted);
text-align: center;
}
.ws-chat-history-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 8px 10px;
border-radius: 6px;
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.ws-chat-history-item:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.ws-chat-history-item.current {
color: var(--color-primary);
font-weight: 600;
}
.ws-chat-history-title {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ws-chat-body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.ws-chat-body :deep(.chat-body) {
flex: 1;
min-height: 0;
}
.ws-chat-collapsed {
display: flex;
align-items: stretch;
}
.ws-chat-expand-btn {
flex-shrink: 0;
padding: 0 10px;
background: none;
border: none;
border-right: 1px solid var(--color-border);
color: var(--color-text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.ws-chat-expand-btn:hover {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.ws-chat-input-row {
flex: 1;
min-width: 0;
padding: 10px 12px;
flex-shrink: 0;
}
</style>
+13 -12
View File
@@ -457,12 +457,12 @@ defineExpose({ reload: loadProjectNotes });
/* ── Left rail ── */
.note-rail {
width: 155px;
width: 200px;
flex-shrink: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--color-border);
background: var(--color-bg-card, var(--color-bg-secondary));
}
.rail-header {
@@ -551,13 +551,13 @@ defineExpose({ reload: loadProjectNotes });
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
cursor: pointer;
gap: 0.15rem;
border-left: 2px solid transparent;
border-right: 2px solid transparent;
transition: background 0.12s;
}
.note-row:hover { background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface)); }
.note-row.active {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border-left-color: var(--color-primary);
border-right-color: var(--color-primary);
}
.note-row-main {
@@ -715,7 +715,6 @@ defineExpose({ reload: loadProjectNotes });
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 0.5rem;
}
@@ -742,8 +741,7 @@ defineExpose({ reload: loadProjectNotes });
.btn-save:disabled { opacity: 0.4; cursor: default; }
.note-title-row {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
padding: 0.9rem 1.1rem 0.5rem;
flex-shrink: 0;
}
@@ -751,19 +749,25 @@ defineExpose({ reload: loadProjectNotes });
width: 100%;
background: transparent;
border: none;
font-size: 1rem;
font-size: 1.4rem;
font-weight: 600;
line-height: 1.25;
color: var(--color-text);
padding: 0;
font-family: 'Fraunces', serif;
letter-spacing: -0.01em;
}
.note-title-input:focus { outline: none; }
.note-title-input::placeholder {
color: var(--color-text-muted);
font-style: italic;
}
.tag-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.4rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.tag-row > :first-child { flex: 1; min-width: 0; }
@@ -789,7 +793,6 @@ defineExpose({ reload: loadProjectNotes });
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.6rem;
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
@@ -824,7 +827,6 @@ defineExpose({ reload: loadProjectNotes });
.toolbar-row {
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
@@ -834,7 +836,6 @@ defineExpose({ reload: loadProjectNotes });
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.3rem 0.6rem;
border-bottom: 1px solid var(--color-border);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
flex-shrink: 0;
}
+10 -12
View File
@@ -5,13 +5,16 @@ const router = createRouter({
history: createWebHistory(),
routes: [
{
// Root lands on the journal. To revert to Knowledge as home, change
// the redirect target below to "/knowledge" (Knowledge stays a real
// route at /knowledge, so the swap is a one-line edit).
path: "/",
name: "knowledge",
component: () => import("@/views/KnowledgeView.vue"),
redirect: "/journal",
},
{
path: "/knowledge",
redirect: "/",
name: "knowledge",
component: () => import("@/views/KnowledgeView.vue"),
},
{
path: "/login",
@@ -45,7 +48,7 @@ const router = createRouter({
},
{
path: "/notes",
redirect: "/",
redirect: "/knowledge",
},
{
path: "/notes/new",
@@ -117,14 +120,9 @@ const router = createRouter({
component: () => import("@/views/CalendarView.vue"),
},
{
path: "/briefing",
name: "briefing",
component: () => import("@/views/BriefingView.vue"),
},
{
path: "/news",
name: "news",
component: () => import("@/views/NewsView.vue"),
path: "/journal",
name: "journal",
component: () => import("@/views/JournalView.vue"),
},
{
path: "/settings",
-5
View File
@@ -16,10 +16,6 @@ export const useSettingsStore = defineStore("settings", () => {
() => settings.value.default_model || ""
);
const rssEnabled = computed(
() => settings.value.rss_enabled === "true"
);
// Voice status — checked once on login, refreshable from Settings
const voiceEnabled = ref(false);
const voiceSttReady = ref(false);
@@ -66,7 +62,6 @@ export const useSettingsStore = defineStore("settings", () => {
loading,
assistantName,
defaultModel,
rssEnabled,
voiceEnabled,
voiceSttReady,
voiceTtsReady,
-828
View File
@@ -1,828 +0,0 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
import ChatPanel from '@/components/ChatPanel.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import BriefingSetupWizard from '@/components/BriefingSetupWizard.vue'
import {
apiGet,
apiPost,
getBriefingConfig,
getBriefingConversations,
getBriefingToday,
triggerBriefingSlot,
postRssReaction,
deleteRssReaction,
getNewsItems,
listEvents,
type BriefingConversation,
type EventEntry,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
wind_unit?: string
forecast: { day: string; condition: string; high: number; low: number; precip_probability: number | null; precip_mm: number | null; windspeed_max: number }[]
}
const chatStore = useChatStore()
const settingsStore = useSettingsStore()
// Setup wizard
const showWizard = ref(false)
const wizardChecked = ref(false)
async function checkSetup() {
const config = await getBriefingConfig()
if (!config.enabled) showWizard.value = true
wizardChecked.value = true
}
async function onWizardDone() {
showWizard.value = false
await loadAll()
}
// Conversations list for the dropdown
const conversations = ref<BriefingConversation[]>([])
const selectedConvId = ref<number | null>(null)
const todayConvId = ref<number | null>(null)
const isToday = computed(() => selectedConvId.value === todayConvId.value)
// Weather panel
const weatherData = ref<WeatherData[]>([])
const selectedWeatherIdx = ref(0)
const tempUnit = ref<string>('C')
interface CurrentConditions {
temperature: number | null;
windspeed: number | null;
description: string;
precip_next_3h: number[];
temp_unit: string;
location: string;
}
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/briefing/weather/current')
// Patch the live temperature into the WeatherCard so it stays fresh
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent — endpoint may not have locations configured */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
}
const refreshingWeather = ref(false)
async function refreshWeather() {
refreshingWeather.value = true
try {
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/briefing/weather/refresh', {})
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
finally { refreshingWeather.value = false }
}
// Upcoming events (right column, below weather)
const upcomingEvents = ref<EventEntry[]>([])
interface GroupedDay {
label: string
dateKey: string
events: EventEntry[]
}
const groupedEvents = computed<GroupedDay[]>(() => {
const groups = new Map<string, EventEntry[]>()
const today = new Date()
today.setHours(0, 0, 0, 0)
for (const ev of upcomingEvents.value) {
const d = new Date(ev.start_dt)
const key = d.toISOString().slice(0, 10)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(ev)
}
const result: GroupedDay[] = []
for (const [key, events] of groups) {
const d = new Date(key + 'T00:00:00')
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
let label: string
if (diff === 0) label = 'Today'
else if (diff === 1) label = 'Tomorrow'
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
result.push({ label, dateKey: key, events })
}
return result
})
function formatEventTime(ev: EventEntry): string {
if (ev.all_day) return 'All day'
const d = new Date(ev.start_dt)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
async function loadEvents() {
try {
const now = new Date()
const end = new Date(now)
end.setDate(end.getDate() + 14)
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
} catch { /* silent */ }
}
// News panel (right column)
const newsItems = ref<NewsItem[]>([])
async function loadNews() {
try {
const data = await getNewsItems({ days: 2, limit: 40 })
newsItems.value = data.items
// Seed reactions from API response
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
} catch { /* silent */ }
}
async function loadAll() {
const [convList, today] = await Promise.all([
getBriefingConversations(),
getBriefingToday().catch(() => null),
loadWeather(),
loadNews(),
loadCurrentConditions(),
loadEvents(),
])
conversations.value = convList
if (today) {
todayConvId.value = today.id
if (!convList.find((c) => c.id === today.id)) {
conversations.value = [
{ id: today.id, title: today.title ?? 'Today', briefing_date: null, message_count: 0, created_at: new Date().toISOString() },
...convList,
]
}
selectedConvId.value = today.id
await chatStore.fetchConversation(today.id)
}
}
watch(selectedConvId, async (id) => {
if (!id) return
try {
await chatStore.fetchConversation(id)
} catch {
// Historical conversation unavailable — do nothing
}
})
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
}
await chatStore.fetchConversation(todayConvId.value)
await chatStore.reconnectIfGenerating(todayConvId.value)
}
// RSS reactions: map of rss_item_id -> 'up' | 'down' | null
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
const current = reactions.value[itemId]
reactions.value[itemId] = current === reaction ? null : reaction
try {
if (current === reaction) {
await deleteRssReaction(itemId)
} else {
await postRssReaction(itemId, reaction)
}
} catch {
reactions.value[itemId] = current ?? null
}
}
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
// Manual trigger
const triggering = ref(false)
async function triggerNow() {
triggering.value = true
try {
await triggerBriefingSlot('compilation')
// Guard: user may have navigated away during the long compilation
if (_mounted) await loadAll()
} finally {
if (_mounted) triggering.value = false
}
}
// Dropdown label
function convLabel(c: BriefingConversation): string {
if (c.id === todayConvId.value) return 'Today'
if (c.briefing_date) {
const d = new Date(c.briefing_date)
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
return c.title || 'Briefing'
}
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
async function _backgroundRefreshMessages() {
try {
const today = await getBriefingToday()
if (!today) return
if (_mounted && isToday.value && chatStore.currentConversation?.id === todayConvId.value) {
await chatStore.fetchConversation(today.id)
}
await loadNews()
} catch { /* silent — don't disturb the UI on network hiccup */ }
}
useBackgroundRefresh(
_backgroundRefreshMessages,
60_000,
() => !chatStore.streaming && isToday.value && !!todayConvId.value,
)
let _mounted = true
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await checkSetup()
if (!showWizard.value) {
await loadAll()
// Poll current conditions every 30 minutes
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
}
})
</script>
<template>
<div class="briefing-root">
<!-- Setup wizard overlay -->
<BriefingSetupWizard v-if="wizardChecked && showWizard" @done="onWizardDone" />
<!-- Main view -->
<div class="briefing-shell" v-if="wizardChecked && !showWizard">
<!-- Header spans all columns -->
<header class="briefing-header">
<div class="briefing-header-left">
<h1 class="briefing-title">Briefing</h1>
<span class="briefing-today-badge">{{ new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' }) }}</span>
</div>
<div class="briefing-header-right">
<select v-if="conversations.length" v-model="selectedConvId" class="briefing-conv-select">
<option v-for="c in conversations" :key="c.id" :value="c.id">{{ convLabel(c) }}</option>
</select>
<button
class="btn-trigger"
@click="triggerNow"
:disabled="triggering"
title="Manually trigger morning briefing now"
>{{ triggering ? '…' : 'Refresh' }}</button>
</div>
</header>
<!-- Left column: Chat -->
<div class="briefing-center">
<ChatPanel
variant="full"
briefingMode
:readOnly="!isToday"
placeholder="Reply to your briefing…"
class="briefing-chat-panel"
/>
</div>
<!-- Right column: Weather + News -->
<div class="briefing-right">
<!-- Weather section (sticky) -->
<div class="weather-section" v-if="weatherData.length">
<div class="weather-section-header">
<div class="weather-tabs" v-if="weatherData.length > 1">
<button
v-for="(loc, i) in weatherData"
:key="(loc as WeatherData).location"
class="weather-tab"
:class="{ active: selectedWeatherIdx === i }"
@click="selectedWeatherIdx = i"
>{{ (loc as WeatherData).location }}</button>
</div>
<button
class="weather-refresh-btn"
:class="{ spinning: refreshingWeather }"
:disabled="refreshingWeather"
@click="refreshWeather"
title="Refresh weather"
></button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
:temp-unit="tempUnit"
/>
</div>
<!-- Upcoming events -->
<div class="events-section" v-if="groupedEvents.length">
<div class="panel-label-row">
<div class="panel-label">Upcoming</div>
<router-link to="/calendar" class="events-cal-link">Calendar </router-link>
</div>
<div class="events-list">
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
<div class="events-day-label">{{ group.label }}</div>
<div v-for="ev in group.events" :key="ev.id" class="event-row">
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="event-body">
<span class="event-title">{{ ev.title }}</span>
<span class="event-time">{{ formatEventTime(ev) }}</span>
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- News section (scrollable) -->
<div v-if="settingsStore.rssEnabled" class="news-section">
<div class="panel-label-row">
<div class="panel-label">Today's News</div>
<span v-if="newsItems.length" class="news-count">{{ newsItems.length }} items</span>
</div>
<div v-if="!newsItems.length" class="panel-empty">No articles in the last 2 days</div>
<div
v-for="item in newsItems"
:key="item.id"
class="news-card"
>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-title"
>{{ item.title }}</a>
<p v-else class="news-title news-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
<button
v-if="isToday && todayConvId"
class="reaction-btn discuss-btn"
@click="discussArticle(item)"
title="Discuss in briefing chat"
>💬</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.briefing-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.briefing-shell {
display: grid;
grid-template-columns: 1fr minmax(320px, 35%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
}
.briefing-header {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
flex-wrap: wrap;
}
.briefing-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.briefing-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.briefing-today-badge {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.briefing-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.briefing-conv-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.btn-trigger {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
}
.btn-trigger:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ─── Left column (Chat) ─────────────────────────────────────────────────── */
.briefing-center {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.briefing-chat-panel {
flex: 1;
min-height: 0;
}
/* ─── Right column (Weather + News) ──────────────────────────────────────── */
.briefing-right {
grid-column: 2;
grid-row: 2;
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.weather-section {
flex-shrink: 0;
padding: 1rem 1rem 0.5rem;
}
.weather-section :deep(.weather-card) {
margin-bottom: 0;
}
.weather-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.weather-refresh-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-muted);
font-size: 1rem;
cursor: pointer;
padding: 0.2rem 0.45rem;
line-height: 1;
transition: all 0.15s;
}
.weather-refresh-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.weather-refresh-btn.spinning {
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.weather-tabs {
display: flex;
gap: 0.25rem;
}
.weather-tab {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: none;
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.weather-tab:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.weather-tab.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
/* ─── Upcoming events ─────────────────────────────────────── */
.events-section {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border);
}
.events-cal-link {
font-size: 0.75rem;
color: var(--color-text-muted);
text-decoration: none;
}
.events-cal-link:hover { color: var(--color-primary); }
.events-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.events-day-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.events-day-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
padding-bottom: 0.15rem;
}
.event-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.event-row:hover {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
}
.event-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 5px;
}
.event-body {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.event-title {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.event-loc {
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.news-section {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-primary);
flex-shrink: 0;
}
.panel-label-row {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
}
.news-count {
font-size: 0.72rem;
color: var(--color-text-muted);
}
/* ── Current conditions (live) ─────────────────────────── */
.panel-empty {
font-size: 0.82rem;
color: var(--color-text-muted);
padding: 0.5rem 0;
}
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-title {
font-size: 0.88rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.35;
text-decoration: none;
margin: 0;
}
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
.news-snippet {
font-size: 0.78rem;
color: var(--color-text-muted);
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.15rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.35rem;
cursor: pointer;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
opacity: 1;
border-color: var(--color-primary);
}
.reaction-btn.active {
opacity: 1;
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
/* ─── Responsive ─────────────────────────────────────────────────────────── */
@media (max-width: 900px) {
.briefing-shell {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr auto;
}
.briefing-center {
grid-column: 1;
grid-row: 2;
}
.briefing-right {
grid-column: 1;
grid-row: 3;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 300px;
}
}
</style>
+535
View File
@@ -0,0 +1,535 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useBackgroundRefresh } from '@/composables/useBackgroundRefresh'
import { useChatStore } from '@/stores/chat'
import ChatPanel from '@/components/ChatPanel.vue'
import WeatherCard from '@/components/WeatherCard.vue'
import {
apiGet,
apiPost,
getJournalToday,
getJournalDay,
getJournalDays,
triggerJournalPrep,
listEvents,
type EventEntry,
} from '@/api/client'
interface WeatherDay {
day: string
condition: string
high: number
low: number
precip_probability: number | null
precip_mm: number | null
windspeed_max: number
}
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
wind_unit?: string
forecast: WeatherDay[]
}
interface CurrentConditions {
temperature: number | null
windspeed: number | null
description: string
precip_next_3h: number[]
temp_unit: string
location: string
}
const chatStore = useChatStore()
// ── Day picker + conversation state ──────────────────────────────────────────
const days = ref<string[]>([])
const todayDate = ref<string | null>(null)
const selectedDay = ref<string | null>(null)
const dayConvId = ref<number | null>(null)
const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value)
// ── Weather panel ────────────────────────────────────────────────────────────
const weatherData = ref<WeatherData[]>([])
const selectedWeatherIdx = ref(0)
const tempUnit = ref<string>('C')
const currentConditions = ref<CurrentConditions | null>(null)
let currentWeatherTimer: ReturnType<typeof setInterval> | null = null
async function loadCurrentConditions() {
try {
currentConditions.value = await apiGet<CurrentConditions>('/api/journal/weather/current')
if (currentConditions.value?.temperature != null && weatherData.value.length > 0) {
weatherData.value[0] = { ...weatherData.value[0], current_temp: currentConditions.value.temperature }
}
} catch { /* silent */ }
}
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather')
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
}
const refreshingWeather = ref(false)
async function refreshWeather() {
refreshingWeather.value = true
try {
const data = await apiPost<{ locations: WeatherData[]; temp_unit: string }>('/api/journal/weather/refresh', {})
weatherData.value = data.locations ?? []
tempUnit.value = data.temp_unit ?? 'C'
} catch { /* silent */ }
finally { refreshingWeather.value = false }
}
// ── Upcoming events ──────────────────────────────────────────────────────────
const upcomingEvents = ref<EventEntry[]>([])
interface GroupedDay {
label: string
dateKey: string
events: EventEntry[]
}
const groupedEvents = computed<GroupedDay[]>(() => {
const groups = new Map<string, EventEntry[]>()
const today = new Date()
today.setHours(0, 0, 0, 0)
for (const ev of upcomingEvents.value) {
const d = new Date(ev.start_dt)
const key = d.toISOString().slice(0, 10)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(ev)
}
const result: GroupedDay[] = []
for (const [key, events] of groups) {
const d = new Date(key + 'T00:00:00')
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
let label: string
if (diff === 0) label = 'Today'
else if (diff === 1) label = 'Tomorrow'
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
result.push({ label, dateKey: key, events })
}
return result
})
function formatEventTime(ev: EventEntry): string {
if (ev.all_day) return 'All day'
const d = new Date(ev.start_dt)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
async function loadEvents() {
try {
const now = new Date()
const end = new Date(now)
end.setDate(end.getDate() + 14)
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
} catch { /* silent */ }
}
// ── Day load + switch ────────────────────────────────────────────────────────
async function loadDay(iso: string) {
const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso)
if (payload.conversation) {
dayConvId.value = payload.conversation.id
await chatStore.fetchConversation(payload.conversation.id)
} else {
dayConvId.value = null
}
}
async function loadAll() {
try {
const today = await getJournalToday()
todayDate.value = today.day_date
selectedDay.value = today.day_date
if (today.conversation) {
dayConvId.value = today.conversation.id
await chatStore.fetchConversation(today.conversation.id)
}
} catch { /* silent */ }
try {
days.value = await getJournalDays()
if (todayDate.value && !days.value.includes(todayDate.value)) {
days.value = [todayDate.value, ...days.value]
}
} catch { /* silent */ }
await Promise.all([loadWeather(), loadCurrentConditions(), loadEvents()])
}
watch(selectedDay, async (iso) => {
if (!iso || iso === todayDate.value) return
try { await loadDay(iso) } catch { /* silent */ }
})
// ── Manual prep regeneration ─────────────────────────────────────────────────
const triggering = ref(false)
async function triggerPrep() {
if (triggering.value) return
triggering.value = true
try {
await triggerJournalPrep()
if (_mounted) await loadAll()
} finally {
if (_mounted) triggering.value = false
}
}
function dayLabel(iso: string): string {
if (iso === todayDate.value) return 'Today'
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
}
const todayBadge = computed(() => {
return new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })
})
// ── Background refresh ───────────────────────────────────────────────────────
async function _backgroundRefreshMessages() {
try {
if (!_mounted || !isToday.value || !dayConvId.value) return
await chatStore.fetchConversation(dayConvId.value)
} catch { /* silent */ }
}
useBackgroundRefresh(
_backgroundRefreshMessages,
60_000,
() => !chatStore.streaming && isToday.value && !!dayConvId.value,
)
let _mounted = true
onUnmounted(() => {
_mounted = false
if (currentWeatherTimer) clearInterval(currentWeatherTimer)
})
onMounted(async () => {
await loadAll()
currentWeatherTimer = setInterval(loadCurrentConditions, 30 * 60 * 1000)
})
</script>
<template>
<div class="journal-root">
<div class="journal-shell">
<header class="journal-header">
<div class="journal-header-left">
<h1 class="journal-title">Journal</h1>
<span class="journal-today-badge">{{ todayBadge }}</span>
</div>
<div class="journal-header-right">
<select v-if="days.length" v-model="selectedDay" class="journal-day-select">
<option v-for="d in days" :key="d" :value="d">{{ dayLabel(d) }}</option>
</select>
<button
class="btn-trigger"
@click="triggerPrep"
:disabled="triggering || !isToday"
title="Regenerate today's daily prep"
>{{ triggering ? '…' : 'Refresh prep' }}</button>
</div>
</header>
<!-- Center: chat (prep is the first assistant message inside) -->
<div class="journal-center">
<ChatPanel
variant="full"
briefingMode
:readOnly="!isToday"
placeholder="Tell your journal…"
class="journal-chat-panel"
/>
</div>
<!-- Right: Weather + Events + News -->
<div class="journal-right">
<div class="weather-section" v-if="weatherData.length">
<div class="weather-section-header">
<div class="weather-tabs" v-if="weatherData.length > 1">
<button
v-for="(loc, i) in weatherData"
:key="(loc as WeatherData).location"
class="weather-tab"
:class="{ active: selectedWeatherIdx === i }"
@click="selectedWeatherIdx = i"
>{{ (loc as WeatherData).location }}</button>
</div>
<button
class="weather-refresh-btn"
:class="{ spinning: refreshingWeather }"
:disabled="refreshingWeather"
@click="refreshWeather"
title="Refresh weather"
></button>
</div>
<WeatherCard
:weather="weatherData[selectedWeatherIdx]"
:temp-unit="tempUnit"
/>
</div>
<div class="events-section" v-if="groupedEvents.length">
<div class="panel-label-row">
<div class="panel-label">Upcoming</div>
<router-link to="/calendar" class="events-cal-link">Calendar </router-link>
</div>
<div class="events-list">
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
<div class="events-day-label">{{ group.label }}</div>
<div v-for="ev in group.events" :key="ev.id" class="event-row">
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="event-body">
<span class="event-title">{{ ev.title }}</span>
<span class="event-time">{{ formatEventTime(ev) }}</span>
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.journal-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.journal-shell {
display: grid;
grid-template-columns: 1fr minmax(320px, 35%);
grid-template-rows: auto 1fr;
height: 100%;
min-height: 0;
}
.journal-header {
grid-column: 1 / -1;
grid-row: 1;
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
gap: 1rem;
flex-wrap: wrap;
}
.journal-header-left { display: flex; align-items: baseline; gap: 0.75rem; }
.journal-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.journal-today-badge { font-size: 0.82rem; color: var(--color-text-muted); }
.journal-header-right { display: flex; align-items: center; gap: 0.5rem; }
.journal-day-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.btn-trigger {
padding: 0.35rem 0.8rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s;
font-family: inherit;
}
.btn-trigger:hover:not(:disabled) { border-color: var(--color-primary); color: var(--color-primary); }
.btn-trigger:disabled { opacity: 0.5; cursor: not-allowed; }
/* ── Center column: prep card + chat ──────────────────────────────────────── */
.journal-center {
grid-column: 1;
grid-row: 2;
display: flex;
flex-direction: column;
min-height: 0;
}
.journal-chat-panel { flex: 1; min-height: 0; }
/* ── Right column ─────────────────────────────────────────────────────────── */
.journal-right {
grid-column: 2;
grid-row: 2;
border-left: 1px solid var(--color-border);
display: flex;
flex-direction: column;
min-height: 0;
}
.weather-section { flex-shrink: 0; padding: 1rem 1rem 0.5rem; }
.weather-section :deep(.weather-card) { margin-bottom: 0; }
.weather-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.weather-refresh-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text-muted);
font-size: 1rem;
cursor: pointer;
padding: 0.2rem 0.45rem;
line-height: 1;
transition: all 0.15s;
}
.weather-refresh-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
.weather-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.weather-refresh-btn.spinning { animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.weather-tabs { display: flex; gap: 0.25rem; }
.weather-tab {
padding: 0.3rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: none;
color: var(--color-text-muted);
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s;
}
.weather-tab:hover { border-color: var(--color-primary); color: var(--color-primary); }
.weather-tab.active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
.events-section { padding: 0.75rem 1rem; border-top: 1px solid var(--color-border); }
.events-cal-link { font-size: 0.75rem; color: var(--color-text-muted); text-decoration: none; }
.events-cal-link:hover { color: var(--color-primary); }
.events-list { display: flex; flex-direction: column; gap: 0.6rem; }
.events-day-group { display: flex; flex-direction: column; gap: 0.2rem; }
.events-day-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.event-row { display: flex; align-items: flex-start; gap: 0.4rem; padding: 0.25rem 0.4rem; border-radius: 6px; }
.event-row:hover { background: color-mix(in srgb, var(--color-primary) 6%, transparent); }
.event-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0; margin-top: 5px;
}
.event-body { display: flex; flex-direction: column; gap: 0.05rem; min-width: 0; }
.event-title { font-size: 0.82rem; font-weight: 600; color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.event-time { font-size: 0.72rem; color: var(--color-text-muted); }
.event-loc { font-size: 0.7rem; color: var(--color-text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.news-section {
flex: 1;
overflow-y: auto;
padding: 0.75rem 1rem 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.panel-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--color-primary);
flex-shrink: 0;
}
.panel-label-row { display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
.news-count { font-size: 0.72rem; color: var(--color-text-muted); }
.panel-empty { font-size: 0.82rem; color: var(--color-text-muted); padding: 0.5rem 0; }
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.65rem 0.85rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
flex-shrink: 0;
}
.news-card-meta { display: flex; align-items: center; gap: 0.5rem; }
.news-source { font-size: 0.72rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: var(--color-primary); }
.news-date { font-size: 0.72rem; color: var(--color-text-muted); }
.news-title { font-size: 0.88rem; font-weight: 600; color: var(--color-text); line-height: 1.35; text-decoration: none; margin: 0; }
a.news-title:hover { text-decoration: underline; color: var(--color-primary); }
.news-snippet {
font-size: 0.78rem;
color: var(--color-text-muted);
line-height: 1.45;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-reactions { display: flex; gap: 0.3rem; margin-top: 0.15rem; }
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.35rem;
cursor: pointer;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover { opacity: 1; border-color: var(--color-primary); }
.reaction-btn.active { opacity: 1; border-color: var(--color-primary); background: color-mix(in srgb, var(--color-primary) 12%, transparent); }
@media (max-width: 900px) {
.journal-shell { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.journal-center { grid-column: 1; grid-row: 2; }
.journal-right {
grid-column: 1; grid-row: 3;
border-left: none;
border-top: 1px solid var(--color-border);
max-height: 300px;
}
}
</style>
-1
View File
@@ -410,7 +410,6 @@ onUnmounted(() => {
<router-link v-if="overdueCount > 0" to="/tasks" class="overdue-badge">
{{ overdueCount }} overdue
</router-link>
<router-link to="/briefing" class="today-link">Briefing</router-link>
<router-link to="/chat" class="today-link">Chat</router-link>
</div>
</div>
-406
View File
@@ -1,406 +0,0 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
getBriefingFeeds,
postRssReaction,
deleteRssReaction,
getNewsItems,
openArticleInChat,
type BriefingFeed,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
const router = useRouter()
const LIMIT = 40
const items = ref<NewsItem[]>([])
const offset = ref(0)
const hasMore = ref(true)
const loading = ref(false)
const feeds = ref<BriefingFeed[]>([])
const selectedFeedId = ref<number | null>(null)
// Reactions map: item id → current reaction
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
// Track which items are currently being opened in chat
const openingChat = ref<Set<number>>(new Set())
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const data = await getNewsItems({
days: 90,
limit: LIMIT,
offset: offset.value,
feed_id: selectedFeedId.value,
})
for (const item of data.items) {
if (reactions.value[item.id] === undefined) {
reactions.value[item.id] = item.reaction
}
}
items.value = [...items.value, ...data.items]
offset.value += data.items.length
hasMore.value = data.items.length === LIMIT
} finally {
loading.value = false
}
}
function onFeedChange() {
items.value = []
offset.value = 0
hasMore.value = true
reactions.value = {}
loadMore()
}
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
const current = reactions.value[itemId]
reactions.value[itemId] = current === reaction ? null : reaction
try {
if (current === reaction) {
await deleteRssReaction(itemId)
} else {
await postRssReaction(itemId, reaction)
}
} catch {
reactions.value[itemId] = current ?? null
}
}
function formatRelativeDate(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
const now = new Date()
const diffH = (now.getTime() - d.getTime()) / 3_600_000
if (diffH < 1) return 'Just now'
if (diffH < 24) return `${Math.round(diffH)}h ago`
if (diffH < 48) return 'Yesterday'
const days = Math.floor(diffH / 24)
if (days < 7) return `${days}d ago`
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
}
async function openInChat(itemId: number) {
if (openingChat.value.has(itemId)) return
openingChat.value.add(itemId)
try {
const result = await openArticleInChat(itemId)
router.push(`/chat/${result.conversation_id}`)
} catch {
// silently fail — button returns to enabled state
} finally {
openingChat.value.delete(itemId)
}
}
onMounted(async () => {
feeds.value = await getBriefingFeeds().catch(() => [])
await loadMore()
})
</script>
<template>
<div class="news-root">
<div class="news-header">
<div class="news-header-left">
<h1 class="news-title">News</h1>
<span class="news-subtitle">Last 90 days</span>
</div>
<div class="news-header-right">
<select
v-model="selectedFeedId"
class="feed-select"
@change="onFeedChange"
>
<option :value="null">All feeds</option>
<option v-for="feed in feeds" :key="feed.id" :value="feed.id">
{{ feed.title }}
</option>
</select>
</div>
</div>
<div class="news-list">
<div v-if="!items.length && !loading" class="news-empty">
No articles found for the selected feed.
</div>
<div
v-for="item in items"
:key="item.id"
class="news-card"
>
<div class="news-card-meta">
<span class="news-source">{{ item.source }}</span>
<span v-if="item.published_at" class="news-date">{{ formatRelativeDate(item.published_at) }}</span>
</div>
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noopener noreferrer"
class="news-card-title"
>{{ item.title }}</a>
<p v-else class="news-card-title news-card-title--plain">{{ item.title }}</p>
<p v-if="item.snippet" class="news-snippet">{{ item.snippet }}</p>
<div v-if="item.topics?.length" class="news-topics">
<span v-for="topic in item.topics" :key="topic" class="news-topic">{{ topic }}</span>
</div>
<div class="news-reactions">
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'up' }"
@click="handleReaction(item.id, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[item.id] === 'down' }"
@click="handleReaction(item.id, 'down')"
title="Not interested"
>👎</button>
<button
class="reaction-btn open-chat-btn"
:class="{ busy: openingChat.has(item.id) }"
:disabled="openingChat.has(item.id)"
@click="openInChat(item.id)"
title="Discuss in chat"
>{{ openingChat.has(item.id) ? '…' : '💬' }}</button>
</div>
</div>
<div class="news-footer">
<button
v-if="hasMore"
class="btn-load-more"
@click="loadMore"
:disabled="loading"
>{{ loading ? 'Loading…' : 'Load more' }}</button>
<div v-if="loading && !items.length" class="news-loading">Loading</div>
<p v-if="!hasMore && items.length" class="news-end">All articles loaded</p>
</div>
</div>
</div>
</template>
<style scoped>
.news-root {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow-y: auto;
}
.news-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem 1rem;
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
gap: 0.75rem;
}
.news-header-left {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.news-title {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.3rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.news-subtitle {
font-size: 0.82rem;
color: var(--color-text-muted);
}
.news-header-right {
display: flex;
align-items: center;
gap: 0.5rem;
}
.feed-select {
padding: 0.35rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
font-family: inherit;
}
.news-list {
padding: 1rem 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 860px;
width: 100%;
margin: 0 auto;
}
.news-empty,
.news-loading {
text-align: center;
padding: 3rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.news-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: 10px;
padding: 0.85rem 1rem;
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.news-card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.news-source {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-primary);
}
.news-date {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.news-card-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.4;
text-decoration: none;
margin: 0;
}
a.news-card-title:hover {
text-decoration: underline;
color: var(--color-primary);
}
.news-snippet {
font-size: 0.82rem;
color: var(--color-text-muted);
line-height: 1.5;
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.news-topics {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-top: 0.1rem;
}
.news-topic {
font-size: 0.68rem;
padding: 0.15rem 0.5rem;
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
color: var(--color-primary);
border-radius: 99px;
font-weight: 500;
}
.news-reactions {
display: flex;
gap: 0.3rem;
margin-top: 0.2rem;
}
.reaction-btn {
background: none;
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.1rem 0.4rem;
cursor: pointer;
font-size: 0.82rem;
line-height: 1.4;
opacity: 0.55;
transition: opacity 0.15s, border-color 0.15s;
}
.reaction-btn:hover {
opacity: 1;
border-color: var(--color-primary);
}
.reaction-btn.active {
opacity: 1;
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
}
.open-chat-btn {
margin-left: auto;
}
.open-chat-btn.busy {
opacity: 0.4;
cursor: wait;
}
.news-footer {
display: flex;
justify-content: center;
padding: 1rem 0 0.5rem;
}
.btn-load-more {
padding: 0.5rem 1.5rem;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.btn-load-more:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.btn-load-more:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.news-end {
font-size: 0.8rem;
color: var(--color-text-muted);
margin: 0;
}
</style>
+7 -370
View File
@@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getBriefingConfig, saveBriefingConfig, getBriefingFeeds, createBriefingFeed, deleteBriefingFeed, refreshBriefingFeeds, geocodeAddress, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type BriefingConfig, type BriefingFeed, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile } from "@/api/client";
import { usePushStore } from "@/stores/push";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
@@ -62,7 +62,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "briefing", "voice", "apikeys", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -72,7 +72,6 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
if (tab === "briefing") loadBriefingTab();
if (tab === "voice") loadVoiceTab();
if (tab === "apikeys") { fetchApiKeys(); loadMcpInfo(); }
}
@@ -291,155 +290,6 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
await loadGroupsPanel();
}
// Briefing settings
const briefingConfig = ref<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',
});
const briefingFeeds = ref<BriefingFeed[]>([]);
const briefingSaving = ref(false);
const briefingSaved = ref(false);
const briefingGeocoding = ref<Record<string, boolean>>({});
const briefingGeoError = ref<Record<string, string>>({});
const newFeedUrl = ref('');
const newFeedCategory = ref('');
const addingFeed = ref(false);
const refreshingFeeds = ref(false);
const briefingIncludeTopics = ref<string[]>([]);
const briefingExcludeTopics = ref<string[]>([]);
function _parseTopics(raw: unknown): string[] {
try {
const val = typeof raw === 'string' ? JSON.parse(raw) : raw;
return Array.isArray(val) ? val.map(String) : [];
} catch {
return [];
}
}
async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds();
const allSettings = await apiGet<Record<string, string>>('/api/settings').catch(() => ({} as Record<string, string>));
briefingIncludeTopics.value = _parseTopics(allSettings['briefing_include_topics'] ?? '[]');
briefingExcludeTopics.value = _parseTopics(allSettings['briefing_exclude_topics'] ?? '[]');
}
async function saveIncludeTopics(topics: string[]) {
briefingIncludeTopics.value = topics;
await apiPut('/api/settings', { briefing_include_topics: JSON.stringify(topics) });
}
async function saveExcludeTopics(topics: string[]) {
briefingExcludeTopics.value = topics;
await apiPut('/api/settings', { briefing_exclude_topics: JSON.stringify(topics) });
}
const _STANDARD_TOPICS = ['technology', 'science', 'politics', 'business', 'health', 'environment', 'local', 'entertainment', 'sports', 'other'];
async function fetchTopicSuggestions(q: string): Promise<string[]> {
if (!q) return _STANDARD_TOPICS;
return _STANDARD_TOPICS.filter((t) => t.startsWith(q.toLowerCase()));
}
async function geocodeLocation(key: 'home' | 'work') {
const loc = briefingConfig.value.locations[key];
if (!loc?.address?.trim()) return;
briefingGeocoding.value[key] = true;
briefingGeoError.value[key] = '';
try {
const result = await geocodeAddress(loc.address.trim());
if (result) {
briefingConfig.value.locations[key] = {
...loc,
lat: result.lat,
lon: result.lon,
label: key.charAt(0).toUpperCase() + key.slice(1),
};
briefingGeoError.value[key] = '';
} else {
briefingGeoError.value[key] = 'Location not found — check the address';
}
} catch {
briefingGeoError.value[key] = 'Geocoding failed';
} finally {
briefingGeocoding.value[key] = false;
}
}
async function saveBriefingSettings() {
briefingSaving.value = true;
briefingSaved.value = false;
try {
await saveBriefingConfig(briefingConfig.value);
briefingSaved.value = true;
setTimeout(() => (briefingSaved.value = false), 2000);
} catch {
toastStore.show('Failed to save briefing settings', 'error');
} finally {
briefingSaving.value = false;
}
}
async function toggleRss() {
try {
await store.updateSettings({ rss_enabled: store.rssEnabled ? "false" : "true" });
} catch {
toastStore.show("Failed to update RSS setting", "error");
}
}
async function addFeed() {
if (!newFeedUrl.value.trim() || addingFeed.value) return;
addingFeed.value = true;
try {
const feed = await createBriefingFeed(newFeedUrl.value.trim(), newFeedCategory.value.trim() || undefined);
briefingFeeds.value.push(feed);
newFeedUrl.value = '';
newFeedCategory.value = '';
} catch (err: any) {
toastStore.show(err?.message === 'Feed already added' ? 'That feed is already in your list' : 'Failed to add feed', 'error');
} finally {
addingFeed.value = false;
}
}
async function removeFeed(id: number) {
await deleteBriefingFeed(id);
briefingFeeds.value = briefingFeeds.value.filter((f) => f.id !== id);
}
async function refreshFeeds() {
if (refreshingFeeds.value) return;
refreshingFeeds.value = true;
try {
const result = await refreshBriefingFeeds();
// Reload feed list so last_fetched_at updates
briefingFeeds.value = await getBriefingFeeds();
toastStore.show(`Refreshed ${result.feeds_refreshed} feed${result.feeds_refreshed !== 1 ? 's' : ''}${result.new_items} new item${result.new_items !== 1 ? 's' : ''}`);
} catch {
toastStore.show('Failed to refresh feeds', 'error');
} finally {
refreshingFeeds.value = false;
}
}
function feedAge(isoStr: string | null): string {
if (!isoStr) return 'never fetched';
const diff = Date.now() - new Date(isoStr).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return 'just now';
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
// Chat retention
const chatRetentionDays = ref(90);
@@ -501,7 +351,7 @@ const smtp = ref({
smtp_username: "",
smtp_password: "",
smtp_from_address: "",
smtp_from_name: "Fabled Assistant",
smtp_from_name: "Fabled Scribe",
smtp_use_tls: "true",
});
const savingSmtp = ref(false);
@@ -1465,7 +1315,7 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'briefing', 'voice', 'apikeys']"
v-for="tab in ['general', 'account', 'profile', 'notifications', 'integrations', 'data', 'voice', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
@@ -1518,7 +1368,7 @@ function formatUserDate(iso: string): string {
<option v-for="m in installedModels" :key="m" :value="m">{{ m }}</option>
</select>
<p class="field-hint">
Model used for background tasks: title generation, tag suggestions, project summaries, and RSS classification.
Model used for background tasks: title generation, tag suggestions, and project summaries.
Using a small dedicated model (e.g. qwen2.5:0.5b) keeps the chat model's KV cache warm between messages, significantly reducing response time.
<span v-if="backgroundModel && backgroundModel === (defaultModel || defaultChatModel)" class="field-hint-warn">
⚠ Setting this to the same model as Chat Model will wipe the KV cache after every background task, increasing response latency.
@@ -1981,7 +1831,7 @@ function formatUserDate(iso: string): string {
<section class="settings-section full-width">
<h2>About</h2>
<p class="version-line">Fabled Assistant <span class="version-badge">{{ appVersion }}</span></p>
<p class="version-line">Fabled Scribe <span class="version-badge">{{ appVersion }}</span></p>
</section>
</div>
@@ -2115,219 +1965,6 @@ function formatUserDate(iso: string): string {
</div>
<!-- ── Briefing ── -->
<div v-show="activeTab === 'briefing'" class="settings-grid">
<section class="settings-section full-width">
<h2>Daily Briefing</h2>
<p class="section-desc">
Configure your daily briefing — a conversation that summarises your day,
weather, and RSS feeds on a schedule.
</p>
<!-- Enable -->
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.enabled" />
Enable Daily Briefing
</label>
<p class="field-hint">Start daily briefings at the configured slots.</p>
</div>
</section>
<!-- Locations -->
<section class="settings-section full-width">
<h2>Locations</h2>
<p class="section-desc">Enter addresses for weather lookup. Click "Look up" to geocode.</p>
<div v-for="key in (['home', 'work'] as const)" :key="key" class="briefing-location-row">
<label class="field-label">{{ key.charAt(0).toUpperCase() + key.slice(1) }}</label>
<div class="briefing-input-group">
<input
type="text"
class="input"
:placeholder="key === 'home' ? 'e.g. 123 Main St, Springfield' : 'e.g. 456 Office Ave, Portland'"
:value="briefingConfig.locations[key]?.address ?? ''"
@input="(e) => {
if (!briefingConfig.locations[key]) briefingConfig.locations[key] = { label: key, address: '' };
briefingConfig.locations[key]!.address = (e.target as HTMLInputElement).value;
}"
/>
<button class="btn-secondary" @click="geocodeLocation(key)" :disabled="briefingGeocoding[key]">
{{ briefingGeocoding[key] ? 'Looking up' : 'Look up' }}
</button>
</div>
<div v-if="briefingConfig.locations[key]?.lat" class="briefing-geo-confirmed">
✓ {{ briefingConfig.locations[key]!.lat?.toFixed(4) }}, {{ briefingConfig.locations[key]!.lon?.toFixed(4) }}
</div>
<div v-if="briefingGeoError[key]" class="briefing-geo-error">{{ briefingGeoError[key] }}</div>
</div>
<div class="checkbox-field" style="margin-top: 0.75rem">
<label>
<input type="checkbox" v-model="briefingConfig.use_caldav_event_locations" />
Also check CalDAV event locations
</label>
<p class="field-hint">Look up weather for locations in today's calendar events.</p>
</div>
<div class="field" style="margin-top: 1rem">
<label class="field-label">Temperature unit</label>
<div class="briefing-unit-toggle">
<button
type="button"
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'C' }]"
@click="briefingConfig.temp_unit = 'C'"
>°C</button>
<button
type="button"
:class="['briefing-unit-btn', { active: briefingConfig.temp_unit === 'F' }]"
@click="briefingConfig.temp_unit = 'F'"
>°F</button>
</div>
</div>
</section>
<!-- Slots -->
<section class="settings-section full-width">
<h2>Scheduled Slots</h2>
<p class="section-desc">Each active slot posts an update into your briefing conversation at the listed local time.</p>
<div class="briefing-slot-list">
<div
v-for="[key, label, localTime] in ([
['compilation', 'Morning briefing', '4:00 am'],
['morning', 'Office check-in', '8:00 am'],
['midday', 'Midday update', '12:00 pm'],
['afternoon', 'End of day', '4:00 pm'],
] as const)"
:key="key"
class="briefing-slot-row"
>
<div class="briefing-slot-info">
<span class="briefing-slot-label">{{ label }}</span>
<span class="briefing-slot-time">{{ localTime }}</span>
</div>
<label>
<input type="checkbox" v-model="briefingConfig.slots[key]" />
</label>
</div>
</div>
<p class="field-hint" style="margin-top: 0.5rem">
Firing in timezone: <strong>{{ userTimezone || 'UTC (not configured — set in General settings)' }}</strong>
</p>
</section>
<!-- RSS toggle -->
<section class="settings-section full-width">
<h2>RSS / News</h2>
<div class="checkbox-field">
<label>
<input type="checkbox" :checked="store.rssEnabled" @change="toggleRss" />
Enable RSS feeds
</label>
<p class="field-hint">Subscribe to RSS/Atom feeds and include news in your briefings.</p>
</div>
</section>
<!-- RSS Feeds -->
<section v-if="store.rssEnabled" class="settings-section full-width">
<div class="briefing-feeds-header">
<div>
<h2>RSS Feeds</h2>
<p class="section-desc">Add RSS or Atom feeds to be summarised in your morning briefing.</p>
</div>
<button
v-if="briefingFeeds.length"
class="btn-secondary briefing-refresh-btn"
@click="refreshFeeds"
:disabled="refreshingFeeds"
title="Fetch latest items from all feeds"
>{{ refreshingFeeds ? 'Refreshing…' : 'Refresh all' }}</button>
</div>
<div class="briefing-feeds-list" v-if="briefingFeeds.length">
<div class="briefing-feed-row" v-for="feed in briefingFeeds" :key="feed.id">
<div class="briefing-feed-info">
<div class="briefing-feed-title-row">
<span class="briefing-feed-title">{{ feed.title || feed.url }}</span>
<span v-if="feed.category" class="briefing-feed-cat">{{ feed.category }}</span>
</div>
<span v-if="feed.title" class="briefing-feed-url">{{ feed.url }}</span>
<span class="briefing-feed-age">{{ feedAge(feed.last_fetched_at) }}</span>
</div>
<button class="briefing-btn-remove" @click="removeFeed(feed.id)" aria-label="Remove feed">✕</button>
</div>
</div>
<p v-else class="field-hint">No feeds yet. Try adding Reuters, BBC World, or Hacker News.</p>
<div class="briefing-add-feed-form">
<div class="briefing-add-feed-inputs">
<input v-model="newFeedUrl" class="input" placeholder="https://example.com/feed.xml" @keydown.enter="addFeed" />
<input v-model="newFeedCategory" class="input briefing-cat-input" placeholder="Category (optional)" @keydown.enter="addFeed" />
</div>
<button class="btn-secondary" @click="addFeed" :disabled="addingFeed || !newFeedUrl.trim()">
{{ addingFeed ? 'Adding…' : 'Add Feed' }}
</button>
</div>
</section>
<!-- News Preferences -->
<section v-if="store.rssEnabled" class="settings-section full-width">
<h2>News Preferences</h2>
<p class="section-desc">
Tell the briefing what topics you care about. Topics are matched against
classified RSS items before each briefing runs.
</p>
<div class="settings-field">
<label class="field-label">Interested in</label>
<TagInput
:model-value="briefingIncludeTopics"
placeholder="e.g. technology, science, local"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveIncludeTopics"
/>
</div>
<div class="settings-field" style="margin-top: 0.75rem">
<label class="field-label">Not interested in</label>
<TagInput
:model-value="briefingExcludeTopics"
placeholder="e.g. sports, celebrity"
:fetch-tags="fetchTopicSuggestions"
@update:model-value="saveExcludeTopics"
/>
</div>
<details class="topic-vocab-hint" style="margin-top: 0.75rem">
<summary style="cursor: pointer; color: var(--color-text-muted); font-size: 0.82rem">Standard topic vocabulary</summary>
<p class="field-hint" style="margin-top: 0.35rem">
technology · science · politics · business · health · environment ·
local · entertainment · sports · other
</p>
<p class="field-hint">Custom terms are also accepted.</p>
</details>
</section>
<!-- Notifications -->
<section class="settings-section full-width">
<h2>Notifications</h2>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="briefingConfig.notifications" />
Push notification when each briefing slot is ready
</label>
</div>
</section>
<section class="settings-section full-width">
<div class="actions">
<button class="btn-save" @click="saveBriefingSettings" :disabled="briefingSaving">
{{ briefingSaved ? 'Saved ✓' : briefingSaving ? 'Saving…' : 'Save Briefing Settings' }}
</button>
</div>
</section>
</div>
<!-- ── Voice ── -->
<div v-show="activeTab === 'voice'" class="settings-grid">
<section class="settings-section full-width">
@@ -2741,7 +2378,7 @@ FABLE_API_KEY={{ effectiveApiKey }}</pre>
</div>
<div class="field">
<label for="smtp-from-name">From Name</label>
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Assistant" class="input" />
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Scribe" class="input" />
</div>
</div>
<div class="checkbox-field">
+36 -199
View File
@@ -1,15 +1,13 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { ref, computed, onMounted } from "vue";
import { useRoute } from "vue-router";
import { apiGet } from "@/api/client";
import { useChatStore } from "@/stores/chat";
import { useToastStore } from "@/stores/toast";
import ChatPanel from "@/components/ChatPanel.vue";
import WorkspaceTaskPanel from "@/components/WorkspaceTaskPanel.vue";
import WorkspaceNoteEditor from "@/components/WorkspaceNoteEditor.vue";
import WorkspaceChatWidget from "@/components/WorkspaceChatWidget.vue";
const route = useRoute();
const chatStore = useChatStore();
const toast = useToastStore();
const projectId = computed(() => Number(route.params.projectId));
@@ -21,142 +19,62 @@ interface Project {
}
const project = ref<Project | null>(null);
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
const taskPanelRef = ref<InstanceType<typeof WorkspaceTaskPanel> | null>(null);
const noteEditorRef = ref<InstanceType<typeof WorkspaceNoteEditor> | null>(null);
const activeNoteId = ref<number | null>(null);
let workspaceConvId: number | null = null;
let isNewConv = false;
function _storageKey(pid: number) {
return `workspace_conv_${pid}`;
}
// Panel collapse state — persisted per project
// Panel collapse state — persisted per project (tasks + notes only; chat is now a widget)
function _panelKey(pid: number) { return `workspace_panels_${pid}`; }
function _loadPanelState(pid: number) {
try {
const raw = localStorage.getItem(_panelKey(pid));
if (raw) {
const saved = JSON.parse(raw);
// Ensure at least one panel is open after restore
if (!saved.tasks && !saved.chat && !saved.notes) saved.chat = true;
return saved as { tasks: boolean; chat: boolean; notes: boolean };
const saved = JSON.parse(raw) as Partial<{ tasks: boolean; notes: boolean }>;
const tasks = saved.tasks ?? true;
const notes = saved.notes ?? true;
// Guard: at least one panel must be open
if (!tasks && !notes) return { tasks: true, notes: true };
return { tasks, notes };
}
} catch { /* ignore */ }
return { tasks: true, chat: true, notes: true };
return { tasks: true, notes: true };
}
const panelOpen = ref({ tasks: true, chat: true, notes: true });
const panelOpen = ref<{ tasks: boolean; notes: boolean }>({ tasks: true, notes: true });
const gridColumns = computed(() => {
return [
panelOpen.value.tasks ? "minmax(0, 0.8fr)" : "0px",
panelOpen.value.chat ? "minmax(0, 1.1fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 1.1fr)" : "0px",
panelOpen.value.tasks ? "minmax(0, 0.85fr)" : "0px",
panelOpen.value.notes ? "minmax(0, 2.15fr)" : "0px",
].join(" ");
});
// SSE watcher — auto-load notes/tasks when tool calls succeed
const processedCount = ref(0);
watch(
() => chatStore.streamingToolCalls,
(calls) => {
for (let i = processedCount.value; i < calls.length; i++) {
const tc = calls[i];
if (
["create_note", "update_note"].includes(tc.function) &&
tc.status === "success" &&
tc.result?.data?.id
) {
activeNoteId.value = tc.result.data.id as number;
noteEditorRef.value?.reload();
}
if (
tc.status === "success" && (
["create_milestone", "update_milestone"].includes(tc.function) ||
(tc.function === "create_note" && tc.result?.data?.status) ||
(tc.function === "update_note" && tc.result?.data?.status !== undefined)
)
) {
taskPanelRef.value?.reload();
}
}
processedCount.value = calls.length;
},
{ deep: true }
);
watch(
() => chatStore.streaming,
(s) => {
if (!s) processedCount.value = 0;
}
);
function togglePanel(panel: keyof typeof panelOpen.value) {
const open = panelOpen.value;
const openCount = [open.tasks, open.chat, open.notes].filter(Boolean).length;
const openCount = [open.tasks, open.notes].filter(Boolean).length;
if (open[panel] && openCount <= 1) return;
panelOpen.value[panel] = !panelOpen.value[panel];
localStorage.setItem(_panelKey(projectId.value), JSON.stringify(panelOpen.value));
}
function prefill(text: string) {
chatPanelRef.value?.prefill(text);
function onNoteChanged(noteId: number) {
activeNoteId.value = noteId;
noteEditorRef.value?.reload();
}
function onTaskChanged() {
taskPanelRef.value?.reload();
}
onMounted(async () => {
// Restore panel state
panelOpen.value = _loadPanelState(projectId.value);
// Load project info
try {
project.value = await apiGet<Project>(`/api/projects/${projectId.value}`);
} catch {
toast.show("Failed to load project", "error");
}
const key = _storageKey(projectId.value);
const storedId = localStorage.getItem(key);
if (storedId) {
// Try to reuse the existing workspace conversation
const existingId = Number(storedId);
try {
await chatStore.fetchConversation(existingId);
workspaceConvId = existingId;
isNewConv = false;
chatStore.reconnectIfGenerating(existingId);
} catch {
// Conversation was deleted — create a fresh one
localStorage.removeItem(key);
}
}
if (workspaceConvId === null) {
// Create a new workspace conversation and persist its ID
const conv = await chatStore.createConversation();
workspaceConvId = conv.id;
isNewConv = true;
await chatStore.fetchConversation(conv.id);
localStorage.setItem(key, String(conv.id));
}
nextTick(() => chatPanelRef.value?.focus());
});
onUnmounted(async () => {
// Only delete if we created a brand-new conversation this session and it's still empty
if (workspaceConvId !== null && isNewConv) {
const conv = chatStore.conversations.find((c) => c.id === workspaceConvId);
if (conv && conv.message_count === 0) {
await chatStore.deleteConversation(workspaceConvId);
localStorage.removeItem(_storageKey(projectId.value));
}
}
});
</script>
@@ -176,13 +94,6 @@ onUnmounted(async () => {
>
Tasks
</button>
<button
:class="['panel-toggle', { active: panelOpen.chat }]"
title="Toggle Chat panel"
@click="togglePanel('chat')"
>
Chat
</button>
<button
:class="['panel-toggle', { active: panelOpen.notes }]"
title="Toggle Notes panel"
@@ -193,9 +104,8 @@ onUnmounted(async () => {
</div>
</header>
<!-- Three-panel body -->
<!-- Two-panel body -->
<div class="ws-body" :style="{ gridTemplateColumns: gridColumns }">
<!-- Left: Tasks -->
<div v-show="panelOpen.tasks" class="ws-panel">
<Transition name="panel-fade">
@@ -209,35 +119,8 @@ onUnmounted(async () => {
</Transition>
</div>
<!-- Center: Chat -->
<div v-show="panelOpen.chat" class="ws-panel ws-panel-chat">
<Transition name="panel-fade">
<div v-if="panelOpen.chat" class="panel-inner panel-inner-chat">
<!-- Quick chips (shown when conversation is empty) -->
<div
v-if="chatStore.currentConversation && !chatStore.currentConversation.messages.length && !chatStore.streaming"
class="empty-chat-prompt"
>
<p class="empty-hint">What would you like to work on?</p>
<div class="quick-chips">
<button class="quick-chip" @click="prefill('Summarize the current status of this project')">📊 Project status</button>
<button class="quick-chip" @click="prefill('Create a note about ')">📝 New note</button>
<button class="quick-chip" @click="prefill('Add tasks for ')"> Add tasks</button>
</div>
</div>
<ChatPanel
ref="chatPanelRef"
variant="full"
:projectId="projectId"
placeholder="Message the agent… (Enter to send)"
class="ws-chat-panel"
/>
</div>
</Transition>
</div>
<!-- Right: Note Editor -->
<div v-show="panelOpen.notes" class="ws-panel">
<div v-show="panelOpen.notes" class="ws-panel ws-panel-notes">
<Transition name="panel-fade">
<div v-if="panelOpen.notes" class="panel-inner">
<WorkspaceNoteEditor
@@ -249,13 +132,21 @@ onUnmounted(async () => {
</div>
</Transition>
</div>
</div>
<!-- Floating chat widget -->
<WorkspaceChatWidget
v-if="project"
:project-id="projectId"
@note-changed="onNoteChanged"
@task-changed="onTaskChanged"
/>
</div>
</template>
<style scoped>
.workspace-root {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
@@ -263,7 +154,6 @@ onUnmounted(async () => {
background: var(--color-bg);
}
/* Header */
.ws-header {
display: flex;
align-items: center;
@@ -318,7 +208,6 @@ onUnmounted(async () => {
color: var(--color-primary);
}
/* Three-panel layout */
.ws-body {
display: grid;
flex: 1;
@@ -331,6 +220,10 @@ onUnmounted(async () => {
min-width: 0;
}
.ws-panel-notes {
border-left: 1px solid var(--color-border);
}
.panel-inner {
height: 100%;
display: flex;
@@ -345,60 +238,4 @@ onUnmounted(async () => {
.panel-fade-leave-to {
opacity: 0;
}
/* Chat panel */
.ws-panel-chat {
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
}
.panel-inner-chat {
position: relative;
}
.ws-chat-panel {
flex: 1;
min-height: 0;
}
.empty-chat-prompt {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
text-align: center;
padding: 2rem 1rem;
pointer-events: none;
z-index: 1;
}
.empty-hint {
margin: 0 0 1rem;
color: var(--color-text-secondary);
font-size: 0.9rem;
}
.quick-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: center;
pointer-events: auto;
}
.quick-chip {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 20px;
padding: 0.4rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-secondary);
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
font-family: inherit;
}
.quick-chip:hover {
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 5%, var(--color-surface));
}
</style>
+1 -1
View File
@@ -8,7 +8,7 @@
set -euo pipefail
REPO_ROOT="/home/bvandeusen/Nextcloud/Projects/fabledassistant"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
input=$(cat)
command=$(echo "$input" | python3 -c "
+7 -17
View File
@@ -11,8 +11,8 @@ from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.briefing import briefing_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.journal import journal_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
@@ -75,8 +75,8 @@ def create_app() -> Quart:
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(briefing_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(journal_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
@@ -320,22 +320,12 @@ def create_app() -> Quart:
await backfill_project_summaries()
except Exception:
logger.warning("Project summary backfill failed", exc_info=True)
try:
from fabledassistant.services.embeddings import backfill_rss_item_embeddings
await backfill_rss_item_embeddings()
except Exception:
logger.warning("RSS embedding backfill failed", exc_info=True)
try:
from fabledassistant.services.embeddings import backfill_rss_article_content
await backfill_rss_article_content()
except Exception:
logger.warning("RSS article content backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill())
# Start briefing scheduler
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
await start_briefing_scheduler(asyncio.get_running_loop())
# Start journal scheduler (per-user daily prep generation)
from fabledassistant.services.journal_scheduler import start_journal_scheduler
start_journal_scheduler(asyncio.get_running_loop())
# Start event scheduler (reminders + CalDAV pull sync)
from fabledassistant.services.event_scheduler import start_event_scheduler
@@ -349,8 +339,8 @@ def create_app() -> Quart:
@app.after_serving
async def shutdown():
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
stop_briefing_scheduler()
from fabledassistant.services.journal_scheduler import stop_journal_scheduler
stop_journal_scheduler()
from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler()
+2 -2
View File
@@ -25,7 +25,7 @@ class Config:
OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
# Lightweight model for background tasks (title generation, tag suggestions,
# project summaries, RSS classification). Using a separate model keeps the
# project summaries). Using a separate model keeps the
# main model's KV cache intact between user messages, enabling prefix cache hits.
OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
# Ollama keep_alive — how long a model stays resident in VRAM after its last
@@ -52,7 +52,7 @@ class Config:
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Scribe")
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
BASE_URL: str = os.environ.get("BASE_URL", "http://localhost:5000").rstrip("/")
TRUST_PROXY_HEADERS: bool = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in ("1", "true", "yes")
+8 -2
View File
@@ -38,8 +38,14 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.rss_feed import RssFeed, RssItem # noqa: E402, F401
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
from fabledassistant.models.rss_item_embedding import RssItemEmbedding # noqa: E402, F401
from fabledassistant.models.moment import ( # noqa: E402, F401
Moment,
MomentEmbedding,
moment_people,
moment_places,
moment_tasks,
moment_notes,
)
+5 -5
View File
@@ -18,11 +18,11 @@ class Conversation(Base, TimestampMixin):
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
# 'chat' (default) or 'briefing'. Briefing conversations are hidden from
# the main chat view and managed by the briefing pipeline.
# 'chat' (default) or 'journal'. Journal conversations are day-anchored
# and rendered by the /journal view; they are hidden from the main chat list.
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
# For briefing conversations only: the calendar date this briefing covers.
briefing_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
# For journal conversations only: the calendar date this conversation covers.
day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
# NULL = orphan notes only; -1 = all notes; positive int = specific project
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
@@ -48,7 +48,7 @@ class Conversation(Base, TimestampMixin):
"title": self.title,
"model": self.model,
"conversation_type": self.conversation_type,
"briefing_date": self.briefing_date.isoformat() if self.briefing_date else None,
"day_date": self.day_date.isoformat() if self.day_date else None,
"rag_project_id": self.rag_project_id,
"message_count": msg_count,
"created_at": self.created_at.isoformat(),
+130
View File
@@ -0,0 +1,130 @@
import datetime
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
# Junction tables. People, Places, Tasks, and (regular) Notes all live in
# the `notes` table — the four junctions are kept separate (rather than one
# merged table with a discriminator) so per-kind queries don't require a
# filter, and so the schema is explicit about which links are which.
moment_people = Table(
"moment_people",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_places = Table(
"moment_places",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_tasks = Table(
"moment_tasks",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_notes = Table(
"moment_notes",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
class Moment(Base):
"""A small structured extraction from a journal conversation.
Many per day. Emitted by the LLM via the `record_moment` tool when it
notices a meaningful beat. Stored separately from Notes — they are
different in kind: Notes are curated artifacts; Moments are ambient
trace data with their own embedding index, so notes-RAG and journal-RAG
cannot cross-contaminate.
"""
__tablename__ = "moments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
conversation_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True
)
source_message_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
)
day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False)
occurred_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
recorded_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
content: Mapped[str] = mapped_column(Text, nullable=False)
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True)
places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True)
tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True)
notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True)
__table_args__ = (
Index("ix_moments_user_day", "user_id", "day_date"),
Index("ix_moments_user_occurred", "user_id", "occurred_at"),
)
def to_dict(self, *, include_links: bool = True) -> dict:
result: dict = {
"id": self.id,
"user_id": self.user_id,
"conversation_id": self.conversation_id,
"source_message_id": self.source_message_id,
"day_date": self.day_date.isoformat(),
"occurred_at": self.occurred_at.isoformat(),
"recorded_at": self.recorded_at.isoformat(),
"content": self.content,
"raw_excerpt": self.raw_excerpt,
"tags": list(self.tags or []),
"pinned": self.pinned,
}
if include_links:
result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])]
result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])]
result["task_ids"] = [t.id for t in (self.tasks or [])]
result["note_ids"] = [n.id for n in (self.notes or [])]
return result
class MomentEmbedding(Base):
"""Embedding vector for a Moment — used by `search_journal` semantic mode.
Stored separately from `note_embeddings` so notes-RAG and journal-RAG
cannot cross-contaminate. This is a hard invariant of the journal design.
"""
__tablename__ = "moment_embeddings"
moment_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("moments.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
-96
View File
@@ -1,96 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
class RssFeed(Base):
__tablename__ = "rss_feeds"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
url: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str | None] = mapped_column(Text, nullable=True)
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
items: Mapped[list["RssItem"]] = relationship(
back_populates="feed", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
Index("ix_rss_feeds_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"url": self.url,
"title": self.title,
"category": self.category,
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
}
class RssItem(Base):
__tablename__ = "rss_items"
id: Mapped[int] = mapped_column(primary_key=True)
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
guid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
url: Mapped[str] = mapped_column(Text, default="")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Truncated to 2000 chars to keep DB size reasonable
content: Mapped[str] = mapped_column(Text, default="")
# Full trafilatura-extracted article body, populated lazily on first
# discuss-click / enrichment pass. Nullable — most items never get this
# cached. Expires naturally with the item (90-day retention).
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
# Map-reduced conversation-ready context derived from content_full. See
# services/article_context.py — populated on first discuss click so
# repeat clicks skip both the fetch and the LLM map step.
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
content_fetched_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
topics: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Note persisting the first-click discussion summary. Set by the article
# discussion pipeline once the seeded chat completes its first assistant
# reply; links back into RAG so re-discussing the same article lands the
# prior summary in context.
discussion_note_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
feed: Mapped["RssFeed"] = relationship(back_populates="items")
__table_args__ = (
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
Index("ix_rss_items_feed_id", "feed_id"),
Index("ix_rss_items_published_at", "published_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"feed_id": self.feed_id,
"guid": self.guid,
"title": self.title,
"url": self.url,
"published_at": self.published_at.isoformat() if self.published_at else None,
"content": self.content,
"topics": self.topics or [],
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
}
@@ -1,25 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Integer
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class RssItemEmbedding(Base):
"""Stores the embedding vector for an RSS item, used for semantic news search."""
__tablename__ = "rss_item_embeddings"
rss_item_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("rss_items.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
-705
View File
@@ -1,705 +0,0 @@
"""Briefing API: RSS feeds, weather, and briefing configuration."""
import asyncio
import json
import logging
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import login_required
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.rss_feed import RssFeed, RssItem
from fabledassistant.services import rss as rss_svc
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation,
list_briefing_conversations,
post_message,
)
from fabledassistant.services.chat import add_message, get_conversation
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.settings import get_setting, set_settings_batch
logger = logging.getLogger(__name__)
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
_REQUIRE = login_required
# ── Config ────────────────────────────────────────────────────────────────────
@briefing_bp.route("/config", methods=["GET"])
@_REQUIRE
async def get_config():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
config = {}
return jsonify(config)
@briefing_bp.route("/config", methods=["PUT"])
@_REQUIRE
async def put_config():
data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
# Live-patch the scheduler using the stored user_timezone.
from fabledassistant.services.briefing_scheduler import update_user_schedule
tz_override = await get_setting(g.user.id, "user_timezone") or None
update_user_schedule(g.user.id, data, tz_override=tz_override)
return jsonify({"ok": True})
async def _rss_enabled() -> bool:
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
@briefing_bp.route("/feeds", methods=["GET"])
@_REQUIRE
async def list_feeds():
if not await _rss_enabled():
return jsonify([])
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
)
feeds = list(result.scalars().all())
return jsonify([f.to_dict() for f in feeds])
@briefing_bp.route("/feeds", methods=["POST"])
@_REQUIRE
async def add_feed():
if not await _rss_enabled():
return jsonify({"error": "RSS is disabled"}), 403
data = await request.get_json()
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Feed URL must use http or https"}), 400
from fabledassistant.services.llm import _is_private_url
if _is_private_url(url):
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
category = data.get("category") or None
async with async_session() as session:
# Prevent duplicates
existing = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
)
if existing.scalars().first():
return jsonify({"error": "Feed already added"}), 409
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
# Fetch in background to auto-populate title and get initial items
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
@_REQUIRE
async def delete_feed(feed_id: int):
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
)
feed = result.scalars().first()
if not feed:
return jsonify({"error": "Not found"}), 404
await session.delete(feed)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/feeds/refresh", methods=["POST"])
@_REQUIRE
async def refresh_feeds():
if not await _rss_enabled():
return jsonify({"feeds_refreshed": 0, "new_items": 0})
results = await rss_svc.refresh_all_feeds(g.user.id)
total_new = sum(results.values())
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
@briefing_bp.route("/feeds/recent", methods=["GET"])
@_REQUIRE
async def recent_items():
if not await _rss_enabled():
return jsonify({"items": []})
limit = min(int(request.args.get("limit", 20)), 100)
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
return jsonify({"items": items})
# ── Weather ───────────────────────────────────────────────────────────────────
@briefing_bp.route("/weather", methods=["GET"])
@_REQUIRE
async def get_weather():
rows = await weather_svc.get_cached_weather_rows(g.user.id)
import json as _json
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = _cfg.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
temp_unit = "C"
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
@briefing_bp.route("/weather/refresh", methods=["POST"])
@_REQUIRE
async def refresh_weather():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else {}
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
config = {}
temp_unit = "C"
locations = config.get("locations", {})
for key, loc in locations.items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
rows = await weather_svc.get_cached_weather_rows(g.user.id)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
# ── Briefing Conversations ─────────────────────────────────────────────────────
@briefing_bp.route("/conversations", methods=["GET"])
@_REQUIRE
async def get_conversations():
convs = await list_briefing_conversations(g.user.id)
return jsonify({"conversations": convs})
@briefing_bp.route("/conversations/today", methods=["GET"])
@_REQUIRE
async def get_today_conversation():
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
# Load messages
async with async_session() as session:
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
data = conv.to_dict()
data["messages"] = messages
return jsonify(data)
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
@_REQUIRE
async def get_conversation_messages(conv_id: int):
# Verify ownership
async with async_session() as session:
conv = await session.get(Conversation, conv_id)
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
return jsonify({"error": "Not found"}), 404
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv_id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
return jsonify({"messages": messages})
@briefing_bp.route("/trigger", methods=["POST"])
@_REQUIRE
async def manual_trigger():
"""Manually trigger a briefing compilation, including a fresh data refresh."""
data = await request.get_json() or {}
slot = data.get("slot", "compilation")
if slot not in ("compilation", "morning", "midday", "afternoon"):
return jsonify({"error": "invalid slot"}), 400
# Refresh external data first (mirrors what the scheduler does)
try:
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(g.user.id)
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
from fabledassistant.services.briefing_pipeline import run_compilation
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
text, metadata = await run_compilation(g.user.id, slot, model)
await _persist_agentic_messages(conv.id, slot, metadata)
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
@briefing_bp.route("/reset-today", methods=["POST"])
@_REQUIRE
async def reset_today_briefing():
"""Delete all messages in today's briefing conversation.
The conversation row itself is kept so its id stays stable for any
open UI sessions. Intended for "wipe and start fresh" workflows
driven from the MCP when iterating on prompts. Pair with
``POST /api/briefing/trigger`` to immediately regenerate.
"""
from sqlalchemy import delete as _delete
from fabledassistant.services.tz import user_briefing_date
# User-local briefing day (flips at 4am local), not ``date.today()`` —
# see services/tz.py for rationale.
today = await user_briefing_date(g.user.id)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == g.user.id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is None:
return jsonify({"deleted": 0, "conversation_id": None})
deleted_result = await session.execute(
_delete(Message).where(Message.conversation_id == conv.id)
)
await session.commit()
deleted = deleted_result.rowcount or 0
return jsonify({"deleted": deleted, "conversation_id": conv.id})
# ── RSS Reactions ──────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@_REQUIRE
async def upsert_rss_reaction():
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
data = await request.get_json()
rss_item_id = data.get("rss_item_id")
reaction = data.get("reaction")
if not rss_item_id or reaction not in ("up", "down"):
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
from sqlalchemy import text as _text
async with async_session() as session:
# Ownership check: verify item belongs to a feed owned by this user
result = await session.execute(
_text("""
SELECT i.id FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = :item_id AND f.user_id = :uid
""").bindparams(item_id=rss_item_id, uid=g.user.id)
)
if result.first() is None:
return jsonify({"error": "Not found"}), 404
# Check existing reaction
existing = await session.execute(
_text("""
SELECT id, reaction FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
row = existing.first()
if row is None:
await session.execute(
_text("""
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
VALUES (:uid, :item_id, :reaction)
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
)
action = "created"
elif row.reaction == reaction:
# Toggle off (same reaction clicked again)
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
action = "removed"
else:
# Flip to opposite reaction
await session.execute(
_text("""
UPDATE rss_item_reactions SET reaction = :reaction
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
)
action = "updated"
await session.commit()
return jsonify({"ok": True, "action": action})
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
@_REQUIRE
async def delete_rss_reaction(item_id: int):
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
from sqlalchemy import text as _text
async with async_session() as session:
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=item_id)
)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
"""Return recent RSS articles with optional feed filter and pagination.
Query params:
days — lookback window (default 2, max 90)
limit — items per page (default 40, max 100)
offset — pagination offset (default 0)
feed_id — optional integer filter by feed
"""
if not await _rss_enabled():
return jsonify({"items": [], "total": 0})
from sqlalchemy import text as _text
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"content": r["content"] or "",
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
# ── Article Discuss ────────────────────────────────────────────────────────────
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Inject article content as a synthetic tool exchange and trigger generation."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify item belongs to user via feed ownership
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalars().first()
if item is None:
return jsonify({"error": "Not found"}), 404
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Shared helper handles the three-layer cache (context_prepared →
# content_full → fresh fetch), writes the synthetic read_article tool
# exchange and the conversational seed user prompt into the conversation.
# The /news from-article route calls the same helper so behavior stays
# byte-identical across entry points.
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
model = await get_setting(uid, "default_model", "") or ""
try:
discuss_prompt = await seed_article_discussion(conv_id, item, model)
except EmptyArticleError as e:
return jsonify({"error": str(e)}), 422
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
assert conv is not None
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)
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
discuss_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
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 ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
-78
View File
@@ -507,81 +507,3 @@ async def delete_model_route():
return jsonify({"error": str(e)}), 500
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
@login_required
async def create_conversation_from_article(item_id: int):
"""Create a chat conversation seeded for article discussion and auto-run.
Mirrors the briefing ``discuss_article`` route: creates a fresh
conversation, stages the shared synthetic read_article exchange + seed
prompt, then kicks off generation so the client lands on an in-flight
stream. The Flutter and web chat screens reconnect to the running buffer
on mount.
"""
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
uid = get_current_user_id()
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.scalars().first()
if item is None:
return jsonify({"error": "Article not found"}), 404
conv_title = (item.title or "Article discussion")[:80]
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
try:
discuss_prompt = await seed_article_discussion(conv.id, item, model)
except EmptyArticleError as e:
# Roll back the empty conversation so the user doesn't end up with a
# phantom entry in their chat list.
try:
await delete_conversation(uid, conv.id)
except Exception:
logger.warning("Failed to clean up empty article conversation %s", conv.id)
return jsonify({"error": str(e)}), 422
# Reload conversation so we see the two messages the helper just added.
conv = await get_conversation(uid, conv.id)
assert conv is not None
history: list[dict] = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict: 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)
assistant_msg = await add_message(conv.id, "assistant", "", status="generating")
buf = create_buffer(conv.id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model, uid, conv.id, conv.title or "", discuss_prompt,
))
return jsonify({
"conversation_id": conv.id,
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202
+329
View File
@@ -0,0 +1,329 @@
"""HTTP endpoints for the Journal feature.
Includes the conversational journal endpoints (config / today / day / days /
trigger-prep / moments) plus the ambient-context surface lifted from the
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
The ambient endpoints read locations + temp_unit + topic preferences from the
``journal_config`` user setting.
"""
from __future__ import annotations
import datetime
import json
import logging
from zoneinfo import ZoneInfo
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.journal_scheduler import (
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
update_user_schedule,
)
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.moments import delete_moment, update_moment
from fabledassistant.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__)
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
def _resolve_tz(tz_str: str) -> ZoneInfo:
try:
return ZoneInfo(tz_str)
except Exception:
return ZoneInfo("UTC")
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
tz = _resolve_tz(tz_str)
now = datetime.datetime.now(tz)
if now.hour < day_rollover_hour:
return (now - datetime.timedelta(days=1)).date()
return now.date()
async def _user_timezone(user_id: int) -> str:
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
async def _resolve_config(user_id: int) -> dict:
raw = await get_setting(user_id, "journal_config", "")
config: dict = {}
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
config = parsed
except Exception:
logger.warning("Invalid journal_config for user %d", user_id)
return {**DEFAULT_JOURNAL_CONFIG, **config}
@journal_bp.get("/config")
@login_required
async def get_config():
user_id = get_current_user_id()
return jsonify(await _resolve_config(user_id))
@journal_bp.put("/config")
@login_required
async def put_config():
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "config must be an object"}), 400
await set_setting(user_id, "journal_config", json.dumps(body))
await update_user_schedule(user_id)
return jsonify({"ok": True})
@journal_bp.get("/today")
@login_required
async def get_today():
user_id = get_current_user_id()
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
await ensure_daily_prep_message(
user_id=user_id, day_date=today, user_timezone=tz_str
)
return await _day_payload(user_id=user_id, day_date=today)
@journal_bp.get("/day/<iso_date>")
@login_required
async def get_day(iso_date: str):
user_id = get_current_user_id()
try:
day = datetime.date.fromisoformat(iso_date)
except ValueError:
return jsonify({"error": "invalid date"}), 400
return await _day_payload(user_id=user_id, day_date=day)
@journal_bp.get("/days")
@login_required
async def list_days():
user_id = get_current_user_id()
async with async_session() as session:
stmt = (
select(Conversation.day_date)
.where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date.is_not(None),
)
.order_by(Conversation.day_date.desc())
)
rows = (await session.execute(stmt)).scalars().all()
return jsonify({"days": [d.isoformat() for d in rows]})
@journal_bp.post("/trigger-prep")
@login_required
async def trigger_prep():
user_id = get_current_user_id()
body = await request.get_json(silent=True) or {}
iso_date = body.get("date")
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
day = (
datetime.date.fromisoformat(iso_date)
if iso_date
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
)
msg = await ensure_daily_prep_message(
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
)
return jsonify({"ok": True, "message_id": msg.id})
@journal_bp.get("/moments")
@login_required
async def list_moments():
user_id = get_current_user_id()
args = request.args
df = args.get("date_from")
dt = args.get("date_to")
person_id = args.get("person_id", type=int)
place_id = args.get("place_id", type=int)
tag = args.get("tag")
query = args.get("query")
pinned_only = args.get("pinned_only", "false").lower() == "true"
limit = args.get("limit", default=50, type=int)
results = await search_journal(
user_id=user_id,
query=query,
person_id=person_id,
place_id=place_id,
tag=tag,
date_from=datetime.date.fromisoformat(df) if df else None,
date_to=datetime.date.fromisoformat(dt) if dt else None,
limit=limit,
)
if pinned_only:
results = [r for r in results if r.get("pinned")]
return jsonify({"moments": results})
@journal_bp.patch("/moments/<int:moment_id>")
@login_required
async def patch_moment(moment_id: int):
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400
moment = await update_moment(
user_id=user_id,
moment_id=moment_id,
content=body.get("content"),
tags=body.get("tags"),
pinned=body.get("pinned"),
person_ids=body.get("person_ids"),
place_ids=body.get("place_ids"),
task_ids=body.get("task_ids"),
note_ids=body.get("note_ids"),
)
if moment is None:
return jsonify({"error": "not found"}), 404
return jsonify(moment.to_dict())
@journal_bp.delete("/moments/<int:moment_id>")
@login_required
async def remove_moment(moment_id: int):
user_id = get_current_user_id()
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
if not deleted:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
# ───────────────────────────────────────────────────────────────────────────────
# Ambient endpoints (lifted from the old briefing surface).
# ───────────────────────────────────────────────────────────────────────────────
async def _journal_temp_unit(user_id: int) -> str:
cfg = await _resolve_config(user_id)
unit = cfg.get("temp_unit", "C")
return unit if unit in ("C", "F") else "C"
# ── Weather ───────────────────────────────────────────────────────────────────
@journal_bp.get("/weather")
@login_required
async def get_weather():
user_id = get_current_user_id()
rows = await weather_svc.get_cached_weather_rows(user_id)
temp_unit = await _journal_temp_unit(user_id)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.get("/weather/current")
@login_required
async def get_current_weather():
"""Live current temperature + conditions for the user's primary location."""
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
locations = cfg.get("locations") or {}
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@journal_bp.post("/weather/refresh")
@login_required
async def refresh_weather():
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
for key, loc in (cfg.get("locations") or {}).items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
rows = await weather_svc.get_cached_weather_rows(user_id)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.post("/weather/geocode")
@login_required
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
async def _day_payload(*, user_id: int, day_date: datetime.date):
async with async_session() as session:
conv_stmt = select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
if conv is None:
return jsonify({
"day_date": day_date.isoformat(),
"conversation": None,
"messages": [],
})
msgs_stmt = (
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = (await session.execute(msgs_stmt)).scalars().all()
return jsonify({
"day_date": day_date.isoformat(),
"conversation": conv.to_dict(),
"messages": [m.to_dict() for m in messages],
})
+3 -10
View File
@@ -94,17 +94,10 @@ async def update_settings_route():
if to_save:
await set_settings_batch(uid, to_save)
# When timezone changes, live-patch the briefing scheduler immediately
# Live-reschedule the journal daily-prep job when the timezone changes.
if "user_timezone" in to_save:
import json as _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)
from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule
await _update_journal_schedule(uid)
if "default_model" in to_save and to_save["default_model"]:
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
@@ -1,270 +0,0 @@
"""Prepare article bodies as conversation-ready context.
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
A raw trafilatura extraction is often too large to drop whole into a chat
history without eating the context window, so this module runs a map-reduce
step over oversized articles and returns a compact, structured context that
still preserves the article's meaning across sections.
Small articles pass through unchanged — map-reduce only fires when the raw
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
by the caller, so repeat discuss-clicks on the same article skip this work
entirely.
The module also owns ``seed_article_discussion``, the shared routine that
stages a synthetic ``read_article`` tool exchange plus a conversational seed
prompt into a conversation. Both the briefing and ``/news`` entry points call
it so the two flows stay byte-identical — the only thing that differs between
them is whether the conversation already existed or was freshly created.
"""
from __future__ import annotations
import asyncio
import logging
import re
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.services.chat import add_message
from fabledassistant.services.llm import generate_completion
logger = logging.getLogger(__name__)
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
# with room left for system prompt, chat history, and the assistant reply.
CHAR_BUDGET = 48_000
# Chunk size for the map step on oversized articles. Overlap preserves
# context across paragraph boundaries that happen to land mid-sentence.
CHUNK_CHARS = 8_000
CHUNK_OVERLAP = 400
_PARA_SPLIT = re.compile(r"\n\s*\n")
def _chunk_by_paragraph(body: str) -> list[str]:
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
a sentence straddling the boundary stays readable on both sides.
"""
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
chunks: list[str] = []
current: list[str] = []
current_len = 0
for para in paragraphs:
para_len = len(para)
if para_len > CHUNK_CHARS:
if current:
chunks.append("\n\n".join(current))
current, current_len = [], 0
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
chunks.append(para[i : i + CHUNK_CHARS])
continue
if current_len + para_len + 2 > CHUNK_CHARS and current:
chunks.append("\n\n".join(current))
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
current = [tail, para] if tail else [para]
current_len = len(tail) + para_len + (2 if tail else 0)
else:
current.append(para)
current_len += para_len + 2
if current:
chunks.append("\n\n".join(current))
return chunks
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
"""Map-step summary of one article chunk.
Aims for ~300 words of dense, factual prose — not bullet points — so the
downstream chat model can quote from it naturally.
"""
messages = [
{
"role": "system",
"content": (
"You are summarizing one section of a larger article so a downstream "
"conversation model can discuss the full article without having to read "
"every word.\n\n"
"Requirements:\n"
"- 250350 words of dense factual prose\n"
"- Preserve specific claims, numbers, names, and quotes\n"
"- Do NOT editorialize or add analysis\n"
"- Do NOT use bullet points or headings\n"
"- Do NOT say 'this section' or 'this article' — write content, not meta"
),
},
{
"role": "user",
"content": (
f"Article: {title}\n"
f"Section {index + 1} of {total}:\n\n{chunk}"
),
},
]
try:
# Pin num_ctx — same rationale as services/research.py:66. A large
# chunk plus system prompt can push well past the default window;
# silent truncation here would drop the tail of the chunk without
# any error, producing a misleading summary.
raw = await generate_completion(
messages, model, max_tokens=600, num_ctx=16384
)
return raw.strip()
except Exception:
logger.warning(
"Article chunk summary failed for section %d/%d of '%s'",
index + 1, total, title, exc_info=True,
)
# Fall back to the raw chunk truncated to ~1500 chars so the overall
# pipeline still delivers something rather than dropping the section.
return chunk[:1500]
async def prepare_article_context(
title: str,
url: str,
body: str,
model: str,
) -> str:
"""Return a conversation-ready context block for ``body``.
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
- Oversized article: runs a parallel map step over paragraph-aware
chunks and concatenates the summaries under section headers.
The returned string is what should go into the ``read_article`` synthetic
tool-result in chat history. Callers are responsible for caching it to
``rss_items.context_prepared``.
"""
body = body or ""
if len(body) <= CHAR_BUDGET:
return body
chunks = _chunk_by_paragraph(body)
logger.info(
"Article '%s' is %d chars, map-reducing into %d chunks",
title, len(body), len(chunks),
)
summaries = await asyncio.gather(
*[
_summarize_chunk(title, chunk, i, len(chunks), model)
for i, chunk in enumerate(chunks)
]
)
header = (
f"(This article was longer than the chat window could hold verbatim, "
f"so the full text was split into {len(chunks)} sections and each was "
"summarized below. Each section preserves specific claims, numbers, "
"and quotes from the original.)\n\n"
)
parts = [
f"## Section {i + 1}\n\n{summary}"
for i, summary in enumerate(summaries)
]
return header + "\n\n".join(parts)
# Conversational seed prompt for article discussions. Kept here so both the
# briefing and /news entry points use the exact same wording. See
# feedback_discuss_prompt_style memory: numbered checklists produce
# assignment-completion responses; this conversational seed opens a dialogue.
ARTICLE_DISCUSS_SEED = (
"I want to talk about this article. Start with a substantive summary "
"of what it's arguing and the key evidence it uses, then tell me what "
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
"from there."
)
class EmptyArticleError(Exception):
"""Raised when an article has no extractable body text.
Callers (the briefing and /news discuss routes) map this to a 422 so the
user sees a clear error instead of a hallucinated summary built from an
empty synthetic tool result.
"""
async def seed_article_discussion(
conv_id: int,
item: RssItem,
model: str,
) -> str:
"""Stage the synthetic read_article tool exchange + conversational seed.
Used by both the briefing ``discuss_article`` route and the ``/news``
``from-article`` conversation creator. Handles the three-layer cache
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
messages into ``conv_id``:
1. An assistant message with a synthetic ``read_article`` tool_call whose
``result.content`` carries the prepared article context. The message
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
hook in ``generation_task.py`` can locate it and persist the first
reply as a discussion-summary Note.
2. A user message with the shared conversational seed prompt.
Returns the seed prompt string so callers can pass it to ``run_generation``
as ``user_content``.
"""
# Avoid circulars: rss helper imports article_context indirectly nowhere,
# but keep this local for symmetry with the route-level imports it
# replaces.
from fabledassistant.services.rss import get_or_fetch_full_article
if item.context_prepared:
article_content = item.context_prepared
else:
raw_body = await get_or_fetch_full_article(item) or item.content or ""
if not raw_body.strip():
# Hard-fail rather than stage an empty synthetic tool result.
# An empty `content` field silently tells the model "the article
# has nothing in it" and it confabulates from RAG/history. Better
# to surface a clean error to the user.
logger.warning(
"Article discussion aborted: empty body for rss_item %s (%s)",
item.id, item.url,
)
raise EmptyArticleError(
"Couldn't extract any readable text from this article."
)
article_content = await prepare_article_context(
item.title or "", item.url, raw_body, model,
)
if not article_content.strip():
raise EmptyArticleError(
"Couldn't extract any readable text from this article."
)
async with async_session() as session:
fresh = await session.get(RssItem, item.id)
if fresh is not None:
fresh.context_prepared = article_content
await session.commit()
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,
msg_metadata={"rss_item_id": item.id, "article_seed": True},
)
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
return ARTICLE_DISCUSS_SEED
@@ -0,0 +1,53 @@
"""Generic article-text fetcher.
Fetches a URL and extracts its main body via trafilatura. The single source
of truth for article-content extraction across the codebase — used by the
``read_article`` LLM tool and the ``lookup`` tool's web-result enrichment.
Trafilatura/lxml is NOT safe to call concurrently — running it via
``run_in_executor`` from multiple coroutines can trip a libxml2 double-free.
Callers must serialize their fetches (await one before starting the next).
"""
from __future__ import annotations
import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
async def fetch_article_text(url: str) -> str | None:
"""Return the clean article body for *url*, or None on failure.
Returns None when the HTTP fetch fails or trafilatura yields nothing
useful. Callers should treat None as "no article content available."
"""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (compatible; FabledScribe/1.0; +https://fabledsword.com)",
}) as client:
resp = await client.get(url)
resp.raise_for_status()
raw_html = resp.text
except Exception:
logger.debug("Failed to fetch article URL %s", url)
return None
loop = asyncio.get_event_loop()
try:
import trafilatura
text = await loop.run_in_executor(
None,
lambda: trafilatura.extract(
raw_html,
include_comments=False,
include_tables=True,
favor_recall=True,
),
)
return text or None
except Exception:
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
return None
@@ -1,92 +0,0 @@
"""Create and manage briefing conversations."""
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.tz import user_briefing_date
logger = logging.getLogger(__name__)
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
"""
Return today's briefing conversation, creating it if it doesn't exist.
"""
# "Today" is the user-local briefing day (flips at 4am local), not
# ``date.today()`` — in a UTC container the latter rolls over at
# 19:00 NY local and makes the in-progress briefing disappear.
today = await user_briefing_date(user_id)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is not None:
return conv
conv = Conversation(
user_id=user_id,
title=f"Briefing — {today.isoformat()}",
model=model,
conversation_type="briefing",
briefing_date=today,
)
session.add(conv)
await session.commit()
await session.refresh(conv)
return conv
async def post_message(
conversation_id: int,
role: str,
content: str,
metadata: dict | None = None,
tool_calls: list | None = None,
) -> Message:
"""Append a message to a briefing conversation.
``tool_calls`` is accepted on assistant-role messages so the full
agentic briefing sequence (assistant tool-call turns and tool-role
results) can be persisted as real conversation rows, keeping the
receipts in context on chat follow-ups.
"""
async with async_session() as session:
msg = Message(
conversation_id=conversation_id,
role=role,
content=content,
status="complete",
msg_metadata=metadata,
tool_calls=tool_calls,
)
session.add(msg)
# Bump 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
async def list_briefing_conversations(user_id: int) -> list[dict]:
"""Return briefing conversations newest first."""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
).order_by(Conversation.briefing_date.desc().nullslast())
.limit(30)
)
convs = list(result.scalars().all())
return [c.to_dict() for c in convs]
@@ -1,429 +0,0 @@
"""
Briefing pipeline: agentic tool-use loop + UI metadata gather.
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
"""
import asyncio
import logging
from datetime import datetime
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
# ── External data gather ──────────────────────────────────────────────────────
async def _gather_external(user_id: int) -> dict:
"""Collect RSS items (when enabled) and weather."""
from fabledassistant.services.weather import get_cached_weather
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
rss_items: list = []
if rss_on:
from fabledassistant.services.rss import get_recent_items
try:
rss_items = await get_recent_items(user_id, limit=20)
except Exception:
pass
try:
weather = await get_cached_weather(user_id)
except Exception:
weather = []
return {
"rss_items": rss_items,
"weather": weather,
}
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
_BRIEFING_AGENT_MAX_ROUNDS = 8
_BRIEFING_AGENT_NUM_CTX = 8192
def _agentic_system_prompt(
profile_body: str,
slot: str,
today_iso: str,
tz_name: str,
day_from_iso: str,
day_to_iso: str,
) -> str:
"""System prompt for the agentic briefing path.
Pushes the model to ground every factual claim in a tool result and
to be honest when tools return nothing, rather than fabricating
content to fill the narrative.
Pre-computes today's window in the user's local timezone so the
model can call date-sensitive tools (list_events, list_tasks filters)
without having to do any timezone math itself — eliminating a whole
class of "wrong day" bugs.
"""
tz_block = (
f"Today is {today_iso} ({tz_name}). "
f"When calling list_events for today, use:\n"
f" date_from = {day_from_iso}\n"
f" date_to = {day_to_iso}\n"
f"These are already the correct local-day boundaries — do not convert "
f"them to UTC or any other timezone. For other date ranges, compute in "
f"the same timezone.\n\n"
)
if slot == "compilation":
base = (
"You are the user's personal assistant giving their full morning briefing. "
"Weave real data from tool calls into a warm, natural-sounding summary.\n\n"
"Tools to call every compilation (skip only if you already know a category is empty):\n"
"- list_tasks — what's due today, overdue, or in progress\n"
"- list_events — what's on the calendar today\n"
"- get_weather — today's forecast\n"
"- get_rss_items — recent news/blog items from the user's feeds\n"
"- list_projects (optional) — active project context for narrative continuity\n\n"
"Rules:\n"
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
"- If a tool returns nothing (no events today, no overdue tasks, no news items), "
"say so honestly. Don't fabricate items to fill space.\n"
"- For news, pick one or two items worth mentioning — surface the theme, not a laundry list.\n"
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
"- Close on one or two concrete, actionable suggestions.\n\n"
)
elif slot == "weekly_review":
base = (
"You are the user's personal assistant delivering a weekly review. "
"Use the tools available to see what was accomplished this week, what's still "
"overdue, how many notes were captured, and what's coming up in the next seven days. "
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
"Rules:\n"
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
"- If a category is empty, say so honestly rather than inventing items.\n"
"- Write flowing prose. No markdown, no bullet points.\n"
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
)
else: # morning, midday, afternoon check-ins
base = (
f"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.\n\n"
"When checking tasks, call list_tasks at least twice:\n"
"- once with status=\"in_progress\" to see anything already being worked on "
"(regardless of due date — these can quietly drag past their due dates)\n"
"- once filtered by due date for what's coming up or overdue today\n\n"
"Rules:\n"
"- Call tools to see current state. Never assert facts without tool results.\n"
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
)
base = tz_block + base
if profile_body:
base += f"User profile (tone and preferences):\n{profile_body}\n"
return base
def _agentic_user_trigger(slot: str, date_str: str) -> str:
"""Seed user-role message that kicks off the agentic run."""
labels = {
"compilation": "morning briefing",
"morning": "morning check-in",
"midday": "midday check-in",
"afternoon": "afternoon wrap-up",
"weekly_review": "weekly review",
}
label = labels.get(slot, f"{slot} briefing")
return f"Generate my {label} for {date_str}."
async def run_agentic_briefing(
user_id: int,
slot: str,
model: str,
conv_id: int | None = None,
rss_override: list[dict] | None = None,
) -> tuple[str, list[dict]]:
"""
Run the agentic briefing loop for a user and slot.
Uses the chat pipeline's tool-use loop with a curated read-only tool
subset and a slot-specific system prompt. Every fact the model states
is either derived from a tool result visible in the returned message
list or it's the model hallucinating — so follow-up chat in the same
conversation can hold the model to what the tool results actually show.
Returns ``(final_prose, message_list)`` where ``message_list`` is the
full sequence including system, user trigger, tool calls, and tool
results. Callers are expected to persist those intermediate turns
alongside the final prose so the receipts remain in conversation
history on follow-up.
If the loop fails or the model returns empty prose, returns
``("", [])`` and the caller should fall back to the legacy path.
"""
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
from fabledassistant.services.tools import execute_tool
from fabledassistant.services.briefing_tools import get_briefing_tools
from fabledassistant.services.user_profile import build_profile_context
profile_context = await build_profile_context(user_id)
tools = await get_briefing_tools(user_id)
if not tools:
logger.warning(
"Agentic briefing for user %d slot %s: no tools available — aborting",
user_id, slot,
)
return "", []
# Compute today's window in the user's local timezone so the model
# receives ready-to-use ISO 8601 boundaries and never has to do its
# own tz math when calling date-sensitive tools like list_events.
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
try:
user_tz = ZoneInfo(tz_name)
except ZoneInfoNotFoundError:
user_tz = ZoneInfo("UTC")
tz_name = "UTC"
now_local = datetime.now(user_tz)
today_iso = now_local.date().isoformat()
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
day_from_iso = day_start.isoformat()
day_to_iso = day_end.isoformat()
system_prompt = _agentic_system_prompt(
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
)
messages: list[dict] = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
]
final_text = ""
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
accumulated_content = ""
accumulated_tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(
messages, model, tools=tools, think=False,
num_ctx=_BRIEFING_AGENT_NUM_CTX,
):
if chunk.type == "content" and chunk.content:
accumulated_content += chunk.content
elif chunk.type == "tool_calls" and chunk.tool_calls:
accumulated_tool_calls.extend(chunk.tool_calls)
except Exception:
logger.warning(
"Agentic briefing stream failed (user %d, slot %s, round %d)",
user_id, slot, round_idx, exc_info=True,
)
return "", []
# Append the assistant turn (content + any tool calls) to history
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
if accumulated_tool_calls:
assistant_msg["tool_calls"] = accumulated_tool_calls
messages.append(assistant_msg)
# No tool calls → the model is done
if not accumulated_tool_calls:
final_text = accumulated_content.strip()
break
# Execute each tool call and append results as tool-role messages
for tc in accumulated_tool_calls:
fn = tc.get("function") or {}
tool_name = fn.get("name", "")
arguments = fn.get("arguments") or {}
if isinstance(arguments, str):
try:
import json as _json
arguments = _json.loads(arguments)
except Exception:
arguments = {}
# Default list_tasks to active statuses only so cancelled/done
# items don't slip into briefing prose. The model can still
# pass an explicit status filter when it wants something else.
if tool_name == "list_tasks" and not arguments.get("status"):
arguments["status"] = ["todo", "in_progress"]
try:
if tool_name == "get_rss_items" and rss_override is not None:
# Use topic-scored/filtered items already computed by
# the briefing pipeline rather than the raw feed dump
# that execute_tool would return. Keeps the model's
# view of news aligned with the user's topic prefs
# and the sidebar's rss_item_ids metadata.
slim = [
{
"id": item.get("id"),
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("feed_title", ""),
"summary": (item.get("content") or "")[:400],
"published_at": item.get("published_at"),
"topics": item.get("topics") or [],
}
for item in rss_override
]
result = {"success": True, "data": {"items": slim, "count": len(slim)}}
else:
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
except Exception as exc:
logger.warning(
"Tool %s failed during agentic briefing: %s", tool_name, exc,
)
result = {"success": False, "error": str(exc)}
# Serialize the result compactly for the model's context
import json as _json
try:
result_str = _json.dumps(result, default=str)[:4000]
except Exception:
result_str = str(result)[:4000]
messages.append({
"role": "tool",
"content": result_str,
"tool_name": tool_name,
})
else:
logger.warning(
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
)
# Walk back to find the last assistant message with non-empty content
for m in reversed(messages):
if m.get("role") == "assistant" and m.get("content"):
final_text = m["content"].strip()
break
return final_text, messages
# ── Main entry point ───────────────────────────────────────────────────────────
async def _get_temp_unit(user_id: int) -> str:
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
import json
raw = await get_setting(user_id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
unit = config.get("temp_unit", "C")
return unit if unit in ("C", "F") else "C"
except Exception:
return "C"
async def run_compilation(
user_id: int,
slot: str,
model: str | None = None,
) -> tuple[str, dict]:
"""
Run the agentic briefing loop and gather UI metadata (RSS + weather).
Returns ``(briefing_text, metadata)`` where metadata contains
``rss_item_ids``, ``rss_items``, ``weather`` for frontend rendering,
and ``agentic_messages`` (the full tool-call sequence) for the
scheduler to persist as separate conversation rows.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
score_and_filter_items,
)
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
include_topics, exclude_topics = await load_topic_preferences(user_id)
topic_scores = await load_topic_reaction_scores(user_id)
external_data, weather_rows, temp_unit = await asyncio.gather(
_gather_external(user_id),
get_cached_weather_rows(user_id),
_get_temp_unit(user_id),
)
raw_rss = external_data.get("rss_items") or []
filtered_rss = score_and_filter_items(
raw_rss,
include_topics=include_topics,
exclude_topics=exclude_topics,
topic_scores=topic_scores,
max_items=10,
)
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
rss_items_meta = [
{
"id": item["id"],
"title": item.get("title", ""),
"url": item.get("url", ""),
"source": item.get("feed_title", ""),
"snippet": (item.get("content") or "")[:300],
"published_at": item.get("published_at"),
}
for item in filtered_rss
if item.get("id")
]
weather_cards = [
card for row in weather_rows
if (card := parse_weather_card_data(row, temp_unit)) is not None
]
weather_card = weather_cards[0] if weather_cards else None
briefing_text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
)
metadata: dict = {
"rss_item_ids": rss_item_ids,
"rss_items": rss_items_meta,
"weather": weather_card,
}
if agentic_messages:
metadata["agentic_messages"] = agentic_messages
if not briefing_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
return "", metadata
return briefing_text, metadata
async def run_slot_injection(
user_id: int,
slot: str,
model: str | None = None,
) -> tuple[str, dict]:
"""
Lighter check-in update for 8am/12pm/4pm slots.
Runs the agentic loop with the slot-specific prompt. Returns
``(text, metadata)`` where metadata contains ``agentic_messages``
for the scheduler to persist.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
text, agentic_messages = await run_agentic_briefing(
user_id, slot, model, conv_id=None,
)
metadata: dict = {}
if agentic_messages:
metadata["agentic_messages"] = agentic_messages
return text, metadata
@@ -1,110 +0,0 @@
"""
Briefing preferences: load topic settings, aggregate reaction scores,
filter and rank RSS items for briefing inclusion.
"""
import json
import logging
from datetime import datetime, timezone
from fabledassistant.models import async_session
logger = logging.getLogger(__name__)
async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]:
"""
Return (include_topics, exclude_topics) from user settings.
"""
from fabledassistant.services.settings import get_setting
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]")
def _parse(raw) -> list[str]:
try:
val = json.loads(raw) if isinstance(raw, str) else raw
return [str(t) for t in val] if isinstance(val, list) else []
except Exception:
return []
return _parse(raw_include), _parse(raw_exclude)
async def load_topic_reaction_scores(user_id: int) -> dict[str, float]:
"""
Aggregate per-topic reaction scores from the last 30 days.
Returns a dict of topic -> net_score (positive = liked, negative = disliked).
Uses rss_item_reactions joined to rss_items.topics.
"""
try:
from sqlalchemy import text as _text
async with async_session() as session:
result = await session.execute(
_text("""
SELECT unnest(i.topics) AS topic,
SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score
FROM rss_item_reactions r
JOIN rss_items i ON i.id = r.rss_item_id
WHERE r.user_id = :uid
AND r.created_at > NOW() - INTERVAL '30 days'
GROUP BY topic
""").bindparams(uid=user_id)
)
return {row.topic: float(row.score) for row in result}
except Exception:
logger.warning("Failed to load topic reaction scores", exc_info=True)
return {}
def score_and_filter_items(
items: list[dict],
include_topics: list[str],
exclude_topics: list[str],
topic_scores: dict[str, float],
max_items: int = 10,
) -> list[dict]:
"""
Score, filter, and rank RSS items for briefing inclusion.
Scoring:
- Hard-exclude: any item tagged with an excluded topic is removed.
- Base score: 0.0
- +2.0 per topic that appears in include_topics
- +1.0 / -1.0 per topic based on reaction score (clamped per topic)
- Tiebreak: newer published_at wins
Returns up to max_items items, highest score first.
Items with classified_at=None (unclassified) pass through with score=0.
"""
include_set = set(include_topics)
exclude_set = set(exclude_topics)
scored = []
for item in items:
item_topics = item.get("topics") or []
# Hard exclude
if exclude_set and any(t in exclude_set for t in item_topics):
continue
score = 0.0
for topic in item_topics:
if topic in include_set:
score += 2.0
if topic in topic_scores:
score += max(-1.0, min(1.0, topic_scores[topic]))
# Parse published_at for tiebreak
pub_str = item.get("published_at") or ""
try:
pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0
except ValueError:
pub_ts = 0.0
scored.append((score, pub_ts, item))
# Sort: highest score first, then newest first
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
return [item for _, _, item in scored[:max_items]]
@@ -1,80 +0,0 @@
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
import logging
from fabledassistant.services.notes import create_note, list_notes, update_note
logger = logging.getLogger(__name__)
PROFILE_TAG = "briefing-profile"
PROFILE_TITLE = "Briefing Profile"
async def _find_profile_note(user_id: int) -> dict | None:
"""Find the user's briefing profile note by tag."""
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
if not notes:
return None
note = notes[0]
return {
"id": note.id,
"body": note.body or "",
"title": note.title,
}
async def get_profile_body(user_id: int) -> str:
"""Return the body of the briefing profile note, or '' if none exists."""
note = await _find_profile_note(user_id)
return note["body"] if note else ""
async def get_profile_note_id(user_id: int) -> int | None:
note = await _find_profile_note(user_id)
return note["id"] if note else None
async def ensure_profile_note(user_id: int) -> int:
"""
Get or create the briefing profile note.
Returns the note id.
"""
note = await _find_profile_note(user_id)
if note:
return note["id"]
created = await create_note(
user_id=user_id,
title=PROFILE_TITLE,
body=(
"# Briefing Profile\n\n"
"This note is maintained by the briefing assistant. "
"It stores your preferences, patterns, and work schedule.\n\n"
"## Work Schedule\n\n"
"Office days: (not yet configured)\n\n"
"## Locations\n\n"
"(configured via Settings → Briefing)\n\n"
"## Preferences\n\n"
"(the assistant will add observations here over time)\n"
),
tags=[PROFILE_TAG],
)
return created.id
async def append_observations(user_id: int, observations: str) -> None:
"""
Append the assistant's end-of-day observations to the profile note.
Creates the note if it doesn't exist.
"""
if not observations.strip():
return
note_id = await ensure_profile_note(user_id)
note = await _find_profile_note(user_id)
if not note:
return
current_body = note.get("body", "")
from datetime import date
date_str = date.today().isoformat()
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
await update_note(user_id, note_id, body=new_body)
logger.info("Briefing profile updated for user %d", user_id)
@@ -1,625 +0,0 @@
"""
APScheduler-based briefing scheduler — per-user, timezone-aware.
Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA
timezone (stored in briefing_config.timezone). Changing the config via the
settings UI calls update_user_schedule() which live-patches the scheduler
without a restart.
Uses a background thread scheduler (not async) because APScheduler 3.x's
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
functions wrapped with asyncio.run_coroutine_threadsafe().
"""
import asyncio
import logging
from datetime import date, datetime, time, timedelta, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
# Slot definitions: (name, hour, minute) — local time in the user's timezone
SLOTS = [
("compilation", 4, 0),
("morning", 8, 0),
("midday", 12, 0),
("afternoon", 16, 0),
]
# Weekly review runs Sunday at 6pm by default
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
WEEKLY_REVIEW_HOUR = 18
WEEKLY_REVIEW_MINUTE = 0
# ── Helpers ───────────────────────────────────────────────────────────────────
def _resolve_timezone(tz_str: str) -> str:
"""Validate and return an IANA timezone string, falling back to UTC."""
if not tz_str:
return "UTC"
try:
ZoneInfo(tz_str)
return tz_str
except (ZoneInfoNotFoundError, KeyError):
logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str)
return "UTC"
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
import json
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"]))
)
rows = list(result.scalars().all())
by_user: dict[int, dict[str, str]] = {}
for row in rows:
by_user.setdefault(row.user_id, {})[row.key] = row.value or ""
enabled = []
for user_id, settings in by_user.items():
try:
config = json.loads(settings.get("briefing_config", "{}") or "{}")
if config.get("enabled"):
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
tz = _resolve_timezone(tz_str)
enabled.append((user_id, tz, config))
except Exception:
pass
return enabled
def _job_id(user_id: int, slot: str) -> str:
return f"briefing_{slot}_user_{user_id}"
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
if _scheduler is None or _loop is None:
return
enabled_slots = (config or {}).get("slots", {})
for slot_name, hour, minute in SLOTS:
jid = _job_id(user_id, slot_name)
# compilation always runs; other slots default to True if not in config
slot_on = enabled_slots.get(slot_name, True)
if not slot_on:
if _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=jid,
replace_existing=True,
misfire_grace_time=3600,
)
# Weekly review job — runs once per week
weekly_jid = _job_id(user_id, "weekly_review")
weekly_on = enabled_slots.get("weekly_review", True)
if weekly_on:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(
day_of_week=WEEKLY_REVIEW_DAY,
hour=WEEKLY_REVIEW_HOUR,
minute=WEEKLY_REVIEW_MINUTE,
timezone=tz,
),
args=[user_id, "weekly_review"],
id=weekly_jid,
replace_existing=True,
misfire_grace_time=7200,
)
elif _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
def _remove_user_jobs(user_id: int) -> None:
"""Remove all slot jobs for a user."""
if _scheduler is None:
return
for slot_name, _, _ in SLOTS:
jid = _job_id(user_id, slot_name)
if _scheduler.get_job(jid):
_scheduler.remove_job(jid)
weekly_jid = _job_id(user_id, "weekly_review")
if _scheduler.get_job(weekly_jid):
_scheduler.remove_job(weekly_jid)
logger.info("Removed briefing jobs for user %d", user_id)
# ── Public API ────────────────────────────────────────────────────────────────
def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None:
"""
Called when a user saves their briefing config via the settings UI.
Live-patches the scheduler — no restart required.
tz_override takes priority over any timezone in config.
"""
if config.get("enabled"):
tz_str = tz_override or config.get("timezone", "UTC")
tz = _resolve_timezone(tz_str)
_add_user_jobs(user_id, tz, config)
else:
_remove_user_jobs(user_id)
# ── Job execution ─────────────────────────────────────────────────────────────
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
from sqlalchemy import select as _sel, func as _func
from fabledassistant.models.project import Project
from fabledassistant.models.note import Note
from fabledassistant.models import async_session as _session
paused: list[str] = []
threshold = datetime.now(timezone.utc) - timedelta(days=14)
try:
async with _session() as session:
projects = (await session.execute(
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
)).scalars().all()
for p in projects:
latest = (await session.execute(
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
)).scalar()
if latest is None or latest < threshold:
p.status = "paused"
paused.append(p.title or "Untitled")
if paused:
await session.commit()
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
except Exception:
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
return paused
async def _persist_agentic_messages(
conv_id: int,
slot: str,
metadata: dict | None,
) -> None:
"""Persist the intermediate turns from an agentic briefing run.
``metadata["agentic_messages"]`` is the full message list the agent
generated — system prompt, user trigger, assistant tool-call turns,
tool-role results, and the final assistant prose.
To stay compatible with the existing chat loader in ``routes/chat.py``,
tool results are folded back into the parent assistant message's
``tool_calls[i]["result"]`` field rather than being persisted as
separate ``role="tool"`` rows. This matches how regular chat
persists agentic turns, so the follow-up chat endpoint can rehydrate
the tool sequence using its existing logic.
Persists everything except the system prompt (implicit in the chat
pipeline) and the final assistant prose (the caller posts that
separately with the user-facing metadata block). The synthetic user
trigger message is persisted so Ollama sees a user→assistant→user
sequence rather than an orphaned assistant reply — it's tagged as
intermediate so the UI can hide it.
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
function is a no-op.
"""
from fabledassistant.services.briefing_conversations import post_message
if not metadata:
return
agentic_messages = metadata.get("agentic_messages") or []
if not agentic_messages:
return
# Drop the system prompt (index 0) and the final assistant prose
# (last item). The caller posts the final prose as its own message.
middle = agentic_messages[1:-1]
# Walk the middle sequence, pairing each assistant tool-call turn
# with the tool-role results that immediately follow it.
i = 0
n = len(middle)
while i < n:
m = middle[i]
role = m.get("role", "")
content = m.get("content", "") or ""
tag = {"briefing_slot": slot, "briefing_intermediate": True}
if role == "assistant" and m.get("tool_calls"):
# Collect subsequent tool-role results, matching them
# positionally onto this assistant's tool_calls. Normalise
# each entry to the flat storage format the chat loader
# expects: {"function": <name>, "arguments": <args>,
# "result": <result>, "status": "success"|"error"}.
raw_tool_calls = list(m["tool_calls"])
flat_tool_calls: list[dict] = []
result_idx = 0
j = i + 1
import json as _json
for raw_tc in raw_tool_calls:
fn = raw_tc.get("function") or {}
name = fn.get("name") if isinstance(fn, dict) else str(fn)
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
if isinstance(arguments, str):
try:
arguments = _json.loads(arguments)
except Exception:
arguments = {}
# Pair up with the next available tool-role message
parsed_result: object = {}
status = "success"
if j < n and middle[j].get("role") == "tool":
tool_content = middle[j].get("content", "") or ""
try:
parsed_result = _json.loads(tool_content)
except Exception:
parsed_result = tool_content
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
status = "error"
j += 1
result_idx += 1
flat_tool_calls.append({
"function": name,
"arguments": arguments,
"result": parsed_result,
"status": status,
})
try:
await post_message(
conv_id, "assistant", content,
metadata=tag,
tool_calls=flat_tool_calls,
)
except Exception:
logger.warning(
"Failed to persist agentic assistant turn for conv %d slot %s",
conv_id, slot, exc_info=True,
)
i = j # skip the tool results we just folded in
continue
if role == "tool":
# Unpaired tool result — shouldn't normally happen, but be
# defensive and persist it as an assistant-visible note so we
# don't lose the receipt entirely.
i += 1
continue
if role == "user":
# Skip the synthetic user trigger ("Generate my morning briefing…").
# Persisting it would recreate the exact "[Midday briefing update]"
# problem PR 2 is designed to eliminate: fake user messages
# cluttering chat history. The LLM can follow an all-assistant
# sequence just fine since the chat endpoint injects the real
# system prompt on follow-up.
i += 1
continue
# assistant without tool_calls — persist as-is (rare intermediate)
try:
await post_message(conv_id, role, content, metadata=tag)
except Exception:
logger.warning(
"Failed to persist agentic %s message for conv %d slot %s",
role, conv_id, slot, exc_info=True,
)
i += 1
async def _run_slot_for_user(user_id: int, slot: str) -> None:
"""Execute one slot job for one user."""
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation, post_message
)
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
from fabledassistant.services.settings import get_setting
from fabledassistant.config import Config
# Morning slot: skip if today is not a configured work day
if slot == "morning":
from fabledassistant.services.user_profile import get_profile
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 configured work day",
user_id, today_abbr,
)
return
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
if slot == "compilation":
# Auto-pause stale projects before compiling the briefing
await _auto_pause_stale_projects(user_id)
# Refresh external data first
try:
import json
config_raw = await get_setting(user_id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
if rss_on:
from fabledassistant.services.rss import refresh_all_feeds
await refresh_all_feeds(user_id)
from fabledassistant.services import weather as wx
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await wx.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
await _run_profile_closeout(user_id, model)
conv = await get_or_create_today_conversation(user_id, model)
text, metadata = await run_compilation(user_id, slot, model)
if text:
# Persist the agentic tool-call sequence as its own messages
# so follow-up chat can see the receipts. Each intermediate
# message is tagged with briefing_slot so the chat context
# loader can decide whether to include them in history.
await _persist_agentic_messages(conv.id, slot, metadata)
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
await post_message(conv.id, "assistant", text, metadata=final_meta)
else:
conv = await get_or_create_today_conversation(user_id, model)
text, slot_metadata = await run_slot_injection(user_id, slot, model)
if text:
# No more synthetic "[Midday briefing update]" user-role
# messages. Slot updates are plain assistant messages tagged
# with briefing_slot so the chat endpoint can filter them
# from the LLM's view of history on follow-ups (they remain
# visible in the UI).
await _persist_agentic_messages(conv.id, slot, slot_metadata)
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
await post_message(conv.id, "assistant", text, metadata=final_meta)
try:
from fabledassistant.services.push import send_push_notification
slot_labels = {
"compilation": "Morning briefing ready",
"morning": "Office briefing ready",
"midday": "Midday check-in",
"afternoon": "End of day wrap-up",
}
await send_push_notification(
user_id=user_id,
title="Briefing",
body=slot_labels.get(slot, "Briefing update"),
url="/briefing",
)
except Exception:
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
def _run_user_slot_sync(user_id: int, slot: str) -> None:
"""Synchronous wrapper called by APScheduler's background thread."""
if _loop is None:
logger.error("No event loop available for briefing slot %s user %d", slot, user_id)
return
future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop)
try:
future.result(timeout=600)
except Exception:
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
async def _run_profile_closeout(user_id: int, model: str) -> None:
"""
Read yesterday's briefing conversation, extract preference observations,
and append them to the briefing profile note.
"""
from fabledassistant.services.user_profile import append_observations
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.tz import user_today
from fabledassistant.models.conversation import Conversation, Message
# User-local "yesterday" so closeout always targets the day that just
# ended in the user's timezone, regardless of container TZ.
yesterday = (await user_today(user_id)) - timedelta(days=1)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == yesterday,
)
)
conv = result.scalars().first()
if not conv:
return
msgs_result = await session.execute(
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
)
messages = list(msgs_result.scalars().all())
if len(messages) < 2:
return
transcript = "\n".join(
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
)
system = (
"You are reviewing a day's briefing conversation to extract preference observations. "
"Identify any patterns, preferences, or schedule facts the user revealed. "
"Write 2-5 short bullet points. Be specific and factual. "
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
"If nothing notable, output only: (nothing to note)"
)
try:
observations = (await generate_completion(
[
{"role": "system", "content": system},
{"role": "user", "content": transcript},
],
model,
)).strip()
except Exception:
logger.warning("Profile closeout synthesis failed for user %d", user_id, exc_info=True)
observations = ""
if observations and "(nothing to note)" not in observations.lower():
await append_observations(user_id, observations)
# ── Startup / catchup ─────────────────────────────────────────────────────────
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
"""
On startup, fire any slot that was missed in the last 24 hours
(one catch-up per slot per user, evaluated in the user's local timezone).
"""
users = await _get_briefing_enabled_users()
for user_id, tz, _config in users:
user_tz = ZoneInfo(tz)
now_local = datetime.now(user_tz)
today_local = now_local.date()
for slot_name, hour, minute in SLOTS:
slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
if slot_local > now_local:
continue # Not yet due
age = (now_local - slot_local).total_seconds()
if age > 86400:
continue # More than 24h ago — skip
# Check if today's conversation already has a message from after slot time
async with async_session() as session:
from fabledassistant.models.conversation import Conversation, Message
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today_local,
)
)
conv = result.scalars().first()
if conv:
# Convert slot_local to UTC for DB comparison (stored as UTC)
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
msgs = await session.execute(
select(Message).where(
Message.conversation_id == conv.id,
Message.created_at >= slot_utc,
).limit(1)
)
if msgs.scalars().first():
continue # Already covered
logger.info(
"Catching up missed briefing slot '%s' for user %d (tz: %s)",
slot_name, user_id, tz,
)
try:
await _run_slot_for_user(user_id, slot_name)
except Exception:
logger.exception(
"Catch-up for slot '%s' user %d failed", slot_name, user_id
)
async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the APScheduler background scheduler with per-user timezone-aware jobs.
Must be awaited from the app's before_serving hook (async context).
"""
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler(timezone="UTC")
# Await directly — we're already on the event loop, so run_coroutine_threadsafe
# would deadlock (it blocks the calling thread, which IS the event loop thread).
try:
users = await _get_briefing_enabled_users()
except Exception:
logger.exception("Failed to load briefing users at startup")
users = []
for user_id, tz, config in users:
_add_user_jobs(user_id, tz, config)
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
def _run_recurrence_spawn() -> None:
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
try:
count = future.result(timeout=300)
logger.info("Recurrence spawn: %d task(s) created", count)
except Exception as exc:
logger.error("Recurrence spawn failed: %s", exc)
_scheduler.add_job(
_run_recurrence_spawn,
CronTrigger(hour=0, minute=0, timezone="UTC"),
id="recurrence_daily",
replace_existing=True,
)
def _run_kokoro_update_check() -> None:
from fabledassistant.services.tts import check_for_kokoro_updates
future = asyncio.run_coroutine_threadsafe(check_for_kokoro_updates(), _loop)
try:
future.result(timeout=300)
except Exception as exc:
logger.error("Kokoro update check failed: %s", exc)
_scheduler.add_job(
_run_kokoro_update_check,
CronTrigger(hour=3, minute=0, timezone="UTC"),
id="kokoro_update_check_daily",
replace_existing=True,
)
_scheduler.start()
logger.info(
"Briefing scheduler started with %d user(s) across %d job(s)",
len(users), len(users) * len(SLOTS),
)
asyncio.create_task(_catchup_missed_slots(loop))
def stop_briefing_scheduler() -> None:
global _scheduler, _loop
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
_loop = None
@@ -1,9 +0,0 @@
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
respective module, so there is no separate allowlist to maintain here.
"""
from fabledassistant.services.tools import get_briefing_tools
__all__ = ["get_briefing_tools"]
@@ -66,7 +66,7 @@ async def setup_user_calendar(user_id: int) -> bool:
<set>
<prop>
<resourcetype><collection/><C:calendar/></resourcetype>
<displayname>Fabled Assistant</displayname>
<displayname>Fabled Scribe</displayname>
</prop>
</set>
</mkcol>""",
+1 -1
View File
@@ -80,7 +80,7 @@ async def list_conversations(
"title": conv.title,
"model": conv.model,
"conversation_type": conv.conversation_type,
"briefing_date": conv.briefing_date.isoformat() if conv.briefing_date else None,
"day_date": conv.day_date.isoformat() if conv.day_date else None,
"rag_project_id": conv.rag_project_id,
"message_count": row[1],
"created_at": conv.created_at.isoformat(),
+6 -6
View File
@@ -38,7 +38,7 @@ _EMAIL_LOGO_SVG = (
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template."""
"""Wrap email body content in the standard Fabled Scribe template."""
return f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -56,7 +56,7 @@ def _email_html(title: str, body: str) -> str:
<!-- Header -->
<div style="background:linear-gradient(135deg,#7c3aed 0%,#6d28d9 100%);padding:24px 28px;text-align:center;">
<div style="margin-bottom:8px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Assistant</span>
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:18px;font-weight:700;letter-spacing:0.01em;">Fabled Scribe</span>
</div>
<p style="margin:0;color:#ede9fe;font-size:13px;letter-spacing:0.03em;">{title}</p>
</div>
@@ -68,7 +68,7 @@ def _email_html(title: str, body: str) -> str:
<!-- Footer -->
<div style="border-top:1px solid #ede9fe;padding:16px 28px;text-align:center;background:#faf5ff;">
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Assistant instance.</p>
<p style="margin:0;color:#a78bfa;font-size:12px;">Sent by your Fabled Scribe instance.</p>
</div>
</div>
@@ -146,7 +146,7 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
username = config.get("smtp_username", "")
password = config.get("smtp_password", "")
from_address = config.get("smtp_from_address", "")
from_name = config.get("smtp_from_name", "Fabled Assistant")
from_name = config.get("smtp_from_name", "Fabled Scribe")
use_tls = config.get("smtp_use_tls", "true") == "true"
msg = EmailMessage()
@@ -176,6 +176,6 @@ async def send_test_email(to: str) -> None:
"""Send a branded test email."""
body = """
<p style="margin:0 0 12px;color:#1e1b4b;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
<p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Scribe instance can send email notifications.</p>
"""
await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
await send_email(to, "Fabled Scribe - Test Email", _email_html("Test Email", body))
-179
View File
@@ -1,7 +1,6 @@
"""Semantic note search via Ollama embedding model (nomic-embed-text).
Embeddings are stored in the note_embeddings table (one row per note).
RSS item embeddings are stored in rss_item_embeddings (one row per item).
All search operations degrade gracefully — if the embedding model is
unavailable the callers fall back to keyword search.
"""
@@ -9,7 +8,6 @@ unavailable the callers fall back to keyword search.
import asyncio
import logging
import math
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import delete, select
@@ -18,8 +16,6 @@ from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.embedding import NoteEmbedding
from fabledassistant.models.note import Note
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.models.rss_item_embedding import RssItemEmbedding
logger = logging.getLogger(__name__)
@@ -28,10 +24,6 @@ logger = logging.getLogger(__name__)
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
# loosely-related results that pad the sidebar without adding real value.
_SIMILARITY_THRESHOLD = 0.45
_RSS_SIMILARITY_THRESHOLD = 0.55
_RSS_SEARCH_LIMIT = 3
_RSS_SEARCH_DAYS = 30
_RSS_SNIPPET_CHARS = 500
async def get_embedding(text: str, model: str | None = None) -> list[float]:
@@ -186,174 +178,3 @@ async def backfill_note_embeddings() -> None:
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
# ── RSS item embeddings ───────────────────────────────────────────────────────
async def upsert_rss_item_embedding(item_id: int, user_id: int, title: str, content: str) -> None:
"""Generate and persist an embedding for an RSS item. Safe to fire-and-forget."""
text = f"{title}\n{content}".strip()
if not text:
return
try:
embedding = await get_embedding(text)
except Exception:
logger.debug("Skipping embedding for RSS item %d — model unavailable", item_id)
return
try:
async with async_session() as session:
await session.execute(
delete(RssItemEmbedding).where(RssItemEmbedding.rss_item_id == item_id)
)
session.add(RssItemEmbedding(rss_item_id=item_id, user_id=user_id, embedding=embedding))
await session.commit()
logger.debug("Upserted embedding for RSS item %d", item_id)
except Exception:
logger.warning("Failed to persist embedding for RSS item %d", item_id, exc_info=True)
async def semantic_search_rss_items(
user_id: int,
query_vector: list[float],
limit: int = _RSS_SEARCH_LIMIT,
days: int = _RSS_SEARCH_DAYS,
) -> list[tuple[float, RssItem]]:
"""Return up to *limit* (score, RssItem) pairs most relevant to *query_vector*.
Only considers items fetched within the last *days* days.
Returns an empty list on any error.
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
try:
async with async_session() as session:
stmt = (
select(RssItemEmbedding, RssItem)
.join(RssItem, RssItemEmbedding.rss_item_id == RssItem.id)
.where(
RssItemEmbedding.user_id == user_id,
RssItem.fetched_at >= since,
)
)
rows = list((await session.execute(stmt)).all())
except Exception:
logger.warning("Failed to query RSS item embeddings", exc_info=True)
return []
if not rows:
return []
scored: list[tuple[float, RssItem]] = []
for rie, item in rows:
try:
sim = _cosine_similarity(query_vector, rie.embedding)
except Exception:
continue
if sim >= _RSS_SIMILARITY_THRESHOLD:
scored.append((sim, item))
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:limit]
async def backfill_rss_item_embeddings() -> None:
"""Generate embeddings for all RSS items that don't have one yet.
Runs as a background task at startup. Adds a small sleep between items
to avoid overwhelming Ollama.
"""
try:
async with async_session() as session:
existing = {
row[0]
for row in (
await session.execute(select(RssItemEmbedding.rss_item_id))
).fetchall()
}
result = await session.execute(
select(RssItem.id, RssItem.feed_id, RssItem.title, RssItem.content)
)
items_to_embed = [row for row in result.fetchall() if row[0] not in existing]
except Exception:
logger.warning("RSS embedding backfill: failed to query items", exc_info=True)
return
if not items_to_embed:
logger.info("RSS embedding backfill: all items already have embeddings")
return
# Resolve user_id per feed_id
try:
from fabledassistant.models.rss_feed import RssFeed
async with async_session() as session:
result = await session.execute(select(RssFeed.id, RssFeed.user_id))
feed_user_map = {fid: uid for fid, uid in result.fetchall()}
except Exception:
logger.warning("RSS embedding backfill: failed to load feed user map", exc_info=True)
return
logger.info("RSS embedding backfill: generating embeddings for %d items", len(items_to_embed))
success = 0
for item_id, feed_id, title, content in items_to_embed:
user_id = feed_user_map.get(feed_id)
if user_id is None:
continue
await upsert_rss_item_embedding(item_id, user_id, title or "", content or "")
success += 1
await asyncio.sleep(0.05)
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))
async def backfill_rss_article_content() -> None:
"""Fetch full article text for RSS items that only have short feed-provided content.
An item is considered unenriched if its content is shorter than 1000 chars —
typical of feed summaries/teasers rather than full articles.
Runs at startup after the embedding backfill.
"""
from fabledassistant.services.rss import _fetch_full_article
from fabledassistant.models.rss_feed import RssFeed
SHORT_THRESHOLD = 1000
try:
async with async_session() as session:
feed_result = await session.execute(select(RssFeed.id, RssFeed.user_id))
feed_user_map = {fid: uid for fid, uid in feed_result.fetchall()}
item_result = await session.execute(
select(RssItem.id, RssItem.feed_id, RssItem.url, RssItem.title, RssItem.content)
.where(RssItem.url != "")
)
candidates = [
row for row in item_result.fetchall()
if len(row[4] or "") < SHORT_THRESHOLD
]
except Exception:
logger.warning("Article content backfill: failed to query items", exc_info=True)
return
if not candidates:
logger.info("Article content backfill: no unenriched items found")
return
logger.info("Article content backfill: enriching %d items", len(candidates))
enriched = 0
for item_id, feed_id, url, title, _ in candidates:
user_id = feed_user_map.get(feed_id)
if user_id is None:
continue
full_text = await _fetch_full_article(url)
if full_text and len(full_text) > SHORT_THRESHOLD:
try:
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
item.content = full_text
await session.commit()
await upsert_rss_item_embedding(item_id, user_id, title or "", full_text)
enriched += 1
except Exception:
logger.debug("Failed to store enriched content for item %d", item_id, exc_info=True)
await asyncio.sleep(0.5)
logger.info("Article content backfill complete: %d/%d items enriched", enriched, len(candidates))
+16 -99
View File
@@ -37,84 +37,6 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
async def _maybe_save_article_discussion_note(
user_id: int, conv_id: int, reply_content: str,
) -> None:
"""Persist a seeded article-discussion's first reply as a Note.
Fires after ``run_generation`` completes. Looks for a synthetic
read_article seed message on the conversation; if found AND the linked
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
the same article are a no-op (already linked).
Failures are logged and swallowed — the chat UI should never break because
Note persistence hit a snag.
"""
try:
if not reply_content or not reply_content.strip():
return
from sqlalchemy import select as _select
from fabledassistant.models.conversation import Message as _Message
from fabledassistant.models.rss_feed import RssItem as _RssItem
from fabledassistant.services.notes import create_note
async with async_session() as session:
result = await session.execute(
_select(_Message)
.where(_Message.conversation_id == conv_id)
.order_by(_Message.id.asc())
)
messages = result.scalars().all()
seed_meta = None
for m in messages:
meta = m.msg_metadata or {}
if meta.get("article_seed") and meta.get("rss_item_id"):
seed_meta = meta
break
if seed_meta is None:
return
item_id = int(seed_meta["rss_item_id"])
item = await session.get(_RssItem, item_id)
if item is None or item.discussion_note_id is not None:
return
article_title = (item.title or "Untitled article").strip()
article_url = item.url
article_topics = list(item.topics or [])
note_title = f"Article: {article_title}"[:200]
body_parts = [f"**Source:** {article_url}"] if article_url else []
body_parts.append(reply_content.strip())
note_body = "\n\n".join(body_parts)
tags = ["article-summary"] + [t for t in article_topics if t]
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=tags,
entity_meta={
"source": "article_discussion",
"rss_item_id": item_id,
"url": article_url,
"conversation_id": conv_id,
},
)
async with async_session() as session:
fresh = await session.get(_RssItem, item_id)
if fresh is not None and fresh.discussion_note_id is None:
fresh.discussion_note_id = note.id
await session.commit()
logger.info(
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
note.id, item_id, conv_id,
)
except Exception:
logger.warning(
"Failed to persist article-discussion note for conv %d",
conv_id, exc_info=True,
)
# Human-readable labels for each tool, shown in the status indicator
_TOOL_LABELS: dict[str, str] = {
"create_note": "Creating note/task",
@@ -254,8 +176,15 @@ async def run_generation(
buf.append_event("status", {"status": "Building context..."})
# Phase 1: Resolve the tools list for this user.
tools = await get_tools_for_user(user_id)
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_conv = await _sess.get(_Conversation, conv_id)
_conversation_type = (
_conv.conversation_type if _conv and _conv.conversation_type else "chat"
)
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
logger.info(
"Starting generation for conv %d: model=%s, tools=%d",
@@ -586,28 +515,16 @@ async def run_generation(
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
# Persist article-discussion seed conversations as a Note on their
# first assistant reply. This makes "Discuss" summaries part of RAG
# so the knowledge base stops being amnesiac about articles the user
# has already engaged with. The hook detects a seeded conversation by
# finding a synthetic read_article assistant message whose
# msg_metadata carries ``article_seed: True`` and whose rss_items row
# has no discussion_note_id yet. Fire-and-forget so the done event
# lands immediately.
asyncio.create_task(_maybe_save_article_discussion_note(
user_id, conv_id, buf.content_so_far,
))
if should_gen_title:
# Feed the title model the *raw* conversation turns only — never
# the post-build_context ``messages`` list. ``build_context``
# prepends RAG snippets, RSS excerpts, URL content, and briefing
# article dumps INTO the user message string itself, so filtering
# by role="user" downstream still surfaces that noise as the
# "user's message". That pollution caused wildly-wrong titles
# (bug #109) — the small background model was staring at article
# excerpts instead of what the user actually typed. Pass the
# original history + the raw user_content + the assistant reply.
# prepends RAG snippets and URL content INTO the user message
# string itself, so filtering by role="user" downstream still
# surfaces that noise as the "user's message". That pollution
# caused wildly-wrong titles (bug #109) — the small background
# model was staring at article excerpts instead of what the user
# actually typed. Pass the original history + the raw user_content
# + the assistant reply.
title_messages: list[dict] = [
{"role": m["role"], "content": m.get("content") or ""}
for m in history
@@ -0,0 +1,174 @@
"""System prompt + ambient context injection for journal conversations.
Distinct from the chat pipeline (services/llm.py) in three ways:
1. New persona: warm, curious listener; not a task manager.
2. Calibration rules: ask before structural changes; record_moment freely.
3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG).
Notes-RAG auto-injection MUST be disabled for journal sessions — the
journal's ambient context replaces it. Failing to disable would let notes
leak into journal sessions, violating the design's isolation invariant.
"""
from __future__ import annotations
import datetime
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
"You are the user's assistant. They've opened their journal — a day-anchored "
"conversation surface where they record their day. Behave like the rest of "
"the app's chat: respond conversationally, ask follow-up questions about what "
"they just said, verify details from earlier in the conversation when "
"relevant, and use tools naturally to act on their behalf when it makes sense. "
"The day's prep message at the top of the conversation is your context — "
"build on it, don't restate it."
)
JOURNAL_CALIBRATION = """\
JOURNAL-SPECIFIC TOOL GUIDANCE:
PEOPLE / PLACES — ask before creating new entries.
- If the user mentions a name you don't already know about, ASK them in plain
language ("Who's Sarah to you?") and WAIT for the reply before calling
save_person or save_place.
- For ambiguous references (multiple matches in their existing people/places),
ask which one. Never guess.
- For unambiguous references to people they've already established, no need
to ask — proceed normally.
MOMENTS — recording them is your primary job, not a "nice to have."
After every substantive user message, BEFORE you compose your reply,
check: did the user describe ANY of these?
- An event that happened ("I went grocery shopping")
- An encounter with a person ("had coffee with Sarah")
- A decision ("I'm going to switch jobs")
- An observation about themselves or the world ("the new place is loud")
- A plan or commitment ("watching a show with Victoria tonight")
- A feeling or state ("I'm tired", "feeling decompressed")
- A small accomplishment or change they made ("installed the new AP")
If the answer is YES to ANY of those — CALL record_moment FIRST, before
composing your reply. This is not optional. The journal exists to capture
these beats; if you skip the call, the beat is lost.
Multiple distinct beats in one message → multiple record_moment calls,
one per beat.
WHEN LINKING ENTITIES: use the *_names parameters (person_names,
place_names, task_titles, note_titles). Server resolves them to IDs by
lookup. Do NOT pass *_ids unless you have an exact ID returned from
another tool call in this same turn. Never invent IDs.
The ONLY messages where you skip record_moment are purely meta-conversational
ones — about the journal itself or about a prior tool result ("thanks",
"no priority needed", "can you also add X to that one", "I meant tasks not
notes"). Those aren't journal beats; they're chat about the chat.
STATE-CHANGING TOOLS — use the confirmation flow.
- update_task / update_note that change state (status, completion, deletion)
follow the standard confirmation pattern: pass `confirmed=false` first; the
frontend shows a confirm UI; call again with `confirmed=true` after the
user confirms.
- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
don't need confirmation.
OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit.
- Notes are not auto-retrieved here. If you need to reference a note, call
search_notes explicitly.
RESPONSE STYLE:
- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). Engage
with what they said directly.
- Don't produce multi-option menus ("1. Show your calendar 2. List your tasks
3. ..."). They feel like a help-desk bot. Ask one specific follow-up or take
one specific action.
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
pick a specific concrete detail from the new message to react to.
- Match the user's length. Short message → short reply. Don't pad.
"""
PHASE_GREETINGS = {
"morning": "Morning. What's the day looking like for you?",
"midday": "How's it going so far?",
"evening": "Wrapping up — how'd the day shake out?",
}
def determine_phase(
*,
now_local: datetime.datetime,
day_rollover_hour: int = 4,
morning_end_hour: int = 12,
midday_end_hour: int = 18,
) -> str:
"""Return 'morning' | 'midday' | 'evening' for a local datetime."""
h = now_local.hour
if h < day_rollover_hour:
return "evening"
if h < morning_end_hour:
return "morning"
if h < midday_end_hour:
return "midday"
return "evening"
def phase_greeting(phase: str) -> str:
return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"])
async def build_journal_system_prompt(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> str:
"""Static-then-dynamic system prompt.
Static prefix (persona + calibration) is identical on every request,
preserving Ollama KV cache. Dynamic suffix changes per-day.
"""
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
today_iso = day_date.isoformat()
tz_block = f"Today is {today_iso} ({user_timezone})."
profile_context = await build_profile_context(user_id)
profile_section = f"\n\n{profile_context}" if profile_context else ""
ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date)
ambient_section = (
f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else ""
)
return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}"
async def _ambient_moments_block(
*, user_id: int, day_date: datetime.date
) -> str:
"""Render last 48h of moments as a compact text block.
Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval.
"""
moments = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=2),
date_to=day_date,
limit=20,
)
if not moments:
return ""
lines: list[str] = []
total_chars = 0
for m in moments:
line = f"- [{m['occurred_at']}] {m['content']}"
if total_chars + len(line) > 1500:
break
lines.append(line)
total_chars += len(line)
return "\n".join(lines)
@@ -0,0 +1,385 @@
"""Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a
new day). Two phases:
1. Gather structured data (tasks/events/weather/projects/recent moments/
open threads) — deterministic, no LLM call.
2. Hand the structured data to the LLM and ask it for a direct, informative
conversational opener — flowing prose, briefing-style. Result is persisted
as the first *assistant* message in today's journal Conversation, so it
renders with the standard Illuminated Transcript bubble styling alongside
the rest of the conversation.
The structured data is preserved on ``Message.msg_metadata.sections`` for
provenance and future tooling.
Message shape:
role: 'assistant'
content: <prose opener>
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
import datetime
import logging
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services.events import list_events
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.settings import get_setting
from fabledassistant.services.weather import get_cached_weather_rows
logger = logging.getLogger(__name__)
async def gather_daily_sections(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> dict:
"""Gather all daily-prep sections and return them as a dict.
Pure data fetching — no LLM call. Each section degrades to an empty
list/dict on failure so the caller always gets a complete shape.
"""
sections: dict = {}
try:
tasks_today, _ = await list_notes(
user_id=user_id,
is_task=True,
status=["todo", "in_progress"],
due_before=day_date,
limit=20,
sort="due_date",
order="asc",
)
sections["tasks"] = [
{
"id": t.id,
"title": t.title,
"status": t.status,
"priority": t.priority,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks_today
]
except Exception:
logger.exception("daily_prep tasks section failed for user %d", user_id)
sections["tasks"] = []
try:
day_start = datetime.datetime.combine(day_date, datetime.time.min)
day_end = datetime.datetime.combine(day_date, datetime.time.max)
sections["events"] = await list_events(
user_id=user_id,
date_from=day_start,
date_to=day_end,
)
except Exception:
logger.exception("daily_prep events section failed for user %d", user_id)
sections["events"] = []
try:
weather_rows = await get_cached_weather_rows(user_id)
sections["weather"] = [w.to_dict() for w in weather_rows]
except Exception:
logger.exception("daily_prep weather section failed for user %d", user_id)
sections["weather"] = []
try:
projects = await list_projects(user_id=user_id, status="active")
sections["projects"] = [
{
"id": p.id,
"title": p.title,
"auto_summary": p.auto_summary,
}
for p in projects[:5]
]
except Exception:
logger.exception("daily_prep projects section failed for user %d", user_id)
sections["projects"] = []
try:
sections["recent_moments"] = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=3),
date_to=day_date - datetime.timedelta(days=1),
limit=10,
)
except Exception:
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
sections["recent_moments"] = []
try:
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
except Exception:
logger.exception("daily_prep open_threads section failed for user %d", user_id)
sections["open_threads"] = []
return sections
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
"""Heuristic: moments from the last 7 days that look unresolved.
Treated as 'unresolved' when they have no linked tasks/notes and aren't
pinned. Starting heuristic — refine empirically.
"""
candidates = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=7),
date_to=day_date - datetime.timedelta(days=1),
limit=50,
)
return [
m for m in candidates
if not m.get("task_ids")
and not m.get("note_ids")
and not m.get("pinned")
]
def _render_sections_for_prompt(sections: dict) -> str:
"""Render the gathered sections as a structured plain-text block for the LLM."""
lines: list[str] = []
tasks = sections.get("tasks") or []
if tasks:
lines.append("TASKS (todo or in-progress):")
for t in tasks[:12]:
line = f" - {t.get('title', '?')}"
if t.get("due_date"):
line += f" (due {t['due_date']})"
if t.get("priority") and t["priority"] not in (None, "none"):
line += f" [{t['priority']} priority]"
if t.get("status") == "in_progress":
line += " [in progress]"
lines.append(line)
lines.append("")
events = sections.get("events") or []
if events:
lines.append("CALENDAR EVENTS TODAY:")
for e in events[:8]:
title = e.get("title", "Untitled")
when = e.get("start_dt", "?")
location = e.get("location") or ""
line = f" - {title} at {when}"
if location:
line += f" ({location})"
lines.append(line)
lines.append("")
weather = sections.get("weather") or []
if weather:
lines.append("WEATHER:")
for w in weather:
label = w.get("location_label") or w.get("location_key") or "Location"
forecast_json = w.get("forecast_json") or {}
daily = forecast_json.get("daily") or {}
today_max = (daily.get("temperature_2m_max") or [None])[0]
today_min = (daily.get("temperature_2m_min") or [None])[0]
precip = (daily.get("precipitation_probability_max") or [None])[0]
bits = [label]
if today_max is not None and today_min is not None:
bits.append(f"high {today_max}° / low {today_min}°")
if precip is not None:
bits.append(f"{precip}% chance of precipitation")
lines.append(" - " + ", ".join(bits))
lines.append("")
projects = sections.get("projects") or []
if projects:
lines.append("ACTIVE PROJECTS:")
for p in projects[:5]:
line = f" - {p.get('title', '?')}"
if p.get("auto_summary"):
summary = p["auto_summary"][:160]
line += f"{summary}"
lines.append(line)
lines.append("")
recent_moments = sections.get("recent_moments") or []
if recent_moments:
lines.append("RECENT JOURNAL MOMENTS (last few days):")
for m in recent_moments[:8]:
day = m.get("day_date", "?")
content = (m.get("content") or "").strip()
lines.append(f" - [{day}] {content}")
lines.append("")
open_threads = sections.get("open_threads") or []
if open_threads:
lines.append("OPEN THREADS (mentioned recently but not resolved):")
for m in open_threads[:5]:
day = m.get("day_date", "?")
content = (m.get("content") or "").strip()
lines.append(f" - [{day}] {content}")
lines.append("")
if not lines:
return "(No data for today — quiet morning.)"
return "\n".join(lines).rstrip()
_PREP_SYSTEM_PROMPT = (
"You are briefing the user on their day. Direct and informative — tell them what's "
"actually on their plate so they can step into the day with a clear picture.\n\n"
"Rules:\n"
"- LEAD with the practical data: tasks due today, calendar events, weather.\n"
"- Be specific and concrete. Use real task titles, event times, temperatures, "
"precipitation chances. Don't paraphrase data into vague summaries.\n"
"- Write in flowing sentences — no markdown, no bullet points, no headers — but "
"keep the prose factual and useful, not sentimental.\n"
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
"greetings unless the actual content warrants two clauses' worth.\n"
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
"at the end as context — not as the lead. Skip them if nothing notable.\n"
"- Close with one short invitation to journal: \"What's on your mind?\", "
"\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
"- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\n"
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
)
def _fallback_prep_text(day_date: datetime.date) -> str:
"""If the LLM call fails, return a minimal greeting so the user still sees something."""
weekday = day_date.strftime("%A")
return f"{weekday}, {day_date.isoformat()}. What's on your mind?"
async def _generate_prep_prose(
*,
sections: dict,
day_date: datetime.date,
user_id: int,
) -> str:
"""Ask the LLM for a direct conversational journal opener built from the sections."""
from fabledassistant.services.llm import generate_completion
model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL
if not model:
logger.warning("No LLM model configured for daily prep — using fallback text")
return _fallback_prep_text(day_date)
rendered = _render_sections_for_prompt(sections)
user_trigger = (
f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
f"Here is what I gathered for you:\n\n{rendered}\n\n"
f"Write the opener for today's journal."
)
messages = [
{"role": "system", "content": _PREP_SYSTEM_PROMPT},
{"role": "user", "content": user_trigger},
]
try:
prose = await generate_completion(
messages=messages,
model=model,
max_tokens=400,
)
except Exception:
logger.exception("Daily prep prose generation failed for day %s", day_date)
return _fallback_prep_text(day_date)
prose = (prose or "").strip()
if not prose:
logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
return _fallback_prep_text(day_date)
return prose
async def ensure_daily_prep_message(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
force: bool = False,
) -> Message:
"""Get or create today's journal Conversation, then ensure the prep message exists.
The prep message is an *assistant* role message containing the prose opener,
with the structured sections preserved on ``msg_metadata``. If a legacy
system-role prep exists from an earlier version, it gets upgraded in place
on the next call.
With ``force=True`` the prose is regenerated even when a prep already exists.
Used by the manual /api/journal/trigger-prep endpoint.
"""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
)
conv = result.scalar_one_or_none()
if conv is None:
conv = Conversation(
user_id=user_id,
conversation_type="journal",
day_date=day_date,
title=day_date.isoformat(),
)
session.add(conv)
await session.flush()
# Find any existing prep (system or assistant role from any version).
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
existing_prep = None
for msg in (await session.execute(prep_stmt)).scalars():
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
existing_prep = msg
break
# If we already have an assistant-role prep with prose content and the
# caller didn't ask to force regeneration, we're done.
if (
existing_prep
and not force
and existing_prep.role == "assistant"
and (existing_prep.content or "").strip()
):
return existing_prep
sections = await gather_daily_sections(
user_id=user_id, day_date=day_date, user_timezone=user_timezone
)
prose = await _generate_prep_prose(
sections=sections, day_date=day_date, user_id=user_id
)
new_metadata = {"kind": "daily_prep", "sections": sections}
if existing_prep:
# Upgrade in place: bump role, replace content + metadata.
existing_prep.role = "assistant"
existing_prep.content = prose
existing_prep.msg_metadata = new_metadata
await session.commit()
return existing_prep
prep_msg = Message(
conversation_id=conv.id,
role="assistant",
content=prose,
msg_metadata=new_metadata,
)
session.add(prep_msg)
await session.commit()
return prep_msg
# Backwards-compat alias — older imports may use the old name.
generate_daily_prep = gather_daily_sections
@@ -0,0 +1,140 @@
"""APScheduler instance for the Journal — daily prep generation.
One per-user cron job: generate today's daily prep at the configured
prep_time in the user's local timezone. Replaces briefing_scheduler.
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
event_scheduler.py.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from fabledassistant.models import User, async_session
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
DEFAULT_CONFIG = {
"prep_enabled": True,
"prep_hour": 5,
"prep_minute": 0,
"day_rollover_hour": 4,
"morning_end_hour": 12,
"midday_end_hour": 18,
}
async def get_journal_config(user_id: int) -> dict:
"""Load a user's journal_config (JSON in settings) merged with defaults."""
raw = await get_setting(user_id, "journal_config", "")
config: dict = {}
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
config = parsed
except Exception:
logger.warning("Invalid journal_config for user %d; using defaults", user_id)
return {**DEFAULT_CONFIG, **config}
async def get_user_timezone(user_id: int) -> str:
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
def _resolve_tz(tz_str: str) -> ZoneInfo:
try:
return ZoneInfo(tz_str)
except Exception:
return ZoneInfo("UTC")
async def _do_daily_prep(user_id: int) -> None:
try:
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
config = await get_journal_config(user_id)
rollover = int(config.get("day_rollover_hour", 4))
now = datetime.datetime.now(tz)
# Today is the day after the most recent rollover.
if now.hour < rollover:
today = (now - datetime.timedelta(days=1)).date()
else:
today = now.date()
await ensure_daily_prep_message(
user_id=user_id, day_date=today, user_timezone=tz_str
)
except Exception:
logger.exception("Daily prep failed for user %d", user_id)
def _run_daily_prep_threadsafe(user_id: int) -> None:
if _loop is None:
return
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
async def update_user_schedule(user_id: int) -> None:
"""Add or replace this user's daily-prep job using their current config."""
if _scheduler is None:
return
job_id = f"journal_prep_{user_id}"
if _scheduler.get_job(job_id):
_scheduler.remove_job(job_id)
config = await get_journal_config(user_id)
if not config.get("prep_enabled", True):
return
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
prep_hour = int(config.get("prep_hour", 5))
prep_minute = int(config.get("prep_minute", 0))
_scheduler.add_job(
_run_daily_prep_threadsafe,
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
args=[user_id],
id=job_id,
replace_existing=True,
)
async def _register_all_user_jobs() -> None:
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
for user in users:
await update_user_schedule(user.id)
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.start()
asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop)
logger.info("Journal scheduler started")
def stop_journal_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Journal scheduler stopped")
@@ -0,0 +1,204 @@
"""Search across Moments and (optionally) journal transcripts.
Three modes, expressed through one tool surface:
1. Pure temporal: no query, just date range -> ordered by occurred_at DESC.
2. Pure entity: person_id or place_id filter -> junction lookup.
3. Semantic: query string -> embedding similarity, optionally constrained.
This module ONLY queries Moments and (optionally) journal-conversation
messages. It MUST NOT touch notes or note_embeddings. The notes-RAG and
journal-RAG isolation is a hard invariant of the journal design.
"""
from __future__ import annotations
import datetime
import math
from typing import Sequence
from sqlalchemy import select
from fabledassistant.models import (
Conversation,
Message,
Moment,
MomentEmbedding,
async_session,
moment_people,
moment_places,
)
from fabledassistant.services.embeddings import get_embedding
DEFAULT_LIMIT = 10
DEFAULT_THRESHOLD = 0.55
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
if not a or not b:
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
async def search_journal(
*,
user_id: int,
query: str | None = None,
person_id: int | None = None,
place_id: int | None = None,
tag: str | None = None,
date_from: datetime.date | None = None,
date_to: datetime.date | None = None,
include_transcripts: bool = False,
limit: int = DEFAULT_LIMIT,
threshold: float = DEFAULT_THRESHOLD,
) -> list[dict]:
"""Return Moments (and optional transcript snippets) matching the filters.
Result rows: dicts from Moment.to_dict() plus an optional `score` when
`query` is set. Transcript snippets carry `kind='transcript'` and `id=None`
to distinguish them.
"""
moment_rows = await _search_moments(
user_id=user_id,
query=query,
person_id=person_id,
place_id=place_id,
tag=tag,
date_from=date_from,
date_to=date_to,
limit=limit,
threshold=threshold,
)
if not include_transcripts:
return moment_rows
transcript_rows = await _search_transcripts(
user_id=user_id,
query=query,
date_from=date_from,
date_to=date_to,
limit=limit,
)
return moment_rows + transcript_rows
async def _search_moments(
*,
user_id: int,
query: str | None,
person_id: int | None,
place_id: int | None,
tag: str | None,
date_from: datetime.date | None,
date_to: datetime.date | None,
limit: int,
threshold: float,
) -> list[dict]:
async with async_session() as session:
stmt = select(Moment).where(Moment.user_id == user_id)
if person_id is not None:
stmt = stmt.join(moment_people, moment_people.c.moment_id == Moment.id).where(
moment_people.c.person_id == person_id
)
if place_id is not None:
stmt = stmt.join(moment_places, moment_places.c.moment_id == Moment.id).where(
moment_places.c.place_id == place_id
)
if tag is not None:
stmt = stmt.where(Moment.tags.any(tag))
if date_from is not None:
stmt = stmt.where(Moment.day_date >= date_from)
if date_to is not None:
stmt = stmt.where(Moment.day_date <= date_to)
if query is None:
stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit)
moments = (await session.execute(stmt)).scalars().all()
return [m.to_dict() for m in moments]
# Semantic mode. Pull a wider candidate set, then rank in Python.
candidate_stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit * 5)
candidates = (await session.execute(candidate_stmt)).scalars().all()
if not candidates:
return []
candidate_ids = [m.id for m in candidates]
emb_stmt = select(MomentEmbedding).where(
MomentEmbedding.moment_id.in_(candidate_ids)
)
embeddings = {
e.moment_id: e.embedding
for e in (await session.execute(emb_stmt)).scalars()
}
query_vec = await get_embedding(query)
scored = []
for m in candidates:
vec = embeddings.get(m.id)
if vec is None:
continue
score = _cosine(query_vec, vec)
if score >= threshold:
row = m.to_dict()
row["score"] = score
scored.append(row)
scored.sort(key=lambda r: r["score"], reverse=True)
return scored[:limit]
async def _search_transcripts(
*,
user_id: int,
query: str | None,
date_from: datetime.date | None,
date_to: datetime.date | None,
limit: int,
) -> list[dict]:
"""Fallback substring search over raw journal Messages.
Substring is intentional: transcripts catch what the LLM didn't extract
as Moments. Semantic search over messages would require a third embedding
index, which we deliberately don't maintain.
"""
if query is None:
return []
async with async_session() as session:
stmt = (
select(Message, Conversation.day_date)
.join(Conversation, Message.conversation_id == Conversation.id)
.where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Message.content.ilike(f"%{query}%"),
)
.order_by(Message.created_at.desc())
.limit(limit)
)
if date_from is not None:
stmt = stmt.where(Conversation.day_date >= date_from)
if date_to is not None:
stmt = stmt.where(Conversation.day_date <= date_to)
rows = (await session.execute(stmt)).all()
return [
{
"id": None,
"kind": "transcript",
"message_id": msg.id,
"conversation_id": msg.conversation_id,
"day_date": day_date.isoformat() if day_date else None,
"occurred_at": msg.created_at.isoformat(),
"content": msg.content[:400],
"raw_excerpt": None,
"tags": [],
"people": [],
"places": [],
}
for msg, day_date in rows
]
+43 -118
View File
@@ -573,6 +573,34 @@ async def build_context(
"""
exclude_set = set(exclude_note_ids or [])
from datetime import date as date_type
# --- Journal short-circuit ---
# Journal conversations get a different persona, calibration, and an
# ambient-moments context block. CRUCIALLY, no notes-RAG injection here
# (preserves the notes/journal isolation invariant).
if conv_id is not None:
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_conv = await _sess.get(_Conversation, conv_id)
if _conv and getattr(_conv, "conversation_type", None) == "journal":
from fabledassistant.services.journal_pipeline import build_journal_system_prompt
day_date = _conv.day_date or date_type.today()
system_content = await build_journal_system_prompt(
user_id=user_id,
day_date=day_date,
user_timezone=user_timezone or "UTC",
)
messages_out: list[dict] = [{"role": "system", "content": system_content}]
messages_out.extend(history)
messages_out.append({"role": "user", "content": user_message})
return messages_out, {
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
today = date_type.today().isoformat()
has_caldav = await is_caldav_configured(user_id)
@@ -595,7 +623,7 @@ async def build_context(
"search_projects", "create_milestone", "update_milestone", "list_milestones",
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
"get_rss_items", "add_rss_feed", "read_article",
"read_article",
]
if has_caldav:
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
@@ -632,7 +660,7 @@ async def build_context(
tool_guidance = "\n".join(tool_lines)
static_block = (
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Assistant. "
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Scribe. "
"Help users with their notes, tasks, and general questions. "
"When note context is provided, use it to give relevant answers.\n\n"
f"{tool_guidance}"
@@ -655,8 +683,8 @@ async def build_context(
# --- System message: stable content only ---
# Workspace context and history summary stay here because they carry
# behavioural instructions / conversational state, not retrieved content.
# Everything retrieval-based (RAG notes, RSS, URL content, current note,
# briefing articles) goes into the user turn below so the system message
# Everything retrieval-based (RAG notes, URL content, current note)
# goes into the user turn below so the system message
# prefix stays byte-for-byte identical across requests, enabling Ollama's
# KV prefix cache to fire reliably.
@@ -698,25 +726,6 @@ async def build_context(
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
)
# Detect briefing conversation — used for both system prompt instruction and article injection
_is_briefing_conv = False
if conv_id is not None:
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_conv = await _sess.get(_Conversation, conv_id)
if _conv and getattr(_conv, "conversation_type", None) == "briefing":
_is_briefing_conv = True
if _is_briefing_conv:
system_content += (
"\n\nYou are in a briefing conversation. "
"The conversation history contains today's briefing — news stories, weather, and tasks. "
"When the user asks about a topic, person, or event from the briefing, answer directly "
"from the conversation history and the article context that follows. "
"Do NOT search the web for information that is already present in the briefing."
)
context_meta: dict = {
"context_note_id": None,
"context_note_title": None,
@@ -752,27 +761,18 @@ async def build_context(
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
# Skip RAG auto-injection on the first turn of a seeded article discussion.
# The article body is already the sole context the user wants — pulling in
# unrelated orphan notes tricks the model into summarizing those instead.
# Follow-up turns keep RAG on because by then the user's own messages drive
# the query rather than the generic seed prompt.
from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED
_skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not _skip_rag_for_article_seed:
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_scored and not _skip_rag_for_article_seed:
if not found_scored:
keywords = _extract_keywords(user_message)
if keywords:
try:
@@ -862,12 +862,6 @@ async def build_context(
f"--- Content from {url} ---\n{content}\n--- End URL Content ---"
)
# Briefing article context for follow-up Q&A
if _is_briefing_conv:
article_context = await _build_briefing_article_context(conv_id) # type: ignore[arg-type]
if article_context:
user_context_parts.append(article_context.strip())
# Build final user message — context prefix (if any) followed by the actual message
if user_context_parts:
user_turn = "\n\n".join(user_context_parts) + "\n\n" + user_message
@@ -878,72 +872,3 @@ async def build_context(
messages.extend(history)
messages.append({"role": "user", "content": user_turn})
return messages, context_meta
async def _build_briefing_article_context(conv_id: int) -> str:
"""Fetch article content from today's briefing message and return a
formatted context block for injection into the system prompt.
Looks at the most recent assistant briefing messages for rss_item_ids
in their metadata, then loads those items from the DB.
Capped at 10 articles × 500 chars to keep token use reasonable.
"""
import json as _json
from sqlalchemy import select, text as _text
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
async with _async_session() as session:
result = await session.execute(
select(Message)
.where(
Message.conversation_id == conv_id,
Message.role == "assistant",
)
.order_by(Message.created_at.desc())
.limit(10)
)
messages = result.scalars().all()
rss_item_ids: list[int] = []
for msg in messages:
meta = msg.msg_metadata or {}
if isinstance(meta, str):
try:
meta = _json.loads(meta)
except Exception:
continue
ids = meta.get("rss_item_ids") or []
if ids:
rss_item_ids = ids
break
if not rss_item_ids:
return ""
async with _async_session() as session:
result = await session.execute(
_text("""
SELECT i.title, i.url, i.content, f.title AS feed_title
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = ANY(:ids)
ORDER BY i.published_at DESC NULLS LAST
LIMIT 10
""").bindparams(ids=rss_item_ids[:10])
)
rows = result.mappings().all()
if not rows:
return ""
lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"]
for row in rows:
lines.append(f"\n[{row['feed_title']}] {row['title']}")
if row["url"]:
lines.append(f"URL: {row['url']}")
if row["content"]:
lines.append(row["content"][:500])
return "\n".join(lines)
+185
View File
@@ -0,0 +1,185 @@
"""Moment CRUD, entity linking, and embedding sync.
Moments are small structured extractions from journal conversations.
Stored separately from Notes — they have their own embedding index and
the isolation between notes-RAG and journal-RAG is enforced at the API
boundary, not just the schema.
"""
from __future__ import annotations
import datetime
from typing import Sequence
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledassistant.models import (
Moment,
MomentEmbedding,
async_session,
moment_notes,
moment_people,
moment_places,
moment_tasks,
)
from fabledassistant.services.embeddings import get_embedding
async def create_moment(
*,
user_id: int,
content: str,
occurred_at: datetime.datetime,
day_date: datetime.date,
conversation_id: int | None = None,
source_message_id: int | None = None,
raw_excerpt: str | None = None,
tags: Sequence[str] = (),
person_ids: Sequence[int] = (),
place_ids: Sequence[int] = (),
task_ids: Sequence[int] = (),
note_ids: Sequence[int] = (),
) -> Moment:
"""Insert a Moment, write its embedding, create junction rows."""
async with async_session() as session:
moment = Moment(
user_id=user_id,
conversation_id=conversation_id,
source_message_id=source_message_id,
day_date=day_date,
occurred_at=occurred_at,
content=content,
raw_excerpt=raw_excerpt,
tags=list(tags),
)
session.add(moment)
await session.flush()
await _set_links(
session,
moment_id=moment.id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
embedding_vector = await get_embedding(content)
session.add(
MomentEmbedding(
moment_id=moment.id,
user_id=user_id,
embedding=embedding_vector,
)
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def get_moment(*, user_id: int, moment_id: int) -> Moment | None:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
return result.scalar_one_or_none()
async def update_moment(
*,
user_id: int,
moment_id: int,
content: str | None = None,
tags: Sequence[str] | None = None,
pinned: bool | None = None,
person_ids: Sequence[int] | None = None,
place_ids: Sequence[int] | None = None,
task_ids: Sequence[int] | None = None,
note_ids: Sequence[int] | None = None,
) -> Moment | None:
"""Update fields and (optionally) replace junction sets.
None for a junction parameter ⇒ leave unchanged. Empty sequence ⇒ clear.
Mirrors the Notes update convention.
"""
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return None
if content is not None:
moment.content = content
embedding_vector = await get_embedding(content)
await session.execute(
delete(MomentEmbedding).where(MomentEmbedding.moment_id == moment_id)
)
session.add(
MomentEmbedding(
moment_id=moment_id,
user_id=user_id,
embedding=embedding_vector,
)
)
if tags is not None:
moment.tags = list(tags)
if pinned is not None:
moment.pinned = pinned
await _set_links(
session,
moment_id=moment_id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def delete_moment(*, user_id: int, moment_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return False
await session.delete(moment)
await session.commit()
return True
async def _set_links(
session: AsyncSession,
*,
moment_id: int,
person_ids: Sequence[int] | None,
place_ids: Sequence[int] | None,
task_ids: Sequence[int] | None,
note_ids: Sequence[int] | None,
) -> None:
"""Replace junction-table rows.
None ⇒ leave existing rows alone. Empty sequence ⇒ clear all rows.
"""
for ids, table, fk_col in [
(person_ids, moment_people, "person_id"),
(place_ids, moment_places, "place_id"),
(task_ids, moment_tasks, "task_id"),
(note_ids, moment_notes, "note_id"),
]:
if ids is None:
continue
await session.execute(delete(table).where(table.c.moment_id == moment_id))
if ids:
await session.execute(
table.insert(),
[{"moment_id": moment_id, fk_col: i} for i in ids],
)
@@ -91,7 +91,7 @@ async def notify_security_event(
</table>
<p style="margin:16px 0 0;color:#9ca3af;font-size:12px;">If this wasn't you, change your password immediately.</p>
"""
await send_email(email, f"Fabled Assistant - {label}", _email_html("Security Alert", body))
await send_email(email, f"Fabled Scribe - {label}", _email_html("Security Alert", body))
except Exception:
logger.exception("Failed to send security notification for user %d", user_id)
@@ -113,7 +113,7 @@ async def send_password_reset_email(email: str, reset_url: str) -> None:
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
"""
await send_email(email, "Fabled Assistant - Password Reset", _email_html("Password Reset", body))
await send_email(email, "Fabled Scribe - Password Reset", _email_html("Password Reset", body))
async def send_password_reset_success_email(email: str) -> None:
@@ -125,14 +125,14 @@ async def send_password_reset_success_email(email: str) -> None:
</p>
<p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
"""
await send_email(email, "Fabled Assistant - Password Changed", _email_html("Password Changed", body))
await send_email(email, "Fabled Scribe - Password Changed", _email_html("Password Changed", body))
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
"""Send a branded invitation email with a registration link."""
body = f"""
<p style="margin:0 0 16px;color:#374151;font-size:15px;">
<strong>{invited_by_username}</strong> has invited you to join Fabled Assistant.
<strong>{invited_by_username}</strong> has invited you to join Fabled Scribe.
Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
@@ -146,7 +146,7 @@ async def send_invitation_email(email: str, invite_url: str, invited_by_username
<p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
"""
await send_email(email, "Fabled Assistant - You're Invited!", _email_html("You're Invited!", body))
await send_email(email, "Fabled Scribe - You're Invited!", _email_html("You're Invited!", body))
async def check_due_tasks() -> None:
@@ -225,7 +225,7 @@ async def check_due_tasks() -> None:
{task_rows}
</table>
"""
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await send_email(email, f"Fabled Scribe - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await log_audit(
"task_reminder",
user_id=user_id,
-317
View File
@@ -1,317 +0,0 @@
"""RSS feed service: fetch, parse with feedparser, and cache items to DB."""
import asyncio
import logging
from datetime import datetime, timezone
import feedparser
import html2text
import httpx
from sqlalchemy import select, text
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssFeed, RssItem
logger = logging.getLogger(__name__)
# Keep only items from the last N days to avoid unbounded growth
ITEM_MAX_AGE_DAYS = 90
_h2t = html2text.HTML2Text()
_h2t.ignore_links = True
_h2t.ignore_images = True
_h2t.ignore_emphasis = False
_h2t.body_width = 0 # No line-wrapping
def _html_to_text(html: str) -> str:
"""Convert HTML to clean plain text via html2text."""
if not html or "<" not in html:
return html
try:
return _h2t.handle(html).strip()
except Exception:
return html
async def get_or_fetch_full_article(item: RssItem) -> str | None:
"""Return the full article body, fetching+caching on miss.
Checks ``item.content_full`` first — populated either by the enrichment
pass at feed-ingest time or by a previous discuss-click. On miss, fetches
via ``_fetch_full_article`` and writes through. Returns ``None`` only if
the fetch itself fails; ``item.content_full == ""`` is still a cache hit.
Callers must pass an RssItem attached to an open session if they want
the write-through to persist — otherwise the fetched text is returned
but the cache stays empty and the next click will re-fetch.
"""
if item.content_full is not None:
return item.content_full
if not item.url:
return None
text = await _fetch_full_article(item.url)
if text is None:
return None
async with async_session() as session:
fresh = await session.get(RssItem, item.id)
if fresh is not None:
fresh.content_full = text
fresh.content_fetched_at = datetime.now(timezone.utc)
await session.commit()
return text
async def _fetch_full_article(url: str) -> str | None:
"""Fetch a URL and extract its main article text via trafilatura.
Returns clean plain text, or None if extraction fails or yields nothing useful.
Runs trafilatura in a thread executor since it does synchronous HTML parsing.
"""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0; +https://fabledsword.com)",
}) as client:
resp = await client.get(url)
resp.raise_for_status()
raw_html = resp.text
except Exception:
logger.debug("Failed to fetch article URL %s", url)
return None
loop = asyncio.get_event_loop()
try:
import trafilatura
text = await loop.run_in_executor(
None,
lambda: trafilatura.extract(
raw_html,
include_comments=False,
include_tables=True,
favor_recall=True,
),
)
return text or None
except Exception:
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
return None
def extract_item(entry) -> dict:
"""Extract a clean item dict from a feedparser entry object."""
# Prefer full content over summary (feedparser uses a list of Content objects)
content = ""
raw_content = getattr(entry, "content", None)
if isinstance(raw_content, list) and raw_content:
content = raw_content[0].value
else:
content = entry.get("summary", "")
content = _html_to_text(content)
pub = None
if entry.published_parsed:
try:
pub = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
except Exception:
pass
return {
"guid": entry.get("id", entry.get("link", "")),
"title": entry.get("title", ""),
"url": entry.get("link", ""),
"content": content,
"published_at": pub,
}
async def _parse_feed(url: str) -> feedparser.FeedParserDict:
"""Run feedparser in a thread executor so we don't block the event loop."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, feedparser.parse, url)
async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
"""
Fetch a feed URL, parse it, and upsert new items into rss_items.
Returns the number of new items stored.
"""
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80])
return 0
try:
parsed = await _parse_feed(url)
except Exception:
logger.warning("Failed to fetch RSS feed %s", url, exc_info=True)
return 0
if parsed.bozo and not parsed.entries:
logger.warning("Malformed RSS feed %s: %s", url, parsed.bozo_exception)
return 0
new_count = 0
feed_user_id: int | None = None
async with async_session() as session:
for entry in parsed.entries:
item_data = extract_item(entry)
if not item_data["guid"]:
continue
# Check if already stored
existing = await session.execute(
select(RssItem).where(
RssItem.feed_id == feed_id,
RssItem.guid == item_data["guid"],
)
)
if existing.scalars().first() is not None:
continue
item = RssItem(
feed_id=feed_id,
**item_data,
)
session.add(item)
new_count += 1
# Update last_fetched_at on the feed
feed_row = await session.get(RssFeed, feed_id)
if feed_row:
feed_row.last_fetched_at = datetime.now(timezone.utc)
feed_user_id = feed_row.user_id
# Auto-populate title from feed metadata if blank
if not feed_row.title and parsed.feed.get("title"):
feed_row.title = parsed.feed.title[:200]
await session.commit()
# Collect IDs of unclassified items after commit.
# We query classified_at IS NULL (not just the items inserted above) because
# classification is best-effort and may have failed on previous fetches.
# Re-queuing all unclassified items for this feed on each fetch is intentional:
# it provides automatic retry without a separate retry loop. The classifier
# only writes to items it successfully classifies, so already-classified items
# are not re-processed (they have classified_at set).
unclassified_ids: list[int] = []
new_item_data: list[tuple[int, str, str]] = [] # (id, title, content) for embedding
if new_count > 0:
result = await session.execute(
select(RssItem.id, RssItem.title, RssItem.content, RssItem.classified_at).where(
RssItem.feed_id == feed_id,
)
)
for row in result.fetchall():
item_id, title, content, classified_at = row
if classified_at is None:
unclassified_ids.append(item_id)
new_item_data.append((item_id, title or "", content or ""))
# Prune old items to keep DB tidy
await _prune_old_items(feed_id)
# Fire-and-forget classification for unclassified items
if unclassified_ids and feed_user_id is not None:
from fabledassistant.services.rss_classifier import classify_and_store
asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id))
# Collect (id, url) for newly inserted items to enrich with full article text
new_items_for_enrichment: list[tuple[int, str]] = []
if new_count > 0:
async with async_session() as session:
result = await session.execute(
select(RssItem.id, RssItem.url).where(
RssItem.feed_id == feed_id,
RssItem.url != "",
).order_by(RssItem.fetched_at.desc()).limit(new_count)
)
new_items_for_enrichment = list(result.fetchall())
# Fire-and-forget: fetch full article text, then re-embed with richer content
if new_items_for_enrichment and feed_user_id is not None:
from fabledassistant.services.embeddings import upsert_rss_item_embedding
async def _enrich_and_embed() -> None:
for item_id, article_url in new_items_for_enrichment:
full_text = await _fetch_full_article(article_url)
if full_text and len(full_text) > 200:
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
item.content = full_text
# Populate the discuss-click cache too so the
# first click skips straight to the map-reduce
# step without re-fetching.
item.content_full = full_text
item.content_fetched_at = datetime.now(timezone.utc)
await session.commit()
await upsert_rss_item_embedding(
item_id, feed_user_id, item.title or "", item.content
)
else:
# Enrich failed — still embed with RSS-provided content
async with async_session() as session:
item = await session.get(RssItem, item_id)
if item:
await upsert_rss_item_embedding(
item_id, feed_user_id, item.title or "", item.content or ""
)
await asyncio.sleep(0.5) # Polite pacing between article fetches
asyncio.create_task(_enrich_and_embed())
elif new_item_data and feed_user_id is not None:
# No URLs to enrich — embed with RSS content only
from fabledassistant.services.embeddings import upsert_rss_item_embedding
async def _embed_only() -> None:
for item_id, title, content in new_item_data:
await upsert_rss_item_embedding(item_id, feed_user_id, title, content)
await asyncio.sleep(0.05)
asyncio.create_task(_embed_only())
return new_count
async def _prune_old_items(feed_id: int) -> None:
"""Delete items older than ITEM_MAX_AGE_DAYS from a feed."""
async with async_session() as session:
await session.execute(
text("""
DELETE FROM rss_items
WHERE feed_id = :feed_id
AND published_at < NOW() - INTERVAL '90 days'
""").bindparams(feed_id=feed_id)
)
await session.commit()
async def get_recent_items(user_id: int, limit: int = 20) -> list[dict]:
"""Return the most recent RSS items across all of a user's feeds."""
async with async_session() as session:
result = await session.execute(
select(RssItem, RssFeed.title.label("feed_title"))
.join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssFeed.user_id == user_id)
.order_by(RssItem.published_at.desc().nullslast())
.limit(limit)
)
rows = result.all()
return [
{**item.to_dict(), "feed_title": feed_title}
for item, feed_title in rows
]
async def refresh_all_feeds(user_id: int) -> dict[int, int]:
"""Fetch all feeds for a user. Returns {feed_id: new_items_count}."""
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.user_id == user_id)
)
feeds = list(result.scalars().all())
results = {}
for feed in feeds:
count = await fetch_and_cache_feed(feed.id, feed.url)
results[feed.id] = count
return results
@@ -1,152 +0,0 @@
"""
RSS item topic classifier.
Classifies RSS items into topic tags using a fast non-streaming LLM call.
Called from rss.py after new items are stored — fire-and-forget.
"""
import json
import logging
import re
from datetime import datetime, timezone
import httpx
from fabledassistant.config import Config
logger = logging.getLogger(__name__)
STANDARD_TOPICS = [
"technology", "science", "politics", "business",
"health", "environment", "local", "entertainment", "sports", "other",
]
_CLASSIFY_PROMPT = """\
Classify each news item into 1-3 topics. Use only topics from this list: {vocab}.
Return ONLY a JSON object mapping item_id (as string) to a list of topics.
Example: {{"1": ["technology", "ai"], "2": ["politics"]}}
Items:
{items_block}"""
async def _llm_classify(prompt: str, model: str) -> str:
"""Make a fast non-streaming LLM call and return the raw text response."""
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_ctx": 2048, "temperature": 0.0},
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status()
return resp.json().get("message", {}).get("content", "")
async def classify_items_batch(
items: list[dict],
user_include_topics: list[str],
model: str | None = None,
) -> dict[int, list[str]]:
"""
Classify a batch of RSS items into topic tags.
Args:
items: list of dicts with 'id', 'title', 'content'
user_include_topics: extra topics from user preferences to add to vocabulary
model: Ollama model name; defaults to Config.OLLAMA_MODEL
Returns:
dict mapping item_id (int) -> list of topic strings.
Items not returned had classification fail; callers should leave classified_at=NULL.
"""
if not items:
return {}
if model is None:
model = Config.OLLAMA_BACKGROUND_MODEL
vocab = STANDARD_TOPICS + [t for t in user_include_topics if t not in STANDARD_TOPICS]
items_block = "\n".join(
f"[{item['id']}] {item['title']}{item.get('content', '')[:300]}"
for item in items
)
prompt = _CLASSIFY_PROMPT.format(vocab=", ".join(vocab), items_block=items_block)
try:
raw = await _llm_classify(prompt, model)
# Strip <think>...</think> blocks emitted by reasoning models (e.g. qwen3)
raw = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL)
# Extract JSON from response (LLM may wrap it in markdown)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
raw = raw.strip()
# Allow control characters that may appear in LLM-generated JSON strings
parsed = json.loads(raw, strict=False)
return {int(k): v for k, v in parsed.items() if isinstance(v, list)}
except Exception:
logger.warning("RSS classification failed", exc_info=True)
return {}
async def classify_and_store(
item_ids: list[int],
user_id: int,
) -> None:
"""
Classify unclassified RSS items and write results to DB.
Called as a fire-and-forget task from rss.py.
"""
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.services.settings import get_setting
if not item_ids:
return
# Load the items
async with async_session() as session:
result = await session.execute(
select(RssItem).where(RssItem.id.in_(item_ids))
)
items = list(result.scalars().all())
if not items:
return
# Get user's include topics to extend vocabulary
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
try:
include_topics = json.loads(raw_include) if isinstance(raw_include, str) else []
except Exception:
include_topics = []
model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
# Classify in batches of 10
batch_size = 10
all_results: dict[int, list[str]] = {}
for i in range(0, len(items), batch_size):
batch = items[i: i + batch_size]
batch_dicts = [{"id": it.id, "title": it.title, "content": it.content} for it in batch]
results = await classify_items_batch(batch_dicts, include_topics, model=model)
all_results.update(results)
# Write back to DB
now = datetime.now(timezone.utc)
async with async_session() as session:
for item in items:
item_db = await session.get(RssItem, item.id)
if item_db is None:
continue
topics = all_results.get(item.id)
if topics is not None:
item_db.topics = topics
item_db.classified_at = now
await session.commit()
@@ -8,13 +8,14 @@ of the app depends on.
# Import every tool module so their @tool decorators run at import time.
# Order does not matter — registration is additive.
from fabledassistant.services.tools import ( # noqa: F401
article,
calendar,
entities,
journal,
notes,
profile,
projects,
rag,
rss,
tasks,
utility,
weather,
@@ -22,12 +23,10 @@ from fabledassistant.services.tools import ( # noqa: F401
)
from fabledassistant.services.tools._registry import (
execute_tool,
get_briefing_tools,
get_tools_for_user,
)
__all__ = [
"execute_tool",
"get_briefing_tools",
"get_tools_for_user",
]
+19 -17
View File
@@ -27,6 +27,7 @@ class ToolDef:
handler: ToolHandler
read_only: bool = False
briefing: bool = False
journal: bool = False
requires: str | None = None
required_params: list[str] = field(default_factory=list)
@@ -57,6 +58,7 @@ def tool(
required: list[str] | None = None,
read_only: bool = False,
briefing: bool = False,
journal: bool = False,
requires: str | None = None,
) -> Callable[[ToolHandler], ToolHandler]:
"""Register an async tool handler with its schema metadata."""
@@ -71,6 +73,7 @@ def tool(
handler=fn,
read_only=read_only,
briefing=briefing,
journal=journal,
requires=requires,
required_params=required or [],
)
@@ -84,33 +87,32 @@ async def _check_requires(user_id: int, requires: str) -> bool:
return await is_caldav_configured(user_id)
if requires == "searxng":
return Config.searxng_enabled()
if requires == "rss":
from fabledassistant.services.settings import get_setting
return (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
return True
async def get_tools_for_user(user_id: int) -> list[dict]:
"""Build the tool schema list for a user based on configured integrations."""
async def get_tools_for_user(
user_id: int, *, conversation_type: str = "chat"
) -> list[dict]:
"""Build the tool schema list for a user, scoped by conversation type.
- 'chat': all tools except those marked journal-only.
- 'journal': all tools except set_rag_scope (scope is implicit in journal).
"""
tools: list[dict] = []
for td in _REGISTRY.values():
if td.requires and not await _check_requires(user_id, td.requires):
continue
if conversation_type == "journal":
if td.name == "set_rag_scope":
continue
else:
if td.journal:
continue
tools.append(td.schema())
logger.debug("User %d: %d tools available", user_id, len(tools))
return tools
async def get_briefing_tools(user_id: int) -> list[dict]:
"""Return only the tool schemas marked ``briefing=True``."""
all_tools = await get_tools_for_user(user_id)
names = {td.name for td in _REGISTRY.values() if td.briefing}
filtered = [t for t in all_tools if t["function"]["name"] in names]
logger.debug(
"Briefing tools for user %d: %d of %d selected",
user_id, len(filtered), len(all_tools),
"User %d / %s: %d tools available", user_id, conversation_type, len(tools)
)
return filtered
return tools
async def execute_tool(
@@ -0,0 +1,35 @@
"""Generic article-reading LLM tool.
The ``read_article`` tool fetches any URL and returns its main body text via
trafilatura. URL-generic — not coupled to any feed system.
"""
from __future__ import annotations
from fabledassistant.services.article_fetcher import fetch_article_text
from fabledassistant.services.tools._registry import tool
@tool(
name="read_article",
description=(
"Fetch the main body text of an article at a URL. Use when the user asks "
"to read, summarize, or discuss a specific article they've linked. "
"Returns the cleaned article text or an empty result if extraction fails."
),
parameters={
"url": {
"type": "string",
"description": "The article URL to fetch.",
},
},
required=["url"],
read_only=True,
)
async def read_article_tool(*, user_id, arguments, **_ctx):
url = (arguments.get("url") or "").strip()
if not url:
return {"success": False, "error": "url is required"}
content = await fetch_article_text(url)
if not content:
return {"success": True, "type": "article", "data": {"url": url, "content": None, "note": "no content extracted"}}
return {"success": True, "type": "article", "data": {"url": url, "content": content[:6000]}}
@@ -0,0 +1,264 @@
"""LLM tools for journal conversations: record_moment and search_journal."""
from __future__ import annotations
import datetime
import logging
from typing import Any
from sqlalchemy import func, select
from fabledassistant.models import Note, async_session
from fabledassistant.services.journal_search import search_journal as svc_search_journal
from fabledassistant.services.moments import create_moment
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
async def _resolve_entity_ids_by_name(
*,
user_id: int,
names: list[str],
note_type: str | None = None,
is_task_only: bool | None = None,
) -> list[int]:
"""Case-insensitive title lookup → note IDs.
Names with no match are silently dropped (logged at debug). Used by
record_moment to resolve user-mentioned names without forcing the
LLM to track tool-return IDs across calls.
"""
if not names:
return []
cleaned = [n.strip() for n in names if n and n.strip()]
if not cleaned:
return []
lowered = [n.lower() for n in cleaned]
async with async_session() as session:
stmt = select(Note.id, func.lower(Note.title).label("ltitle")).where(
Note.user_id == user_id,
func.lower(Note.title).in_(lowered),
)
if note_type is not None:
stmt = stmt.where(Note.note_type == note_type)
if is_task_only is True:
stmt = stmt.where(Note.status.isnot(None))
elif is_task_only is False:
stmt = stmt.where(Note.status.is_(None))
rows = (await session.execute(stmt)).all()
# Pick first match per name; preserve input order.
by_lower: dict[str, int] = {}
for note_id, ltitle in rows:
if ltitle not in by_lower:
by_lower[ltitle] = note_id
resolved: list[int] = []
for low, original in zip(lowered, cleaned):
if low in by_lower:
resolved.append(by_lower[low])
else:
logger.debug(
"record_moment: no entity match for %r (note_type=%s, task_only=%s)",
original, note_type, is_task_only,
)
return resolved
@tool(
name="record_moment",
description=(
"Record a meaningful beat from the conversation as a structured Moment. "
"Use freely (no confirmation) for anything significant the user mentions: "
"events, encounters, decisions, observations, feelings worth remembering. "
"Each Moment is one or two sentences distilling the beat — not a full transcript. "
"STRONGLY PREFER the *_names parameters when linking entities — the server "
"resolves names to IDs by lookup, so you cannot accidentally invent or "
"re-use the wrong ID. Use *_ids only when you have an exact ID returned "
"from another tool call in this same turn."
),
parameters={
"content": {
"type": "string",
"description": "1-2 sentence distillation of the moment in the user's voice or third-person.",
},
"occurred_at": {
"type": "string",
"description": "ISO 8601 datetime when the moment happened. Pass 'now' for the present moment; the handler resolves it.",
},
"raw_excerpt": {
"type": "string",
"description": "Optional: literal user phrase the moment summarizes. Useful for trust UI.",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags (no # prefix).",
},
"person_names": {
"type": "array",
"items": {"type": "string"},
"description": "PREFERRED. Names of people mentioned (e.g. ['Victoria', 'Mom']). Server resolves to existing person notes by case-insensitive title match. Names with no match are silently skipped.",
},
"place_names": {
"type": "array",
"items": {"type": "string"},
"description": "PREFERRED. Names of places mentioned (e.g. ['the new ramen place']). Server resolves to existing place notes by title.",
},
"task_titles": {
"type": "array",
"items": {"type": "string"},
"description": "Titles of tasks this moment references. Server resolves to task IDs by title match.",
},
"note_titles": {
"type": "array",
"items": {"type": "string"},
"description": "Titles of notes this moment references. Server resolves to note IDs by title match.",
},
"person_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use person_names instead. Only valid if you obtained the ID from a tool result in this same turn. Never invent IDs.",
},
"place_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use place_names instead. Same caveat as person_ids.",
},
"task_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use task_titles instead. Same caveat as person_ids.",
},
"note_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use note_titles instead. Same caveat as person_ids.",
},
},
required=["content"],
read_only=False,
journal=True,
)
async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
content = arguments.get("content", "").strip()
if not content:
return {"success": False, "error": "content is required"}
occurred_raw = arguments.get("occurred_at")
now = datetime.datetime.now(datetime.timezone.utc)
if occurred_raw and occurred_raw != "now":
try:
occurred_dt = datetime.datetime.fromisoformat(occurred_raw)
if occurred_dt.tzinfo is None:
occurred_dt = occurred_dt.replace(tzinfo=datetime.timezone.utc)
except ValueError:
occurred_dt = now
else:
occurred_dt = now
# Start with any explicit IDs the LLM provided.
person_ids = list(arguments.get("person_ids") or [])
place_ids = list(arguments.get("place_ids") or [])
task_ids = list(arguments.get("task_ids") or [])
note_ids = list(arguments.get("note_ids") or [])
# Resolve names → IDs and merge. Names are the preferred path because the
# LLM is unreliable at tracking IDs across tool calls (see prior bug:
# hallucinated person_ids=[1,2] referencing test-data notes).
person_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("person_names") or [],
note_type="person",
)
)
place_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("place_names") or [],
note_type="place",
)
)
task_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("task_titles") or [],
is_task_only=True,
)
)
note_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("note_titles") or [],
is_task_only=False,
)
)
# Dedupe while preserving order.
person_ids = list(dict.fromkeys(person_ids))
place_ids = list(dict.fromkeys(place_ids))
task_ids = list(dict.fromkeys(task_ids))
note_ids = list(dict.fromkeys(note_ids))
moment = await create_moment(
user_id=user_id,
content=content,
occurred_at=occurred_dt,
day_date=occurred_dt.date(),
conversation_id=conv_id,
raw_excerpt=arguments.get("raw_excerpt"),
tags=arguments.get("tags") or [],
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
return {
"success": True,
"type": "moment_recorded",
"moment": moment.to_dict(),
}
@tool(
name="search_journal",
description=(
"Search the user's journal: past Moments and (optionally) raw transcripts. "
"Use to recall things mentioned in prior days. Three modes: "
"(a) date filter only -> recent moments in that range; "
"(b) person_id/place_id filter -> moments mentioning that entity; "
"(c) query string -> semantic search, optionally constrained by date/entity."
),
parameters={
"query": {"type": "string", "description": "Optional semantic query."},
"person_id": {"type": "integer", "description": "Filter to this person."},
"place_id": {"type": "integer", "description": "Filter to this place."},
"tag": {"type": "string", "description": "Filter to moments with this tag."},
"date_from": {"type": "string", "description": "ISO date lower bound (inclusive)."},
"date_to": {"type": "string", "description": "ISO date upper bound (inclusive)."},
"include_transcripts": {
"type": "boolean",
"description": "If true, also return matching raw transcript excerpts when query is set. Default false.",
},
"limit": {"type": "integer", "description": "Max results, default 10."},
},
required=[],
read_only=True,
journal=True,
)
async def search_journal_tool(*, user_id, arguments, **_ctx) -> dict[str, Any]:
df_raw = arguments.get("date_from")
dt_raw = arguments.get("date_to")
df = datetime.date.fromisoformat(df_raw) if df_raw else None
dt = datetime.date.fromisoformat(dt_raw) if dt_raw else None
results = await svc_search_journal(
user_id=user_id,
query=arguments.get("query"),
person_id=arguments.get("person_id"),
place_id=arguments.get("place_id"),
tag=arguments.get("tag"),
date_from=df,
date_to=dt,
include_transcripts=bool(arguments.get("include_transcripts", False)),
limit=int(arguments.get("limit", 10)),
)
return {"success": True, "type": "journal_search_results", "count": len(results), "results": results}
-101
View File
@@ -1,101 +0,0 @@
"""RSS and article tools."""
from __future__ import annotations
import logging
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="get_rss_items",
description="Get recent items from the user's RSS feeds (news, blogs, Reddit, podcasts). Returns titles, URLs, and summaries of recent posts.",
parameters={
"limit": {"type": "integer", "description": "Number of items to return (default 15, max 50)"},
"category": {"type": "string", "description": "Filter by feed category (e.g. 'news', 'tech'). Omit for all."},
},
read_only=True,
briefing=True,
requires="rss",
)
async def get_rss_items_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.rss import get_recent_items
limit = min(int(arguments.get("limit", 15)), 50)
items = await get_recent_items(user_id, limit=limit)
return {"data": {"items": items, "count": len(items)}}
@tool(
name="add_rss_feed",
description="Add an RSS/Atom feed. Use when user asks to subscribe to or track a feed, blog, subreddit, or podcast.",
parameters={
"url": {"type": "string", "description": "The RSS/Atom feed URL to add."},
"category": {"type": "string", "description": "Optional category label (e.g. 'news', 'tech', 'reddit'). Omit if unsure."},
},
required=["url"],
requires="rss",
)
async def add_rss_feed_tool(*, user_id, arguments, **_ctx):
import asyncio as _asyncio
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.rss_feed import RssFeed
from fabledassistant.services.rss import fetch_and_cache_feed
url = str(arguments.get("url", "")).strip()
if not url:
return {"error": "url is required"}
category = arguments.get("category") or None
async with _async_session() as session:
existing = await session.execute(
_select(RssFeed).where(RssFeed.user_id == user_id, RssFeed.url == url)
)
if existing.scalars().first():
return {"error": "Feed already added", "url": url}
feed = RssFeed(user_id=user_id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
_asyncio.create_task(fetch_and_cache_feed(feed_id, url))
return {"data": {"id": feed_id, "url": url, "message": "Feed added and fetching started."}}
@tool(
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 lookup for URLs — use this tool instead."
),
parameters={
"url": {"type": "string", "description": "The URL to fetch and read"},
},
required=["url"],
read_only=True,
briefing=True,
)
async def read_article_tool(*, user_id, arguments, **_ctx):
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,
}
@@ -26,18 +26,18 @@ logger = logging.getLogger(__name__)
briefing=True,
)
async def get_weather_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.briefing_pipeline import _get_temp_unit
from fabledassistant.services.weather import (
_fetch_open_meteo,
geocode,
get_cached_weather,
get_temp_unit,
parse_forecast,
refresh_location_cache,
)
location = (arguments.get("location") or "").strip()
num_days = max(1, min(8, int(arguments.get("days") or 1)))
temp_unit = await _get_temp_unit(user_id)
temp_unit = await get_temp_unit(user_id)
def _apply_unit(days: list[dict]) -> list[dict]:
if temp_unit != "F":
@@ -82,7 +82,7 @@ async def get_weather_tool(*, user_id, arguments, **_ctx):
if stale_keys:
try:
from fabledassistant.services.settings import get_setting as _wx_get_setting
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
raw_cfg = await _wx_get_setting(user_id, "journal_config", "{}")
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
for key in stale_keys:
+2 -2
View File
@@ -70,14 +70,14 @@ async def lookup_tool(*, user_id, arguments, **_ctx):
if search_results:
# Sequential fetches: trafilatura/lxml is not safe to run concurrently
# via run_in_executor — parallel calls can trip a libxml2 double-free.
from fabledassistant.services.rss import _fetch_full_article
from fabledassistant.services.article_fetcher import fetch_article_text
for r in search_results[:2]:
url = r.get("url", "")
if not url:
continue
try:
content = await _fetch_full_article(url)
content = await fetch_article_text(url)
except Exception:
content = None
web_payload.append({
+17 -12
View File
@@ -13,11 +13,13 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from fabledassistant.services.settings import get_setting
# Briefing day boundary — kept in sync with the compilation slot in
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
# (not midnight) so the 00:0004:00 local window still shows yesterday's
# briefing until the 4am compilation generates the new one.
BRIEFING_DAY_START_HOUR = 4
# Day-rollover boundary for journal/day-anchored views. The "day" flips at
# this local hour (not midnight) so the 00:0004:00 local window still
# shows yesterday's content until the user "starts" the new day.
DAY_ROLLOVER_HOUR = 4
# Backwards-compat alias for code paths still referencing the old name.
BRIEFING_DAY_START_HOUR = DAY_ROLLOVER_HOUR
async def get_user_tz(user_id: int) -> ZoneInfo:
@@ -35,13 +37,16 @@ async def user_today(user_id: int) -> date:
return datetime.now(tz).date()
async def user_briefing_date(user_id: int) -> date:
"""Return the current "briefing day" in the user's local timezone.
async def user_day_date(user_id: int) -> date:
"""Return the current "day" in the user's local timezone.
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
aligned with the compilation slot that generates the day's briefing.
Between 00:00 and 04:00 local this still returns *yesterday*, so the
UI keeps showing the in-progress briefing until the new one is built.
The day flips at ``DAY_ROLLOVER_HOUR`` (4am local) rather than midnight,
so the 00:0004:00 local window still returns *yesterday* — the user's
journal stays anchored to yesterday until they cross the rollover.
"""
tz = await get_user_tz(user_id)
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
return (datetime.now(tz) - timedelta(hours=DAY_ROLLOVER_HOUR)).date()
# Backwards-compat alias used by briefing-only modules being torn down in Stage B.
user_briefing_date = user_day_date
+7
View File
@@ -39,6 +39,13 @@ def describe_weathercode(code: int) -> str:
return _WMO_CODES.get(code, f"Unknown (code {code})")
async def get_temp_unit(user_id: int) -> str:
"""Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
from fabledassistant.services.settings import get_setting
raw = await get_setting(user_id, "temp_unit", "C")
return raw if raw in ("C", "F") else "C"
def parse_forecast(raw: dict) -> list[dict]:
"""Convert Open-Meteo daily response into a clean list of day dicts."""
daily = raw.get("daily", {})
-216
View File
@@ -1,216 +0,0 @@
import json
import pytest
from unittest.mock import patch, AsyncMock
# ---------------------------------------------------------------------------
# read_article tool tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_read_article_success():
"""read_article returns success with content when _fetch_full_article succeeds."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new_callable=AsyncMock,
return_value="Article body 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 body text here."
assert result["truncated"] is False
@pytest.mark.asyncio
async def test_read_article_fetch_failure():
"""read_article returns success=False when _fetch_full_article returns None."""
from fabledassistant.services.tools import execute_tool
with patch(
"fabledassistant.services.rss._fetch_full_article",
new_callable=AsyncMock,
return_value=None,
):
result = await execute_tool(user_id=1, tool_name="read_article", arguments={"url": "https://example.com/broken"})
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 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_callable=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 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
assert "No URL provided" in result["error"]
# ---------------------------------------------------------------------------
# History builder tests
# ---------------------------------------------------------------------------
def _build_history(messages: list[dict]) -> list[dict]:
"""Replicate the fixed history builder from routes/chat.py."""
history = []
for msg in messages:
if msg["role"] == "system":
continue
msg_dict = {"role": msg["role"], "content": msg.get("content") or ""}
tool_calls = msg.get("tool_calls")
if tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in tool_calls
]
history.append(msg_dict)
for tc in tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
return history
def test_history_builder_replays_tool_calls():
"""History builder with tool_calls produces assistant entry + tool result entry."""
messages = [
{
"role": "assistant",
"content": "",
"tool_calls": [
{
"function": "read_article",
"arguments": {"url": "https://example.com"},
"result": {"success": True, "content": "Article text"},
}
],
},
{"role": "user", "content": "Summarize it", "tool_calls": None},
]
history = _build_history(messages)
assert len(history) == 3
assert history[0]["role"] == "assistant"
assert history[0]["tool_calls"][0]["function"]["name"] == "read_article"
assert history[1]["role"] == "tool"
assert json.loads(history[1]["content"])["success"] is True
assert history[2]["role"] == "user"
assert history[2]["content"] == "Summarize it"
def test_history_builder_no_tool_calls_unchanged():
"""History builder with tool_calls=None produces same output as before."""
messages = [
{"role": "user", "content": "Hello", "tool_calls": None},
{"role": "assistant", "content": "Hi there!", "tool_calls": None},
]
history = _build_history(messages)
assert len(history) == 2
assert history[0] == {"role": "user", "content": "Hello"}
assert history[1] == {"role": "assistant", "content": "Hi there!"}
# ---------------------------------------------------------------------------
# prepare_article_context tests
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_article_context_small_passthrough():
"""Articles under CHAR_BUDGET pass through unchanged with zero LLM calls."""
from fabledassistant.services import article_context
body = "A short article.\n\nWith two paragraphs."
with patch(
"fabledassistant.services.article_context.generate_completion",
new_callable=AsyncMock,
) as mock_gen:
out = await article_context.prepare_article_context(
"Title", "https://example.com", body, "test-model",
)
assert out == body
mock_gen.assert_not_called()
@pytest.mark.asyncio
async def test_prepare_article_context_large_runs_map_reduce():
"""Articles over CHAR_BUDGET are chunked and map-reduced via the background model."""
from fabledassistant.services import article_context
# CHAR_BUDGET is 48_000 — build a body well over that with paragraph breaks
# so the chunker has natural splits to work with.
paragraph = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 40).strip()
body = "\n\n".join([paragraph] * 30) # ~70k+ chars across 30 paragraphs
assert len(body) > article_context.CHAR_BUDGET
with patch(
"fabledassistant.services.article_context.generate_completion",
new_callable=AsyncMock,
return_value="Summary of this section with specific claims preserved.",
) as mock_gen:
out = await article_context.prepare_article_context(
"Long Article", "https://example.com/long", body, "test-model",
)
# At least one LLM call fired (the map step ran)
assert mock_gen.await_count >= 1
# Output carries the oversized-article header and section markers
assert "longer than the chat window" in out
assert "## Section 1" in out
# Map output is much smaller than the raw body
assert len(out) < len(body)
def test_chunk_by_paragraph_respects_boundaries():
"""Chunker splits on paragraph breaks, not mid-sentence."""
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
paragraphs = [f"Paragraph {i}. " + ("x" * 1000) for i in range(20)]
body = "\n\n".join(paragraphs)
chunks = _chunk_by_paragraph(body)
# Each chunk stays under the budget
for chunk in chunks:
assert len(chunk) <= CHUNK_CHARS
# Total content is preserved (modulo overlap duplication, so ≥ original)
assert sum(len(c) for c in chunks) >= len(body) * 0.9
def test_chunk_by_paragraph_handles_oversized_paragraph():
"""A single paragraph larger than CHUNK_CHARS gets split mid-paragraph."""
from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS
body = "x" * (CHUNK_CHARS * 3) # one huge paragraph, no breaks
chunks = _chunk_by_paragraph(body)
assert len(chunks) >= 3
for chunk in chunks:
assert len(chunk) <= CHUNK_CHARS
-40
View File
@@ -1,40 +0,0 @@
def test_conversation_has_briefing_fields():
"""Conversation model must declare conversation_type and briefing_date."""
from fabledassistant.models.conversation import Conversation
cols = {c.key for c in Conversation.__table__.columns}
assert "conversation_type" in cols
assert "briefing_date" in cols
def test_rss_models_exist():
from fabledassistant.models.rss_feed import RssFeed, RssItem
feed_cols = {c.key for c in RssFeed.__table__.columns}
item_cols = {c.key for c in RssItem.__table__.columns}
assert {"id", "user_id", "url", "title", "last_fetched_at"} <= feed_cols
assert {"id", "feed_id", "guid", "title", "url", "published_at", "content"} <= item_cols
def test_weather_cache_model_exists():
from fabledassistant.models.weather_cache import WeatherCache
cols = {c.key for c in WeatherCache.__table__.columns}
assert {"id", "user_id", "location_key", "location_label", "forecast_json", "previous_json", "fetched_at"} <= cols
def test_message_metadata_field_exists():
from fabledassistant.models.conversation import Message
# DB column is named 'metadata'; Python attribute is 'msg_metadata'
# (SQLAlchemy reserves 'metadata' as an attribute name on mapped classes)
col_names = {c.name for c in Message.__table__.columns}
assert "metadata" in col_names
m = Message(conversation_id=1, role="assistant", content="hi", msg_metadata={"foo": 1})
assert m.msg_metadata == {"foo": 1}
def test_rss_item_topics_field_exists():
from fabledassistant.models.rss_feed import RssItem
cols = {c.key for c in RssItem.__table__.columns}
assert "topics" in cols
assert "classified_at" in cols
-39
View File
@@ -1,39 +0,0 @@
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
@pytest.mark.asyncio
async def test_get_or_create_profile_note_returns_body():
"""get_profile_body() should return empty string when no profile note exists."""
with patch("fabledassistant.services.briefing_profile.list_notes", new_callable=AsyncMock) as mock_list:
mock_list.return_value = ([], 0)
from fabledassistant.services.briefing_profile import get_profile_body
body = await get_profile_body(user_id=1)
assert body == ""
@pytest.mark.asyncio
async def test_post_message_accepts_metadata():
"""post_message should accept an optional metadata dict and store it."""
from unittest.mock import AsyncMock, patch, MagicMock
mock_msg = MagicMock()
mock_msg.id = 1
with patch("fabledassistant.services.briefing_conversations.async_session") as mock_session_cls:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock()
mock_session.get = AsyncMock(return_value=None)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session_cls.return_value = mock_session
from fabledassistant.services.briefing_conversations import post_message
metadata = {"weather": {"location": "Berlin"}, "rss_item_ids": [1, 2]}
await post_message(1, "assistant", "Hello", metadata=metadata)
# Verify Message was constructed with msg_metadata
call_args = mock_session.add.call_args[0][0]
assert call_args.msg_metadata == metadata
-114
View File
@@ -1,114 +0,0 @@
"""Tests for briefing_scheduler slot gating and work-day gate."""
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
def _make_scheduler():
"""Return a mock APScheduler with tracked job IDs."""
sched = MagicMock()
sched.get_job.return_value = None
return sched
def test_add_user_jobs_skips_disabled_slots():
"""_add_user_jobs should not register jobs for slots set to False in config."""
from fabledassistant.services import briefing_scheduler as bs
mock_sched = _make_scheduler()
bs._scheduler = mock_sched
bs._loop = MagicMock()
config = {"slots": {"compilation": True, "morning": False, "midday": False, "afternoon": False}}
bs._add_user_jobs(user_id=1, tz="UTC", config=config)
added_ids = [call.kwargs.get("id") or call.args[3] for call in mock_sched.add_job.call_args_list]
# Only compilation should be scheduled
assert any("compilation" in jid for jid in added_ids)
assert not any("morning" in jid for jid in added_ids)
assert not any("midday" in jid for jid in added_ids)
assert not any("afternoon" in jid for jid in added_ids)
def test_add_user_jobs_schedules_all_when_config_missing():
"""_add_user_jobs with no config should schedule all 4 slots (safe default)."""
from fabledassistant.services import briefing_scheduler as bs
mock_sched = _make_scheduler()
bs._scheduler = mock_sched
bs._loop = MagicMock()
bs._add_user_jobs(user_id=1, tz="UTC", config=None)
added_ids = [
call.kwargs.get("id") or call.args[3]
for call in mock_sched.add_job.call_args_list
]
assert any("compilation" in jid for jid in added_ids)
assert any("morning" in jid for jid in added_ids)
assert any("midday" in jid for jid in added_ids)
assert any("afternoon" in jid for jid in added_ids)
@pytest.mark.asyncio
async def test_run_slot_skips_morning_on_non_work_day():
"""_run_slot_for_user should return early for morning slot when today is not a work day."""
from fabledassistant.services import briefing_scheduler as bs
fake_profile = MagicMock()
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
# Patch datetime at module level (it's imported at module level in briefing_scheduler)
# Patch get_setting and get_profile at their source modules (they're lazily imported)
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock) as mock_inject:
mock_now = MagicMock()
mock_now.strftime.return_value = "Sat"
mock_dt.now.return_value = mock_now
await bs._run_slot_for_user(user_id=1, slot="morning")
mock_inject.assert_not_called()
@pytest.mark.asyncio
async def test_run_slot_morning_runs_on_work_day():
"""_run_slot_for_user should proceed for morning slot when today is a work day."""
from fabledassistant.services import briefing_scheduler as bs
fake_profile = MagicMock()
fake_profile.work_schedule = {"days": ["Mon", "Tue", "Wed", "Thu", "Fri"]}
with patch("fabledassistant.services.briefing_scheduler.datetime") as mock_dt, \
patch("fabledassistant.services.user_profile.get_profile", new_callable=AsyncMock, return_value=fake_profile), \
patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="UTC"), \
patch("fabledassistant.services.briefing_conversations.get_or_create_today_conversation", new_callable=AsyncMock) as mock_conv, \
patch("fabledassistant.services.briefing_pipeline.run_slot_injection", new_callable=AsyncMock, return_value=("", {})) as mock_inject, \
patch("fabledassistant.services.briefing_conversations.post_message", new_callable=AsyncMock), \
patch("fabledassistant.services.push.send_push_notification", new_callable=AsyncMock):
mock_now = MagicMock()
mock_now.strftime.return_value = "Mon"
mock_dt.now.return_value = mock_now
mock_conv.return_value = MagicMock(id=1)
await bs._run_slot_for_user(user_id=1, slot="morning")
mock_inject.assert_called_once()
@pytest.mark.asyncio
async def test_settings_put_timezone_updates_scheduler():
"""Saving user_timezone should call update_user_schedule when briefing is enabled."""
import json
from fabledassistant.services.briefing_scheduler import update_user_schedule
enabled_config = json.dumps({"enabled": True, "slots": {}, "locations": {}})
config = json.loads(enabled_config)
with patch("fabledassistant.services.briefing_scheduler._add_user_jobs") as mock_add:
update_user_schedule(1, config, tz_override="America/New_York")
mock_add.assert_called_once()
call_args = mock_add.call_args
assert call_args.args[0] == 1 # user_id
assert call_args.args[2] == config # config passed through
+23
View File
@@ -0,0 +1,23 @@
"""Unit tests for the cosine helper inside journal_search."""
import math
from fabledassistant.services.journal_search import _cosine
def test_cosine_identical_vectors():
v = [1.0, 2.0, 3.0]
assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9)
def test_cosine_orthogonal_vectors():
a = [1.0, 0.0]
b = [0.0, 1.0]
assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9)
def test_cosine_zero_vector_returns_zero():
assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0
def test_cosine_empty_returns_zero():
assert _cosine([], [1.0]) == 0.0
+2 -2
View File
@@ -34,7 +34,7 @@ async def test_lookup_wikipedia_miss_searxng_fallback():
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
patch("fabledassistant.config.Config") as mock_config, \
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Full article about QUIC..."):
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Full article about QUIC..."):
mock_config.searxng_enabled.return_value = True
result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
@@ -59,7 +59,7 @@ async def test_lookup_parallel_both_sources():
with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
patch("fabledassistant.config.Config") as mock_config, \
patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
patch("fabledassistant.services.rss._fetch_full_article", new_callable=AsyncMock, return_value="Current president is..."):
patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Current president is..."):
mock_config.searxng_enabled.return_value = True
result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"})
-108
View File
@@ -1,108 +0,0 @@
"""Tests for news API — retention constant and endpoint formatting."""
import pytest
from unittest.mock import AsyncMock, MagicMock
from datetime import datetime, timezone
def test_rss_item_max_age_is_90_days():
"""Retention window should be 90 days."""
from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS
assert ITEM_MAX_AGE_DAYS == 90
def _make_mock_session(rows):
"""Return a mock async context manager whose session.execute returns rows."""
mock_result = MagicMock()
mock_result.mappings.return_value.all.return_value = rows
mock_session = AsyncMock()
mock_session.execute = AsyncMock(return_value=mock_result)
mock_cm = MagicMock()
mock_cm.__aenter__ = AsyncMock(return_value=mock_session)
mock_cm.__aexit__ = AsyncMock(return_value=False)
return mock_cm
def test_list_news_item_serialisation():
"""News item serialisation should truncate content to 300 chars and include reaction."""
pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc)
item = {
"id": 1,
"title": "EU AI Act deadline",
"url": "https://example.com/ai",
"content": "x" * 500,
"published_at": pub,
"topics": ["tech"],
"feed_title": "TechCrunch",
"reaction": "up",
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == "x" * 300
assert result["source"] == "TechCrunch"
assert result["reaction"] == "up"
assert result["published_at"] == pub.isoformat()
def test_build_briefing_article_context_metadata_extraction():
"""Verify the rss_item_ids extraction logic from message metadata."""
import json
# Simulates the logic in _build_briefing_article_context
def extract_ids(msg_metadata):
meta = msg_metadata or {}
if isinstance(meta, str):
try:
meta = json.loads(meta)
except Exception:
return []
return meta.get("rss_item_ids") or []
assert extract_ids({}) == []
assert extract_ids(None) == []
assert extract_ids({"rss_item_ids": [1, 2, 3]}) == [1, 2, 3]
assert extract_ids(json.dumps({"rss_item_ids": [4, 5]})) == [4, 5]
assert extract_ids("not json") == []
def test_list_news_null_content_serialisation():
"""Null content should produce empty snippet without error."""
item = {
"id": 2,
"title": "No content article",
"url": "https://example.com/b",
"content": None,
"published_at": None,
"topics": None,
"feed_title": "Hacker News",
"reaction": None,
}
result = {
"id": item["id"],
"title": item["title"],
"url": item["url"],
"snippet": (item["content"] or "")[:300],
"published_at": item["published_at"].isoformat() if item["published_at"] else None,
"topics": item["topics"] or [],
"source": item["feed_title"],
"reaction": item["reaction"],
}
assert result["snippet"] == ""
assert result["topics"] == []
assert result["published_at"] is None
assert result["reaction"] is None
-132
View File
@@ -1,132 +0,0 @@
import pytest
from unittest.mock import patch, MagicMock
def test_extract_item_fields():
"""extract_item() should pull title, link, id, summary from a feedparser entry."""
from fabledassistant.services.rss import extract_item
entry = MagicMock()
entry.get = lambda k, d="": {"title": "Test Post", "link": "https://example.com/1",
"id": "guid-1", "summary": "A summary"}.get(k, d)
entry.published_parsed = None
item = extract_item(entry)
assert item["title"] == "Test Post"
assert item["url"] == "https://example.com/1"
assert item["guid"] == "guid-1"
assert item["content"] == "A summary"
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
def test_extract_item_prefers_content_over_summary():
"""extract_item() should prefer 'content' field over 'summary' when present."""
from fabledassistant.services.rss import extract_item
entry = MagicMock()
content_obj = MagicMock()
content_obj.value = "Full content here"
entry.get = lambda k, d="": {
"title": "T", "link": "http://x.com", "id": "g",
"summary": "Short summary",
}.get(k, d)
entry.content = [content_obj]
entry.published_parsed = None
item = extract_item(entry)
assert item["content"] == "Full content here"
@pytest.mark.asyncio
async def test_classify_items_batch_returns_topic_map():
"""classify_items_batch should return a dict mapping item_id to topic list."""
from unittest.mock import AsyncMock
from fabledassistant.services.rss_classifier import classify_items_batch
fake_response = '{"1": ["technology", "ai"], "2": ["politics"]}'
with patch(
"fabledassistant.services.rss_classifier._llm_classify",
new_callable=AsyncMock,
return_value=fake_response,
):
items = [
{"id": 1, "title": "OpenAI releases GPT-5", "content": "..."},
{"id": 2, "title": "EU passes new law", "content": "..."},
]
result = await classify_items_batch(items, user_include_topics=[])
assert result[1] == ["technology", "ai"]
assert result[2] == ["politics"]
@pytest.mark.asyncio
async def test_classify_items_batch_handles_llm_failure():
"""classify_items_batch should return empty dict on LLM error."""
from unittest.mock import AsyncMock
from fabledassistant.services.rss_classifier import classify_items_batch
with patch(
"fabledassistant.services.rss_classifier._llm_classify",
new_callable=AsyncMock,
side_effect=Exception("LLM unavailable"),
):
items = [{"id": 5, "title": "Some news", "content": ""}]
result = await classify_items_batch(items, user_include_topics=[])
assert result == {}
def test_score_rss_items_excludes_blacklisted_topics():
"""Items with excluded topics should be removed."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T08:00:00"},
{"id": 2, "title": "Sports score", "topics": ["sports"], "published_at": "2026-03-25T08:00:00"},
]
result = score_and_filter_items(
items,
include_topics=["technology"],
exclude_topics=["sports"],
topic_scores={},
max_items=10,
)
ids = [r["id"] for r in result]
assert 1 in ids
assert 2 not in ids
def test_score_rss_items_boosts_included_topics():
"""Items matching include_topics should rank higher than neutral items."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "Random news", "topics": ["other"], "published_at": "2026-03-25T07:00:00"},
{"id": 2, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T06:00:00"},
]
result = score_and_filter_items(
items,
include_topics=["technology"],
exclude_topics=[],
topic_scores={},
max_items=10,
)
assert result[0]["id"] == 2
def test_score_rss_items_no_preferences_returns_all():
"""With no preferences, all items should be returned sorted by recency."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "A", "topics": [], "published_at": "2026-03-24T10:00:00"},
{"id": 2, "title": "B", "topics": [], "published_at": "2026-03-25T10:00:00"},
]
result = score_and_filter_items(items, [], [], {}, max_items=10)
assert result[0]["id"] == 2 # Newer first
+6 -6
View File
@@ -26,12 +26,12 @@ async def test_get_user_tz_falls_back_to_utc_on_bad_input():
@pytest.mark.asyncio
async def test_user_briefing_date_before_4am_returns_yesterday():
"""00:0003:59 local still shows yesterday's briefing day."""
async def test_user_day_date_before_4am_returns_yesterday():
"""00:0003:59 local still shows yesterday's day_date."""
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
# 02:00 NY on 2026-04-13 → day_date = 2026-04-12 (still pre-rollover)
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
class _FakeDatetime(datetime):
@@ -41,12 +41,12 @@ async def test_user_briefing_date_before_4am_returns_yesterday():
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
day = await tz_mod.user_day_date(1)
assert day.isoformat() == "2026-04-12"
@pytest.mark.asyncio
async def test_user_briefing_date_after_4am_returns_today():
async def test_user_day_date_after_4am_returns_today():
from fabledassistant.services import tz as tz_mod
ny = ZoneInfo("America/New_York")
@@ -59,5 +59,5 @@ async def test_user_briefing_date_after_4am_returns_today():
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
patch.object(tz_mod, "datetime", _FakeDatetime):
day = await tz_mod.user_briefing_date(1)
day = await tz_mod.user_day_date(1)
assert day.isoformat() == "2026-04-13"