Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e407043713 | |||
| 00dee5ea0e | |||
| 55e260b392 | |||
| 359cb24d9a | |||
| 6180ee65c3 | |||
| 66571e16c1 | |||
| 73eb34d722 | |||
| 47b4af281b | |||
| e8a4ca915a | |||
| 583a140485 | |||
| 7358909432 | |||
| c24b21f259 | |||
| e54d172487 | |||
| f9973e22f1 | |||
| e9ddab7368 | |||
| 16b2b5c68e | |||
| ce5b2ffaeb | |||
| c232a7d079 | |||
| 1c3f0ab7f7 | |||
| 33f52081e5 | |||
| a58dbe79f2 | |||
| 0d41b8be34 | |||
| bf3bf99410 | |||
| 9cb3700a5c | |||
| b44d8496bc | |||
| 3eb61950c9 | |||
| beb57876fb | |||
| 5ea3bb5aff | |||
| 6e57ce4555 | |||
| 44119fb957 | |||
| d2605287f7 | |||
| 97d62a6a32 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""add api_keys table
|
||||
|
||||
Revision ID: 0027
|
||||
Revises: 0026
|
||||
Create Date: 2026-03-23
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0027"
|
||||
down_revision = "0026"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"api_keys",
|
||||
sa.Column("id", sa.Integer(), primary_key=True),
|
||||
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("name", sa.Text(), nullable=False),
|
||||
sa.Column("key_hash", sa.Text(), nullable=False, unique=True),
|
||||
sa.Column("key_prefix", sa.Text(), nullable=False),
|
||||
sa.Column("scope", sa.Text(), nullable=False),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("ix_api_keys_user_id", "api_keys", ["user_id"])
|
||||
op.create_index("ix_api_keys_key_hash", "api_keys", ["key_hash"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("api_keys")
|
||||
@@ -0,0 +1,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
@@ -0,0 +1,2 @@
|
||||
FABLE_URL=http://localhost:5000
|
||||
FABLE_API_KEY=fmcp_your_key_here
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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]
|
||||
@@ -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"
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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})
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -12,7 +12,65 @@
|
||||
> Include file-level details in the commit body when the change is non-trivial.
|
||||
|
||||
## Last Updated
|
||||
2026-03-11 — Multi-user sharing & collaboration, in-app notifications, groups management, backup rewrite.
|
||||
2026-03-16 — Fuzzy 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user