Compare commits

...

32 Commits

Author SHA1 Message Date
bvandeusen e407043713 Merge pull request 'feat: API key auth, fable-mcp package, and semantic search endpoint' (#8) from dev into main
feat: API key auth, fable-mcp package, and semantic search endpoint
2026-03-24 03:05:16 +00:00
bvandeusen 00dee5ea0e ci: remove redundant pull_request trigger
All code goes through dev first where CI runs on push. PR runs
(dev→main) were duplicating already-validated checks and causing
queue contention. Build job was already gated to push-only events.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 23:05:10 -04:00
bvandeusen bf3bf99410 Merge pull request 'dev to main: improvements and bug fixes' (#4) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#4
2026-03-12 22:06:01 -04:00
bvandeusen 9cb3700a5c fix: add temp_unit and timezone to inline BriefingConfig literals
BriefingSetupWizard and SettingsView had hardcoded initial objects that
predated the new fields, causing TS2345 type errors.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:33:17 -04:00
bvandeusen b44d8496bc fix: queue drain survives navigation away from a streaming conversation
Previously the queue drain guard (currentConversation.id === convId) meant
that if the user navigated to ChatView while a workspace stream was running,
the workspace's queued messages were silently abandoned — they sat in
localStorage indefinitely with no code path to resume them.

- Extract _tryDrainQueue(convId) with the same guard logic
- Call it at stream-end (replaces the inline block)
- Call it in fetchConversation after _loadQueue, so returning to a
  conversation with orphaned queue messages drains them automatically
- Order is now preserved: messages drain in the order they were queued,
  even across navigation events

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:08:45 -04:00
bvandeusen 3eb61950c9 fix: auto-scroll workspace chat when messages are added
Watch currentConversation.messages.length so the chat panel scrolls to
the bottom when user messages are appended or assistant messages land,
not only while streaming content is updating.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 18:02:28 -04:00
bvandeusen beb57876fb feat: update_milestone tool and workspace milestone refresh
- Add update_milestone LLM tool: accepts project + milestone title to
  look up, then applies any of title/description/status changes
- WorkspaceView SSE watcher now triggers taskPanelRef.reload() on
  create_milestone and update_milestone success, so milestone groups
  appear immediately without a manual refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 17:58:13 -04:00
bvandeusen 5ea3bb5aff feat: per-user timezone-aware briefing scheduler
Briefing slots (4am/8am/12pm/4pm) now fire in each user's local timezone
rather than UTC, so the schedule matches the user's actual day.

Backend:
- briefing_scheduler: replaced 4 global UTC cron jobs with per-user
  CronTrigger jobs keyed to the user's IANA timezone; update_user_schedule()
  live-patches the scheduler when config is saved (no restart needed)
- Catchup logic evaluates missed slots in the user's local timezone
- put_config route calls update_user_schedule() after saving

Frontend:
- New Timezone section in Briefing settings: text input pre-filled from
  browser (Intl.DateTimeFormat) with a Detect button for re-detection
- Slot times shown as fixed local times (4:00 am etc.) with the configured
  timezone displayed beneath, replacing the old UTC-conversion display
- timezone field added to BriefingConfig type and default

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:34:31 -04:00
bvandeusen 6e57ce4555 feat: temperature unit preference and slot timezone display for briefing
- Add °C/°F toggle in briefing settings; persisted in briefing_config.temp_unit
- briefing_pipeline reads temp_unit and converts Open-Meteo Celsius values
  before passing them to the LLM (both full compilation and slot injection)
- Scheduled Slots section now shows each UTC slot time converted to the
  user's browser local time, plus a line confirming which timezone the
  browser is using

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-12 08:29:40 -04:00
bvandeusen 44119fb957 Merge pull request 'v26.03.12.1' (#3) from dev into main
Reviewed-on: bvandeusen/FabledAssistant#3
2026-03-12 06:45:45 -04:00
bvandeusen d2605287f7 fix: briefing pipeline — pass user_id to is_caldav_configured, use p.title not p.get() 2026-03-12 06:43:46 -04:00
bvandeusen 97d62a6a32 docs: update summary.md — Daily Briefing, CI release process, CalVer, dedup 2026-03-12 00:08:39 -04:00
47 changed files with 4812 additions and 176 deletions
+3 -10
View File
@@ -2,7 +2,7 @@
#
# Push to dev: typecheck + lint + test → build :dev + :<sha>
# Tag v* (release): typecheck + lint + test → build :latest + :<sha> + :<version>
# Pull request: typecheck + lint + test only (no build)
# Pull request: no CI run — code is validated on dev push before any PR is opened
#
# To cut a release:
# Create a release via the Forgejo UI on main with a v* tag name.
@@ -27,13 +27,8 @@ on:
- "Dockerfile"
- "assets/**"
- ".forgejo/workflows/ci.yml"
pull_request:
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
- ".forgejo/workflows/ci.yml"
# pull_request trigger intentionally omitted — all changes go through dev
# first, where CI already runs on push. PR runs would be redundant duplication.
env:
REGISTRY: git.fabledsword.com
@@ -142,5 +137,3 @@ jobs:
push: true
tags: ${{ steps.tags.outputs.value }}
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max
+34
View File
@@ -0,0 +1,34 @@
"""add api_keys table
Revision ID: 0027
Revises: 0026
Create Date: 2026-03-23
"""
from alembic import op
import sqlalchemy as sa
revision = "0027"
down_revision = "0026"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"api_keys",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
sa.Column("key_prefix", sa.Text(), nullable=False),
sa.Column("scope", sa.Text(), nullable=False),
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
def downgrade() -> None:
op.drop_table("api_keys")
+271
View File
@@ -0,0 +1,271 @@
# Fable MCP — Design Spec
**Date:** 2026-03-23
**Status:** Approved
**Author:** bvandeusen + Claude
---
## Overview
A Python MCP (Model Context Protocol) server that lets Claude directly interface with a running Fabled Assistant instance. The goal is two-fold: Claude's stronger reasoning handles planning, documentation, and analysis while Fable remains the authoritative store for notes, tasks, projects, and milestones; and direct API access enables process improvement by letting Claude observe and operate on real data rather than working from descriptions.
The work is split into two sub-projects:
1. **Fable API Key Feature** — additions to the main `fabledassistant` project to support bearer token authentication
2. **Fable MCP Server** — a new standalone Python package at `fable-mcp/` in the same repo root
A third sub-project (Forgejo MCP for CI/CD automation) is planned as a follow-on after the Fable MCP is working.
---
## Sub-project 1: Fable API Key Feature
### Motivation
All existing Fable routes use session-based authentication (`session["user_id"]`). The MCP server runs as a local process and cannot maintain a browser session, so a stateless bearer token mechanism is required. API keys are user-scoped and carry a read/write permission level.
### Database
New table `api_keys` via migration `0027_add_api_keys.py`:
- `down_revision = "0026"` (the briefing tables migration)
- `revision = "0027"`
| Column | Type | Notes |
|--------|------|-------|
| `id` | Integer PK | |
| `user_id` | Integer FK → users | CASCADE delete |
| `name` | Text | Human-readable label |
| `key_hash` | Text | SHA-256 of the full key, used for lookup |
| `key_prefix` | Text | First 8 chars (e.g. `fmcp_ab12`), shown in UI |
| `scope` | Text | `"read"` or `"write"` |
| `last_used_at` | Timestamp | Nullable, updated on each authenticated request |
| `created_at` | Timestamp | |
| `revoked_at` | Timestamp | Nullable — soft delete |
Full keys are generated as `fmcp_<32 random url-safe chars>`, returned once at creation, never stored. Only the SHA-256 hash is persisted.
### Auth Middleware (`auth.py`)
`_check_auth` is updated to check the `Authorization: Bearer <token>` header before falling back to session auth:
1. If header present: hash the token, look up in `api_keys` where `revoked_at IS NULL`
2. If found: update `last_used_at`, set `g.user` and `g.api_key`
3. Scope enforcement: if `g.api_key.scope == "read"` and `request.method` is not `GET`, return 403
4. If header absent: existing session logic runs unchanged
Session auth is untouched — no regression risk for the web UI.
**Admin routes and API keys:** Routes decorated with `admin_required` check `user.role == "admin"` after auth resolves. API keys authenticate as the key owner — so a write-scoped key for a non-admin user will fail admin-protected routes with 403 (role check, not scope check). This is intentional: API keys cannot elevate privilege beyond the user's role.
### Routes (`/api/api-keys`)
New blueprint `api_keys_bp`, registered in `app.py`:
- `GET /api/api-keys` — list caller's keys (prefix, name, scope, last_used_at, created_at — never the hash or full key)
- `POST /api/api-keys` — create key; body: `{name, scope}`. Returns full key in response **once only**
- `DELETE /api/api-keys/:id` — revoke by setting `revoked_at = now()`
### Settings UI
New "API Keys" tab in `SettingsView.vue` (added to `VALID_TABS`):
- Create form: name input + read/write radio/toggle + "Generate Key" button
- After creation: one-time modal displaying the full key with a copy button and a warning that it will not be shown again
- Keys table: columns for name, scope badge, prefix, last used, revoke button
- Revoke shows inline confirmation before calling DELETE
### New Search Endpoint
`GET /api/search?q=<query>&content_type=note|task|all&limit=N`
Calls the existing `semantic_search_notes()` service (already in `services/embeddings.py`). The `content_type` parameter maps to the service's `is_task` argument:
- `content_type=note``is_task=False`
- `content_type=task``is_task=True`
- `content_type=all` (default) → `is_task=None`
Returns:
```json
{
"results": [
{"id": 1, "title": "...", "body": "...", "is_task": false, "similarity": 0.87, "tags": [...]}
],
"total": 5
}
```
This endpoint is needed by the MCP's `search.py` tool module. It reuses existing infrastructure with no new ML work.
### Conversation Type: `"mcp"`
A new conversation type `"mcp"` is added alongside `"chat"` and `"briefing"`. This requires changes in two places in the main app:
**`services/chat.py`** — `create_conversation(user_id, title, model)` gains an optional `conversation_type: str = "chat"` parameter, passed through to the `Conversation` constructor.
**`routes/chat.py`** — `POST /api/chat/conversations` accepts an optional `conversation_type` body field (defaults to `"chat"`), passed to `create_conversation`. `GET /api/chat/conversations` continues to default-filter to `conversation_type="chat"` (excluding `"mcp"` and `"briefing"`); pass `?type=mcp` to retrieve MCP conversations.
**Retention:** `cleanup_old_conversations` in `routes/chat.py` currently deletes conversations regardless of type. It must be updated to exclude `conversation_type="mcp"` from the sweep, so MCP audit-trail conversations are not automatically pruned.
MCP conversations:
- Are created with a caller-supplied name (e.g., `"MCP Session 2026-03-23"`) or auto-named
- Are excluded from the default chat list
- Are accessible via `GET /api/chat/conversations?type=mcp` for inspection
- Appear in the Fable UI if explicitly navigated to, providing an audit trail of MCP-driven interactions
### Chat SSE Wire Format
The MCP's `chat.py` tool must consume the existing SSE stream. The relevant endpoints and event schema:
- **Create conversation:** `POST /api/chat/conversations``{id, title, ...}`
- **Post message + start generation:** `POST /api/chat/conversations/:id/messages``{message_id, ...}`; then connect to:
- **Stream:** `GET /api/chat/conversations/:id/stream` (SSE)
SSE event format (each line is `data: <json>`):
- `{"type": "token", "content": "..."}` — streaming token
- `{"type": "tool_call", "name": "...", "result": {...}}` — tool fired
- `{"type": "done", "content": "...", "tools_used": [...]}` — generation complete; this is the termination event
The MCP tool reads tokens until it receives `type: "done"`, then returns `response` (full content) and `tools_used` (list of tool names).
---
## Sub-project 2: Fable MCP Server
### Location
`fable-mcp/` at the repository root, alongside `src/`, `frontend/`, `alembic/`. It is **not** part of the main Docker build and has no import relationship with `fabledassistant`. It will be extracted to its own Forgejo repo once stable.
### Package Structure
```
fable-mcp/
pyproject.toml # entry point: fable-mcp = "fable_mcp.server:main"
README.md
.env.example # FABLE_URL=http://localhost:8080, FABLE_API_KEY=fmcp_...
fable_mcp/
__init__.py
server.py # FastMCP instance, imports + registers all tool modules
client.py # FableClient: async httpx wrapper, env var config, FableAPIError
tools/
__init__.py
notes.py # list_notes, get_note, create_note, update_note, delete_note
tasks.py # list_tasks, get_task, create_task, update_task, delete_task,
# patch_task_status, add_task_log
projects.py # list_projects, get_project, create_project, update_project,
# delete_project, get_project_summary
milestones.py # list_milestones, get_milestone, create_milestone,
# update_milestone, delete_milestone
search.py # semantic_search — calls GET /api/search
chat.py # send_message — creates/continues conversation, returns response
```
### Dependencies
```toml
[project]
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
```
### `client.py`
`FableClient` is an async context manager wrapping `httpx.AsyncClient`. It reads `FABLE_URL` and `FABLE_API_KEY` from environment variables (with `python-dotenv` fallback to `.env`). All methods are `async def` and return parsed dicts. A `FableAPIError(status_code, message)` exception is raised for any non-2xx response.
A module-level singleton `_client: FableClient` is initialized at server startup and shared across all tool modules.
### `server.py`
Uses `mcp[cli]`'s `FastMCP` class. Imports all tool modules, which register their tools via decorators against the shared `FastMCP` instance. Entry point `main()` calls `mcp.run()` (stdio transport).
### Tool Modules
Each module imports `_client` from `client.py` and defines tools as `async def` functions decorated with `@mcp.tool()`. Tool docstrings serve as the MCP tool descriptions visible to Claude.
**`notes.py`** tools:
- `list_notes(query, tags, project_id, limit, offset)`
- `get_note(note_id)`
- `create_note(title, body, tags, project_id)`
- `update_note(note_id, title, body, tags, project_id)`
- `delete_note(note_id)`
**`tasks.py`** tools:
- `list_tasks(query, project_id, milestone_id, status, priority, limit, offset)`
- `get_task(task_id)`
- `create_task(title, body, project_id, milestone_id, priority, status)`
- `update_task(task_id, title, body, project_id, milestone_id, priority, status)`
- `delete_task(task_id)`
- `patch_task_status(task_id, status)`
- `add_task_log(task_id, content)`
**`projects.py`** tools:
- `list_projects()`
- `get_project(project_id)` — includes milestone summary
- `create_project(title, description, goal, color)`
- `update_project(project_id, title, description, goal, status, color)`
- `delete_project(project_id)`
**`milestones.py`** tools:
- `list_milestones(project_id, status)`
- `get_milestone(project_id, milestone_id)`
- `create_milestone(project_id, title, description, order_index)`
- `update_milestone(project_id, milestone_id, title, description, status, order_index)`
- `delete_milestone(project_id, milestone_id)`
**`search.py`** tools:
- `semantic_search(query, content_type, limit)``content_type` is `"note"`, `"task"`, or `"all"` (avoids shadowing Python's `type` builtin). Calls `GET /api/search`, returns ranked results with similarity scores.
**`chat.py`** tools:
- `send_message(message, conversation_name)`:
- Looks up or creates a Fable conversation with the given name and `conversation_type="mcp"`
- Posts the message via `POST /api/chat/conversations/:id/messages`
- Consumes SSE stream from `GET /api/chat/conversations/:id/stream` until `type: "done"`
- Returns: `{response: str, tools_used: [str], conversation_id: int}`
- MCP conversations are excluded from the normal chat UI list
### Error Handling
`FableAPIError` is caught at each tool boundary and returned as a descriptive string rather than propagating as an exception. This ensures Claude receives a readable error message (e.g., `"Fable API error 403: Read-only key cannot perform write operations."`) rather than a stack trace. Network errors (`httpx.RequestError`) are similarly caught and surfaced as strings.
Scope enforcement lives entirely in Fable's auth middleware — the MCP does not duplicate it.
### Claude Code Registration
After `pip install -e fable-mcp/` (or `uv tool install ./fable-mcp`), add to `~/.claude/settings.json`:
```json
{
"mcpServers": {
"fable": {
"command": "fable-mcp",
"env": {
"FABLE_URL": "http://localhost:8080",
"FABLE_API_KEY": "fmcp_your_key_here"
}
}
}
}
```
Claude Code spawns the process over stdio automatically. No Docker, no daemon.
---
## Build & Repo Plan
1. Implement and test within `fabledassistant/fable-mcp/`
2. Once stable, extract to a new Forgejo repo (`bvandeusen/fable-mcp`)
3. Forgejo MCP (Gitea MCP) added as a second MCP server to automate build/push/config workflows — separate spec when ready
---
## Out of Scope (v1)
- MCP resources (browsable URI tree) — tools cover all use cases in Claude Code
- Forgejo MCP — follow-on spec
- Rate limiting on API key endpoints
- Key expiry / TTL
- Per-resource scope (e.g., key restricted to one project)
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
View File
+124
View File
@@ -0,0 +1,124 @@
"""Async HTTP client for the Fable Assistant API."""
from __future__ import annotations
import os
from typing import Any, AsyncIterator
import httpx
class FableAPIError(Exception):
"""Raised when the Fable API returns a non-2xx response."""
def __init__(self, status_code: int, message: str) -> None:
self.status_code = status_code
super().__init__(f"Fable API error {status_code}: {message}")
class FableClient:
"""Async wrapper around httpx for the Fable REST API."""
def __init__(self) -> None:
url = os.environ.get("FABLE_URL", "").rstrip("/")
key = os.environ.get("FABLE_API_KEY", "")
if not url:
raise ValueError("FABLE_URL environment variable is required")
if not key:
raise ValueError("FABLE_API_KEY environment variable is required")
self.base_url = url
self._headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "FableClient":
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=30.0,
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("FableClient must be used as an async context manager")
return self._client
@staticmethod
def _raise_for_status(response: httpx.Response) -> None:
if response.is_error:
try:
message = response.json().get("error", response.text)
except Exception:
message = response.text
raise FableAPIError(response.status_code, message)
async def get(self, path: str, **kwargs: Any) -> Any:
response = await self._http().get(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def post(self, path: str, **kwargs: Any) -> Any:
response = await self._http().post(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def patch(self, path: str, **kwargs: Any) -> Any:
response = await self._http().patch(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def put(self, path: str, **kwargs: Any) -> Any:
response = await self._http().put(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def delete(self, path: str, **kwargs: Any) -> Any:
response = await self._http().delete(path, **kwargs)
if response.status_code == 204:
return None
self._raise_for_status(response)
if response.content:
return response.json()
return None
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_client: FableClient | None = None
def init_client() -> FableClient:
"""Initialise the module-level FableClient singleton (reads env vars)."""
global _client
_client = FableClient()
return _client
def get_client() -> FableClient:
"""Return the singleton client; raises RuntimeError if not initialised."""
if _client is None:
raise RuntimeError("Call init_client() before get_client()")
return _client
def _reset_client() -> None:
"""Reset the singleton — used by tests only."""
global _client
_client = None
+356
View File
@@ -0,0 +1,356 @@
"""Fable MCP server — exposes Fable Assistant as MCP tools via stdio transport."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
from fable_mcp.client import FableClient
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat
load_dotenv()
mcp = FastMCP("fable")
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes stored in Fable. Optionally filter by tag or search text."""
async with FableClient() as client:
return await notes.list_notes(
client,
limit=limit,
offset=offset,
tag=tag or None,
search=search_text or None,
)
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID."""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@mcp.tool()
async def fable_create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable."""
async with FableClient() as client:
return await notes.create_note(
client,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only provided fields are changed."""
async with FableClient() as client:
return await notes.update_note(
client,
note_id=note_id,
title=title or None,
body=body or None,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Delete a Fable note by ID."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable. Filter by status (todo/in_progress/done) or project."""
async with FableClient() as client:
return await tasks.list_tasks(
client,
limit=limit,
offset=offset,
status=status or None,
project_id=project_id or None,
)
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID, including parent task title."""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@mcp.tool()
async def fable_create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable."""
async with FableClient() as client:
return await tasks.create_task(
client,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
)
@mcp.tool()
async def fable_update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only provided fields are changed."""
async with FableClient() as client:
return await tasks.update_task(
client,
task_id=task_id,
title=title or None,
body=body or None,
status=status or None,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
)
@mcp.tool()
async def fable_add_task_log(task_id: int, body: str) -> dict:
"""Append a progress log entry to a Fable task."""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, body=body)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user."""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including milestone summary."""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@mcp.tool()
async def fable_create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable."""
async with FableClient() as client:
return await projects.create_project(
client,
title=title,
description=description,
goal=goal or None,
status=status,
color=color or None,
)
@mcp.tool()
async def fable_update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project."""
async with FableClient() as client:
return await projects.update_project(
client,
project_id=project_id,
title=title or None,
description=description or None,
goal=goal or None,
status=status or None,
color=color or None,
)
# ---------------------------------------------------------------------------
# Milestones
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project."""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@mcp.tool()
async def fable_create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project."""
async with FableClient() as client:
return await milestones.create_milestone(
client,
project_id=project_id,
title=title,
description=description,
status=status,
)
@mcp.tool()
async def fable_update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone."""
async with FableClient() as client:
return await milestones.update_milestone(
client,
project_id=project_id,
milestone_id=milestone_id,
title=title or None,
description=description or None,
status=status or None,
order_index=order_index if order_index >= 0 else None,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_search(
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks.
content_type: "note", "task", or "all" (default).
Returns results ranked by similarity with id, title, body snippet, tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List MCP chat conversations stored in Fable."""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@mcp.tool()
async def fable_send_message(
message: str,
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a message to Fable's LLM and receive the full response.
Fable handles tool use, RAG, and history internally.
Pass conversation_id to continue an existing conversation.
Returns conversation_id, response text, and any tool_call events.
"""
async with FableClient() as client:
return await chat.send_message(
client,
message=message,
conversation_id=conversation_id or None,
think=think,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
# Validate env vars at startup — raises ValueError with a clear message if missing.
FableClient()
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
+75
View File
@@ -0,0 +1,75 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import json
from typing import Any
from fable_mcp.client import FableClient
async def list_conversations(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
"""List MCP chat conversations."""
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
return await client.get("/api/chat/conversations", params=params)
async def send_message(
client: FableClient,
*,
message: str,
conversation_id: str | None = None,
think: bool = False,
) -> dict[str, Any]:
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Streams the SSE response and accumulates token chunks into a single string.
Returns:
Dict with:
- "conversation_id": str
- "response": str (full assistant message)
- "tool_calls": list of any tool_call events observed
"""
if conversation_id is None:
conv = await client.post(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = conv["id"]
# Build SSE stream URL with message as query param
params: dict[str, Any] = {"message": message}
if think:
params["think"] = "true"
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/stream"
async for raw_line in client.stream_get(stream_path, params=params):
if not raw_line.startswith("data: "):
continue
payload_str = raw_line[len("data: "):]
try:
event = json.loads(payload_str)
except json.JSONDecodeError:
continue
event_type = event.get("type")
if event_type == "token":
tokens.append(event.get("content", ""))
elif event_type == "tool_call":
tool_calls.append(event)
elif event_type == "done":
break
return {
"conversation_id": conversation_id,
"response": "".join(tokens),
"tool_calls": tool_calls,
}
+53
View File
@@ -0,0 +1,53 @@
"""MCP tools for Fable milestones."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""List milestones for a project."""
return await client.get(f"/api/projects/{project_id}/milestones")
async def create_milestone(
client: FableClient,
*,
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict[str, Any]:
"""Create a milestone within a project."""
payload: dict[str, Any] = {
"title": title,
"description": description,
"status": status,
}
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
async def update_milestone(
client: FableClient,
*,
project_id: int,
milestone_id: int,
title: str | None = None,
description: str | None = None,
status: str | None = None,
order_index: int | None = None,
) -> dict[str, Any]:
"""Update an existing milestone."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if order_index is not None:
payload["order_index"] = order_index
return await client.patch(
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
)
+72
View File
@@ -0,0 +1,72 @@
"""MCP tools for Fable notes."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_notes(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
tag: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List notes with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag:
params["tag"] = tag
if search:
params["search"] = search
return await client.get("/api/notes", params=params)
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
"""Fetch a single note by ID."""
return await client.get(f"/api/notes/{note_id}")
async def create_note(
client: FableClient,
*,
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Create a new note."""
payload: dict[str, Any] = {"title": title, "body": body}
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.post("/api/notes", json=payload)
async def update_note(
client: FableClient,
*,
note_id: int,
title: str | None = None,
body: str | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing note."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.patch(f"/api/notes/{note_id}", json=payload)
async def delete_note(client: FableClient, *, note_id: int) -> None:
"""Delete a note by ID."""
await client.delete(f"/api/notes/{note_id}")
+59
View File
@@ -0,0 +1,59 @@
"""MCP tools for Fable projects."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_projects(client: FableClient) -> dict[str, Any]:
"""List all projects for the authenticated user."""
return await client.get("/api/projects")
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""Fetch a project by ID (includes milestone summary)."""
return await client.get(f"/api/projects/{project_id}")
async def create_project(
client: FableClient,
*,
title: str,
description: str = "",
goal: str | None = None,
status: str = "active",
color: str | None = None,
) -> dict[str, Any]:
"""Create a new project."""
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
if goal is not None:
payload["goal"] = goal
if color is not None:
payload["color"] = color
return await client.post("/api/projects", json=payload)
async def update_project(
client: FableClient,
*,
project_id: int,
title: str | None = None,
description: str | None = None,
goal: str | None = None,
status: str | None = None,
color: str | None = None,
) -> dict[str, Any]:
"""Update an existing project."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if goal is not None:
payload["goal"] = goal
if status is not None:
payload["status"] = status
if color is not None:
payload["color"] = color
return await client.patch(f"/api/projects/{project_id}", json=payload)
+30
View File
@@ -0,0 +1,30 @@
"""MCP tool for semantic search across Fable notes and tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def search(
client: FableClient,
*,
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict[str, Any]:
"""Semantic search over notes and/or tasks.
Args:
q: Search query string.
content_type: One of "note", "task", or "all" (default).
limit: Maximum number of results to return.
Returns:
Dict with "results" list and "total" count.
Each result has: id, title, body, is_task, tags, similarity.
"""
params: dict[str, Any] = {"q": q, "limit": limit}
if content_type != "all":
params["content_type"] = content_type
return await client.get("/api/search", params=params)
+93
View File
@@ -0,0 +1,93 @@
"""MCP tools for Fable tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_tasks(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
status: str | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""List tasks with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if project_id is not None:
params["project_id"] = project_id
return await client.get("/api/tasks", params=params)
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
"""Fetch a single task by ID (includes parent_title)."""
return await client.get(f"/api/tasks/{task_id}")
async def create_task(
client: FableClient,
*,
title: str,
body: str = "",
status: str = "todo",
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
parent_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new task."""
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
if parent_id is not None:
payload["parent_id"] = parent_id
if tags is not None:
payload["tags"] = tags
return await client.post("/api/tasks", json=payload)
async def update_task(
client: FableClient,
*,
task_id: int,
title: str | None = None,
body: str | None = None,
status: str | None = None,
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing task."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if status is not None:
payload["status"] = status
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
return await client.patch(f"/api/tasks/{task_id}", json=payload)
async def add_task_log(
client: FableClient,
*,
task_id: int,
body: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"body": body})
+24
View File
@@ -0,0 +1,24 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.1.0"
description = "MCP server for Fabled Assistant"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
[project.scripts]
fable-mcp = "fable_mcp.server:main"
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
+12
View File
@@ -0,0 +1,12 @@
"""Shared pytest fixtures for fable-mcp tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_client():
"""A mock FableClient that returns empty responses by default."""
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
return client
+142
View File
@@ -0,0 +1,142 @@
"""Tests for FableClient."""
import os
import pytest
import respx
import httpx
from unittest.mock import patch
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
@pytest.fixture(autouse=True)
def reset_singleton():
"""Reset the module-level client singleton between tests."""
_reset_client()
yield
_reset_client()
@pytest.fixture
def base_env(monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
class TestFableClientInit:
def test_missing_fable_url_raises(self, monkeypatch):
monkeypatch.delenv("FABLE_URL", raising=False)
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
with pytest.raises(ValueError, match="FABLE_URL"):
FableClient()
def test_missing_api_key_raises(self, monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.delenv("FABLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="FABLE_API_KEY"):
FableClient()
def test_trailing_slash_stripped(self, base_env):
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
client = FableClient()
assert client.base_url == "http://fable.test"
def test_auth_header_set(self, base_env):
client = FableClient()
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
class TestFableClientHTTP:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(200, json={"notes": []})
)
async with FableClient() as client:
data = await client.get("/api/notes")
assert data == {"notes": []}
@respx.mock
@pytest.mark.asyncio
async def test_non_2xx_raises_fable_api_error(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert exc_info.value.status_code == 401
@respx.mock
@pytest.mark.asyncio
async def test_post_sends_json(self, base_env):
respx.post("http://fable.test/api/notes").mock(
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
)
async with FableClient() as client:
data = await client.post("/api/notes", json={"title": "Test"})
assert data["id"] == 1
@respx.mock
@pytest.mark.asyncio
async def test_patch_sends_json(self, base_env):
respx.patch("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
)
async with FableClient() as client:
data = await client.patch("/api/notes/1", json={"title": "Updated"})
assert data["title"] == "Updated"
@respx.mock
@pytest.mark.asyncio
async def test_delete_returns_none_on_204(self, base_env):
respx.delete("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(204)
)
async with FableClient() as client:
result = await client.delete("/api/notes/1")
assert result is None
@respx.mock
@pytest.mark.asyncio
async def test_fable_api_error_includes_message(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(404, json={"error": "Not found"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert "Not found" in str(exc_info.value)
assert exc_info.value.status_code == 404
class TestSingleton:
def test_get_client_before_init_raises(self):
with pytest.raises(RuntimeError, match="init_client"):
get_client()
def test_init_client_returns_instance(self, base_env):
client = init_client()
assert isinstance(client, FableClient)
def test_get_client_after_init_returns_same(self, base_env):
init_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
class TestStreamGet:
@respx.mock
@pytest.mark.asyncio
async def test_stream_get_yields_lines(self, base_env):
sse_body = b"data: line1\n\ndata: line2\n\n"
respx.get("http://fable.test/api/stream").mock(
return_value=httpx.Response(200, content=sse_body)
)
lines = []
async with FableClient() as client:
async for line in client.stream_get("/api/stream"):
lines.append(line)
assert "data: line1" in lines
assert "data: line2" in lines
+58
View File
@@ -0,0 +1,58 @@
"""Tests for chat MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_send_message_creates_conversation_and_streams(client):
from fable_mcp.tools.chat import send_message
client.post = AsyncMock(return_value={"id": "conv-1"})
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Hello"}',
'data: {"type": "token", "content": " world"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id=None)
assert result["conversation_id"] == "conv-1"
assert "Hello world" in result["response"]
@pytest.mark.asyncio
async def test_send_message_reuses_existing_conversation(client):
from fable_mcp.tools.chat import send_message
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Reply"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id="existing-conv")
# Should not call post to create a conversation
client.post.assert_not_called()
assert result["conversation_id"] == "existing-conv"
@pytest.mark.asyncio
async def test_list_conversations_calls_get(client):
from fable_mcp.tools.chat import list_conversations
client.get = AsyncMock(return_value={"conversations": []})
await list_conversations(client)
client.get.assert_called_once()
assert "/api/chat/conversations" in client.get.call_args[0][0]
+35
View File
@@ -0,0 +1,35 @@
"""Tests for milestones MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_milestones_calls_correct_endpoint(client):
from fable_mcp.tools.milestones import list_milestones
client.get = AsyncMock(return_value={"milestones": []})
await list_milestones(client, project_id=3)
client.get.assert_called_once_with("/api/projects/3/milestones")
@pytest.mark.asyncio
async def test_create_milestone_posts_to_project_endpoint(client):
from fable_mcp.tools.milestones import create_milestone
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
await create_milestone(client, project_id=3, title="M1")
path = client.post.call_args[0][0]
assert path == "/api/projects/3/milestones"
assert client.post.call_args[1]["json"]["title"] == "M1"
@pytest.mark.asyncio
async def test_update_milestone_patches(client):
from fable_mcp.tools.milestones import update_milestone
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
path = client.patch.call_args[0][0]
assert "/api/projects/3/milestones/10" in path
+56
View File
@@ -0,0 +1,56 @@
"""Tests for notes MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_notes_calls_get(client):
from fable_mcp.tools.notes import list_notes
client.get = AsyncMock(return_value={"notes": [], "total": 0})
result = await list_notes(client, limit=10)
client.get.assert_called_once()
call_args = client.get.call_args
assert "/api/notes" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_note_calls_correct_endpoint(client):
from fable_mcp.tools.notes import get_note
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
result = await get_note(client, note_id=42)
client.get.assert_called_once_with("/api/notes/42")
assert result["id"] == 42
@pytest.mark.asyncio
async def test_create_note_posts_payload(client):
from fable_mcp.tools.notes import create_note
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
result = await create_note(client, title="My Note", body="Content")
client.post.assert_called_once()
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "My Note"
assert call_kwargs["json"]["body"] == "Content"
@pytest.mark.asyncio
async def test_update_note_patches_payload(client):
from fable_mcp.tools.notes import update_note
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
result = await update_note(client, note_id=1, title="Updated")
client.patch.assert_called_once()
path = client.patch.call_args[0][0]
assert "/api/notes/1" in path
@pytest.mark.asyncio
async def test_delete_note_calls_delete(client):
from fable_mcp.tools.notes import delete_note
client.delete = AsyncMock(return_value=None)
await delete_note(client, note_id=5)
client.delete.assert_called_once_with("/api/notes/5")
+42
View File
@@ -0,0 +1,42 @@
"""Tests for projects MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_projects_calls_get(client):
from fable_mcp.tools.projects import list_projects
client.get = AsyncMock(return_value={"projects": []})
await list_projects(client)
client.get.assert_called_once_with("/api/projects")
@pytest.mark.asyncio
async def test_get_project_calls_correct_endpoint(client):
from fable_mcp.tools.projects import get_project
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
result = await get_project(client, project_id=2)
client.get.assert_called_once_with("/api/projects/2")
@pytest.mark.asyncio
async def test_create_project_posts_payload(client):
from fable_mcp.tools.projects import create_project
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
await create_project(client, title="New", description="Desc")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New"
assert call_kwargs["json"]["description"] == "Desc"
@pytest.mark.asyncio
async def test_update_project_patches(client):
from fable_mcp.tools.projects import update_project
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
await update_project(client, project_id=4, status="active")
assert "/api/projects/4" in client.patch.call_args[0][0]
+54
View File
@@ -0,0 +1,54 @@
"""Tests for tasks MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_tasks_calls_get(client):
from fable_mcp.tools.tasks import list_tasks
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
await list_tasks(client)
client.get.assert_called_once()
assert "/api/tasks" in client.get.call_args[0][0]
@pytest.mark.asyncio
async def test_get_task_calls_correct_endpoint(client):
from fable_mcp.tools.tasks import get_task
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
result = await get_task(client, task_id=7)
client.get.assert_called_once_with("/api/tasks/7")
@pytest.mark.asyncio
async def test_create_task_posts_payload(client):
from fable_mcp.tools.tasks import create_task
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
await create_task(client, title="New task")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New task"
@pytest.mark.asyncio
async def test_update_task_patches(client):
from fable_mcp.tools.tasks import update_task
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
await update_task(client, task_id=3, status="done")
client.patch.assert_called_once()
assert "/api/tasks/3" in client.patch.call_args[0][0]
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "body": "progress"})
await add_task_log(client, task_id=9, body="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["body"] == "progress"
+4
View File
@@ -328,6 +328,8 @@ export interface BriefingConfig {
work_days: number[];
slots: BriefingSlots;
notifications: boolean;
temp_unit: 'C' | 'F';
timezone: string;
}
export interface BriefingFeed {
@@ -360,6 +362,8 @@ const DEFAULT_BRIEFING_CONFIG: BriefingConfig = {
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
};
export async function getBriefingConfig(): Promise<BriefingConfig> {
@@ -19,6 +19,8 @@ const config = reactive<BriefingConfig>({
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
})
// Step 2 — locations
+28 -17
View File
@@ -109,6 +109,28 @@ export const useChatStore = defineStore("chat", () => {
}
}
// Drain the next queued message for a conversation, if conditions are met.
// Called both at stream-end and after fetchConversation, so orphaned queue
// messages (e.g. from a navigation away mid-stream) are picked up on return.
function _tryDrainQueue(convId: number) {
const queue = convQueues.value[convId];
if (!queue?.length) return;
if (isStreamingConv(convId)) return; // stream-end will drain naturally
if (currentConversation.value?.id !== convId) return; // not our conversation
const next = queue.shift()!;
_saveQueue(convId);
setTimeout(() => sendMessage(
next.content,
next.contextNoteId,
next.includeNoteIds,
next.think,
next.contextNoteTitle,
next.excludeNoteIds,
next.ragProjectId,
next.workspaceProjectId,
), 0);
}
function clearQueue() {
const id = currentConversation.value?.id;
if (id) {
@@ -158,6 +180,9 @@ export const useChatStore = defineStore("chat", () => {
`/api/chat/conversations/${id}`
);
_loadQueue(id);
// Drain any messages that were queued but never sent because the user
// navigated away before the previous stream finished.
_tryDrainQueue(id);
} catch (e) {
useToastStore().show("Failed to load conversation", "error");
throw e;
@@ -431,23 +456,9 @@ export const useChatStore = defineStore("chat", () => {
s.pendingTool = null;
}
// Process next queued message, if any.
// Use setTimeout so this frame resolves before the next send begins.
const queue = convQueues.value[convId];
if (queue?.length && currentConversation.value?.id === convId) {
const next = queue.shift()!;
_saveQueue(convId);
setTimeout(() => sendMessage(
next.content,
next.contextNoteId,
next.includeNoteIds,
next.think,
next.contextNoteTitle,
next.excludeNoteIds,
next.ragProjectId,
next.workspaceProjectId,
), 0);
}
// Process next queued message if this is still the active conversation.
// If the user has navigated away, _tryDrainQueue will fire on fetchConversation.
_tryDrainQueue(convId);
}
async function reconnectIfGenerating(convId: number): Promise<void> {
+316 -6
View File
@@ -32,7 +32,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", "notifications", "integrations", "data", "briefing", "config", "users", "logs", "groups"]);
const VALID_TABS = new Set(["general", "account", "notifications", "integrations", "data", "briefing", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
watch(activeTab, (v) => {
@@ -41,8 +41,104 @@ watch(activeTab, (v) => {
if (v === "logs" && authStore.isAdmin) loadLogsPanel();
if (v === "groups" && authStore.isAdmin) loadGroupsPanel();
if (v === "briefing") loadBriefingTab();
if (v === "apikeys") fetchApiKeys();
});
// API Keys
const apiKeys = ref<Array<{id: number, name: string, scope: string, key_prefix: string, last_used_at: string | null}>>([]);
const newKeyName = ref('');
const newKeyScope = ref<'read' | 'write'>('write');
const newKeyValue = ref('');
const apiKeyCopied = ref(false);
const creatingApiKey = ref(false);
const revokeConfirmId = ref<number | null>(null);
async function fetchApiKeys() {
const res = await fetch('/api/api-keys', { credentials: 'include' });
if (res.ok) {
const data = await res.json();
apiKeys.value = data.api_keys;
}
}
async function createApiKey() {
if (!newKeyName.value) return;
creatingApiKey.value = true;
try {
const res = await fetch('/api/api-keys', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newKeyName.value, scope: newKeyScope.value }),
});
if (res.ok) {
const data = await res.json();
newKeyValue.value = data.key;
newKeyName.value = '';
await fetchApiKeys();
}
} finally {
creatingApiKey.value = false;
}
}
async function revokeApiKey(id: number) {
await fetch(`/api/api-keys/${id}`, { method: 'DELETE', credentials: 'include' });
revokeConfirmId.value = null;
await fetchApiKeys();
}
async function copyApiKey() {
try {
await navigator.clipboard.writeText(newKeyValue.value);
} catch {
// Fallback for http (non-secure) contexts where clipboard API is unavailable
const ta = document.createElement('textarea');
ta.value = newKeyValue.value;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.focus();
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
apiKeyCopied.value = true;
setTimeout(() => { apiKeyCopied.value = false; }, 2000);
}
function _downloadFile(filename: string, content: string, mimeType = 'text/plain') {
const blob = new Blob([content], { type: mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
function downloadEnvFile() {
const baseUrl = window.location.origin;
const content = `FABLE_URL=${baseUrl}\nFABLE_API_KEY=${newKeyValue.value}\n`;
_downloadFile('fable-mcp.env', content);
}
function downloadMcpConfig() {
const baseUrl = window.location.origin;
const config = {
mcpServers: {
fable: {
command: 'fable-mcp',
env: {
FABLE_URL: baseUrl,
FABLE_API_KEY: newKeyValue.value,
},
},
},
};
_downloadFile('fable-mcp-config.json', JSON.stringify(config, null, 2), 'application/json');
}
// Groups management
const groups = ref<GroupEntry[]>([]);
const groupsLoading = ref(false);
@@ -126,6 +222,8 @@ const briefingConfig = ref<BriefingConfig>({
work_days: [1, 2, 3, 4, 5],
slots: { compilation: true, morning: true, midday: false, afternoon: false },
notifications: true,
temp_unit: 'C',
timezone: '',
});
const briefingFeeds = ref<BriefingFeed[]>([]);
const briefingSaving = ref(false);
@@ -138,6 +236,10 @@ const addingFeed = ref(false);
async function loadBriefingTab() {
briefingConfig.value = await getBriefingConfig();
briefingFeeds.value = await getBriefingFeeds();
// Auto-populate timezone from browser if not already stored.
if (!briefingConfig.value.timezone) {
briefingConfig.value.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
}
async function geocodeLocation(key: 'home' | 'work') {
@@ -173,6 +275,7 @@ function toggleWorkDay(day: number) {
days.sort();
}
async function saveBriefingSettings() {
briefingSaving.value = true;
briefingSaved.value = false;
@@ -329,6 +432,7 @@ onMounted(async () => {
// base URL not configured yet
}
if (activeTab.value === "groups") loadGroupsPanel();
if (activeTab.value === "briefing") loadBriefingTab();
}
});
@@ -863,12 +967,12 @@ function formatUserDate(iso: string): string {
<div class="sidebar-group">
<div class="sidebar-group-label">User</div>
<button
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing']"
v-for="tab in ['general', 'account', 'notifications', 'integrations', 'data', 'briefing', 'apikeys']"
:key="tab"
:class="['sidebar-item', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
{{ tab === 'apikeys' ? 'API Keys' : tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<div v-if="authStore.isAdmin" class="sidebar-group">
@@ -1316,6 +1420,22 @@ function formatUserDate(iso: string): string {
</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>
<!-- Work schedule -->
@@ -1333,13 +1453,42 @@ function formatUserDate(iso: string): string {
</div>
</section>
<!-- Timezone -->
<section class="settings-section full-width">
<h2>Timezone</h2>
<p class="section-desc">
Briefing slots fire at the times below in this timezone.
Auto-detected from your browser — override if the server should use a different zone.
</p>
<div class="briefing-timezone-row">
<input
type="text"
class="input"
v-model="briefingConfig.timezone"
placeholder="e.g. America/New_York"
style="flex: 1"
/>
<button
type="button"
class="btn-secondary"
@click="briefingConfig.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone"
>Detect</button>
</div>
<p class="field-hint">
Use an
<a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones" target="_blank" rel="noopener">IANA timezone name</a>
(e.g. <code>Europe/London</code>, <code>America/Chicago</code>).
Your browser reports: <strong>{{ Intl.DateTimeFormat().resolvedOptions().timeZone }}</strong>
</p>
</section>
<!-- Slots -->
<section class="settings-section full-width">
<h2>Scheduled Slots</h2>
<p class="section-desc">Each active slot will post an update into your briefing conversation.</p>
<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, time] in ([
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'],
@@ -1350,13 +1499,16 @@ function formatUserDate(iso: string): string {
>
<div class="briefing-slot-info">
<span class="briefing-slot-label">{{ label }}</span>
<span class="briefing-slot-time">{{ time }}</span>
<span class="briefing-slot-time">{{ localTime }}</span>
</div>
<label>
<input type="checkbox" v-model="briefingConfig.slots[key]" />
</label>
</div>
</div>
<p v-if="briefingConfig.timezone" class="field-hint" style="margin-top: 0.5rem">
Firing in timezone: <strong>{{ briefingConfig.timezone }}</strong>
</p>
</section>
<!-- RSS Feeds -->
@@ -1402,6 +1554,69 @@ function formatUserDate(iso: string): string {
</div>
<!-- ── API Keys ── -->
<div v-show="activeTab === 'apikeys'" class="settings-grid">
<section class="settings-section full-width">
<h2>API Keys</h2>
<p class="settings-description">
API keys let external tools (like the Fable MCP server) access your data without a browser session.
Write-scoped keys can create and update content; read-only keys can only query.
</p>
<!-- Create form -->
<div class="api-key-create-form">
<input v-model="newKeyName" placeholder="Key name (e.g. Claude MCP)" class="settings-input" />
<div class="api-key-scope-select">
<label><input type="radio" v-model="newKeyScope" value="read" /> Read-only</label>
<label><input type="radio" v-model="newKeyScope" value="write" /> Read + Write</label>
</div>
<button @click="createApiKey" :disabled="!newKeyName || creatingApiKey" class="btn btn-primary">
{{ creatingApiKey ? 'Generating' : 'Generate Key' }}
</button>
</div>
<!-- One-time key reveal -->
<div v-if="newKeyValue" class="api-key-reveal">
<p><strong>Copy this key now — it will not be shown again.</strong></p>
<div class="api-key-value-row">
<code class="api-key-value">{{ newKeyValue }}</code>
<button @click="copyApiKey" class="btn btn-secondary btn-sm">{{ apiKeyCopied ? 'Copied!' : 'Copy' }}</button>
</div>
<div style="display:flex; gap:0.5rem; margin-top: 0.5rem;">
<button @click="downloadEnvFile" class="btn btn-secondary btn-sm">Download .env</button>
<button @click="downloadMcpConfig" class="btn btn-secondary btn-sm">Download Claude config</button>
<button @click="newKeyValue = ''" class="btn btn-secondary">Done</button>
</div>
</div>
<!-- Keys table -->
<table v-if="apiKeys.length > 0" class="api-keys-table">
<thead>
<tr><th>Name</th><th>Scope</th><th>Prefix</th><th>Last Used</th><th></th></tr>
</thead>
<tbody>
<tr v-for="key in apiKeys" :key="key.id">
<td>{{ key.name }}</td>
<td><span :class="['scope-badge', key.scope]">{{ key.scope }}</span></td>
<td><code>{{ key.key_prefix }}…</code></td>
<td>{{ key.last_used_at ? new Date(key.last_used_at).toLocaleDateString() : 'Never' }}</td>
<td>
<span v-if="revokeConfirmId !== key.id">
<button @click="revokeConfirmId = key.id" class="btn btn-secondary btn-sm">Revoke</button>
</span>
<span v-else>
Sure?
<button @click="revokeApiKey(key.id)" class="btn btn-danger btn-sm">Yes</button>
<button @click="revokeConfirmId = null" class="btn btn-secondary btn-sm">No</button>
</span>
</td>
</tr>
</tbody>
</table>
<p v-else-if="apiKeys.length === 0 && activeTab === 'apikeys'" class="settings-empty">No API keys yet.</p>
</section>
</div>
<!-- ── Admin ── -->
<div v-if="authStore.isAdmin" v-show="activeTab === 'config'" class="settings-grid">
@@ -2697,6 +2912,38 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap;
margin-top: 0.5rem;
}
.briefing-timezone-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.5rem;
}
.briefing-unit-toggle {
display: flex;
gap: 0;
border: 1px solid var(--color-border);
border-radius: 8px;
overflow: hidden;
width: fit-content;
margin-top: 0.25rem;
}
.briefing-unit-btn {
padding: 0.35rem 1rem;
background: var(--color-bg-card);
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
border: none;
font-family: inherit;
transition: all 0.15s;
}
.briefing-unit-btn:first-child {
border-right: 1px solid var(--color-border);
}
.briefing-unit-btn.active {
background: var(--color-primary);
color: #fff;
}
.briefing-day-btn {
padding: 0.35rem 0.65rem;
border: 1px solid var(--color-border);
@@ -2789,4 +3036,67 @@ function formatUserDate(iso: string): string {
flex-wrap: wrap;
margin-top: 0.5rem;
}
/* API Keys tab */
.api-key-create-form {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 1.5rem;
}
.api-key-scope-select {
display: flex;
gap: 1rem;
}
.api-key-scope-select label {
display: flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.api-key-reveal {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface));
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-md);
padding: 1rem;
margin-bottom: 1.5rem;
}
.api-key-value-row {
display: flex;
gap: 0.5rem;
align-items: center;
margin-top: 0.5rem;
}
.api-key-value {
flex: 1;
background: var(--color-surface-2, var(--color-surface));
padding: 0.4rem 0.6rem;
border-radius: var(--radius-sm);
font-size: 0.85rem;
word-break: break-all;
}
.api-keys-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.api-keys-table th, .api-keys-table td {
text-align: left;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
}
.api-keys-table th { font-weight: 600; opacity: 0.7; }
.scope-badge {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
font-size: 0.78rem;
font-weight: 600;
}
.scope-badge.read { background: color-mix(in srgb, #3b82f6 15%, transparent); color: #3b82f6; }
.scope-badge.write { background: color-mix(in srgb, #10b981 15%, transparent); color: #10b981; }
.settings-empty { opacity: 0.5; margin-top: 1rem; }
.settings-description { opacity: 0.7; margin-bottom: 1rem; line-height: 1.5; }
</style>
+2 -1
View File
@@ -80,7 +80,7 @@ watch(
activeNoteId.value = tc.result.data.id as number;
}
if (
["create_task", "update_task"].includes(tc.function) &&
["create_task", "update_task", "create_milestone", "update_milestone"].includes(tc.function) &&
tc.status === "success"
) {
taskPanelRef.value?.reload();
@@ -115,6 +115,7 @@ function scrollToBottom() {
}
watch(() => chatStore.streamingContent, scrollToBottom);
watch(() => chatStore.currentConversation?.messages.length, scrollToBottom);
function togglePanel(panel: keyof typeof panelOpen.value) {
const open = panelOpen.value;
+4
View File
@@ -27,6 +27,8 @@ from fabledassistant.routes.groups import groups_bp
from fabledassistant.routes.shares import shares_bp
from fabledassistant.routes.in_app_notifications import notifications_bp
from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.search import search_bp
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
@@ -77,6 +79,8 @@ def create_app() -> Quart:
app.register_blueprint(shares_bp)
app.register_blueprint(notifications_bp)
app.register_blueprint(users_bp)
app.register_blueprint(api_keys_bp)
app.register_blueprint(search_bp)
@app.before_request
async def before_request():
+23 -1
View File
@@ -1,13 +1,35 @@
import functools
from quart import g, jsonify, session
from quart import g, jsonify, request, session
from fabledassistant.services.auth import get_user_by_id
from fabledassistant.services.api_keys import lookup_key
def _check_auth(f, required_role: str | None = None):
@functools.wraps(f)
async def decorated(*args, **kwargs):
# --- Bearer token path ---
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
raw_key = auth_header[len("Bearer "):]
api_key = await lookup_key(raw_key)
if api_key is None:
return jsonify({"error": "Invalid or revoked API key"}), 401
user = await get_user_by_id(api_key.user_id)
if not user:
return jsonify({"error": "User not found"}), 401
# Scope enforcement: read-only keys cannot mutate
if api_key.scope == "read" and request.method not in ("GET", "HEAD", "OPTIONS"):
return jsonify({"error": "Read-only key cannot perform write operations"}), 403
# Role check (admin_required routes)
if required_role and user.role != required_role:
return jsonify({"error": "Admin access required"}), 403
g.user = user
g.api_key = api_key
return await f(*args, **kwargs)
# --- Session path (unchanged) ---
user_id = session.get("user_id")
if not user_id:
return jsonify({"error": "Authentication required"}), 401
+1
View File
@@ -40,3 +40,4 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402,
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
+42
View File
@@ -0,0 +1,42 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class ApiKey(Base, CreatedAtMixin):
__tablename__ = "api_keys"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(Text, nullable=False)
key_hash: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
key_prefix: Mapped[str] = mapped_column(Text, nullable=False)
scope: Mapped[str] = mapped_column(Text, nullable=False) # "read" or "write"
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_api_keys_user_id", "user_id"),
Index("ix_api_keys_key_hash", "key_hash"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"name": self.name,
"key_prefix": self.key_prefix,
"scope": self.scope,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"created_at": self.created_at.isoformat(),
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
}
+42
View File
@@ -0,0 +1,42 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.api_keys import create_api_key, list_api_keys, revoke_api_key
api_keys_bp = Blueprint("api_keys", __name__, url_prefix="/api/api-keys")
@api_keys_bp.route("", methods=["GET"])
@login_required
async def list_keys_route():
uid = get_current_user_id()
keys = await list_api_keys(uid)
return jsonify({"api_keys": keys})
@api_keys_bp.route("", methods=["POST"])
@login_required
async def create_key_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
name = (data.get("name") or "").strip()
scope = (data.get("scope") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
if scope not in ("read", "write"):
return jsonify({"error": "scope must be 'read' or 'write'"}), 400
full_key, key_dict = await create_api_key(uid, name=name, scope=scope)
# Return the full key ONCE — it is never retrievable again
return jsonify({"key": full_key, "api_key": key_dict}), 201
@api_keys_bp.route("/<int:key_id>", methods=["DELETE"])
@login_required
async def revoke_key_route(key_id: int):
uid = get_current_user_id()
deleted = await revoke_api_key(uid, key_id)
if not deleted:
return jsonify({"error": "API key not found"}), 404
return "", 204
+3
View File
@@ -44,6 +44,9 @@ async def get_config():
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 so the new timezone takes effect immediately.
from fabledassistant.services.briefing_scheduler import update_user_schedule
update_user_schedule(g.user.id, data)
return jsonify({"ok": True})
+5 -1
View File
@@ -75,7 +75,11 @@ async def create_conversation_route():
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conv = await create_conversation(uid, title=title, model=model)
conversation_type = data.get("conversation_type", "chat")
# Only allow known types to prevent accidental misuse
if conversation_type not in ("chat", "mcp"):
conversation_type = "chat"
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
return jsonify(conv.to_dict()), 201
+46
View File
@@ -0,0 +1,46 @@
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.embeddings import semantic_search_notes
search_bp = Blueprint("search", __name__, url_prefix="/api/search")
def _content_type_to_is_task(content_type: str) -> bool | None:
"""Map content_type query param to semantic_search_notes is_task arg."""
if content_type == "note":
return False
if content_type == "task":
return True
return None # "all" or unknown → no filter
@search_bp.route("", methods=["GET"])
@login_required
async def search_route():
uid = get_current_user_id()
q = (request.args.get("q") or "").strip()
if not q:
return jsonify({"error": "q is required"}), 400
content_type = request.args.get("content_type", "all")
limit = min(request.args.get("limit", 10, type=int), 50)
is_task = _content_type_to_is_task(content_type)
results = await semantic_search_notes(
uid, q, limit=limit, is_task=is_task, threshold=0.3
)
return jsonify({
"results": [
{
"id": note.id,
"title": note.title,
"body": note.body or "",
"is_task": note.is_task,
"tags": note.tags or [],
"similarity": score,
}
for score, note in results # semantic_search_notes returns list[tuple[float, Note]]
],
"total": len(results),
})
+9
View File
@@ -1,8 +1,11 @@
import asyncio
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.models.note import TaskPriority, TaskStatus
from fabledassistant.routes.utils import not_found, parse_iso_date
from fabledassistant.services.embeddings import upsert_note_embedding
from fabledassistant.services.notes import (
create_note,
delete_note,
@@ -93,6 +96,9 @@ async def create_task_route():
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict()), 201
@@ -147,6 +153,9 @@ async def update_task_route(task_id: int):
task = await update_note(uid, task_id, **fields)
if task is None:
return not_found("Task")
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
asyncio.create_task(upsert_note_embedding(task.id, uid, text))
return jsonify(task.to_dict())
+87
View File
@@ -0,0 +1,87 @@
import hashlib
import secrets
from datetime import datetime, timezone
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.api_key import ApiKey
def generate_key() -> str:
"""Generate a new full API key. Never stored — caller must hash it."""
return "fmcp_" + secrets.token_urlsafe(32)
def _hash_key(key: str) -> str:
return hashlib.sha256(key.encode()).hexdigest()
def _key_prefix(key: str) -> str:
"""Return first 12 chars of the key for display (e.g. 'fmcp_abcdefg')."""
return key[:12]
async def create_api_key(
user_id: int, name: str, scope: str
) -> tuple[str, dict]:
"""Create a new API key. Returns (full_key, key_dict). full_key is never stored."""
if scope not in ("read", "write"):
raise ValueError("scope must be 'read' or 'write'")
full_key = generate_key()
key = ApiKey(
user_id=user_id,
name=name,
key_hash=_hash_key(full_key),
key_prefix=_key_prefix(full_key),
scope=scope,
)
async with async_session() as session:
session.add(key)
await session.commit()
await session.refresh(key)
return full_key, key.to_dict()
async def list_api_keys(user_id: int) -> list[dict]:
"""List all non-revoked API keys for the user."""
async with async_session() as session:
result = await session.execute(
select(ApiKey)
.where(ApiKey.user_id == user_id, ApiKey.revoked_at.is_(None))
.order_by(ApiKey.created_at.desc())
)
return [k.to_dict() for k in result.scalars().all()]
async def revoke_api_key(user_id: int, key_id: int) -> bool:
"""Soft-delete a key by setting revoked_at. Returns True if found."""
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(ApiKey.id == key_id, ApiKey.user_id == user_id)
)
key = result.scalars().first()
if key is None:
return False
key.revoked_at = datetime.now(timezone.utc)
await session.commit()
return True
async def lookup_key(raw_key: str) -> ApiKey | None:
"""Look up a non-revoked ApiKey by raw token value. Updates last_used_at."""
key_hash = _hash_key(raw_key)
async with async_session() as session:
result = await session.execute(
select(ApiKey).where(
ApiKey.key_hash == key_hash,
ApiKey.revoked_at.is_(None),
)
)
key = result.scalars().first()
if key is None:
return None
key.last_used_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(key)
return key
@@ -80,7 +80,7 @@ async def _gather_internal(user_id: int) -> dict:
# Calendar events today
calendar_events = []
try:
if is_caldav_configured():
if await is_caldav_configured(user_id):
events = await list_events(user_id, start=today, end=today)
calendar_events = [
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
@@ -94,7 +94,7 @@ async def _gather_internal(user_id: int) -> dict:
try:
projects = await list_projects(user_id)
for p in projects[:5]:
projects_summary.append(p.get("title", "Untitled project"))
projects_summary.append(p.title or "Untitled project")
except Exception:
logger.warning("Failed to gather projects for briefing", exc_info=True)
@@ -192,16 +192,26 @@ def _internal_user_prompt(data: dict, slot: str) -> str:
return "\n".join(lines)
def _external_user_prompt(data: dict, slot: str) -> str:
def _format_temp(value: float, unit: str) -> str:
"""Convert Celsius to the requested unit and format as an integer string."""
if unit == "F":
return f"{value * 9 / 5 + 32:.0f}"
return f"{value:.0f}"
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
unit_sym = f"°{temp_unit}"
lines = [f"Briefing slot: {slot}", ""]
if data["weather"]:
lines.append("WEATHER:")
for loc in data["weather"]:
lines.append(f" {loc['location_label']}:")
for day in loc["days"][:3]:
t_min = _format_temp(day["temp_min"], temp_unit)
t_max = _format_temp(day["temp_max"], temp_unit)
lines.append(
f" {day['date']}: {day['description']}, "
f"{day['temp_min']}{day['temp_max']}°C, {day['precip_mm']}mm rain"
f"{t_min}{t_max}{unit_sym}, {day['precip_mm']}mm rain"
)
if loc["changes_since_last_fetch"]:
lines.append(" FORECAST CHANGES:")
@@ -218,6 +228,18 @@ def _external_user_prompt(data: dict, slot: str) -> str:
# ── 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) -> str:
"""
Run the full two-lane briefing pipeline for a user and slot.
@@ -227,7 +249,10 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
profile_body = await get_profile_body(user_id)
profile_body, temp_unit = await asyncio.gather(
get_profile_body(user_id),
_get_temp_unit(user_id),
)
# Parallel gather
internal_data, external_data = await asyncio.gather(
@@ -244,7 +269,7 @@ async def run_compilation(user_id: int, slot: str, model: str | None = None) ->
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data, slot),
_external_user_prompt(external_data, slot, temp_unit),
model,
),
)
@@ -268,9 +293,10 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
internal_data, external_data = await asyncio.gather(
internal_data, external_data, temp_unit = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
_get_temp_unit(user_id),
)
system = (
@@ -281,6 +307,6 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
f"Slot: {slot}\n\n"
+ _internal_user_prompt(internal_data, slot)
+ "\n\n"
+ _external_user_prompt(external_data, slot)
+ _external_user_prompt(external_data, slot, temp_unit)
)
return await _llm_synthesise(system, user_prompt, model)
@@ -1,14 +1,20 @@
"""
APScheduler-based briefing scheduler.
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().
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 datetime import date, datetime, time, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
@@ -20,8 +26,9 @@ from fabledassistant.models.setting import Setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
# Slot definitions: (name, hour, minute)
# Slot definitions: (name, hour, minute) — local time in the user's timezone
SLOTS = [
("compilation", 4, 0),
("morning", 8, 0),
@@ -30,8 +37,22 @@ SLOTS = [
]
# ── 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]]:
"""Return [(user_id, model)] for users with briefing enabled."""
"""Return [(user_id, iana_timezone)] for all users with briefing enabled."""
import json
async with async_session() as session:
result = await session.execute(
@@ -44,12 +65,60 @@ async def _get_briefing_enabled_users() -> list[tuple[int, str]]:
try:
config = json.loads(row.value) if row.value else {}
if config.get("enabled"):
enabled.append((row.user_id, "")) # model resolved per-user at runtime
tz = _resolve_timezone(config.get("timezone", "UTC"))
enabled.append((row.user_id, tz))
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) -> None:
"""Add (or replace) all 4 slot jobs for a user in their timezone."""
if _scheduler is None or _loop is None:
return
for slot_name, hour, minute in SLOTS:
_scheduler.add_job(
_run_user_slot_sync,
CronTrigger(hour=hour, minute=minute, timezone=tz),
args=[user_id, slot_name],
id=_job_id(user_id, slot_name),
replace_existing=True,
misfire_grace_time=3600,
)
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)
logger.info("Removed briefing jobs for user %d", user_id)
# ── Public API ────────────────────────────────────────────────────────────────
def update_user_schedule(user_id: int, config: dict) -> None:
"""
Called when a user saves their briefing config via the settings UI.
Live-patches the scheduler — no restart required.
"""
if config.get("enabled"):
tz = _resolve_timezone(config.get("timezone", "UTC"))
_add_user_jobs(user_id, tz)
else:
_remove_user_jobs(user_id)
# ── Job execution ─────────────────────────────────────────────────────────────
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 (
@@ -64,12 +133,11 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
if slot == "compilation":
# Refresh external data first
try:
from fabledassistant.services.rss import refresh_all_feeds
import json
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(user_id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(user_id)
# Refresh weather for configured locations
from fabledassistant.services import weather as wx
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
@@ -83,25 +151,19 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
except Exception:
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
# Run previous day's profile close-out
await _run_profile_closeout(user_id, model)
# Create today's conversation and post opening message
conv = await get_or_create_today_conversation(user_id, model)
text = await run_compilation(user_id, slot, model)
if text:
await post_message(conv.id, "assistant", text)
else:
# Inject slot update into today's conversation
conv = await get_or_create_today_conversation(user_id, model)
text = await run_slot_injection(user_id, slot, model)
if text:
# Post as a system-injected user prompt + assistant response pair
await post_message(conv.id, "user", f"[{slot.title()} briefing update]")
await post_message(conv.id, "assistant", text)
# Send push notification
try:
from fabledassistant.services.push import send_push_notification
slot_labels = {
@@ -122,10 +184,22 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
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, ask the LLM to extract preference
observations, and append them to the briefing profile note.
Read yesterday's briefing conversation, extract preference observations,
and append them to the briefing profile note.
"""
from fabledassistant.services.briefing_profile import append_observations
from fabledassistant.services.briefing_pipeline import _llm_synthesise
@@ -149,7 +223,7 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
messages = list(msgs_result.scalars().all())
if len(messages) < 2:
return # Nothing interesting to learn from
return
transcript = "\n".join(
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
@@ -166,100 +240,97 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
await append_observations(user_id, observations)
def _run_slot_sync(slot: str, loop: asyncio.AbstractEventLoop) -> None:
"""Synchronous wrapper called by APScheduler's background thread."""
async def _job():
users = await _get_briefing_enabled_users()
for user_id, _ in users:
try:
await _run_slot_for_user(user_id, slot)
except Exception:
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
future = asyncio.run_coroutine_threadsafe(_job(), loop)
try:
future.result(timeout=600) # 10 min max per slot run
except Exception:
logger.exception("Briefing slot '%s' job failed", slot)
# ── Startup / catchup ─────────────────────────────────────────────────────────
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
"""
On startup, check if any slot was missed within the last 24 hours.
Fire it once if so. Never backfill more than one slot per slot-name.
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).
"""
now = datetime.now(timezone.utc)
today = now.date()
users = await _get_briefing_enabled_users()
for user_id, tz 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_time = datetime.combine(today, time(hour, minute), tzinfo=timezone.utc)
if slot_time > now:
continue # Hasn't happened yet today
age = (now - slot_time).total_seconds()
if age > 86400:
continue # More than 24h ago — skip
# Check if we already have a message for this slot today
# Simple heuristic: if today's briefing conversation has messages posted
# after the slot time, consider it covered
users = await _get_briefing_enabled_users()
for user_id, _ in users:
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,
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_time,
Message.created_at >= slot_utc,
).limit(1)
)
if msgs.scalars().first():
continue # Already covered
# Fire the missed slot
logger.info("Catching up missed briefing slot '%s' for user %d", slot_name, user_id)
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)
logger.exception(
"Catch-up for slot '%s' user %d failed", slot_name, user_id
)
def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
"""
Start the APScheduler background scheduler.
Start the APScheduler background scheduler with per-user timezone-aware jobs.
Must be called from the app's before_serving hook with the running event loop.
"""
global _scheduler
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler(timezone="UTC")
for slot_name, hour, minute in SLOTS:
_scheduler.add_job(
_run_slot_sync,
CronTrigger(hour=hour, minute=minute),
args=[slot_name, loop],
id=f"briefing_{slot_name}",
replace_existing=True,
misfire_grace_time=3600, # Fire up to 1h late rather than skip
)
# Schedule jobs synchronously: run the async query in the provided loop
future = asyncio.run_coroutine_threadsafe(_get_briefing_enabled_users(), loop)
try:
users = future.result(timeout=10)
except Exception:
logger.exception("Failed to load briefing users at startup")
users = []
for user_id, tz in users:
_add_user_jobs(user_id, tz)
_scheduler.start()
logger.info("Briefing scheduler started")
logger.info(
"Briefing scheduler started with %d user(s) across %d job(s)",
len(users), len(users) * len(SLOTS),
)
# Catch up missed slots in the background
asyncio.run_coroutine_threadsafe(_catchup_missed_slots(loop), loop)
def stop_briefing_scheduler() -> None:
global _scheduler
global _scheduler, _loop
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
_loop = None
+7 -3
View File
@@ -15,10 +15,10 @@ logger = logging.getLogger(__name__)
async def create_conversation(
user_id: int, title: str = "", model: str = ""
user_id: int, title: str = "", model: str = "", conversation_type: str = "chat"
) -> Conversation:
async with async_session() as session:
conv = Conversation(user_id=user_id, title=title, model=model)
conv = Conversation(user_id=user_id, title=title, model=model, conversation_type=conversation_type)
session.add(conv)
await session.commit()
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
@@ -128,7 +128,11 @@ async def cleanup_old_conversations(user_id: int, days: int) -> int:
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.updated_at < cutoff)
.where(
Conversation.user_id == user_id,
Conversation.updated_at < cutoff,
Conversation.conversation_type != "mcp", # preserve MCP audit trail
)
.returning(Conversation.id)
)
await session.commit()
-1
View File
@@ -157,7 +157,6 @@ async def list_notes(
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.parent_id == parent_id)
sort_col = getattr(Note, sort, Note.updated_at)
if order == "asc":
+90 -58
View File
@@ -30,6 +30,35 @@ def _schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> No
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
async def _resolve_project(user_id: int, project_name: str):
"""Exact-then-fuzzy project lookup. Returns the Project or None.
Resolution order:
1. Exact title match (case-insensitive via DB)
2. project_name is a substring of an existing title ("Famous Supply" in "Famous Supply Co.")
3. Existing title is a substring of project_name ("Famous Supply Co." in "Famous Supply Co. UK")
4. SequenceMatcher ratio >= 0.55 (catches abbreviations / partial names)
"""
from fabledassistant.services.projects import get_project_by_title, list_projects
proj = await get_project_by_title(user_id, project_name)
if proj is not None:
return proj
needle = project_name.lower().strip()
all_p = await list_projects(user_id)
# Substring checks first (deterministic)
for p in all_p:
haystack = p.title.lower().strip()
if needle in haystack or haystack in needle:
return p
# SequenceMatcher fallback for abbreviations / partial names
best, best_r = None, 0.0
for p in all_p:
r = SequenceMatcher(None, needle, p.title.lower().strip()).ratio()
if r >= 0.55 and r > best_r:
best, best_r = p, r
return best
# Core tools — always available
_CORE_TOOLS = [
{
@@ -358,7 +387,7 @@ _CORE_TOOLS = [
"type": "function",
"function": {
"name": "create_project",
"description": "Create a new project. IMPORTANT: You MUST ask the user to confirm the project name and intent before calling this tool. Only call with confirmed=true after the user has explicitly approved creation.",
"description": "Create a brand-new project. IMPORTANT: NEVER call this because a project name was not found — always use list_projects first to confirm no similar project exists. Only call this when the user has explicitly asked to create a new project AND confirmed it. Call with confirmed=true only after the user has explicitly approved. The system will block creation if any project with a similar name already exists.",
"parameters": {
"type": "object",
"properties": {
@@ -436,6 +465,24 @@ _CORE_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "update_milestone",
"description": "Update the title, description, or status of an existing milestone.",
"parameters": {
"type": "object",
"properties": {
"project": {"type": "string", "description": "Project title the milestone belongs to"},
"milestone": {"type": "string", "description": "Current milestone title to look up"},
"title": {"type": "string", "description": "New title (omit to keep current)"},
"description": {"type": "string", "description": "New description (omit to keep current)"},
"status": {"type": "string", "enum": ["active", "completed", "cancelled"], "description": "New status (omit to keep current)"},
},
"required": ["project", "milestone"],
},
},
},
{
"type": "function",
"function": {
@@ -842,14 +889,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
parent_task_name = arguments.get("parent_task")
pre_fields: dict = {}
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
pre_fields["project_id"] = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
@@ -924,14 +966,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_name = arguments.get("project")
project_id = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
project_id = proj.id
existing_notes, _ = await list_notes(user_id=user_id, q=note_title, is_task=False, limit=1)
@@ -1024,12 +1061,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
if "project" in arguments:
project_name = arguments["project"]
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects."}
update_fields["project_id"] = proj.id
@@ -1064,13 +1096,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
if project_name:
from fabledassistant.services.projects import get_project_by_title, list_projects as _lp
proj = await get_project_by_title(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
if matches:
proj = matches[0]
proj = await _resolve_project(user_id, project_name)
if proj:
project_id = proj.id
if milestone_name:
@@ -1132,12 +1158,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
# Resolve project name → id
search_project_id: int | None = None
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt2, list_projects as _lp2
proj = await _gpbt2(user_id, project_name)
if proj is None:
all_p = await _lp2(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj:
search_project_id = proj.id
@@ -1316,12 +1337,7 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
project_id = None
project_name = arguments.get("project")
if project_name:
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found."}
project_id = proj.id
@@ -1394,7 +1410,6 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
}
elif tool_name == "create_milestone":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import create_milestone as _cm, get_project_milestone_summary
project_name = arguments.get("project", "")
ms_title_pre = arguments.get("title", "Untitled Milestone")
@@ -1402,13 +1417,9 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
return {"success": False, "error": "project is required"}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Milestone creation requires explicit user confirmation. Ask the user: 'Shall I create a milestone titled \"{ms_title_pre}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
proj = await _gpbt(user_id, project_name)
proj = await _resolve_project(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to see available projects or create_project to create one."}
return {"success": False, "error": f"Project '{project_name}' not found. Use list_projects to find the correct project name and retry with the exact name."}
ms_title = ms_title_pre
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt2, list_milestones as _lms
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
@@ -1426,15 +1437,34 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
)
return {"success": True, "type": "milestone", "data": ms.to_dict()}
elif tool_name == "update_milestone":
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt, update_milestone as _um
project_name = arguments.get("project", "")
milestone_name = arguments.get("milestone", "")
if not project_name or not milestone_name:
return {"success": False, "error": "Both project and milestone are required"}
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"Project '{project_name}' not found"}
ms = await _gmbt(user_id, proj.id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found in project '{proj.title}'. Use list_milestones to see available milestones."}
fields: dict[str, object] = {}
if "title" in arguments:
fields["title"] = arguments["title"]
if "description" in arguments:
fields["description"] = arguments["description"]
if "status" in arguments:
fields["status"] = arguments["status"]
if not fields:
return {"success": False, "error": "No fields to update — provide at least one of: title, description, status"}
updated = await _um(user_id, ms.id, **fields)
return {"success": True, "type": "milestone", "data": updated.to_dict()}
elif tool_name == "list_milestones":
from fabledassistant.services.projects import get_project_by_title as _gpbt, list_projects as _lp
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
proj = await _gpbt(user_id, project_name)
if proj is None:
all_p = await _lp(user_id)
matches = [p for p in all_p if project_name.lower() in p.title.lower()]
proj = matches[0] if matches else None
proj = await _resolve_project(user_id, project_name)
if proj is None:
return {"success": False, "error": f"No project found matching '{project_name}'"}
summary = await get_project_milestone_summary(user_id, proj.id)
@@ -1473,15 +1503,17 @@ async def execute_tool(user_id: int, tool_name: str, arguments: dict) -> dict:
elif tool_name == "create_project":
from fabledassistant.services.projects import create_project as _create_project, get_project_by_title as _gpbt, list_projects as _lp
proj_title = arguments.get("title", "New Project")
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
# Always check for existing/similar projects first — even when confirmed=true
existing_proj = await _gpbt(user_id, proj_title)
if existing_proj is not None:
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use update_project to modify it instead of creating a duplicate."}
return {"success": False, "error": f"A project titled '{existing_proj.title}' already exists (id: {existing_proj.id}). Use that project instead of creating a duplicate."}
all_projects = await _lp(user_id)
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects)
# Broad fuzzy check (0.55) catches partial names and abbreviations
near_proj, ratio = _fuzzy_title_match(proj_title, all_projects, threshold=0.55)
if near_proj is not None:
return {"success": False, "error": f"A project with a very similar title '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). Use update_project to modify it instead of creating a duplicate."}
return {"success": False, "requires_confirmation": True, "error": f"A project with a similar name '{near_proj.title}' already exists (id: {near_proj.id}, similarity: {ratio:.0%}). This is likely the project you meant — use that project's name instead. Only call create_project again if you are certain this should be a completely new, separate project with a different confirmed=true."}
if not arguments.get("confirmed"):
return {"success": False, "requires_confirmation": True, "error": f"Project creation requires explicit user confirmation. Ask the user: 'Shall I create a new project titled \"{proj_title}\"?' Then retry with confirmed=true if they agree."}
project = await _create_project(
user_id,
title=proj_title,
+59 -1
View File
@@ -12,7 +12,65 @@
> Include file-level details in the commit body when the change is non-trivial.
## Last Updated
2026-03-11Multi-user sharing & collaboration, in-app notifications, groups management, backup rewrite.
2026-03-16Fuzzy project resolution, accidental project creation guard, task embedding, briefing CalDAV fix, notes count query fix.
---
**2026-03-16 — Fuzzy project resolution + create_project guard:**
- `services/tools.py``_resolve_project` rewritten with 4-step lookup: (1) exact DB match, (2) `query in title` substring, (3) `title in query` reverse substring, (4) SequenceMatcher ≥ 0.55 fallback. Partial names like "Famous Supply" now reliably resolve to "Famous Supply Co." without the model creating a duplicate project.
- `services/tools.py``create_project` handler: similar-project checks (threshold 0.55) now run **before** the `confirmed` gate, so `confirmed=true` cannot bypass them. All call sites that duplicated the old inline substring logic replaced with `_resolve_project`.
- `services/tools.py` — Error messages on project-not-found no longer say "or create_project to create one"; they direct the model to `list_projects` instead. Tool description explicitly says never call `create_project` in response to a lookup failure.
- `routes/tasks.py` — Fire-and-forget `upsert_note_embedding` added to create and update task routes (was missing; tasks created via REST API weren't getting embeddings).
- `services/briefing_pipeline.py``await is_caldav_configured()` (was called without await — was a latent bug since `is_caldav_configured` became async).
- `services/notes.py` — Removed erroneous duplicate `count_query.where(Note.parent_id == parent_id)` that was applied inside the `no_project` branch.
---
**CI release process (2026-03-12):**
- `.forgejo/workflows/ci.yml`: removed `main` from `branches` push trigger. CI now fires only on `dev` pushes, `v*` tags, and pull requests. Main branch merges no longer trigger a run — the PR check covers pre-merge safety. Production release process: create a release via Forgejo UI on `main` with a `v*` tag → tag push event fires CI → build job pushes `:latest` + `:<version>` Docker images to registry. Branch protection is not an obstacle because the UI release creates the tag through the API.
---
**Daily Briefing — full feature (2026-03-11):**
Backend:
- Migration `0026_add_briefing_tables.py`: `rss_feeds` (url, name, user_id), `rss_items` (feed_id FK, guid, title, url, summary, pub_date), `weather_cache` (user_id UNIQUE, lat, lon, location_name, forecast_json, fetched_at); added `conversation_type` TEXT and `briefing_date` DATE to `conversations`.
- `models/rss_feed.py`, `models/rss_item.py`, `models/weather_cache.py`: SQLAlchemy models with `to_dict()`.
- `services/weather.py`: geocoding via Nominatim (Open-Meteo), forecast fetch via Open-Meteo API, per-user DB cache with change detection. `get_weather(user_id)` returns structured dict with location, current, and 5-day forecast.
- `services/rss.py`: feedparser-based fetch, per-feed DB cache with prune-to-100. `get_rss_items(user_id, limit)` returns recent items across all user feeds.
- `services/briefing_profile.py`: service that reads user briefing config (CalDAV, weather, RSS, office days, tasks) and composes a profile note used as context for generation.
- `services/briefing_pipeline.py`: two-lane parallel gather (async `asyncio.gather` over weather, RSS, tasks, calendar) → LLM synthesis. Streams result into a `GenerationBuffer` using the same SSE infrastructure as chat.
- `routes/briefing.py`: RSS CRUD at `/api/briefing/feeds`, weather config at `/api/briefing/weather`, briefing config at `/api/briefing/config`, `POST /api/briefing/trigger` to manually fire a slot. Registered as `briefing_bp` in `app.py`.
- `routes/chat.py`: `GET /api/briefing/conversations/today` — creates the day's briefing conversation if absent (type=`briefing`, `briefing_date=today`); `GET /api/briefing/conversations` — lists all past briefing conversations.
- `services/generation_task.py` / `routes/chat.py`: `conversation_type` filter support so briefing conversations are excluded from the main chat list and vice versa.
- APScheduler integration: `services/scheduler.py` runs briefing slots (morning, midday, evening) on schedule with catch-up logic — if a slot was missed while the server was down, it runs immediately on next startup.
- LLM tools: `get_weather` and `get_rss_items` added to `_CORE_TOOLS` in `services/tools.py`.
Frontend:
- `frontend/src/views/BriefingView.vue` (new): primary briefing page. Soft chat-style layout. Top section shows the digest card (expandable) followed by the conversation thread. Reply bar at bottom — sends via standard chat endpoints and streams via SSE. Manual "Refresh" button triggers a new briefing generation. Conversation history dropdown (past briefing dates) in header.
- `frontend/src/components/BriefingSetupWizard.vue` (new): 4-step setup wizard shown on first visit when briefing is not configured (welcome → location → office days → RSS feeds). Completes by enabling briefing in settings.
- `frontend/src/views/SettingsView.vue`: Briefing tab added (`briefing` in `VALID_TABS`): enable toggle, location geocoding input (live preview), office days checkboxes, time slot toggles (morning/midday/evening), RSS feed CRUD (add URL + name, list, delete), push notification toggle for briefing completion.
- `frontend/src/router/index.ts`: `/briefing` route added → `BriefingView`.
- `frontend/src/components/AppHeader.vue`: "Briefing" nav link added.
---
**Previous session (2026-03-11):**
**Version footer + CalVer tracking:**
- `4636c9a` Add CalVer build-time version tracking: `Dockerfile` injects `BUILD_VERSION` ARG as `VITE_APP_VERSION` env var during frontend build. Frontend reads `import.meta.env.VITE_APP_VERSION` and displays it in the Settings footer. Docker image tags include `:<version>` on release builds.
- `2cb4e6d` Version footer, task history UI, note version retention policy: Settings page shows `v<version>` in footer. Task history now displays inline in a sidebar within the task editor (previously required navigation). Note version retention policy configurable (max versions per note, default 20 — enforced at write time in `services/note_versions.py`).
**Content deduplication + history sidebar:**
- `6d593a0` Move history to sidebar, simplify task assist, add content deduplication: `HistoryPanel` moved to a collapsible right sidebar in `NoteEditorView` (previously a modal overlay). Writing assist panel simplified — removed multi-step UX, direct instruction input. `build_context()` in `services/llm.py` deduplicates injected content (RAG-found notes, sidebar includes, context notes) by ID so the same note cannot appear in multiple context blocks.
**ShareDialog CSS fix:**
- `a63f067` Fix ShareDialog transparent background — replaced undefined CSS custom property references with explicit values.
---
**Previous session (2026-03-11 — Multi-user & backup):** Multi-user sharing & collaboration, in-app notifications, groups management, backup rewrite.
**Multi-user sharing & collaboration:**
- `alembic/versions/0025_add_sharing_and_notifications.py` (new): creates `groups`, `group_memberships`, `project_shares`, `note_shares`, `notifications` tables. Partial unique indexes via raw SQL. CHECK constraint enforces exclusive user/group target on share rows.
+166
View File
@@ -0,0 +1,166 @@
"""Unit tests for api_keys service — pure logic, DB calls mocked."""
import hashlib
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fabledassistant.services.api_keys import (
_hash_key,
_key_prefix,
generate_key,
create_api_key,
list_api_keys,
revoke_api_key,
lookup_key,
)
def test_generate_key_format():
key = generate_key()
assert key.startswith("fmcp_")
assert len(key) > 12
def test_generate_key_uniqueness():
keys = {generate_key() for _ in range(100)}
assert len(keys) == 100
def test_hash_key_is_sha256():
key = "fmcp_testkey"
h = _hash_key(key)
expected = hashlib.sha256(key.encode()).hexdigest()
assert h == expected
def test_key_prefix_returns_first_12_chars():
key = "fmcp_abcdefghijklmnop"
assert _key_prefix(key) == "fmcp_abcdefg"
@pytest.mark.asyncio
async def test_create_api_key_returns_full_key():
mock_key_obj = MagicMock()
mock_key_obj.id = 1
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock(side_effect=lambda obj: None)
# Return our fake object from refresh
mock_session.refresh = AsyncMock()
mock_key_obj.to_dict.return_value = {"id": 1, "name": "test", "scope": "read", "key_prefix": "fmcp_xxx"}
mock_session_ctx.return_value = mock_session
# Patch the select result
with patch("fabledassistant.services.api_keys.ApiKey") as mock_model:
mock_model.return_value = mock_key_obj
full_key, key_dict = await create_api_key(user_id=1, name="test", scope="read")
assert full_key.startswith("fmcp_")
@pytest.mark.asyncio
async def test_lookup_key_returns_none_for_unknown():
with patch("fabledassistant.services.api_keys.async_session") as mock_session_ctx:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_result = MagicMock()
mock_result.scalars.return_value.first.return_value = None
mock_session.execute = AsyncMock(return_value=mock_result)
mock_session_ctx.return_value = mock_session
result = await lookup_key("fmcp_doesnotexist")
assert result is None
def test_hash_key_deterministic():
key = "fmcp_some_test_key_value"
assert _hash_key(key) == _hash_key(key)
@pytest.mark.asyncio
async def test_scope_validation():
with pytest.raises(ValueError, match="scope must be"):
await create_api_key(1, "bad", "superadmin")
# --- Auth middleware tests ---
@pytest.mark.asyncio
async def test_read_only_key_blocked_on_post():
"""Read-only API key returns 403 on non-GET requests."""
from unittest.mock import AsyncMock, MagicMock, patch
from quart import Quart
fake_key = MagicMock()
fake_key.user_id = 7
fake_key.scope = "read"
fake_user = MagicMock()
fake_user.role = "user"
app = Quart(__name__)
async def dummy():
from quart import jsonify
return jsonify({"ok": True})
from fabledassistant.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_readonly"},
):
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
assert resp[1] == 403
@pytest.mark.asyncio
async def test_bearer_token_valid_write_key_passes():
"""Valid write-scoped bearer token passes auth on POST."""
from unittest.mock import AsyncMock, MagicMock, patch
from quart import Quart, g
fake_key = MagicMock()
fake_key.user_id = 7
fake_key.scope = "write"
fake_user = MagicMock()
fake_user.role = "user"
fake_user.id = 7
app = Quart(__name__)
result_holder = {}
async def dummy():
result_holder["user"] = g.user
result_holder["api_key"] = g.api_key
from quart import jsonify
return jsonify({"ok": True})
from fabledassistant.auth import _check_auth
wrapped = _check_auth(dummy)
async with app.test_request_context(
"/test", method="POST",
headers={"Authorization": "Bearer fmcp_writekey"},
):
with patch("fabledassistant.auth.lookup_key", AsyncMock(return_value=fake_key)), \
patch("fabledassistant.auth.get_user_by_id", AsyncMock(return_value=fake_user)):
resp = await wrapped()
# Should not be a tuple (no error response)
assert not isinstance(resp, tuple) or resp[1] == 200
assert result_holder.get("user") is fake_user
assert result_holder.get("api_key") is fake_key
+18
View File
@@ -0,0 +1,18 @@
"""Unit tests for the search route parameter mapping."""
from fabledassistant.routes.search import _content_type_to_is_task
def test_content_type_note():
assert _content_type_to_is_task("note") is False
def test_content_type_task():
assert _content_type_to_is_task("task") is True
def test_content_type_all():
assert _content_type_to_is_task("all") is None
def test_content_type_unknown_defaults_to_all():
assert _content_type_to_is_task("unknown") is None