refactor: Phase 8 — backend deletion (chat / voice / push / journal / curator)

Mega-commit. Strips all server-side LLM machinery now that Phase 7 has
removed the corresponding UI surfaces and the MCP HTTP endpoint is the
sole assistant interface.

Deleted (services/):
  chat, generation_buffer, generation_log, generation_task, llm, tools/
  (entire package), stt, tts, voice_config, voice_library, push,
  journal_closeout, journal_pipeline, journal_prep, journal_scheduler,
  journal_search, curator, curator_scheduler, consolidation,
  tag_suggestions, research, weather, article_fetcher, pending_actions,
  moments, assist, wikipedia.

Deleted (routes/):
  chat, voice, push, journal, quick_capture, fable_mcp_dist.

Deleted (models/):
  conversation, generation_tool_log, push_subscription,
  pending_curator_action, moment, weather_cache.

Deleted (tests/):
  test_generation_log, test_journal_*, test_consolidation, test_lookup_tool,
  test_notes_consolidation_trigger, test_record_moment_guards,
  test_research_pipeline, test_tools_*, test_tool_use_fixes,
  test_voice_library, test_weather_service, test_calendar_tool_tz,
  test_wikipedia.

Deleted (top-level):
  fable-mcp/ (legacy standalone stdio package — wheel-build pipeline
  also removed from Dockerfile).

app.py:
  - blueprint registrations for the 6 deleted routes
  - startup hook trimmed: no more Ollama warmup, KV-cache priming,
    journal/curator schedulers, voice model loading
  - shutdown hook simplified
  - httpx import dropped (was for Ollama calls)

pyproject.toml:
  - removed deps: pywebpush, feedparser, html2text, trafilatura
  - removed [voice] extras entirely
  - description updated for the MCP-first architecture

Dockerfile:
  - removed faster-whisper / piper-tts install steps
  - removed bundled piper voice download stage
  - removed fable-mcp wheel build stage

Surviving-file edits:
  - services/auth.py: drop Conversation table claim on first-user setup
  - services/backup.py: drop conversation / push-subscription export+restore;
    v1/v2 restore now silently skip pre-pivot conversation data
  - services/notes.py: drop maybe_consolidate trigger on task done/cancelled;
    drop _maybe_trigger_project_summary (LLM auto-summary)
  - services/projects.py: drop generate_project_summary + backfill_project_summaries
    (both LLM-driven)
  - services/user_profile.py: drop append_observations / consolidate /
    clear_learned_data (curator-tied) and build_profile_context
    (was LLM system-prompt builder)
  - services/notifications.py: stub out _fire_push_notif (was send_push_notification)
  - services/event_scheduler.py: drop event-reminder push + chat-retention
    cleanup job; keep CalDAV pull-sync + reminders job (in-app)
  - services/diagnostics.py: _curator_busy() always False
  - routes/notes.py: drop /assist, /assist/stream, /suggest-tags endpoints
  - routes/tasks.py: drop /<id>/consolidate endpoint
  - routes/settings.py: drop /models, KV-cache-prime-on-save, journal-schedule
    timezone hook, and the SearXNG search-test endpoint; inline _is_private_url
    (was in services/llm.py)
  - routes/admin.py: drop /voice, /voice/reload endpoints
  - routes/profile.py: drop /consolidate, /observations (GET, DELETE)
  - models/__init__.py: drop the 6 dead model imports

Frontend cascade:
  - stores/push.ts: deleted entirely (no callers after Phase 7)
  - stores/settings.ts: drop checkVoiceStatus + voice-status state
  - views/SettingsView.vue: drop Locations section + journalConfig state
    (was tied to /api/journal/config); drop JournalConfig + journal/voice
    api/client imports
  - frontend/api/client.ts: orphaned voice/journal/profile-observation/
    fable-mcp-dist exports are left as dead but harmless (call them and
    they 404; type-check is clean).

Pre-existing v1 backups that contained conversations/messages still
restore — those tables are silently dropped from the import path.
Anyone pulling the new image with a populated database will need the
Phase 9 migration to drop the dead tables (coming next).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 17:47:18 -04:00
parent 8bec68abc0
commit 91bafb641f
123 changed files with 161 additions and 19312 deletions
+1 -1
View File
@@ -1 +1 @@
3158517
603480
-50
View File
@@ -17,56 +17,6 @@ COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install .
# Speech-to-text (faster-whisper + soundfile). faster-whisper is pure
# Python; the actual inference engine is ctranslate2 (C++) which has
# cp314 wheels as of v4.7.2 (2026-05-19). No torch needed — ctranslate2
# does its own CPU inference. Image add: ~150 MB.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install faster-whisper soundfile
# Text-to-speech (piper-tts). Replaces kokoro, which has been
# stale upstream since April 2025 (requires_python<3.13). Piper depends
# only on onnxruntime (already pulled in for STT via faster-whisper) and
# pathvalidate — total Python overhead is tiny. Voice models are separate
# .onnx + .onnx.json files bundled below.
RUN --mount=type=cache,target=/root/.cache/pip \
pip install piper-tts
# Bundle two default voices in the image so first-run TTS works offline.
# Additional voices can be downloaded at runtime into /data/voices via the
# admin UI (see services/tts.py for the voice-discovery logic).
# Voice catalog: https://huggingface.co/rhasspy/piper-voices
#
# Using Python's urllib instead of curl/wget because python:3.14-slim
# ships neither; python is obviously available. The heredoc requires
# the BuildKit Dockerfile 1.3+ frontend, which the `# syntax=...:1`
# directive at the top of this file already pulls in.
RUN <<'PYEOF' python3
import os, urllib.request
VOICES = ["en_US-amy-medium", "en_US-ryan-medium"]
BASE = "https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US"
TARGET = "/opt/piper-voices"
os.makedirs(TARGET, exist_ok=True)
for v in VOICES:
dataset = v.split("-")[1]
for ext in ("onnx", "onnx.json"):
url = f"{BASE}/{dataset}/medium/{v}.{ext}"
path = os.path.join(TARGET, f"{v}.{ext}")
print(f"Downloading {url}", flush=True)
urllib.request.urlretrieve(url, path)
size = os.path.getsize(path)
print(f" -> {path} ({size:,} bytes)", flush=True)
PYEOF
# Build the fable-mcp wheel so it can be served for download
COPY fable-mcp/ fable-mcp/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install build hatchling \
&& python -m build --wheel ./fable-mcp --outdir /app/dist/ \
&& pip uninstall -y build \
&& rm -rf fable-mcp/
COPY --from=build-frontend /build/dist/ src/fabledassistant/static/
COPY alembic.ini .
COPY alembic/ alembic/
+12
View File
@@ -0,0 +1,12 @@
# Ollama benchmark
- Server: `http://192.168.0.9:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: auto (chat=off, curator=on)
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:8b | chat | 5 | 98 | 187 | 2126 | 13.8 | 26 |
| gemma2:9b | chat | 5 | 89 | 184 | 2084 | 11.7 | 22 |
| llama3.2:3b | chat | 5 | 102 | 185 | 1091 | 31.9 | 28 |
| mistral-nemo:12b | chat | 5 | 82 | 228 | 2070 | 10.2 | 19 |
+12
View File
@@ -0,0 +1,12 @@
# Ollama benchmark
- Server: `http://192.168.0.9:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: forced OFF
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:30b-a3b | curator | 3 | 529 | 183 | 164198 | 17.4 | 2732 |
| qwen3:32b | curator | 3 | 535 | 677 | 136519 | 3.0 | 406 |
| gemma2:27b | curator | 3 | 545 | 392 | 132739 | 3.7 | 451 |
| mistral-small:22b | curator | 3 | 562 | 293 | 105971 | 4.7 | 485 |
+10
View File
@@ -0,0 +1,10 @@
# Ollama benchmark
- Server: `http://192.168.0.9:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: forced ON
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:30b-a3b | curator | 3 | 529 | 147344 | 185694 | 15.6 | 3300 |
| qwen3:32b | curator | 3 | 529 | 98890 | 213817 | 3.1 | 651 |
+12
View File
@@ -0,0 +1,12 @@
# Ollama benchmark
- Server: `http://192.168.0.13:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: auto (chat=off, curator=on)
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:8b | chat | 5 | 98 | 410 | 4131 | 7.0 | 26 |
| gemma2:9b | chat | 5 | 89 | 410 | 2950 | 8.1 | 21 |
| llama3.2:3b | chat | 5 | 102 | 311 | 1627 | 21.7 | 28 |
| mistral-nemo:12b | chat | 5 | 82 | 458 | 2966 | 7.9 | 19 |
+12
View File
@@ -0,0 +1,12 @@
# Ollama benchmark
- Server: `http://192.168.0.13:11434`
- Hardware mode: GPU offload (99 layers) (`num_gpu=99`)
- Think: auto (chat=off, curator=on)
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:8b | chat | 5 | 98 | 369 | 1301 | 23.8 | 25 |
| gemma2:9b | chat | 5 | 89 | 426 | 1681 | 22.1 | 24 |
| llama3.2:3b | chat | 5 | 102 | 400 | 1033 | 54.1 | 29 |
| mistral-nemo:12b | chat | 5 | 82 | 446 | 1451 | 19.7 | 18 |
+9
View File
@@ -0,0 +1,9 @@
# Ollama benchmark
- Server: `http://192.168.0.13:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: forced OFF
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| llama3.3:70b | curator | 3 | 499 | 1284 | 326662 | 1.1 | 367 |
+12
View File
@@ -0,0 +1,12 @@
# Ollama benchmark
- Server: `http://192.168.0.13:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: forced OFF
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:30b-a3b | curator | 3 | 529 | 355 | 324789 | 9.8 | 3195 |
| qwen3:32b | curator | 3 | 535 | 813 | 157978 | 2.6 | 404 |
| gemma2:27b | curator | 3 | 545 | 693 | 166996 | 2.7 | 453 |
| mistral-small:22b | curator | 3 | 562 | 386 | 144660 | 3.6 | 490 |
+10
View File
@@ -0,0 +1,10 @@
# Ollama benchmark
- Server: `http://192.168.0.13:11434`
- Hardware mode: CPU only (`num_gpu=0`)
- Think: forced ON
| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) | Total p50 (ms) | tok/s p50 | Output tok (mean) |
|---|---|---|---|---|---|---|---|
| qwen3:30b-a3b | curator | 3 | 529 | 261474 | 319287 | 9.8 | 3310 |
| qwen3:32b | curator | 3 | 529 | 127386 | 274725 | 2.5 | 756 |
-2
View File
@@ -1,2 +0,0 @@
FABLE_URL=http://localhost:5000
FABLE_API_KEY=fmcp_your_key_here
View File
-126
View File
@@ -1,126 +0,0 @@
"""Async HTTP client for the Fable Assistant API."""
from __future__ import annotations
import os
from typing import Any, AsyncIterator
import httpx
class FableAPIError(Exception):
"""Raised when the Fable API returns a non-2xx response."""
def __init__(self, status_code: int, message: str) -> None:
self.status_code = status_code
super().__init__(f"Fable API error {status_code}: {message}")
class FableClient:
"""Async wrapper around httpx for the Fable REST API."""
def __init__(self) -> None:
url = os.environ.get("FABLE_URL", "").rstrip("/")
key = os.environ.get("FABLE_API_KEY", "")
if not url:
raise ValueError("FABLE_URL environment variable is required")
if not key:
raise ValueError("FABLE_API_KEY environment variable is required")
self.base_url = url
self._headers = {
"Authorization": f"Bearer {key}",
"Content-Type": "application/json",
}
self._client: httpx.AsyncClient | None = None
async def __aenter__(self) -> "FableClient":
self._client = httpx.AsyncClient(
base_url=self.base_url,
headers=self._headers,
timeout=httpx.Timeout(connect=10.0, read=300.0, write=30.0, pool=10.0),
)
return self
async def __aexit__(self, *args: Any) -> None:
if self._client:
await self._client.aclose()
self._client = None
def _http(self) -> httpx.AsyncClient:
if self._client is None:
raise RuntimeError("FableClient must be used as an async context manager")
return self._client
@staticmethod
def _raise_for_status(response: httpx.Response) -> None:
if response.is_error:
try:
message = response.json().get("error", response.text)
except Exception:
message = response.text
raise FableAPIError(response.status_code, message)
async def get(self, path: str, **kwargs: Any) -> Any:
response = await self._http().get(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def post(self, path: str, **kwargs: Any) -> Any:
response = await self._http().post(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def patch(self, path: str, **kwargs: Any) -> Any:
response = await self._http().patch(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def put(self, path: str, **kwargs: Any) -> Any:
response = await self._http().put(path, **kwargs)
self._raise_for_status(response)
return response.json()
async def delete(self, path: str, **kwargs: Any) -> Any:
response = await self._http().delete(path, **kwargs)
if response.status_code == 204:
return None
self._raise_for_status(response)
if response.content:
return response.json()
return None
async def stream_get(self, path: str, **kwargs: Any) -> AsyncIterator[str]:
"""Yield non-empty lines from a streaming GET response (SSE)."""
async with self._http().stream("GET", path, **kwargs) as response:
if response.is_error:
await response.aread()
self._raise_for_status(response)
async for line in response.aiter_lines():
if line:
yield line
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_client: FableClient | None = None
def init_client() -> FableClient:
"""Initialise the module-level FableClient singleton (reads env vars)."""
global _client
_client = FableClient()
return _client
def get_client() -> FableClient:
"""Return the singleton client; raises RuntimeError if not initialised."""
if _client is None:
raise RuntimeError("Call init_client() before get_client()")
return _client
def _reset_client() -> None:
"""Reset the singleton — used by tests only."""
global _client
_client = None
-757
View File
@@ -1,757 +0,0 @@
"""Fable MCP server — exposes Fable Scribe as MCP tools via stdio transport."""
from __future__ import annotations
from mcp.server.fastmcp import FastMCP
from dotenv import load_dotenv
from fable_mcp.client import FableClient
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, journal
load_dotenv()
_INSTRUCTIONS = """
Fabled Scribe is a self-hosted second-brain and project management system with LLM integration.
## Data model
The hierarchy is: Project → Milestone → Task/Note.
- **Notes** and **Tasks** share the same underlying model. Tasks are notes with `is_task=True`.
The note tools (fable_*_note) operate on notes; the task tools (fable_*_task) operate on tasks.
Do not use note tools to manipulate tasks or vice versa.
- **Projects** group related work. A project has a title, description, goal, status, and an
auto-generated summary used for semantic search. Status values: `active`, `archived`.
- **Milestones** belong to a project and group tasks within it. Status values: `active`, `done`.
- **Tasks** belong to a project and optionally a milestone. They support sub-tasks via `parent_id`.
- Status values: `todo`, `in_progress`, `done`, `cancelled`
- Priority values: `none` (default), `low`, `medium`, `high`
- **Notes** are free-form markdown documents. They can belong to a project or be standalone
(orphan notes). Orphan notes are included in the default RAG scope for chat conversations.
## Tags
Tags are plain strings — do NOT include a `#` prefix. Example: `["python", "architecture"]`.
Tags are stored as an array on the note/task. Passing `tags=[]` clears all tags; omitting `tags`
leaves existing tags unchanged on updates.
## Integer-or-none fields
Due to MCP type constraints, optional integer fields (project_id, milestone_id, parent_id)
use `0` to mean "not set / no association". Pass `0` to leave the field unset.
## Search
`fable_search` performs semantic (embedding-based) search over notes and tasks. Use it to find
relevant content by meaning rather than exact keywords. Returns results ranked by cosine
similarity with id, title, a body snippet, and tags.
## Chat / LLM delegation
`fable_send_message` sends a natural-language message to Fable's built-in LLM (Ollama). Fable
handles its own tool use, RAG context injection, and conversation history internally.
Use `fable_send_message` when:
- The request is conversational or requires Fable's internal reasoning across many records
- You want Fable's RAG to surface relevant notes automatically
Use the direct CRUD tools when:
- You know exactly what to create/read/update/delete
- You need structured data back (IDs, field values) for further processing
- You are populating Fable programmatically from another system
## Task logs
Use `fable_add_task_log` to append time-stamped progress notes to a task without overwriting
its main body. Suitable for recording work sessions, decisions, or status updates over time.
## Journal
Fable Scribe runs a per-day Journal — a conversational surface where the user narrates
their day. Each day has its own conversation. The first assistant message in a day's
conversation is the **daily prep**: an LLM-generated briefing covering today's tasks,
calendar events, weather, active projects, and recent journal context. Subsequent turns
are user/assistant journaling exchanges; the LLM may emit **Moments** (small structured
extractions) via the `record_moment` tool during the conversation.
Use `fable_get_today_journal` to inspect today's prep + conversation. Use
`fable_get_journal_day` for past days. Use `fable_list_moments` to query the structured
journal extractions across days. Use `fable_trigger_journal_prep` to force-regenerate
today's prep prose.
## Admin logs
`fable_get_app_logs` requires an admin-scoped API key. Regular user keys will be rejected.
"""
mcp = FastMCP("fable", instructions=_INSTRUCTIONS)
# ---------------------------------------------------------------------------
# Notes
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_notes(
limit: int = 20,
offset: int = 0,
tag: str = "",
search_text: str = "",
) -> dict:
"""List notes (non-task documents) stored in Fable.
Optionally filter by a single tag (plain string, no # prefix) or a keyword search
against title and body. Results are ordered by last-updated descending.
Use fable_search for semantic/meaning-based lookup instead of exact keyword search.
"""
async with FableClient() as client:
return await notes.list_notes(
client,
limit=limit,
offset=offset,
tag=tag or None,
search=search_text or None,
)
@mcp.tool()
async def fable_get_note(note_id: int) -> dict:
"""Fetch the full content of a single Fable note by its ID.
Returns id, title, body (markdown), tags, project_id, created_at, updated_at.
"""
async with FableClient() as client:
return await notes.get_note(client, note_id=note_id)
@mcp.tool()
async def fable_create_note(
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Create a new note in Fable.
Args:
title: Note title (required).
body: Markdown content. Supports [[wikilinks]] to other notes by title.
tags: List of plain-string tags without # prefix, e.g. ["python", "ideas"].
project_id: Associate with a project (use 0 for no project / orphan note).
Returns the created note object including its assigned id.
"""
async with FableClient() as client:
return await notes.create_note(
client,
title=title,
body=body,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_update_note(
note_id: int,
title: str = "",
body: str = "",
tags: list[str] | None = None,
project_id: int = 0,
) -> dict:
"""Update an existing Fable note. Only explicitly provided fields are changed.
Args:
note_id: ID of the note to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
tags: Replaces the full tag list. Pass [] to clear all tags. Omit to leave unchanged.
project_id: New project association (0 = remove from project). Omit to leave unchanged.
"""
async with FableClient() as client:
return await notes.update_note(
client,
note_id=note_id,
title=title or None,
body=body or None,
tags=tags,
project_id=project_id or None,
)
@mcp.tool()
async def fable_delete_note(note_id: int) -> str:
"""Permanently delete a Fable note by ID. This cannot be undone."""
async with FableClient() as client:
await notes.delete_note(client, note_id=note_id)
return f"Note {note_id} deleted."
# ---------------------------------------------------------------------------
# Tasks
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_tasks(
limit: int = 20,
offset: int = 0,
status: str = "",
project_id: int = 0,
) -> dict:
"""List tasks in Fable.
Args:
status: Filter by status — one of: todo, in_progress, done, cancelled. Omit for all.
project_id: Filter to a specific project. Use 0 for no filter.
Results are ordered by last-updated descending.
"""
async with FableClient() as client:
return await tasks.list_tasks(
client,
limit=limit,
offset=offset,
status=status or None,
project_id=project_id or None,
)
@mcp.tool()
async def fable_get_task(task_id: int) -> dict:
"""Fetch a single Fable task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
parent_id, parent_title, due_date, created_at, updated_at.
"""
async with FableClient() as client:
return await tasks.get_task(client, task_id=task_id)
@mcp.tool()
async def fable_create_task(
title: str,
body: str = "",
status: str = "todo",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
) -> dict:
"""Create a new task in Fable.
Args:
title: Task title (required).
body: Markdown description / notes for the task.
status: Initial status — one of: todo (default), in_progress, done, cancelled.
priority: One of: low, medium, high. Omit for no priority (defaults to "none").
project_id: Associate with a project (0 = no project).
milestone_id: Place within a project milestone (0 = no milestone).
parent_id: Make this a sub-task of another task (0 = top-level).
tags: List of plain-string tags without # prefix.
Returns the created task object including its assigned id.
"""
async with FableClient() as client:
return await tasks.create_task(
client,
title=title,
body=body,
status=status,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
)
@mcp.tool()
async def fable_update_task(
task_id: int,
title: str = "",
body: str = "",
status: str = "",
priority: str = "",
project_id: int = 0,
milestone_id: int = 0,
) -> dict:
"""Update an existing Fable task. Only explicitly provided fields are changed.
Args:
task_id: ID of the task to update.
title: New title, or omit to leave unchanged.
body: New markdown body, or omit to leave unchanged.
status: New status — one of: todo, in_progress, done, cancelled.
priority: New priority — one of: none, low, medium, high.
project_id: New project (0 = remove from project). Omit to leave unchanged.
milestone_id: New milestone (0 = remove from milestone). Omit to leave unchanged.
"""
async with FableClient() as client:
return await tasks.update_task(
client,
task_id=task_id,
title=title or None,
body=body or None,
status=status or None,
priority=priority or None,
project_id=project_id or None,
milestone_id=milestone_id or None,
)
@mcp.tool()
async def fable_add_task_log(task_id: int, content: str) -> dict:
"""Append a timestamped progress log entry to a Fable task.
Use this to record work sessions, decisions, or status updates over time without
overwriting the task's main body. Each entry is stored separately and shown
chronologically in the task view.
"""
async with FableClient() as client:
return await tasks.add_task_log(client, task_id=task_id, content=content)
# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_projects() -> dict:
"""List all Fable projects for the current user.
Returns id, title, description, goal, status (active/archived), color,
and a short auto-generated summary for each project.
"""
async with FableClient() as client:
return await projects.list_projects(client)
@mcp.tool()
async def fable_get_project(project_id: int) -> dict:
"""Fetch a Fable project by ID, including its milestone summary.
Returns full project fields plus a milestone_summary list with each milestone's
id, title, status, and task counts.
"""
async with FableClient() as client:
return await projects.get_project(client, project_id=project_id)
@mcp.tool()
async def fable_create_project(
title: str,
description: str = "",
goal: str = "",
status: str = "active",
color: str = "",
) -> dict:
"""Create a new project in Fable.
Args:
title: Project name (required).
description: Short summary of what the project is.
goal: The desired outcome or definition of done for the project.
status: active (default) or archived.
color: Optional hex colour for the project card (e.g. "#6366f1").
Returns the created project object including its assigned id.
"""
async with FableClient() as client:
return await projects.create_project(
client,
title=title,
description=description,
goal=goal or None,
status=status,
color=color or None,
)
@mcp.tool()
async def fable_update_project(
project_id: int,
title: str = "",
description: str = "",
goal: str = "",
status: str = "",
color: str = "",
) -> dict:
"""Update an existing Fable project. Only explicitly provided fields are changed.
Args:
project_id: ID of the project to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
goal: New goal/definition-of-done, or omit to leave unchanged.
status: New status — active or archived.
color: New hex colour, or omit to leave unchanged.
"""
async with FableClient() as client:
return await projects.update_project(
client,
project_id=project_id,
title=title or None,
description=description or None,
goal=goal or None,
status=status or None,
color=color or None,
)
# ---------------------------------------------------------------------------
# Milestones
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_milestones(project_id: int) -> dict:
"""List milestones for a Fable project, ordered by order_index.
Returns id, title, description, status (active/done), order_index, and task counts.
"""
async with FableClient() as client:
return await milestones.list_milestones(client, project_id=project_id)
@mcp.tool()
async def fable_create_milestone(
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict:
"""Create a milestone within a Fable project.
Args:
project_id: The project this milestone belongs to (required).
title: Milestone name (required).
description: Optional description of what this milestone covers.
status: active (default) or done.
Returns the created milestone including its assigned id.
"""
async with FableClient() as client:
return await milestones.create_milestone(
client,
project_id=project_id,
title=title,
description=description,
status=status,
)
@mcp.tool()
async def fable_update_milestone(
project_id: int,
milestone_id: int,
title: str = "",
description: str = "",
status: str = "",
order_index: int = -1,
) -> dict:
"""Update a Fable milestone. Only explicitly provided fields are changed.
Args:
project_id: Project the milestone belongs to.
milestone_id: ID of the milestone to update.
title: New title, or omit to leave unchanged.
description: New description, or omit to leave unchanged.
status: New status — active or done.
order_index: New display position (0-based). Use -1 to leave unchanged.
"""
async with FableClient() as client:
return await milestones.update_milestone(
client,
project_id=project_id,
milestone_id=milestone_id,
title=title or None,
description=description or None,
status=status or None,
order_index=order_index if order_index >= 0 else None,
)
# ---------------------------------------------------------------------------
# Search
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_search(
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict:
"""Semantic search over Fable notes and tasks using embedding similarity.
Finds content by meaning rather than exact keywords. Use this to discover
relevant records when you don't know the exact title or tags.
Args:
q: Natural-language query string.
content_type: "note", "task", or "all" (default).
limit: Maximum number of results (default 10).
Returns results ordered by cosine similarity, each with id, title, body snippet, and tags.
"""
async with FableClient() as client:
return await search.search(client, q=q, content_type=content_type, limit=limit)
# ---------------------------------------------------------------------------
# Chat
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_conversations(limit: int = 20, offset: int = 0) -> dict:
"""List chat conversations stored in Fable, ordered by last activity.
Returns id, title, message_count, created_at, updated_at for each conversation.
Use the id with fable_send_message to continue a specific conversation.
"""
async with FableClient() as client:
return await chat.list_conversations(client, limit=limit, offset=offset)
@mcp.tool()
async def fable_send_message(
message: str,
conversation_id: str = "",
think: bool = False,
) -> dict:
"""Send a natural-language message to Fable's built-in LLM and receive the full response.
Fable handles tool use, RAG context injection, and conversation history internally.
The LLM can create/update notes and tasks, search, manage projects, and more — all
driven by natural language without you needing to call individual tools.
Use this when:
- The request is conversational or exploratory
- You want Fable's RAG to automatically surface relevant notes as context
- The task is complex enough to benefit from Fable's internal reasoning
Use the direct CRUD tools (fable_create_note, etc.) instead when you need
structured data back or are performing bulk/programmatic operations.
Args:
message: The user message to send.
conversation_id: Continue an existing conversation by passing its id.
Omit to start a new conversation.
think: Enable extended reasoning mode for complex multi-step requests.
Returns conversation_id (for follow-up messages), the assistant response text,
and a list of any tool_call events that fired during generation.
"""
async with FableClient() as client:
return await chat.send_message(
client,
message=message,
conversation_id=conversation_id or None,
think=think,
)
# ---------------------------------------------------------------------------
# Admin / observability
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_app_logs(
category: str = "error",
limit: int = 20,
search: str = "",
) -> dict:
"""Fetch Fable application logs. Requires an admin-scoped API key.
Args:
category: Log category — "error" (default), "audit", "usage", or "generation".
limit: Maximum number of log entries to return.
search: Optional keyword filter matched against action, endpoint, username, details.
Returns a list of log entries ordered by most recent first.
Regular user API keys will receive a 403 — only admin keys are accepted.
"""
async with FableClient() as client:
return await admin.get_app_logs(
client,
category=category,
limit=limit,
search=search or None,
)
# ---------------------------------------------------------------------------
# Journal — daily prep, day payloads, moments
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_today_journal() -> dict:
"""Fetch today's Journal day payload.
Creates today's journal conversation and generates the daily prep
message if neither exists yet. Returns:
{
"day_date": "YYYY-MM-DD",
"conversation": { id, title, conversation_type, day_date, ... },
"messages": [ ... ordered list of messages ... ]
}
The first assistant message is the daily prep — a conversational
opener generated by the LLM from gathered tasks/events/weather/
projects/recent moments/open threads. Its ``msg_metadata.sections``
carries the underlying structured data for inspection.
"""
async with FableClient() as client:
return await journal.get_today_journal(client)
@mcp.tool()
async def fable_get_journal_day(iso_date: str) -> dict:
"""Fetch a specific day's Journal payload by ISO date.
Args:
iso_date: YYYY-MM-DD format date.
Returns the same shape as fable_get_today_journal. If no journal
exists for that day, ``conversation`` and ``messages`` will be
null/empty respectively.
"""
async with FableClient() as client:
return await journal.get_journal_day(client, iso_date=iso_date)
@mcp.tool()
async def fable_list_journal_days() -> dict:
"""List dates that have journal content for the current user, newest first.
Returns ``{"days": ["YYYY-MM-DD", ...]}``. Use these dates to query
specific days via ``fable_get_journal_day``.
"""
async with FableClient() as client:
return await journal.list_journal_days(client)
@mcp.tool()
async def fable_trigger_journal_prep(iso_date: str = "") -> dict:
"""Force-regenerate the daily prep prose for today (or a specific day).
The prep is the first assistant message in a day's journal — a
conversational LLM-generated briefing built from tasks/events/weather/
projects/recent moments/open threads. Use this to iterate on the prep
prompt or refresh after data changes.
Args:
iso_date: Optional YYYY-MM-DD. If empty, regenerates today.
Returns ``{"ok": true, "message_id": ...}``.
"""
async with FableClient() as client:
return await journal.trigger_journal_prep(
client, iso_date=iso_date or None,
)
@mcp.tool()
async def fable_get_journal_config() -> dict:
"""Fetch the user's journal config.
Includes prep schedule (prep_enabled / prep_hour / prep_minute),
day rollover hour, phase boundaries, and any locations / temp_unit
used for the prep's weather section.
"""
async with FableClient() as client:
return await journal.get_journal_config(client)
@mcp.tool()
async def fable_list_moments(
query: str = "",
person_id: int = 0,
place_id: int = 0,
tag: str = "",
date_from: str = "",
date_to: str = "",
pinned_only: bool = False,
limit: int = 50,
) -> dict:
"""Search/list journal Moments — small structured extractions the LLM emits during journaling.
All filters are optional and combinable. Without a query, returns
moments ordered by occurred_at DESC. With a query, returns
semantically-ranked moments above the similarity threshold.
Args:
query: Optional semantic query string.
person_id: Filter to moments mentioning this person (0 = no filter).
place_id: Filter to moments mentioning this place (0 = no filter).
tag: Filter to moments with this tag.
date_from: ISO YYYY-MM-DD lower bound (inclusive).
date_to: ISO YYYY-MM-DD upper bound (inclusive).
pinned_only: If True, only return pinned moments.
limit: Max results, default 50.
Returns ``{"moments": [...]}`` where each moment has id, day_date,
occurred_at, content, raw_excerpt, tags, people, places, task_ids,
note_ids, pinned, and (when query set) score.
"""
async with FableClient() as client:
return await journal.list_moments(
client,
query=query or None,
person_id=person_id if person_id else None,
place_id=place_id if place_id else None,
tag=tag or None,
date_from=date_from or None,
date_to=date_to or None,
pinned_only=pinned_only,
limit=limit,
)
# ---------------------------------------------------------------------------
# Generic conversation access
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_get_conversation(conversation_id: int) -> dict:
"""Fetch any conversation (chat or journal) with its full message list.
Returns conversation metadata plus an ordered ``messages`` array.
Each message includes role, content, tool_calls (with results),
context_note_id, and msg_metadata. Tool calls are in the stored
flat format: ``[{"function": name, "arguments": {...}, "result": {...}}]``.
Useful for inspecting journal preps, chat history, or verifying
that a tool actually ran with the expected arguments.
"""
async with FableClient() as client:
return await journal.get_conversation(
client, conversation_id=conversation_id,
)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
# Validate env vars at startup — raises ValueError with a clear message if missing.
FableClient()
mcp.run(transport="stdio")
if __name__ == "__main__":
main()
-20
View File
@@ -1,20 +0,0 @@
"""Admin tools: application log access (requires admin API key)."""
from __future__ import annotations
from fable_mcp.client import FableClient
async def get_app_logs(
client: FableClient,
category: str = "error",
limit: int = 20,
search: str | None = None,
) -> dict:
"""Fetch application logs from Fable. Requires an admin-scoped API key.
category: "error" | "audit" | "usage" | "generation" (default: "error")
"""
params: dict = {"category": category, "limit": limit}
if search:
params["search"] = search
return await client.get("/api/admin/logs", params=params)
-95
View File
@@ -1,95 +0,0 @@
"""MCP tools for Fable chat — create conversations and stream responses."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from fable_mcp.client import FableClient, FableAPIError
async def list_conversations(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
) -> dict[str, Any]:
"""List MCP chat conversations."""
params: dict[str, Any] = {"limit": limit, "offset": offset, "type": "mcp"}
return await client.get("/api/chat/conversations", params=params)
async def send_message(
client: FableClient,
*,
message: str,
conversation_id: str | None = None,
think: bool = False,
) -> dict[str, Any]:
"""Send a message to Fable and return the full assistant response.
Creates a new MCP conversation if conversation_id is None.
Posts the user message to start generation, then streams the SSE buffer.
SSE event format:
id: <N>
event: <type> # chunk | done | tool_call | status | ...
data: <json>
Returns:
Dict with:
- "conversation_id": str
- "response": str (full assistant message)
- "tool_calls": list of any tool_call events observed
"""
if conversation_id is None:
conv = await client.post(
"/api/chat/conversations",
json={"conversation_type": "mcp"},
)
conversation_id = str(conv["id"])
# Start generation
await client.post(
f"/api/chat/conversations/{conversation_id}/messages",
json={"content": message, "think": think},
)
tokens: list[str] = []
tool_calls: list[Any] = []
stream_path = f"/api/chat/conversations/{conversation_id}/generation/stream"
# Retry connecting to the stream briefly — the background task may not have
# created the generation buffer by the time we issue the GET.
for attempt in range(10):
try:
event_type: str | None = None
async for raw_line in client.stream_get(stream_path):
if raw_line.startswith("event: "):
event_type = raw_line[len("event: "):].strip()
elif raw_line.startswith("data: "):
payload_str = raw_line[len("data: "):]
try:
data = json.loads(payload_str)
except json.JSONDecodeError:
continue
if event_type == "chunk":
tokens.append(data.get("chunk", ""))
elif event_type == "tool_call":
tool_calls.append(data.get("tool_call", data))
elif event_type == "done":
break
event_type = None # reset after consuming data line
break # stream completed successfully
except FableAPIError as exc:
if exc.status_code == 404 and attempt < 9:
await asyncio.sleep(0.3)
continue
raise
return {
"conversation_id": conversation_id,
"response": "".join(tokens),
"tool_calls": tool_calls,
}
-96
View File
@@ -1,96 +0,0 @@
"""MCP tools for inspecting and controlling the Fable Scribe Journal."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
# ── Day payloads ─────────────────────────────────────────────────────────────
async def get_today_journal(client: FableClient) -> dict[str, Any]:
"""Fetch today's journal day payload (creates today's conversation + prep if needed)."""
return await client.get("/api/journal/today")
async def get_journal_day(client: FableClient, *, iso_date: str) -> dict[str, Any]:
"""Fetch a specific day's journal payload by ISO date (YYYY-MM-DD)."""
return await client.get(f"/api/journal/day/{iso_date}")
async def list_journal_days(client: FableClient) -> dict[str, Any]:
"""List dates that have journal content for the current user."""
return await client.get("/api/journal/days")
# ── Daily prep ───────────────────────────────────────────────────────────────
async def trigger_journal_prep(
client: FableClient,
*,
iso_date: str | None = None,
) -> dict[str, Any]:
"""Force-regenerate the daily prep for today (or a specific day)."""
payload: dict[str, Any] = {}
if iso_date:
payload["date"] = iso_date
return await client.post("/api/journal/trigger-prep", json=payload)
# ── Config ───────────────────────────────────────────────────────────────────
async def get_journal_config(client: FableClient) -> dict[str, Any]:
"""Fetch the user's journal config (prep schedule, day rollover, locations, etc.)."""
return await client.get("/api/journal/config")
# ── Moments ──────────────────────────────────────────────────────────────────
async def list_moments(
client: FableClient,
*,
query: str | None = None,
person_id: int | None = None,
place_id: int | None = None,
tag: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
pinned_only: bool = False,
limit: int = 50,
) -> dict[str, Any]:
"""List/search journal moments with optional filters.
All params are optional. Returns a dict with a ``moments`` array.
"""
params: dict[str, str] = {"limit": str(limit)}
if query:
params["query"] = query
if person_id is not None:
params["person_id"] = str(person_id)
if place_id is not None:
params["place_id"] = str(place_id)
if tag:
params["tag"] = tag
if date_from:
params["date_from"] = date_from
if date_to:
params["date_to"] = date_to
if pinned_only:
params["pinned_only"] = "true"
return await client.get("/api/journal/moments", params=params)
# ── Generic conversation access (still works for journal conversations) ───────
async def get_conversation(
client: FableClient,
*,
conversation_id: int,
) -> dict[str, Any]:
"""Fetch any conversation (chat or journal) with its full message list."""
return await client.get(f"/api/chat/conversations/{conversation_id}")
-53
View File
@@ -1,53 +0,0 @@
"""MCP tools for Fable milestones."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_milestones(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""List milestones for a project."""
return await client.get(f"/api/projects/{project_id}/milestones")
async def create_milestone(
client: FableClient,
*,
project_id: int,
title: str,
description: str = "",
status: str = "active",
) -> dict[str, Any]:
"""Create a milestone within a project."""
payload: dict[str, Any] = {
"title": title,
"description": description,
"status": status,
}
return await client.post(f"/api/projects/{project_id}/milestones", json=payload)
async def update_milestone(
client: FableClient,
*,
project_id: int,
milestone_id: int,
title: str | None = None,
description: str | None = None,
status: str | None = None,
order_index: int | None = None,
) -> dict[str, Any]:
"""Update an existing milestone."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if status is not None:
payload["status"] = status
if order_index is not None:
payload["order_index"] = order_index
return await client.patch(
f"/api/projects/{project_id}/milestones/{milestone_id}", json=payload
)
-72
View File
@@ -1,72 +0,0 @@
"""MCP tools for Fable notes."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_notes(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
tag: str | None = None,
search: str | None = None,
) -> dict[str, Any]:
"""List notes with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if tag:
params["tag"] = tag
if search:
params["search"] = search
return await client.get("/api/notes", params=params)
async def get_note(client: FableClient, *, note_id: int) -> dict[str, Any]:
"""Fetch a single note by ID."""
return await client.get(f"/api/notes/{note_id}")
async def create_note(
client: FableClient,
*,
title: str,
body: str = "",
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Create a new note."""
payload: dict[str, Any] = {"title": title, "body": body}
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.post("/api/notes", json=payload)
async def update_note(
client: FableClient,
*,
note_id: int,
title: str | None = None,
body: str | None = None,
tags: list[str] | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing note."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if tags is not None:
payload["tags"] = tags
if project_id is not None:
payload["project_id"] = project_id
return await client.patch(f"/api/notes/{note_id}", json=payload)
async def delete_note(client: FableClient, *, note_id: int) -> None:
"""Delete a note by ID."""
await client.delete(f"/api/notes/{note_id}")
-59
View File
@@ -1,59 +0,0 @@
"""MCP tools for Fable projects."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_projects(client: FableClient) -> dict[str, Any]:
"""List all projects for the authenticated user."""
return await client.get("/api/projects")
async def get_project(client: FableClient, *, project_id: int) -> dict[str, Any]:
"""Fetch a project by ID (includes milestone summary)."""
return await client.get(f"/api/projects/{project_id}")
async def create_project(
client: FableClient,
*,
title: str,
description: str = "",
goal: str | None = None,
status: str = "active",
color: str | None = None,
) -> dict[str, Any]:
"""Create a new project."""
payload: dict[str, Any] = {"title": title, "description": description, "status": status}
if goal is not None:
payload["goal"] = goal
if color is not None:
payload["color"] = color
return await client.post("/api/projects", json=payload)
async def update_project(
client: FableClient,
*,
project_id: int,
title: str | None = None,
description: str | None = None,
goal: str | None = None,
status: str | None = None,
color: str | None = None,
) -> dict[str, Any]:
"""Update an existing project."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if description is not None:
payload["description"] = description
if goal is not None:
payload["goal"] = goal
if status is not None:
payload["status"] = status
if color is not None:
payload["color"] = color
return await client.patch(f"/api/projects/{project_id}", json=payload)
-30
View File
@@ -1,30 +0,0 @@
"""MCP tool for semantic search across Fable notes and tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def search(
client: FableClient,
*,
q: str,
content_type: str = "all",
limit: int = 10,
) -> dict[str, Any]:
"""Semantic search over notes and/or tasks.
Args:
q: Search query string.
content_type: One of "note", "task", or "all" (default).
limit: Maximum number of results to return.
Returns:
Dict with "results" list and "total" count.
Each result has: id, title, body, is_task, tags, similarity.
"""
params: dict[str, Any] = {"q": q, "limit": limit}
if content_type != "all":
params["content_type"] = content_type
return await client.get("/api/search", params=params)
-93
View File
@@ -1,93 +0,0 @@
"""MCP tools for Fable tasks."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_tasks(
client: FableClient,
*,
limit: int = 20,
offset: int = 0,
status: str | None = None,
project_id: int | None = None,
) -> dict[str, Any]:
"""List tasks with optional filtering."""
params: dict[str, Any] = {"limit": limit, "offset": offset}
if status:
params["status"] = status
if project_id is not None:
params["project_id"] = project_id
return await client.get("/api/tasks", params=params)
async def get_task(client: FableClient, *, task_id: int) -> dict[str, Any]:
"""Fetch a single task by ID (includes parent_title)."""
return await client.get(f"/api/tasks/{task_id}")
async def create_task(
client: FableClient,
*,
title: str,
body: str = "",
status: str = "todo",
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
parent_id: int | None = None,
tags: list[str] | None = None,
) -> dict[str, Any]:
"""Create a new task."""
payload: dict[str, Any] = {"title": title, "body": body, "status": status}
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
if parent_id is not None:
payload["parent_id"] = parent_id
if tags is not None:
payload["tags"] = tags
return await client.post("/api/tasks", json=payload)
async def update_task(
client: FableClient,
*,
task_id: int,
title: str | None = None,
body: str | None = None,
status: str | None = None,
priority: str | None = None,
project_id: int | None = None,
milestone_id: int | None = None,
) -> dict[str, Any]:
"""Update an existing task."""
payload: dict[str, Any] = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if status is not None:
payload["status"] = status
if priority is not None:
payload["priority"] = priority
if project_id is not None:
payload["project_id"] = project_id
if milestone_id is not None:
payload["milestone_id"] = milestone_id
return await client.patch(f"/api/tasks/{task_id}", json=payload)
async def add_task_log(
client: FableClient,
*,
task_id: int,
content: str,
) -> dict[str, Any]:
"""Append a log entry to a task."""
return await client.post(f"/api/tasks/{task_id}/logs", json={"content": content})
-24
View File
@@ -1,24 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "fable-mcp"
version = "0.3.0"
description = "MCP server for Fabled Scribe"
requires-python = ">=3.12"
dependencies = [
"mcp[cli]>=1.0",
"httpx>=0.27",
"python-dotenv>=1.0",
]
[project.scripts]
fable-mcp = "fable_mcp.server:main"
[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "respx>=0.21"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
View File
-12
View File
@@ -1,12 +0,0 @@
"""Shared pytest fixtures for fable-mcp tests."""
from unittest.mock import AsyncMock, MagicMock
import pytest
@pytest.fixture
def mock_client():
"""A mock FableClient that returns empty responses by default."""
client = AsyncMock()
client.__aenter__ = AsyncMock(return_value=client)
client.__aexit__ = AsyncMock(return_value=False)
return client
-142
View File
@@ -1,142 +0,0 @@
"""Tests for FableClient."""
import os
import pytest
import respx
import httpx
from unittest.mock import patch
from fable_mcp.client import FableClient, FableAPIError, get_client, init_client, _reset_client
@pytest.fixture(autouse=True)
def reset_singleton():
"""Reset the module-level client singleton between tests."""
_reset_client()
yield
_reset_client()
@pytest.fixture
def base_env(monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
class TestFableClientInit:
def test_missing_fable_url_raises(self, monkeypatch):
monkeypatch.delenv("FABLE_URL", raising=False)
monkeypatch.setenv("FABLE_API_KEY", "fmcp_testkey")
with pytest.raises(ValueError, match="FABLE_URL"):
FableClient()
def test_missing_api_key_raises(self, monkeypatch):
monkeypatch.setenv("FABLE_URL", "http://fable.test")
monkeypatch.delenv("FABLE_API_KEY", raising=False)
with pytest.raises(ValueError, match="FABLE_API_KEY"):
FableClient()
def test_trailing_slash_stripped(self, base_env):
with patch.dict(os.environ, {"FABLE_URL": "http://fable.test/"}):
client = FableClient()
assert client.base_url == "http://fable.test"
def test_auth_header_set(self, base_env):
client = FableClient()
assert client._headers["Authorization"] == "Bearer fmcp_testkey"
class TestFableClientHTTP:
@respx.mock
@pytest.mark.asyncio
async def test_get_returns_json(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(200, json={"notes": []})
)
async with FableClient() as client:
data = await client.get("/api/notes")
assert data == {"notes": []}
@respx.mock
@pytest.mark.asyncio
async def test_non_2xx_raises_fable_api_error(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(401, json={"error": "Unauthorized"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert exc_info.value.status_code == 401
@respx.mock
@pytest.mark.asyncio
async def test_post_sends_json(self, base_env):
respx.post("http://fable.test/api/notes").mock(
return_value=httpx.Response(201, json={"id": 1, "title": "Test"})
)
async with FableClient() as client:
data = await client.post("/api/notes", json={"title": "Test"})
assert data["id"] == 1
@respx.mock
@pytest.mark.asyncio
async def test_patch_sends_json(self, base_env):
respx.patch("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(200, json={"id": 1, "title": "Updated"})
)
async with FableClient() as client:
data = await client.patch("/api/notes/1", json={"title": "Updated"})
assert data["title"] == "Updated"
@respx.mock
@pytest.mark.asyncio
async def test_delete_returns_none_on_204(self, base_env):
respx.delete("http://fable.test/api/notes/1").mock(
return_value=httpx.Response(204)
)
async with FableClient() as client:
result = await client.delete("/api/notes/1")
assert result is None
@respx.mock
@pytest.mark.asyncio
async def test_fable_api_error_includes_message(self, base_env):
respx.get("http://fable.test/api/notes").mock(
return_value=httpx.Response(404, json={"error": "Not found"})
)
async with FableClient() as client:
with pytest.raises(FableAPIError) as exc_info:
await client.get("/api/notes")
assert "Not found" in str(exc_info.value)
assert exc_info.value.status_code == 404
class TestSingleton:
def test_get_client_before_init_raises(self):
with pytest.raises(RuntimeError, match="init_client"):
get_client()
def test_init_client_returns_instance(self, base_env):
client = init_client()
assert isinstance(client, FableClient)
def test_get_client_after_init_returns_same(self, base_env):
init_client()
c1 = get_client()
c2 = get_client()
assert c1 is c2
class TestStreamGet:
@respx.mock
@pytest.mark.asyncio
async def test_stream_get_yields_lines(self, base_env):
sse_body = b"data: line1\n\ndata: line2\n\n"
respx.get("http://fable.test/api/stream").mock(
return_value=httpx.Response(200, content=sse_body)
)
lines = []
async with FableClient() as client:
async for line in client.stream_get("/api/stream"):
lines.append(line)
assert "data: line1" in lines
assert "data: line2" in lines
-58
View File
@@ -1,58 +0,0 @@
"""Tests for chat MCP tools."""
import json
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_send_message_creates_conversation_and_streams(client):
from fable_mcp.tools.chat import send_message
client.post = AsyncMock(return_value={"id": "conv-1"})
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Hello"}',
'data: {"type": "token", "content": " world"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id=None)
assert result["conversation_id"] == "conv-1"
assert "Hello world" in result["response"]
@pytest.mark.asyncio
async def test_send_message_reuses_existing_conversation(client):
from fable_mcp.tools.chat import send_message
async def fake_stream(path, **kwargs):
for line in [
'data: {"type": "token", "content": "Reply"}',
'data: {"type": "done"}',
]:
yield line
client.stream_get = fake_stream
result = await send_message(client, message="Hi", conversation_id="existing-conv")
# Should not call post to create a conversation
client.post.assert_not_called()
assert result["conversation_id"] == "existing-conv"
@pytest.mark.asyncio
async def test_list_conversations_calls_get(client):
from fable_mcp.tools.chat import list_conversations
client.get = AsyncMock(return_value={"conversations": []})
await list_conversations(client)
client.get.assert_called_once()
assert "/api/chat/conversations" in client.get.call_args[0][0]
-35
View File
@@ -1,35 +0,0 @@
"""Tests for milestones MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_milestones_calls_correct_endpoint(client):
from fable_mcp.tools.milestones import list_milestones
client.get = AsyncMock(return_value={"milestones": []})
await list_milestones(client, project_id=3)
client.get.assert_called_once_with("/api/projects/3/milestones")
@pytest.mark.asyncio
async def test_create_milestone_posts_to_project_endpoint(client):
from fable_mcp.tools.milestones import create_milestone
client.post = AsyncMock(return_value={"id": 10, "title": "M1"})
await create_milestone(client, project_id=3, title="M1")
path = client.post.call_args[0][0]
assert path == "/api/projects/3/milestones"
assert client.post.call_args[1]["json"]["title"] == "M1"
@pytest.mark.asyncio
async def test_update_milestone_patches(client):
from fable_mcp.tools.milestones import update_milestone
client.patch = AsyncMock(return_value={"id": 10, "status": "completed"})
await update_milestone(client, project_id=3, milestone_id=10, status="completed")
path = client.patch.call_args[0][0]
assert "/api/projects/3/milestones/10" in path
-56
View File
@@ -1,56 +0,0 @@
"""Tests for notes MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_notes_calls_get(client):
from fable_mcp.tools.notes import list_notes
client.get = AsyncMock(return_value={"notes": [], "total": 0})
result = await list_notes(client, limit=10)
client.get.assert_called_once()
call_args = client.get.call_args
assert "/api/notes" in call_args[0][0]
@pytest.mark.asyncio
async def test_get_note_calls_correct_endpoint(client):
from fable_mcp.tools.notes import get_note
client.get = AsyncMock(return_value={"id": 42, "title": "Hello"})
result = await get_note(client, note_id=42)
client.get.assert_called_once_with("/api/notes/42")
assert result["id"] == 42
@pytest.mark.asyncio
async def test_create_note_posts_payload(client):
from fable_mcp.tools.notes import create_note
client.post = AsyncMock(return_value={"id": 1, "title": "My Note"})
result = await create_note(client, title="My Note", body="Content")
client.post.assert_called_once()
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "My Note"
assert call_kwargs["json"]["body"] == "Content"
@pytest.mark.asyncio
async def test_update_note_patches_payload(client):
from fable_mcp.tools.notes import update_note
client.patch = AsyncMock(return_value={"id": 1, "title": "Updated"})
result = await update_note(client, note_id=1, title="Updated")
client.patch.assert_called_once()
path = client.patch.call_args[0][0]
assert "/api/notes/1" in path
@pytest.mark.asyncio
async def test_delete_note_calls_delete(client):
from fable_mcp.tools.notes import delete_note
client.delete = AsyncMock(return_value=None)
await delete_note(client, note_id=5)
client.delete.assert_called_once_with("/api/notes/5")
-42
View File
@@ -1,42 +0,0 @@
"""Tests for projects MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_projects_calls_get(client):
from fable_mcp.tools.projects import list_projects
client.get = AsyncMock(return_value={"projects": []})
await list_projects(client)
client.get.assert_called_once_with("/api/projects")
@pytest.mark.asyncio
async def test_get_project_calls_correct_endpoint(client):
from fable_mcp.tools.projects import get_project
client.get = AsyncMock(return_value={"id": 2, "title": "Proj"})
result = await get_project(client, project_id=2)
client.get.assert_called_once_with("/api/projects/2")
@pytest.mark.asyncio
async def test_create_project_posts_payload(client):
from fable_mcp.tools.projects import create_project
client.post = AsyncMock(return_value={"id": 4, "title": "New"})
await create_project(client, title="New", description="Desc")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New"
assert call_kwargs["json"]["description"] == "Desc"
@pytest.mark.asyncio
async def test_update_project_patches(client):
from fable_mcp.tools.projects import update_project
client.patch = AsyncMock(return_value={"id": 4, "status": "active"})
await update_project(client, project_id=4, status="active")
assert "/api/projects/4" in client.patch.call_args[0][0]
-54
View File
@@ -1,54 +0,0 @@
"""Tests for tasks MCP tools."""
import pytest
from unittest.mock import AsyncMock
@pytest.fixture
def client(mock_client):
return mock_client
@pytest.mark.asyncio
async def test_list_tasks_calls_get(client):
from fable_mcp.tools.tasks import list_tasks
client.get = AsyncMock(return_value={"tasks": [], "total": 0})
await list_tasks(client)
client.get.assert_called_once()
assert "/api/tasks" in client.get.call_args[0][0]
@pytest.mark.asyncio
async def test_get_task_calls_correct_endpoint(client):
from fable_mcp.tools.tasks import get_task
client.get = AsyncMock(return_value={"id": 7, "title": "Fix bug"})
result = await get_task(client, task_id=7)
client.get.assert_called_once_with("/api/tasks/7")
@pytest.mark.asyncio
async def test_create_task_posts_payload(client):
from fable_mcp.tools.tasks import create_task
client.post = AsyncMock(return_value={"id": 3, "title": "New task"})
await create_task(client, title="New task")
call_kwargs = client.post.call_args[1]
assert call_kwargs["json"]["title"] == "New task"
@pytest.mark.asyncio
async def test_update_task_patches(client):
from fable_mcp.tools.tasks import update_task
client.patch = AsyncMock(return_value={"id": 3, "status": "done"})
await update_task(client, task_id=3, status="done")
client.patch.assert_called_once()
assert "/api/tasks/3" in client.patch.call_args[0][0]
@pytest.mark.asyncio
async def test_add_task_log_posts_to_logs_endpoint(client):
from fable_mcp.tools.tasks import add_task_log
client.post = AsyncMock(return_value={"id": 1, "content": "progress"})
await add_task_log(client, task_id=9, content="progress")
client.post.assert_called_once()
path = client.post.call_args[0][0]
assert path == "/api/tasks/9/logs"
assert client.post.call_args[1]["json"]["content"] == "progress"
-771
View File
@@ -1,771 +0,0 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "attrs"
version = "26.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
{ url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
{ url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
{ url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
{ url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
{ url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
{ url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
{ url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
{ url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "click"
version = "8.3.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "46.0.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
]
[[package]]
name = "fable-mcp"
version = "0.3.0"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "mcp", extra = ["cli"] },
{ name = "python-dotenv" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "respx" },
]
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.27" },
{ name = "mcp", extras = ["cli"], specifier = ">=1.0" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
{ name = "python-dotenv", specifier = ">=1.0" },
{ name = "respx", marker = "extra == 'dev'", specifier = ">=0.21" },
]
provides-extras = ["dev"]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "httpx-sse"
version = "0.4.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jsonschema"
version = "4.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "jsonschema-specifications" },
{ name = "referencing" },
{ name = "rpds-py" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
]
[[package]]
name = "jsonschema-specifications"
version = "2025.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "referencing" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "mcp"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "jsonschema" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-multipart" },
{ name = "pywin32", marker = "sys_platform == 'win32'" },
{ name = "sse-starlette" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[package.optional-dependencies]
cli = [
{ name = "python-dotenv" },
{ name = "typer" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.12.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" },
{ url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" },
{ url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" },
{ url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" },
{ url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" },
{ url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" },
{ url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" },
{ url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" },
{ url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" },
{ url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" },
{ url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" },
{ url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" },
{ url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" },
{ url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
{ url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" },
{ url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" },
{ url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
name = "pyjwt"
version = "2.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.26"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/71/b145a380824a960ebd60e1014256dbb7d2253f2316ff2d73dfd8928ec2c3/python_multipart-0.0.26.tar.gz", hash = "sha256:08fadc45918cd615e26846437f50c5d6d23304da32c341f289a617127b081f17", size = 43501, upload-time = "2026-04-10T14:09:59.473Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/22/f1925cdda983ab66fc8ec6ec8014b959262747e58bdca26a4e3d1da29d56/python_multipart-0.0.26-py3-none-any.whl", hash = "sha256:c0b169f8c4484c13b0dcf2ef0ec3a4adb255c4b7d18d8e420477d2b1dd03f185", size = 28847, upload-time = "2026-04-10T14:09:58.131Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" },
{ url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" },
{ url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" },
{ url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
{ url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
{ url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "referencing"
version = "0.37.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "rpds-py" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
]
[[package]]
name = "respx"
version = "0.23.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/43/98/4e55c9c486404ec12373708d015ebce157966965a5ebe7f28ff2c784d41b/respx-0.23.1.tar.gz", hash = "sha256:242dcc6ce6b5b9bf621f5870c82a63997e8e82bc7c947f9ffe272b8f3dd5a780", size = 29243, upload-time = "2026-04-08T14:37:16.008Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/4a/221da6ca167db45693d8d26c7dc79ccfc978a440251bf6721c9aaf251ac0/respx-0.23.1-py2.py3-none-any.whl", hash = "sha256:b18004b029935384bccfa6d7d9d74b4ec9af73a081cc28600fffc0447f4b8c1a", size = 25557, upload-time = "2026-04-08T14:37:14.613Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rpds-py"
version = "0.30.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" },
{ url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" },
{ url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" },
{ url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" },
{ url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" },
{ url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" },
{ url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" },
{ url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" },
{ url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" },
{ url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" },
{ url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" },
{ url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" },
{ url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" },
{ url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" },
{ url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" },
{ url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" },
{ url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" },
{ url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" },
{ url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" },
{ url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" },
{ url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" },
{ url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" },
{ url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" },
{ url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" },
{ url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" },
{ url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" },
{ url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" },
{ url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" },
{ url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" },
{ url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" },
{ url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" },
{ url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" },
{ url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" },
{ url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" },
{ url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" },
{ url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" },
{ url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" },
{ url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" },
{ url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" },
{ url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" },
{ url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" },
{ url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" },
{ url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" },
{ url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" },
{ url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" },
{ url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" },
{ url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" },
{ url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" },
{ url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" },
{ url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" },
{ url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" },
{ url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" },
{ url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" },
{ url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" },
{ url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" },
{ url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" },
{ url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" },
{ url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" },
{ url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "sse-starlette"
version = "3.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "starlette" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/8c/f9290339ef6d79badbc010f067cd769d6601ec11a57d78569c683fb4dd87/sse_starlette-3.3.4.tar.gz", hash = "sha256:aaf92fc067af8a5427192895ac028e947b484ac01edbc3caf00e7e7137c7bef1", size = 32427, upload-time = "2026-03-29T09:00:23.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/7f/3de5402f39890ac5660b86bcf5c03f9d855dad5c4ed764866d7b592b46fd/sse_starlette-3.3.4-py3-none-any.whl", hash = "sha256:84bb06e58939a8b38d8341f1bc9792f06c2b53f48c608dd207582b664fc8f3c1", size = 14330, upload-time = "2026-03-29T09:00:21.846Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "uvicorn"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
-109
View File
@@ -1,109 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
function urlBase64ToUint8Array(base64String: string): Uint8Array<ArrayBuffer> {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const buf = new ArrayBuffer(rawData.length)
const outputArray = new Uint8Array<ArrayBuffer>(buf)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export const usePushStore = defineStore('push', () => {
const isSupported = ref(
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
)
const permission = ref<NotificationPermission>(
'Notification' in window ? Notification.permission : 'denied'
)
const isSubscribed = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function checkSubscription(): Promise<void> {
if (!isSupported.value) return
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
isSubscribed.value = !!sub
} catch {
isSubscribed.value = false
}
}
async function subscribe(): Promise<void> {
if (!isSupported.value) {
error.value = 'Push notifications not supported in this browser'
return
}
loading.value = true
error.value = null
try {
// Request permission
permission.value = await Notification.requestPermission()
if (permission.value !== 'granted') {
error.value = 'Notification permission denied'
return
}
// Fetch VAPID public key
const resp = await fetch('/api/push/vapid-public-key')
if (!resp.ok) {
error.value = 'Push notifications not configured on server'
return
}
const { publicKey } = await resp.json()
// Register service worker
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
// Subscribe
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Save to server
const saveResp = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
if (!saveResp.ok) throw new Error('Failed to save subscription')
isSubscribed.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to subscribe'
} finally {
loading.value = false
}
}
async function unsubscribe(): Promise<void> {
loading.value = true
error.value = null
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
await sub.unsubscribe()
}
isSubscribed.value = false
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to unsubscribe'
} finally {
loading.value = false
}
}
return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe }
})
+2 -34
View File
@@ -1,6 +1,6 @@
import { ref, computed } from "vue";
import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPut, getVoiceStatus } from "@/api/client";
import { apiGet, apiPut } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import type { AppSettings } from "@/types/settings";
@@ -8,32 +8,6 @@ export const useSettingsStore = defineStore("settings", () => {
const settings = ref<AppSettings>({});
const loading = ref(false);
const assistantName = computed(
() => settings.value.assistant_name || "Fable"
);
const defaultModel = computed(
() => settings.value.default_model || ""
);
// Voice status — checked once on login, refreshable from Settings
const voiceEnabled = ref(false);
const voiceSttReady = ref(false);
const voiceTtsReady = ref(false);
async function checkVoiceStatus() {
try {
const s = await getVoiceStatus();
voiceEnabled.value = s.enabled;
voiceSttReady.value = s.enabled && s.stt;
voiceTtsReady.value = s.enabled && s.tts;
} catch {
voiceEnabled.value = false;
voiceSttReady.value = false;
voiceTtsReady.value = false;
}
}
async function fetchSettings() {
loading.value = true;
try {
@@ -60,12 +34,6 @@ export const useSettingsStore = defineStore("settings", () => {
return {
settings,
loading,
assistantName,
defaultModel,
voiceEnabled,
voiceSttReady,
voiceTtsReady,
checkVoiceStatus,
fetchSettings,
updateSettings,
};
+1 -149
View File
@@ -3,7 +3,7 @@ import { ref, computed, watch, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile, type JournalConfig } from "@/api/client";
import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getProfile, updateProfile, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type UserProfile } from "@/api/client";
import type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -373,95 +373,6 @@ function toggleProfileWorkDay(day: string) {
function emptyTagsFetch(): Promise<string[]> { return Promise.resolve([]) }
// ── Journal config (locations, temp unit, prep schedule) ────────────────────
const journalConfig = ref<JournalConfig>({
prep_enabled: true,
prep_hour: 5,
prep_minute: 0,
day_rollover_hour: 4,
closeout_enabled: true,
locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } },
temp_unit: 'C',
})
const journalConfigSaving = ref(false)
const journalConfigSaved = ref(false)
const homeQuery = ref('')
const workQuery = ref('')
const homeGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const workGeoStatus = ref<'' | 'ok' | 'error' | 'pending'>('')
const homeGeoMsg = ref('')
const workGeoMsg = ref('')
async function loadJournalConfig() {
try {
const cfg = await getJournalConfig()
journalConfig.value = {
prep_enabled: cfg.prep_enabled ?? true,
prep_hour: cfg.prep_hour ?? 5,
prep_minute: cfg.prep_minute ?? 0,
day_rollover_hour: cfg.day_rollover_hour ?? 4,
closeout_enabled: cfg.closeout_enabled ?? true,
morning_end_hour: cfg.morning_end_hour,
midday_end_hour: cfg.midday_end_hour,
locations: {
home: cfg.locations?.home ?? { label: 'Home', address: '' },
work: cfg.locations?.work ?? { label: 'Work', address: '' },
},
temp_unit: cfg.temp_unit ?? 'C',
}
homeQuery.value = cfg.locations?.home?.address ?? ''
workQuery.value = cfg.locations?.work?.address ?? ''
} catch { /* non-critical */ }
}
async function geocodeFor(which: 'home' | 'work') {
const query = (which === 'home' ? homeQuery.value : workQuery.value).trim()
const statusRef = which === 'home' ? homeGeoStatus : workGeoStatus
const msgRef = which === 'home' ? homeGeoMsg : workGeoMsg
const label = which === 'home' ? 'Home' : 'Work'
if (!query) {
// Clear location
if (!journalConfig.value.locations) journalConfig.value.locations = {}
journalConfig.value.locations[which] = { label, address: '' }
statusRef.value = ''
msgRef.value = ''
return
}
statusRef.value = 'pending'
msgRef.value = 'Looking up…'
try {
const result = await geocodeAddress(query)
if (!result) {
statusRef.value = 'error'
msgRef.value = `No match for "${query}"`
return
}
if (!journalConfig.value.locations) journalConfig.value.locations = {}
journalConfig.value.locations[which] = {
label,
address: query,
lat: result.lat,
lon: result.lon,
}
statusRef.value = 'ok'
msgRef.value = `Found: ${result.display_name}`
} catch {
statusRef.value = 'error'
msgRef.value = 'Geocoding failed'
}
}
async function saveJournalCfg() {
journalConfigSaving.value = true
journalConfigSaved.value = false
try {
await saveJournalConfig(journalConfig.value)
journalConfigSaved.value = true
setTimeout(() => { journalConfigSaved.value = false }, 2000)
} catch { toastStore.show('Failed to save journal settings', 'error') }
finally { journalConfigSaving.value = false }
}
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
@@ -486,7 +397,6 @@ onMounted(async () => {
await loadProfile();
// Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
await loadJournalConfig();
// Load CalDAV settings
try {
@@ -1330,64 +1240,6 @@ function formatUserDate(iso: string): string {
</div>
</section>
<section class="settings-section full-width">
<h2>Locations</h2>
<p class="section-desc">Home and work locations are used by the journal's daily prep to fetch local weather. Place names are geocoded once on save.</p>
<div class="field">
<label>Home</label>
<div class="location-row">
<input
v-model="homeQuery"
type="text"
class="input"
placeholder="e.g. Brooklyn, NY"
@blur="geocodeFor('home')"
@keydown.enter.prevent="geocodeFor('home')"
/>
</div>
<p v-if="homeGeoStatus === 'ok'" class="geo-msg geo-ok">{{ homeGeoMsg }}</p>
<p v-else-if="homeGeoStatus === 'error'" class="geo-msg geo-error">{{ homeGeoMsg }}</p>
<p v-else-if="homeGeoStatus === 'pending'" class="geo-msg geo-pending">{{ homeGeoMsg }}</p>
</div>
<div class="field">
<label>Work</label>
<div class="location-row">
<input
v-model="workQuery"
type="text"
class="input"
placeholder="e.g. Manhattan, NY"
@blur="geocodeFor('work')"
@keydown.enter.prevent="geocodeFor('work')"
/>
</div>
<p v-if="workGeoStatus === 'ok'" class="geo-msg geo-ok">{{ workGeoMsg }}</p>
<p v-else-if="workGeoStatus === 'error'" class="geo-msg geo-error">{{ workGeoMsg }}</p>
<p v-else-if="workGeoStatus === 'pending'" class="geo-msg geo-pending">{{ workGeoMsg }}</p>
</div>
<div class="field">
<label>Temperature unit</label>
<div class="unit-toggle">
<button
type="button"
class="unit-btn"
:class="{ active: journalConfig.temp_unit === 'C' }"
@click="journalConfig.temp_unit = 'C'"
>Celsius</button>
<button
type="button"
class="unit-btn"
:class="{ active: journalConfig.temp_unit === 'F' }"
@click="journalConfig.temp_unit = 'F'"
>Fahrenheit</button>
</div>
</div>
<div class="actions">
<button class="btn-save" @click="saveJournalCfg" :disabled="journalConfigSaving">{{ journalConfigSaving ? 'Saving…' : 'Save' }}</button>
<span v-if="journalConfigSaved" class="saved-msg">Saved!</span>
</div>
</section>
</div>
<div v-show="activeTab === 'notifications'" class="settings-grid">
+1 -10
View File
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "fabledassistant"
version = "0.1.0"
description = "Self-hosted note-taking and task-tracking app with LLM integration"
description = "Self-hosted note-taking, project-management, and calendar app exposed to Claude via MCP"
requires-python = ">=3.14"
dependencies = [
"quart>=0.19",
@@ -18,11 +18,7 @@ dependencies = [
"aiosmtplib>=3.0",
"caldav>=1.3",
"icalendar>=5.0",
"feedparser>=6.0",
"html2text>=2024.2",
"trafilatura>=1.12",
"APScheduler>=3.10,<4.0",
"pywebpush>=2.0",
"mcp[cli]>=1.0",
"fastembed>=0.4",
]
@@ -33,11 +29,6 @@ dev = [
"pytest-asyncio>=0.23",
"ruff>=0.6",
]
voice = [
"faster-whisper>=1.0",
"kokoro>=0.9",
"soundfile>=0.12",
]
[tool.setuptools.packages.find]
where = ["src"]
+4 -190
View File
@@ -3,25 +3,18 @@ import time
import traceback as tb_module
from pathlib import Path
import httpx
from quart import Quart, g, jsonify, make_response, request, send_from_directory
from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.journal import journal_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
from fabledassistant.routes.images import images_bp
from fabledassistant.routes.milestones import milestones_bp
from fabledassistant.routes.task_logs import task_logs_bp
from fabledassistant.routes.projects import projects_bp
from fabledassistant.routes.push import push_bp
from fabledassistant.routes.fable_mcp_dist import fable_mcp_dist_bp
from fabledassistant.routes.quick_capture import quick_capture_bp
from fabledassistant.routes.settings import settings_bp
from fabledassistant.routes.tasks import tasks_bp
from fabledassistant.routes.groups import groups_bp
@@ -31,7 +24,6 @@ from fabledassistant.routes.users import users_bp
from fabledassistant.routes.api_keys import api_keys_bp
from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.voice import voice_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
from fabledassistant.mcp import mount_mcp
@@ -76,16 +68,11 @@ def create_app() -> Quart:
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(journal_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(fable_mcp_dist_bp)
app.register_blueprint(projects_bp)
app.register_blueprint(push_bp)
app.register_blueprint(quick_capture_bp)
app.register_blueprint(settings_bp)
app.register_blueprint(task_logs_bp)
app.register_blueprint(tasks_bp)
@@ -96,7 +83,6 @@ def create_app() -> Quart:
app.register_blueprint(api_keys_bp)
app.register_blueprint(events_bp)
app.register_blueprint(search_bp)
app.register_blueprint(voice_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
@@ -161,218 +147,46 @@ def create_app() -> Quart:
import asyncio
from fabledassistant.services.embeddings import backfill_note_embeddings
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model
from fabledassistant.services.logging import start_log_retention_loop
from fabledassistant.services.notifications import start_notification_loop
from fabledassistant.services.push import ensure_vapid_keys
start_cleanup_loop()
start_log_retention_loop()
start_notification_loop()
ensure_vapid_keys()
async def _warm_model(model: str) -> None:
"""Warm an already-installed model into VRAM (no pull)."""
from fabledassistant.services.llm import keep_alive_for
try:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
)
logger.info("Warmed model '%s' into VRAM", model)
except Exception:
logger.warning("Failed to warm model '%s'", model, exc_info=True)
async def _prime_kv_cache(user_id: int, model: str) -> None:
"""Send a minimal chat request to prime Ollama's KV cache with the user's system prompt.
This ensures the next real user message only needs to process its own tokens
rather than the full ~4,650-token system prompt, cutting TTFT from ~25s to <1s.
The num_ctx must match what real requests will use so Ollama doesn't reload.
"""
try:
from fabledassistant.services.llm import build_context, keep_alive_for, pick_num_ctx
from fabledassistant.services.tools import get_tools_for_user
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
# Include tool schemas so num_ctx matches real chat requests.
tools = await get_tools_for_user(user_id)
num_ctx = pick_num_ctx(messages, tools=tools)
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": keep_alive_for(model),
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
async def _warm_user_models() -> None:
"""
Pull any user-configured models that are missing from Ollama, then warm
them and prime the KV cache with each user's system prompt.
Handles both default_model (chat) and background_model user overrides.
Falls back silently if no user preferences exist or Ollama is unreachable.
"""
from sqlalchemy import select as sa_select
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
# 1. Collect all user model preferences (both chat and background).
try:
async with async_session() as session:
rows = await session.execute(
sa_select(Setting.user_id, Setting.key, Setting.value).where(
Setting.key.in_(["default_model", "background_model"]),
Setting.value.isnot(None),
Setting.value != "",
)
)
settings_rows: list[tuple[int, str, str]] = list(rows)
except Exception:
logger.debug("Could not read user model preferences from DB", exc_info=True)
return
if not settings_rows:
logger.debug("No user model preferences found; skipping warm-up")
return
# 2. Build the set of unique models to ensure, and the list of
# (user_id, chat_model) pairs for KV-cache priming.
all_models: set[str] = set()
user_chat_models: list[tuple[int, str]] = []
for user_id_val, key, model in settings_rows:
all_models.add(model)
if key == "default_model":
user_chat_models.append((user_id_val, model))
# 3. Ask Ollama which models are currently installed.
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
raw_installed: set[str] = {m["name"] for m in resp.json().get("models", [])}
installed: set[str] = raw_installed | {
n.removesuffix(":latest") for n in raw_installed if n.endswith(":latest")
}
except Exception:
logger.debug("Could not reach Ollama to check installed models", exc_info=True)
return
# 4. Pull any user-configured models that are missing.
for model in all_models:
if model not in installed:
logger.info("User-configured model '%s' not installed; pulling...", model)
await _pull_model(model)
installed.add(model)
# 5. Warm each unique chat model, then prime KV cache per user.
warmed: set[str] = set()
for user_id_val, model in user_chat_models:
if model in installed:
if model not in warmed:
await _warm_model(model)
warmed.add(model)
await _prime_kv_cache(user_id_val, model)
async def _pull_model(model: str, warm: bool = False) -> None:
try:
await ensure_model(model)
except Exception:
logger.warning(
"Failed to ensure model '%s'",
model,
exc_info=True,
)
return
if warm:
await _warm_model(model)
# Pull supporting models without warming them — embedding model and
# the configured background model load on demand without competing
# for VRAM. The chat model is warmed by _warm_user_models() which
# uses each user's *actual* default_model setting; warming
# Config.OLLAMA_MODEL unconditionally (the OLD behaviour) blasted
# the system default into VRAM ahead of the user's real preference,
# which on a single-GPU setup pushed the user's chat model out
# before they ever sent a message.
asyncio.create_task(_pull_model(Config.OLLAMA_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.EMBEDDING_MODEL, warm=False))
asyncio.create_task(_pull_model(Config.OLLAMA_BACKGROUND_MODEL, warm=False))
asyncio.create_task(_warm_user_models())
# After models are pulled, backfill embeddings for existing notes.
# Runs in the background so it never blocks the server from accepting requests.
# Backfill embeddings for any notes that don't have one. Runs in the
# background so it never blocks the server from accepting requests.
async def _delayed_backfill() -> None:
await asyncio.sleep(30) # Give Ollama time to load the embedding model
try:
await backfill_note_embeddings()
except Exception:
logger.warning("Embedding backfill failed", exc_info=True)
try:
from fabledassistant.services.projects import backfill_project_summaries
await backfill_project_summaries()
except Exception:
logger.warning("Project summary backfill failed", exc_info=True)
asyncio.create_task(_delayed_backfill())
# Start journal scheduler (per-user daily prep generation)
from fabledassistant.services.journal_scheduler import start_journal_scheduler
start_journal_scheduler(asyncio.get_running_loop())
# Start event scheduler (reminders + CalDAV pull sync)
# Event scheduler (reminders + CalDAV pull sync)
from fabledassistant.services.event_scheduler import start_event_scheduler
start_event_scheduler(asyncio.get_running_loop())
# Start version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
# Version-pinning scheduler (daily auto-pin scan at 03:00 UTC)
from fabledassistant.services.version_pinning_scheduler import (
start_version_pinning_scheduler,
)
start_version_pinning_scheduler(asyncio.get_running_loop())
# Start curator scheduler (15-min sweep of journal conversations)
from fabledassistant.services.curator_scheduler import start_curator_scheduler
start_curator_scheduler(asyncio.get_running_loop())
# Diagnostic instrumentation — heartbeat, signal handlers, asyncio
# exception hook. Cheap (~1 log line/min), high diagnostic value when
# the app crashes mysteriously. See services/diagnostics.py.
from fabledassistant.services.diagnostics import start_diagnostics
start_diagnostics(asyncio.get_running_loop())
# Voice model loading (enabled via Admin → Config in the UI, or VOICE_ENABLED env var)
from fabledassistant.services.stt import load_stt_model
from fabledassistant.services.tts import load_tts_model
asyncio.create_task(load_stt_model())
asyncio.create_task(load_tts_model())
@app.after_serving
async def shutdown():
from fabledassistant.services.journal_scheduler import stop_journal_scheduler
stop_journal_scheduler()
from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler()
from fabledassistant.services.version_pinning_scheduler import (
stop_version_pinning_scheduler,
)
stop_version_pinning_scheduler()
from fabledassistant.services.curator_scheduler import stop_curator_scheduler
stop_curator_scheduler()
from fabledassistant.services.diagnostics import stop_diagnostics
stop_diagnostics()
-13
View File
@@ -20,7 +20,6 @@ from fabledassistant.models.base import CreatedAtMixin, TimestampMixin # noqa:
from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa: E402, F401
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
@@ -29,7 +28,6 @@ from fabledassistant.models.invitation import InvitationToken # noqa: E402, F40
from fabledassistant.models.embedding import NoteEmbedding # noqa: E402, F401
from fabledassistant.models.image_cache import ImageCache # noqa: E402, F401
from fabledassistant.models.project import Project # noqa: E402, F401
from fabledassistant.models.push_subscription import PushSubscription # noqa: E402, F401
from fabledassistant.models.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
from fabledassistant.models.task_log import TaskLog # noqa: E402, F401
@@ -38,16 +36,5 @@ from fabledassistant.models.note_version import NoteVersion # noqa: E402, F401
from fabledassistant.models.group import Group, GroupMembership # noqa: E402, F401
from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402, F401
from fabledassistant.models.notification import Notification # noqa: E402, F401
from fabledassistant.models.weather_cache import WeatherCache # noqa: E402, F401
from fabledassistant.models.api_key import ApiKey # noqa: E402, F401
from fabledassistant.models.user_profile import UserProfile # noqa: E402, F401
from fabledassistant.models.moment import ( # noqa: E402, F401
Moment,
MomentEmbedding,
moment_people,
moment_places,
moment_tasks,
moment_notes,
)
from fabledassistant.models.generation_tool_log import GenerationToolLog # noqa: E402, F401
from fabledassistant.models.pending_curator_action import PendingCuratorAction # noqa: E402, F401
-106
View File
@@ -1,106 +0,0 @@
import datetime
from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy import inspect
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin, TimestampMixin
class Conversation(Base, TimestampMixin):
__tablename__ = "conversations"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
title: Mapped[str] = mapped_column(Text, default="")
model: Mapped[str] = mapped_column(Text, default="")
# 'chat' (default) or 'journal'. Journal conversations are day-anchored
# and rendered by the /journal view; they are hidden from the main chat list.
conversation_type: Mapped[str] = mapped_column(Text, default="chat", server_default="chat")
# For journal conversations only: the calendar date this conversation covers.
day_date: Mapped[datetime.date | None] = mapped_column(Date, nullable=True)
# NULL = orphan notes only; -1 = all notes; positive int = specific project
rag_project_id: Mapped[int | None] = mapped_column(Integer, nullable=True, default=None)
# Curator scheduler bookkeeping (Phase 2, Fable #172). NULL = never run;
# scheduler processes journal conversations where this is NULL OR older
# than the most recent user message. See services/curator_scheduler.py.
last_curator_run_at: Mapped[datetime.datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
# Curator's most recent one-line summary of what was captured (Phase 3,
# Fable #172). Injected into the next chat turn's system prompt so the
# chat model stays aware of recent topics without needing tools to
# retrieve. ≤ 240 chars enforced by curator service. Last-write-wins;
# we don't keep history of prior summaries.
curator_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None)
messages: Mapped[list["Message"]] = relationship(
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
__table_args__ = (
Index("ix_conversations_updated_at", "updated_at"),
Index("ix_conversations_user_id", "user_id"),
)
def to_dict(self) -> dict:
# Use loaded messages if available, otherwise 0
if "messages" in inspect(self).dict:
msg_count = len(self.messages) if self.messages else 0
else:
msg_count = 0
return {
"id": self.id,
"title": self.title,
"model": self.model,
"conversation_type": self.conversation_type,
"day_date": self.day_date.isoformat() if self.day_date else None,
"rag_project_id": self.rag_project_id,
"message_count": msg_count,
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
}
class Message(Base, CreatedAtMixin):
__tablename__ = "messages"
id: Mapped[int] = mapped_column(primary_key=True)
conversation_id: Mapped[int] = mapped_column(
Integer, ForeignKey("conversations.id", ondelete="CASCADE")
)
role: Mapped[str] = mapped_column(Text)
content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(Text, default="complete")
context_note_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
# 'metadata' is reserved by SQLAlchemy Declarative — use msg_metadata as the
# Python attribute name, mapped to the 'metadata' DB column.
msg_metadata: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
conversation: Mapped["Conversation"] = relationship(back_populates="messages")
__table_args__ = (
Index("ix_messages_conversation_id", "conversation_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"conversation_id": self.conversation_id,
"role": self.role,
"content": self.content,
"status": self.status,
"context_note_id": self.context_note_id,
"tool_calls": self.tool_calls,
"metadata": self.msg_metadata,
"created_at": self.created_at.isoformat(),
}
@@ -1,75 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class GenerationToolLog(Base):
"""One row per assistant turn — what tools the model could/did use.
Lets the operator answer "does model X actually fire record_moment?"
empirically across model swaps without relying on anecdote.
"""
__tablename__ = "generation_tool_log"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
conv_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
)
# SET NULL (migration matches) so the telemetry row survives if the
# underlying assistant message is later deleted.
assistant_message_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("messages.id", ondelete="SET NULL"),
nullable=True,
)
model: Mapped[str] = mapped_column(Text, nullable=False)
think_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False)
tools_available: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list
)
tools_attempted: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list
)
tools_succeeded: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list
)
# JSONB list of {name, error} dicts.
tools_failed: Mapped[list[dict]] = mapped_column(
JSONB, nullable=False, default=list
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
__table_args__ = (
Index("ix_generation_tool_log_user_created", "user_id", created_at.desc()),
Index("ix_generation_tool_log_conv", "conv_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"conv_id": self.conv_id,
"assistant_message_id": self.assistant_message_id,
"model": self.model,
"think_enabled": self.think_enabled,
"tools_available": list(self.tools_available or []),
"tools_attempted": list(self.tools_attempted or []),
"tools_succeeded": list(self.tools_succeeded or []),
"tools_failed": list(self.tools_failed or []),
"created_at": self.created_at.isoformat() if self.created_at else None,
}
-130
View File
@@ -1,130 +0,0 @@
import datetime
from sqlalchemy import Boolean, Column, Date, DateTime, ForeignKey, Index, Integer, Table, Text
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
# Junction tables. People, Places, Tasks, and (regular) Notes all live in
# the `notes` table — the four junctions are kept separate (rather than one
# merged table with a discriminator) so per-kind queries don't require a
# filter, and so the schema is explicit about which links are which.
moment_people = Table(
"moment_people",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("person_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_places = Table(
"moment_places",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("place_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_tasks = Table(
"moment_tasks",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("task_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
moment_notes = Table(
"moment_notes",
Base.metadata,
Column("moment_id", Integer, ForeignKey("moments.id", ondelete="CASCADE"), primary_key=True),
Column("note_id", Integer, ForeignKey("notes.id", ondelete="CASCADE"), primary_key=True),
)
class Moment(Base):
"""A small structured extraction from a journal conversation.
Many per day. Emitted by the LLM via the `record_moment` tool when it
notices a meaningful beat. Stored separately from Notes — they are
different in kind: Notes are curated artifacts; Moments are ambient
trace data with their own embedding index, so notes-RAG and journal-RAG
cannot cross-contaminate.
"""
__tablename__ = "moments"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
conversation_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("conversations.id", ondelete="SET NULL"), nullable=True
)
source_message_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("messages.id", ondelete="SET NULL"), nullable=True
)
day_date: Mapped[datetime.date] = mapped_column(Date, nullable=False)
occurred_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
recorded_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
content: Mapped[str] = mapped_column(Text, nullable=False)
raw_excerpt: Mapped[str | None] = mapped_column(Text, nullable=True)
tags: Mapped[list[str]] = mapped_column(ARRAY(Text), nullable=False, default=list)
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
people = relationship("Note", secondary=moment_people, lazy="selectin", viewonly=True)
places = relationship("Note", secondary=moment_places, lazy="selectin", viewonly=True)
tasks = relationship("Note", secondary=moment_tasks, lazy="selectin", viewonly=True)
notes = relationship("Note", secondary=moment_notes, lazy="selectin", viewonly=True)
__table_args__ = (
Index("ix_moments_user_day", "user_id", "day_date"),
Index("ix_moments_user_occurred", "user_id", "occurred_at"),
)
def to_dict(self, *, include_links: bool = True) -> dict:
result: dict = {
"id": self.id,
"user_id": self.user_id,
"conversation_id": self.conversation_id,
"source_message_id": self.source_message_id,
"day_date": self.day_date.isoformat(),
"occurred_at": self.occurred_at.isoformat(),
"recorded_at": self.recorded_at.isoformat(),
"content": self.content,
"raw_excerpt": self.raw_excerpt,
"tags": list(self.tags or []),
"pinned": self.pinned,
}
if include_links:
result["people"] = [{"id": p.id, "title": p.title} for p in (self.people or [])]
result["places"] = [{"id": p.id, "title": p.title} for p in (self.places or [])]
result["task_ids"] = [t.id for t in (self.tasks or [])]
result["note_ids"] = [n.id for n in (self.notes or [])]
return result
class MomentEmbedding(Base):
"""Embedding vector for a Moment — used by `search_journal` semantic mode.
Stored separately from `note_embeddings` so notes-RAG and journal-RAG
cannot cross-contaminate. This is a hard invariant of the journal design.
"""
__tablename__ = "moment_embeddings"
moment_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("moments.id", ondelete="CASCADE"),
primary_key=True,
)
user_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
embedding: Mapped[list] = mapped_column(JSONB, nullable=False)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.datetime.now(datetime.timezone.utc),
)
@@ -1,81 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class PendingCuratorAction(Base):
"""Curator-proposed mutation awaiting user approval.
The curator can be confidently wrong, and the user is not in the loop
when it runs. Additive operations land directly; mutating operations
(update_*, delete_*) write a row here instead. The user reviews each
one from the journal's Needs Review panel.
On approval, the original tool call is replayed via execute_tool with
authority="user" — bypassing the curator interceptor so the request
just runs.
"""
__tablename__ = "pending_curator_actions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# SET NULL so a curator-proposed action survives if its source
# conversation is later deleted — the user might still want to
# review and approve it.
conv_id: Mapped[int | None] = mapped_column(
Integer,
ForeignKey("conversations.id", ondelete="SET NULL"),
nullable=True,
)
# The tool name the curator wanted to call (`update_task`, `delete_note`,
# etc.). Used at approval-replay time to dispatch through execute_tool.
action_type: Mapped[str] = mapped_column(Text, nullable=False)
# Display hints — what the UI shows in the card header.
target_type: Mapped[str | None] = mapped_column(Text, nullable=True)
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
target_label: Mapped[str | None] = mapped_column(Text, nullable=True)
# The curator's proposed arguments — replayed verbatim on approval.
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
# State of the target at proposal time. Lets the UI render an honest
# diff (current → proposed) even if other work modifies the entity
# between proposal and review.
current_snapshot: Mapped[dict] = mapped_column(
JSONB, nullable=False, default=dict
)
status: Mapped[str] = mapped_column(
Text, nullable=False, default="pending", server_default="pending"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
)
reviewed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, default=None
)
def to_dict(self) -> dict:
return {
"id": self.id,
"user_id": self.user_id,
"conv_id": self.conv_id,
"action_type": self.action_type,
"target_type": self.target_type,
"target_id": self.target_id,
"target_label": self.target_label,
"payload": dict(self.payload or {}),
"current_snapshot": dict(self.current_snapshot or {}),
"status": self.status,
"created_at": self.created_at.isoformat() if self.created_at else None,
"reviewed_at": (
self.reviewed_at.isoformat() if self.reviewed_at else None
),
}
@@ -1,20 +0,0 @@
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
from fabledassistant.models.base import CreatedAtMixin
class PushSubscription(Base, CreatedAtMixin):
__tablename__ = "push_subscriptions"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
endpoint: Mapped[str] = mapped_column(Text)
p256dh: Mapped[str] = mapped_column(Text)
auth: Mapped[str] = mapped_column(Text)
user_agent: Mapped[str | None] = mapped_column(Text, nullable=True)
last_used: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
__table_args__ = (
UniqueConstraint("user_id", "endpoint", name="uq_push_subscription_user_endpoint"),
)
@@ -1,38 +0,0 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class WeatherCache(Base):
__tablename__ = "weather_cache"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
# Unique key per location, e.g. "home", "work", or "event:<uid>"
location_key: Mapped[str] = mapped_column(Text)
location_label: Mapped[str] = mapped_column(Text, default="")
# Current 7-day forecast from Open-Meteo (raw JSON)
forecast_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
# Previous forecast — used to detect changes between fetches
previous_json: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
UniqueConstraint("user_id", "location_key", name="uq_weather_cache_user_location"),
Index("ix_weather_cache_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"location_key": self.location_key,
"location_label": self.location_label,
"forecast_json": self.forecast_json,
"fetched_at": self.fetched_at.isoformat(),
}
-39
View File
@@ -19,7 +19,6 @@ from fabledassistant.services.backup import (
restore_full_backup,
)
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_base_url, get_smtp_config, is_smtp_configured, send_test_email
from fabledassistant.services.voice_config import get_voice_config
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.notifications import send_invitation_email
from fabledassistant.services.settings import set_setting, set_settings_batch
@@ -205,44 +204,6 @@ async def update_base_url():
return jsonify({"status": "ok"})
@admin_bp.route("/voice", methods=["GET"])
@admin_required
async def get_voice_config_route():
config = await get_voice_config()
return jsonify(config)
@admin_bp.route("/voice", methods=["PUT"])
@admin_required
async def update_voice_config():
data = await request.get_json()
uid = get_current_user_id()
valid_models = {"tiny.en", "base.en", "small.en", "medium.en"}
settings: dict[str, str] = {}
if "voice_enabled" in data:
settings["voice_enabled"] = "true" if data["voice_enabled"] else "false"
if "voice_stt_model" in data:
model = str(data["voice_stt_model"])
if model not in valid_models:
return jsonify({"error": f"Invalid STT model. Choose from: {', '.join(sorted(valid_models))}"}), 400
settings["voice_stt_model"] = model
if settings:
await set_settings_batch(uid, settings)
await log_audit("voice_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details=settings)
return jsonify({"status": "ok"})
@admin_bp.route("/voice/reload", methods=["POST"])
@admin_required
async def reload_voice_models():
"""Reload STT and TTS models in the background without a server restart."""
from fabledassistant.services.stt import reload_stt_model
from fabledassistant.services.tts import reload_tts_model
asyncio.create_task(reload_stt_model())
asyncio.create_task(reload_tts_model())
return jsonify({"status": "loading"})
@admin_bp.route("/invitations", methods=["POST"])
@admin_required
async def create_invite():
-548
View File
@@ -1,548 +0,0 @@
import asyncio
import json
import logging
import httpx
from quart import Blueprint, Response, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.chat import (
add_message,
bulk_delete_conversations,
create_conversation,
delete_conversation,
get_conversation,
list_conversations,
save_response_as_note,
summarize_conversation_as_note,
update_conversation,
)
from fabledassistant.services.generation_buffer import (
GenerationState,
create_buffer,
get_buffer,
)
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.notes import get_notes_by_ids
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
chat_bp = Blueprint("chat", __name__, url_prefix="/api/chat")
@chat_bp.route("/conversations", methods=["GET"])
@login_required
async def list_conversations_route():
uid = get_current_user_id()
limit, offset = parse_pagination()
conv_type = request.args.get("type", "chat")
conversations, total = await list_conversations(uid, limit=limit, offset=offset, conv_type=conv_type)
return jsonify({
"conversations": conversations,
"total": total,
})
@chat_bp.route("/conversations/bulk-delete", methods=["POST"])
@login_required
async def bulk_delete_conversations_route():
uid = get_current_user_id()
data = await request.get_json()
ids = data.get("ids", []) if isinstance(data, dict) else []
if not isinstance(ids, list) or not all(isinstance(i, int) for i in ids):
return jsonify({"error": "ids must be a list of integers"}), 400
count = await bulk_delete_conversations(uid, ids)
return jsonify({"deleted": count})
@chat_bp.route("/conversations", methods=["POST"])
@login_required
async def create_conversation_route():
uid = get_current_user_id()
data = await request.get_json(force=True, silent=True) or {}
title = data.get("title", "")
model = data.get("model", Config.OLLAMA_MODEL)
conversation_type = data.get("conversation_type", "chat")
# Only allow known types to prevent accidental misuse
if conversation_type not in ("chat", "mcp", "voice"):
conversation_type = "chat"
conv = await create_conversation(uid, title=title, model=model, conversation_type=conversation_type)
return jsonify(conv.to_dict()), 201
@chat_bp.route("/conversations/<int:conv_id>", methods=["GET"])
@login_required
async def get_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
result = conv.to_dict()
note_ids = [m.context_note_id for m in conv.messages if m.context_note_id]
note_map = await get_notes_by_ids(uid, note_ids) if note_ids else {}
result["messages"] = []
for m in conv.messages:
msg_dict = m.to_dict()
if m.context_note_id and m.context_note_id in note_map:
msg_dict["context_note_title"] = note_map[m.context_note_id].title
result["messages"].append(msg_dict)
return jsonify(result)
@chat_bp.route("/conversations/<int:conv_id>", methods=["DELETE"])
@login_required
async def delete_conversation_route(conv_id: int):
uid = get_current_user_id()
deleted = await delete_conversation(uid, conv_id)
if not deleted:
return not_found("Conversation")
return "", 204
@chat_bp.route("/conversations/<int:conv_id>", methods=["PATCH"])
@login_required
async def update_conversation_route(conv_id: int):
from fabledassistant.services.chat import _UNSET
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title")
model = data.get("model")
rag_project_id = data.get("rag_project_id", _UNSET)
if title is None and model is None and rag_project_id is _UNSET:
return jsonify({"error": "title, model, or rag_project_id is required"}), 400
conv = await update_conversation(uid, conv_id, title=title, model=model, rag_project_id=rag_project_id)
if conv is None:
return not_found("Conversation")
return jsonify(conv.to_dict())
@chat_bp.route("/conversations/<int:conv_id>/messages", methods=["POST"])
@login_required
async def send_message_route(conv_id: int):
"""Start generation: save user message, launch background task, return 202."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
data = await request.get_json()
content = data.get("content", "").strip()
if not content:
return jsonify({"error": "content is required"}), 400
context_note_id = data.get("context_note_id")
include_note_ids = data.get("include_note_ids") or []
excluded_note_ids = data.get("excluded_note_ids") or []
think = bool(data.get("think", False))
rag_project_id = data.get("rag_project_id") or None
workspace_project_id = data.get("workspace_project_id") or None
user_timezone = data.get("user_timezone") or None
if not user_timezone:
user_timezone = await get_setting(uid, "user_timezone") or None
effective_rag_project_id = workspace_project_id or rag_project_id
# Reject if generation already running for this conversation
existing = get_buffer(conv_id)
if existing and existing.state == GenerationState.RUNNING:
return jsonify({"error": "Generation already in progress"}), 409
# Save user message
await add_message(conv_id, "user", content, context_note_id=context_note_id)
# Create placeholder assistant message
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
try:
buf = create_buffer(conv_id, assistant_msg.id)
except RuntimeError:
return jsonify({"error": "Generation already in progress"}), 409
# Build history from existing messages (excluding system and the placeholder)
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
# Launch background generation task (context building happens inside the task).
#
# Wrap in a top-level guard: `run_generation` is fire-and-forget via
# asyncio.create_task. Without a wrapper, an unhandled exception inside
# the coroutine is swallowed by the event loop, the buffer stays in
# GenerationState.RUNNING forever, and every subsequent POST to this
# conversation returns 409 — locking the user out of the chat surface
# with no log trail. Observed in production 2026-05-22 where the
# generation hung before any internal log line could fire.
async def _run_generation_guarded():
try:
await run_generation(
buf, history, model,
uid, conv_id, conv.title, content,
context_note_id=context_note_id,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
think=think,
rag_project_id=effective_rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
voice_mode=(conv.conversation_type == "voice"),
)
except Exception:
logger.exception(
"run_generation crashed for conv %d msg %d (model=%s); "
"transitioning buffer to FAILED so the route can be used again",
conv_id, assistant_msg.id, model,
)
try:
buf.state = GenerationState.ERRORED
buf.append_event(
"done",
{"done": True, "error": "Generation crashed; see server logs"},
)
except Exception:
logger.exception("Failed to mark buffer ERRORED for conv %d", conv_id)
try:
from fabledassistant.services.generation_task import _update_message
await _update_message(
assistant_msg.id,
"",
"error",
tool_calls=None,
)
except Exception:
logger.exception(
"Failed to mark assistant message %d as error after crash",
assistant_msg.id,
)
asyncio.create_task(_run_generation_guarded())
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202
@chat_bp.route("/conversations/<int:conv_id>/generation/stream", methods=["GET"])
@login_required
async def generation_stream_route(conv_id: int):
"""SSE endpoint that tails the generation buffer for a conversation."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None:
return jsonify({"error": "No active generation"}), 404
# Determine starting point from Last-Event-ID header or query param
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
try:
last_id = int(last_id_str) if last_id_str is not None else -1
except (ValueError, TypeError):
last_id = -1
async def stream():
cursor = last_id
while True:
# Replay any buffered events past the cursor
pending = buf.events_after(cursor)
for event in pending:
yield buf.format_sse(event)
cursor = event.index
# If generation is done and all events delivered, close stream
if buf.state != GenerationState.RUNNING:
break
# Wait for new events or send keepalive on timeout
got_new = await buf.wait_for_event(cursor, timeout=15.0)
if not got_new:
yield ": keepalive\n\n"
# Final drain: deliver any events appended between the last yield
# and the state check above (e.g. the 'done' event).
for event in buf.events_after(cursor):
yield buf.format_sse(event)
return Response(
stream(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@chat_bp.route("/conversations/<int:conv_id>/generation/confirm", methods=["POST"])
@login_required
async def confirm_generation_route(conv_id: int):
"""Resolve a pending tool confirmation (accept or decline)."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
if buf.confirmation_future is None or buf.confirmation_future.done():
return jsonify({"error": "No pending tool confirmation"}), 409
data = await request.get_json(force=True, silent=True) or {}
decision = bool(data.get("confirmed", False))
buf.confirmation_future.set_result(decision)
return jsonify({"status": "ok", "confirmed": decision})
@chat_bp.route("/conversations/<int:conv_id>/generation/cancel", methods=["POST"])
@login_required
async def cancel_generation_route(conv_id: int):
"""Cancel an active generation for a conversation."""
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
buf = get_buffer(conv_id)
if buf is None or buf.state != GenerationState.RUNNING:
return jsonify({"error": "No active generation"}), 404
buf.cancel_event.set()
return jsonify({"status": "cancelled"})
@chat_bp.route("/messages/<int:message_id>/save-as-note", methods=["POST"])
@login_required
async def save_message_as_note_route(message_id: int):
uid = get_current_user_id()
try:
note = await save_response_as_note(uid, message_id)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/conversations/<int:conv_id>/summarize", methods=["POST"])
@login_required
async def summarize_conversation_route(conv_id: int):
uid = get_current_user_id()
conv = await get_conversation(uid, conv_id)
if conv is None:
return not_found("Conversation")
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
try:
note = await summarize_conversation_as_note(uid, conv_id, model)
return jsonify(note), 201
except ValueError as e:
return jsonify({"error": str(e)}), 400
@chat_bp.route("/ps", methods=["GET"])
@login_required
async def running_models_route():
"""Return currently loaded (hot) models from Ollama."""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
resp.raise_for_status()
data = resp.json()
models = [
{
"name": m["name"],
"size": m.get("size", 0),
"size_vram": m.get("size_vram", 0),
"expires_at": m.get("expires_at", ""),
}
for m in data.get("models", [])
]
return jsonify({"models": models})
except Exception as e:
logger.debug("Failed to query Ollama /api/ps: %s", e)
return jsonify({"models": []})
@chat_bp.route("/warm", methods=["POST"])
@login_required
async def warm_model_route():
"""Pre-load a model into Ollama memory."""
data = await request.get_json(force=True, silent=True) or {}
model = data.get("model", "").strip()
if not model:
return jsonify({"error": "model is required"}), 400
async def _warm():
from fabledassistant.services.llm import keep_alive_for
try:
async with httpx.AsyncClient(timeout=300.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/generate",
json={"model": model, "prompt": "", "keep_alive": keep_alive_for(model)},
)
logger.info("Warmed model %s", model)
except Exception as e:
logger.warning("Failed to warm model %s: %s", model, e)
asyncio.create_task(_warm())
return jsonify({"status": "warming"}), 202
@chat_bp.route("/status", methods=["GET"])
@login_required
async def chat_status_route():
"""Check Ollama availability, model installation, and model load state."""
uid = get_current_user_id()
default_model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
# Guard against empty-string rows written by older code when user selected "default"
if not default_model:
default_model = Config.OLLAMA_MODEL
result = {
"ollama": "unavailable",
"model": "not_found",
"default_model": default_model,
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
# Query installed models and loaded models in parallel
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
if isinstance(tags_resp, Exception):
logger.debug("Ollama /api/tags failed: %s", tags_resp)
else:
tags_resp.raise_for_status()
result["ollama"] = "available"
model_names = {m["name"] for m in tags_resp.json().get("models", [])}
base = default_model.removesuffix(":latest")
if default_model in model_names or f"{base}:latest" in model_names or base in model_names:
# Installed — now check if currently loaded in memory
result["model"] = "cold"
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
if default_model in loaded_names or f"{base}:latest" in loaded_names or base in loaded_names:
result["model"] = "loaded"
except Exception:
logger.debug("Ollama /api/ps check failed", exc_info=True)
except Exception:
logger.debug("Ollama status check failed", exc_info=True)
return jsonify(result)
@chat_bp.route("/models", methods=["GET"])
@login_required
async def list_models_route():
try:
async with httpx.AsyncClient(timeout=10.0) as client:
tags_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/tags"))
ps_task = asyncio.create_task(client.get(f"{Config.OLLAMA_URL}/api/ps"))
tags_resp, ps_resp = await asyncio.gather(tags_task, ps_task, return_exceptions=True)
loaded_names: set[str] = set()
if not isinstance(ps_resp, Exception):
try:
ps_resp.raise_for_status()
loaded_names = {m["name"] for m in ps_resp.json().get("models", [])}
except Exception:
pass
models = []
if not isinstance(tags_resp, Exception):
tags_resp.raise_for_status()
for m in tags_resp.json().get("models", []):
models.append({
"name": m["name"],
"size": m.get("size", 0),
"modified_at": m.get("modified_at", ""),
"loaded": m["name"] in loaded_names,
})
return jsonify({"models": models})
except Exception as e:
logger.warning("Failed to list Ollama models: %s", e)
return jsonify({"models": [], "error": str(e)}), 200
@chat_bp.route("/models/pull", methods=["POST"])
@admin_required
async def pull_model_route():
"""Pull a model from Ollama, streaming progress via SSE."""
data = await request.get_json()
model_name = data.get("model", "").strip()
if not model_name:
return jsonify({"error": "model is required"}), 400
async def generate():
try:
async with httpx.AsyncClient(timeout=1800.0) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/pull",
json={"name": model_name},
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.strip():
continue
progress = json.loads(line)
event_data = json.dumps(progress)
yield f"data: {event_data}\n\n"
yield f"data: {json.dumps({'status': 'success'})}\n\n"
except Exception as e:
logger.exception("Error pulling model %s", model_name)
yield f"data: {json.dumps({'error': str(e)})}\n\n"
return Response(
generate(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
@chat_bp.route("/models/delete", methods=["POST"])
@admin_required
async def delete_model_route():
"""Delete a model from Ollama."""
data = await request.get_json()
model_name = data.get("model", "").strip()
if not model_name:
return jsonify({"error": "model is required"}), 400
try:
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.request(
"DELETE",
f"{Config.OLLAMA_URL}/api/delete",
json={"name": model_name},
)
resp.raise_for_status()
return jsonify({"status": "deleted", "model": model_name})
except httpx.HTTPStatusError as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": f"Failed to delete model: {e.response.status_code}"}), 400
except Exception as e:
logger.warning("Failed to delete model %s: %s", model_name, e)
return jsonify({"error": str(e)}), 500
@@ -1,42 +0,0 @@
"""Serve the fable-mcp distribution wheel built into the Docker image."""
import logging
import os
from pathlib import Path
from quart import Blueprint, jsonify, send_file
from fabledassistant.auth import login_required
logger = logging.getLogger(__name__)
fable_mcp_dist_bp = Blueprint("fable_mcp_dist", __name__, url_prefix="/api/fable-mcp")
# Wheel is built into the image at this path (see Dockerfile)
_DIST_DIR = Path(os.environ.get("FABLE_MCP_DIST_DIR", "/app/dist"))
def _find_wheel() -> Path | None:
"""Return the newest fable_mcp wheel in the dist dir, or None."""
wheels = sorted(_DIST_DIR.glob("fable_mcp-*.whl"), reverse=True)
return wheels[0] if wheels else None
@fable_mcp_dist_bp.route("/info", methods=["GET"])
@login_required
async def fable_mcp_info():
"""Return availability and filename of the bundled fable-mcp wheel."""
wheel = _find_wheel()
return jsonify({
"available": wheel is not None,
"filename": wheel.name if wheel else None,
})
@fable_mcp_dist_bp.route("/download", methods=["GET"])
@login_required
async def download_fable_mcp():
"""Serve the fable-mcp wheel file as a download."""
wheel = _find_wheel()
if wheel is None:
return jsonify({"error": "Package not built into this image"}), 404
return await send_file(wheel, as_attachment=True)
-519
View File
@@ -1,519 +0,0 @@
"""HTTP endpoints for the Journal feature.
Includes the conversational journal endpoints (config / today / day / days /
trigger-prep / moments) plus the ambient-context surface lifted from the
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
The ambient endpoints read locations + temp_unit + topic preferences from the
``journal_config`` user setting.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
from zoneinfo import ZoneInfo
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.journal_scheduler import (
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
update_user_schedule,
)
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.moments import delete_moment, update_moment
from fabledassistant.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__)
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
def _resolve_tz(tz_str: str) -> ZoneInfo:
try:
return ZoneInfo(tz_str)
except Exception:
return ZoneInfo("UTC")
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
tz = _resolve_tz(tz_str)
now = datetime.datetime.now(tz)
if now.hour < day_rollover_hour:
return (now - datetime.timedelta(days=1)).date()
return now.date()
async def _user_timezone(user_id: int) -> str:
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
async def _resolve_config(user_id: int) -> dict:
raw = await get_setting(user_id, "journal_config", "")
config: dict = {}
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
config = parsed
except Exception:
logger.warning("Invalid journal_config for user %d", user_id)
return {**DEFAULT_JOURNAL_CONFIG, **config}
def _valid_location_keys(cfg: dict) -> set[str]:
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
(orphaned cache rows, locations the user typed but didn't geocode) is
excluded so it can't render as a fake site in the UI."""
locations = cfg.get("locations") or {}
return {
key for key, loc in locations.items()
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
}
@journal_bp.get("/config")
@login_required
async def get_config():
user_id = get_current_user_id()
return jsonify(await _resolve_config(user_id))
@journal_bp.put("/config")
@login_required
async def put_config():
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "config must be an object"}), 400
await set_setting(user_id, "journal_config", json.dumps(body))
await update_user_schedule(user_id)
# Trigger a background weather refresh for any newly-saved location with
# valid lat/lon. Without this, the cache row for the location doesn't
# exist (or stays stale) until the user clicks the manual refresh button,
# so the journal weather panel renders empty for newly-entered sites.
valid_locs = [
(key, loc)
for key, loc in (body.get("locations") or {}).items()
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
]
if valid_locs:
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
return jsonify({"ok": True})
async def _refresh_locations_in_background(
user_id: int, locations: list[tuple[str, dict]]
) -> None:
for key, loc in locations:
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning(
"Post-save weather refresh failed for user %d / %s",
user_id, key, exc_info=True,
)
@journal_bp.get("/today")
@login_required
async def get_today():
user_id = get_current_user_id()
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
await ensure_daily_prep_message(
user_id=user_id, day_date=today, user_timezone=tz_str
)
return await _day_payload(user_id=user_id, day_date=today)
@journal_bp.get("/day/<iso_date>")
@login_required
async def get_day(iso_date: str):
user_id = get_current_user_id()
try:
day = datetime.date.fromisoformat(iso_date)
except ValueError:
return jsonify({"error": "invalid date"}), 400
return await _day_payload(user_id=user_id, day_date=day)
@journal_bp.get("/days")
@login_required
async def list_days():
user_id = get_current_user_id()
async with async_session() as session:
stmt = (
select(Conversation.day_date)
.where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date.is_not(None),
)
.order_by(Conversation.day_date.desc())
)
rows = (await session.execute(stmt)).scalars().all()
return jsonify({"days": [d.isoformat() for d in rows]})
@journal_bp.post("/curator/run/<int:conv_id>")
@login_required
async def trigger_curator_run(conv_id: int):
"""Manually run the journal curator over a conversation.
The curator reads recent messages and fires tool calls (record_moment,
update_task, etc.) the chat model can't (chat models have tools=[]).
Returns a summary of what was captured.
See services/curator.py for the architectural background.
"""
user_id = get_current_user_id()
# Confirm the conversation belongs to this user (curator runs against
# arbitrary conv_ids would otherwise leak data across tenants).
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_res = await _sess.execute(
_select(_Conversation).where(
_Conversation.id == conv_id,
_Conversation.user_id == user_id,
)
)
if _res.scalar_one_or_none() is None:
return jsonify({"error": "Conversation not found"}), 404
from fabledassistant.services.curator import (
is_curator_running,
run_curator_for_conversation,
)
# The curator typically runs on a large model (30b-70b on CPU); we
# serialize runs globally via a module-level lock. Reject rather
# than block when busy — blocking would tie up an HTTP worker for
# minutes. The user can retry in a moment.
if is_curator_running():
return jsonify({
"error": "Curator is currently running. Please try again in a moment.",
"busy": True,
}), 409
result = await run_curator_for_conversation(conv_id)
# Stamp last_curator_run_at on success so the scheduler doesn't
# immediately re-process the same conversation on its next sweep.
# Errored runs intentionally leave the timestamp alone so the
# scheduler retries them. Persist the curator's summary too when
# non-empty (Phase 3 feedback loop) — empty summary keeps the
# existing one rather than clobbering useful context.
if not result.error:
import datetime as _dt
from sqlalchemy import update as _update
_values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)}
if result.summary:
_values["curator_summary"] = result.summary.strip()[:240]
async with _async_session() as _sess:
await _sess.execute(
_update(_Conversation).where(_Conversation.id == conv_id).values(**_values)
)
await _sess.commit()
return jsonify(result.to_dict())
@journal_bp.post("/trigger-prep")
@login_required
async def trigger_prep():
user_id = get_current_user_id()
body = await request.get_json(silent=True) or {}
iso_date = body.get("date")
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
day = (
datetime.date.fromisoformat(iso_date)
if iso_date
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
)
msg = await ensure_daily_prep_message(
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
)
return jsonify({"ok": True, "message_id": msg.id})
@journal_bp.get("/moments")
@login_required
async def list_moments():
user_id = get_current_user_id()
args = request.args
df = args.get("date_from")
dt = args.get("date_to")
person_id = args.get("person_id", type=int)
place_id = args.get("place_id", type=int)
tag = args.get("tag")
query = args.get("query")
pinned_only = args.get("pinned_only", "false").lower() == "true"
limit = args.get("limit", default=50, type=int)
results = await search_journal(
user_id=user_id,
query=query,
person_id=person_id,
place_id=place_id,
tag=tag,
date_from=datetime.date.fromisoformat(df) if df else None,
date_to=datetime.date.fromisoformat(dt) if dt else None,
limit=limit,
)
if pinned_only:
results = [r for r in results if r.get("pinned")]
return jsonify({"moments": results})
@journal_bp.get("/pending")
@login_required
async def list_pending_actions():
"""List curator-proposed mutations awaiting the user's review."""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import list_pending
pending = await list_pending(user_id)
return jsonify({"pending": pending, "count": len(pending)})
@journal_bp.post("/pending/<int:action_id>/approve")
@login_required
async def approve_pending_action(action_id: int):
"""Approve a proposed action — replays the underlying tool call.
Returns the tool result on success. If the replay errors (e.g., the
target was deleted in the meantime), the action stays pending so the
user can re-try or reject explicitly.
"""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import approve
result = await approve(action_id, user_id)
return jsonify(result)
@journal_bp.post("/pending/<int:action_id>/reject")
@login_required
async def reject_pending_action(action_id: int):
"""Reject a proposed action — marks rejected without executing anything."""
user_id = get_current_user_id()
from fabledassistant.services.pending_actions import reject
result = await reject(action_id, user_id)
return jsonify(result)
@journal_bp.patch("/moments/<int:moment_id>")
@login_required
async def patch_moment(moment_id: int):
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400
moment = await update_moment(
user_id=user_id,
moment_id=moment_id,
content=body.get("content"),
tags=body.get("tags"),
pinned=body.get("pinned"),
person_ids=body.get("person_ids"),
place_ids=body.get("place_ids"),
task_ids=body.get("task_ids"),
note_ids=body.get("note_ids"),
)
if moment is None:
return jsonify({"error": "not found"}), 404
return jsonify(moment.to_dict())
@journal_bp.delete("/moments/<int:moment_id>")
@login_required
async def remove_moment(moment_id: int):
user_id = get_current_user_id()
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
if not deleted:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
# ───────────────────────────────────────────────────────────────────────────────
# Ambient endpoints (lifted from the old briefing surface).
# ───────────────────────────────────────────────────────────────────────────────
async def _journal_temp_unit(user_id: int) -> str:
cfg = await _resolve_config(user_id)
unit = cfg.get("temp_unit", "C")
return unit if unit in ("C", "F") else "C"
# ── Weather ───────────────────────────────────────────────────────────────────
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
def _is_stale(cache_row) -> bool:
if cache_row is None or cache_row.fetched_at is None:
return True
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
return age > _STALE_THRESHOLD_SECONDS
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
config has no usable lat/lon for a given location_key."""
cfg = await _resolve_config(user_id)
locations = cfg.get("locations") or {}
for key in stale_keys:
loc = locations.get(key)
if not loc or not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
@journal_bp.get("/weather")
@login_required
async def get_weather():
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
valid_keys = _valid_location_keys(cfg)
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
temp_unit = await _journal_temp_unit(user_id)
# Kick off a best-effort background refresh for stale rows so the next page
# load gets fresh data; we still serve whatever's currently cached now.
stale_keys = {row.location_key for row in rows if _is_stale(row)}
if stale_keys:
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.get("/weather/current")
@login_required
async def get_current_weather():
"""Live current temperature + conditions for the user's primary location."""
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
locations = cfg.get("locations") or {}
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@journal_bp.post("/weather/refresh")
@login_required
async def refresh_weather():
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
for key, loc in (cfg.get("locations") or {}).items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
valid_keys = _valid_location_keys(cfg)
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.post("/weather/geocode")
@login_required
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
async def _day_payload(*, user_id: int, day_date: datetime.date):
async with async_session() as session:
conv_stmt = select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
if conv is None:
return jsonify({
"day_date": day_date.isoformat(),
"conversation": None,
"messages": [],
})
msgs_stmt = (
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = (await session.execute(msgs_stmt)).scalars().all()
# conv.to_dict() recomputes message_count from the `messages`
# relationship, which isn't eager-loaded here, so it would report 0.
# We already have the real list — override with the known count, same
# convention the chat-list path uses (services/chat.py).
conv_dict = conv.to_dict()
conv_dict["message_count"] = len(messages)
return jsonify({
"day_date": day_date.isoformat(),
"conversation": conv_dict,
"messages": [m.to_dict() for m in messages],
})
+1 -141
View File
@@ -4,18 +4,10 @@ import re
from fabledassistant.services.embeddings import upsert_note_embedding
from quart import Blueprint, Response, jsonify, request
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.routes.utils import not_found, parse_iso_date, parse_pagination
from fabledassistant.config import Config
from fabledassistant.services.assist import build_assist_messages
from fabledassistant.services.generation_buffer import (
GenerationState,
create_assist_buffer,
get_assist_buffer,
)
from fabledassistant.services.generation_task import run_assist_generation
from fabledassistant.services.notes import (
build_note_graph,
convert_note_to_task,
@@ -31,8 +23,6 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.services.note_drafts import upsert_draft, get_draft, delete_draft
from fabledassistant.services.note_versions import list_versions, get_version
@@ -140,18 +130,6 @@ async def list_tags_route():
return jsonify({"tags": tags})
@notes_bp.route("/suggest-tags", methods=["POST"])
@login_required
async def suggest_tags_route():
uid = get_current_user_id()
data = await request.get_json()
title = data.get("title", "")
body = data.get("body", "")
current_tags = data.get("current_tags", [])
tags = await suggest_tags(uid, title, body, current_tags=current_tags)
return jsonify({"suggested_tags": tags})
@notes_bp.route("/<int:note_id>/append-tag", methods=["POST"])
@login_required
async def append_tag_route(note_id: int):
@@ -318,124 +296,6 @@ async def get_backlinks_route(note_id: int):
return jsonify({"backlinks": links})
@notes_bp.route("/assist", methods=["POST"])
@login_required
async def assist_route():
"""Launch a background assist generation task."""
uid = get_current_user_id()
data = await request.get_json()
body = data.get("body", "")
target_section = data.get("target_section", "")
instruction = data.get("instruction", "")
whole_doc = bool(data.get("whole_doc", False))
project_id = data.get("project_id")
note_id = data.get("note_id")
if not whole_doc and not target_section:
return jsonify({"error": "target_section is required for section mode"}), 400
if not instruction:
return jsonify({"error": "instruction is required"}), 400
# Fetch related project notes for context (up to 5, excluding current note).
# Glossary-tagged notes are prioritised so the model knows canonical definitions.
context_notes: list[dict] = []
if project_id:
try:
pid = int(project_id)
# 1) Glossary notes first (up to 3)
glossary_notes, _ = await list_notes(
uid, project_id=pid, tags=["definition"], sort="updated_at", order="desc", limit=3
)
seen_ids: set[int] = set()
for n in glossary_notes:
if n.id == note_id:
continue
seen_ids.add(n.id)
context_notes.append({
"title": n.title or "Untitled",
"tags": n.tags or [],
"body": n.body or "",
})
# 2) Fill remaining slots with recently-updated notes
if len(context_notes) < 5:
recent_notes, _ = await list_notes(
uid, project_id=pid, sort="updated_at", order="desc",
limit=5 - len(context_notes) + len(seen_ids) + 1,
)
for n in recent_notes:
if n.id == note_id or n.id in seen_ids:
continue
if len(context_notes) >= 5:
break
seen_ids.add(n.id)
context_notes.append({
"title": n.title or "Untitled",
"tags": n.tags or [],
"body": n.body or "",
})
except Exception:
logger.warning("Failed to fetch project context notes for assist", exc_info=True)
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL) or Config.OLLAMA_MODEL
messages = build_assist_messages(
body, target_section, instruction,
whole_doc=whole_doc,
context_notes=context_notes or None,
)
buf = create_assist_buffer(uid)
asyncio.create_task(run_assist_generation(buf, messages, model))
return jsonify({"status": "started"}), 202
@notes_bp.route("/assist/stream", methods=["GET"])
@login_required
async def assist_stream_route():
"""SSE endpoint that tails the assist generation buffer."""
uid = get_current_user_id()
buf = get_assist_buffer(uid)
if buf is None:
return jsonify({"error": "No active assist generation"}), 404
last_id_str = request.headers.get("Last-Event-ID") or request.args.get("last_event_id")
try:
last_id = int(last_id_str) if last_id_str is not None else -1
except (ValueError, TypeError):
last_id = -1
async def stream():
cursor = last_id
while True:
pending = buf.events_after(cursor)
for event in pending:
yield buf.format_sse(event)
cursor = event.index
if buf.state != GenerationState.RUNNING:
break
got_new = await buf.wait_for_event(cursor, timeout=15.0)
if not got_new:
yield ": keepalive\n\n"
# Final drain: deliver any events appended between the last yield
# and the state check above (e.g. the 'done' event).
for event in buf.events_after(cursor):
yield buf.format_sse(event)
return Response(
stream(),
content_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
},
)
# ── Link suggestions ─────────────────────────────────────────────────────────
_WIKILINK_RE = re.compile(r'\[\[[^\]]+\]\]')
-28
View File
@@ -5,8 +5,6 @@ from fabledassistant.services.user_profile import (
VALID_EXPERTISE,
VALID_STYLES,
VALID_TONES,
clear_learned_data,
consolidate_observations,
get_profile,
update_profile,
)
@@ -43,29 +41,3 @@ async def update_profile_route():
profile = await update_profile(uid, data)
return jsonify(profile.to_dict())
@profile_bp.route("/consolidate", methods=["POST"])
@login_required
async def trigger_consolidate():
uid = get_current_user_id()
summary = await consolidate_observations(uid)
return jsonify({"status": "ok", "learned_summary": summary})
@profile_bp.route("/observations", methods=["DELETE"])
@login_required
async def clear_observations():
uid = get_current_user_id()
await clear_learned_data(uid)
return jsonify({"status": "ok"})
@profile_bp.route("/observations", methods=["GET"])
@login_required
async def list_observations():
uid = get_current_user_id()
profile = await get_profile(uid)
raw = list(profile.observations_raw or [])
# Newest first, last 14 entries
return jsonify({"observations": list(reversed(raw[-14:]))})
-54
View File
@@ -1,54 +0,0 @@
"""Push notification subscription routes."""
import logging
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id, admin_required
from fabledassistant.config import Config
from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
push_bp = Blueprint("push", __name__, url_prefix="/api/push")
@push_bp.route("/vapid-public-key", methods=["GET"])
@login_required
async def get_vapid_key():
if not vapid_enabled():
return jsonify({"error": "Push notifications not configured"}), 503
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY})
@push_bp.route("/subscribe", methods=["POST"])
@login_required
async def subscribe():
uid = get_current_user_id()
data = await request.get_json()
if not data or not data.get("endpoint"):
return jsonify({"error": "Invalid subscription data"}), 400
user_agent = request.headers.get("User-Agent")
sub = await save_subscription(uid, data, user_agent=user_agent)
return jsonify({"id": sub.id}), 201
@push_bp.route("/subscribe", methods=["DELETE"])
@login_required
async def unsubscribe():
uid = get_current_user_id()
data = await request.get_json()
endpoint = (data or {}).get("endpoint", "")
if not endpoint:
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
@push_bp.route("/reset-vapid", methods=["POST"])
@admin_required
async def reset_vapid():
"""Regenerate VAPID keys and clear all push subscriptions."""
ok = await regenerate_vapid_keys()
if ok:
return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
return jsonify({"error": "Key regeneration failed"}), 500
-111
View File
@@ -1,111 +0,0 @@
"""Quick-capture endpoint for mobile/external clients.
POST /api/quick-capture — sends text through the main LLM tool-calling pipeline
and returns a single synchronous JSON response. No SSE, no conversation ID.
"""
import logging
from datetime import date
from quart import Blueprint, jsonify, request
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.config import Config
from fabledassistant.services.llm import stream_chat_with_tools
from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__)
quick_capture_bp = Blueprint("quick_capture", __name__, url_prefix="/api/quick-capture")
# Tools offered to the quick-capture endpoint. Excludes destructive ops,
# read-only queries, and conversational-only tools.
_CAPTURE_TOOL_NAMES = {"create_note", "create_event", "update_note", "research_topic"}
_SYSTEM_PROMPT = """\
Today is {today}. You are a quick-capture assistant. The user has sent a short \
snippet from their mobile device. Create the appropriate item — note, task, or \
calendar event — using the available tools. Always call a tool; never reply \
conversationally."""
@quick_capture_bp.route("", methods=["POST"])
@login_required
async def quick_capture_route():
"""Classify text via native tool-calling and create the appropriate item."""
uid = get_current_user_id()
data = await request.get_json(silent=True) or {}
text = data.get("text", "").strip()
if not text:
return jsonify({"error": "text is required"}), 400
from fabledassistant.services.settings import get_setting
model = await get_setting(uid, "default_model", Config.OLLAMA_MODEL)
all_tools = await get_tools_for_user(uid)
capture_tools = [
t for t in all_tools if t.get("function", {}).get("name") in _CAPTURE_TOOL_NAMES
]
messages = [
{"role": "system", "content": _SYSTEM_PROMPT.format(today=date.today().isoformat())},
{"role": "user", "content": text},
]
# Quick capture is a fast classification path — never think.
tool_calls: list[dict] = []
try:
async for chunk in stream_chat_with_tools(messages, model, tools=capture_tools, think=False, num_ctx=4096):
if chunk.type == "tool_calls" and chunk.tool_calls:
tool_calls = chunk.tool_calls
except Exception:
logger.warning("Quick-capture LLM call failed for uid=%d", uid, exc_info=True)
if tool_calls:
tc = tool_calls[0]
tool_name = tc.get("function", {}).get("name", "")
arguments = tc.get("function", {}).get("arguments", {})
if tool_name == "research_topic" and Config.searxng_enabled():
from fabledassistant.services.research import run_research_pipeline
topic = arguments.get("topic", text)
try:
note = await run_research_pipeline(topic, uid, model)
logger.info("Quick-capture uid=%d: research note id=%d '%s'", uid, note.id, note.title)
return jsonify({
"success": True,
"type": "note",
"message": f"Research note created: {note.title}",
"data": {"id": note.id, "title": note.title},
})
except Exception as exc:
logger.exception("Quick-capture research failed: %s", topic)
return jsonify({"error": f"Research failed: {exc}"}), 500
result = await execute_tool(uid, tool_name, arguments)
if result.get("success"):
item_type = result.get("type", "note")
title = (result.get("data") or {}).get("title", "")
logger.info("Quick-capture uid=%d: %s '%s'", uid, item_type, title)
return jsonify({
"success": True,
"type": item_type,
"message": f"{item_type.capitalize()}: {title}",
"data": result.get("data"),
})
logger.warning("Quick-capture uid=%d: tool '%s' failed: %s", uid, tool_name, result.get("error"))
# Fallback: create a plain note with the raw text
result = await execute_tool(uid, "create_note", {"title": text[:80], "body": text})
if result.get("success"):
title = (result.get("data") or {}).get("title", "")
logger.info("Quick-capture uid=%d: fallback note '%s'", uid, title)
return jsonify({
"success": True,
"type": "note",
"message": f"Note created: {title}",
"data": result.get("data"),
"fallback": True,
})
return jsonify({"error": "Failed to create item"}), 500
+32 -78
View File
@@ -1,49 +1,46 @@
import asyncio
"""User settings + integrations (CalDAV, SearXNG status).
Chat-model picker endpoints (/models), KV-cache priming, and journal-schedule
hooks were removed in Phase 8 alongside the chat/journal subsystems.
"""
import ipaddress
import logging
import socket
from urllib.parse import urlparse
from quart import Blueprint, jsonify, request
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.config import Config
from fabledassistant.services.caldav import CALDAV_SETTING_KEYS, get_caldav_config, test_connection
from fabledassistant.services.llm import get_installed_models, _is_private_url
from fabledassistant.services.settings import delete_setting, get_all_settings, get_setting, set_settings_batch
logger = logging.getLogger(__name__)
async def _prime_kv_cache_bg(user_id: int, model: str) -> None:
"""Fire-and-forget: prime Ollama's KV cache with the user's system prompt."""
import httpx
from fabledassistant.services.llm import build_context, pick_num_ctx
from fabledassistant.services.tools import get_tools_for_user
def _is_private_url(url: str) -> bool:
"""SSRF-blocking helper: returns True for URLs that resolve to private,
loopback, or link-local addresses. Inlined here after services/llm.py
(the original home) was removed in Phase 8."""
try:
messages, _ = await build_context(
user_id=user_id,
history=[],
current_note_id=None,
user_message=" ",
)
# Size the prime to match what real chat requests will use, including
# tool schemas — otherwise Ollama reloads the model on the first real
# request and throws away the cache we just built.
tools = await get_tools_for_user(user_id)
num_ctx = pick_num_ctx(messages, tools=tools)
from fabledassistant.services.llm import keep_alive_for
async with httpx.AsyncClient(timeout=120.0) as client:
await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"options": {"num_predict": 1, "num_ctx": num_ctx},
"keep_alive": keep_alive_for(model),
},
)
logger.info("Primed KV cache for user %d with model '%s' (num_ctx=%d)", user_id, model, num_ctx)
host = urlparse(url).hostname
if not host:
return True
# Resolve to all addresses; reject if any is private/loopback/link-local.
infos = socket.getaddrinfo(host, None)
for family, *_rest, sockaddr in infos:
ip_str = sockaddr[0]
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
continue
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
except Exception:
logger.warning("Failed to prime KV cache for user %d", user_id, exc_info=True)
# Conservative: if we can't resolve, treat as private (reject).
return True
return False
settings_bp = Blueprint("settings", __name__, url_prefix="/api/settings")
@@ -64,29 +61,10 @@ async def update_settings_route():
if not isinstance(data, dict):
return jsonify({"error": "Expected a JSON object"}), 400
# Normalize model names to lowercase before validation. Ollama's /api/tags
# preserves whatever casing was used at pull time, but /api/chat rejects
# mixed-case tags — lowercasing here keeps the two paths consistent and
# means the stored setting is always in a form Ollama will actually accept.
_MODEL_KEYS = frozenset({"default_model", "background_model"})
for _key in _MODEL_KEYS:
if _key in data and data[_key]:
data[_key] = str(data[_key]).lower()
if "default_model" in data:
installed = await get_installed_models()
if data["default_model"]:
model = str(data["default_model"])
if installed and model not in installed:
return jsonify({"error": f"Model '{model}' is not installed"}), 400
# Empty string for model keys means "reset to system default".
# Delete the DB row so get_setting() falls back to Config defaults
# rather than returning "" and breaking model resolution everywhere.
to_save = {}
for k, v in data.items():
str_v = str(v)
if k in _MODEL_KEYS and not str_v:
if not str_v:
await delete_setting(uid, k)
else:
to_save[k] = str_v
@@ -94,29 +72,10 @@ async def update_settings_route():
if to_save:
await set_settings_batch(uid, to_save)
# Live-reschedule the journal daily-prep job when the timezone changes.
if "user_timezone" in to_save:
from fabledassistant.services.journal_scheduler import update_user_schedule as _update_journal_schedule
await _update_journal_schedule(uid)
if "default_model" in to_save and to_save["default_model"]:
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
settings = await get_all_settings(uid)
return jsonify(settings)
@settings_bp.route("/models", methods=["GET"])
@login_required
async def get_models_route():
"""Return installed Ollama models and the configured defaults."""
models = sorted(await get_installed_models())
return jsonify({
"models": models,
"default_chat_model": Config.OLLAMA_MODEL,
})
@settings_bp.route("/caldav", methods=["GET"])
@login_required
async def get_caldav():
@@ -166,12 +125,7 @@ async def test_caldav():
@settings_bp.route("/search", methods=["GET"])
@login_required
async def test_search():
"""Test SearXNG connectivity and preview results for a query."""
"""Report SearXNG configuration status (used by the Integrations tab)."""
if not Config.searxng_enabled():
return jsonify({"configured": False, "results": [], "searxng_url": ""})
q = request.args.get("q", "").strip()
if not q:
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
from fabledassistant.services.research import _search_searxng
results = await _search_searxng(q)
return jsonify({"configured": True, "results": results, "query": q, "searxng_url": Config.SEARXNG_URL})
return jsonify({"configured": True, "results": [], "searxng_url": Config.SEARXNG_URL})
-20
View File
@@ -284,23 +284,3 @@ async def delete_task_route(task_id: int):
return "", 204
@tasks_bp.route("/<int:task_id>/consolidate", methods=["POST"])
@login_required
async def consolidate_task_route(task_id: int):
"""Manually trigger a consolidation pass for a task.
Bypasses the auto_consolidate_tasks setting (the user is asking
explicitly). Returns the task's updated state including the freshly-
written body and consolidated_at timestamp.
"""
uid = get_current_user_id()
if not await can_write_note(uid, task_id):
return jsonify({"error": "Permission denied"}), 403
from fabledassistant.services.consolidation import consolidate_task
await consolidate_task(uid, task_id)
note = await get_note(uid, task_id)
if note is None:
return not_found("Task")
return jsonify(note.to_dict())
-258
View File
@@ -1,258 +0,0 @@
"""Voice (Speech-to-Speech) routes at /api/voice."""
import logging
import time
from quart import Blueprint, jsonify, request
from fabledassistant.auth import admin_required, login_required
logger = logging.getLogger(__name__)
voice_bp = Blueprint("voice", __name__, url_prefix="/api/voice")
@voice_bp.route("/status", methods=["GET"])
@login_required
async def voice_status():
"""Return availability of STT and TTS services."""
from fabledassistant.services.voice_config import get_voice_config
from fabledassistant.services.stt import stt_available
from fabledassistant.services.tts import tts_available
config = await get_voice_config()
enabled = config.get("voice_enabled", "false").lower() in ("1", "true", "yes")
if not enabled:
return jsonify({"enabled": False, "stt": False, "tts": False})
return jsonify({
"enabled": True,
"stt": stt_available(),
"tts": tts_available(),
"stt_model": config.get("voice_stt_model", "base.en"),
"tts_backend": "piper",
})
@voice_bp.route("/voices", methods=["GET"])
@login_required
async def list_voices():
"""Return available piper voice IDs and metadata.
Scans /opt/piper-voices (bundled) + /data/voices (admin-downloaded)
on every call so newly-downloaded voices show up without a restart.
Does NOT require tts_available() — even if the active voice failed
to load, the catalog is still useful for picking a different one.
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import list_voices
return jsonify({"voices": list_voices()})
@voice_bp.route("/transcribe", methods=["POST"])
@login_required
async def transcribe_audio():
"""Accept a multipart audio file and return the transcript.
Request: multipart/form-data with field 'audio' (WebM/Opus blob)
Response: {"transcript": "...", "duration_ms": 123}
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.stt import stt_available, transcribe
if not stt_available():
return jsonify({"error": "STT not available — model may still be loading"}), 503
files = await request.files
audio_file = files.get("audio")
if audio_file is None:
return jsonify({"error": "No audio file provided"}), 400
audio_bytes = audio_file.read()
if not audio_bytes:
return jsonify({"error": "Empty audio file"}), 400
if len(audio_bytes) > 25 * 1024 * 1024: # 25 MB hard cap
return jsonify({"error": "Audio file too large (max 25 MB)"}), 413
mime_type = audio_file.content_type or "audio/webm"
form = await request.form
context = (form.get("context") or "").strip() or None
t0 = time.monotonic()
try:
transcript = await transcribe(audio_bytes, mime_type, initial_prompt=context)
except Exception:
logger.exception("STT transcription failed")
return jsonify({"error": "Transcription failed"}), 500
duration_ms = round((time.monotonic() - t0) * 1000)
return jsonify({"transcript": transcript, "duration_ms": duration_ms})
@voice_bp.route("/synthesise", methods=["POST"])
@login_required
async def synthesise_speech():
"""Convert text to speech and return WAV bytes.
Request body: {"text": "...", "voice": "af_heart", "speed": 1.0}
Response: audio/wav bytes
"""
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return jsonify({"error": "Voice feature is disabled"}), 503
from fabledassistant.services.tts import synthesise, tts_available
if not tts_available():
return jsonify({"error": "TTS not available — model may still be loading"}), 503
data = await request.get_json()
if not data:
return jsonify({"error": "JSON body required"}), 400
text = str(data.get("text", "")).strip()
if not text:
return jsonify({"error": "text is required"}), 400
char_count = len(text)
if char_count > 8000:
logger.warning(
"TTS request rejected: text too long (%d chars, limit 8000). Preview: %r",
char_count, text[:120],
)
return jsonify({"error": "text too long (max 8000 characters)"}), 400
# Piper voice file basename (e.g. "en_US-amy-medium"). Default is read
# from user settings if not in the request body.
voice = str(data.get("voice", "")) or "en_US-amy-medium"
try:
speed = float(data.get("speed", 1.0))
except (TypeError, ValueError):
speed = 1.0
# Pull saved settings only when caller didn't override.
if "voice" not in data and "speed" not in data:
from fabledassistant.services.settings import get_setting
from fabledassistant.auth import get_current_user_id
try:
uid = get_current_user_id()
saved_voice = await get_setting(uid, "voice_tts_voice", "")
if saved_voice:
voice = saved_voice
saved_speed = await get_setting(uid, "voice_tts_speed", "")
if saved_speed:
try:
speed = float(saved_speed)
except ValueError:
pass
except Exception:
pass
logger.info("TTS synthesis start: %d chars, voice=%s, speed=%.2f", char_count, voice, speed)
t0 = time.monotonic()
try:
wav_bytes = await synthesise(text, voice=voice, speed=speed)
except Exception:
logger.exception(
"TTS synthesis failed: %d chars, voice=%s. Preview: %r",
char_count, voice, text[:120],
)
return jsonify({"error": "Synthesis failed"}), 500
duration_ms = round((time.monotonic() - t0) * 1000)
if not wav_bytes:
logger.warning(
"TTS synthesis returned empty audio: %d chars, voice=%s, %dms. Preview: %r",
char_count, voice, duration_ms, text[:120],
)
else:
logger.info(
"TTS synthesis complete: %d chars → %d bytes in %dms (voice=%s)",
char_count, len(wav_bytes), duration_ms, voice,
)
from quart import Response
return Response(wav_bytes, mimetype="audio/wav")
# ── Voice library (admin only) ──────────────────────────────────────────────
# Browse the piper-voices catalog, download new voices into /data/voices,
# remove user-installed voices. Bundled voices in /opt/piper-voices are
# read-only and cannot be touched via these endpoints.
# These are admin-only because installs consume shared disk and affect
# every user on the instance (voices are picked per-user, but the files
# themselves are shared).
@voice_bp.route("/voices/library", methods=["GET"])
@admin_required
async def voice_library():
"""Return the piper-voices catalog with install state annotations.
Query params:
?refresh=1 — force a fresh fetch from HuggingFace (bypass the 24h
in-memory cache). Use sparingly; HF doesn't appreciate hammering.
"""
from fabledassistant.services import voice_library as lib
force = (request.args.get("refresh") or "").lower() in ("1", "true", "yes")
try:
catalog = await lib.fetch_catalog(force_refresh=force)
except Exception:
logger.exception("Voice catalog fetch failed")
return jsonify({"error": "Failed to fetch voice catalog"}), 502
voices = lib.shape_catalog_for_ui(catalog)
return jsonify({"voices": voices, "count": len(voices)})
@voice_bp.route("/voices/install", methods=["POST"])
@admin_required
async def install_voice_route():
"""Download a voice into /data/voices.
Body: {"voice_id": "en_US-amy-medium"}
Idempotent — already-installed voices return {"skipped": true} without
re-downloading.
"""
from fabledassistant.services import voice_library as lib
data = await request.get_json()
voice_id = str((data or {}).get("voice_id") or "").strip()
if not voice_id:
return jsonify({"error": "voice_id is required"}), 400
try:
result = await lib.install_voice(voice_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except KeyError as e:
return jsonify({"error": str(e)}), 404
except Exception:
logger.exception("Voice install failed: %s", voice_id)
return jsonify({"error": "Voice install failed"}), 500
return jsonify(result)
@voice_bp.route("/voices/<voice_id>", methods=["DELETE"])
@admin_required
async def uninstall_voice_route(voice_id: str):
"""Remove a /data/voices voice. Bundled voices return 403."""
from fabledassistant.services import voice_library as lib
try:
result = await lib.uninstall_voice(voice_id)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except PermissionError as e:
return jsonify({"error": str(e)}), 403
except Exception:
logger.exception("Voice uninstall failed: %s", voice_id)
return jsonify({"error": "Voice uninstall failed"}), 500
return jsonify(result)
@@ -1,53 +0,0 @@
"""Generic article-text fetcher.
Fetches a URL and extracts its main body via trafilatura. The single source
of truth for article-content extraction across the codebase — used by the
``read_article`` LLM tool and the ``lookup`` tool's web-result enrichment.
Trafilatura/lxml is NOT safe to call concurrently — running it via
``run_in_executor`` from multiple coroutines can trip a libxml2 double-free.
Callers must serialize their fetches (await one before starting the next).
"""
from __future__ import annotations
import asyncio
import logging
import httpx
logger = logging.getLogger(__name__)
async def fetch_article_text(url: str) -> str | None:
"""Return the clean article body for *url*, or None on failure.
Returns None when the HTTP fetch fails or trafilatura yields nothing
useful. Callers should treat None as "no article content available."
"""
try:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
"User-Agent": "Mozilla/5.0 (compatible; FabledScribe/1.0; +https://fabledsword.com)",
}) as client:
resp = await client.get(url)
resp.raise_for_status()
raw_html = resp.text
except Exception:
logger.debug("Failed to fetch article URL %s", url)
return None
loop = asyncio.get_event_loop()
try:
import trafilatura
text = await loop.run_in_executor(
None,
lambda: trafilatura.extract(
raw_html,
include_comments=False,
include_tables=True,
favor_recall=True,
),
)
return text or None
except Exception:
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
return None
-92
View File
@@ -1,92 +0,0 @@
MAX_BODY_CHARS = 24000
MAX_CONTEXT_NOTE_CHARS = 1500
def _build_context_block(context_notes: list[dict]) -> str:
"""Format project context notes into a prompt block."""
lines = [
"--- Project Context ---",
"The following notes are from the same project. Notes tagged 'definition' are "
"canonical term definitions — treat them as authoritative. Use them to maintain "
"consistency in terminology, tone, style, and [[wikilinks]] to other documents.",
"",
]
for n in context_notes:
tags_str = f" [tags: {', '.join(n['tags'])}]" if n.get("tags") else ""
body_preview = (n["body"] or "")[:MAX_CONTEXT_NOTE_CHARS]
if len(n["body"] or "") > MAX_CONTEXT_NOTE_CHARS:
body_preview += ""
lines.append(f"**{n['title']}**{tags_str}")
if body_preview:
lines.append(body_preview)
lines.append("")
lines.append("--- End Project Context ---")
return "\n".join(lines)
def build_assist_messages(
body: str,
target_section: str,
instruction: str,
whole_doc: bool = False,
context_notes: list[dict] | None = None,
) -> list[dict]:
"""Build Ollama messages for writing assist.
When whole_doc=True, the model revises the entire document.
When whole_doc=False (section mode), the full body is provided as read-only
context and the model outputs only the replacement for the target section.
context_notes: optional list of {title, tags, body} dicts from the same project,
injected so the model can maintain consistency with related documents.
"""
truncated_body = body[:MAX_BODY_CHARS]
if len(body) > MAX_BODY_CHARS:
truncated_body += "\n… (truncated)"
context_block = (_build_context_block(context_notes) + "\n\n") if context_notes else ""
if whole_doc:
system_content = (
"You are a writing assistant. Revise the document per the instruction. "
"Output ONLY the complete revised document. Preserve all structure and "
"content not addressed by the instruction. "
"Preserve all markdown formatting exactly — including bullet lists (- item), "
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
"italic (_text_), and code blocks. Never flatten nested lists into plain text. "
"When referencing concepts defined in the Project Context, use [[wikilinks]] "
"to link to the relevant note by its exact title."
)
user_content = (
f"{context_block}"
f"--- Document ---\n{truncated_body}\n--- End Document ---\n\n"
f"Instruction: {instruction}"
)
else:
system_content = (
"You are an AI writing assistant integrated into a note-taking app. "
"The user is editing a document. The full document is shown below for context.\n\n"
f"--- Full Document ---\n{truncated_body}\n--- End Document ---\n\n"
"The user will give you a specific section of the document and an instruction. "
"Output ONLY the replacement text for that section. "
"If the target section starts with a markdown heading (e.g. ## Heading), "
"your output MUST also start with a heading at the same level. "
"You may revise the heading text but do not remove it. "
"Do not include other sections, explanatory text, or markdown code fences around the output. "
"Match the document's existing tone and style. "
"IMPORTANT: Preserve all markdown formatting exactly — including bullet lists (- item), "
"numbered lists (1. item), nested/indented sub-items ( - sub), bold (**text**), "
"italic (_text_), and code blocks. Never flatten nested lists into plain text. "
"When referencing concepts defined in the Project Context, use [[wikilinks]] "
"to link to the relevant note by its exact title."
)
user_content = (
f"{context_block}"
f"--- Target Section ---\n{target_section}\n--- End Target Section ---\n\n"
f"Instruction: {instruction}"
)
return [
{"role": "system", "content": system_content},
{"role": "user", "content": user_content},
]
-9
View File
@@ -7,7 +7,6 @@ import bcrypt
from sqlalchemy import func, select, update
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation
from fabledassistant.models.note import Note
from fabledassistant.models.invitation import InvitationToken
from fabledassistant.models.password_reset import PasswordResetToken
@@ -59,14 +58,6 @@ async def create_user(
.where((Note.user_id.is_(None)) | (Note.user_id == user.id))
.values(user_id=user.id)
)
await session.execute(
update(Conversation)
.where(
(Conversation.user_id.is_(None))
| (Conversation.user_id == user.id)
)
.values(user_id=user.id)
)
await session.execute(
update(Setting)
.where(Setting.user_id.is_(None))
+14 -127
View File
@@ -2,16 +2,13 @@ import logging
from datetime import date, datetime, timezone
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.milestone import Milestone
from fabledassistant.models.note import Note
from fabledassistant.models.note_draft import NoteDraft
from fabledassistant.models.note_version import NoteVersion
from fabledassistant.models.project import Project
from fabledassistant.models.push_subscription import PushSubscription
from fabledassistant.models.setting import Setting
from fabledassistant.models.task_log import TaskLog
from fabledassistant.models.user import User
@@ -43,18 +40,14 @@ async def export_full_backup() -> dict:
note_versions = (await session.execute(
select(NoteVersion).order_by(NoteVersion.note_id, NoteVersion.id)
)).scalars().all()
conversations = (await session.execute(
select(Conversation).options(selectinload(Conversation.messages))
)).scalars().all()
settings = (await session.execute(select(Setting))).scalars().all()
push_subs = (await session.execute(select(PushSubscription))).scalars().all()
return {
"version": 2,
"scope": "full",
"exported_at": datetime.now(timezone.utc).isoformat(),
"_security_notice": (
"This backup contains hashed passwords and push subscription keys. "
"This backup contains hashed passwords. "
"Store it securely and restrict access."
),
"users": [
@@ -156,44 +149,10 @@ async def export_full_backup() -> dict:
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"user_id": c.user_id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"status": m.status,
"context_note_id": m.context_note_id,
"tool_calls": m.tool_calls,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"user_id": s.user_id, "key": s.key, "value": s.value}
for s in settings
],
"push_subscriptions": [
{
"user_id": ps.user_id,
"endpoint": ps.endpoint,
"p256dh": ps.p256dh,
"auth": ps.auth,
"user_agent": ps.user_agent,
"created_at": ps.created_at.isoformat(),
}
for ps in push_subs
],
}
@@ -220,11 +179,6 @@ async def export_user_backup(user_id: int) -> dict:
select(NoteVersion).where(NoteVersion.user_id == user_id)
.order_by(NoteVersion.note_id, NoteVersion.id)
)).scalars().all()
conversations = (await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.user_id == user_id)
)).scalars().all()
settings = (await session.execute(
select(Setting).where(Setting.user_id == user_id)
)).scalars().all()
@@ -322,28 +276,6 @@ async def export_user_backup(user_id: int) -> dict:
}
for nv in note_versions
],
"conversations": [
{
"id": c.id,
"title": c.title,
"model": c.model,
"created_at": c.created_at.isoformat(),
"updated_at": c.updated_at.isoformat(),
"messages": [
{
"id": m.id,
"role": m.role,
"content": m.content,
"status": m.status,
"context_note_id": m.context_note_id,
"tool_calls": m.tool_calls,
"created_at": m.created_at.isoformat(),
}
for m in c.messages
],
}
for c in conversations
],
"settings": [
{"key": s.key, "value": s.value}
for s in settings
@@ -364,8 +296,12 @@ async def restore_full_backup(data: dict) -> dict:
async def _restore_v1(data: dict) -> dict:
"""Restore legacy v1 backup (original format)."""
stats = {"users": 0, "notes": 0, "conversations": 0, "messages": 0, "settings": 0}
"""Restore legacy v1 backup (original format).
Pre-pivot v1 backups included conversations + messages; those are
skipped during restore now that the chat subsystem is gone.
"""
stats = {"users": 0, "notes": 0, "settings": 0}
async with async_session() as session:
user_id_map: dict[int, int] = {}
@@ -416,31 +352,6 @@ async def _restore_v1(data: dict) -> dict:
if note_row:
note_row.parent_id = note_id_map[old_parent]
for c_data in data.get("conversations", []):
mapped_user_id = user_id_map.get(c_data.get("user_id", 0))
if mapped_user_id is None:
continue
conv = Conversation(
user_id=mapped_user_id,
title=c_data.get("title", ""),
model=c_data.get("model", ""),
created_at=_dt(c_data.get("created_at")),
updated_at=_dt(c_data.get("updated_at")),
)
session.add(conv)
await session.flush()
stats["conversations"] += 1
for m_data in c_data.get("messages", []):
msg = Message(
conversation_id=conv.id,
role=m_data["role"],
content=m_data.get("content", ""),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
created_at=_dt(m_data.get("created_at")),
)
session.add(msg)
stats["messages"] += 1
for s_data in data.get("settings", []):
mapped_user_id = user_id_map.get(s_data.get("user_id", 0))
if mapped_user_id is None:
@@ -455,11 +366,15 @@ async def _restore_v1(data: dict) -> dict:
async def _restore_v2(data: dict) -> dict:
"""Restore v2 backup with full FK re-mapping."""
"""Restore v2 backup with full FK re-mapping.
Conversations + push subscriptions in pre-pivot backups are silently
skipped — those subsystems were removed in the MCP-first pivot.
"""
stats: dict[str, int] = {
"users": 0, "projects": 0, "milestones": 0, "notes": 0,
"task_logs": 0, "note_drafts": 0, "note_versions": 0,
"conversations": 0, "messages": 0, "settings": 0,
"settings": 0,
}
async with async_session() as session:
@@ -616,35 +531,7 @@ async def _restore_v2(data: dict) -> dict:
session.add(nv)
stats["note_versions"] += 1
# 8. Conversations + Messages
for c_data in data.get("conversations", []):
mapped_uid = user_id_map.get(c_data.get("user_id", 0))
if mapped_uid is None:
continue
conv = Conversation(
user_id=mapped_uid,
title=c_data.get("title", ""),
model=c_data.get("model", ""),
created_at=_dt(c_data.get("created_at")),
updated_at=_dt(c_data.get("updated_at")),
)
session.add(conv)
await session.flush()
stats["conversations"] += 1
for m_data in c_data.get("messages", []):
msg = Message(
conversation_id=conv.id,
role=m_data["role"],
content=m_data.get("content", ""),
status=m_data.get("status", "complete"),
context_note_id=note_id_map.get(m_data["context_note_id"]) if m_data.get("context_note_id") else None,
tool_calls=m_data.get("tool_calls"),
created_at=_dt(m_data.get("created_at")),
)
session.add(msg)
stats["messages"] += 1
# 9. Settings
# 8. Settings
for s_data in data.get("settings", []):
mapped_uid = user_id_map.get(s_data.get("user_id", 0))
if mapped_uid is None:
-302
View File
@@ -1,302 +0,0 @@
import logging
from datetime import datetime, timedelta, timezone
from sqlalchemy import func, select, delete as sa_delete
from sqlalchemy.orm import selectinload
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.config import Config
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.notes import create_note
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
async def create_conversation(
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, conversation_type=conversation_type)
session.add(conv)
await session.commit()
# Re-fetch with messages eagerly loaded to avoid lazy-load in async context
result = await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(Conversation.id == conv.id)
)
return result.scalars().first()
async def get_conversation(
user_id: int, conversation_id: int
) -> Conversation | None:
async with async_session() as session:
result = await session.execute(
select(Conversation)
.options(selectinload(Conversation.messages))
.where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
return result.scalars().first()
async def list_conversations(
user_id: int, limit: int = 50, offset: int = 0, conv_type: str = "chat"
) -> tuple[list[dict], int]:
async with async_session() as session:
total = await session.scalar(
select(func.count(Conversation.id)).where(
Conversation.user_id == user_id,
Conversation.conversation_type == conv_type,
)
) or 0
# Subquery for message count — avoids loading all messages
msg_count = (
select(func.count(Message.id))
.where(Message.conversation_id == Conversation.id)
.correlate(Conversation)
.scalar_subquery()
)
result = await session.execute(
select(Conversation, msg_count.label("message_count"))
.where(Conversation.user_id == user_id, Conversation.conversation_type == conv_type)
.order_by(Conversation.updated_at.desc())
.limit(limit)
.offset(offset)
)
conversations = []
for row in result.all():
conv = row[0]
d = {
"id": conv.id,
"title": conv.title,
"model": conv.model,
"conversation_type": conv.conversation_type,
"day_date": conv.day_date.isoformat() if conv.day_date else None,
"rag_project_id": conv.rag_project_id,
"message_count": row[1],
"created_at": conv.created_at.isoformat(),
"updated_at": conv.updated_at.isoformat(),
}
conversations.append(d)
return conversations, total
async def delete_conversation(user_id: int, conversation_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
conv = result.scalars().first()
if conv is None:
return False
await session.delete(conv)
await session.commit()
return True
async def bulk_delete_conversations(user_id: int, ids: list[int]) -> int:
"""Delete multiple conversations by ID for a user. Returns count deleted."""
if not ids:
return 0
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(Conversation.user_id == user_id, Conversation.id.in_(ids))
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
async def cleanup_old_conversations(user_id: int, days: int) -> int:
"""Delete conversations older than `days` days. Returns count deleted."""
if days <= 0:
return 0
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
async with async_session() as session:
result = await session.execute(
sa_delete(Conversation)
.where(
Conversation.user_id == user_id,
Conversation.updated_at < cutoff,
Conversation.conversation_type != "mcp", # preserve MCP audit trail
Conversation.conversation_type != "voice", # voice convs managed separately
Conversation.conversation_type != "briefing", # briefing history managed by briefing system
)
.returning(Conversation.id)
)
await session.commit()
return len(result.fetchall())
_UNSET = object()
async def update_conversation(
user_id: int,
conversation_id: int,
title: str | None = None,
model: str | None = None,
rag_project_id: object = _UNSET,
) -> Conversation | None:
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.id == conversation_id,
Conversation.user_id == user_id,
)
)
conv = result.scalars().first()
if conv is None:
return None
if title is not None:
conv.title = title
if model is not None:
conv.model = model
if rag_project_id is not _UNSET:
conv.rag_project_id = rag_project_id # type: ignore[assignment]
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(conv)
return conv
async def update_conversation_title(
user_id: int, conversation_id: int, title: str
) -> Conversation | None:
return await update_conversation(user_id, conversation_id, title=title)
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
msg_metadata: dict | None = None,
) -> Message:
async with async_session() as session:
kwargs: dict = dict(
conversation_id=conversation_id,
role=role,
content=content,
context_note_id=context_note_id,
)
if status is not None:
kwargs["status"] = status
if tool_calls is not None:
kwargs["tool_calls"] = tool_calls
if msg_metadata is not None:
kwargs["msg_metadata"] = msg_metadata
msg = Message(**kwargs)
session.add(msg)
# Touch conversation updated_at
conv = await session.get(Conversation, conversation_id)
if conv:
conv.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(msg)
return msg
async def get_message(message_id: int) -> Message | None:
async with async_session() as session:
return await session.get(Message, message_id)
async def save_response_as_note(user_id: int, message_id: int) -> dict:
"""Create a note from an assistant message. Returns the new note dict."""
msg = await get_message(message_id)
if msg is None:
raise ValueError("Message not found")
if msg.role != "assistant":
raise ValueError("Can only save assistant messages as notes")
conv = await get_conversation(user_id, msg.conversation_id)
# Generate title via LLM using the assistant message content
title = ""
if conv:
try:
prompt_messages = [
{
"role": "system",
"content": (
"Generate a concise 3-8 word title for a note based on "
"this content. Reply with ONLY the title, no quotes or "
"punctuation."
),
},
{"role": "user", "content": msg.content[:2000]},
]
# 3-8 word title generation is the kind of trivial small-input
# / small-output task the chat model (default_model) handles in
# ~1s. Routing here so the worker (background_model) isn't
# interrupted from heavier curator / prep / closeout passes.
chat_model = (
await get_setting(user_id, "default_model", "")
or Config.OLLAMA_MODEL
)
title = await generate_completion(prompt_messages, chat_model)
title = title.strip().strip('"\'').strip()[:100]
except Exception:
logger.warning("Failed to generate note title, using fallback", exc_info=True)
if not title:
lines = msg.content.strip().split("\n", 1)
title = lines[0].strip().lstrip("# ")[:100]
note = await create_note(user_id, title=title, body=msg.content, tags=["chat"])
return note.to_dict()
async def summarize_conversation_as_note(
user_id: int, conversation_id: int, model: str
) -> dict:
"""Summarize a conversation using the LLM and save as a note."""
conv = await get_conversation(user_id, conversation_id)
if conv is None:
raise ValueError("Conversation not found")
# Build the conversation text
conv_text = []
for msg in conv.messages:
if msg.role == "system":
continue
label = "User" if msg.role == "user" else "Assistant"
conv_text.append(f"{label}: {msg.content}")
prompt_messages = [
{
"role": "system",
"content": (
"Summarize the following conversation into a concise note. "
"Include key points, decisions, and any action items. "
"Format the summary in markdown."
),
},
{"role": "user", "content": "\n\n".join(conv_text)},
]
logger.info("Summarizing conversation %d with model %s", conversation_id, model)
summary = await generate_completion(prompt_messages, model)
title = conv.title or "Conversation Summary"
title = f"Summary: {title}"
note = await create_note(user_id, title=title, body=summary, tags=["chat"])
return note.to_dict()
@@ -1,206 +0,0 @@
"""Background task-body consolidation pipeline.
Reads a task's description (user goal) + work logs (chronological) and writes
a 1-3 paragraph summary into Note.body via the background model. Triggered by
log accumulation (debounced), status transitions to terminal states, and a
manual API endpoint.
Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
"""
from __future__ import annotations
import asyncio
import logging
from collections import defaultdict
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
from fabledassistant.models.note import Note
from fabledassistant.models.task_log import TaskLog
logger = logging.getLogger(__name__)
# Trigger thresholds. Tunable as constants; could be promoted to env vars if
# the defaults prove wrong in practice.
DEFAULT_LOG_THRESHOLD = 3
MAX_LOGS_FOR_PROMPT = 50
MAX_PROMPT_INPUT_CHARS = 8000
# Per-task asyncio locks to prevent two simultaneous consolidations of the
# same task. Single-process; no cross-process coordination needed.
_locks: dict[int, asyncio.Lock] = defaultdict(asyncio.Lock)
async def _logs_since_last_consolidation(user_id: int, task_id: int) -> int:
"""Count work logs that arrived after the most recent consolidation pass.
Returns 0 if the task doesn't exist. Returns the total log count when
consolidated_at is NULL (i.e. never consolidated).
"""
async with async_session() as session:
task = (
await session.execute(
select(Note).where(Note.id == task_id, Note.user_id == user_id)
)
).scalars().first()
if task is None:
return 0
stmt = select(func.count(TaskLog.id)).where(
TaskLog.task_id == task_id, TaskLog.user_id == user_id,
)
if task.consolidated_at is not None:
stmt = stmt.where(TaskLog.created_at > task.consolidated_at)
return int((await session.execute(stmt)).scalar() or 0)
async def _auto_consolidate_enabled(user_id: int) -> bool:
"""User-level setting; default true. Manual endpoint bypasses this."""
from fabledassistant.services.settings import get_setting
val = await get_setting(user_id, "auto_consolidate_tasks", "true")
return str(val).lower() in ("true", "1", "yes")
async def maybe_consolidate(user_id: int, task_id: int, *, reason: str) -> None:
"""Debounced gate. Decides whether to schedule a consolidate_task pass.
reason='log_added' — gated by log count >= DEFAULT_LOG_THRESHOLD
reason='task_closed' — proceeds unconditionally (subject to setting)
"""
if not await _auto_consolidate_enabled(user_id):
return
if reason == "log_added":
n = await _logs_since_last_consolidation(user_id, task_id)
if n < DEFAULT_LOG_THRESHOLD:
return
elif reason != "task_closed":
logger.warning("maybe_consolidate: unknown reason %r", reason)
return
# Fire-and-forget; consolidate_task handles its own errors.
asyncio.create_task(consolidate_task(user_id, task_id))
def _build_consolidation_prompt(
*, title: str, description: str | None, logs: list,
) -> str:
"""Build the LLM prompt for one consolidation pass.
Caps total log content at MAX_PROMPT_INPUT_CHARS; logs that don't fit
are dropped. Caller is expected to slice to the most-recent window
before calling.
"""
log_lines: list[str] = []
chars = 0
for log in logs:
ts = log.created_at.isoformat() if getattr(log, "created_at", None) else "?"
line = f"- [{ts}] {log.content}"
if chars + len(line) > MAX_PROMPT_INPUT_CHARS:
break
log_lines.append(line)
chars += len(line)
return (
"You are summarizing the work done on a task. The user wrote the goal "
"below; do not restate it. Read the chronological work-log entries and "
"produce a 1-3 paragraph summary of: what was attempted, what worked, "
"what failed, what's current state. Use the user's voice; cite specific "
"commands/decisions; favor brevity over completeness. Output plain "
"markdown body content only — no preamble.\n\n"
f"TITLE: {title}\n"
f"GOAL (read-only context): {description or '(no goal recorded)'}\n"
f"WORK LOG (chronological):\n" + "\n".join(log_lines)
)
async def consolidate_task(user_id: int, task_id: int) -> None:
"""Run a consolidation pass: read description + logs, write summary to body.
Errors are logged and swallowed so the fire-and-forget caller is never
interrupted; on LLM failure the body and consolidated_at are left
untouched and the next trigger retries.
"""
lock = _locks[task_id]
if lock.locked():
logger.debug(
"consolidate_task: skipping — already running for task %d", task_id
)
return
async with lock:
try:
async with async_session() as session:
task = (
await session.execute(
select(Note).where(
Note.id == task_id, Note.user_id == user_id,
)
)
).scalars().first()
if task is None or not task.status:
return # not a task, or missing
logs = (
await session.execute(
select(TaskLog)
.where(
TaskLog.task_id == task_id,
TaskLog.user_id == user_id,
)
.order_by(TaskLog.created_at.asc())
)
).scalars().all()
if not logs:
return # nothing to summarize yet
title = task.title or ""
description = task.description
window = logs[-MAX_LOGS_FOR_PROMPT:]
prompt = _build_consolidation_prompt(
title=title, description=description, logs=window,
)
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.settings import get_setting
from fabledassistant.config import Config
bg_model = await get_setting(
user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL,
)
summary = await generate_completion(
[{"role": "user", "content": prompt}],
model=bg_model,
max_tokens=800,
num_ctx=4096,
)
if not summary or not summary.strip():
return
now = datetime.now(timezone.utc)
async with async_session() as session:
task = (
await session.execute(
select(Note).where(
Note.id == task_id, Note.user_id == user_id,
)
)
).scalars().first()
if task is None:
return
task.body = summary.strip()
task.consolidated_at = now
task.updated_at = now
await session.commit()
from fabledassistant.services.embeddings import upsert_note_embedding
await upsert_note_embedding(
task_id, user_id, f"{title}\n{summary.strip()}".strip(),
)
logger.info(
"consolidate_task: refreshed task %d body (%d chars)",
task_id, len(summary),
)
except Exception:
logger.exception(
"consolidate_task failed for task %d", task_id,
)
-465
View File
@@ -1,465 +0,0 @@
"""Journal curator: async LLM pass that extracts captures from a chat.
Architecture (2026-05-22 brainstorm, Fable note #172):
The journal chat model has no tools — it just talks. This curator is the
second LLM pass that reads recent journal messages and fires the tool
calls (record_moment, update_task, etc.) the chat model can't.
Runs against the user's `background_model` so the chat model's KV cache
isn't disturbed. Can be triggered manually via the journal route or by
the scheduler (phase 2). The chat model is unaffected during a curator
run; this is intentionally fire-and-go.
The curator does NOT add its own messages to the conversation. Only the
side-effects of its tool calls land in the database (moments table,
notes table, tasks, etc.). The user sees those side-effects via the
existing journal data surfaces, not via a chat-stream injection — see
the brainstorm doc's surfacing decision (right-rail captures panel, not
inline observer voice).
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.llm import pick_num_ctx, stream_chat_with_tools
from fabledassistant.services.settings import get_setting
from fabledassistant.services.tools import execute_tool, get_tools_for_user
logger = logging.getLogger(__name__)
# Tool-call iteration cap. The chat path uses 6; the curator should
# converge faster because its task is bounded (extract beats from a
# fixed transcript, not respond to evolving conversation).
_MAX_TOOL_ROUNDS = 4
# Module-level serial lock for `run_curator_for_conversation`. The curator
# model is typically large (30b-70b) and runs on CPU+RAM; loading more
# than one curator request at a time wastes memory on a KV cache slot
# that's never going to be used in parallel, and can swap the worker
# model into thrashing. Every entry point — scheduler sweep, manual
# trigger from the journal route, future hooks — must acquire this lock
# so the system guarantees at most one curator pass runs at a time
# globally. Manual-trigger route checks `is_curator_running()` first
# and returns 409 rather than blocking the HTTP request.
_CURATOR_RUN_LOCK = asyncio.Lock()
def is_curator_running() -> bool:
"""True iff a curator pass is currently executing.
Used by the manual-trigger route to decide between 'queue' (block on
the lock) and 'reject' (return 409). Avoids tying up an HTTP request
for minutes when a curator pass on a 70b CPU model is already in
flight.
"""
return _CURATOR_RUN_LOCK.locked()
# Curator tool allowlist. Additive operations only — no updates, no
# deletes. Risk model: the curator can be confidently wrong, and the
# user is not in the loop when it runs. Adds are easily undone by the
# user (delete the moment, delete the task). Updates and deletes are
# not — a curator that confidently overwrites a task title with the
# wrong value is worse than a curator that creates a duplicate task.
#
# Plus a small set of read-only helpers the curator needs for entity
# resolution before it can safely link names → ids on the additive
# calls.
_CURATOR_ALLOWED_TOOLS: frozenset[str] = frozenset({
# Additive — primary curator work; run directly.
"record_moment",
"create_note", # also creates tasks (status='todo')
"log_work", # appends to a task's work-log timeline
"save_person",
"save_place",
"create_project",
"create_milestone",
# Mutating — intercepted by execute_tool's authority="curator" path
# and routed to the pending-actions queue for user review.
# See services/tools/_registry.py:_CURATOR_MUTATING_TOOLS.
"update_note", # covers tasks + notes; routed to pending
"update_milestone", # routed to pending
"update_project", # routed to pending
"update_profile", # routed to pending
"delete_note", # routed to pending
# Read-only — supporting lookups for entity resolution and cross-ref.
"search_notes",
"search_projects",
"search_journal",
"list_tasks",
"list_projects",
"list_milestones",
"read_note",
"get_project",
"get_profile",
})
_CURATOR_SYSTEM_PROMPT = """You are a curator reading a fragment of the user's journal conversation. Your job is to capture meaningful beats as structured records using the tools provided. You do NOT respond to the user — your only output is tool calls.
TRANSCRIPT FORMAT:
The transcript shows lines prefixed with either `User:` or `Assistant:`. Only the `User:` lines are journal entries. The `Assistant:` lines are context — they tell you what was said back, so you can disambiguate references ("which Sarah?") — but they are NEVER journal beats themselves. NEVER create a moment or any other record based on content that appears only in an `Assistant:` line. If a user line is short and the meaning depends on the assistant's question, capture the user's intent in your own concise phrasing, not by quoting the assistant.
WHAT TO CAPTURE (from `User:` lines only):
- Events that happened ("I went grocery shopping", "finished the network restage")
- Encounters with people ("had coffee with Sarah", "called Mom")
- Decisions ("going to switch jobs", "won't pursue the contract")
- Observations about the user's state or world ("the new place is loud", "feeling tired")
- Plans and commitments ("watching a show tonight", "dentist Thursday")
- Small accomplishments or changes the user made ("installed the new AP", "shipped the migration")
- New tasks or todos the user wants to track ("I need to call mom tomorrow", "remind me to renew the domain")
- Knowledge worth saving as a note ("the dhcp issue at Bedford was caused by stale leases")
TOOL USE — two categories.
**Additive (run immediately).** These tools persist to the user's data on the spot. Use them freely when warranted:
- `record_moment` — for journal beats. **EXACTLY ONE tool call per distinct beat.** If the user mentioned three things, call record_moment three times — once per thing. NEVER call record_moment more than once for the same beat with different phrasings. Before each call, mentally check: "have I already recorded this beat in a previous tool call this turn?" If yes, skip — duplicates are worse than misses. Write content in the user's voice (first-person or imperative); NEVER "the user mentioned…" framing.
- `create_note` — create a task (set status='todo' plus due_date/priority if mentioned) OR a knowledge note (omit status). Use this when the user states a new commitment with a clear scope, or shares a chunk of reusable knowledge.
- `log_work` — append a progress entry to an existing task. Use this when the user describes work they did on a task that already exists. ALWAYS call search_notes first to confirm the task exists by title or keyword; if no match, prefer create_note with status='todo' instead, or skip.
- `save_person` / `save_place` — **call this when the user explicitly introduces a named person or place you don't already have an entry for.** Explicit introductions look like "my father's name is Dale", "my friend Sarah works at Famous Supply", "we went to Olive Garden", "I work out of the Bedford office". When the user gives a name AND signal (relation, role, or context), create the entry — don't skip. The conservative "skip if unsure" rule applies to ambiguous mentions ("a friend told me…", "someone at work said…"), not to clearly-introduced names. Pass the name, and any relation/role/notes the user provided, as the entry's content.
- `create_project` / `create_milestone` — only when the user is clearly starting a NEW project or milestone they intend to track. Don't infer projects from passing mentions.
**Proposing (queued for user review).** These tools mutate existing data; calls are intercepted and shown to the user for approval in the Needs Review panel. Use them when the user's intent is clear, but expect each one to wait for explicit user OK:
- `update_note` — mark a task done ("I finished the network restage" → update_note query='network restage', status='done'), change priority, edit a note's body, etc. ALWAYS search_notes first to verify the target exists.
- `update_milestone` — close a milestone the user explicitly says is finished, or rename one.
- `update_project` — adjust a project's status/description when the user explicitly states a change.
- `update_profile` — record a profile fact the user explicitly stated about themselves ("I'm a backend engineer now"). Don't infer profile updates from passing context.
- `delete_note` — propose removing a note or task the user explicitly says they no longer need.
For proposing calls, the user sees a "before / proposed" diff and approves or rejects. You don't need to confirm with the user yourself — proposing IS the asking. If the proposal returns `{success: true, pending: true}`, that means it's queued; carry on with other captures.
ENTITY LINKING (on record_moment):
- Use the *_names parameters (person_names, place_names, task_titles, note_titles). The server resolves names → ids.
- Before passing task_titles or note_titles, call search_notes to confirm the title exists. Don't invent titles.
CROSS-REFERENCE — surface relevant past work:
When the user mentions a project, task, person, place, or topic that you can search for, call search_notes / search_journal / search_projects to find what they've already written or done about it. This is NOT about deciding what to capture — it's about giving the chat model context about past work next turn.
Examples of when to search:
- User mentions "Famous Supply" → search_projects("Famous Supply") to confirm it exists; search_journal(query="Famous Supply") to find recent related moments.
- User describes ongoing work on a topic → search_journal to find what they noted before.
- User mentions a person or place by name → search_notes to find related context, especially for recent mentions.
In your final summary line, weave 1-2 short references to relevant past entries when they connect meaningfully to today's beats. Example: "Captured the dhcp fix at Bedford; related to the network restage note from May 13." Avoid: enumeration ("found 5 related notes"), or invented references — only mention what you actually retrieved.
If nothing relevant turns up, don't force a reference. The cross-ref is helpful when natural, dead weight when forced.
WHAT NOT TO DO:
- Don't capture content from `Assistant:` lines.
- Don't fire tool calls for purely meta-conversational fragments ("ok", "thanks", "got it").
- Don't try to call create_event, delete_event, or anything calendar-related (curator scope excludes calendar entirely).
After the tool calls, you may emit one short summary sentence (≤ 20 words) describing what you captured. The summary is shown back to the chat model in subsequent turns so it stays aware of recent topics; it is NOT shown to the user directly. Examples:
- "Captured network restage progress and a coffee mention with Sarah."
- "Logged work on the Famous Supply task; recorded a tired but accomplished feeling."
- "Created a task for the domain renewal next month."
- "" (empty if nothing was captured — perfectly fine).
"""
@dataclass
class CuratorToolCall:
"""One tool call attempted by the curator."""
name: str
arguments: dict
result: dict | None = None
error: str | None = None
status: str = "pending" # success | error | pending
@dataclass
class CuratorRunResult:
"""What the curator did in a single pass over a conversation."""
conv_id: int
user_id: int
model: str
messages_examined: int
tool_calls: list[CuratorToolCall] = field(default_factory=list)
summary: str = ""
duration_ms: int = 0
error: str | None = None
@property
def tools_attempted(self) -> int:
return len(self.tool_calls)
@property
def tools_succeeded(self) -> int:
return sum(1 for tc in self.tool_calls if tc.status == "success")
def to_dict(self) -> dict:
return {
"conv_id": self.conv_id,
"user_id": self.user_id,
"model": self.model,
"messages_examined": self.messages_examined,
"tool_calls": [
{
"name": tc.name,
"arguments": tc.arguments,
"status": tc.status,
"error": tc.error,
}
for tc in self.tool_calls
],
"tools_attempted": self.tools_attempted,
"tools_succeeded": self.tools_succeeded,
"summary": self.summary,
"duration_ms": self.duration_ms,
"error": self.error,
}
def _format_transcript(messages: list[Message]) -> str:
"""Render a list of Message rows as a plain transcript the curator can read.
Only user + assistant messages are included. System messages (instructions
the chat model received) and tool-result messages (JSON noise from prior
tool calls) are noise for the curator's task; including them risked the
curator extracting from system text. The system prompt explicitly tells
the curator to extract ONLY from `User:` lines and to treat `Assistant:`
lines as context, so the format mirrors that contract.
"""
lines: list[str] = []
for m in messages:
if not m.content:
continue
role = (m.role or "").lower()
if role not in ("user", "assistant"):
continue
ts = m.created_at.strftime("%H:%M") if m.created_at else "??:??"
role_label = role.capitalize()
lines.append(f"[{ts}] {role_label}: {m.content.strip()}")
return "\n".join(lines)
async def _load_messages_since(
conv_id: int, since: datetime | None
) -> list[Message]:
"""Load conversation messages since the cutoff (or all of today)."""
async with async_session() as session:
stmt = select(Message).where(Message.conversation_id == conv_id)
if since is not None:
stmt = stmt.where(Message.created_at > since)
stmt = stmt.order_by(Message.created_at.asc())
result = await session.execute(stmt)
return list(result.scalars().all())
async def run_curator_for_conversation(
conv_id: int,
*,
since: datetime | None = None,
user_id_override: int | None = None,
) -> CuratorRunResult:
"""Run a single curator pass over the given journal conversation.
Args:
conv_id: target conversation.
since: only consider messages after this timestamp. Defaults to
the last 24 hours so a first manual trigger gets the day's
worth of context without going back forever.
user_id_override: optional — used by the scheduler to attribute
the run to the conversation's owner without re-fetching.
Manual triggers from the route pass None and we read from
the conversation row.
Returns a CuratorRunResult; never raises (errors land in result.error).
Guarded by `_CURATOR_RUN_LOCK` so at most one curator pass runs at
once globally. The scheduler processes candidates serially within
a sweep anyway; the lock matters when the manual-trigger route
fires concurrently with a sweep (or vice versa). Manual-trigger
callers should `is_curator_running()` first and reject rather than
block, since acquiring this lock can take minutes for a large model.
"""
async with _CURATOR_RUN_LOCK:
return await _run_curator_inner(
conv_id, since=since, user_id_override=user_id_override,
)
async def _run_curator_inner(
conv_id: int,
*,
since: datetime | None = None,
user_id_override: int | None = None,
) -> CuratorRunResult:
"""Curator-pass body. Always invoked under `_CURATOR_RUN_LOCK`."""
started_at = time.monotonic()
async with async_session() as session:
conv = await session.get(Conversation, conv_id)
if conv is None:
return CuratorRunResult(
conv_id=conv_id, user_id=0, model="",
messages_examined=0, error=f"Conversation {conv_id} not found",
)
if conv.conversation_type != "journal":
return CuratorRunResult(
conv_id=conv_id, user_id=conv.user_id, model="",
messages_examined=0,
error=f"Curator only runs on journal conversations (got {conv.conversation_type!r})",
)
user_id = user_id_override or conv.user_id
# Default lookback: last 24h. Phase 2's scheduler will narrow this
# by passing the conversation's last_curator_run_at as `since`.
if since is None:
since = datetime.now(timezone.utc) - timedelta(hours=24)
messages = await _load_messages_since(conv_id, since)
if not messages:
logger.info(
"Curator skipped conv %d: no new messages since %s",
conv_id, since.isoformat(),
)
return CuratorRunResult(
conv_id=conv_id, user_id=user_id,
model="", messages_examined=0,
duration_ms=int((time.monotonic() - started_at) * 1000),
)
# Use the background model so the chat model's KV cache survives.
# Falls back to OLLAMA_MODEL if no background model is configured.
model = await get_setting(user_id, "background_model", "") or Config.OLLAMA_MODEL
# Filter the journal tool set down to the curator's additive-only
# allowlist (see _CURATOR_ALLOWED_TOOLS comment). Belt-and-suspenders
# with the system prompt: if the prompt fails to dissuade the model
# from update/delete, the tool list literally doesn't include them
# so the call can't fire even if hallucinated. execute_tool also
# treats unknown names as errors, so the filter is the canonical
# boundary for what the curator can actually do.
all_tools = await get_tools_for_user(user_id, conversation_type="journal")
tools = [
t for t in all_tools
if (t.get("function") or {}).get("name") in _CURATOR_ALLOWED_TOOLS
]
transcript = _format_transcript(messages)
user_prompt = (
"Below is a fragment of the user's journal conversation. Extract "
"the captureable beats using the tools provided, then emit one "
"short summary line (or empty).\n\n"
"TRANSCRIPT:\n"
f"{transcript}\n"
)
llm_messages: list[dict] = [
{"role": "system", "content": _CURATOR_SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
]
result = CuratorRunResult(
conv_id=conv_id, user_id=user_id, model=model,
messages_examined=len(messages),
)
try:
num_ctx = pick_num_ctx(llm_messages, tools=tools)
summary_chunks: list[str] = []
# Tool-call iteration loop — same shape as run_generation, but
# without SSE streaming since nothing is watching live.
for round_idx in range(_MAX_TOOL_ROUNDS):
tool_calls_this_round: list[dict] = []
content_this_round: list[str] = []
async for chunk in stream_chat_with_tools(
llm_messages, model, tools=tools, think=False, num_ctx=num_ctx,
):
if chunk.type == "content":
content_this_round.append(chunk.content)
elif chunk.type == "tool_calls":
tool_calls_this_round = chunk.tool_calls or []
elif chunk.type == "done":
break
# The model's content this round contributes to the summary
# only on the final round (after no more tool calls fire).
if not tool_calls_this_round:
summary_chunks.append("".join(content_this_round).strip())
break
# Execute each tool call, capture result, append back into the
# message list as a tool-role message so the model sees what
# happened on the next round.
llm_messages.append({
"role": "assistant",
"content": "".join(content_this_round),
"tool_calls": tool_calls_this_round,
})
for tc in tool_calls_this_round:
fn = tc.get("function", {}) if isinstance(tc, dict) else {}
name = fn.get("name") or ""
args = fn.get("arguments") or {}
if isinstance(args, str):
try:
args = json.loads(args)
except Exception:
args = {}
call = CuratorToolCall(name=name, arguments=args)
try:
# authority="curator" routes mutating tools
# (_CURATOR_MUTATING_TOOLS in tools/_registry.py) to
# the pending-actions queue instead of executing them.
# Additive tools run normally; the curator can never
# silently mutate user data.
tool_result = await execute_tool(
user_id, name, args,
conv_id=conv_id,
authority="curator",
)
call.result = tool_result
call.status = (
"success" if (tool_result or {}).get("success", True)
and not (tool_result or {}).get("error")
else "error"
)
if call.status == "error":
call.error = str((tool_result or {}).get("error", ""))[:500]
except Exception as e:
call.status = "error"
call.error = f"{type(e).__name__}: {e}"[:500]
tool_result = {"success": False, "error": call.error}
logger.exception("Curator tool %r failed for conv %d", name, conv_id)
result.tool_calls.append(call)
llm_messages.append({
"role": "tool",
"content": json.dumps(tool_result)[:4000],
})
else:
logger.warning(
"Curator hit _MAX_TOOL_ROUNDS=%d for conv %d (still emitting tool calls)",
_MAX_TOOL_ROUNDS, conv_id,
)
# Trim summary: at most one sentence, cap length aggressively.
summary = " ".join(summary_chunks).strip().splitlines()
result.summary = (summary[0][:240] if summary and summary[0] else "")
except Exception as e:
result.error = f"{type(e).__name__}: {e}"
logger.exception("Curator run failed for conv %d", conv_id)
result.duration_ms = int((time.monotonic() - started_at) * 1000)
logger.info(
"Curator pass complete: conv=%d model=%s messages=%d "
"tool_calls=%d (ok=%d) duration=%dms summary=%r",
conv_id, model, result.messages_examined,
result.tools_attempted, result.tools_succeeded,
result.duration_ms, result.summary[:60],
)
return result
@@ -1,197 +0,0 @@
"""APScheduler entry that runs the journal curator periodically.
Phase 2 of the conversation+curator architecture (Fable #172). Every
`CURATOR_INTERVAL_MIN` minutes, scans every journal conversation that
has user messages newer than its `last_curator_run_at` timestamp and
runs `curator.run_curator_for_conversation` against it.
Design notes:
- One global job, not per-user. The scheduler is system-wide because
the curator runs are bounded (one short Ollama call per conversation)
and the cost of "scan all journal conversations" is tiny next to the
cost of an LLM call.
- Idempotent. If no journal conversation has new messages, the job
does nothing. If a curator run fails, the timestamp is NOT advanced
so the next sweep retries.
- Bounded concurrency. The job processes conversations sequentially —
the Ollama server is a single bottleneck and parallelism would
contend on KV cache slots. With OLLAMA_MAX_LOADED_MODELS=2 (chat +
curator separate models), the curator can run undisturbed by chat.
- Skipped when the curator has nothing meaningful to do: only
considers conversations whose newest user message is newer than
`last_curator_run_at`. Read-only sweeps are common and cheap.
The scheduler is started in app.py alongside the existing journal_scheduler.
"""
from __future__ import annotations
import asyncio
import datetime
import logging
from datetime import timezone
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import func, select, update
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.curator import run_curator_for_conversation
logger = logging.getLogger(__name__)
# 15 minutes between sweeps. Adjustable later (settings or env var) if
# the cadence turns out wrong in practice. Tested in conversation: long
# enough that small-talk doesn't generate constant curator runs, short
# enough that captures appear before the user forgets what they said.
CURATOR_INTERVAL_MIN = 15
# Hard cap on conversations processed per sweep — if there's a backlog
# (e.g. service was down for a day), we don't want one sweep to take an
# hour. Anything past this cap rolls to the next sweep.
_MAX_CONVERSATIONS_PER_SWEEP = 20
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
_running_lock = asyncio.Lock() # prevents overlapping sweeps if one runs long
async def _candidate_conversations() -> list[tuple[int, datetime.datetime | None]]:
"""Return (conv_id, last_curator_run_at) for journal conversations that
have user messages newer than the last curator run. Capped at the
sweep limit.
Returning the timestamp alongside the id lets `_sweep` pass it to the
curator as the `since` cutoff, so each sweep only sees messages added
since the previous successful pass — no re-extracting already-captured
beats.
"""
async with async_session() as session:
# Per-conversation: newest USER message timestamp, vs last_curator_run_at.
latest_user_msg = (
select(
Message.conversation_id.label("conv_id"),
func.max(Message.created_at).label("latest_at"),
)
.where(Message.role == "user")
.group_by(Message.conversation_id)
.subquery()
)
stmt = (
select(Conversation.id, Conversation.last_curator_run_at)
.join(latest_user_msg, latest_user_msg.c.conv_id == Conversation.id)
.where(Conversation.conversation_type == "journal")
.where(
(Conversation.last_curator_run_at.is_(None))
| (latest_user_msg.c.latest_at > Conversation.last_curator_run_at)
)
.order_by(latest_user_msg.c.latest_at.asc())
.limit(_MAX_CONVERSATIONS_PER_SWEEP)
)
rows = await session.execute(stmt)
return [(row[0], row[1]) for row in rows.all()]
async def _stamp_last_run(conv_id: int, summary: str | None = None) -> None:
"""Bump last_curator_run_at to now() after a successful pass.
When `summary` is non-empty, also persists it to `curator_summary`
so the chat model picks it up on subsequent turns (Phase 3 feedback
loop). An empty summary means the curator had nothing meaningful
to capture — we still bump the timestamp (the run succeeded), but
leave the existing summary in place rather than clobbering useful
context with "".
"""
values: dict = {"last_curator_run_at": datetime.datetime.now(timezone.utc)}
if summary:
values["curator_summary"] = summary.strip()[:240]
async with async_session() as session:
await session.execute(
update(Conversation).where(Conversation.id == conv_id).values(**values)
)
await session.commit()
async def _sweep() -> None:
"""Process all candidate journal conversations.
Wrapped in a lock so that if a sweep is still running when the next
interval fires, the new one waits / is skipped rather than running
concurrently. Ollama doesn't love parallel requests on the same model.
"""
if _running_lock.locked():
logger.info("Curator sweep already in progress; skipping this tick")
return
async with _running_lock:
try:
candidates = await _candidate_conversations()
except Exception:
logger.exception("Curator candidate query failed")
return
if not candidates:
logger.debug("Curator sweep: no journal conversations need processing")
return
logger.info(
"Curator sweep: %d journal conversation(s) to process",
len(candidates),
)
for conv_id, last_run_at in candidates:
try:
# Pass last_run_at as the `since` cutoff so the curator only
# examines messages added since the previous successful pass.
# Without this, every sweep re-loads the curator's default
# 24h window — and the LLM re-extracts beats from messages
# that were already captured, producing duplicate moments.
result = await run_curator_for_conversation(
conv_id, since=last_run_at,
)
if result.error:
logger.warning(
"Curator run errored for conv %d (will retry next sweep): %s",
conv_id, result.error,
)
continue
await _stamp_last_run(conv_id, summary=result.summary)
except Exception:
logger.exception(
"Curator sweep crashed on conv %d (will retry next sweep)",
conv_id,
)
def _sweep_threadsafe() -> None:
"""BackgroundScheduler runs jobs on its own thread; bridge to the asyncio loop."""
if _loop is None:
logger.warning("Curator scheduler tick but no asyncio loop registered")
return
asyncio.run_coroutine_threadsafe(_sweep(), _loop)
def start_curator_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.add_job(
_sweep_threadsafe,
trigger=IntervalTrigger(minutes=CURATOR_INTERVAL_MIN),
id="curator_sweep",
replace_existing=True,
# Don't fire the very first tick immediately — let the app finish
# boot, then start the cadence. APScheduler computes next_run
# from now + interval by default.
)
_scheduler.start()
logger.info(
"Curator scheduler started (interval=%d min, max %d conversations per sweep)",
CURATOR_INTERVAL_MIN, _MAX_CONVERSATIONS_PER_SWEEP,
)
def stop_curator_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Curator scheduler stopped")
+2 -5
View File
@@ -109,11 +109,8 @@ def _asyncio_task_count() -> int | None:
def _curator_busy() -> bool | None:
try:
from fabledassistant.services.curator import is_curator_running
return is_curator_running()
except Exception:
return None
# Curator was removed in Phase 8 of the MCP-first pivot. Always False.
return False
def _uptime_secs() -> float | None:
@@ -57,16 +57,9 @@ async def _fire_reminders() -> None:
async with async_session() as session:
for event in to_notify:
try:
from fabledassistant.services.push import send_push_notification # noqa: PLC0415
start_local = event.start_dt.strftime("%H:%M")
await send_push_notification(
user_id=event.user_id,
title=f"Reminder: {event.title}",
body=f"Starting at {start_local} UTC",
)
except Exception:
logger.warning("Failed to send reminder push for event %d", event.id, exc_info=True)
# Push delivery removed alongside the chat subsystem in Phase 8.
# Event reminders are still flagged via in-app notifications
# (see services/notifications.py).
# Mark as sent regardless of push success to avoid re-firing
result = await session.execute(
@@ -98,44 +91,6 @@ def _run_caldav_sync_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_caldav_sync(), loop)
# ---------------------------------------------------------------------------
# Chat retention cleanup job
# ---------------------------------------------------------------------------
async def _run_chat_retention_cleanup() -> None:
"""Delete old conversations for all users according to their retention setting."""
from sqlalchemy import select as sa_select # noqa: PLC0415
from fabledassistant.models.user import User # noqa: PLC0415
from fabledassistant.services.chat import cleanup_old_conversations # noqa: PLC0415
from fabledassistant.services.settings import get_setting # noqa: PLC0415
async with async_session() as session:
result = await session.execute(sa_select(User.id))
user_ids = [row[0] for row in result.all()]
total_deleted = 0
for user_id in user_ids:
try:
retention_str = await get_setting(user_id, "chat_retention_days", "90")
try:
retention_days = int(retention_str)
except (ValueError, TypeError):
retention_days = 90
if retention_days > 0:
deleted = await cleanup_old_conversations(user_id, retention_days)
total_deleted += deleted
except Exception:
logger.warning("Chat retention cleanup failed for user %d", user_id, exc_info=True)
if total_deleted:
logger.info("Chat retention cleanup: deleted %d conversation(s)", total_deleted)
def _run_chat_retention_threadsafe(loop: asyncio.AbstractEventLoop) -> None:
asyncio.run_coroutine_threadsafe(_run_chat_retention_cleanup(), loop)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
@@ -165,17 +120,8 @@ def start_event_scheduler(loop: asyncio.AbstractEventLoop) -> None:
replace_existing=True,
)
# Chat retention cleanup once per day
_scheduler.add_job(
_run_chat_retention_threadsafe,
trigger=IntervalTrigger(hours=24),
args=[loop],
id="chat_retention_cleanup",
replace_existing=True,
)
_scheduler.start()
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h, chat cleanup every 24h)")
logger.info("Event scheduler started (reminders every 5m, CalDAV sync every 1h)")
def stop_event_scheduler() -> None:
@@ -1,138 +0,0 @@
"""In-memory generation event buffer for SSE fan-out.
Each active generation gets a GenerationBuffer keyed by conversation_id.
SSE clients tail the buffer and can reconnect at any point without data loss.
"""
import asyncio
import json
import logging
import time
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger(__name__)
class GenerationState(str, Enum):
RUNNING = "running"
COMPLETED = "completed"
ERRORED = "errored"
@dataclass
class SSEEvent:
index: int
event_type: str # "context", "chunk", "done", "error"
data: dict
@dataclass
class GenerationBuffer:
conversation_id: int
assistant_message_id: int
state: GenerationState = GenerationState.RUNNING
events: list[SSEEvent] = field(default_factory=list)
content_so_far: str = ""
finished_at: float | None = None
cancel_event: asyncio.Event = field(default_factory=asyncio.Event)
_notify: asyncio.Event = field(default_factory=asyncio.Event)
# Tool confirmation — set when a write tool is awaiting user approval
confirmation_future: asyncio.Future | None = None
pending_tool: dict | None = None
def append_event(self, event_type: str, data: dict) -> SSEEvent:
event = SSEEvent(index=len(self.events), event_type=event_type, data=data)
self.events.append(event)
# Wake all waiting SSE clients, then reset for next wait
old = self._notify
self._notify = asyncio.Event()
old.set()
return event
async def wait_for_event(self, after_index: int, timeout: float = 30.0) -> bool:
"""Wait until there are events beyond after_index. Returns True if new events exist."""
if after_index + 1 < len(self.events):
return True
try:
await asyncio.wait_for(self._notify.wait(), timeout=timeout)
return True
except asyncio.TimeoutError:
return False
def events_after(self, index: int) -> list[SSEEvent]:
"""Return events with index > given index (for replay on reconnect)."""
start = index + 1
if start >= len(self.events):
return []
return self.events[start:]
@staticmethod
def format_sse(event: SSEEvent) -> str:
return f"id: {event.index}\nevent: {event.event_type}\ndata: {json.dumps(event.data)}\n\n"
# Module-level singleton registry
_buffers: dict[int | str, GenerationBuffer] = {}
_cleanup_task: asyncio.Task | None = None
_GRACE_PERIOD = 60.0 # seconds to keep completed buffers
def create_buffer(conv_id: int, msg_id: int) -> GenerationBuffer:
existing = _buffers.get(conv_id)
if existing and existing.state == GenerationState.RUNNING:
raise RuntimeError(f"Generation already running for conversation {conv_id}")
buf = GenerationBuffer(conversation_id=conv_id, assistant_message_id=msg_id)
_buffers[conv_id] = buf
return buf
def get_buffer(conv_id: int) -> GenerationBuffer | None:
return _buffers.get(conv_id)
def remove_buffer(conv_id: int) -> None:
_buffers.pop(conv_id, None)
def create_assist_buffer(user_id: int) -> GenerationBuffer:
key = f"assist:{user_id}"
existing = _buffers.get(key)
if existing and existing.state == GenerationState.RUNNING:
# Orphan the old buffer — the background task holds a direct reference
# and will complete against it harmlessly. A new request always wins.
logger.warning("Assist generation still running for user %d; orphaning old buffer", user_id)
buf = GenerationBuffer(conversation_id=0, assistant_message_id=0)
_buffers[key] = buf
return buf
def get_assist_buffer(user_id: int) -> GenerationBuffer | None:
return _buffers.get(f"assist:{user_id}")
def remove_assist_buffer(user_id: int) -> None:
_buffers.pop(f"assist:{user_id}", None)
async def _cleanup_loop() -> None:
"""Remove completed/errored buffers after grace period."""
while True:
await asyncio.sleep(15)
now = time.monotonic()
to_remove = [
key for key, buf in _buffers.items()
if buf.state != GenerationState.RUNNING
and buf.finished_at is not None
and now - buf.finished_at > _GRACE_PERIOD
]
for key in to_remove:
_buffers.pop(key, None)
logger.debug("Cleaned up generation buffer for key %s", key)
def start_cleanup_loop() -> None:
global _cleanup_task
if _cleanup_task is None or _cleanup_task.done():
_cleanup_task = asyncio.create_task(_cleanup_loop())
@@ -1,111 +0,0 @@
"""Per-turn tool-call telemetry for assistant generations.
Captures the empirical surface for evaluating model swaps:
- Which tools were available to the model on this turn?
- Which did it attempt to call?
- Which succeeded?
- Which failed, and with what error?
One row per assistant turn. Designed to answer questions like
"does mistral-small actually fire record_moment when it should?"
without relying on anecdote across model changes.
"""
from __future__ import annotations
import logging
from typing import Iterable
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.generation_tool_log import GenerationToolLog
logger = logging.getLogger(__name__)
async def log_tool_outcomes(
*,
user_id: int,
conv_id: int,
assistant_message_id: int | None,
model: str,
think_enabled: bool,
tools_available: Iterable[str],
tool_calls: Iterable[dict],
) -> None:
"""Persist one row capturing this turn's tool-call outcomes.
`tool_calls` is the assembled `all_tool_calls` list from
`generation_task.run_generation`. Each entry has the shape:
{"function": <name>, "arguments": ..., "result": ..., "status": ...}
where `status` is "success" or "error" and `result` may contain an
`error` field when status is "error".
Fire-and-forget from the caller's perspective — failure here must not
affect the user-facing generation flow, so exceptions are caught and
logged rather than propagated.
"""
try:
available = sorted({str(t) for t in tools_available if t})
attempted: list[str] = []
succeeded: list[str] = []
failed: list[dict] = []
for call in tool_calls:
name = str(call.get("function") or call.get("name") or "").strip()
if not name:
continue
attempted.append(name)
status = call.get("status")
result = call.get("result") or {}
err = result.get("error") if isinstance(result, dict) else None
is_error = (status == "error") or bool(err) or (
isinstance(result, dict) and result.get("success") is False
)
if is_error:
failed.append({"name": name, "error": str(err or "unspecified")[:500]})
else:
succeeded.append(name)
async with async_session() as session:
session.add(
GenerationToolLog(
user_id=user_id,
conv_id=conv_id,
assistant_message_id=assistant_message_id,
model=model,
think_enabled=think_enabled,
tools_available=available,
tools_attempted=attempted,
tools_succeeded=succeeded,
tools_failed=failed,
)
)
await session.commit()
except Exception:
logger.warning(
"Failed to persist generation_tool_log for conv %d (msg=%s, model=%s)",
conv_id,
assistant_message_id,
model,
exc_info=True,
)
async def recent_logs(
*,
user_id: int,
limit: int = 50,
model: str | None = None,
) -> list[dict]:
"""Return recent generation_tool_log rows for the user, newest first.
Optionally filter to a specific model. Returns plain dicts so callers
don't need an open session to read fields.
"""
async with async_session() as session:
stmt = select(GenerationToolLog).where(GenerationToolLog.user_id == user_id)
if model:
stmt = stmt.where(GenerationToolLog.model == model)
stmt = stmt.order_by(GenerationToolLog.created_at.desc()).limit(limit)
result = await session.execute(stmt)
return [row.to_dict() for row in result.scalars().all()]
@@ -1,653 +0,0 @@
"""Background asyncio task for LLM generation.
Streams from Ollama into a GenerationBuffer, periodically flushing to DB.
Runs independently of any HTTP connection.
"""
import asyncio
import json
import logging
import re
import time
from collections.abc import AsyncGenerator
import httpx
from sqlalchemy import update
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Message
from fabledassistant.services.generation_buffer import (
GenerationBuffer,
GenerationState,
)
from fabledassistant.services.llm import ChatChunk, build_context, generate_completion, pick_num_ctx, stream_chat, stream_chat_with_tools, summarize_history_for_context
from fabledassistant.services.chat import update_conversation_title
from fabledassistant.services.settings import get_setting
from fabledassistant.services.logging import log_generation
from fabledassistant.services.tools import get_tools_for_user, execute_tool
from fabledassistant.services.research import run_research_pipeline
logger = logging.getLogger(__name__)
# Mistral prefixes tool-call responses with "[TOOL_CALLS]" as visible text
_TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
# Human-readable labels for each tool, shown in the status indicator
_TOOL_LABELS: dict[str, str] = {
"create_note": "Creating note/task",
"update_note": "Updating note/task",
"delete_note": "Deleting note/task",
"read_note": "Reading note",
"list_notes": "Listing notes",
"list_tasks": "Searching tasks",
"search_notes": "Searching notes (semantic)",
"create_event": "Creating calendar event",
"list_events": "Searching calendar",
"search_events": "Searching calendar",
"update_event": "Updating calendar event",
"delete_event": "Removing calendar event",
"list_calendars": "Listing calendars",
"lookup": "Looking up information",
"research_topic": "Researching topic",
}
async def _generate_title(messages: list[dict], user_id: int) -> str:
"""Ask the LLM for a concise conversation title.
Only uses user messages to avoid feeding tool-call JSON, system prompt
fragments, or other noise into the title generator. Caps input length
to keep the task fast and focused.
"""
user_texts = []
for m in messages:
if m["role"] == "user":
content = (m.get("content") or "").strip()
if content:
user_texts.append(content[:300])
if not user_texts:
return ""
# First + last user messages capture intent best
if len(user_texts) > 2:
user_texts = [user_texts[0], user_texts[-1]]
prompt_messages = [
{
"role": "user",
"content": (
"Generate a concise 3-8 word title for a conversation that started with:\n\n"
+ "\n\n".join(user_texts)
+ "\n\nReply with ONLY the title. No quotes, no punctuation, no explanation."
),
},
]
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
title = await generate_completion(prompt_messages, bg_model, max_tokens=30, num_ctx=1024)
# Strip common LLM noise: quotes, thinking tags, role labels
title = title.strip().strip('"\'').strip()
for prefix in ("Title:", "title:", "Assistant:", "User:"):
if title.startswith(prefix):
title = title[len(prefix):].strip()
# Drop anything after a newline (model sometimes adds explanation)
if "\n" in title:
title = title.split("\n")[0].strip()
return title[:80] if title else ""
async def _update_message(
message_id: int,
content: str,
status: str,
tool_calls: list[dict] | None = None,
) -> None:
values: dict = {"content": content, "status": status}
if tool_calls is not None:
values["tool_calls"] = tool_calls
async with async_session() as session:
await session.execute(
update(Message)
.where(Message.id == message_id)
.values(**values)
)
await session.commit()
async def _stream_with_retry(
messages: list[dict],
model: str,
tools: list[dict],
think: bool,
num_ctx: int | None = None,
) -> AsyncGenerator[ChatChunk, None]:
"""stream_chat_with_tools with automatic retry on Ollama 500 errors.
500s occur when Ollama is still loading a model or handling a concurrent
request (e.g. tag suggestions racing with round 1). Retries up to 2 times
with a short delay — by which point the model is warm and other calls done.
"""
last_exc: BaseException | None = None
for attempt in range(3):
if attempt > 0:
delay = 3.0 * attempt
logger.warning(
"Ollama stream 500 (attempt %d/3), retrying in %.0fs", attempt, delay
)
await asyncio.sleep(delay)
try:
async for chunk in stream_chat_with_tools(messages, model, tools=tools, think=think, num_ctx=num_ctx):
yield chunk
return
except httpx.HTTPStatusError as exc:
last_exc = exc
if exc.response.status_code != 500:
break # non-500 is not retryable
except BaseException as exc:
last_exc = exc
break
if last_exc is not None:
raise last_exc
async def run_generation(
buf: GenerationBuffer,
history: list[dict],
model: str,
user_id: int,
conv_id: int,
conv_title: str,
user_content: str,
context_note_id: int | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
think: bool = False,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
voice_mode: bool = False,
) -> None:
"""Stream LLM response into buffer with periodic DB flushes."""
MAX_TOOL_ROUNDS = 6
msg_id = buf.assistant_message_id
buf.append_event("status", {"status": "Building context..."})
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
#
# Journal conversations get NO tools (2026-05-22 architecture pivot):
# the chat model talks, a separate background curator does tool calls
# asynchronously. See services/curator.py. Removing tools here is the
# mechanical change that makes the architecture real — the chat model
# can no longer fire record_moment / create_task / etc. and therefore
# can no longer lie about firing them.
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_conv = await _sess.get(_Conversation, conv_id)
_conversation_type = (
_conv.conversation_type if _conv and _conv.conversation_type else "chat"
)
if _conversation_type == "journal":
tools = []
logger.info(
"Conv %d is journal: passing tools=[] to chat model "
"(curator handles tool calls async)",
conv_id,
)
else:
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
logger.info(
"Starting generation for conv %d: model=%s, tools=%d",
conv_id, model, len(tools),
)
# Phase 2: Summarize long conversation history if needed.
history_to_use = history
history_summary: str | None = None
if len(history) > 30: # matches _HISTORY_SUMMARY_THRESHOLD in llm.py
buf.append_event("status", {"status": "Summarizing conversation history..."})
history_to_use, history_summary = await summarize_history_for_context(history, model)
# Phase 3: Build context.
# Note: Ollama lazy-loads models on the first /api/chat request, so polling
# /api/ps for model readiness only causes delay. We proceed immediately and
# let Ollama handle loading on demand.
# Fetch voice_speech_style from user settings when voice_mode is active.
voice_speech_style = "conversational"
if voice_mode:
from fabledassistant.services.settings import get_setting
voice_speech_style = await get_setting(user_id, "voice_speech_style", "conversational")
messages, context_meta = await build_context(
user_id, history_to_use, context_note_id, user_content,
history_summary=history_summary,
include_note_ids=include_note_ids,
excluded_note_ids=excluded_note_ids,
rag_project_id=rag_project_id,
workspace_project_id=workspace_project_id,
user_timezone=user_timezone,
conv_id=conv_id,
voice_mode=voice_mode,
voice_speech_style=voice_speech_style,
)
# Pick the smallest context tier that fits the current messages AND the
# tool schemas (which can be 6-10K tokens on their own with ~40 tools).
# Using the minimum needed tier reduces KV cache size and speeds up prefill.
num_ctx = pick_num_ctx(messages, tools=tools)
logger.debug("Adaptive num_ctx=%d for conv %d", num_ctx, conv_id)
# Emit context event
buf.append_event("context", {"context": context_meta})
# Think mode is hardcoded off (2026-05-23). Historical context:
# originally forced on for qwen3's combined think+tools template;
# then made user-configurable when we decoupled the architecture;
# now removed entirely because in the chat+curator world there's no
# reason for the chat model to think. Chat has tools=[] — it just
# talks, and think on a no-tools conversational pass is pure
# latency cost (the May 2026 bench measured 1-2 min/turn for
# unclear quality benefit). The curator (services/curator.py) also
# already hardcodes think=False for its own reasons.
think_requested = think
think = False
t_start = time.monotonic()
timing: dict = {
"think_requested": think_requested,
"think": think,
"num_ctx": num_ctx,
"tools": [],
"rounds": 0,
"prompt_tokens": None,
"output_tokens": None,
"first_token_ms": None,
"thinking_ms": None,
"ttft_ms": None,
"generation_ms": None,
"total_ms": None,
}
last_flush = time.monotonic()
all_tool_calls: list[dict] = []
new_rag_scope: object = False # sentinel; set to int|None when scope changes
new_rag_scope_label: str | None = None
try:
cancelled = False
research_completed = False
for _round in range(MAX_TOOL_ROUNDS):
timing["rounds"] = _round + 1
round_tool_calls: list[dict] = []
logger.info("Generation round %d started for conv %d (model=%s)", _round, conv_id, model)
if cancelled:
break
buf.append_event("status", {"status": "Generating response..." if _round == 0 else "Composing response..."})
t_stream = time.monotonic()
approx_msg_chars = sum(len(str(m.get("content", ""))) for m in messages)
round_content_start = len(buf.content_so_far)
round_output_tokens_start = timing.get("output_tokens") or 0
round_prompt_tokens_start = timing.get("prompt_tokens") or 0
logger.info(
"CTX_DIAG round_start conv=%d round=%d num_ctx=%d msgs=%d approx_chars=%d think=%s",
conv_id, _round, num_ctx, len(messages), approx_msg_chars, think,
)
async for chunk in _stream_with_retry(messages, model, tools, think, num_ctx=num_ctx):
if buf.cancel_event.is_set():
cancelled = True
break
if chunk.type == "thinking":
if timing["first_token_ms"] is None:
timing["first_token_ms"] = int((time.monotonic() - t_start) * 1000)
buf.append_event("thinking_chunk", {"chunk": chunk.content})
elif chunk.type == "content":
if timing["ttft_ms"] is None:
now_ms = int((time.monotonic() - t_start) * 1000)
timing["ttft_ms"] = now_ms
if timing["first_token_ms"] is None:
# No thinking phase occurred — first token IS content.
timing["first_token_ms"] = now_ms
else:
# Thinking phase duration = gap between first thinking
# token and first content token.
timing["thinking_ms"] = now_ms - timing["first_token_ms"]
buf.content_so_far += chunk.content
clean = _TOOL_CALL_MARKER.sub("", chunk.content)
if clean:
buf.append_event("chunk", {"chunk": clean})
now = time.monotonic()
if now - last_flush >= DB_FLUSH_INTERVAL:
try:
await _update_message(msg_id, buf.content_so_far, "generating")
except Exception:
logger.warning("Failed periodic flush for message %d", msg_id, exc_info=True)
last_flush = now
elif chunk.type == "done":
if chunk.prompt_tokens is not None:
timing["prompt_tokens"] = (timing["prompt_tokens"] or 0) + chunk.prompt_tokens
if chunk.output_tokens is not None:
timing["output_tokens"] = (timing["output_tokens"] or 0) + chunk.output_tokens
elif chunk.type == "tool_calls" and chunk.tool_calls:
logger.info("Round %d: model returned %d tool call(s)", _round, len(chunk.tool_calls))
for tc in chunk.tool_calls:
fn = tc.get("function", {})
tool_name = fn.get("name", "")
arguments = fn.get("arguments", {})
logger.info("Executing tool: %s(%s)", tool_name, json.dumps(arguments)[:200])
buf.append_event("status", {"status": f"{_TOOL_LABELS.get(tool_name, 'Working')}..."})
t_tool = time.monotonic()
if tool_name == "research_topic":
topic = arguments.get("topic", "")
try:
note = await run_research_pipeline(topic, user_id, model, buf, project_id=workspace_project_id)
result = {
"success": True,
"type": "research_note",
"data": {"id": note.id, "title": note.title},
}
done_text = (
f"\n\n---\n\nResearch complete! I've compiled a note: "
f"**[{note.title}](/notes/{note.id})**."
)
buf.append_event("chunk", {"chunk": done_text})
buf.content_so_far += done_text
except Exception as e:
logger.exception("Research pipeline failed for topic: %s", topic)
err_msg = str(e) or f"{type(e).__name__}: unexpected error"
result = {"success": False, "error": err_msg}
err_text = f"\nResearch failed: {err_msg}"
buf.append_event("chunk", {"chunk": err_text})
buf.content_so_far += err_text
research_completed = True
else:
result = await execute_tool(
user_id, tool_name, arguments,
conv_id=conv_id,
workspace_project_id=workspace_project_id,
)
# Capture RAG scope change for SSE done event
if result.get("type") == "rag_scope_set" and result.get("success"):
new_rag_scope = arguments.get("project_id")
new_rag_scope_label = result.get("scope_label")
timing["tools"].append({"name": tool_name, "ms": int((time.monotonic() - t_tool) * 1000)})
logger.info("Tool %s result: success=%s", tool_name, result.get("success"))
tool_record = {
"function": tool_name,
"arguments": arguments,
"result": result,
"status": "success" if result.get("success") else "error",
}
round_tool_calls.append(tool_record)
all_tool_calls.append(tool_record)
buf.append_event("tool_call", {"tool_call": tool_record})
round_content_added = len(buf.content_so_far) - round_content_start
round_output_tokens_added = (timing.get("output_tokens") or 0) - round_output_tokens_start
round_prompt_tokens = (timing.get("prompt_tokens") or 0) - round_prompt_tokens_start
headroom = num_ctx - round_prompt_tokens if round_prompt_tokens else None
is_silent = (
not round_tool_calls
and round_content_added == 0
and round_output_tokens_added > 0
)
logger.info(
"CTX_DIAG round_end conv=%d round=%d think=%s prompt_tokens=%d output_tokens=%d headroom=%s content_added=%d tool_calls=%d silent=%s",
conv_id, _round, think, round_prompt_tokens, round_output_tokens_added,
headroom, round_content_added, len(round_tool_calls), is_silent,
)
timing["generation_ms"] = int((time.monotonic() - t_stream) * 1000)
if cancelled:
logger.info("Generation cancelled for conv %d", conv_id)
break
if research_completed:
logger.info("Research complete for conv %d, ending generation", conv_id)
break
if not round_tool_calls:
logger.info("Round %d: no tool calls, final content length=%d", _round, len(buf.content_so_far))
break
logger.info("Round %d: %d tool call(s) executed, starting next round", _round, len(round_tool_calls))
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
messages.append({
"role": "assistant",
"content": buf.content_so_far,
"tool_calls": [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in round_tool_calls
],
})
for tc in round_tool_calls:
messages.append({"role": "tool", "content": json.dumps(tc["result"])})
buf.content_so_far = ""
# Strip model artifacts from final content
buf.content_so_far = _TOOL_CALL_MARKER.sub("", buf.content_so_far)
# Silent-generation safety net: the model burned output tokens but
# nothing landed in content or tool_calls (seen with qwen3:14b when
# its tool-call emission doesn't parse). Show a visible fallback so
# the user isn't staring at an empty bubble.
if (
not cancelled
and not buf.content_so_far.strip()
and not all_tool_calls
and (timing.get("output_tokens") or 0) > 0
):
logger.warning(
"Silent generation for conv %d: output_tokens=%s but empty content "
"and no tool calls (model=%s)",
conv_id, timing.get("output_tokens"), model,
)
fallback = (
"I wasn't able to produce a usable response — the model generated "
"tokens that couldn't be parsed as content or a tool call. "
"Please try rephrasing, or try again."
)
buf.content_so_far = fallback
buf.append_event("chunk", {"chunk": fallback})
# Final save
logger.info("Generation complete for conv %d: content_length=%d, tool_calls=%d",
conv_id, len(buf.content_so_far), len(all_tool_calls))
await _update_message(
msg_id,
buf.content_so_far,
"complete",
tool_calls=all_tool_calls if all_tool_calls else None,
)
timing["total_ms"] = int((time.monotonic() - t_start) * 1000)
logger.info(
"Generation timing for conv %d: total=%dms think=%s(req=%s) first_token=%s "
"thinking=%s ttft=%s generation=%s tools=%s",
conv_id, timing["total_ms"],
timing["think"], timing["think_requested"],
timing["first_token_ms"], timing["thinking_ms"], timing["ttft_ms"],
timing["generation_ms"],
[(t["name"], t["ms"]) for t in timing["tools"]],
)
try:
await log_generation(user_id, conv_id, model, timing)
except Exception:
logger.warning("Failed to persist generation timing for conv %d", conv_id, exc_info=True)
# Per-turn tool-call telemetry. Empirical surface for evaluating
# model swaps without needing user reports — answers "did model X
# actually fire record_moment when it should have?" The helper is
# internally try/except so this never affects the user-facing flow.
from fabledassistant.services.generation_log import log_tool_outcomes
await log_tool_outcomes(
user_id=user_id,
conv_id=conv_id,
assistant_message_id=msg_id,
model=model,
think_enabled=think,
tools_available=[
(t.get("function") or {}).get("name") for t in tools
],
tool_calls=all_tool_calls,
)
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
done_payload: dict = {"done": True, "message_id": msg_id, "timing": timing}
if new_rag_scope is not False:
done_payload["new_rag_scope"] = new_rag_scope
done_payload["new_rag_scope_label"] = new_rag_scope_label
buf.append_event("done", done_payload)
# Fire push notification when complete (non-critical, fire-and-forget)
try:
from fabledassistant.services.push import send_push_notification, vapid_enabled
if vapid_enabled():
text = buf.content_so_far.strip()
if text:
preview = text[:120].rstrip()
if len(text) > 120:
preview += ""
else:
# Tool-only response — summarise what was done
tool_names = [tc.get("function") for tc in all_tool_calls if tc.get("function")]
if tool_names:
preview = f"Completed: {', '.join(tool_names[:3])}"
else:
preview = "Action completed"
asyncio.create_task(send_push_notification(
user_id,
title="Response ready",
body=preview,
url=f"/chat/{conv_id}",
))
except Exception:
logger.warning("Failed to schedule push notification", exc_info=True)
# Title generation is non-critical — fire-and-forget so done fires immediately
non_system = [m for m in messages if m["role"] != "system"]
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
if should_gen_title:
# Feed the title model the *raw* conversation turns only — never
# the post-build_context ``messages`` list. ``build_context``
# prepends RAG snippets and URL content INTO the user message
# string itself, so filtering by role="user" downstream still
# surfaces that noise as the "user's message". That pollution
# caused wildly-wrong titles (bug #109) — the small background
# model was staring at article excerpts instead of what the user
# actually typed. Pass the original history + the raw user_content
# + the assistant reply.
title_messages: list[dict] = [
{"role": m["role"], "content": m.get("content") or ""}
for m in history
if m.get("role") in ("user", "assistant")
]
title_messages.append({"role": "user", "content": user_content})
title_messages.append({"role": "assistant", "content": buf.content_so_far})
async def _bg_title() -> None:
try:
title = await _generate_title(title_messages, user_id)
if title:
await update_conversation_title(user_id, conv_id, title)
except Exception:
logger.warning("Failed to generate title for conversation %d", conv_id, exc_info=True)
if not conv_title:
fallback = user_content[:80]
if len(user_content) > 80:
fallback += "..."
await update_conversation_title(user_id, conv_id, fallback)
asyncio.create_task(_bg_title())
except Exception as e:
logger.exception("Error in generation task for conversation %d", conv_id)
# Save partial content with error status
try:
await _update_message(msg_id, buf.content_so_far, "error")
except Exception:
logger.warning("Failed to save error state for message %d", msg_id, exc_info=True)
buf.state = GenerationState.ERRORED
buf.finished_at = time.monotonic()
buf.append_event("error", {"error": str(e)})
async def run_assist_generation(
buf: GenerationBuffer,
messages: list[dict],
model: str,
) -> None:
"""Stream LLM response for assist into buffer. No DB persistence.
Retries up to 3 times on Ollama 500 errors (model still loading).
On each retry the accumulated content is reset so the done event
always reflects only the successful generation.
"""
from fabledassistant.services.llm import pick_num_ctx
input_chars = sum(len(m.get("content", "")) for m in messages)
num_ctx = pick_num_ctx(messages)
logger.info("Assist generation started: model=%s, input_chars=%d, num_ctx=%d", model, input_chars, num_ctx)
last_exc: BaseException | None = None
for attempt in range(3):
if attempt > 0:
delay = 3.0 * attempt
logger.warning(
"Ollama assist stream 500 (attempt %d/3), retrying in %.0fs", attempt, delay
)
await asyncio.sleep(delay)
try:
buf.content_so_far = ""
async for chunk in stream_chat(messages, model, options={"num_predict": num_ctx}, num_ctx=num_ctx):
buf.content_so_far += chunk
buf.append_event("chunk", {"chunk": chunk})
output_chars = len(buf.content_so_far)
logger.info(
"Assist generation complete: output_chars=%d, events=%d",
output_chars, len(buf.events),
)
buf.state = GenerationState.COMPLETED
buf.finished_at = time.monotonic()
buf.append_event("done", {"done": True, "full_text": buf.content_so_far})
logger.info("Assist done event appended (event index %d)", len(buf.events) - 1)
return
except httpx.HTTPStatusError as exc:
last_exc = exc
if exc.response.status_code != 500:
break
except Exception as exc:
last_exc = exc
break
logger.exception("Error in assist generation task")
buf.state = GenerationState.ERRORED
buf.finished_at = time.monotonic()
buf.append_event("error", {"error": str(last_exc)})
@@ -1,133 +0,0 @@
"""Journal closeout — nightly extraction of profile observations.
Runs once per user per day at day_rollover_hour. Reads yesterday's /journal
conversation, filters out assistant-authored auto-content (daily prep),
asks the background LLM to extract user-side patterns/habits, and appends
the bullets to user_profiles.observations_raw via append_observations.
"""
from __future__ import annotations
import datetime
import logging
from sqlalchemy import or_, select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.settings import get_setting
from fabledassistant.services.user_profile import append_observations
logger = logging.getLogger(__name__)
# Message kinds whose content must NEVER be sent to the closeout LLM.
# These are assistant-authored auto-blocks that would otherwise dominate
# attention and leak back into "what the assistant has learned."
EXCLUDED_KINDS: set[str] = {"daily_prep"}
def _filter_messages(messages):
"""Drop messages whose msg_metadata.kind is in EXCLUDED_KINDS.
Accepts any iterable of message-like objects with `role`, `content`,
and `msg_metadata` attributes (real Message rows or SimpleNamespace).
"""
kept = []
for m in messages:
meta = getattr(m, "msg_metadata", None) or {}
if meta.get("kind") in EXCLUDED_KINDS:
continue
kept.append(m)
return kept
_TRANSCRIPT_WINDOW = 20
_CONTENT_CAP = 500
def _build_transcript(messages) -> str:
"""Format the last 20 messages as `ROLE: content[:500]` lines."""
tail = list(messages)[-_TRANSCRIPT_WINDOW:]
return "\n".join(
f"{m.role.upper()}: {m.content[:_CONTENT_CAP]}" for m in tail
)
SYSTEM_PROMPT = (
"You are reviewing a day's journal conversation to extract preference "
"observations the USER revealed about themselves.\n\n"
"Rules:\n"
"- Only extract patterns, habits, recurring frustrations, or contextual "
"facts the user said or demonstrated.\n"
"- DO NOT restate facts that belong in structured fields: name, job title, "
"industry, expertise level, response style, tone, interests. Those are "
"handled separately.\n"
"- DO NOT extract anything from the ASSISTANT turns about the user — only "
"what the user themselves stated or demonstrated by their choices.\n"
"- Write 2-5 short bullet points. Be specific and factual.\n"
"- If nothing notable, output only: (nothing to note)"
)
async def run_for_user(user_id: int, yesterday: datetime.date) -> None:
"""Extract preference observations from yesterday's journal conversation.
Skips silently when there is nothing meaningful to extract.
"""
async with async_session() as session:
conv_result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == yesterday,
)
)
conv = conv_result.scalar_one_or_none()
if conv is None:
logger.debug("closeout: no journal conv for user %d on %s", user_id, yesterday)
return
msg_result = await session.execute(
select(Message)
.where(
Message.conversation_id == conv.id,
Message.role.in_(("user", "assistant")),
or_(
Message.msg_metadata.is_(None),
~Message.msg_metadata["kind"].astext.in_(EXCLUDED_KINDS),
),
)
.order_by(Message.created_at)
)
messages = list(msg_result.scalars().all())
# Defensive second-pass filter (covers any message with metadata the
# SQL JSON path can't reach, e.g. older rows where kind nesting differs).
messages = _filter_messages(messages)
if len(messages) < 2:
logger.debug("closeout: not enough messages for user %d (%d)", user_id, len(messages))
return
transcript = _build_transcript(messages)
model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
try:
output = (await generate_completion(
[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": transcript},
],
model,
)).strip()
except Exception:
logger.warning("closeout LLM failed for user %d", user_id, exc_info=True)
return
if not output or "(nothing to note)" in output.lower():
logger.debug("closeout: nothing to note for user %d", user_id)
return
await append_observations(user_id, output)
logger.info("closeout: appended observations for user %d (%s)", user_id, yesterday)
@@ -1,193 +0,0 @@
"""System prompt + ambient context injection for journal conversations.
Distinct from the chat pipeline (services/llm.py) in three ways:
1. New persona: warm, curious listener; not a task manager.
2. Calibration rules: ask before structural changes; record_moment freely.
3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG).
Notes-RAG auto-injection MUST be disabled for journal sessions — the
journal's ambient context replaces it. Failing to disable would let notes
leak into journal sessions, violating the design's isolation invariant.
"""
from __future__ import annotations
import datetime
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = (
"You are the user's journal companion. They've opened their journal — a "
"day-anchored conversation surface where they record their day. You are "
"here to listen. Talk with them, ask one short follow-up when natural, "
"and let them lead. You do NOT need to record anything; a separate "
"process (the curator) reads the conversation periodically and captures "
"structured records on its own. Trust that — focus only on being a "
"thoughtful presence in the conversation. The day's prep message at the "
"top of the conversation is your context — build on it, don't restate it."
)
# Chat-only calibration. The conversation+curator architecture (Fable #172,
# May 2026) split tool-calling out of the chat surface: this prompt is sent
# to a model with `tools=[]` on the journal route. Older versions of this
# prompt instructed the model to CALL record_moment / search_notes / etc.
# — with no tools available that produced empty responses and silent-
# generation failures. The curator (services/curator.py) handles all
# tool work asynchronously now; the chat just talks.
JOURNAL_CALIBRATION = """\
HOW TO TALK IN THE JOURNAL:
- ONE question per reply, MAXIMUM. If you find yourself writing a second
question mark, delete it. Ask the single follow-up that most opens up
what the user just said — or, just as good, ask nothing and only
acknowledge. Three questions ("how was the food? what'd you order?
did your daughter enjoy it?") feels like an interview, not a journal
companion. ONE question. Often ZERO questions.
- Match the user's length. Short message → short reply. Don't pad.
- Don't apologize for the user's feelings ("I'm sorry you're feeling…").
Engage with what they said directly.
- Don't produce multi-option menus ("1. Show your calendar 2. ..."). They
read as help-desk-bot. Ask one specific follow-up or simply acknowledge.
- Don't repeat a prior reply verbatim. If the user circles back on a theme,
pick a specific concrete detail from the new message to react to.
- Don't offer to do things, don't suggest the user share more for you to
react to (NO "do you have any pictures you can share?", NO "tell me
more about X", NO "what did Y look like?"). Their reply lives in their
head, not in something they need to fetch and paste for you. React to
what they actually said; don't fish for what they didn't.
- Don't offer troubleshooting steps, checklists, or generic process advice
for the user's work unless they explicitly ask. When the user is logging
what they're doing, they want to be heard, not coached. "I'm prepping
for an ISP migration" should be acknowledged — not met with
"Are you handling the network configuration yourself? Are there checks
you need to do first?" If a follow-up would presume they want help, drop it.
- Never claim to have done anything for the user (no "I've recorded that",
"I've added that to your tasks", "I'll note that down"). You have no
tools and cannot act on their data. The curator handles capture
separately and silently. If the user asks you to record or save
something, just acknowledge their intent in plain language — don't
claim to have done it.
- No emojis. The journal is a thinking-companion surface; emojis read as
chat-bot warmth that's out of register.
"""
PHASE_GREETINGS = {
"morning": "Morning. What's the day looking like for you?",
"midday": "How's it going so far?",
"evening": "Wrapping up — how'd the day shake out?",
}
def determine_phase(
*,
now_local: datetime.datetime,
day_rollover_hour: int = 4,
morning_end_hour: int = 12,
midday_end_hour: int = 18,
) -> str:
"""Return 'morning' | 'midday' | 'evening' for a local datetime."""
h = now_local.hour
if h < day_rollover_hour:
return "evening"
if h < morning_end_hour:
return "morning"
if h < midday_end_hour:
return "midday"
return "evening"
def phase_greeting(phase: str) -> str:
return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"])
async def build_journal_system_prompt(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
conv_id: int | None = None,
) -> str:
"""Static-then-dynamic system prompt.
Static prefix (persona + calibration) is identical on every request,
preserving Ollama KV cache. Dynamic suffix changes per-day, plus a
per-conversation curator_summary if one is available (Phase 3
feedback loop, Fable #172).
"""
static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}"
today_iso = day_date.isoformat()
# Include the day-of-week explicitly. LLMs are unreliable at deriving
# weekday names from ISO dates, which causes "this Friday" / "next
# Monday" to land on the wrong calendar day.
weekday = day_date.strftime("%A")
tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})."
profile_context = await build_profile_context(user_id)
profile_section = f"\n\n{profile_context}" if profile_context else ""
ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date)
ambient_section = (
f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else ""
)
# Curator feedback (Phase 3): a one-line summary of what the curator
# extracted from this conversation on its most recent pass. Lets the
# chat model stay aware of topics it can no longer surface via tool
# calls (chat is tools=[]).
curator_section = ""
if conv_id is not None:
curator_section = await _curator_summary_block(conv_id)
return (
f"{static_block}\n\n{tz_block}"
f"{profile_section}{ambient_section}{curator_section}"
)
async def _curator_summary_block(conv_id: int) -> str:
"""Render the conversation's stored curator_summary as a system block.
Empty when no curator pass has produced a summary yet. Importantly,
the block is positioned AFTER ambient context (and the static block)
so the chat model treats it as up-to-date awareness, not background.
"""
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation
async with async_session() as session:
result = await session.execute(
select(Conversation.curator_summary).where(Conversation.id == conv_id)
)
summary = result.scalar_one_or_none()
if not summary:
return ""
return f"\n\nCURATOR NOTES (recent captures from this conversation): {summary.strip()}"
async def _ambient_moments_block(
*, user_id: int, day_date: datetime.date
) -> str:
"""Render last 48h of moments as a compact text block.
Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval.
"""
moments = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=2),
date_to=day_date,
limit=20,
)
if not moments:
return ""
lines: list[str] = []
total_chars = 0
for m in moments:
line = f"- [{m['occurred_at']}] {m['content']}"
if total_chars + len(line) > 1500:
break
lines.append(line)
total_chars += len(line)
return "\n".join(lines)
@@ -1,615 +0,0 @@
"""Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a
new day). Two phases:
1. Gather structured data (tasks/events/weather/projects/recent moments/
open threads) — deterministic, no LLM call.
2. Hand the structured data to the LLM and ask it for a direct, informative
conversational opener — flowing prose, briefing-style. Result is persisted
as the first *assistant* message in today's journal Conversation, so it
renders with the standard Illuminated Transcript bubble styling alongside
the rest of the conversation.
The structured data is preserved on ``Message.msg_metadata.sections`` for
provenance and future tooling.
Message shape:
role: 'assistant'
content: <prose opener>
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
import datetime
import logging
import re
from zoneinfo import ZoneInfo
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services.events import list_events
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects
from fabledassistant.services.settings import get_setting
from fabledassistant.services.weather import get_cached_weather_rows
logger = logging.getLogger(__name__)
# How many days out from today an event needs to be before the prep treats
# it as too far-future to surface. Catches recurring-event canonical rows
# whose RRULE expansion missed (an `rrulestr` failure falls back to the
# canonical event in `list_events`, which leaks far-future occurrences
# into today's prep).
_EVENT_PROXIMITY_DAYS = 7
def _task_to_prep_dict(task, today: datetime.date) -> dict:
"""Render a Note row as a prep-payload task entry, tagging overdue
staleness when relevant so the prompt can frame it correctly."""
d = {
"id": task.id,
"title": task.title,
"status": task.status,
"priority": task.priority,
"due_date": task.due_date.isoformat() if task.due_date else None,
}
if task.due_date and task.due_date < today:
d["days_overdue"] = (today - task.due_date).days
return d
def _filter_proximate_events(
events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo
) -> list[dict]:
"""Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS``
away from ``day_date`` in the user's local timezone.
Belt-and-suspenders against `list_events` returning a canonical
far-future event (e.g. when RRULE expansion fails and the loop falls
back to the original event row, regardless of date). The user
observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every
daily prep 5 months out; this filter keeps the prep proximate.
"""
proximate: list[dict] = []
for e in events:
raw = e.get("start_dt") or ""
try:
start_dt = datetime.datetime.fromisoformat(
raw.replace("Z", "+00:00") if isinstance(raw, str) else ""
)
local_date = start_dt.astimezone(user_tz).date()
delta = abs((local_date - day_date).days)
except (ValueError, TypeError, AttributeError):
# Unparseable date — keep the event rather than suppress real data.
proximate.append(e)
continue
if delta <= _EVENT_PROXIMITY_DAYS:
proximate.append(e)
else:
logger.info(
"daily_prep: dropping non-proximate event %r start_local=%s "
"(%d days from day %s)",
e.get("title"), local_date.isoformat(), delta, day_date.isoformat(),
)
return proximate
async def gather_daily_sections(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> dict:
"""Gather all daily-prep sections and return them as a dict.
Pure data fetching — no LLM call. Each section degrades to an empty
list/dict on failure so the caller always gets a complete shape.
Tasks are returned in three explicit buckets so the prompt can frame
overdue items correctly (instead of calling them "due today" — the
pre-2026-04-29 behavior, before this rewrite).
"""
sections: dict = {}
next_day = day_date + datetime.timedelta(days=1)
upcoming_end = day_date + datetime.timedelta(days=8)
try:
due_today_rows, _ = await list_notes(
user_id=user_id, is_task=True, status=["todo", "in_progress"],
due_after=day_date, due_before=next_day,
limit=20, sort="due_date", order="asc",
)
upcoming_rows, _ = await list_notes(
user_id=user_id, is_task=True, status=["todo", "in_progress"],
due_after=next_day, due_before=upcoming_end,
limit=20, sort="due_date", order="asc",
)
overdue_rows, _ = await list_notes(
user_id=user_id, is_task=True, status=["todo", "in_progress"],
due_before=day_date,
limit=20, sort="due_date", order="asc",
)
sections["tasks_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows]
sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows]
sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows]
# Backwards-compat alias for any consumers still reading sections["tasks"].
# The combined view is more useful than the prior overdue-only behavior.
sections["tasks"] = (
sections["tasks_due_today"]
+ sections["tasks_upcoming"]
+ sections["tasks_overdue"]
)
except Exception:
logger.exception("daily_prep tasks section failed for user %d", user_id)
sections["tasks_due_today"] = []
sections["tasks_upcoming"] = []
sections["tasks_overdue"] = []
sections["tasks"] = []
try:
try:
user_tz = ZoneInfo(user_timezone)
except Exception:
logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone)
user_tz = ZoneInfo("UTC")
# Build the local-day window in the user's TZ, then convert to UTC
# for the DB / RRULE expansion. A naive datetime here previously
# caused rrule.between() to throw, falling back to the canonical
# event row regardless of date — the source of stale recurring
# events polluting every daily prep.
day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz)
day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz)
day_start = day_start_local.astimezone(datetime.timezone.utc)
day_end = day_end_local.astimezone(datetime.timezone.utc)
all_events = await list_events(
user_id=user_id,
date_from=day_start,
date_to=day_end,
)
sections["events"] = _filter_proximate_events(
all_events, day_date=day_date, user_tz=user_tz,
)
except Exception:
logger.exception("daily_prep events section failed for user %d", user_id)
sections["events"] = []
try:
# Lazy import: journal_scheduler imports this module for prep generation,
# so a top-level import would cycle.
from fabledassistant.services.journal_scheduler import get_journal_config
cfg = await get_journal_config(user_id)
valid_weather_keys = {
key for key, loc in (cfg.get("locations") or {}).items()
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
}
weather_rows = await get_cached_weather_rows(user_id, valid_weather_keys)
sections["weather"] = [w.to_dict() for w in weather_rows]
except Exception:
logger.exception("daily_prep weather section failed for user %d", user_id)
sections["weather"] = []
try:
projects = await list_projects(user_id=user_id, status="active")
sections["projects"] = [
{
"id": p.id,
"title": p.title,
"auto_summary": p.auto_summary,
}
for p in projects[:5]
]
except Exception:
logger.exception("daily_prep projects section failed for user %d", user_id)
sections["projects"] = []
try:
sections["recent_moments"] = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=3),
date_to=day_date - datetime.timedelta(days=1),
limit=10,
)
except Exception:
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
sections["recent_moments"] = []
try:
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
except Exception:
logger.exception("daily_prep open_threads section failed for user %d", user_id)
sections["open_threads"] = []
return sections
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
"""Heuristic: moments from the last 7 days that look unresolved.
Treated as 'unresolved' when they have no linked tasks/notes and aren't
pinned. Starting heuristic — refine empirically.
"""
candidates = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=7),
date_to=day_date - datetime.timedelta(days=1),
limit=50,
)
return [
m for m in candidates
if not m.get("task_ids")
and not m.get("note_ids")
and not m.get("pinned")
]
def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str:
line = f" - {t.get('title', '?')}"
if include_overdue and t.get("days_overdue"):
n = t["days_overdue"]
unit = "day" if n == 1 else "days"
line += f" (was due {t['due_date']}, {n} {unit} overdue)"
elif include_due and t.get("due_date"):
line += f" (due {t['due_date']})"
if t.get("priority") and t["priority"] not in (None, "none"):
line += f" [{t['priority']} priority]"
if t.get("status") == "in_progress":
line += " [in progress]"
return line
def _render_sections_for_prompt(sections: dict) -> str:
"""Render the gathered sections as a structured plain-text block for the LLM."""
lines: list[str] = []
due_today = sections.get("tasks_due_today") or []
upcoming = sections.get("tasks_upcoming") or []
overdue = sections.get("tasks_overdue") or []
if due_today:
lines.append("TASKS DUE TODAY:")
for t in due_today[:8]:
lines.append(_render_task_line(t, include_due=False, include_overdue=False))
lines.append("")
if upcoming:
lines.append("UPCOMING TASKS (next 7 days):")
for t in upcoming[:8]:
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
lines.append("")
if overdue:
lines.append("OVERDUE TASKS (past their due date, still open — backlog, not today's work):")
for t in overdue[:8]:
lines.append(_render_task_line(t, include_due=False, include_overdue=True))
lines.append("")
events = sections.get("events") or []
if events:
lines.append("CALENDAR EVENTS TODAY:")
for e in events[:8]:
title = e.get("title", "Untitled")
when = e.get("start_dt", "?")
location = e.get("location") or ""
line = f" - {title} at {when}"
if location:
line += f" ({location})"
lines.append(line)
lines.append("")
weather = sections.get("weather") or []
if weather:
lines.append("WEATHER:")
for w in weather:
label = w.get("location_label") or w.get("location_key") or "Location"
forecast_json = w.get("forecast_json") or {}
daily = forecast_json.get("daily") or {}
today_max = (daily.get("temperature_2m_max") or [None])[0]
today_min = (daily.get("temperature_2m_min") or [None])[0]
precip = (daily.get("precipitation_probability_max") or [None])[0]
bits = [label]
if today_max is not None and today_min is not None:
bits.append(f"high {today_max}° / low {today_min}°")
if precip is not None:
bits.append(f"{precip}% chance of precipitation")
lines.append(" - " + ", ".join(bits))
lines.append("")
else:
# Explicit absent-marker. A silently-omitted weather block leaves a
# small model an unanchored void it tends to fill with plausible
# fabricated weather (observed: invented "68°F, 15% rain" with an
# empty weather section). A concrete "none" line + directive holds
# far better than relying on a negative system-prompt rule alone.
lines.append(
"WEATHER: none available — no weather, temperature, or precipitation "
"data exists for today. Do NOT mention weather in any form."
)
lines.append("")
projects = sections.get("projects") or []
if projects:
lines.append("ACTIVE PROJECTS:")
for p in projects[:5]:
line = f" - {p.get('title', '?')}"
if p.get("auto_summary"):
summary = p["auto_summary"][:160]
line += f"{summary}"
lines.append(line)
lines.append("")
recent_moments = sections.get("recent_moments") or []
if recent_moments:
lines.append("RECENT JOURNAL MOMENTS (last few days):")
for m in recent_moments[:8]:
day = m.get("day_date", "?")
content = (m.get("content") or "").strip()
lines.append(f" - [{day}] {content}")
lines.append("")
open_threads = sections.get("open_threads") or []
if open_threads:
lines.append("OPEN THREADS (mentioned recently but not resolved):")
for m in open_threads[:5]:
day = m.get("day_date", "?")
content = (m.get("content") or "").strip()
lines.append(f" - [{day}] {content}")
lines.append("")
# The weather-none marker is always emitted, so `lines` is never empty;
# a quiet day is one with no *substantive* sections beyond that marker.
substantive = [ln for ln in lines if ln and not ln.startswith("WEATHER: none")]
if not substantive:
return (
"(No tasks, events, or notable data for today — a quiet day. "
"Do not invent weather or any other details.)"
)
return "\n".join(lines).rstrip()
_PREP_SYSTEM_PROMPT = (
"You are briefing the user on their day. Direct and informative — tell them what's "
"actually on their plate so they can step into the day with a clear picture.\n\n"
"Rules:\n"
"- LEAD with the practical data: tasks due today, calendar events, weather.\n"
"- Be specific and concrete. Use real task titles, event times, temperatures, "
"precipitation chances. Don't paraphrase data into vague summaries.\n"
"- Write in flowing sentences — no markdown, no bullet points, no headers — but "
"keep the prose factual and useful, not sentimental.\n"
"- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" "
"greetings unless the actual content warrants two clauses' worth.\n"
"- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, "
"OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items "
"\"due today\" — they aren't. When OVERDUE TASKS appears, state the overdue "
"duration exactly as given (e.g. \"3 days overdue\") and frame it as backlog to "
"revisit, not as today's work. Do NOT echo the parenthetical section labels "
"verbatim. If the only data is overdue, lead with it but frame it as a backlog "
"reminder.\n"
"- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY "
"at the end as context — not as the lead. Skip them if nothing notable.\n"
"- Close with one short invitation to journal: \"What's on your mind?\", "
"\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n"
"- Use ONLY the data below. A category marked 'none' (or absent) genuinely has no "
"data — do not invent it and do not mention it. In particular, NEVER state weather, "
"temperature, or precipitation unless an explicit WEATHER section with numbers "
"appears below.\n"
"- NEVER invent a task's due status. A task appears under exactly one bucket "
"(TASKS DUE TODAY, UPCOMING TASKS, or OVERDUE TASKS). Frame it as whichever "
"bucket it appears under — never call an OVERDUE TASK \"due today\", never "
"promote an UPCOMING TASK to \"due today\". If TASKS DUE TODAY is empty or "
"absent, do not say anything is due today.\n"
"- NEVER invent times of day. Task lines have a due DATE (YYYY-MM-DD) and an "
"optional status; they do NOT have a time. NEVER say \"at 1:00 PM\" or "
"similar unless an EVENTS section contains an explicit time. If a task's "
"line shows only a date, the time is unknown — do not specify one.\n"
"- A task's status is the literal value in its line (todo / in_progress / "
"done / cancelled). NEVER paraphrase it to something the data doesn't say "
"(e.g. don't call a 'todo' task \"in progress\").\n"
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
)
def _fallback_prep_text(day_date: datetime.date) -> str:
"""If the LLM call fails, return a minimal greeting so the user still sees something."""
weekday = day_date.strftime("%A")
return f"{weekday}, {day_date.isoformat()}. What's on your mind?"
# Strong, low-false-positive weather signals. Deliberately NOT bare words like
# "rain"/"sunny"/"weather" (those legitimately appear in task/event titles —
# "buy rain boots"). Targets the concrete phrasings small models actually
# emit when fabricating ("partly cloudy with a high of 68°F and a 15% chance
# of rain"): temperature glyphs, "high/low of N", "chance of <precip>",
# "(partly|mostly) (cloudy|sunny)", "overcast", "precipitation", "forecast".
_WEATHER_SIGNAL_RE = re.compile(
r"""
\d{1,3}\s?°
| \b\d{1,3}\s?°?\s?(?:degrees|fahrenheit|celsius)\b
| \b(?:high|low)\s+of\s+\d
| \bchance\s+of\s+(?:rain|showers?|precipitation|snow|sleet|storms?|thunder)
| \b(?:partly|mostly)\s+(?:cloudy|sunny)\b
| \bovercast\b
| \bprecipitation\b
| \bforecast\b
""",
re.IGNORECASE | re.VERBOSE,
)
def _prose_fabricated_weather(prose: str, sections: dict) -> bool:
"""True when the prose talks weather but no weather data was gathered.
The deterministic backstop for the system-prompt rule: an 814B model
still invents weather on quiet days even when told not to. If the
WEATHER section is genuinely empty and the prose trips a strong weather
signal, that text is fabricated.
"""
if sections.get("weather"):
return False
return bool(_WEATHER_SIGNAL_RE.search(prose or ""))
async def _generate_prep_prose(
*,
sections: dict,
day_date: datetime.date,
user_id: int,
) -> str:
"""Ask the LLM for a direct conversational journal opener built from the sections."""
from fabledassistant.services.llm import generate_completion
# Daily prep is a deliberate, multi-section generation — runs once a day,
# latency-tolerant, benefits from a smarter model. Route to the worker
# (background_model) rather than the chat model. The chat model in the
# conversation+curator architecture is small/fast/no-tools; prep needs
# the heavier reasoning the worker provides.
model = (
await get_setting(user_id, "background_model", "")
or Config.OLLAMA_BACKGROUND_MODEL
or Config.OLLAMA_MODEL
)
if not model:
logger.warning("No LLM model configured for daily prep — using fallback text")
return _fallback_prep_text(day_date)
rendered = _render_sections_for_prompt(sections)
user_trigger = (
f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n"
f"Here is what I gathered for you:\n\n{rendered}\n\n"
f"Write the opener for today's journal."
)
_WEATHER_CORRECTION = (
"\n\nIMPORTANT: there is NO weather data for today. Do not mention "
"weather, temperature, sky conditions, or precipitation in any form."
)
# Up to 2 attempts: if the first trips the fabricated-weather guard, retry
# once with an explicit corrective appended to the user turn. A 14B model
# almost always complies on the corrected pass; if it still doesn't we log
# and accept (surgically excising a sentence risks breaking prose flow —
# better a rare stray clause than mangled output, and the log lets us
# measure whether a model bump is actually warranted).
prose = ""
for attempt in (1, 2):
trigger = user_trigger
if attempt == 2:
trigger = user_trigger + _WEATHER_CORRECTION
messages = [
{"role": "system", "content": _PREP_SYSTEM_PROMPT},
{"role": "user", "content": trigger},
]
try:
raw = await generate_completion(
messages=messages,
model=model,
max_tokens=400,
)
except Exception:
logger.exception("Daily prep prose generation failed for day %s", day_date)
return _fallback_prep_text(day_date)
prose = (raw or "").strip()
if not prose:
logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
return _fallback_prep_text(day_date)
if not _prose_fabricated_weather(prose, sections):
return prose
if attempt == 1:
logger.warning(
"daily_prep: fabricated weather detected for day %s (no weather "
"data gathered) — regenerating with corrective", day_date,
)
else:
logger.error(
"daily_prep: weather still fabricated after corrective retry "
"for day %s — accepting prose as-is", day_date,
)
return prose
async def ensure_daily_prep_message(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
force: bool = False,
) -> Message:
"""Get or create today's journal Conversation, then ensure the prep message exists.
The prep message is an *assistant* role message containing the prose opener,
with the structured sections preserved on ``msg_metadata``. If a legacy
system-role prep exists from an earlier version, it gets upgraded in place
on the next call.
With ``force=True`` the prose is regenerated even when a prep already exists.
Used by the manual /api/journal/trigger-prep endpoint.
"""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
)
conv = result.scalar_one_or_none()
if conv is None:
conv = Conversation(
user_id=user_id,
conversation_type="journal",
day_date=day_date,
title=day_date.isoformat(),
)
session.add(conv)
await session.flush()
# Find any existing prep (system or assistant role from any version).
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
existing_prep = None
for msg in (await session.execute(prep_stmt)).scalars():
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
existing_prep = msg
break
# If we already have an assistant-role prep with prose content and the
# caller didn't ask to force regeneration, we're done.
if (
existing_prep
and not force
and existing_prep.role == "assistant"
and (existing_prep.content or "").strip()
):
return existing_prep
sections = await gather_daily_sections(
user_id=user_id, day_date=day_date, user_timezone=user_timezone
)
prose = await _generate_prep_prose(
sections=sections, day_date=day_date, user_id=user_id
)
new_metadata = {"kind": "daily_prep", "sections": sections}
if existing_prep:
# Upgrade in place: bump role, replace content + metadata.
existing_prep.role = "assistant"
existing_prep.content = prose
existing_prep.msg_metadata = new_metadata
await session.commit()
return existing_prep
prep_msg = Message(
conversation_id=conv.id,
role="assistant",
content=prose,
msg_metadata=new_metadata,
)
session.add(prep_msg)
await session.commit()
return prep_msg
# Backwards-compat alias — older imports may use the old name.
generate_daily_prep = gather_daily_sections
@@ -1,210 +0,0 @@
"""APScheduler instance for the Journal — daily prep generation.
One per-user cron job: generate today's daily prep at the configured
prep_time in the user's local timezone. Replaces briefing_scheduler.
Mirrors the BackgroundScheduler + threadsafe-async-call pattern used by
event_scheduler.py.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
from zoneinfo import ZoneInfo
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy import select
from fabledassistant.models import User, async_session
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
_scheduler: BackgroundScheduler | None = None
_loop: asyncio.AbstractEventLoop | None = None
DEFAULT_CONFIG = {
"prep_enabled": True,
"prep_hour": 5,
"prep_minute": 0,
"day_rollover_hour": 4,
"morning_end_hour": 12,
"midday_end_hour": 18,
"closeout_enabled": True,
}
async def get_journal_config(user_id: int) -> dict:
"""Load a user's journal_config (JSON in settings) merged with defaults."""
raw = await get_setting(user_id, "journal_config", "")
config: dict = {}
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
config = parsed
except Exception:
logger.warning("Invalid journal_config for user %d; using defaults", user_id)
return {**DEFAULT_CONFIG, **config}
async def get_user_timezone(user_id: int) -> str:
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
def _resolve_tz(tz_str: str) -> ZoneInfo:
try:
return ZoneInfo(tz_str)
except Exception:
return ZoneInfo("UTC")
async def _do_daily_prep(user_id: int) -> None:
try:
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
config = await get_journal_config(user_id)
rollover = int(config.get("day_rollover_hour", 4))
now = datetime.datetime.now(tz)
# Today is the day after the most recent rollover.
if now.hour < rollover:
today = (now - datetime.timedelta(days=1)).date()
else:
today = now.date()
await ensure_daily_prep_message(
user_id=user_id, day_date=today, user_timezone=tz_str
)
except Exception:
logger.exception("Daily prep failed for user %d", user_id)
def _run_daily_prep_threadsafe(user_id: int) -> None:
if _loop is None:
return
asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop)
async def _do_closeout(user_id: int) -> None:
try:
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
now = datetime.datetime.now(tz)
# We just rolled into a new day in user-local time. The day that
# just ended is yesterday's calendar date regardless of whether
# rollover_hour is 0 or 4 — APScheduler fires precisely at the
# configured hour so no clock-skew correction is needed.
yesterday = now.date() - datetime.timedelta(days=1)
from fabledassistant.services.journal_closeout import run_for_user
await run_for_user(user_id=user_id, yesterday=yesterday)
except Exception:
logger.exception("Closeout failed for user %d", user_id)
def _run_closeout_threadsafe(user_id: int) -> None:
if _loop is None:
return
asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop)
async def _closeout_catchup(user_id: int) -> None:
"""On startup, run yesterday's closeout once if the slot already passed
and no entry for yesterday exists in observations_raw.
"""
try:
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
config = await get_journal_config(user_id)
if not config.get("closeout_enabled", True):
return
rollover_hour = int(config.get("day_rollover_hour", 4))
now = datetime.datetime.now(tz)
# Slot hasn't passed yet today → wait for the cron.
if now.hour < rollover_hour:
return
yesterday = (now - datetime.timedelta(days=1)).date()
from fabledassistant.services.user_profile import get_profile
profile = await get_profile(user_id)
existing_dates = {
(e or {}).get("date") for e in (profile.observations_raw or [])
}
if yesterday.isoformat() in existing_dates:
return
from fabledassistant.services.journal_closeout import run_for_user
await run_for_user(user_id=user_id, yesterday=yesterday)
except Exception:
logger.exception("Closeout catch-up failed for user %d", user_id)
async def update_user_schedule(user_id: int) -> None:
"""Add or replace this user's daily-prep + closeout jobs from current config."""
if _scheduler is None:
return
config = await get_journal_config(user_id)
tz_str = await get_user_timezone(user_id)
tz = _resolve_tz(tz_str)
# ── Prep job ──────────────────────────────────────────────────────────
prep_job_id = f"journal_prep_{user_id}"
if _scheduler.get_job(prep_job_id):
_scheduler.remove_job(prep_job_id)
if config.get("prep_enabled", True):
prep_hour = int(config.get("prep_hour", 5))
prep_minute = int(config.get("prep_minute", 0))
_scheduler.add_job(
_run_daily_prep_threadsafe,
trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz),
args=[user_id],
id=prep_job_id,
replace_existing=True,
)
# ── Closeout job ──────────────────────────────────────────────────────
closeout_job_id = f"journal_closeout_{user_id}"
if _scheduler.get_job(closeout_job_id):
_scheduler.remove_job(closeout_job_id)
if config.get("closeout_enabled", True):
rollover_hour = int(config.get("day_rollover_hour", 4))
_scheduler.add_job(
_run_closeout_threadsafe,
trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz),
args=[user_id],
id=closeout_job_id,
replace_existing=True,
)
async def _register_all_user_jobs() -> None:
async with async_session() as session:
users = (await session.execute(select(User))).scalars().all()
for user in users:
await update_user_schedule(user.id)
# Fire catch-up asynchronously so a slow LLM call doesn't block startup
if _loop is not None:
asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop)
def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None:
global _scheduler, _loop
if _scheduler is not None:
return
_loop = loop
_scheduler = BackgroundScheduler()
_scheduler.start()
asyncio.run_coroutine_threadsafe(_register_all_user_jobs(), loop)
logger.info("Journal scheduler started")
def stop_journal_scheduler() -> None:
global _scheduler
if _scheduler is not None:
_scheduler.shutdown(wait=False)
_scheduler = None
logger.info("Journal scheduler stopped")
@@ -1,204 +0,0 @@
"""Search across Moments and (optionally) journal transcripts.
Three modes, expressed through one tool surface:
1. Pure temporal: no query, just date range -> ordered by occurred_at DESC.
2. Pure entity: person_id or place_id filter -> junction lookup.
3. Semantic: query string -> embedding similarity, optionally constrained.
This module ONLY queries Moments and (optionally) journal-conversation
messages. It MUST NOT touch notes or note_embeddings. The notes-RAG and
journal-RAG isolation is a hard invariant of the journal design.
"""
from __future__ import annotations
import datetime
import math
from typing import Sequence
from sqlalchemy import select
from fabledassistant.models import (
Conversation,
Message,
Moment,
MomentEmbedding,
async_session,
moment_people,
moment_places,
)
from fabledassistant.services.embeddings import get_embedding
DEFAULT_LIMIT = 10
DEFAULT_THRESHOLD = 0.55
def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
if not a or not b:
return 0.0
dot = sum(x * y for x, y in zip(a, b))
norm_a = math.sqrt(sum(x * x for x in a))
norm_b = math.sqrt(sum(y * y for y in b))
if norm_a == 0 or norm_b == 0:
return 0.0
return dot / (norm_a * norm_b)
async def search_journal(
*,
user_id: int,
query: str | None = None,
person_id: int | None = None,
place_id: int | None = None,
tag: str | None = None,
date_from: datetime.date | None = None,
date_to: datetime.date | None = None,
include_transcripts: bool = False,
limit: int = DEFAULT_LIMIT,
threshold: float = DEFAULT_THRESHOLD,
) -> list[dict]:
"""Return Moments (and optional transcript snippets) matching the filters.
Result rows: dicts from Moment.to_dict() plus an optional `score` when
`query` is set. Transcript snippets carry `kind='transcript'` and `id=None`
to distinguish them.
"""
moment_rows = await _search_moments(
user_id=user_id,
query=query,
person_id=person_id,
place_id=place_id,
tag=tag,
date_from=date_from,
date_to=date_to,
limit=limit,
threshold=threshold,
)
if not include_transcripts:
return moment_rows
transcript_rows = await _search_transcripts(
user_id=user_id,
query=query,
date_from=date_from,
date_to=date_to,
limit=limit,
)
return moment_rows + transcript_rows
async def _search_moments(
*,
user_id: int,
query: str | None,
person_id: int | None,
place_id: int | None,
tag: str | None,
date_from: datetime.date | None,
date_to: datetime.date | None,
limit: int,
threshold: float,
) -> list[dict]:
async with async_session() as session:
stmt = select(Moment).where(Moment.user_id == user_id)
if person_id is not None:
stmt = stmt.join(moment_people, moment_people.c.moment_id == Moment.id).where(
moment_people.c.person_id == person_id
)
if place_id is not None:
stmt = stmt.join(moment_places, moment_places.c.moment_id == Moment.id).where(
moment_places.c.place_id == place_id
)
if tag is not None:
stmt = stmt.where(Moment.tags.any(tag))
if date_from is not None:
stmt = stmt.where(Moment.day_date >= date_from)
if date_to is not None:
stmt = stmt.where(Moment.day_date <= date_to)
if query is None:
stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit)
moments = (await session.execute(stmt)).scalars().all()
return [m.to_dict() for m in moments]
# Semantic mode. Pull a wider candidate set, then rank in Python.
candidate_stmt = stmt.order_by(Moment.occurred_at.desc()).limit(limit * 5)
candidates = (await session.execute(candidate_stmt)).scalars().all()
if not candidates:
return []
candidate_ids = [m.id for m in candidates]
emb_stmt = select(MomentEmbedding).where(
MomentEmbedding.moment_id.in_(candidate_ids)
)
embeddings = {
e.moment_id: e.embedding
for e in (await session.execute(emb_stmt)).scalars()
}
query_vec = await get_embedding(query)
scored = []
for m in candidates:
vec = embeddings.get(m.id)
if vec is None:
continue
score = _cosine(query_vec, vec)
if score >= threshold:
row = m.to_dict()
row["score"] = score
scored.append(row)
scored.sort(key=lambda r: r["score"], reverse=True)
return scored[:limit]
async def _search_transcripts(
*,
user_id: int,
query: str | None,
date_from: datetime.date | None,
date_to: datetime.date | None,
limit: int,
) -> list[dict]:
"""Fallback substring search over raw journal Messages.
Substring is intentional: transcripts catch what the LLM didn't extract
as Moments. Semantic search over messages would require a third embedding
index, which we deliberately don't maintain.
"""
if query is None:
return []
async with async_session() as session:
stmt = (
select(Message, Conversation.day_date)
.join(Conversation, Message.conversation_id == Conversation.id)
.where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Message.content.ilike(f"%{query}%"),
)
.order_by(Message.created_at.desc())
.limit(limit)
)
if date_from is not None:
stmt = stmt.where(Conversation.day_date >= date_from)
if date_to is not None:
stmt = stmt.where(Conversation.day_date <= date_to)
rows = (await session.execute(stmt)).all()
return [
{
"id": None,
"kind": "transcript",
"message_id": msg.id,
"conversation_id": msg.conversation_id,
"day_date": day_date.isoformat() if day_date else None,
"occurred_at": msg.created_at.isoformat(),
"content": msg.content[:400],
"raw_excerpt": None,
"tags": [],
"people": [],
"places": [],
}
for msg, day_date in rows
]
-881
View File
@@ -1,881 +0,0 @@
import asyncio
import ipaddress
import json
import logging
import re
import socket
import time
from collections.abc import AsyncGenerator
from dataclasses import dataclass, field
from typing import Literal
from urllib.parse import urlparse
import httpx
from fabledassistant.config import Config
from fabledassistant.services.caldav import is_caldav_configured
from fabledassistant.services.notes import get_note, search_notes_for_context
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
# Context window tiers. The smallest tier that fits the current input is used
# so Ollama allocates a smaller KV cache, reducing prefill time and VRAM usage.
# Requests using the same tier hit Ollama's prefix cache; a tier upgrade causes
# a one-time model reload but then the larger cache stays warm.
_CTX_TIERS = (8192, 16384, 32768)
def keep_alive_for(model: str) -> str:
"""Return the Ollama keep_alive duration for *model*.
Background models get a shorter window because they're called
sporadically; the main interactive model gets a longer one so it
stays warm between user messages.
"""
if model == Config.OLLAMA_BACKGROUND_MODEL:
return Config.OLLAMA_KEEP_ALIVE_BACKGROUND
return Config.OLLAMA_KEEP_ALIVE_MAIN
def pick_num_ctx(messages: list[dict], tools: list[dict] | None = None) -> int:
"""Return the smallest context tier that fits *messages* + *tools* with 25% headroom.
The ``tools`` JSON schemas are a large, often-overlooked chunk of the prompt.
With ~40 tools in the registry the schemas alone can be 6-10K tokens — enough
that omitting them from the estimate causes silent prompt truncation.
Stays at or below Config.OLLAMA_NUM_CTX (the configured ceiling).
"""
total_chars = sum(len(m.get("content") or "") for m in messages)
if tools:
# Serialize the same way Ollama will see them. json.dumps gives us a
# faithful char count for the schema payload without any guesswork.
total_chars += len(json.dumps(tools))
estimated_tokens = int(total_chars / 3.5)
needed = int(estimated_tokens * 1.25) + 256 # 25% headroom + output buffer
cap = Config.OLLAMA_NUM_CTX
for tier in _CTX_TIERS:
if tier >= needed and tier <= cap:
return tier
return cap
STOP_WORDS = frozenset({
"a", "an", "the", "is", "it", "to", "in", "for", "of", "and", "or",
"on", "at", "by", "with", "from", "as", "be", "was", "were", "been",
"are", "am", "do", "does", "did", "have", "has", "had", "will", "would",
"can", "could", "shall", "should", "may", "might", "must", "that",
"this", "these", "those", "i", "me", "my", "you", "your", "he", "she",
"we", "they", "them", "his", "her", "its", "our", "their", "what",
"which", "who", "whom", "how", "when", "where", "why", "not", "no",
"but", "if", "so", "than", "too", "very", "just", "about", "up",
})
RAG_AUTO_THRESHOLD = 0.60
RAG_AUTO_LIMIT = 3
RAG_AUTO_SNIPPET = 4000
async def get_installed_models() -> set[str]:
"""Return set of installed Ollama model names (with and without :latest).
Tags are normalized to lowercase. Ollama stores whatever casing was used at
pull time (``ollama pull gemma3:12B`` keeps the ``B``), but inference calls
against the capitalized tag can 400 — the two code paths are inconsistent.
Lowercasing on read avoids exposing that mixed-case name in the UI and
keeps the validation check below from accepting a form Ollama will reject.
"""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/tags")
resp.raise_for_status()
data = resp.json()
names: set[str] = set()
for m in data.get("models", []):
name = m["name"].lower()
names.add(name)
if name.endswith(":latest"):
names.add(name.removesuffix(":latest"))
return names
except Exception:
logger.warning("Failed to fetch installed Ollama models")
return set()
async def ensure_model(model: str) -> None:
"""Check if model exists in Ollama, pull if missing."""
# Match the lowercase normalization in get_installed_models so a legacy
# mixed-case setting doesn't force a spurious re-pull at startup.
model = model.lower()
try:
installed = await get_installed_models()
if model in installed or f"{model}:latest" in installed:
logger.info("Model '%s' already available", model)
return
except Exception:
logger.warning("Failed to check Ollama models, attempting pull anyway")
logger.info("Pulling model '%s' from Ollama...", model)
try:
async with httpx.AsyncClient(timeout=1800.0) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/pull",
json={"name": model},
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if line.strip():
status = json.loads(line)
if "status" in status:
logger.info("Pull %s: %s", model, status["status"])
logger.info("Model '%s' pulled successfully", model)
except Exception:
logger.warning("Failed to pull model '%s' — chat may not work", model, exc_info=True)
async def wait_for_model_loaded(model: str, timeout: float = 90.0) -> bool:
"""Poll /api/ps every 2s until the model appears in Ollama's loaded-model list.
Returns True when the model is loaded, False if timeout elapses first.
Used before generation to avoid streaming 500s during cold model loads.
"""
base = model.removesuffix(":latest")
deadline = time.monotonic() + timeout
while True:
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{Config.OLLAMA_URL}/api/ps")
resp.raise_for_status()
loaded = {m["name"] for m in resp.json().get("models", [])}
if model in loaded or f"{base}:latest" in loaded or base in loaded:
return True
except Exception:
pass # Ollama may still be starting up
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
await asyncio.sleep(min(2.0, remaining))
async def _raise_ollama_error(resp: httpx.Response, model: str) -> None:
"""On a non-2xx Ollama response, log its body before raising.
``resp.raise_for_status()`` alone throws away Ollama's error body, which
carries the actual reason (e.g. ``"model does not support tools"``,
``"context length exceeded"``). Reading the body first means failures
surface a usable message instead of a bare HTTPStatusError.
"""
if resp.status_code < 400:
return
body = (await resp.aread()).decode("utf-8", errors="replace").strip()
logger.error(
"Ollama /api/chat %d for model=%s: %s",
resp.status_code, model, body[:500],
)
resp.raise_for_status()
async def stream_chat(
messages: list[dict],
model: str,
options: dict | None = None,
think: bool = False,
num_ctx: int | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completion from Ollama, yielding content chunks.
Set think=False (default) to disable chain-of-thought on qwen3+ models.
Thinking tokens are silently discarded anyway, but disabling avoids the
multi-minute delay before the first content token arrives.
"""
merged_options = {"num_ctx": num_ctx or Config.OLLAMA_NUM_CTX}
if options:
merged_options.update(options)
payload: dict = {"model": model, "messages": messages, "stream": True, "options": merged_options, "think": think, "keep_alive": keep_alive_for(model)}
# read=None: no per-chunk timeout — Ollama may pause for any duration while
# processing a large input context before the first token arrives.
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0)) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/chat",
json=payload,
) as resp:
await _raise_ollama_error(resp, model)
async for line in resp.aiter_lines():
if not line.strip():
continue
data = json.loads(line)
chunk = data.get("message", {}).get("content", "")
if chunk:
yield chunk
if data.get("done"):
break
@dataclass
class ChatChunk:
"""A chunk yielded by stream_chat_with_tools."""
type: Literal["content", "thinking", "tool_calls", "done"]
content: str = ""
tool_calls: list[dict] | None = None
# Token counts from the Ollama done event (only set on type="done")
prompt_tokens: int | None = None
output_tokens: int | None = None
async def stream_chat_with_tools(
messages: list[dict],
model: str,
tools: list[dict] | None = None,
think: bool = False,
num_ctx: int | None = None,
) -> AsyncGenerator[ChatChunk, None]:
"""Stream chat completion from Ollama with tool support.
Yields ChatChunk objects. If the model returns tool_calls, a
ChatChunk(type="tool_calls") is yielded. Always ends with
ChatChunk(type="done").
Set think=True to enable the model's chain-of-thought reasoning (qwen3+).
Thinking tokens are consumed by Ollama and not forwarded to the caller;
only the final response content is yielded. Expect higher TTFT when enabled.
"""
resolved_ctx = num_ctx or Config.OLLAMA_NUM_CTX
options: dict = {"num_ctx": resolved_ctx}
if tools:
options["num_predict"] = 8192
payload: dict = {
"model": model,
"messages": messages,
"stream": True,
"options": options,
"think": think,
"keep_alive": keep_alive_for(model),
}
if tools:
payload["tools"] = tools
# read=None: no per-chunk timeout for the same reason as stream_chat.
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=30.0, read=None, write=None, pool=30.0)) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/chat",
json=payload,
) as resp:
await _raise_ollama_error(resp, model)
accumulated_tool_calls: list[dict] = []
# Silent-generation diagnostic: if Ollama reports non-zero eval_count
# but we never yielded any thinking/content/tool_calls, something
# in the frames isn't landing in a field we read. Capture the last
# few frames so we can see what Ollama actually sent.
yielded_anything = False
recent_frames: list[str] = []
async for line in resp.aiter_lines():
if not line.strip():
continue
if len(recent_frames) >= 5:
recent_frames.pop(0)
recent_frames.append(line[:500])
data = json.loads(line)
msg = data.get("message", {})
# Thinking chunks (qwen3 chain-of-thought, only when think=True)
thinking = msg.get("thinking", "")
if thinking:
yielded_anything = True
yield ChatChunk(type="thinking", content=thinking)
# Content chunks
chunk = msg.get("content", "")
if chunk:
yielded_anything = True
yield ChatChunk(type="content", content=chunk)
# Collect tool calls from any message (some models
# emit them before the done flag)
tc = msg.get("tool_calls")
if tc:
accumulated_tool_calls.extend(tc)
if data.get("done"):
if accumulated_tool_calls:
logger.info(
"Ollama returned %d tool call(s): %s",
len(accumulated_tool_calls),
json.dumps(accumulated_tool_calls)[:500],
)
yielded_anything = True
yield ChatChunk(type="tool_calls", tool_calls=accumulated_tool_calls)
else:
logger.debug("Ollama done with no tool calls")
eval_count = data.get("eval_count") or 0
if not yielded_anything and eval_count > 0:
logger.warning(
"Ollama silent generation: model=%s eval_count=%d but no "
"thinking/content/tool_calls were yielded. Last frames: %s",
model, eval_count, recent_frames,
)
yield ChatChunk(
type="done",
prompt_tokens=data.get("prompt_eval_count"),
output_tokens=eval_count,
)
break
async def generate_completion(
messages: list[dict],
model: str,
max_tokens: int = 4096,
num_ctx: int | None = None,
) -> str:
"""Non-streaming chat completion, returns full response text.
Retries up to 2 times on Ollama 500 errors (cold model loading race).
num_ctx overrides the model's context window for this call only.
"""
last_exc: Exception | None = None
# Default num_ctx to Config.OLLAMA_NUM_CTX (matching stream_chat /
# stream_chat_with_tools). Without this, Ollama silently uses the model's
# default window (~4k on qwen3) and truncates anything longer. That is
# how the research pipeline's outline step kept falling back to a single
# monolith note: its 12-source prompt is ~6k tokens and was being chopped
# before the model ever saw it. Non-streaming callers must not inherit
# that footgun — if you truly want the model default, pass num_ctx=0.
options: dict = {
"num_predict": max_tokens,
"num_ctx": num_ctx if num_ctx is not None else Config.OLLAMA_NUM_CTX,
}
for attempt in range(3):
if attempt > 0:
delay = 3.0 * attempt
logger.warning(
"generate_completion 500 (attempt %d/3), retrying in %.0fs", attempt, delay
)
await asyncio.sleep(delay)
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(1800.0, connect=30.0, read=300.0)) as client:
resp = await client.post(
f"{Config.OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": messages,
"stream": False,
"think": False,
"options": options,
"keep_alive": keep_alive_for(model),
},
)
resp.raise_for_status()
data = resp.json()
return data.get("message", {}).get("content", "")
except httpx.HTTPStatusError as exc:
last_exc = exc
if exc.response.status_code != 500:
break
except Exception as exc:
last_exc = exc
break
raise last_exc
def _is_private_url(url: str) -> bool:
"""Return True if the URL resolves to a private/loopback/link-local address (SSRF guard)."""
try:
host = urlparse(url).hostname
if not host:
return True
if host.lower() in ("localhost", "::1"):
return True
addr_info = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
for entry in addr_info:
ip = ipaddress.ip_address(entry[4][0])
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved or ip.is_multicast:
return True
return False
except Exception:
return True # Block on resolution failure
async def fetch_url_content(url: str) -> str:
"""Fetch a URL and return text content (HTML tags stripped)."""
if _is_private_url(url):
logger.warning("Blocked fetch of private/internal URL: %s", url)
return "[URL blocked: internal network access not permitted]"
try:
async with httpx.AsyncClient(
timeout=15.0, follow_redirects=False, headers={"User-Agent": "FabledAssistant/1.0"}
) as client:
resp = await client.get(url)
resp.raise_for_status()
text = resp.text
# Strip HTML tags
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL)
text = re.sub(r"<style[^>]*>.*?</style>", "", text, flags=re.DOTALL)
text = re.sub(r"<[^>]+>", " ", text)
# Collapse whitespace
text = re.sub(r"\s+", " ", text).strip()
# Truncate to reasonable size
if len(text) > 4000:
text = text[:4000] + "..."
return text
except Exception as e:
logger.warning("Failed to fetch URL %s: %s", url, e)
return f"[Failed to fetch URL: {url}]"
def _extract_keywords(text: str) -> list[str]:
"""Extract meaningful keywords from text for note search."""
words = re.findall(r"\b[a-zA-Z]{3,}\b", text.lower())
keywords = [w for w in words if w not in STOP_WORDS]
# Deduplicate while preserving order
seen: set[str] = set()
unique = []
for w in keywords:
if w not in seen:
seen.add(w)
unique.append(w)
return unique[:5]
def _find_urls(text: str) -> list[str]:
"""Find URLs in text."""
return re.findall(r"https?://[^\s<>\"')\]]+", text)
# History summarization thresholds
_HISTORY_SUMMARY_THRESHOLD = 30 # total messages before summarizing
_HISTORY_KEEP_RECENT = 8 # verbatim tail to preserve (4 exchanges)
async def summarize_history_for_context(
history: list[dict],
model: str,
) -> tuple[list[dict], str | None]:
"""Summarize old conversation history when it exceeds the threshold.
Returns (recent_history, summary_text | None).
recent_history is the verbatim tail passed to the model.
summary_text (when not None) should be injected into the system prompt
so the model retains the gist of earlier exchanges without the full tokens.
For short conversations, returns (history, None) immediately with no LLM call.
"""
if len(history) <= _HISTORY_SUMMARY_THRESHOLD:
return history, None
to_summarize = history[:-_HISTORY_KEEP_RECENT]
recent = history[-_HISTORY_KEEP_RECENT:]
# Two-pass for very long histories: summarize first half, combine with second half
if len(to_summarize) > 50:
mid = len(to_summarize) // 2
first_half = to_summarize[:mid]
second_half = to_summarize[mid:]
# Summarize first half
first_lines = []
for m in first_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
first_lines.append(f"{label}: {content[:400]}")
if first_lines:
try:
first_summary_messages = [
{"role": "system", "content": "Summarize this conversation in 3-4 sentences covering topics, notes/tasks created, and key decisions."},
{"role": "user", "content": "\n".join(first_lines)},
]
summary_a = await generate_completion(first_summary_messages, model, max_tokens=300)
summary_a = summary_a.strip()
except Exception:
summary_a = ""
else:
summary_a = ""
# Build lines for final pass from second half
second_lines = []
for m in second_half:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
second_lines.append(f"{label}: {content[:400]}")
if summary_a:
lines = [f"[Earlier summary: {summary_a}]"] + second_lines
else:
lines = second_lines
else:
lines: list[str] = []
for m in to_summarize:
role = m.get("role", "")
content = (m.get("content") or "").strip()
if role in ("user", "assistant") and content:
label = "User" if role == "user" else "Assistant"
lines.append(f"{label}: {content[:400]}")
if not lines:
return history, None
prompt_messages = [
{
"role": "system",
"content": (
"Summarize this conversation history. Capture: "
"(1) All notes, tasks, and projects created or modified — include their exact names. "
"(2) Key decisions made and conclusions reached. "
"(3) Open questions and next steps mentioned. "
"(4) The overall topic arc so the conversation can continue naturally. "
"Be specific and factual. Output 4-8 concise sentences. Nothing else."
),
},
{"role": "user", "content": "\n".join(lines)},
]
try:
summary = await generate_completion(prompt_messages, model, max_tokens=400)
summary = summary.strip()
if summary:
logger.info(
"Summarized %d history messages (%d chars) for context",
len(to_summarize), len(summary),
)
return recent, summary
except Exception:
logger.warning("Failed to summarize conversation history", exc_info=True)
return history, None
async def build_context(
user_id: int,
history: list[dict],
current_note_id: int | None,
user_message: str,
exclude_note_ids: list[int] | None = None,
history_summary: str | None = None,
include_note_ids: list[int] | None = None,
excluded_note_ids: list[int] | None = None,
rag_project_id: int | None = None,
workspace_project_id: int | None = None,
user_timezone: str | None = None,
conv_id: int | None = None,
voice_mode: bool = False,
voice_speech_style: str = "conversational",
) -> tuple[list[dict], dict]:
"""Build messages array for Ollama with system prompt and context.
Returns (messages, context_meta) where context_meta contains info about
which notes were included as context.
"""
exclude_set = set(exclude_note_ids or [])
from datetime import date as date_type
# --- Journal short-circuit ---
# Journal conversations get a different persona, calibration, and an
# ambient-moments context block. CRUCIALLY, no notes-RAG injection here
# (preserves the notes/journal isolation invariant).
if conv_id is not None:
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_conv = await _sess.get(_Conversation, conv_id)
if _conv and getattr(_conv, "conversation_type", None) == "journal":
from fabledassistant.services.journal_pipeline import build_journal_system_prompt
day_date = _conv.day_date or date_type.today()
system_content = await build_journal_system_prompt(
user_id=user_id,
day_date=day_date,
user_timezone=user_timezone or "UTC",
conv_id=conv_id,
)
messages_out: list[dict] = [{"role": "system", "content": system_content}]
messages_out.extend(history)
messages_out.append({"role": "user", "content": user_message})
return messages_out, {
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
assistant_name = await get_setting(user_id, "assistant_name", "Fable")
_today_obj = date_type.today()
today = _today_obj.isoformat()
# Day-of-week paired with the ISO date so the model doesn't have to
# derive the weekday — that derivation is a documented failure mode
# for "this Friday" / "next Monday"-style requests.
today_weekday = _today_obj.strftime("%A")
has_caldav = await is_caldav_configured(user_id)
# Build tool usage guidance based on available integrations
# --- Static block (Ollama KV-cache prefix) ---
# Everything here must be byte-for-byte identical across requests for the same
# user so Ollama can reuse the cached KV state. No dates, timezones, RAG notes,
# or user-profile data here — those go in the dynamic tail below.
tool_lines = [
"You have access to tool functions. You MUST use them when the user asks you to create, add, find, schedule, or search for anything.",
"CRITICAL: Call the tool functions directly. NEVER write out function calls as text or code. NEVER describe what you would do — just do it.",
"GROUNDING: When the user asks about their own data — tasks, notes, events, projects, news, anything stored in this system — call the relevant tool to see what actually exists before answering. Never assert facts about the user's data from memory, prior context, or assumption. If you are unsure whether something exists, check with a tool.",
"HONESTY WHEN EMPTY: If a tool returns empty results (no matching tasks, no events in the date range, no search hits, no notes found), tell the user plainly that nothing matched. Do not fabricate example items, do not invent plausible-sounding meetings or deadlines to fill the response, and do not hedge with generic suggestions dressed up as real data. A direct 'you don't have anything on your calendar today' is always better than an invented event.",
"EXISTING WORK: When the user describes ongoing or completed work that references a specific project or task by name or partial name, call search_notes first to locate the existing item. Only call record_moment, create_task, or create_note if no matching task surfaces and the user confirms.",
]
actions = [
"create_note (also creates tasks — set status='todo')", "update_note", "delete_note",
"read_note", "list_notes", "list_tasks", "log_work", "search_notes",
"create_project", "list_projects", "get_project", "update_project",
"search_projects", "create_milestone", "update_milestone", "list_milestones",
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
"read_article",
]
if has_caldav:
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
tool_lines.append(
"For calendar events, use ISO 8601 datetime format with the user's timezone offset (stated in context below). "
"Always include the UTC offset in datetime strings (e.g. 2026-09-30T14:00:00+01:00)."
)
tool_lines.append("When the user says 'remind me' with a time before an event, use the reminder_minutes parameter.")
actions.append("lookup")
if Config.searxng_enabled():
actions.extend(["research_topic", "search_images"])
tool_lines.append(f"Available actions: {', '.join(actions)}.")
tool_lines.append(
"For relative dates like 'Friday' or 'next week', resolve them to YYYY-MM-DD format. "
"Always include the UTC offset when creating events (user's timezone is stated in context below)."
)
tool_lines.append("When creating notes, use the `tags` parameter — do not embed #tag text in the note body.")
tool_lines.append(
"When search_images returns results, embed each image directly in your response by writing "
"the 'embed' field verbatim (e.g. ![Cat](/api/images/3)), then the 'citation' field on the "
"next line. Never describe images as text or list their URLs — always render them as markdown images."
)
tool_lines.append(
"Use update_note for existing notes/tasks; use create_note only for new content. "
"Use search_notes for semantic/conceptual queries. "
"Delete tools require an explicit user request. "
"Never proactively search notes or comment on absent context."
)
tool_lines.append(
"IMPORTANT: When creating tasks or notes, NEVER infer or guess a project name. "
"Only set the project parameter if the user explicitly names a project. "
"If the user says 'create a task to buy milk', do NOT assign it to a project."
)
tool_guidance = "\n".join(tool_lines)
static_block = (
f"You are a helpful assistant named {assistant_name}, integrated into a note-taking and task-tracking app called Fabled Scribe. "
"Help users with their notes, tasks, and general questions. "
"When note context is provided, use it to give relevant answers.\n\n"
f"{tool_guidance}"
)
# --- Dynamic tail (appended after static block, evaluated every request) ---
# Date, timezone, user profile, and entities change per-day or per-user.
# Keeping these at the end preserves the static prefix for KV-cache reuse.
tz_line = f" The user's timezone is {user_timezone}." if user_timezone else ""
from fabledassistant.services.user_profile import build_profile_context
from fabledassistant.services.knowledge import get_people_and_places_context
profile_context = await build_profile_context(user_id)
profile_section = f"\n\n{profile_context}" if profile_context else ""
entities_context = await get_people_and_places_context(user_id)
entities_section = f"\n\n{entities_context}" if entities_context else ""
dynamic_tail = f"\n\nToday is {today_weekday}, {today}.{tz_line}{profile_section}{entities_section}"
# --- System message: stable content only ---
# Workspace context and history summary stay here because they carry
# behavioural instructions / conversational state, not retrieved content.
# Everything retrieval-based (RAG notes, URL content, current note)
# goes into the user turn below so the system message
# prefix stays byte-for-byte identical across requests, enabling Ollama's
# KV prefix cache to fire reliably.
if voice_mode:
_style_hints = {
"conversational": "Be warm, natural, and conversational — like speaking to a friend.",
"concise": "Be brief and to the point. One or two sentences maximum unless detail is essential.",
"detailed": "Give thorough, informative responses as if narrating an explanation aloud.",
}
style_hint = _style_hints.get(voice_speech_style, _style_hints["conversational"])
voice_preamble = (
"VOICE MODE: Respond naturally as if speaking aloud. "
"No markdown, bullet points, headers, or code blocks. Complete sentences only. "
f"{style_hint}\n\n"
)
system_content = voice_preamble + static_block + dynamic_tail
else:
system_content = static_block + dynamic_tail
# Inject workspace context (behavioural — must stay in system)
if workspace_project_id is not None:
from fabledassistant.services.projects import get_project
try:
wp = await get_project(user_id, workspace_project_id)
if wp:
system_content += (
f"\n\n--- Active Workspace ---\n"
f"You are in the \"{wp.title}\" project workspace.\n"
f"All notes and tasks you create or update MUST belong to this project.\n"
f"Always pass project=\"{wp.title}\" when calling create_note.\n"
f"--- End Active Workspace ---"
)
except Exception:
logger.warning("Failed to fetch workspace project %d", workspace_project_id)
# Inject compressed history summary (conversational state — stays in system)
if history_summary:
system_content += (
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
)
context_meta: dict = {
"context_note_id": None,
"context_note_title": None,
"auto_notes": [],
"auto_injected_notes": [],
}
# --- User turn context prefix: retrieval-based content ---
# Collected here and prepended to the user message so the system message
# stays stable and the KV prefix cache can fire on every request.
user_context_parts: list[str] = []
# Current note being viewed (full body, no truncation)
if current_note_id:
note = await get_note(user_id, current_note_id)
if note:
context_meta["context_note_id"] = note.id
context_meta["context_note_title"] = note.title
user_context_parts.append(
f"--- Current Note ---\n"
f"Title: {note.title}\n"
f"Content:\n{note.body}\n"
f"--- End Note ---"
)
# Semantic / keyword note search
search_exclude = set(exclude_set)
if current_note_id:
search_exclude.add(current_note_id)
found_scored: list[tuple[float | None, object]] = []
orphan_only = rag_project_id is None
effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None
try:
from fabledassistant.services.embeddings import semantic_search_notes
for score, note in await semantic_search_notes(
user_id, user_message, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((score, note))
except Exception:
logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True)
if not found_scored:
keywords = _extract_keywords(user_message)
if keywords:
try:
for note in await search_notes_for_context(
user_id, keywords, exclude_ids=search_exclude or None, limit=8,
project_id=effective_project_id,
orphan_only=orphan_only,
):
found_scored.append((None, note))
except Exception:
logger.warning("Failed to search notes for context", exc_info=True)
excluded_inject_set = set(excluded_note_ids or [])
auto_inject: list[tuple[float, object]] = []
sidebar_only: list[tuple[float | None, object]] = []
for score, n in found_scored:
if (
score is not None
and score >= RAG_AUTO_THRESHOLD
and len(auto_inject) < RAG_AUTO_LIMIT
and n.id not in excluded_inject_set
):
auto_inject.append((score, n))
else:
sidebar_only.append((score, n))
if auto_inject:
snippets = []
for score, n in auto_inject:
body_snippet = (n.body or "")[:RAG_AUTO_SNIPPET]
snippets.append(f"**{n.title}** (relevance: {round(score * 100)}%)\n{body_snippet}")
context_meta["auto_injected_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2),
})
user_context_parts.append(
"[The following are reference excerpts from the user's personal notes, "
"not part of their message. Use them only if relevant to answering.]\n"
"--- Relevant Notes ---\n"
+ "\n\n".join(snippets)
+ "\n--- End Relevant Notes ---"
)
for score, n in auto_inject:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": True,
})
for score, n in sidebar_only:
context_meta["auto_notes"].append({
"id": n.id,
"title": n.title,
"score": round(score, 2) if score is not None else None,
"auto_injected": False,
})
context_meta["auto_note_ids"] = [n.id for _, n in found_scored]
# Explicitly included notes (user opted in via sidebar)
if include_note_ids:
from fabledassistant.services.notes import get_note as _get_note
included_snippets: list[str] = []
for nid in include_note_ids:
try:
n = await _get_note(user_id, nid)
if n:
body_preview = n.body or ""
included_snippets.append(f"- {n.title}: {body_preview}")
except Exception:
logger.warning("Failed to load included note %d for context", nid, exc_info=True)
if included_snippets:
user_context_parts.append(
"--- Included Notes ---\n"
+ "\n".join(included_snippets)
+ "\n--- End Included Notes ---"
)
# URL content fetched from links in the user message
urls = _find_urls(user_message)
for url in urls[:2]:
content = await fetch_url_content(url)
if content and not content.startswith("[Failed"):
user_context_parts.append(
f"--- Content from {url} ---\n{content}\n--- End URL Content ---"
)
# Build final user message — context prefix (if any) followed by the actual message
if user_context_parts:
user_turn = "\n\n".join(user_context_parts) + "\n\n" + user_message
else:
user_turn = user_message
messages = [{"role": "system", "content": system_content}]
messages.extend(history)
messages.append({"role": "user", "content": user_turn})
return messages, context_meta
-185
View File
@@ -1,185 +0,0 @@
"""Moment CRUD, entity linking, and embedding sync.
Moments are small structured extractions from journal conversations.
Stored separately from Notes — they have their own embedding index and
the isolation between notes-RAG and journal-RAG is enforced at the API
boundary, not just the schema.
"""
from __future__ import annotations
import datetime
from typing import Sequence
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from fabledassistant.models import (
Moment,
MomentEmbedding,
async_session,
moment_notes,
moment_people,
moment_places,
moment_tasks,
)
from fabledassistant.services.embeddings import get_embedding
async def create_moment(
*,
user_id: int,
content: str,
occurred_at: datetime.datetime,
day_date: datetime.date,
conversation_id: int | None = None,
source_message_id: int | None = None,
raw_excerpt: str | None = None,
tags: Sequence[str] = (),
person_ids: Sequence[int] = (),
place_ids: Sequence[int] = (),
task_ids: Sequence[int] = (),
note_ids: Sequence[int] = (),
) -> Moment:
"""Insert a Moment, write its embedding, create junction rows."""
async with async_session() as session:
moment = Moment(
user_id=user_id,
conversation_id=conversation_id,
source_message_id=source_message_id,
day_date=day_date,
occurred_at=occurred_at,
content=content,
raw_excerpt=raw_excerpt,
tags=list(tags),
)
session.add(moment)
await session.flush()
await _set_links(
session,
moment_id=moment.id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
embedding_vector = await get_embedding(content)
session.add(
MomentEmbedding(
moment_id=moment.id,
user_id=user_id,
embedding=embedding_vector,
)
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def get_moment(*, user_id: int, moment_id: int) -> Moment | None:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
return result.scalar_one_or_none()
async def update_moment(
*,
user_id: int,
moment_id: int,
content: str | None = None,
tags: Sequence[str] | None = None,
pinned: bool | None = None,
person_ids: Sequence[int] | None = None,
place_ids: Sequence[int] | None = None,
task_ids: Sequence[int] | None = None,
note_ids: Sequence[int] | None = None,
) -> Moment | None:
"""Update fields and (optionally) replace junction sets.
None for a junction parameter ⇒ leave unchanged. Empty sequence ⇒ clear.
Mirrors the Notes update convention.
"""
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return None
if content is not None:
moment.content = content
embedding_vector = await get_embedding(content)
await session.execute(
delete(MomentEmbedding).where(MomentEmbedding.moment_id == moment_id)
)
session.add(
MomentEmbedding(
moment_id=moment_id,
user_id=user_id,
embedding=embedding_vector,
)
)
if tags is not None:
moment.tags = list(tags)
if pinned is not None:
moment.pinned = pinned
await _set_links(
session,
moment_id=moment_id,
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
await session.commit()
await session.refresh(moment, ["people", "places", "tasks", "notes"])
return moment
async def delete_moment(*, user_id: int, moment_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
select(Moment).where(Moment.id == moment_id, Moment.user_id == user_id)
)
moment = result.scalar_one_or_none()
if moment is None:
return False
await session.delete(moment)
await session.commit()
return True
async def _set_links(
session: AsyncSession,
*,
moment_id: int,
person_ids: Sequence[int] | None,
place_ids: Sequence[int] | None,
task_ids: Sequence[int] | None,
note_ids: Sequence[int] | None,
) -> None:
"""Replace junction-table rows.
None ⇒ leave existing rows alone. Empty sequence ⇒ clear all rows.
"""
for ids, table, fk_col in [
(person_ids, moment_people, "person_id"),
(place_ids, moment_places, "place_id"),
(task_ids, moment_tasks, "task_id"),
(note_ids, moment_notes, "note_id"),
]:
if ids is None:
continue
await session.execute(delete(table).where(table.c.moment_id == moment_id))
if ids:
await session.execute(
table.insert(),
[{"moment_id": moment_id, fk_col: i} for i in ids],
)
-34
View File
@@ -49,29 +49,6 @@ async def _maybe_reactivate_project(project_id: int) -> None:
logger.debug("_maybe_reactivate_project failed for project %d", project_id, exc_info=True)
async def _maybe_trigger_project_summary(user_id: int, project_id: int) -> None:
"""Fire generate_project_summary() if the project summary is missing or >1h old."""
import asyncio
from datetime import timedelta
from fabledassistant.models.project import Project
from fabledassistant.services.projects import generate_project_summary
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project is None:
return
stale = (
project.summary_updated_at is None
or (datetime.now(timezone.utc) - project.summary_updated_at) > timedelta(hours=1)
)
if stale:
asyncio.create_task(generate_project_summary(user_id, project_id))
except Exception:
logger.debug("_maybe_trigger_project_summary failed for project %d", project_id, exc_info=True)
async def create_note(
user_id: int,
title: str = "",
@@ -122,7 +99,6 @@ async def create_note(
if project_id is not None:
await _maybe_reactivate_project(project_id)
await _maybe_trigger_project_summary(user_id, project_id)
return note
@@ -281,8 +257,6 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
old_body = note.body
old_title = note.title
old_tags = list(note.tags or [])
# Snapshot status to detect terminal transitions for consolidation trigger.
old_status = note.status
for key, value in fields.items():
if not hasattr(note, key):
continue
@@ -327,16 +301,8 @@ async def update_note(user_id: int, note_id: int, **fields: object) -> Note | No
from fabledassistant.services.note_versions import create_version
await create_version(user_id, note_id, old_body, old_title, old_tags)
# Trigger consolidation when a task transitions into a terminal status.
# Captured before mutation; the gate inside maybe_consolidate handles the
# auto-consolidate setting.
if note.status in ("done", "cancelled") and old_status != note.status:
from fabledassistant.services.consolidation import maybe_consolidate
await maybe_consolidate(user_id, note.id, reason="task_closed")
if note.project_id is not None:
await _maybe_reactivate_project(note.project_id)
await _maybe_trigger_project_summary(user_id, note.project_id)
return note
@@ -267,11 +267,9 @@ async def create_in_app_notification(user_id: int, notif_type: str, payload: dic
async def _fire_push_notif(user_id: int, title: str, body: str, url: str) -> None:
try:
from fabledassistant.services.push import send_push_notification
await send_push_notification(user_id, title, body, url=url)
except Exception:
logger.exception("Push notification failed for user %d", user_id)
# Push delivery was removed alongside the chat subsystem (Phase 8).
# In-app notifications still flow through the bell-icon feed.
return None
async def _fire_share_email(user_id: int, subject: str, body_text: str) -> None:
@@ -1,167 +0,0 @@
"""Pending-action service — curator-proposed mutations awaiting user approval.
See models/pending_curator_action.py for the schema rationale. This module
is the API everything else uses:
- `create_pending(...)` is called from execute_tool's curator-authority
interceptor when the curator tries to call a mutating tool. The caller
has already captured a snapshot of the target's current state.
- `list_pending(user_id)` is what the Needs Review panel reads.
- `approve(action_id, user_id)` replays the original tool call via
`execute_tool` with `authority="user"`, which bypasses the curator
interceptor and just runs. On success, the row moves to status='approved'.
- `reject(action_id, user_id)` marks the row 'rejected' without executing.
History row stays around for audit; nothing runs.
"""
from __future__ import annotations
import datetime
import logging
from datetime import timezone
from typing import Any
from sqlalchemy import select, update
from fabledassistant.models import async_session
from fabledassistant.models.pending_curator_action import PendingCuratorAction
logger = logging.getLogger(__name__)
async def create_pending(
*,
user_id: int,
conv_id: int | None,
action_type: str,
target_type: str | None,
target_id: int | None,
target_label: str | None,
payload: dict,
current_snapshot: dict,
) -> PendingCuratorAction:
"""Persist a curator-proposed mutation for later review.
Returns the created row so the interceptor can include its id in the
tool-result envelope (handy for tests, logs, and future UI deep-links).
"""
async with async_session() as session:
row = PendingCuratorAction(
user_id=user_id,
conv_id=conv_id,
action_type=action_type,
target_type=target_type,
target_id=target_id,
target_label=target_label,
payload=payload or {},
current_snapshot=current_snapshot or {},
status="pending",
)
session.add(row)
await session.commit()
await session.refresh(row)
logger.info(
"Curator proposed %s on %s/%s (label=%r) — action_id=%d, conv=%s",
action_type, target_type, target_id, target_label, row.id, conv_id,
)
return row
async def list_pending(user_id: int, limit: int = 50) -> list[dict]:
"""Return the user's pending actions, newest first.
Only `status='pending'` — once a row is approved or rejected, it
drops off this list. Use the index `ix_pending_curator_actions_user_pending`.
"""
async with async_session() as session:
result = await session.execute(
select(PendingCuratorAction)
.where(
PendingCuratorAction.user_id == user_id,
PendingCuratorAction.status == "pending",
)
.order_by(PendingCuratorAction.created_at.desc())
.limit(limit)
)
return [row.to_dict() for row in result.scalars().all()]
async def _load_for_user(action_id: int, user_id: int) -> PendingCuratorAction | None:
async with async_session() as session:
result = await session.execute(
select(PendingCuratorAction).where(
PendingCuratorAction.id == action_id,
PendingCuratorAction.user_id == user_id,
)
)
return result.scalar_one_or_none()
async def _mark_reviewed(action_id: int, status: str) -> None:
async with async_session() as session:
await session.execute(
update(PendingCuratorAction)
.where(PendingCuratorAction.id == action_id)
.values(status=status, reviewed_at=datetime.datetime.now(timezone.utc))
)
await session.commit()
async def approve(action_id: int, user_id: int) -> dict[str, Any]:
"""Replay the curator's proposed action with user authority and mark approved.
The replay path goes through `execute_tool` with `authority="user"` so
the curator interceptor doesn't intercept its own replay (which would
create another pending row and loop forever).
Returns the tool-result dict from execute_tool. Action stays in
`pending` if replay returns an error so the user can retry; only
transitions to `approved` on success.
"""
row = await _load_for_user(action_id, user_id)
if row is None:
return {"success": False, "error": "Pending action not found"}
if row.status != "pending":
return {
"success": False,
"error": f"Action already {row.status}",
}
from fabledassistant.services.tools import execute_tool
try:
# authority="user" bypasses the curator interceptor — required so
# the replay actually executes the mutating tool instead of
# creating another pending row (which would infinite-loop).
result = await execute_tool(
user_id,
row.action_type,
row.payload,
conv_id=row.conv_id,
authority="user",
)
except Exception as e:
logger.exception(
"Replay of curator action %d (%s) failed",
action_id, row.action_type,
)
return {"success": False, "error": f"{type(e).__name__}: {e}"}
if result.get("success", True) and not result.get("error"):
await _mark_reviewed(action_id, "approved")
return result
async def reject(action_id: int, user_id: int) -> dict[str, Any]:
"""Mark the pending action as rejected without executing anything."""
row = await _load_for_user(action_id, user_id)
if row is None:
return {"success": False, "error": "Pending action not found"}
if row.status != "pending":
return {
"success": False,
"error": f"Action already {row.status}",
}
await _mark_reviewed(action_id, "rejected")
return {"success": True, "id": action_id, "status": "rejected"}
-85
View File
@@ -71,7 +71,6 @@ async def list_projects(user_id: int, status: str | None = None) -> list[Project
async def update_project(user_id: int, project_id: int, **fields: object) -> Project | None:
import asyncio
async with async_session() as session:
result = await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
@@ -85,93 +84,9 @@ async def update_project(user_id: int, project_id: int, **fields: object) -> Pro
project.updated_at = datetime.now(timezone.utc)
await session.commit()
await session.refresh(project)
asyncio.create_task(generate_project_summary(user_id, project_id))
return project
async def generate_project_summary(user_id: int, project_id: int) -> None:
"""Generate an LLM summary for a project and persist it. Fire-and-forget safe."""
try:
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id, Project.user_id == user_id)
)).scalars().first()
if project is None:
return
note_rows = (await session.execute(
select(Note.title, Note.body)
.where(Note.project_id == project_id, Note.user_id == user_id)
.order_by(Note.updated_at.desc())
.limit(10)
)).all()
title = project.title or ""
description = project.description or ""
goal = project.goal or ""
note_snippets = "\n".join(
f"- {r.title}: {(r.body or '')[:200]}" for r in note_rows
)
prompt = (
f"Summarize this project in 3-4 sentences covering its purpose, themes, and content.\n"
f"Title: {title}\nDescription: {description}\nGoal: {goal}\n"
f"Recent notes:\n{note_snippets}"
)
from fabledassistant.services.llm import generate_completion
from fabledassistant.config import Config
from fabledassistant.services.settings import get_setting
messages = [{"role": "user", "content": prompt}]
bg_model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL)
summary = await generate_completion(messages, model=bg_model, max_tokens=400, num_ctx=2048)
if not summary:
return
async with async_session() as session:
project = (await session.execute(
select(Project).where(Project.id == project_id)
)).scalars().first()
if project:
project.auto_summary = summary.strip()
project.summary_updated_at = datetime.now(timezone.utc)
await session.commit()
logger.debug("Generated summary for project %d", project_id)
except Exception:
logger.debug("Failed to generate summary for project %d", project_id, exc_info=True)
# Cutoff: summaries older than this were generated by qwen2.5:3b, which
# produced broken output (token repetition, hallucinated topics, misspellings).
# Anything stamped before the switch to gemma3:4b on 2026-04-12 gets
# regenerated at startup so the whole project list ends up on the better model.
_BACKGROUND_MODEL_CUTOVER = datetime(2026, 4, 12, tzinfo=timezone.utc)
async def backfill_project_summaries() -> None:
"""Generate summaries for projects missing or predating the gemma3:4b cutover.
Fire-and-forget: each summary runs as its own background task.
"""
import asyncio
from sqlalchemy import or_
try:
async with async_session() as session:
rows = (await session.execute(
select(Project.id, Project.user_id).where(
or_(
Project.auto_summary.is_(None),
Project.summary_updated_at.is_(None),
Project.summary_updated_at < _BACKGROUND_MODEL_CUTOVER,
)
)
)).all()
for row in rows:
asyncio.create_task(generate_project_summary(row.user_id, row.id))
except Exception:
logger.debug("backfill_project_summaries failed", exc_info=True)
async def delete_project(user_id: int, project_id: int) -> bool:
async with async_session() as session:
result = await session.execute(
-238
View File
@@ -1,238 +0,0 @@
"""Browser push notification service using VAPID/pywebpush."""
import asyncio
import base64
import json
import logging
from datetime import datetime, timezone
from functools import lru_cache
from pathlib import Path
from sqlalchemy import delete, select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.push_subscription import PushSubscription
logger = logging.getLogger(__name__)
# Persisted alongside other app data so keys survive container restarts.
_VAPID_KEYS_FILE = Path(Config.IMAGE_CACHE_DIR).parent / "vapid_keys.json"
@lru_cache(maxsize=1)
def _get_webpush():
"""Lazy import to avoid startup errors if pywebpush is not installed."""
try:
from pywebpush import webpush
return webpush
except ImportError:
return None
def vapid_enabled() -> bool:
return bool(Config.VAPID_PRIVATE_KEY and Config.VAPID_PUBLIC_KEY)
def ensure_vapid_keys() -> None:
"""Load or auto-generate VAPID keys, storing them in the data volume.
Called once at startup. If VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY env vars
are already set they take precedence and nothing is written to disk.
"""
if vapid_enabled():
logger.info("VAPID keys loaded from environment variables")
return
# Try to load previously generated keys from the data volume.
if _VAPID_KEYS_FILE.exists():
try:
data = json.loads(_VAPID_KEYS_FILE.read_text())
Config.VAPID_PRIVATE_KEY = data["private_key"]
Config.VAPID_PUBLIC_KEY = data["public_key"]
logger.info("VAPID keys loaded from %s", _VAPID_KEYS_FILE)
return
except Exception:
logger.warning("Failed to load VAPID keys from %s — regenerating", _VAPID_KEYS_FILE, exc_info=True)
# Generate a fresh key pair.
try:
from cryptography.hazmat.primitives import serialization
from py_vapid import Vapid01
v = Vapid01()
v.generate_keys()
# pywebpush expects the private key as a base64url-encoded DER blob
# (passed to Vapid.from_string → from_der), NOT a PEM string.
private_der = v.private_key.private_bytes(
serialization.Encoding.DER,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
)
private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
pub_bytes = v.public_key.public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
public_b64 = base64.urlsafe_b64encode(pub_bytes).rstrip(b"=").decode()
# Persist so they survive container restarts.
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
_VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64}))
Config.VAPID_PRIVATE_KEY = private_b64
Config.VAPID_PUBLIC_KEY = public_b64
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
except Exception:
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
async def regenerate_vapid_keys() -> bool:
"""Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair.
All existing browser subscriptions are invalidated when keys rotate, so they
must be cleared — users will need to re-enable notifications.
"""
if _VAPID_KEYS_FILE.exists():
_VAPID_KEYS_FILE.unlink()
Config.VAPID_PRIVATE_KEY = ""
Config.VAPID_PUBLIC_KEY = ""
# Clear all push subscriptions — they are bound to the old public key.
async with async_session() as session:
await session.execute(delete(PushSubscription))
await session.commit()
ensure_vapid_keys()
enabled = vapid_enabled()
if enabled:
logger.info("VAPID keys regenerated successfully")
else:
logger.error("VAPID key regeneration failed")
return enabled
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")
keys = subscription_json.get("keys", {})
p256dh = keys.get("p256dh", "")
auth = keys.get("auth", "")
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
existing = result.scalars().first()
if existing:
existing.p256dh = p256dh
existing.auth = auth
existing.user_agent = user_agent
existing.last_used = datetime.now(timezone.utc)
await session.commit()
await session.refresh(existing)
return existing
else:
sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
user_agent=user_agent,
)
session.add(sub)
await session.commit()
await session.refresh(sub)
return sub
async def delete_subscription(user_id: int, endpoint: str) -> None:
async with async_session() as session:
await session.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id,
PushSubscription.endpoint == endpoint,
)
)
await session.commit()
async def _remove_expired_subscription(user_id: int, endpoint: str) -> None:
"""Remove a subscription that returned 410 Gone."""
try:
await delete_subscription(user_id, endpoint)
logger.info("Removed expired push subscription for user %d", user_id)
except Exception:
logger.warning("Failed to remove expired subscription", exc_info=True)
async def send_push_notification(
user_id: int,
title: str,
body: str,
url: str = "/",
) -> None:
"""Send a push notification to all subscriptions for a user.
Fire-and-forget — wrap in asyncio.create_task() at call site.
"""
if not vapid_enabled():
logger.debug("VAPID not configured, skipping push notification")
return
webpush = _get_webpush()
if webpush is None:
logger.warning("pywebpush not installed, cannot send push notifications")
return
async with async_session() as session:
result = await session.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
if not subscriptions:
return
payload = json.dumps({"title": title, "body": body, "url": url})
vapid_claims = {
"sub": Config.VAPID_CLAIMS_SUB,
}
def _send_sync(sub: PushSubscription) -> tuple[int, str]:
"""Synchronous send — runs in executor."""
try:
subscription_info = {
"endpoint": sub.endpoint,
"keys": {"p256dh": sub.p256dh, "auth": sub.auth},
}
response = webpush(
subscription_info=subscription_info,
data=payload,
vapid_private_key=Config.VAPID_PRIVATE_KEY,
vapid_claims=vapid_claims,
)
return response.status_code, sub.endpoint
except Exception as e:
logger.error("Push send failed for sub %d: %s", sub.id, e, exc_info=True)
return 0, sub.endpoint
loop = asyncio.get_event_loop()
tasks = [loop.run_in_executor(None, _send_sync, sub) for sub in subscriptions]
results = await asyncio.gather(*tasks, return_exceptions=True)
for sub, result in zip(subscriptions, results):
if isinstance(result, Exception):
logger.error("Push gather exception for user %d: %s", user_id, result, exc_info=result)
continue
status_code, endpoint = result
if status_code in (200, 201):
logger.info("Push notification sent to sub %d (status %d)", sub.id, status_code)
elif status_code == 410:
asyncio.create_task(_remove_expired_subscription(user_id, endpoint))
elif status_code:
logger.error("Push returned unexpected status %d for user %d sub %d", status_code, user_id, sub.id)
-583
View File
@@ -1,583 +0,0 @@
"""Web research pipeline: sub-queries → SearXNG → fetch → synthesize → note."""
import asyncio
import json
import logging
import re
import httpx
from fabledassistant.config import Config
from fabledassistant.services.llm import fetch_url_content, generate_completion, stream_chat
from fabledassistant.services.notes import create_note, update_note
from fabledassistant.services.wikipedia import wiki_search
from fabledassistant.models.note import Note
logger = logging.getLogger(__name__)
SEARXNG_QUERIES = 5 # sub-queries to generate
RESULTS_PER_QUERY = 3 # results fetched from SearXNG per query
PAGES_PER_QUERY = 3 # pages actually read per sub-query (top N results)
MAX_SYNTHESIS_SOURCES = 12 # deduplicated sources passed to synthesis LLM
CHARS_PER_SOURCE = 2000 # content chars per source sent to synthesis
def _build_sources_block(sources: list[dict]) -> str:
"""Format fetched sources into a text block for LLM prompts."""
parts = []
for i, s in enumerate(sources, 1):
content = (s.get("content") or s.get("snippet") or "")[:CHARS_PER_SOURCE]
parts.append(
f"[Source {i}] {s['title']}\nURL: {s['url']}\nSearch query: {s['query']}\n\n{content}"
)
return "\n\n" + ("" * 60) + "\n\n".join(parts)
async def _generate_outline(topic: str, sources: list[dict], model: str) -> list[dict]:
"""Generate a topic outline from fetched research sources.
Returns a list of {"title": str, "focus": str} dicts (28 entries).
Retries once on failure before returning [] (callers fall back to single-note).
"""
import json as _json
sources_block = _build_sources_block(sources) if sources else "(no sources)"
messages = [
{
"role": "system",
"content": (
"You are a research organizer. Given research sources on a topic, produce a JSON array "
"of section objects that together cover the topic comprehensively from distinct angles.\n\n"
"Rules:\n"
"- Return exactly 37 sections\n"
"- Each section must cover a unique angle — no overlap between sections\n"
"- Titles must work as standalone note titles (specific, not generic like 'Overview')\n"
"- focus: one sentence describing exactly what this section covers\n"
"- Respond with ONLY a JSON array, no other text\n\n"
'Example: [{"title": "CRISPR: Molecular Mechanisms", "focus": "How Cas9 identifies and cuts DNA at guide-RNA-specified sites"}]'
),
},
{
"role": "user",
"content": f"Topic: {topic}\n\nSources:\n{sources_block}",
},
]
for attempt in range(2):
try:
# Pin num_ctx explicitly. The prompt carries up to 12 sources at
# 2000 chars each (~6k tokens of source material alone) plus the
# system prompt — well over Ollama's default model window on
# qwen3. Without this, Ollama silently truncates the prompt, the
# model can't see most of the sources, JSON parsing fails twice,
# and the pipeline falls back to a single monolith note
# (`research.py:251`). Do not remove even if `generate_completion`
# appears to default this — see the comment there.
raw = await generate_completion(
messages, model, max_tokens=400, num_ctx=16384
)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
idx = raw.find("[")
if idx >= 0:
parsed, _ = _json.JSONDecoder().raw_decode(raw[idx:])
if isinstance(parsed, list):
sections = [
s for s in parsed
if isinstance(s, dict) and s.get("title") and s.get("focus")
]
if len(sections) >= 2:
return sections[:8]
except Exception:
logger.warning(
"Outline generation attempt %d failed for topic '%s'",
attempt + 1, topic, exc_info=True,
)
return []
async def _synthesize_section(
section_title: str,
section_focus: str,
sources: list[dict],
model: str,
) -> tuple[str, str]:
"""Synthesize one focused note section.
Returns (section_title, body_markdown). Does not stream.
"""
sources_block = _build_sources_block(sources) if sources else "(no sources provided)"
messages = [
{
"role": "system",
"content": (
"You are a focused research writer. Write a single well-structured note section "
"on the specific topic provided.\n\n"
"Requirements:\n"
f"- Focus strictly on: {section_focus}\n"
"- 300600 words of substantive prose\n"
"- Use ### for subsections only when they genuinely aid clarity\n"
"- Do NOT include a top-level # heading — the title is set separately\n"
"- Write in detailed prose paragraphs — not bullet points\n"
"- End with a '## Sources' section listing relevant source URLs as markdown hyperlinks\n"
"- Ignore source material that falls outside your assigned focus"
),
},
{
"role": "user",
"content": (
f"Section title: {section_title}\n"
f"Focus: {section_focus}\n\n"
f"Sources:\n{sources_block}"
),
},
]
raw = await generate_completion(messages, model, max_tokens=2048, num_ctx=16384)
return section_title, raw.strip()
async def _generate_executive_summary(
topic: str,
section_bodies: list[tuple[str, str]],
model: str,
) -> str:
"""Generate a 2-3 paragraph executive summary from completed section notes.
Args:
section_bodies: list of (title, body) pairs from the section notes.
Returns summary markdown (no heading — caller adds structure).
"""
sections_block = "\n\n".join(
f"### {title}\n{body[:1500]}" for title, body in section_bodies
)
messages = [
{
"role": "system",
"content": (
"You are a research summarizer. Given several completed research sections on a topic, "
"write a concise executive summary.\n\n"
"Requirements:\n"
"- 23 paragraphs of substantive prose (150300 words total)\n"
"- Cover the key findings and insights across all sections\n"
"- Highlight the most important or surprising takeaways\n"
"- Write so someone can decide which sections to read in detail\n"
"- Do NOT include headings, bullet points, or source citations\n"
"- Do NOT start with 'This research' or 'This document' — jump straight into the content"
),
},
{
"role": "user",
"content": f"Topic: {topic}\n\nSections:\n{sections_block}",
},
]
try:
# Pin num_ctx explicitly — see `_generate_outline` comment for the
# rationale. This prompt carries N sections × 1500 chars of section
# prose, which can easily exceed the default model window. Don't
# trust the `generate_completion` default to stick.
raw = await generate_completion(
messages, model, max_tokens=600, num_ctx=16384
)
return raw.strip()
except Exception:
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
return ""
async def run_research_pipeline(
topic: str,
user_id: int,
model: str,
buf=None,
project_id: int | None = None,
) -> Note:
"""Full research pipeline: search → fetch → outline → section notes → index note.
Emits status events via buf throughout (when buf is provided).
Returns the index note (or a single fallback note on outline failure).
"""
def _status(msg: str) -> None:
if buf is not None:
buf.append_event("status", {"status": msg})
# Step 1: Generate sub-queries
_status("Generating search queries...")
queries = await _generate_sub_queries(topic, model)
logger.info("Research: generated %d sub-queries for topic '%s'", len(queries), topic)
# Step 2: Search all queries in parallel (SearXNG + Wikipedia)
async def _search_with_stagger(i: int, query: str) -> tuple[str, list[dict]]:
if i > 0:
await asyncio.sleep(0.2 * i)
_status(f"Searching: {query}...")
results = await _search_searxng(query)
logger.info("Research: query '%s'%d results", query, len(results))
return query, results
async def _wiki_for_query(query: str) -> list[dict]:
return await wiki_search(query, limit=1)
searxng_task = asyncio.gather(
*[_search_with_stagger(i, q) for i, q in enumerate(queries)]
)
wiki_task = asyncio.gather(
*[_wiki_for_query(q) for q in queries]
)
search_results, wiki_results = await asyncio.gather(searxng_task, wiki_task)
# Deduplicate URLs across all queries
seen_urls: set[str] = set()
url_tasks: list[tuple[str, dict, str]] = [] # (url, result_dict, query)
wiki_sources: list[dict] = [] # Wikipedia articles (already have content)
for query, results in search_results:
for result in results[:PAGES_PER_QUERY]:
url = result.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
url_tasks.append((url, result, query))
# Add Wikipedia results (they already have content via extract)
for query, wiki_hits in zip(queries, wiki_results):
for hit in wiki_hits:
url = hit.get("url", "")
if url and url not in seen_urls:
seen_urls.add(url)
wiki_sources.append({
"url": url,
"title": hit["title"],
"query": query,
"snippet": hit["extract"][:200],
"content": hit["extract"],
})
# Fetch all unique SearXNG URLs in parallel
async def _fetch_source(url: str, result: dict, query: str) -> dict:
title = result.get("title", url)
_status(f"Reading: {title[:60]}...")
content = await fetch_url_content(url)
return {
"url": url,
"title": title,
"query": query,
"snippet": result.get("snippet", ""),
"content": content,
}
fetched_sources: list[dict] = list(await asyncio.gather(
*[_fetch_source(url, result, query) for url, result, query in url_tasks]
))
all_sources = wiki_sources + fetched_sources
if not all_sources:
raise ValueError(f"No results found for '{topic}'")
good_sources = [s for s in all_sources if not s["content"].startswith("[Failed to fetch")]
if not good_sources:
raise ValueError(f"Could not read any sources for '{topic}'")
synthesis_sources = good_sources[:MAX_SYNTHESIS_SOURCES]
logger.info(
"Research: %d/%d sources successfully fetched, using %d for synthesis",
len(good_sources), len(all_sources), len(synthesis_sources),
)
# Step 3: Generate topic outline
_status("Generating outline...")
outline = await _generate_outline(topic, synthesis_sources, model)
# Fallback: outline failed or too short → single monolithic note
if not outline:
logger.warning("Research outline empty, falling back to single note for '%s'", topic)
_status("Synthesizing report...")
title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None)
note = await create_note(
user_id=user_id, title=title, body=body, tags=["research"], project_id=project_id,
)
logger.info("Research (fallback): created note id=%d title='%s'", note.id, note.title)
return note
# Step 4: Synthesize each section in parallel
for section in outline:
_status(f"Writing: {section['title']}...")
raw_results = await asyncio.gather(
*[_synthesize_section(s["title"], s["focus"], synthesis_sources, model) for s in outline],
return_exceptions=True,
)
# Collect successful results for index + summary generation
section_results: list[tuple[dict, str, str]] = [] # (outline_entry, title, body)
for section, result in zip(outline, raw_results):
if isinstance(result, Exception):
logger.warning("Section synthesis failed for '%s': %s", section["title"], result)
continue
sec_title, sec_body = result
section_results.append((section, sec_title, sec_body))
# All sections failed — fall back to single note
if not section_results:
logger.warning("All section syntheses failed, falling back to single note for '%s'", topic)
_status("Synthesizing report (fallback)...")
title, body = await _synthesize_note(topic, synthesis_sources, model, buf=None)
note = await create_note(
user_id=user_id, title=title, body=body, tags=["research"], project_id=project_id,
)
return note
# Step 5: Generate executive summary from section content
_status("Writing summary...")
executive_summary = await _generate_executive_summary(
topic, [(t, b) for _, t, b in section_results], model,
)
# Step 6: Create index note first (so section notes can reference it via parent_id)
from datetime import date as _date
index_lines = [
f"Research overview for **{topic}** — {_date.today().isoformat()}",
"",
f"Generated from {len(synthesis_sources)} web sources across {len(section_results)} sections.",
"",
]
if executive_summary:
index_lines += ["## Summary", "", executive_summary, ""]
index_lines += ["## Sections", ""]
# Placeholder — will be updated with real links after section notes are created
index_note = await create_note(
user_id=user_id,
title=f"Research: {topic}",
body="\n".join(index_lines),
tags=["research", "research-index"],
project_id=project_id,
)
# Step 7: Create section notes with parent_id pointing to index
_status(f"Saving {len(section_results)} notes...")
section_note_pairs: list[tuple[dict, Note]] = []
for section, sec_title, sec_body in section_results:
try:
note = await create_note(
user_id=user_id,
title=sec_title,
body=sec_body,
tags=["research"],
project_id=project_id,
parent_id=index_note.id,
)
section_note_pairs.append((section, note))
except Exception:
logger.warning("Failed to save section note '%s'", sec_title, exc_info=True)
# Step 8: Update index note body with real links to section notes
for section, note in section_note_pairs:
index_lines.append(f"- [{note.title}](/notes/{note.id}) — {section['focus']}")
await update_note(
user_id=user_id,
note_id=index_note.id,
body="\n".join(index_lines),
)
logger.info(
"Research: created %d section notes + index id=%d for topic '%s'",
len(section_note_pairs), index_note.id, topic,
)
return index_note
async def _generate_sub_queries(topic: str, model: str) -> list[str]:
"""Ask the model for focused search queries for the topic."""
messages = [
{
"role": "system",
"content": (
f"You are a research assistant. Given a research topic, generate exactly {SEARXNG_QUERIES} "
"focused web search queries that together would provide comprehensive coverage of the topic. "
"Vary the angle of each query: include overview, implementation details, best practices, "
"common problems, and real-world examples. "
"Respond with ONLY a JSON array of strings, no other text. "
'Example: ["query one", "query two", "query three"]'
),
},
{"role": "user", "content": f"Topic: {topic}"},
]
try:
raw = await generate_completion(messages, model, max_tokens=200)
raw = raw.strip()
raw = re.sub(r"^```(?:json)?\s*", "", raw)
raw = re.sub(r"\s*```$", "", raw)
idx = raw.find("[")
if idx >= 0:
parsed, _ = json.JSONDecoder().raw_decode(raw[idx:])
if isinstance(parsed, list) and parsed:
queries = [str(q).strip() for q in parsed if str(q).strip()]
if queries:
return queries[:SEARXNG_QUERIES]
except Exception:
logger.warning("Sub-query generation failed, falling back to topic", exc_info=True)
return [topic]
async def _search_searxng(query: str) -> list[dict]:
"""Search SearXNG and return top results as [{url, title, snippet}]."""
url = Config.SEARXNG_URL.rstrip("/") + "/search"
params = {"q": query, "format": "json", "categories": "general"}
for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "5"))
wait = min(retry_after, 10) * (attempt + 1)
logger.warning(
"SearXNG 429 for query '%s' (attempt %d/3), waiting %ds",
query, attempt + 1, wait,
)
await asyncio.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
results = data.get("results", [])
out = []
for r in results[:RESULTS_PER_QUERY]:
out.append({
"url": r.get("url", ""),
"title": r.get("title", ""),
"snippet": r.get("content", ""),
})
return out
except httpx.HTTPStatusError:
logger.warning("SearXNG search failed for query '%s'", query, exc_info=True)
return []
except Exception:
logger.warning("SearXNG search failed for query '%s'", query, exc_info=True)
return []
logger.warning("SearXNG search gave up after 3 attempts for query '%s'", query)
return []
async def _search_searxng_images(query: str) -> list[dict]:
"""Search SearXNG image category and return [{img_src, page_url, title, source_domain}]."""
url = Config.SEARXNG_URL.rstrip("/") + "/search"
params = {"q": query, "format": "json", "categories": "images"}
for attempt in range(3):
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.get(url, params=params)
if resp.status_code == 429:
retry_after = int(resp.headers.get("Retry-After", "5"))
wait = min(retry_after, 10) * (attempt + 1)
logger.warning(
"SearXNG image 429 for '%s' (attempt %d/3), waiting %ds",
query, attempt + 1, wait,
)
await asyncio.sleep(wait)
continue
resp.raise_for_status()
data = resp.json()
out = []
for r in data.get("results", []):
img_src = r.get("img_src") or r.get("thumbnail_src", "")
if not img_src:
continue
try:
from urllib.parse import urlparse
source_domain = urlparse(r.get("url", "")).netloc or ""
except Exception:
source_domain = ""
out.append({
"img_src": img_src,
"page_url": r.get("url", ""),
"title": r.get("title", ""),
"source_domain": source_domain,
})
return out
except httpx.HTTPStatusError:
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
return []
except Exception:
logger.warning("SearXNG image search failed for '%s'", query, exc_info=True)
return []
logger.warning("SearXNG image search gave up after 3 attempts for '%s'", query)
return []
async def _synthesize_note(
topic: str,
sources: list[dict],
model: str,
buf=None,
) -> tuple[str, str]:
"""Synthesize a comprehensive markdown research document from fetched sources.
Returns (title, body_markdown).
When buf is provided, tokens are streamed into the chat buffer in real time
so the user can see the note being written. Uses an extended context window.
"""
sources_block = _build_sources_block(sources)
messages = [
{
"role": "system",
"content": (
"You are a thorough researcher and writer. "
"Your task is to write an exhaustive, well-structured document on the given topic — "
"not a brief summary or intro paragraph.\n\n"
"Requirements:\n"
"- Write at least 2500 words of substantive content (excluding the Sources section)\n"
"- Choose sections (##) that make sense for the topic — let the subject matter determine the structure. "
"A technical topic might need implementation, configuration, and troubleshooting sections. "
"A comparison topic might need dedicated sections per subject being compared plus a summary. "
"A scientific topic might need background, mechanisms, research findings, and implications. "
"Use your judgment — minimum 6 major sections.\n"
"- Use ### for subsections where they add clarity\n"
"- Write in detailed prose paragraphs — do not reduce sections to bullet-point lists\n"
"- Include specific details, examples, data points, comparisons, and nuance from the sources\n"
"- Do not pad with vague generalities — every paragraph should say something concrete\n"
"- The first line must be the document title starting with '# '\n"
"- End with a '## Sources' section listing every source as a markdown hyperlink\n\n"
"The reader wants to finish this document with a thorough understanding of the topic, "
"not just an overview."
),
},
{
"role": "user",
"content": (
f"Write a comprehensive reference document on: {topic}\n\n"
f"Sources ({len(sources)} pages fetched):\n{sources_block}"
),
},
]
if buf is not None:
# Stream tokens into the chat buffer so the user sees the note being written
raw_parts: list[str] = []
async for token in stream_chat(
messages, model, options={"num_ctx": 16384, "num_predict": 8192}
):
raw_parts.append(token)
buf.append_event("chunk", {"chunk": token})
buf.content_so_far += token
raw = "".join(raw_parts).strip()
else:
raw = await generate_completion(
messages,
model,
max_tokens=8192,
num_ctx=16384,
)
raw = raw.strip()
# Extract title from first # heading
lines = raw.splitlines()
title = f"Research: {topic}"
body_lines = lines
if lines and lines[0].startswith("# "):
title = lines[0][2:].strip()
body_lines = lines[1:]
body = "\n".join(body_lines).strip()
return title, body
-90
View File
@@ -1,90 +0,0 @@
"""Speech-to-text service using faster-whisper (in-process, CPU/GPU)."""
import asyncio
import logging
import tempfile
import time
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from faster_whisper import WhisperModel
logger = logging.getLogger(__name__)
_model: "WhisperModel | None" = None
_model_lock = asyncio.Lock()
_load_error: str | None = None
async def load_stt_model() -> None:
"""Load the Whisper model. Called once at startup when voice is enabled."""
global _model, _load_error
from fabledassistant.services.voice_config import get_stt_model, is_voice_enabled
if not await is_voice_enabled():
return
async with _model_lock:
if _model is not None:
return
try:
from faster_whisper import WhisperModel
model_name = await get_stt_model()
logger.info("Loading Whisper STT model '%s'...", model_name)
loop = asyncio.get_running_loop()
_model = await loop.run_in_executor(
None,
lambda: WhisperModel(model_name, device="cpu", compute_type="int8"),
)
logger.info("Whisper STT model '%s' loaded", model_name)
except Exception:
_load_error = "Failed to load Whisper STT model"
logger.exception(_load_error)
async def reload_stt_model() -> None:
"""Unload the current model and reload with the current config. Safe to call at runtime."""
global _model, _load_error
async with _model_lock:
_model = None
_load_error = None
await load_stt_model()
def stt_available() -> bool:
return _model is not None
async def transcribe(audio_bytes: bytes, mime_type: str = "audio/webm", initial_prompt: str | None = None) -> str:
"""Transcribe audio bytes to text. Runs the model in a thread executor.
initial_prompt: optional text to bias the model toward domain-specific vocabulary
(e.g. recent conversation context). Reduces mishearings like "gold" for "cold".
"""
if _model is None:
raise RuntimeError("STT model not loaded")
suffix = ".webm"
if "ogg" in mime_type:
suffix = ".ogg"
elif "wav" in mime_type:
suffix = ".wav"
elif "mp4" in mime_type or "m4a" in mime_type:
suffix = ".mp4"
def _run() -> str:
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as f:
f.write(audio_bytes)
f.flush()
t0 = time.monotonic()
segments, _ = _model.transcribe( # type: ignore[union-attr]
f.name,
beam_size=5,
initial_prompt=initial_prompt or None,
)
text = " ".join(seg.text.strip() for seg in segments).strip()
logger.debug("STT transcription took %.2fs", time.monotonic() - t0)
return text
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _run)
@@ -1,96 +0,0 @@
"""LLM-powered tag suggestions for notes and tasks."""
import json
import logging
import re
from fabledassistant.config import Config
from fabledassistant.services.llm import generate_completion
from fabledassistant.services.notes import get_all_tags
from fabledassistant.services.settings import get_setting
logger = logging.getLogger(__name__)
async def suggest_tags(user_id: int, title: str, body: str, current_tags: list[str] | None = None) -> list[str]:
"""Suggest relevant tags for a note/task based on its content.
Returns a list of tag strings (without # prefix), excluding tags
already present in current_tags.
"""
if not title.strip() and not body.strip():
return []
existing_tags = await get_all_tags(user_id)
# Tag suggestion is a tiny single-shot task (3-5 tags from a note's
# title + body) — route to the chat model (small/fast) rather than
# tying up the worker on a trivial call. The chat model handles this
# in well under a second.
model = (
await get_setting(user_id, "default_model", "")
or Config.OLLAMA_MODEL
)
existing_list = ", ".join(f"#{t}" for t in existing_tags) if existing_tags else "(none yet)"
messages = [
{
"role": "system",
"content": (
"You are a tag suggestion assistant. Given a note's title and body, "
"suggest 3-5 relevant tags. Prefer reusing existing tags when they fit, "
"but you may suggest new ones too. "
"Tags must use hyphens for multi-word names (e.g. science-fiction, space-travel) — never spaces. "
"Reply with ONLY a JSON array of tag strings (without # prefix). "
'Example: ["meeting", "project/alpha", "science-fiction"]\n\n'
f"Existing tags: {existing_list}"
),
},
{
"role": "user",
"content": f"Title: {title}\n\nBody:\n{body[:2000]}",
},
]
try:
response = await generate_completion(messages, model)
except Exception:
logger.warning("Tag suggestion LLM call failed", exc_info=True)
return []
tags = _parse_tag_list(response)
# Filter out tags already applied
existing = set(current_tags or [])
tags = [t for t in tags if t not in existing]
return tags[:5]
def _parse_tag_list(response: str) -> list[str]:
"""Parse a JSON array of tags from LLM response, with fallback for markdown wrapping."""
text = response.strip()
# Try direct JSON parse
def _clean(tag: str) -> str:
return str(tag).strip().lstrip("#").replace(" ", "-")
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return [_clean(t) for t in parsed if t]
except json.JSONDecodeError:
pass
# Fallback: extract JSON array from markdown code block
match = re.search(r"\[.*?\]", text, re.DOTALL)
if match:
try:
parsed = json.loads(match.group())
if isinstance(parsed, list):
return [_clean(t) for t in parsed if t]
except json.JSONDecodeError:
pass
logger.warning("Could not parse tag suggestions from LLM response: %s", text[:200])
return []
@@ -1,32 +0,0 @@
"""Tool registry package.
Importing this package loads all tool modules (triggering ``@tool``
decorator registration) and re-exports the public API that the rest
of the app depends on.
"""
# Import every tool module so their @tool decorators run at import time.
# Order does not matter — registration is additive.
from fabledassistant.services.tools import ( # noqa: F401
article,
calendar,
entities,
journal,
notes,
profile,
projects,
rag,
tasks,
utility,
weather,
web,
)
from fabledassistant.services.tools._registry import (
execute_tool,
get_tools_for_user,
)
__all__ = [
"execute_tool",
"get_tools_for_user",
]
@@ -1,164 +0,0 @@
"""Shared utilities used across tool modules."""
from __future__ import annotations
import asyncio
import logging
import re
from datetime import date, datetime
from difflib import SequenceMatcher
logger = logging.getLogger(__name__)
_PUNCT_RE = re.compile(r"[^\w\s]")
def schedule_embedding(note_id: int, user_id: int, title: str, body: str) -> None:
"""Fire-and-forget: update the embedding for a note after it's created/modified."""
from fabledassistant.services.embeddings import upsert_note_embedding
text = f"{title}\n{body}".strip() if body else (title or "")
if text:
asyncio.create_task(upsert_note_embedding(note_id, user_id, text))
_PROJECT_QUERY_NOISE = {"project", "projects"}
def _normalize(s: str) -> str:
"""Lowercase and collapse non-alphanumerics to single spaces."""
return re.sub(r"[^a-z0-9]+", " ", s.lower()).strip()
def _normalize_query(query: str) -> str:
"""Normalize plus drop trailing type-nouns ('project' / 'projects')
that users add as filler when referring to a project by name."""
tokens = [t for t in _normalize(query).split() if t not in _PROJECT_QUERY_NOISE]
return " ".join(tokens)
def score_project_match(query: str, project) -> float:
"""Score how well `query` matches `project`. Range [0.0, 1.0].
Tiered: exact title → 1.0, substring either-way → 0.85, query found in
description/summary → 0.70, otherwise SequenceMatcher ratio against the
title. Substring tiers exist because LLM-generated colloquial queries
(e.g. "famous supply project" for "Famous-Supply Work topics") would
otherwise score too low under pure SequenceMatcher and be treated as
no match. Filler words like "project" are stripped from the query so
the substring check still fires.
"""
q = _normalize_query(query)
if not q:
return 0.0
title = _normalize(project.title)
description = _normalize(project.description or "")
summary = _normalize(project.auto_summary or "")
combined = f"{title} {description} {summary}".strip()
if q == title:
return 1.0
if q in title or title in q:
return 0.85
if q in combined:
return 0.70
# SequenceMatcher against the title — comparing against `combined`
# dilutes the ratio with long description/summary text and produces
# uniformly low scores even for plausible matches.
return SequenceMatcher(None, q, title).ratio()
async def resolve_project(user_id: int, project_name: str):
"""Exact-then-scored project lookup. Returns the Project or None.
Resolution order:
1. Exact title match (case-insensitive via DB query).
2. Highest `score_project_match` across all projects, threshold 0.55.
"""
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
all_p = await list_projects(user_id)
best, best_score = None, 0.0
for p in all_p:
score = score_project_match(project_name, p)
if score >= 0.55 and score > best_score:
best, best_score = p, score
return best
def parse_due_date(value: str | None) -> date | None:
"""Parse a due date string, returning None on failure."""
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (ValueError, TypeError):
logger.warning("Invalid due_date format: %s", value)
return None
def fuzzy_title_match(title: str, candidates, threshold: float = 0.82):
"""Return (best_match, ratio) if any candidate's title is similar enough.
Uses SequenceMatcher ratio. Threshold 0.82 catches near-duplicates like
"Game Premise" / "Game Premise Notes" while leaving clearly different
titles alone. Returns (None, 0.0) when no candidate meets the threshold.
"""
needle = title.lower().strip()
best, best_r = None, 0.0
for c in candidates:
r = SequenceMatcher(None, needle, c.title.lower().strip()).ratio()
if r >= threshold and r > best_r:
best, best_r = c, r
return best, best_r
async def check_duplicate(
user_id: int,
title: str,
body: str,
is_task: bool,
confirmed: bool,
) -> dict | None:
"""Check for exact, fuzzy, and semantic duplicates. Returns error dict or None."""
from fabledassistant.services.notes import list_notes
item_label = "task" if is_task else "note"
existing, _ = await list_notes(user_id=user_id, q=title, is_task=is_task, limit=1)
exact = next((n for n in existing if n.title.lower() == title.lower()), None)
if exact is not None:
return {
"success": False,
"error": f"A {item_label} titled '{title}' already exists (id: {exact.id}). Use update_note to modify it instead of creating a duplicate.",
}
clean_q = _PUNCT_RE.sub(" ", title).strip()
candidates, _ = await list_notes(user_id=user_id, q=clean_q, is_task=is_task, limit=20)
near, ratio = fuzzy_title_match(title, candidates)
if near is not None:
return {
"success": False,
"requires_confirmation": True,
"similar_note": {"id": near.id, "title": near.title},
"error": f"A {item_label} with a very similar title '{near.title}' already exists (similarity: {ratio:.0%}). Ask the user to confirm before creating a separate entry.",
}
if not confirmed and len(body.strip()) >= 80:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_query = f"{title}\n{body}".strip()
sem_hits = await _ssn(user_id, sem_query, limit=3, threshold=0.90, is_task=is_task)
if sem_hits:
best_score, best_note = sem_hits[0]
return {
"success": False,
"requires_confirmation": True,
"similar_note": {"id": best_note.id, "title": best_note.title},
"error": f"A {item_label} with very similar content exists: '{best_note.title}' (semantic similarity: {best_score:.0%}). Ask the user to confirm before creating a separate entry.",
}
return None
@@ -1,339 +0,0 @@
"""Decorator-based tool registry.
Each tool is registered via ``@tool(...)`` which captures the OpenAI-style
function schema and handler in one place. ``get_tools_for_user`` and
``execute_tool`` are the public API consumed by the rest of the app.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from typing import Any, Callable, Coroutine
from fabledassistant.config import Config
from fabledassistant.services.caldav import is_caldav_configured
logger = logging.getLogger(__name__)
type ToolHandler = Callable[..., Coroutine[Any, Any, dict]]
@dataclass(frozen=True, slots=True)
class ToolDef:
name: str
description: str
parameters: dict
handler: ToolHandler
read_only: bool = False
briefing: bool = False
journal: bool = False
requires: str | None = None
required_params: list[str] = field(default_factory=list)
def schema(self) -> dict:
"""Return the OpenAI function-calling schema dict."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": self.parameters,
**({"required": self.required_params} if self.required_params else {}),
},
},
}
_REGISTRY: dict[str, ToolDef] = {}
def tool(
*,
name: str,
description: str,
parameters: dict | None = None,
required: list[str] | None = None,
read_only: bool = False,
briefing: bool = False,
journal: bool = False,
requires: str | None = None,
) -> Callable[[ToolHandler], ToolHandler]:
"""Register an async tool handler with its schema metadata."""
def decorator(fn: ToolHandler) -> ToolHandler:
if name in _REGISTRY:
raise ValueError(f"Duplicate tool registration: {name}")
_REGISTRY[name] = ToolDef(
name=name,
description=description,
parameters=parameters or {},
handler=fn,
read_only=read_only,
briefing=briefing,
journal=journal,
requires=requires,
required_params=required or [],
)
return fn
return decorator
async def _check_requires(user_id: int, requires: str) -> bool:
if requires == "caldav":
return await is_caldav_configured(user_id)
if requires == "searxng":
return Config.searxng_enabled()
return True
async def get_tools_for_user(
user_id: int, *, conversation_type: str = "chat"
) -> list[dict]:
"""Build the tool schema list for a user, scoped by conversation type.
- 'chat': all tools except those marked journal-only.
- 'journal': all tools except set_rag_scope (scope is implicit in journal).
"""
tools: list[dict] = []
for td in _REGISTRY.values():
if td.requires and not await _check_requires(user_id, td.requires):
continue
if conversation_type == "journal":
if td.name == "set_rag_scope":
continue
else:
if td.journal:
continue
tools.append(td.schema())
logger.debug(
"User %d / %s: %d tools available", user_id, conversation_type, len(tools)
)
return tools
# Mutating tools that the curator must NOT execute directly. When
# `execute_tool` is called with `authority="curator"`, calls to these
# tools are intercepted and routed to the pending-actions queue for
# user review (services/pending_actions.create_pending). The user
# approves or rejects each from the journal's Needs Review panel.
#
# Why this set in particular: confidently-wrong updates / deletes
# corrupt user data in ways that creates don't. Bad creates are
# deletable; bad updates aren't. The curator stays additive by
# default; mutating ops only land via explicit user approval.
_CURATOR_MUTATING_TOOLS: frozenset[str] = frozenset({
"update_note", # covers tasks AND notes (same Note model)
"update_milestone",
"update_project",
"update_profile",
"delete_note",
# update_event / delete_event intentionally excluded — calendar
# events should always be explicit user intent, never curator
# inference. The curator simply doesn't get to touch the calendar.
})
async def execute_tool(
user_id: int,
tool_name: str,
arguments: dict,
conv_id: int | None = None,
workspace_project_id: int | None = None,
*,
authority: str = "user",
) -> dict:
"""Execute a tool call and return the result.
`authority` controls the curator-interceptor behavior:
- "user" (default): normal execution. Used by the chat route, journal
curator-trigger route, MCP, and the pending-actions replay path.
- "curator": mutating tools listed in `_CURATOR_MUTATING_TOOLS` are
intercepted and queued via services.pending_actions.create_pending.
Non-mutating tools (create_*, search_*, list_*, record_moment,
log_work, etc.) execute as normal.
The default "user" preserves all existing callers without changes.
"""
td = _REGISTRY.get(tool_name)
if td is None:
return {"success": False, "error": f"Unknown tool: {tool_name}"}
if authority == "curator" and tool_name in _CURATOR_MUTATING_TOOLS:
return await _queue_for_review(user_id, conv_id, tool_name, arguments)
try:
return await td.handler(
user_id=user_id,
arguments=arguments,
conv_id=conv_id,
workspace_project_id=workspace_project_id,
)
except Exception as e:
logger.exception("Tool execution failed: %s", tool_name)
return {"success": False, "error": str(e)}
async def _queue_for_review(
user_id: int, conv_id: int | None, tool_name: str, arguments: dict,
) -> dict:
"""Capture a snapshot of the target entity and write a pending action.
The interceptor never raises — if snapshot capture fails for any
reason, we still queue the action with an empty snapshot rather than
silently dropping the curator's proposal. The user can still see and
approve/reject it; the diff display just won't show a real "before."
"""
from fabledassistant.services.pending_actions import create_pending
target_type: str | None = None
target_id: int | None = None
target_label: str | None = None
snapshot: dict = {}
try:
helper = _SNAPSHOT_HELPERS.get(tool_name)
if helper is not None:
target_type, target_id, target_label, snapshot = await helper(
user_id, arguments,
)
except Exception:
logger.exception(
"Snapshot capture failed for curator-proposed %s (user=%d); "
"queuing with empty snapshot",
tool_name, user_id,
)
row = await create_pending(
user_id=user_id,
conv_id=conv_id,
action_type=tool_name,
target_type=target_type,
target_id=target_id,
target_label=target_label,
payload=dict(arguments or {}),
current_snapshot=snapshot or {},
)
return {
"success": True,
"pending": True,
"action_id": row.id,
"message": (
f"Proposed {tool_name} queued for review (action #{row.id}). "
"User will approve or reject from the journal."
),
}
# Snapshot helpers — each fetches the current state of the target entity
# for the diff display in the Needs Review panel. Lazy-imported because
# the registry module is otherwise dep-free of model/service code.
async def _snapshot_note(
user_id: int, arguments: dict,
) -> tuple[str | None, int | None, str | None, dict]:
"""Resolve update_note / delete_note's target — same fuzzy match
update_note_tool uses, so the snapshot reflects what'd actually be
mutated on approval.
"""
from fabledassistant.services.notes import get_note_by_title, list_notes
query = str(arguments.get("query") or "").strip()
if not query:
return None, None, None, {}
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
note = notes[0] if notes else None
if note is None:
return "note", None, query, {}
snapshot = {
"id": note.id,
"title": note.title,
"is_task": bool(note.is_task),
"status": note.status,
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"tags": list(note.tags or []),
"body": (note.body or "")[:500], # cap — diffs only show short context
"description": (note.description or "")[:500] if hasattr(note, "description") else None,
"project_id": note.project_id,
"milestone_id": note.milestone_id,
}
target_type = "task" if note.is_task else "note"
return target_type, note.id, note.title, snapshot
async def _snapshot_milestone(
user_id: int, arguments: dict,
) -> tuple[str | None, int | None, str | None, dict]:
from fabledassistant.services.milestones import find_milestone_by_title
name = str(arguments.get("milestone") or arguments.get("title") or "").strip()
if not name:
return None, None, None, {}
ms = await find_milestone_by_title(user_id, name)
if ms is None:
return "milestone", None, name, {}
snapshot = {
"id": ms.id,
"title": ms.title,
"description": (ms.description or "")[:500],
"status": ms.status,
"project_id": ms.project_id,
}
return "milestone", ms.id, ms.title, snapshot
async def _snapshot_project(
user_id: int, arguments: dict,
) -> tuple[str | None, int | None, str | None, dict]:
from fabledassistant.services.tools._helpers import resolve_project
name = str(arguments.get("project") or arguments.get("title") or "").strip()
if not name:
return None, None, None, {}
proj = await resolve_project(user_id, name)
if proj is None:
return "project", None, name, {}
snapshot = {
"id": proj.id,
"title": proj.title,
"description": (proj.description or "")[:500],
"goal": (proj.goal or "")[:500],
"status": proj.status,
}
return "project", proj.id, proj.title, snapshot
async def _snapshot_profile(
user_id: int, _arguments: dict,
) -> tuple[str | None, int | None, str | None, dict]:
from fabledassistant.services.user_profile import get_profile
profile = await get_profile(user_id)
if profile is None:
return "profile", user_id, None, {}
snapshot = {
"display_name": profile.display_name,
"job_title": profile.job_title,
"industry": profile.industry,
"expertise_level": profile.expertise_level,
"response_style": profile.response_style,
"tone": profile.tone,
"interests": list(profile.interests or []),
}
return "profile", user_id, profile.display_name or "your profile", snapshot
_SNAPSHOT_HELPERS = {
"update_note": _snapshot_note,
"delete_note": _snapshot_note,
"update_milestone": _snapshot_milestone,
"update_project": _snapshot_project,
"update_profile": _snapshot_profile,
}
@@ -1,35 +0,0 @@
"""Generic article-reading LLM tool.
The ``read_article`` tool fetches any URL and returns its main body text via
trafilatura. URL-generic — not coupled to any feed system.
"""
from __future__ import annotations
from fabledassistant.services.article_fetcher import fetch_article_text
from fabledassistant.services.tools._registry import tool
@tool(
name="read_article",
description=(
"Fetch the main body text of an article at a URL. Use when the user asks "
"to read, summarize, or discuss a specific article they've linked. "
"Returns the cleaned article text or an empty result if extraction fails."
),
parameters={
"url": {
"type": "string",
"description": "The article URL to fetch.",
},
},
required=["url"],
read_only=True,
)
async def read_article_tool(*, user_id, arguments, **_ctx):
url = (arguments.get("url") or "").strip()
if not url:
return {"success": False, "error": "url is required"}
content = await fetch_article_text(url)
if not content:
return {"success": True, "type": "article", "data": {"url": url, "content": None, "note": "no content extracted"}}
return {"success": True, "type": "article", "data": {"url": url, "content": content[:6000]}}
@@ -1,503 +0,0 @@
"""Calendar and event tools."""
from __future__ import annotations
import re
from datetime import date as _date, datetime, time as _time, timezone
from fabledassistant.services.events import (
create_event as events_create_event,
delete_event as events_delete_event,
find_events_by_query,
list_events as events_list_events,
search_events as events_search_events,
update_event as events_update_event,
)
from fabledassistant.services.tools._helpers import resolve_project
from fabledassistant.services.tools._registry import tool
from fabledassistant.services.tz import get_user_tz
_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
_TIME_RE = re.compile(r"^\d{2}:\d{2}(:\d{2})?$")
async def _combine_local_in_user_tz(
user_id: int, date_str: str, time_str: str | None
) -> datetime:
"""Build a UTC datetime from separate date and time strings.
The whole point of this helper: a `YYYY-MM-DD` string carries no TZ
metadata that a model could mis-tag, so the calendar day cannot drift
across the local→UTC boundary. The wall-clock `HH:MM` likewise has no
TZ; we attach the user's local zone explicitly via ``datetime.combine``
and then convert to UTC for storage.
Strict shape validation rejects anything that isn't a bare date or a
bare time — no `2026-05-01Z`, no `08:00 UTC` slipping through.
"""
if not _DATE_RE.match(date_str):
raise ValueError(
f"start_date / end_date must be YYYY-MM-DD with no timezone; got {date_str!r}"
)
d = _date.fromisoformat(date_str)
if time_str is None or time_str == "":
t = _time(0, 0)
else:
if not _TIME_RE.match(time_str):
raise ValueError(
f"start_time / end_time must be HH:MM (or HH:MM:SS), no timezone; got {time_str!r}"
)
t = _time.fromisoformat(time_str)
user_tz = await get_user_tz(user_id)
local = datetime.combine(d, t, tzinfo=user_tz)
return local.astimezone(timezone.utc)
async def _parse_datetime_in_user_tz(
user_id: int, value: str
) -> tuple[datetime, bool]:
"""Legacy single-string parser. Kept as a fallback when the model
emits the older `start` / `end` shape; new calls should use
`start_date`+`start_time` (and `end_date`+`end_time`) which sidestep
the TZ-tagging foot-gun this parser is vulnerable to.
Naive inputs are interpreted in the **user's local timezone** and then
converted to UTC for storage. Never default to UTC for naive inputs —
that's how all-day events landed on the wrong day for non-UTC users.
Returns ``(utc_datetime, was_date_only)``.
"""
was_date_only = "T" not in value and " " not in value
if was_date_only:
value = f"{value}T00:00:00"
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
if dt.tzinfo is None:
user_tz = await get_user_tz(user_id)
dt = dt.replace(tzinfo=user_tz)
return dt.astimezone(timezone.utc), was_date_only
async def _resolve_event_start(
user_id: int, args: dict
) -> tuple[datetime, bool]:
"""Resolve the start datetime from either the new split fields
(`start_date` + optional `start_time`) or the legacy combined `start`.
Returns ``(utc_datetime, was_date_only)``."""
if "start_date" in args and args["start_date"]:
date_str = args["start_date"]
time_str = args.get("start_time") or None
return await _combine_local_in_user_tz(user_id, date_str, time_str), time_str is None
if "start" in args and args["start"]:
return await _parse_datetime_in_user_tz(user_id, args["start"])
raise ValueError("Either start_date or start is required")
async def _resolve_event_end(
user_id: int, args: dict
) -> datetime | None:
"""Resolve the end datetime from either the new split fields or the
legacy combined `end`. Returns ``None`` when no end fields are set."""
if "end_date" in args and args["end_date"]:
date_str = args["end_date"]
time_str = args.get("end_time") or None
return await _combine_local_in_user_tz(user_id, date_str, time_str)
if "end" in args and args["end"]:
dt, _ = await _parse_datetime_in_user_tz(user_id, args["end"])
return dt
return None
def _candidate_summary(event) -> dict:
"""Compact event summary used in ambiguous-match responses.
Keeps the candidate list small so the model can disambiguate from
the same turn without bloating context. Includes id (for the
follow-up call), title, start_dt, and location when present.
"""
return {
"id": event.id,
"title": event.title,
"start_dt": event.start_dt.isoformat() if event.start_dt else None,
"location": event.location or None,
}
async def _resolve_event_for_action(
*, user_id: int, arguments: dict, action: str,
):
"""Pick the single event the model intends to update or delete.
Resolution rules:
- ``event_id`` in arguments → exact lookup (skip query). Used by
the model to disambiguate after a multi-match refusal.
- else ``query`` → ``find_events_by_query``:
- 0 results → return error tuple ("not_found", ...)
- 1 result → return that event
- 2+ results → return ("ambiguous", error, candidates) so the
caller can refuse the call and show candidates to the model.
Returns either an Event (success) or a 2- or 3-tuple of
``(error_kind, error_dict)`` for the caller to translate into a
tool-call response.
"""
from fabledassistant.services.events import get_event
event_id = arguments.get("event_id")
if event_id is not None:
try:
event_id_int = int(event_id)
except (TypeError, ValueError):
return ("invalid_id", {
"success": False,
"error": f"event_id must be an integer; got {event_id!r}.",
})
ev = await get_event(user_id=user_id, event_id=event_id_int)
if ev is None:
return ("not_found", {
"success": False,
"error": f"No event found with id={event_id_int}.",
})
return ev
query = arguments.get("query", "")
if not query:
return ("invalid_query", {
"success": False,
"error": "Either query or event_id is required.",
})
matches = await find_events_by_query(user_id=user_id, query=query)
if not matches:
return ("not_found", {
"success": False,
"error": f"No event found matching {query!r}.",
})
if len(matches) == 1:
return matches[0]
# Multi-match: refuse and surface candidates so the model can
# disambiguate via event_id on the next call. Prevents the silent-
# picks-matches[0] failure mode that mutated the wrong event in the
# 2026-04-29 dentist-appointment incident (Fable #161).
return ("ambiguous", {
"success": False,
"error": (
f"Found {len(matches)} events matching {query!r}. "
f"Pick one by passing `event_id` instead of `query`, "
f"or refine the search term to match a single event."
),
"action": action,
"candidates": [_candidate_summary(m) for m in matches[:8]],
})
def _validate_weekday(start_dt_utc: datetime, user_tz, expected: str | None) -> str | None:
"""Verify the resolved local date falls on the expected day of the week.
Models routinely miscompute "this Friday" / "next Monday" when the
system prompt only carries an ISO date without a weekday. When the
model passes `expected_weekday`, the backend rejects mismatches with
a self-correcting error message naming the actual weekday.
Returns an error string on mismatch, or ``None`` when the check
passes (or no expected weekday was supplied).
"""
if not expected:
return None
expected_norm = expected.strip().lower()
valid = {"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}
if expected_norm not in valid:
return f"expected_weekday must be a full English weekday name; got {expected!r}."
local = start_dt_utc.astimezone(user_tz)
actual = local.strftime("%A").lower()
if actual == expected_norm:
return None
return (
f"Date {local.date().isoformat()} falls on {actual.title()}, "
f"not {expected_norm.title()}. Recompute the date for "
f"{expected_norm.title()} or confirm with the user before retrying."
)
@tool(
name="create_event",
description=(
"Create a calendar event for the user. Use this when the user asks "
"to schedule, add, or create a meeting, appointment, or event. "
"Always pass `start_date` (YYYY-MM-DD) and `start_time` (HH:MM) as "
"separate fields in the user's local time — never combine them and "
"never include a timezone suffix. The server attaches the user's "
"configured timezone. Omit `start_time` (or set `all_day=true`) "
"for all-day events like birthdays or holidays. "
"When the user names a weekday ('this Friday', 'next Monday'), "
"state the resolved calendar date in your reply BEFORE calling "
"this tool, and pass `expected_weekday` so the server can verify "
"the date falls on the day you intended.\n\n"
"DON'T call this tool with placeholder values. If the user "
"mentions an event without giving you concrete details (a real "
"title, a specific time, and location when it applies), record "
"a moment instead and ask for the missing pieces — do NOT create "
"an event with a stand-in title like 'Appointment', 'Meeting', "
"or 'Event' and a description that says 'details TBD'. Wait for "
"the user's reply, then call create_event ONCE with the actual "
"title, time, and location. Premature placeholder events pollute "
"the calendar and require an immediate update_event to fix — "
"both visible to the user, neither what they asked for."
),
parameters={
"title": {"type": "string", "description": "A descriptive event title"},
"start_date": {"type": "string", "description": "Start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
"start_time": {"type": "string", "description": "Start wall-clock time as HH:MM (24-hour). Omit for all-day events. No timezone suffix."},
"end_date": {"type": "string", "description": "Optional end calendar date as YYYY-MM-DD."},
"end_time": {"type": "string", "description": "Optional end wall-clock time as HH:MM."},
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end_date/end_time is set or all_day is true)"},
"description": {"type": "string", "description": "Optional event description"},
"location": {"type": "string", "description": "Optional event location"},
"color": {"type": "string", "description": "Optional hex color for the event (e.g. '#6366f1')"},
"all_day": {"type": "boolean", "description": "True for all-day events (birthdays, holidays, deadlines)"},
"recurrence": {"type": "string", "description": "Optional iCalendar RRULE (e.g. 'FREQ=YEARLY' for annual, 'FREQ=WEEKLY' for weekly, 'FREQ=MONTHLY' for monthly)"},
"reminder_minutes": {"type": "integer", "description": "Optional reminder N minutes before the event (e.g. 30 for 30 minutes before)"},
"attendees": {"type": "array", "items": {"type": "string"}, "description": "Optional list of attendee email addresses"},
"calendar_name": {"type": "string", "description": "Optional calendar name to create the event in. Falls back to default calendar."},
"project": {"type": "string", "description": "Optional project name to associate this event with"},
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the start_date should fall on. Pass this whenever the user names a weekday so the server can verify the date is correct. Rejects with a corrective error if the date falls on a different day."},
# Legacy combined fields kept for backward compatibility with saved
# tool-call payloads in conversation history. New calls should use
# start_date + start_time. Hidden from typical model output via the
# description above; still accepted by the resolver as a fallback.
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
},
required=["title"],
)
async def create_event_tool(*, user_id, arguments, **_ctx):
all_day = arguments.get("all_day", False)
try:
start_dt, start_was_date_only = await _resolve_event_start(user_id, arguments)
except ValueError as exc:
return {"success": False, "error": str(exc)}
except TypeError as exc:
return {"success": False, "error": f"Invalid start: {exc}"}
if start_was_date_only:
all_day = True
user_tz = await get_user_tz(user_id)
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
if weekday_err:
return {"success": False, "error": weekday_err}
try:
end_dt = await _resolve_event_end(user_id, arguments)
except ValueError as exc:
return {"success": False, "error": str(exc)}
except TypeError as exc:
return {"success": False, "error": f"Invalid end: {exc}"}
project_id = None
project_name = arguments.get("project")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
project_id = proj.id
event = await events_create_event(
user_id=user_id,
title=arguments.get("title", "Untitled Event"),
start_dt=start_dt,
end_dt=end_dt,
all_day=all_day,
description=arguments.get("description") or "",
location=arguments.get("location") or "",
color=arguments.get("color") or "",
recurrence=arguments.get("recurrence"),
project_id=project_id,
duration=arguments.get("duration"),
reminder_minutes=arguments.get("reminder_minutes"),
attendees=arguments.get("attendees"),
calendar_name=arguments.get("calendar_name"),
)
return {"success": True, "type": "event", "data": event.to_dict()}
@tool(
name="list_events",
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
parameters={
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
},
required=["date_from", "date_to"],
read_only=True,
briefing=True,
)
async def list_events_tool(*, user_id, arguments, **_ctx):
# Bare local dates are expanded to a full local day in the user's TZ:
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
# UTC before the DB query. Previously the tool description told the
# model to pass UTC ranges, which missed events for non-UTC users.
try:
date_from, _ = await _parse_datetime_in_user_tz(
user_id, arguments["date_from"]
)
date_to_str = arguments["date_to"]
if "T" not in date_to_str and " " not in date_to_str:
date_to_str = f"{date_to_str}T23:59:59"
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
except (ValueError, TypeError, KeyError) as exc:
return {"success": False, "error": f"Invalid date range: {exc}"}
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
return {
"success": True,
"type": "events",
"data": {"count": len(events), "events": events},
}
@tool(
name="search_events",
description="Search calendar events by keyword. Use this when the user asks to find a specific event or meeting.",
parameters={
"query": {"type": "string", "description": "Search keyword to match against event titles, locations, and descriptions"},
"include_past": {"type": "boolean", "description": "Set to true to include past events in results (default: future events only)"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_events_tool(*, user_id, arguments, **_ctx):
events = await events_search_events(
user_id=user_id,
query=arguments.get("query", ""),
include_past=arguments.get("include_past", False),
)
return {
"success": True,
"type": "events",
"data": {
"query": arguments.get("query", ""),
"count": len(events),
"events": [e.to_dict() for e in events],
},
}
@tool(
name="update_event",
description=(
"Update an existing calendar event. Use this when the user asks to "
"change, move, reschedule, or modify an event. Pass `start_date` "
"(YYYY-MM-DD) and `start_time` (HH:MM) as separate fields in the "
"user's local time when rescheduling — never combine them, never "
"include a timezone suffix. "
"When the user names a weekday ('move to Friday'), state the "
"resolved calendar date in your reply BEFORE calling this tool, "
"and pass `expected_weekday` so the server can verify the date "
"falls on the day you intended.\n\n"
"Identify the event with EITHER `query` (a title substring) OR "
"`event_id` (when you already have an exact id from a prior tool "
"result). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event."
),
parameters={
"query": {"type": "string", "description": "Search term to find the event to update (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
"title": {"type": "string", "description": "New title for the event"},
"start_date": {"type": "string", "description": "New start calendar date as YYYY-MM-DD in the user's local time. No timezone suffix."},
"start_time": {"type": "string", "description": "New start wall-clock time as HH:MM. No timezone suffix."},
"end_date": {"type": "string", "description": "New end calendar date as YYYY-MM-DD."},
"end_time": {"type": "string", "description": "New end wall-clock time as HH:MM."},
"all_day": {"type": "boolean", "description": "Whether the event is all-day"},
"description": {"type": "string", "description": "New event description"},
"location": {"type": "string", "description": "New event location"},
"color": {"type": "string", "description": "New hex color for the event (e.g. '#6366f1')"},
"recurrence": {"type": "string", "description": "New iCalendar RRULE"},
"reminder_minutes": {"type": "integer", "description": "Reminder N minutes before the event. Pass 0 to remove an existing reminder."},
"expected_weekday": {"type": "string", "description": "Optional weekday name (e.g. 'friday') the new start_date should fall on. Pass whenever the user names a weekday."},
# Legacy combined fields kept for backcompat — see create_event.
"start": {"type": "string", "description": "[Deprecated] Combined start datetime — prefer start_date + start_time."},
"end": {"type": "string", "description": "[Deprecated] Combined end datetime — prefer end_date + end_time."},
},
required=[],
)
async def update_event_tool(*, user_id, arguments, **_ctx):
resolved = await _resolve_event_for_action(
user_id=user_id, arguments=arguments, action="update",
)
if isinstance(resolved, tuple):
return resolved[1] # error dict from the resolver
event_to_update = resolved
fields: dict = {}
for str_field in ("title", "description", "location", "color", "recurrence"):
if arguments.get(str_field) is not None:
fields[str_field] = arguments[str_field]
if arguments.get("all_day") is not None:
fields["all_day"] = arguments["all_day"]
if "reminder_minutes" in arguments:
rm = arguments["reminder_minutes"]
fields["reminder_minutes"] = None if rm == 0 else rm
# Resolve start: split fields preferred, legacy `start` as fallback.
if arguments.get("start_date") or arguments.get("start"):
try:
start_dt, _ = await _resolve_event_start(user_id, arguments)
except (ValueError, TypeError) as exc:
return {"success": False, "error": f"Invalid start: {exc}"}
user_tz = await get_user_tz(user_id)
weekday_err = _validate_weekday(start_dt, user_tz, arguments.get("expected_weekday"))
if weekday_err:
return {"success": False, "error": weekday_err}
fields["start_dt"] = start_dt
if arguments.get("end_date") or arguments.get("end"):
try:
end_dt = await _resolve_event_end(user_id, arguments)
except (ValueError, TypeError) as exc:
return {"success": False, "error": f"Invalid end: {exc}"}
if end_dt is not None:
fields["end_dt"] = end_dt
updated = await events_update_event(user_id=user_id, event_id=event_to_update.id, **fields)
if updated is None:
return {"success": False, "error": "Event not found or update failed."}
return {"success": True, "type": "event_updated", "data": updated.to_dict()}
@tool(
name="delete_event",
description=(
"Delete a calendar event. Use this when the user asks to cancel, "
"remove, or delete an event. Identify the event with EITHER "
"`query` (a title substring) OR `event_id` (when you have an "
"exact id). If `query` matches multiple events, the tool returns "
"an ambiguity error with a candidate list — pick one by passing "
"its `event_id` on the next call, or refine the query so it "
"matches a single event. Deleting the wrong event is a costly "
"user error; never guess between candidates."
),
parameters={
"query": {"type": "string", "description": "Search term to find the event to delete (matches against title). Required unless event_id is set."},
"event_id": {"type": "integer", "description": "Exact event id, used to disambiguate when a prior call returned multiple candidates. Takes precedence over query."},
},
required=[],
)
async def delete_event_tool(*, user_id, arguments, **_ctx):
resolved = await _resolve_event_for_action(
user_id=user_id, arguments=arguments, action="delete",
)
if isinstance(resolved, tuple):
return resolved[1]
event_to_delete = resolved
await events_delete_event(user_id=user_id, event_id=event_to_delete.id)
return {"success": True, "type": "event_deleted", "data": {"id": event_to_delete.id, "title": event_to_delete.title}}
@tool(
name="list_calendars",
description="List all available calendars. Use this when the user asks which calendars they have.",
parameters={},
read_only=True,
requires="caldav",
)
async def list_calendars_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.caldav import list_calendars
calendars = await list_calendars(user_id=user_id)
return {
"success": True,
"type": "calendars",
"data": {"count": len(calendars), "calendars": calendars},
}
@@ -1,231 +0,0 @@
"""Entity tools: people, places, and lists."""
from __future__ import annotations
from fabledassistant.services.notes import create_note, list_notes, update_note
from fabledassistant.services.tools._helpers import schedule_embedding
from fabledassistant.services.tools._registry import tool
def _build_person_body(meta: dict, extra_notes: str | None = None) -> str:
"""Build markdown body from person metadata."""
lines = []
for key, label in (("relationship", "Relationship"), ("phone", "Phone"), ("email", "Email"), ("birthday", "Birthday"), ("address", "Address")):
if meta.get(key):
lines.append(f"**{label}:** {meta[key]}")
if extra_notes:
lines.append(f"\n{extra_notes}")
return "\n".join(lines)
def _build_place_body(meta: dict, extra_notes: str | None = None) -> str:
"""Build markdown body from place metadata."""
lines = []
for key, label in (("address", "Address"), ("phone", "Phone"), ("hours", "Hours"), ("url", "Website")):
if meta.get(key):
lines.append(f"**{label}:** {meta[key]}")
if extra_notes:
lines.append(f"\n{extra_notes}")
return "\n".join(lines)
_PERSON_FIELDS = ("relationship", "phone", "email", "birthday", "address")
_PLACE_FIELDS = ("address", "phone", "hours", "url")
@tool(
name="save_person",
description=(
"Save or update a person in the user's knowledge base. Creates a new entry if the "
"person doesn't exist, or updates an existing one. Use when the user introduces "
"someone by name or adds/corrects details about a known person."
),
parameters={
"name": {"type": "string", "description": "Full name of the person (used to find existing entry or as new entry title)"},
"relationship": {"type": "string", "description": "Relationship to the user (e.g. daughter, dentist, colleague)"},
"phone": {"type": "string", "description": "Phone number"},
"email": {"type": "string", "description": "Email address"},
"birthday": {"type": "string", "description": "Birthday in YYYY-MM-DD format"},
"address": {"type": "string", "description": "Home or mailing address"},
"notes": {"type": "string", "description": "Any additional free-form notes about this person"},
},
required=["name"],
)
async def save_person_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "person"), None)
if target is not None:
meta = dict(target.entity_meta or {})
for field in _PERSON_FIELDS:
val = arguments.get(field)
if val is not None:
meta[field] = str(val)
extra_notes = arguments.get("notes")
body = _build_person_body(meta, extra_notes)
updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta)
if updated is None:
return {"success": False, "error": "Failed to update person."}
schedule_embedding(target.id, user_id, target.title, body)
return {"success": True, "type": "person_updated", "data": {"id": target.id, "name": target.title}}
meta = {}
for field in _PERSON_FIELDS:
val = arguments.get(field)
if val:
meta[field] = str(val)
body = _build_person_body(meta, arguments.get("notes", ""))
note = await create_note(
user_id=user_id, title=name, body=body,
tags=["person"], note_type="person", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "person", "data": {"id": note.id, "name": name}}
@tool(
name="save_place",
description=(
"Save or update a named location in the user's knowledge base. Creates a new entry "
"if the place doesn't exist, or updates an existing one. Use when the user wants to "
"remember or correct details about a place (business, venue, address)."
),
parameters={
"name": {"type": "string", "description": "Name of the place (e.g. 'Dr. Smith Dental', 'Costco')"},
"address": {"type": "string", "description": "Street address"},
"phone": {"type": "string", "description": "Phone number"},
"hours": {"type": "string", "description": "Opening hours (e.g. 'Mon-Fri 9am-5pm')"},
"url": {"type": "string", "description": "Website URL"},
"notes": {"type": "string", "description": "Any additional free-form notes about this place"},
},
required=["name"],
)
async def save_place_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
existing, _ = await list_notes(user_id=user_id, q=name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "place"), None)
if target is not None:
meta = dict(target.entity_meta or {})
for field in _PLACE_FIELDS:
val = arguments.get(field)
if val is not None:
meta[field] = str(val)
extra_notes = arguments.get("notes")
body = _build_place_body(meta, extra_notes)
updated = await update_note(user_id=user_id, note_id=target.id, body=body, entity_meta=meta)
if updated is None:
return {"success": False, "error": "Failed to update place."}
schedule_embedding(target.id, user_id, target.title, body)
return {"success": True, "type": "place_updated", "data": {"id": target.id, "name": target.title}}
meta = {}
for field in _PLACE_FIELDS:
val = arguments.get(field)
if val:
meta[field] = str(val)
body = _build_place_body(meta, arguments.get("notes", ""))
note = await create_note(
user_id=user_id, title=name, body=body,
tags=["place"], note_type="place", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, note.body)
return {"success": True, "type": "place", "data": {"id": note.id, "name": name}}
@tool(
name="create_list",
description="Create a named list (shopping list, to-do list, packing list, etc.). Items are stored as a markdown task list. Use add_to_list to add items to an existing list.",
parameters={
"name": {"type": "string", "description": "List name (e.g. 'Grocery List', 'Hardware Store')"},
"items": {"type": "array", "items": {"type": "string"}, "description": "Initial list items (unchecked)"},
"store": {"type": "string", "description": "Optional associated store or place name"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags for the list"},
},
required=["name"],
)
async def create_list_tool(*, user_id, arguments, **_ctx):
name = str(arguments.get("name", "")).strip()
if not name:
return {"success": False, "error": "name is required"}
items = arguments.get("items") or []
if not isinstance(items, list):
items = []
body = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
meta = {}
store = arguments.get("store")
if store:
meta["store"] = str(store)
tags = arguments.get("tags") or []
if not isinstance(tags, list):
tags = []
note = await create_note(
user_id=user_id, title=name, body=body,
tags=tags, note_type="list", entity_meta=meta,
)
schedule_embedding(note.id, user_id, name, body)
return {"success": True, "type": "list", "data": {"id": note.id, "name": name, "item_count": len(items)}}
@tool(
name="add_to_list",
description="Add one or more items to an existing list. Items are appended as unchecked entries.",
parameters={
"list_name": {"type": "string", "description": "Name of the list to add items to"},
"items": {"type": "array", "items": {"type": "string"}, "description": "Items to add"},
},
required=["list_name", "items"],
)
async def add_to_list_tool(*, user_id, arguments, **_ctx):
list_name = str(arguments.get("list_name", "")).strip()
items = arguments.get("items") or []
if not list_name:
return {"success": False, "error": "list_name is required"}
if not isinstance(items, list) or not items:
return {"success": False, "error": "items must be a non-empty array"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found. Use create_list to create it first."}
new_lines = "\n".join(f"- [ ] {item}" for item in items if str(item).strip())
existing_body = (target.body or "").rstrip()
new_body = f"{existing_body}\n{new_lines}".lstrip()
await update_note(user_id=user_id, note_id=target.id, body=new_body)
schedule_embedding(target.id, user_id, target.title, new_body)
return {"success": True, "type": "list_updated", "data": {"id": target.id, "name": target.title, "added": len(items)}}
@tool(
name="clear_checked_items",
description="Remove all checked/completed items from a list, keeping only unchecked items.",
parameters={
"list_name": {"type": "string", "description": "Name of the list to clear"},
},
required=["list_name"],
)
async def clear_checked_items_tool(*, user_id, arguments, **_ctx):
import re as _re
list_name = str(arguments.get("list_name", "")).strip()
if not list_name:
return {"success": False, "error": "list_name is required"}
existing, _ = await list_notes(user_id=user_id, q=list_name, is_task=False, limit=10)
target = next((n for n in existing if n.note_type == "list" and n.title.lower() == list_name.lower()), None)
if target is None:
target = next((n for n in existing if n.note_type == "list"), None)
if target is None:
return {"success": False, "error": f"No list named '{list_name}' found."}
body = target.body or ""
cleaned = _re.sub(r"^- \[[xX]\] .+\n?", "", body, flags=_re.MULTILINE).strip()
await update_note(user_id=user_id, note_id=target.id, body=cleaned)
schedule_embedding(target.id, user_id, target.title, cleaned)
return {"success": True, "type": "list_cleared", "data": {"id": target.id, "name": target.title}}
@@ -1,385 +0,0 @@
"""LLM tools for journal conversations: record_moment and search_journal."""
from __future__ import annotations
import datetime
import logging
import re
from typing import Any
from sqlalchemy import func, select
from fabledassistant.models import Note, async_session
from fabledassistant.services.journal_search import search_journal as svc_search_journal
from fabledassistant.services.moments import create_moment
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
# Generic placeholders the model occasionally emits for `place_names` when no
# real place was named. Filtered out server-side as belt-and-suspenders to the
# prompt-layer guidance — these aren't places, just role-labels for locations
# the user already has named (Home / Work weather targets, etc.).
_PLACEHOLDER_PLACE_NAMES = frozenset({
"work", "home", "office", "the office", "my office", "my work",
"my home", "house", "my house", "the house",
})
# Short closed-class words excluded from the keyword-overlap check below.
# Case is normalized to lowercase before comparison.
_STOPWORDS = frozenset({
"the", "a", "an", "and", "or", "but", "at", "in", "on", "of", "to",
"for", "with", "by", "from", "about", "as", "is", "was", "were", "be",
"been", "being", "have", "has", "had", "do", "does", "did", "will",
"would", "could", "should", "may", "might", "must", "this", "that",
"these", "those", "i", "you", "he", "she", "we", "they", "it", "his",
"her", "their", "my", "your", "our", "its", "me", "him", "us", "them",
"are", "if", "so", "no", "not", "yes", "now", "then", "than", "too",
"very", "just", "also", "any", "all", "some", "one", "two", "out",
"up", "down", "off", "over", "under", "into", "onto", "upon",
})
def _content_keywords(text: str) -> set[str]:
"""Tokenize text into the meaningful keyword set used for overlap checks.
Lowercased, alphanumeric runs only, stopwords removed, tokens shorter
than 3 chars dropped. Numeric tokens are kept (e.g. "Branch 14" yields
{"branch", "14"}) because they often anchor task references.
"""
tokens = re.split(r"[^a-z0-9]+", text.lower())
return {t for t in tokens if len(t) >= 3 and t not in _STOPWORDS}
def _filter_placeholder_places(names: list[str]) -> tuple[list[str], list[str]]:
"""Drop generic placeholders from a `place_names` list.
Returns ``(kept, dropped)``. The dropped list is used purely for log
visibility — the moment is still created, the bogus links are just
not persisted.
"""
kept: list[str] = []
dropped: list[str] = []
for n in names:
norm = (n or "").strip()
if norm and norm.lower() in _PLACEHOLDER_PLACE_NAMES:
dropped.append(norm)
else:
kept.append(norm)
return kept, dropped
async def _filter_task_ids_by_keyword_overlap(
*, user_id: int, content: str, task_ids: list[int]
) -> list[int]:
"""Drop task links whose title shares no meaningful keyword with the
moment content.
Reproducer this guards against (2026-04-27): the model emitted
`task_titles=["Research Weston's ADHD Evaluation"]` on a moment about
restaging Docker — the title was real (Weston's task is in the prep
context) but the user never referenced it. Without this guard, the
only task surfaced in the prep gets attached to every moment as
filler. After this guard the link is dropped (zero-overlap) and a
log entry is emitted at INFO so we can observe how often this fires.
"""
if not task_ids:
return []
async with async_session() as session:
stmt = select(Note.id, Note.title).where(
Note.user_id == user_id,
Note.id.in_(task_ids),
)
rows = (await session.execute(stmt)).all()
title_by_id = {nid: (title or "") for nid, title in rows}
content_kw = _content_keywords(content)
kept: list[int] = []
for tid in task_ids:
title = title_by_id.get(tid, "")
title_kw = _content_keywords(title)
if title_kw and content_kw & title_kw:
kept.append(tid)
else:
logger.info(
"record_moment: dropped task link id=%s title=%r — no keyword overlap with content %r",
tid, title, content[:80],
)
return kept
async def _resolve_entity_ids_by_name(
*,
user_id: int,
names: list[str],
note_type: str | None = None,
is_task_only: bool | None = None,
) -> list[int]:
"""Case-insensitive title lookup → note IDs.
Names with no match are silently dropped (logged at debug). Used by
record_moment to resolve user-mentioned names without forcing the
LLM to track tool-return IDs across calls.
"""
if not names:
return []
cleaned = [n.strip() for n in names if n and n.strip()]
if not cleaned:
return []
lowered = [n.lower() for n in cleaned]
async with async_session() as session:
stmt = select(Note.id, func.lower(Note.title).label("ltitle")).where(
Note.user_id == user_id,
func.lower(Note.title).in_(lowered),
)
if note_type is not None:
stmt = stmt.where(Note.note_type == note_type)
if is_task_only is True:
stmt = stmt.where(Note.status.isnot(None))
elif is_task_only is False:
stmt = stmt.where(Note.status.is_(None))
rows = (await session.execute(stmt)).all()
# Pick first match per name; preserve input order.
by_lower: dict[str, int] = {}
for note_id, ltitle in rows:
if ltitle not in by_lower:
by_lower[ltitle] = note_id
resolved: list[int] = []
for low, original in zip(lowered, cleaned):
if low in by_lower:
resolved.append(by_lower[low])
else:
logger.debug(
"record_moment: no entity match for %r (note_type=%s, task_only=%s)",
original, note_type, is_task_only,
)
return resolved
@tool(
name="record_moment",
description=(
"Record a meaningful beat from the conversation as a structured Moment. "
"Use freely (no confirmation) for anything significant the user mentions: "
"events, encounters, decisions, observations, feelings worth remembering. "
"Each Moment is one or two sentences distilling the beat — not a full transcript. "
"STRONGLY PREFER the *_names parameters when linking entities — the server "
"resolves names to IDs by lookup, so you cannot accidentally invent or "
"re-use the wrong ID. Use *_ids only when you have an exact ID returned "
"from another tool call in this same turn. "
"`task_titles` and `note_titles` must be exact titles returned by a prior "
"search_notes call in this same turn. Do NOT pass user-typed phrases, "
"project names, or invented titles. If you have not searched yet, call "
"search_notes first."
),
parameters={
"content": {
"type": "string",
"description": (
"1-2 sentence distillation of the moment in the user's "
"voice — first-person or imperative, like a journal jot. "
"GOOD: 'Restaging Docker on the Bedford swarm; one "
"Windows node had network breakage.' / 'Appointment "
"Friday — details TBD.' "
"BAD: 'The user mentioned having an appointment.' / "
"'User reports Docker swarm restage.' Strip 'the user…' "
"/ 'user mentioned…' framings entirely."
),
},
"occurred_at": {
"type": "string",
"description": "ISO 8601 datetime when the moment happened. Pass 'now' for the present moment; the handler resolves it.",
},
"raw_excerpt": {
"type": "string",
"description": "Optional: literal user phrase the moment summarizes. Useful for trust UI.",
},
"tags": {
"type": "array",
"items": {"type": "string"},
"description": "Optional tags (no # prefix).",
},
"person_names": {
"type": "array",
"items": {"type": "string"},
"description": "PREFERRED. Names of people mentioned (e.g. ['Victoria', 'Mom']). Server resolves to existing person notes by case-insensitive title match. Names with no match are silently skipped.",
},
"place_names": {
"type": "array",
"items": {"type": "string"},
"description": "PREFERRED. Names of places mentioned (e.g. ['the new ramen place']). Server resolves to existing place notes by title.",
},
"task_titles": {
"type": "array",
"items": {"type": "string"},
"description": "Titles of tasks this moment references. Server resolves to task IDs by title match.",
},
"note_titles": {
"type": "array",
"items": {"type": "string"},
"description": "Titles of notes this moment references. Server resolves to note IDs by title match.",
},
"person_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use person_names instead. Only valid if you obtained the ID from a tool result in this same turn. Never invent IDs.",
},
"place_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use place_names instead. Same caveat as person_ids.",
},
"task_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use task_titles instead. Same caveat as person_ids.",
},
"note_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "DISCOURAGED — use note_titles instead. Same caveat as person_ids.",
},
},
required=["content"],
read_only=False,
journal=True,
)
async def record_moment_tool(*, user_id, arguments, conv_id=None, **_ctx):
content = arguments.get("content", "").strip()
if not content:
return {"success": False, "error": "content is required"}
occurred_raw = arguments.get("occurred_at")
now = datetime.datetime.now(datetime.timezone.utc)
if occurred_raw and occurred_raw != "now":
try:
occurred_dt = datetime.datetime.fromisoformat(occurred_raw)
if occurred_dt.tzinfo is None:
occurred_dt = occurred_dt.replace(tzinfo=datetime.timezone.utc)
except ValueError:
occurred_dt = now
else:
occurred_dt = now
# Start with any explicit IDs the LLM provided.
person_ids = list(arguments.get("person_ids") or [])
place_ids = list(arguments.get("place_ids") or [])
task_ids = list(arguments.get("task_ids") or [])
note_ids = list(arguments.get("note_ids") or [])
# Resolve names → IDs and merge. Names are the preferred path because the
# LLM is unreliable at tracking IDs across tool calls (see prior bug:
# hallucinated person_ids=[1,2] referencing test-data notes).
person_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("person_names") or [],
note_type="person",
)
)
raw_place_names = arguments.get("place_names") or []
kept_place_names, dropped_place_names = _filter_placeholder_places(raw_place_names)
if dropped_place_names:
logger.info(
"record_moment: dropped placeholder place_names %r — not real places",
dropped_place_names,
)
place_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=kept_place_names,
note_type="place",
)
)
task_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("task_titles") or [],
is_task_only=True,
)
)
note_ids.extend(
await _resolve_entity_ids_by_name(
user_id=user_id,
names=arguments.get("note_titles") or [],
is_task_only=False,
)
)
# Dedupe while preserving order.
person_ids = list(dict.fromkeys(person_ids))
place_ids = list(dict.fromkeys(place_ids))
task_ids = list(dict.fromkeys(task_ids))
note_ids = list(dict.fromkeys(note_ids))
# Drop task links that don't share a keyword with the moment content.
# Belt-and-suspenders to the prompt-layer rule "only link tasks the user
# explicitly references" — if the model attaches a task anyway (because
# it's in the prep context), the keyword check refuses to persist a link
# the moment can't justify.
task_ids = await _filter_task_ids_by_keyword_overlap(
user_id=user_id, content=content, task_ids=task_ids,
)
moment = await create_moment(
user_id=user_id,
content=content,
occurred_at=occurred_dt,
day_date=occurred_dt.date(),
conversation_id=conv_id,
raw_excerpt=arguments.get("raw_excerpt"),
tags=arguments.get("tags") or [],
person_ids=person_ids,
place_ids=place_ids,
task_ids=task_ids,
note_ids=note_ids,
)
return {
"success": True,
"type": "moment_recorded",
"moment": moment.to_dict(),
}
@tool(
name="search_journal",
description=(
"Search the user's journal: past Moments and (optionally) raw transcripts. "
"Use to recall things mentioned in prior days. Three modes: "
"(a) date filter only -> recent moments in that range; "
"(b) person_id/place_id filter -> moments mentioning that entity; "
"(c) query string -> semantic search, optionally constrained by date/entity."
),
parameters={
"query": {"type": "string", "description": "Optional semantic query."},
"person_id": {"type": "integer", "description": "Filter to this person."},
"place_id": {"type": "integer", "description": "Filter to this place."},
"tag": {"type": "string", "description": "Filter to moments with this tag."},
"date_from": {"type": "string", "description": "ISO date lower bound (inclusive)."},
"date_to": {"type": "string", "description": "ISO date upper bound (inclusive)."},
"include_transcripts": {
"type": "boolean",
"description": "If true, also return matching raw transcript excerpts when query is set. Default false.",
},
"limit": {"type": "integer", "description": "Max results, default 10."},
},
required=[],
read_only=True,
journal=True,
)
async def search_journal_tool(*, user_id, arguments, **_ctx) -> dict[str, Any]:
df_raw = arguments.get("date_from")
dt_raw = arguments.get("date_to")
df = datetime.date.fromisoformat(df_raw) if df_raw else None
dt = datetime.date.fromisoformat(dt_raw) if dt_raw else None
results = await svc_search_journal(
user_id=user_id,
query=arguments.get("query"),
person_id=arguments.get("person_id"),
place_id=arguments.get("place_id"),
tag=arguments.get("tag"),
date_from=df,
date_to=dt,
include_transcripts=bool(arguments.get("include_transcripts", False)),
limit=int(arguments.get("limit", 10)),
)
return {"success": True, "type": "journal_search_results", "count": len(results), "results": results}
-455
View File
@@ -1,455 +0,0 @@
"""Note tools: create, update, get, list, delete, search."""
from __future__ import annotations
import logging
from fabledassistant.services.notes import create_note, delete_note, get_note_by_title, list_notes, update_note
from fabledassistant.services.tag_suggestions import suggest_tags
from fabledassistant.services.tools._helpers import (
check_duplicate,
parse_due_date,
resolve_project,
schedule_embedding,
)
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="create_note",
description=(
"Create a new note or task. "
"For a knowledge note, omit the status field. "
"For an actionable task (todo, reminder, action item), set status to 'todo'. "
"Use this whenever the user asks to write down, save, record, or add a task/todo. "
"For standalone reusable knowledge, ALSO use this when the user explicitly asks "
"to save something as a note / runbook / how-to, OR when their message contains "
"a fenced code block or a numbered procedure (3+ steps) that's reusable beyond "
"a single task. For task-specific work-in-progress, use log_work instead — that "
"feeds the task's auto-summary."
),
parameters={
"title": {"type": "string", "description": "The title"},
"body": {"type": "string", "description": "Content in markdown. NOTE: when status is set (creating a task), body is ignored — task bodies are auto-maintained from work logs. Use `description` to provide the goal/context for tasks."},
"description": {"type": "string", "description": "User-stated goal or initial context for a task. Read-only context for the auto-summary pipeline. Ignored when status is omitted (knowledge note)."},
"tags": {"type": "array", "items": {"type": "string"}, "description": 'Tags (without # prefix, hyphens for multi-word: ["science-fiction", "story/idea"]). Do NOT embed #tags in the body.'},
"project": {"type": "string", "description": "Optional project name. Only set this if the user explicitly named a project. Do NOT infer a project from the content or context."},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "Set to 'todo' to create a task. Omit entirely for a knowledge note."},
"due_date": {"type": "string", "description": "Due date in YYYY-MM-DD format (tasks only)"},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Priority level (tasks only, default: none)"},
"parent_task": {"type": "string", "description": "Title of a parent task to make this a sub-task of"},
"milestone": {"type": "string", "description": "Milestone title within the project to assign to"},
"recurrence_rule": {"type": "object", "description": 'Recurrence rule (tasks only). Interval form: {"type":"interval","every":3,"unit":"month"} (units: day/week/month/year). Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Annual: add "month":6.'},
"confirmed": {"type": "boolean", "description": "Set to true only when the user has explicitly confirmed creation after being warned about a similar existing item."},
},
required=["title"],
)
async def create_note_tool(*, user_id, arguments, **_ctx):
title = arguments.get("title", "Untitled")
body = arguments.get("body", "")
description = arguments.get("description")
tags = arguments.get("tags", [])
if not isinstance(title, str):
return {"success": False, "error": "title must be a string. Call create_note once per item."}
if not isinstance(body, str):
body = ""
if not isinstance(tags, list):
tags = []
is_task = "status" in arguments and arguments["status"] is not None
status = arguments.get("status", "todo") if is_task else None
# Task bodies are auto-maintained by the consolidation pipeline; drop any
# body argument arriving with a task creation so it never lands in the DB.
if is_task:
body = ""
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
parent_task_name = arguments.get("parent_task")
project_id = None
milestone_id = None
if project_name:
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 find the correct project name and retry with the exact name."}
project_id = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt
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."}
milestone_id = ms.id
dup = await check_duplicate(user_id, title, body, is_task=is_task, confirmed=bool(arguments.get("confirmed")))
if dup is not None:
return dup
note = await create_note(
user_id=user_id,
title=title,
body=body,
description=description,
tags=tags,
status=status,
priority=arguments.get("priority", "none") if is_task else None,
due_date=parse_due_date(arguments.get("due_date")) if is_task else None,
recurrence_rule=arguments.get("recurrence_rule") if is_task else None,
project_id=project_id,
milestone_id=milestone_id,
)
if parent_task_name:
parent_notes, _ = await list_notes(user_id=user_id, q=parent_task_name, is_task=True, limit=1)
if parent_notes:
note = await update_note(user_id, note.id, parent_id=parent_notes[0].id)
suggested = await suggest_tags(user_id, title, body)
schedule_embedding(note.id, user_id, title, body)
if is_task:
return {
"success": True,
"type": "task",
"data": {
"id": note.id,
"title": note.title,
"status": note.status,
"priority": note.priority,
"due_date": str(note.due_date) if note.due_date else None,
"project_id": note.project_id,
"milestone_id": note.milestone_id,
"parent_id": note.parent_id,
},
"suggested_tags": suggested,
}
return {
"success": True,
"type": "note",
"data": {"id": note.id, "title": note.title, "project_id": note.project_id},
"suggested_tags": suggested,
}
@tool(
name="update_note",
description="Update an existing note or task — content, title, status, priority, or due date. Use for edits, marking tasks done, changing priority. Never use create_note for existing notes.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the note or task to update"},
"body": {"type": "string", "description": "New note content in markdown (omit if only updating task fields). REJECTED on tasks — task bodies are auto-maintained from work logs; use `log_work` to record progress or `description` to revise the goal."},
"description": {"type": "string", "description": "Update the user-stated goal/context (tasks). Distinct from `body` (machine-maintained on tasks)."},
"title": {"type": "string", "description": "Optional new title"},
"mode": {"type": "string", "enum": ["replace", "append"], "description": "How to apply the new body: 'replace' overwrites existing content (default), 'append' adds after existing content"},
"status": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"], "description": "New task status. Use to mark a task done, start it, cancel it, etc."},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "New task priority"},
"due_date": {"type": "string", "description": "New due date in YYYY-MM-DD format"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Tags to apply (see tag_mode for how they're applied)"},
"tag_mode": {"type": "string", "enum": ["add", "remove", "replace"], "description": "'add' appends tags without duplicates (default), 'remove' removes listed tags, 'replace' sets tags to exactly this list (destructive — avoid unless explicitly requested)"},
"project": {"type": "string", "description": "Optional project name to assign this note/task to (set to empty string to remove project)"},
"milestone": {"type": "string", "description": "Optional milestone title within the project to assign this task to (set to empty string to remove milestone)"},
"recurrence_rule": {"type": "object", "description": 'Set or update recurrence. Interval form: {"type":"interval","every":3,"unit":"month"}. Calendar form: {"type":"calendar","unit":"month","day_of_month":1}. Pass null to remove.'},
},
required=["query"],
)
async def update_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
new_body = arguments.get("body", "")
new_title = arguments.get("title")
mode = arguments.get("mode", "replace")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=5)
note_only = [n for n in notes if n.status is None]
candidates = note_only or notes
if not candidates:
return {"success": False, "error": f"No note found matching '{query}'."}
note = candidates[0]
# Schema-level separation: task bodies are auto-maintained from work logs.
# Reject body writes here rather than silently dropping so the LLM gets
# nudged toward log_work / description.
if note.is_task and arguments.get("body"):
return {
"success": False,
"error": (
"Cannot write to `body` on a task — the body is auto-maintained "
"from work logs. Use the `log_work` tool to record progress, or "
"update `description` to revise the goal."
),
}
update_fields: dict = {}
if new_title:
update_fields["title"] = new_title
if new_body:
if mode == "append" and note.body:
update_fields["body"] = note.body + "\n\n" + new_body
else:
update_fields["body"] = new_body
if "description" in arguments:
update_fields["description"] = arguments["description"]
if "status" in arguments:
update_fields["status"] = arguments["status"]
if "priority" in arguments:
update_fields["priority"] = arguments["priority"]
if "due_date" in arguments:
update_fields["due_date"] = parse_due_date(arguments["due_date"])
if "tags" in arguments:
new_tags = arguments["tags"]
if isinstance(new_tags, list):
tag_mode = arguments.get("tag_mode", "add")
if tag_mode == "add":
existing = list(note.tags or [])
for t in new_tags:
if t not in existing:
existing.append(t)
update_fields["tags"] = existing
elif tag_mode == "remove":
existing = list(note.tags or [])
update_fields["tags"] = [t for t in existing if t not in new_tags]
else:
update_fields["tags"] = new_tags
if "project" in arguments:
project_name = arguments["project"]
if project_name:
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
else:
update_fields["project_id"] = None
update_fields["milestone_id"] = None
if "milestone" in arguments:
milestone_name = arguments["milestone"]
if milestone_name:
ref_project_id = update_fields.get("project_id") or note.project_id
if ref_project_id is None:
return {"success": False, "error": "Cannot assign a milestone without a project. Set project first."}
from fabledassistant.services.milestones import get_milestone_by_title as _gmbt_upd
ms = await _gmbt_upd(user_id, ref_project_id, milestone_name)
if ms is None:
return {"success": False, "error": f"Milestone '{milestone_name}' not found. Use list_milestones to see available milestones."}
update_fields["milestone_id"] = ms.id
else:
update_fields["milestone_id"] = None
updated = await update_note(user_id, note.id, **update_fields)
if updated is None:
return {"success": False, "error": "Failed to update note."}
suggested = await suggest_tags(user_id, updated.title, updated.body or "")
schedule_embedding(updated.id, user_id, updated.title, updated.body or "")
item_type = "task" if updated.status is not None else "note"
return {
"success": True,
"type": "note_updated",
"updated": f"{item_type} '{updated.title}' (id: {updated.id})",
"data": {
"id": updated.id,
"title": updated.title,
"item_type": item_type,
"status": updated.status,
"tags": updated.tags or [],
"project_id": updated.project_id,
},
"suggested_tags": suggested,
}
@tool(
name="search_notes",
description=(
"Find notes or tasks by meaning. Returns a ranked list of matches with "
"short previews. Use this when looking for items on a topic but you "
"don't know the exact title. For the full body of a known item, use "
"read_note instead. Do not include 'task', 'note', or 'project' in the "
"`query` — use the `type` and `project` parameters instead."
),
parameters={
"query": {"type": "string", "description": "A natural-language description of what you're looking for — concepts, themes, topics, or keywords"},
"type": {"type": "string", "enum": ["note", "task"], "description": "Restrict results to only notes or only tasks. Omit to search both."},
"project": {"type": "string", "description": "Optional project name to restrict search to notes in that project"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_notes_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
type_filter = arguments.get("type")
project_name = arguments.get("project")
is_task: bool | None = None
if type_filter == "note":
is_task = False
elif type_filter == "task":
is_task = True
search_project_id: int | None = None
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
search_project_id = proj.id
results_map: dict[int, dict] = {}
try:
from fabledassistant.services.embeddings import semantic_search_notes as _ssn
sem_results = await _ssn(
user_id, query, limit=8, threshold=0.40,
project_id=search_project_id,
)
for score, n in sem_results:
n_is_task = n.status is not None
if is_task is True and not n_is_task:
continue
if is_task is False and n_is_task:
continue
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n_is_task else "note",
"status": n.status,
"relevance": f"{round(score * 100)}%",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
logger.debug("search_notes semantic: %d results for %r", len(results_map), query)
except Exception:
logger.debug("Semantic search unavailable in search_notes, using keyword only", exc_info=True)
try:
kw_notes, _ = await list_notes(
user_id=user_id, q=query, is_task=is_task,
project_id=search_project_id, limit=8,
)
for n in kw_notes:
if n.id not in results_map:
results_map[n.id] = {
"id": n.id,
"title": n.title,
"type": "task" if n.status is not None else "note",
"status": n.status,
"relevance": "keyword",
"preview": (n.body[:300] + "\u2026") if n.body and len(n.body) > 300 else (n.body or ""),
}
except Exception:
logger.warning("Keyword fallback in search_notes failed", exc_info=True)
results = list(results_map.values())[:8]
return {
"success": True,
"type": "search",
"data": {"query": query, "count": len(results), "results": results},
}
@tool(
name="read_note",
description="Read the full content of one specific note or task. Use when you know which item you want and need its complete body text. For discovering items by topic, use search_notes instead.",
parameters={
"query": {"type": "string", "description": "Title or keyword to identify the note or task"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def get_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note found matching '{query}'."}
note = notes[0]
return {
"success": True,
"type": "note_content",
"data": {"id": note.id, "title": note.title, "body": note.body or "", "tags": note.tags or []},
}
@tool(
name="list_notes",
description="Browse recent notes (not tasks) with optional filters. Returns titles and short previews. Use when the user asks 'show my notes' or wants to browse by tag, project, or recency. For tasks use list_tasks; for topic search use search_notes.",
parameters={
"q": {"type": "string", "description": "Optional keyword filter"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Filter notes that have ALL of these tags"},
"project": {"type": "string", "description": "Filter notes by project name"},
"sort": {"type": "string", "enum": ["updated_at", "created_at", "title"], "description": "Sort field (default: updated_at)"},
"limit": {"type": "integer", "description": "Maximum number of notes to return (default 10)"},
},
read_only=True,
briefing=True,
)
async def list_notes_tool(*, user_id, arguments, **_ctx):
tags_raw = arguments.get("tags")
tags = tags_raw if isinstance(tags_raw, list) else None
project_id = None
project_name = arguments.get("project")
if project_name:
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
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or arguments.get("query"),
tags=tags,
is_task=False,
project_id=project_id,
sort=arguments.get("sort", "updated_at"),
order="desc",
limit=int(arguments.get("limit", 10)),
)
results = [
{
"id": n.id,
"title": n.title,
"tags": n.tags or [],
"updated_at": n.updated_at.isoformat(),
"preview": (n.body[:200] + "...") if n.body and len(n.body) > 200 else (n.body or ""),
}
for n in notes
]
return {
"success": True,
"type": "notes_list",
"data": {"total": total, "count": len(results), "results": results},
}
@tool(
name="delete_note",
description="Delete any item from the user's knowledge base permanently — notes, tasks, persons (created via save_person), places (created via save_place), and lists (created via create_list) are all stored as notes and use this single delete tool. Use ONLY when the user explicitly asks to delete or remove an item. Always confirm with the user first — this cannot be undone.",
parameters={
"query": {"type": "string", "description": "Title or keyword to find the item to delete (works for notes, tasks, persons, places, and lists)"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this item deleted."},
},
required=["query"],
)
async def delete_note_tool(*, user_id, arguments, **_ctx):
query = arguments.get("query", "")
note = await get_note_by_title(user_id, query)
if note is None:
notes, _ = await list_notes(user_id=user_id, q=query, limit=3)
if not notes:
return {"success": False, "error": f"No note or task found matching '{query}'."}
note = notes[0]
if not arguments.get("confirmed"):
item_type = "task" if note.status is not None else "note"
return {
"success": False,
"requires_confirmation": True,
"error": f"Deleting {item_type} '{note.title}' is permanent. Ask the user to confirm, then retry with confirmed=true.",
}
deleted = await delete_note(user_id, note.id)
if not deleted:
return {"success": False, "error": "Failed to delete."}
item_type = "task" if note.status is not None else "note"
return {"success": True, "type": f"{item_type}_deleted", "data": {"id": note.id, "title": note.title}}
@@ -1,77 +0,0 @@
"""User profile tools."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="get_profile",
description=(
"Retrieve the user's stored profile: name, job title, industry, expertise level, "
"preferred response style, tone, and interests. Use this when you need to personalise "
"a response and the user's profile hasn't already been injected into context."
),
parameters={},
read_only=True,
)
async def get_profile_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.user_profile import get_profile as _get_profile
profile = await _get_profile(user_id)
return {
"success": True,
"type": "profile",
"data": {
"display_name": profile.display_name or "",
"job_title": profile.job_title or "",
"industry": profile.industry or "",
"expertise_level": profile.expertise_level or "intermediate",
"response_style": profile.response_style or "balanced",
"tone": profile.tone or "casual",
"interests": profile.interests or [],
},
}
@tool(
name="update_profile",
description=(
"Update the user's stored profile when they share personal information: their name, "
"job, industry, expertise, preferred response style, tone, or interests. "
"Only set fields the user has explicitly mentioned."
),
parameters={
"display_name": {"type": "string", "description": "User's preferred display name"},
"job_title": {"type": "string", "description": "Job title (e.g. 'Software Engineer')"},
"industry": {"type": "string", "description": "Industry (e.g. 'Healthcare', 'Finance')"},
"expertise_level": {"type": "string", "enum": ["novice", "intermediate", "expert"], "description": "User's general expertise level"},
"response_style": {"type": "string", "enum": ["concise", "balanced", "detailed"], "description": "Preferred response length/depth"},
"tone": {"type": "string", "enum": ["casual", "professional", "technical"], "description": "Preferred communication tone"},
"interests": {"type": "array", "items": {"type": "string"}, "description": "List of topics or hobbies the user is interested in"},
},
)
async def update_profile_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.user_profile import VALID_EXPERTISE, VALID_STYLES, VALID_TONES, update_profile as _update_profile
data: dict = {}
for field in ("display_name", "job_title", "industry"):
val = arguments.get(field)
if val is not None:
data[field] = str(val)
expertise = arguments.get("expertise_level")
if expertise in VALID_EXPERTISE:
data["expertise_level"] = expertise
style = arguments.get("response_style")
if style in VALID_STYLES:
data["response_style"] = style
tone = arguments.get("tone")
if tone in VALID_TONES:
data["tone"] = tone
interests = arguments.get("interests")
if isinstance(interests, list):
data["interests"] = [str(i) for i in interests if str(i).strip()]
if not data:
return {"success": False, "error": "No valid fields provided to update."}
await _update_profile(user_id, data)
return {"success": True, "type": "profile_updated", "data": {"fields_updated": list(data.keys())}}
@@ -1,250 +0,0 @@
"""Project and milestone tools."""
from __future__ import annotations
from fabledassistant.services.tools._helpers import fuzzy_title_match, resolve_project
from fabledassistant.services.tools._registry import tool
@tool(
name="create_project",
description="Create a new project. Use list_projects first to check for duplicates. Only call after user has explicitly confirmed.",
parameters={
"title": {"type": "string", "description": "Project title"},
"description": {"type": "string", "description": "Brief description"},
"goal": {"type": "string", "description": "The goal or desired outcome"},
"color": {"type": "string", "description": "Optional hex color for the project (e.g. '#6366f1')"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this project created."},
},
required=["title", "confirmed"],
)
async def create_project_tool(*, user_id, arguments, **_ctx):
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")
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 that project instead of creating a duplicate."}
all_projects = await _lp(user_id)
near_proj, ratio = fuzzy_title_match(proj_title, all_projects, threshold=0.55)
if near_proj is not None:
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,
description=arguments.get("description", ""),
goal=arguments.get("goal", ""),
color=arguments.get("color") or None,
)
return {"success": True, "type": "project", "data": project.to_dict()}
@tool(
name="list_projects",
description="List the user's projects. Use when asked about projects, initiatives, or campaigns.",
parameters={
"status": {"type": "string", "enum": ["active", "completed", "archived"], "description": "Filter by status (omit for all)"},
},
read_only=True,
briefing=True,
)
async def list_projects_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import list_projects as _list_projects
projects = await _list_projects(user_id, status=arguments.get("status"))
return {
"success": True,
"type": "projects_list",
"data": {"count": len(projects), "projects": [p.to_dict() for p in projects]},
}
@tool(
name="get_project",
description="Get details and summary of a specific project.",
parameters={
"query": {"type": "string", "description": "Project name or keyword to find it"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def get_project_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import get_project_by_title as _gpbt, get_project_summary as _gps, list_projects as _lp
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
summary = await _gps(user_id, project.id)
data = project.to_dict()
data["summary"] = summary
return {"success": True, "type": "project_detail", "data": data}
@tool(
name="update_project",
description="Update a project's title, status, description, goal, or color.",
parameters={
"query": {"type": "string", "description": "Project name to find"},
"title": {"type": "string", "description": "New project title"},
"status": {"type": "string", "enum": ["active", "completed", "archived"]},
"description": {"type": "string"},
"goal": {"type": "string"},
"color": {"type": "string", "description": "Hex color (e.g. '#6366f1'), or empty string to clear"},
},
required=["query"],
)
async def update_project_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import get_project_by_title as _gpbt, update_project as _up, list_projects as _lp
query = arguments.get("query", "")
project = await _gpbt(user_id, query)
if project is None:
all_projects = await _lp(user_id)
matches = [p for p in all_projects if query.lower() in p.title.lower()]
if matches:
project = matches[0]
if project is None:
return {"success": False, "error": f"No project found matching '{query}'"}
fields = {}
for k in ("title", "status", "description", "goal"):
if k in arguments:
fields[k] = arguments[k]
if "color" in arguments:
fields["color"] = arguments["color"] or None
updated = await _up(user_id, project.id, **fields)
return {"success": True, "type": "project_updated", "data": updated.to_dict()}
@tool(
name="search_projects",
description="Search for projects by name, description, or theme. Use this to find which project a user is referring to and get its ID for set_rag_scope.",
parameters={
"query": {"type": "string", "description": "Search query — project name, topic, or theme"},
},
required=["query"],
read_only=True,
briefing=True,
)
async def search_projects_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.projects import list_projects
from fabledassistant.services.tools._helpers import score_project_match
query = str(arguments.get("query", ""))
projects = await list_projects(user_id)
scored: list[tuple[float, object]] = [(score_project_match(query, p), p) for p in projects]
scored.sort(key=lambda x: x[0], reverse=True)
results = []
for score, p in scored[:5]:
results.append({
"id": p.id,
"title": p.title,
"summary_snippet": (p.auto_summary or p.description or "")[:200],
"score": round(score, 3),
})
return {"success": True, "type": "projects_list", "data": {"projects": results}}
@tool(
name="create_milestone",
description="Create a milestone inside a project. Confirm name and project with user before calling.",
parameters={
"project": {"type": "string", "description": "Project title"},
"title": {"type": "string", "description": "Milestone title"},
"description": {"type": "string", "description": "Optional description"},
"confirmed": {"type": "boolean", "description": "Must be true — only set after the user has explicitly confirmed they want this milestone created."},
},
required=["project", "title", "confirmed"],
)
async def create_milestone_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.milestones import create_milestone as _cm, get_milestone_by_title as _gmbt2, list_milestones as _lms
project_name = arguments.get("project", "")
ms_title = arguments.get("title", "Untitled Milestone")
if not project_name:
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}\" in project \"{project_name}\"?' Then retry with confirmed=true if they agree."}
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 find the correct project name and retry with the exact name."}
existing_ms = await _gmbt2(user_id, proj.id, ms_title)
if existing_ms is not None:
return {"success": False, "error": f"A milestone titled '{existing_ms.title}' already exists in '{proj.title}' (id: {existing_ms.id}). Use update_milestone to modify it instead."}
all_ms = await _lms(user_id, proj.id)
near_ms, ratio = fuzzy_title_match(ms_title, all_ms)
if near_ms is not None:
return {"success": False, "error": f"A milestone with a very similar title '{near_ms.title}' already exists in '{proj.title}' (id: {near_ms.id}, similarity: {ratio:.0%}). Use update_milestone to modify it instead."}
ms = await _cm(user_id, proj.id, title=ms_title, description=arguments.get("description"))
return {"success": True, "type": "milestone", "data": ms.to_dict()}
@tool(
name="update_milestone",
description="Update the title, description, or status of an existing milestone.",
parameters={
"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", "done"], "description": "New status: 'active' (in progress) or 'done' (completed)"},
},
required=["project", "milestone"],
)
async def update_milestone_tool(*, user_id, arguments, **_ctx):
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()}
@tool(
name="list_milestones",
description="List milestones for a project with their progress percentages.",
parameters={
"project": {"type": "string", "description": "Project name to look up"},
},
required=["project"],
read_only=True,
briefing=True,
)
async def list_milestones_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.milestones import get_project_milestone_summary
project_name = arguments.get("project", "")
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)
return {
"success": True,
"type": "milestones_list",
"data": {"project": proj.title, "count": len(summary), "milestones": summary},
}
-34
View File
@@ -1,34 +0,0 @@
"""RAG scope tool."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="set_rag_scope",
description="Change the RAG scope for this conversation. Use project_id=<int> to scope to a project, project_id=null to scope to orphan notes only, project_id=-1 for all notes.",
parameters={
"project_id": {"type": ["integer", "null"], "description": "Project ID to scope to, null for orphan-only, -1 for all notes"},
},
required=["project_id"],
)
async def set_rag_scope_tool(*, user_id, arguments, conv_id=None, workspace_project_id=None, **_ctx):
if workspace_project_id is not None:
return {"success": False, "error": "Cannot change RAG scope in workspace view"}
if conv_id is None:
return {"success": False, "error": "No conversation context available"}
project_id = arguments.get("project_id")
if project_id is not None and project_id != -1:
from fabledassistant.services.projects import get_project
proj = await get_project(user_id, int(project_id))
if proj is None:
return {"success": False, "error": "Project not found"}
scope_label = proj.title
elif project_id == -1:
scope_label = "All notes"
else:
scope_label = "Orphan notes only"
from fabledassistant.services.chat import update_conversation
await update_conversation(user_id, conv_id, rag_project_id=project_id)
return {"success": True, "type": "rag_scope_set", "scope_label": scope_label}
-127
View File
@@ -1,127 +0,0 @@
"""Task tools: list, log work."""
from __future__ import annotations
from fabledassistant.services.notes import get_note_by_title, list_notes
from fabledassistant.services.tools._helpers import (
parse_due_date,
resolve_project,
)
from fabledassistant.services.tools._registry import tool
@tool(
name="list_tasks",
description="List tasks with optional filters. For overdue tasks, set due_before to today's date.",
parameters={
"q": {"type": "string", "description": "Optional keyword filter — searches title and body"},
"status": {"type": "array", "items": {"type": "string", "enum": ["todo", "in_progress", "done", "cancelled"]}, "description": "Filter by one or more statuses. Omit for all."},
"priority": {"type": "string", "enum": ["none", "low", "medium", "high"], "description": "Filter by priority level"},
"due_before": {"type": "string", "description": "Return tasks due before this date (YYYY-MM-DD). Use today's date to find overdue tasks."},
"due_after": {"type": "string", "description": "Return tasks due on or after this date (YYYY-MM-DD)"},
"project": {"type": "string", "description": "Filter tasks by project name"},
"milestone": {"type": "string", "description": "Filter tasks by milestone name within a project"},
"limit": {"type": "integer", "description": "Maximum number of tasks to return (default 10)"},
},
read_only=True,
briefing=True,
)
async def list_tasks_tool(*, user_id, arguments, **_ctx):
project_id = None
milestone_id = None
project_name = arguments.get("project")
milestone_name = arguments.get("milestone")
if project_name:
proj = await resolve_project(user_id, project_name)
if proj:
project_id = proj.id
if milestone_name:
from fabledassistant.services.milestones import get_milestone_by_title
ms = await get_milestone_by_title(user_id, proj.id, milestone_name)
if ms:
milestone_id = ms.id
elif milestone_name:
from fabledassistant.services.milestones import find_milestone_by_title
ms = await find_milestone_by_title(user_id, milestone_name)
if ms:
milestone_id = ms.id
notes, total = await list_notes(
user_id=user_id,
q=arguments.get("q") or None,
is_task=True,
status=arguments.get("status"),
priority=arguments.get("priority"),
due_before=parse_due_date(arguments.get("due_before")),
due_after=parse_due_date(arguments.get("due_after")),
project_id=project_id,
milestone_id=milestone_id,
exclude_paused_projects=project_id is None,
limit=int(arguments.get("limit", 10)),
sort="due_date",
order="asc",
)
results = []
for n in notes:
results.append({
"id": n.id,
"title": n.title,
"status": n.status,
"priority": n.priority,
"due_date": str(n.due_date) if n.due_date else None,
"project_id": n.project_id,
"preview": (n.body[:120] + "...") if n.body and len(n.body) > 120 else (n.body or ""),
})
return {
"success": True,
"type": "tasks",
"data": {"total": total, "count": len(results), "results": results},
}
@tool(
name="log_work",
description=(
"Add a work log entry to a task to record progress, work done, or time spent. "
"Use this when the user says they worked on, completed, or spent time on a task. "
"Work logs feed the task's auto-summary: every few entries the task body is "
"rewritten from the logs by a background pass. Be specific (commands run, "
"decisions made, what failed vs. what worked) — the summary is only as good "
"as the logs."
),
parameters={
"task": {"type": "string", "description": "Title or keyword identifying the task (required)"},
"content": {"type": "string", "description": "Description of the work done (required)"},
"duration_minutes": {"type": "integer", "description": "Optional time spent in minutes"},
},
required=["task", "content"],
)
async def log_work_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.task_logs import create_log as _create_log
task_query = arguments.get("task", "")
content = arguments.get("content", "").strip()
if not task_query:
return {"success": False, "error": "task is required"}
if not content:
return {"success": False, "error": "content is required"}
duration_minutes = arguments.get("duration_minutes")
if duration_minutes is not None:
try:
duration_minutes = int(duration_minutes)
except (TypeError, ValueError):
duration_minutes = None
note = await get_note_by_title(user_id, task_query)
if note is None or note.status is None:
notes, _ = await list_notes(user_id=user_id, q=task_query, is_task=True, limit=5)
note = notes[0] if notes else None
if note is None:
return {"success": False, "error": f"No task found matching '{task_query}'."}
try:
log = await _create_log(user_id, note.id, content, duration_minutes)
except ValueError as e:
return {"success": False, "error": str(e)}
from fabledassistant.services.consolidation import maybe_consolidate
await maybe_consolidate(user_id, note.id, reason="log_added")
return {"success": True, "log": log.to_dict(), "task": note.title}
@@ -1,34 +0,0 @@
"""General-purpose utility tools."""
from __future__ import annotations
from fabledassistant.services.tools._registry import tool
@tool(
name="calculate",
description=(
"Evaluate a mathematical expression and return the exact result. Use this for any "
"arithmetic, percentages, unit conversions, or multi-step calculations where precision "
"matters. Supports standard math operators (+, -, *, /, **, %) and all Python math "
"module functions (sqrt, log, sin, cos, floor, ceil, etc.)."
),
parameters={
"expression": {"type": "string", "description": "A valid Python math expression (e.g. '(450 * 1.13) / 12', 'sqrt(144)', 'log(1000, 10)')"},
},
required=["expression"],
)
async def calculate_tool(*, user_id, arguments, **_ctx):
import math as _math
expr = str(arguments.get("expression", "")).strip()
if not expr:
return {"success": False, "error": "expression is required"}
allowed_names = {k: v for k, v in vars(_math).items() if not k.startswith("_")}
allowed_names["abs"] = abs
allowed_names["round"] = round
try:
result = eval(expr, {"__builtins__": {}}, allowed_names) # noqa: S307
except Exception as calc_err:
return {"success": False, "error": f"Could not evaluate expression: {calc_err}"}
return {"success": True, "type": "calculation", "data": {"expression": expr, "result": result}}
@@ -1,122 +0,0 @@
"""Weather tool."""
from __future__ import annotations
import json as _wx_json
import logging
from datetime import datetime, timedelta, timezone
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="get_weather",
description=(
"Get the weather forecast. By default returns today only — use days=7 when the user "
"explicitly asks for a multi-day forecast or 'this week'. "
"Pass a city/region name to look up any location, or omit to get the user's configured home/work locations."
),
parameters={
"location": {"type": "string", "description": "City/region to look up, or 'home'/'work' for saved locations. Omit for all saved."},
"days": {"type": "integer", "description": "Number of days to return (18). Default 1 (today only). Use 7 or 8 when the user asks for a weekly forecast or multiple days."},
},
read_only=True,
briefing=True,
)
async def get_weather_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.weather import (
_fetch_open_meteo,
geocode,
get_cached_weather,
get_temp_unit,
parse_forecast,
refresh_location_cache,
)
location = (arguments.get("location") or "").strip()
num_days = max(1, min(8, int(arguments.get("days") or 1)))
temp_unit = await get_temp_unit(user_id)
def _apply_unit(days: list[dict]) -> list[dict]:
if temp_unit != "F":
return days
def _c_to_f(v: float | None) -> float | None:
return round(v * 9 / 5 + 32, 1) if v is not None else None
return [
{**d, "temp_max": _c_to_f(d.get("temp_max")), "temp_min": _c_to_f(d.get("temp_min"))}
for d in days
]
_known = {"home", "work", "all", ""}
if location.lower() not in _known:
try:
lat, lon, label = await geocode(location)
raw = await _fetch_open_meteo(lat, lon)
days = _apply_unit(parse_forecast(raw))[:num_days]
return {"success": True, "data": {"locations": [{
"location_key": "query",
"location_label": label,
"fetched_at": datetime.now(timezone.utc).isoformat(),
"days": days,
"temp_unit": temp_unit,
"changes_since_last_fetch": [],
}]}}
except Exception as e:
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
locations = await get_cached_weather(user_id)
if location.lower() not in ("all", ""):
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
now_utc = datetime.now(timezone.utc)
stale_cutoff = now_utc - timedelta(hours=6)
stale_keys = []
for loc in locations:
try:
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
except Exception:
fetched = None
if fetched is None or fetched < stale_cutoff:
stale_keys.append(loc["location_key"])
if stale_keys:
try:
from fabledassistant.services.settings import get_setting as _wx_get_setting
raw_cfg = await _wx_get_setting(user_id, "journal_config", "{}")
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
for key in stale_keys:
meta = cfg_locs.get(key) or {}
if meta.get("lat") and meta.get("lon"):
try:
await refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=meta.get("label", key),
lat=meta["lat"],
lon=meta["lon"],
)
except Exception:
logger.debug("Weather refresh failed for %s", key, exc_info=True)
locations = await get_cached_weather(user_id)
if location.lower() not in ("all", ""):
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
except Exception:
logger.debug("Weather staleness refresh failed", exc_info=True)
stamped_locations = []
for loc in locations:
days = _apply_unit(loc.get("days", []))[:num_days]
try:
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
age_h = (now_utc - fetched).total_seconds() / 3600.0
except Exception:
age_h = None
stamped_locations.append({
**loc,
"days": days,
"temp_unit": temp_unit,
"cache_age_hours": round(age_h, 1) if age_h is not None else None,
"is_stale": age_h is not None and age_h > 12.0,
})
return {"success": True, "data": {"locations": stamped_locations}}
-195
View File
@@ -1,195 +0,0 @@
"""Web search, research, and image tools."""
from __future__ import annotations
import logging
from fabledassistant.services.tools._registry import tool
logger = logging.getLogger(__name__)
@tool(
name="lookup",
description=(
"Look up a topic, concept, or factual question. Returns a concise answer from "
"Wikipedia or web sources. Use for definitions, explanations, 'what is X', "
"'how does Y work', current events, or version numbers. No note is saved. "
"For comprehensive written reports saved as notes, use research_topic instead."
),
parameters={
"query": {"type": "string", "description": "The topic or question to look up"},
},
required=["query"],
)
async def lookup_tool(*, user_id, arguments, **_ctx):
import asyncio
from fabledassistant.config import Config
from fabledassistant.services.wikipedia import wiki_summary
query = arguments.get("query", "").strip()
if not query:
return {"success": False, "error": "query is required"}
searxng_enabled = Config.searxng_enabled()
async def _searxng_results() -> list[dict]:
if not searxng_enabled:
return []
from fabledassistant.services.research import _search_searxng
return await _search_searxng(query) or []
wiki, search_results = await asyncio.gather(
wiki_summary(query),
_searxng_results(),
)
wiki_payload: dict | None = None
if wiki:
wiki_payload = {
"title": wiki["title"],
"extract": wiki["extract"],
"url": wiki["url"],
}
thumb_url = wiki.get("thumbnail_url") or ""
if thumb_url:
from fabledassistant.services.images import fetch_and_store_image
record = await fetch_and_store_image(
url=thumb_url,
title=wiki["title"],
source_domain="en.wikipedia.org",
)
if record:
wiki_payload["image"] = {
"embed": f"![{wiki['title']}](/api/images/{record.id})",
"citation": f"*Source: [Wikipedia]({wiki['url']})*",
}
web_payload: list[dict] = []
if search_results:
# Sequential fetches: trafilatura/lxml is not safe to run concurrently
# via run_in_executor — parallel calls can trip a libxml2 double-free.
from fabledassistant.services.article_fetcher import fetch_article_text
for r in search_results[:2]:
url = r.get("url", "")
if not url:
continue
try:
content = await fetch_article_text(url)
except Exception:
content = None
web_payload.append({
"url": url,
"title": r.get("title", url),
"snippet": r.get("snippet", ""),
"content": (content or "")[:4000],
})
if not wiki_payload and not web_payload:
return {
"success": True,
"type": "lookup",
"data": {
"query": query,
"message": "No results found. You can answer from your own knowledge.",
},
}
data: dict = {
"query": query,
"wikipedia": wiki_payload,
"web": web_payload,
}
if wiki_payload and wiki_payload.get("image"):
data["image_instructions"] = (
"If an image is relevant to your reply, embed it by writing the wikipedia.image.embed "
"field verbatim, then the citation field on the next line. Otherwise omit it."
)
return {
"success": True,
"type": "lookup",
"data": data,
}
@tool(
name="research_topic",
description=(
"Deep web research — searches multiple sources, synthesizes findings, and saves the result "
"as a structured note with sections and citations. Use when the user says 'research', "
"'look into', or wants a comprehensive write-up. Takes 30120 seconds. "
"For a quick factual answer without saving a note, use lookup."
),
parameters={
"topic": {"type": "string", "description": "The topic or question to research"},
},
required=["topic"],
requires="searxng",
)
async def research_topic_tool(*, user_id, arguments, **_ctx):
# Research is always intercepted in generation_task.py (round 0) before execute_tool.
# Reaching here indicates a code path regression.
topic = arguments.get("topic", "")
logger.error(
"research_topic reached execute_tool — should have been intercepted upstream "
"in generation_task.py. topic=%r",
topic,
)
return {"success": False, "error": "Research could not be started. Please try again."}
@tool(
name="search_images",
description="Search and display images inline. Use ONLY when the user explicitly asks to see, show, or find an image or photo. Not for factual questions — use lookup for those.",
parameters={
"query": {"type": "string", "description": "The image search query"},
},
required=["query"],
requires="searxng",
)
async def search_images_tool(*, user_id, arguments, **_ctx):
from fabledassistant.services.images import fetch_and_store_image
from fabledassistant.services.research import _search_searxng_images
query = arguments.get("query", "")
if not query:
return {"success": False, "error": "query is required"}
raw_results = await _search_searxng_images(query)
if not raw_results:
return {"success": False, "error": f"No images found for '{query}'"}
images = []
for r in raw_results[:2]:
img_url = r.get("img_src", "")
if not img_url:
continue
record = await fetch_and_store_image(
url=img_url,
title=r.get("title"),
source_domain=r.get("source_domain"),
)
if record:
title = record.title or query
page_url = r.get("page_url", "")
source_domain = r.get("source_domain", "")
images.append({
"embed": f"![{title}](/api/images/{record.id})",
"citation": f"*Source: [{source_domain or 'image'}]({page_url})*",
"page_url": page_url,
"title": title,
})
if not images:
return {"success": False, "error": f"Could not retrieve images for '{query}'"}
return {
"success": True,
"type": "image_search",
"data": {
"query": query,
"images": images,
"instructions": (
"Embed each image in your response by writing the 'embed' field verbatim, "
"then the 'citation' field on the next line. Do not paraphrase or list URLs."
),
},
}
-288
View File
@@ -1,288 +0,0 @@
"""Text-to-speech service using piper-tts (in-process, CPU).
Voice model files (.onnx + .onnx.json sidecar) live in two directories:
- /opt/piper-voices/ — bundled at image build time, read-only.
- /data/voices/ — admin-downloaded at runtime, persisted across
restarts via the same volume as other /data/*.
A single PiperVoice is kept loaded at a time (one voice per request would
be too slow; keeping all warm would use a lot of RAM for a feature most
users won't enable). The active voice is whichever one the user picked
via Settings → Voice — selection changes trigger reload_tts_model().
"""
from __future__ import annotations
import asyncio
import io
import json
import logging
import time
import wave
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from piper import PiperVoice
logger = logging.getLogger(__name__)
# Two directories scanned for voices. Order matters for tie-breaking when
# both contain the same voice id — /data wins (admin-downloaded override).
_BUNDLED_VOICES_DIR = Path("/opt/piper-voices")
_USER_VOICES_DIR = Path("/data/voices")
_DEFAULT_VOICE_ID = "en_US-amy-medium"
_voice: "PiperVoice | None" = None
_voice_id: str | None = None # id of the currently-loaded voice
_voice_lock = asyncio.Lock()
_load_error: str | None = None
def _voice_dirs() -> list[Path]:
"""Directories scanned for voice files, in precedence order (later wins)."""
return [_BUNDLED_VOICES_DIR, _USER_VOICES_DIR]
def _discover_voice(voice_id: str) -> Path | None:
"""Find the .onnx file for `voice_id` across the scanned directories.
/data wins over /opt if both contain the same id so an admin-downloaded
voice can override a bundled one if they want.
"""
found: Path | None = None
for d in _voice_dirs():
candidate = d / f"{voice_id}.onnx"
if candidate.is_file() and (d / f"{voice_id}.onnx.json").is_file():
found = candidate
return found
def _scan_voices() -> list[Path]:
"""Return all valid voice files (.onnx + .onnx.json) across all dirs.
Same-id duplicates are deduplicated; later directories override earlier
so a /data voice shadows a bundled one with the same name.
"""
by_id: dict[str, Path] = {}
for d in _voice_dirs():
if not d.is_dir():
continue
for onnx in sorted(d.glob("*.onnx")):
sidecar = onnx.with_suffix(".onnx.json")
if not sidecar.is_file():
continue
by_id[onnx.stem] = onnx
return list(by_id.values())
def _voice_metadata(onnx_path: Path) -> dict:
"""Read the .onnx.json sidecar and return a UI-shaped metadata dict.
The sidecar's full schema is large; we surface only what the picker
needs: id (filename stem), label (derived), language, quality, sample_rate.
"""
sidecar = onnx_path.with_suffix(".onnx.json")
info: dict = {}
try:
info = json.loads(sidecar.read_text())
except Exception:
logger.warning("Could not parse piper sidecar %s", sidecar, exc_info=True)
voice_id = onnx_path.stem
lang_info = info.get("language") or {}
audio_info = info.get("audio") or {}
# Filename convention: <lang>-<dataset>-<quality> e.g. en_US-amy-medium
parts = voice_id.split("-")
dataset = parts[1] if len(parts) >= 2 else voice_id
quality = parts[2] if len(parts) >= 3 else "medium"
lang_code = lang_info.get("code") or (parts[0] if parts else "")
region_name = lang_info.get("name_native") or lang_info.get("region") or lang_code
return {
"id": voice_id,
"label": f"{dataset.capitalize()} ({region_name}, {quality})",
"language": lang_code,
"quality": quality,
"sample_rate": audio_info.get("sample_rate", 22050),
}
async def load_tts_model() -> None:
"""Load the active piper voice. Called once at startup if voice is enabled.
The active voice is determined by the per-user `voice_tts_voice` setting
when known; falls back to `_DEFAULT_VOICE_ID` if unset or invalid.
Because the active voice is per-user but the loaded model is a single
in-process resource, this loads the default at startup; per-user voice
selection triggers a reload at request time.
"""
global _voice, _voice_id, _load_error
from fabledassistant.services.voice_config import is_voice_enabled
if not await is_voice_enabled():
return
async with _voice_lock:
if _voice is not None:
return
try:
target_id = _DEFAULT_VOICE_ID
onnx_path = _discover_voice(target_id)
if onnx_path is None:
# No bundled default? Fall back to whatever is on disk.
discovered = _scan_voices()
if not discovered:
_load_error = (
"No piper voice models found in /opt/piper-voices "
"or /data/voices"
)
logger.error(_load_error)
return
onnx_path = discovered[0]
target_id = onnx_path.stem
logger.warning(
"Default voice %r not found; using %r instead",
_DEFAULT_VOICE_ID, target_id,
)
logger.info("Loading piper TTS voice %s from %s", target_id, onnx_path)
loop = asyncio.get_running_loop()
from piper import PiperVoice
_voice = await loop.run_in_executor(
None, lambda: PiperVoice.load(str(onnx_path))
)
_voice_id = target_id
logger.info("Piper TTS voice %s loaded", target_id)
except Exception:
_load_error = "Failed to load piper TTS voice"
logger.exception(_load_error)
async def reload_tts_model(voice_id: str | None = None) -> None:
"""Unload the current voice and load `voice_id` (or re-load the default).
Safe to call at runtime — used when the user changes voice in Settings.
"""
global _voice, _voice_id, _load_error
async with _voice_lock:
_voice = None
_voice_id = None
_load_error = None
if voice_id:
await _switch_voice(voice_id)
else:
await load_tts_model()
async def _switch_voice(voice_id: str) -> None:
"""Internal: load a specific voice by id. No-op if already loaded."""
global _voice, _voice_id, _load_error
async with _voice_lock:
if _voice is not None and _voice_id == voice_id:
return
onnx_path = _discover_voice(voice_id)
if onnx_path is None:
_load_error = f"Voice {voice_id!r} not found in /opt or /data"
logger.warning(_load_error)
return
try:
logger.info("Switching piper voice to %s", voice_id)
loop = asyncio.get_running_loop()
from piper import PiperVoice
_voice = await loop.run_in_executor(
None, lambda: PiperVoice.load(str(onnx_path))
)
_voice_id = voice_id
_load_error = None
except Exception:
_load_error = f"Failed to load piper voice {voice_id!r}"
logger.exception(_load_error)
def tts_available() -> bool:
return _voice is not None
def list_voices() -> list[dict]:
"""Return metadata for every voice on disk, in stable order."""
return [_voice_metadata(p) for p in _scan_voices()]
async def synthesise(
text: str,
voice: str = _DEFAULT_VOICE_ID,
speed: float = 1.0,
voice_blend: list[dict] | None = None, # accepted for backcompat; ignored
) -> bytes:
"""Synthesise text → WAV bytes (PCM 16-bit mono). Runs in executor.
`voice_blend` is accepted for backward compatibility with callers that
still pass it, but piper has no voice-blending equivalent, so it is
silently ignored. The first voice in the blend (if any) is used; if
that doesn't exist on disk, the request uses the default voice.
`speed` is the kokoro-shaped multiplier (1.0=normal, 1.5=faster).
Piper uses `length_scale` where smaller = faster, so the conversion
is `length_scale = 1.0 / speed`.
"""
# Backcompat: if blend was passed, just use the first entry's voice.
if voice_blend and isinstance(voice_blend, list) and voice_blend:
first = voice_blend[0]
if isinstance(first, dict) and first.get("voice"):
voice = str(first["voice"])
# Lazy-load / switch if needed.
if _voice is None or _voice_id != voice:
await _switch_voice(voice)
if _voice is None:
raise RuntimeError(f"TTS voice {voice!r} not loaded: {_load_error}")
speed = max(0.5, min(2.0, speed))
length_scale = 1.0 / speed
def _run() -> bytes:
# piper.SynthesisConfig is optional; defaults are sensible.
# We override length_scale only.
from piper import SynthesisConfig
config = SynthesisConfig(length_scale=length_scale)
t0 = time.monotonic()
buf = io.BytesIO()
sample_rate = _voice.config.sample_rate # type: ignore[union-attr]
total_samples = 0
with wave.open(buf, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2) # 16-bit PCM
wav.setframerate(sample_rate)
try:
for chunk in _voice.synthesize(text, config): # type: ignore[union-attr]
pcm = chunk.audio_int16_bytes
if not pcm:
continue
wav.writeframes(pcm)
total_samples += len(pcm) // 2
except Exception:
logger.exception(
"Piper synthesis error: %d chars, preview=%r",
len(text), text[:80],
)
raise
elapsed = time.monotonic() - t0
logger.info(
"Piper synthesis: %d chars → %d samples (%.2fs, %.0f chars/s, voice=%s)",
len(text), total_samples, elapsed,
len(text) / elapsed if elapsed > 0 else 0,
_voice_id,
)
if not total_samples:
return b""
return buf.getvalue()
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, _run)

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