From 5d2d27c499cc834c7280385694f6bf112350e4ba Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 20 May 2026 18:54:35 -0400
Subject: [PATCH 001/118] fix(journal): anti-hallucination hardening +
message_count fix
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Prep prose (services/journal_prep.py):
- Emit explicit "WEATHER: none available — do NOT mention weather"
absent-marker so a small model can't invent partly-cloudy/temperature
prose when both configured locations have empty addresses.
- Replace negative-only system rule with positive-anchored guidance
forbidding weather/temp/precip mentions unless a numeric WEATHER
section is present; also bans echoing parenthetical labels verbatim.
- Reword overdue header to "(past their due date, still open — backlog,
not today's work)" and render lines as "was due , N day(s)
overdue" with correct singular/plural. Supersedes the wording noted
in Fable task #159.
- Deterministic fabricated-weather reconciler: low-false-positive regex
detects fabricated weather phrasing; on trip with an empty section,
regenerate once with a corrective. Persistent fabrication logs ERROR
rather than mangling prose.
Journal route (routes/journal.py):
- Override message_count with len(messages) in _day_payload. The chat
path already does this; the journal path was hitting the
Conversation.to_dict() fallback to 0 because messages aren't
eager-loaded on that instance.
Tests:
- tests/test_journal_message_count.py — pins the model-level trap and
the override contract (3 cases).
- tests/test_journal_prep_hardening.py — 11 cases covering the
fabricated-weather reconciler and absent-marker rendering.
- tests/test_journal_prep_filtering.py — updated one stale assertion.
Tracks Fable task #171.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/fabledassistant/routes/journal.py | 8 +-
src/fabledassistant/services/journal_prep.py | 139 +++++++++++++++----
tests/test_journal_message_count.py | 60 ++++++++
tests/test_journal_prep_filtering.py | 4 +-
tests/test_journal_prep_hardening.py | 123 ++++++++++++++++
5 files changed, 306 insertions(+), 28 deletions(-)
create mode 100644 tests/test_journal_message_count.py
create mode 100644 tests/test_journal_prep_hardening.py
diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py
index 462e840..910a9b0 100644
--- a/src/fabledassistant/routes/journal.py
+++ b/src/fabledassistant/routes/journal.py
@@ -407,8 +407,14 @@ async def _day_payload(*, user_id: int, day_date: datetime.date):
.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.to_dict(),
+ "conversation": conv_dict,
"messages": [m.to_dict() for m in messages],
})
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
index f1d9dcf..c661dcf 100644
--- a/src/fabledassistant/services/journal_prep.py
+++ b/src/fabledassistant/services/journal_prep.py
@@ -23,6 +23,7 @@ from __future__ import annotations
import datetime
import logging
+import re
from zoneinfo import ZoneInfo
from sqlalchemy import select
@@ -249,7 +250,9 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
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"):
- line += f" (due {t['due_date']}, {t['days_overdue']} days ago)"
+ 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"):
@@ -277,7 +280,7 @@ def _render_sections_for_prompt(sections: dict) -> str:
lines.append(_render_task_line(t, include_due=True, include_overdue=False))
lines.append("")
if overdue:
- lines.append("OVERDUE TASKS (still on the list, not currently due):")
+ 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("")
@@ -312,6 +315,17 @@ def _render_sections_for_prompt(sections: dict) -> str:
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:
@@ -342,8 +356,14 @@ def _render_sections_for_prompt(sections: dict) -> str:
lines.append(f" - [{day}] {content}")
lines.append("")
- if not lines:
- return "(No data for today — quiet morning.)"
+ # 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()
@@ -360,15 +380,19 @@ _PREP_SYSTEM_PROMPT = (
"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, surface it with the "
- "staleness duration (\"still on the list 68 days\") and frame it as something to "
- "revisit, not as today's work. If the only data is overdue, lead with it but "
- "frame it as a backlog reminder.\n"
+ "\"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"
- "- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\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"
"- Voice is competent assistant briefing the user. Not a friend writing a letter."
)
@@ -379,6 +403,40 @@ def _fallback_prep_text(day_date: datetime.date) -> str:
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 ",
+# "(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 8–14B 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,
@@ -400,25 +458,54 @@ async def _generate_prep_prose(
f"Write the opener for today's journal."
)
- messages = [
- {"role": "system", "content": _PREP_SYSTEM_PROMPT},
- {"role": "user", "content": user_trigger},
- ]
+ _WEATHER_CORRECTION = (
+ "\n\nIMPORTANT: there is NO weather data for today. Do not mention "
+ "weather, temperature, sky conditions, or precipitation in any form."
+ )
- try:
- prose = await generate_completion(
- messages=messages,
- model=model,
- max_tokens=400,
- )
- except Exception:
- logger.exception("Daily prep prose generation failed for day %s", day_date)
- return _fallback_prep_text(day_date)
+ # 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 = (prose or "").strip()
- if not prose:
- logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
- return _fallback_prep_text(day_date)
+ 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
diff --git a/tests/test_journal_message_count.py b/tests/test_journal_message_count.py
new file mode 100644
index 0000000..4975791
--- /dev/null
+++ b/tests/test_journal_message_count.py
@@ -0,0 +1,60 @@
+"""Regression coverage for the journal `message_count: 0` bug.
+
+`_day_payload` (routes/journal.py) loads messages in a separate query and
+serializes the Conversation with `conv.to_dict()`. `Conversation.to_dict()`
+derives `message_count` from the `messages` relationship, which is NOT
+eager-loaded on that instance — so it silently fell back to 0 for every
+journal day (observed via the fable MCP: conversation #291 reported
+message_count 0 with 9 messages present).
+
+Full HTTP coverage of `_day_payload` needs a live DB (not available in the
+unit-test env — see test_events_routes.py). These tests pin the model-level
+contract that necessitates the route-side override.
+"""
+from datetime import date, datetime, timezone
+
+from fabledassistant.models.conversation import Conversation, Message
+
+
+def _conv() -> Conversation:
+ now = datetime(2026, 5, 19, 9, 0, tzinfo=timezone.utc)
+ return Conversation(
+ user_id=1,
+ conversation_type="journal",
+ day_date=date(2026, 5, 19),
+ title="2026-05-19",
+ created_at=now,
+ updated_at=now,
+ )
+
+
+def test_to_dict_reports_zero_when_messages_relationship_not_loaded():
+ """The trap: an untouched `messages` relationship is absent from the
+ instance state, so to_dict() reports 0 regardless of DB rows. This is
+ exactly the journal path's situation and why the route must override."""
+ conv = _conv()
+ assert conv.to_dict()["message_count"] == 0
+
+
+def test_to_dict_counts_when_messages_loaded():
+ """When the relationship IS populated the count is correct — confirming
+ the model isn't broken, the journal path just never loaded it."""
+ conv = _conv()
+ conv.messages = [
+ Message(conversation_id=1, role="assistant", content="prep"),
+ Message(conversation_id=1, role="user", content="hi"),
+ ]
+ assert conv.to_dict()["message_count"] == 2
+
+
+def test_day_payload_override_yields_true_count():
+ """Pins the fix contract: _day_payload already holds the real message
+ list and overrides message_count with len(messages), the same way the
+ chat-list path (services/chat.py) supplies its own count."""
+ conv = _conv() # messages relationship deliberately not loaded
+ messages = [object(), object(), object()] # stand-ins for the loaded rows
+
+ conv_dict = conv.to_dict()
+ conv_dict["message_count"] = len(messages)
+
+ assert conv_dict["message_count"] == 3
diff --git a/tests/test_journal_prep_filtering.py b/tests/test_journal_prep_filtering.py
index 58f68fe..362dca3 100644
--- a/tests/test_journal_prep_filtering.py
+++ b/tests/test_journal_prep_filtering.py
@@ -145,7 +145,9 @@ def test_render_overdue_includes_staleness_duration():
}
rendered = _render_sections_for_prompt(sections)
assert "OVERDUE TASKS" in rendered
- assert "68 days ago" in rendered
+ # Unambiguous overdue phrasing (replaced the old "68 days ago", which the
+ # model parroted alongside the header into a self-contradiction).
+ assert "68 days overdue" in rendered
assert "2026-02-20" in rendered
# Crucially, NOT framed as due today.
assert "DUE TODAY" not in rendered
diff --git a/tests/test_journal_prep_hardening.py b/tests/test_journal_prep_hardening.py
new file mode 100644
index 0000000..5b50e21
--- /dev/null
+++ b/tests/test_journal_prep_hardening.py
@@ -0,0 +1,123 @@
+"""Coverage for the daily-prep hardening pass.
+
+Targets the two prep-quality defects observed via the fable MCP on
+2026-05-19 (conversation #291):
+
+ 1. Fabricated weather ("partly cloudy with a high of 68°F and a 15%
+ chance of rain") emitted with an empty weather section.
+ 2. Self-contradictory overdue phrasing ("still on the list, not
+ currently due, 88 days ago"; "1 days ago").
+
+All targets are pure sync functions — no DB / LLM needed.
+"""
+import datetime
+
+from fabledassistant.services.journal_prep import (
+ _prose_fabricated_weather,
+ _render_sections_for_prompt,
+ _render_task_line,
+)
+
+TODAY = datetime.date(2026, 5, 19)
+
+
+# ── Overdue phrasing ────────────────────────────────────────────────────────
+
+
+def test_overdue_line_singular_day():
+ line = _render_task_line(
+ {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
+ include_due=False, include_overdue=True,
+ )
+ assert "1 day overdue" in line
+ assert "1 days" not in line # the old ungrammatical form is gone
+
+
+def test_overdue_line_plural_days_no_contradiction():
+ line = _render_task_line(
+ {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
+ include_due=False, include_overdue=True,
+ )
+ assert "88 days overdue" in line
+ assert "was due 2026-02-20" in line
+ assert "days ago" not in line # replaced by unambiguous "overdue"
+
+
+def test_overdue_section_header_is_unambiguous():
+ out = _render_sections_for_prompt({
+ "tasks_overdue": [
+ {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
+ ],
+ })
+ assert "past their due date, still open" in out
+ # The phrase that the model parroted into the self-contradiction is gone.
+ assert "not currently due" not in out
+
+
+# ── Weather absent-marker ───────────────────────────────────────────────────
+
+
+def test_empty_weather_emits_explicit_none_marker():
+ out = _render_sections_for_prompt({
+ "tasks_overdue": [
+ {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
+ ],
+ "weather": [],
+ })
+ assert "WEATHER: none available" in out
+ assert "Do NOT mention weather" in out
+
+
+def test_present_weather_renders_data_not_marker():
+ out = _render_sections_for_prompt({
+ "tasks_due_today": [{"title": "Ship it"}],
+ "weather": [{
+ "location_label": "Home",
+ "forecast_json": {"daily": {
+ "temperature_2m_max": [70],
+ "temperature_2m_min": [52],
+ "precipitation_probability_max": [10],
+ }},
+ }],
+ })
+ assert "WEATHER:" in out
+ assert "none available" not in out
+ assert "high 70°" in out
+
+
+def test_quiet_day_returns_fabrication_forbidding_text():
+ out = _render_sections_for_prompt({})
+ assert "quiet day" in out
+ assert "Do not invent weather" in out
+
+
+# ── Fabricated-weather guard ────────────────────────────────────────────────
+
+
+def test_guard_flags_the_real_observed_hallucination():
+ prose = (
+ "You have two overdue tasks. The weather today is partly cloudy "
+ "with a high of 68°F and a 15% chance of rain. What's on your mind?"
+ )
+ assert _prose_fabricated_weather(prose, {"weather": []}) is True
+
+
+def test_guard_flags_bare_temperature_glyph():
+ assert _prose_fabricated_weather("Expect around 72° later.", {"weather": []}) is True
+
+
+def test_guard_passes_clean_prose():
+ prose = "Two tasks are overdue and nothing is due today. What's on your mind?"
+ assert _prose_fabricated_weather(prose, {"weather": []}) is False
+
+
+def test_guard_no_false_positive_on_rain_in_task_title():
+ # Bare "rain" must NOT trip the guard — only "chance of rain" etc. does.
+ prose = "Don't forget to buy rain boots and finish the training module."
+ assert _prose_fabricated_weather(prose, {"weather": []}) is False
+
+
+def test_guard_allows_weather_when_data_present():
+ prose = "Home is 70°F with a 10% chance of rain."
+ sections = {"weather": [{"location_label": "Home"}]}
+ assert _prose_fabricated_weather(prose, sections) is False
From 3f1bcc336002c1c07add3b48a352278096d379df Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 20 May 2026 20:16:26 -0400
Subject: [PATCH 002/118] ci: consume shared ci-python:3.14 image; bump runtime
to 3.14
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Migrate to the FabledRulebook CI-Runner contract:
- .forgejo/workflows/ci.yml: all four jobs (typecheck/lint/test/build)
now schedule on the `python-ci` runner label and run inside
container.image: git.fabledsword.com/bvandeusen/ci-python:3.14
(Python 3.14 + Node 24 + ruff + uv + Docker CLI). Dropped the inline
uv install in the test job — uv is now baked into the image.
- Dockerfile: production runtime bumped to python:3.14-slim so test
results stay representative against what we ship.
- ci-requirements.md: new file at repo root declaring image deps and
per-job installs (per FabledRulebook ci-runners.md).
- infra/Dockerfile.runner-base: deleted. The in-repo runner base
(Ubuntu 24.04 + Python 3.12 + Node 22) is superseded by the shared
ci-python image. The runner-host deployment files
(runner-compose.yml + act-runner-config.yml) stay as deployment-shape
documentation; source of truth is the deployed config.
- docs/development.md: CI/CD + Runner sections refreshed.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.forgejo/workflows/ci.yml | 18 +++++++++----
Dockerfile | 3 ++-
ci-requirements.md | 49 ++++++++++++++++++++++++++++++++++++
docs/development.md | 18 ++++++++-----
infra/Dockerfile.runner-base | 48 -----------------------------------
5 files changed, 76 insertions(+), 60 deletions(-)
create mode 100644 ci-requirements.md
delete mode 100644 infra/Dockerfile.runner-base
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 4ff4ecb..ed9998c 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -64,7 +64,9 @@ jobs:
name: TypeScript typecheck
# Skip on main merge-commit pushes — see workflow header comment.
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
- runs-on: ci-runner
+ runs-on: python-ci
+ container:
+ image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
@@ -86,11 +88,13 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
- runs-on: ci-runner
+ runs-on: python-ci
+ container:
+ image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
- # ruff is pre-installed in the ci-runner base image — no install
+ # ruff is pre-installed in the ci-python image — no install
# step needed, lint runs in ~2s.
- name: Lint
run: ruff check src/
@@ -98,7 +102,9 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
- runs-on: ci-runner
+ runs-on: python-ci
+ container:
+ image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
@@ -132,7 +138,9 @@ jobs:
# pushes skip here too, so no production image is ever built from a
# raw main push (only from the v* tag the release creates).
if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v')
- runs-on: ci-runner
+ runs-on: python-ci
+ container:
+ image: git.fabledsword.com/bvandeusen/ci-python:3.14
permissions:
contents: read
packages: write
diff --git a/Dockerfile b/Dockerfile
index 931d8cd..d3fc9e5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -8,7 +8,8 @@ COPY frontend/ .
RUN npm run build
# Stage 2: Python runtime
-FROM python:3.12-slim AS runtime
+# Tracks CI image (ci-python:3.14) so test results stay representative.
+FROM python:3.14-slim AS runtime
WORKDIR /app
COPY pyproject.toml .
diff --git a/ci-requirements.md b/ci-requirements.md
new file mode 100644
index 0000000..ca436a8
--- /dev/null
+++ b/ci-requirements.md
@@ -0,0 +1,49 @@
+# CI Requirements — FabledScribe
+
+> Spec lives in [`docs/process.md`](https://git.fabledsword.com/bvandeusen/CI-runner/src/branch/main/docs/process.md)
+> in the CI-Runner repo.
+
+## Runtime image
+
+```
+git.fabledsword.com/bvandeusen/ci-python:3.14
+```
+
+Used by all four jobs in `.forgejo/workflows/ci.yml`: typecheck (Vue/TS),
+lint (ruff), test (pytest), build (docker buildx).
+
+## Image deps used
+
+- python 3.14
+- node 24 (used for `npm ci` + `vue-tsc` in the typecheck job, and as the
+ frontend builder stage inside the production `Dockerfile`)
+- ruff (lint job runs `ruff check src/` with zero install overhead)
+- docker CLI + buildx (build job pushes the production image to the
+ Forgejo registry)
+
+## Per-job tool installs
+
+Anything CI installs at job time that isn't in the image. Promotion
+candidates if more than one project needs them.
+
+- `uv` — installed inline in the test job (`curl -LsSf
+ https://astral.sh/uv/install.sh | sh`). **Temporary**: belongs in the
+ ci-python image so every consumer doesn't re-install on cold start.
+ Tracked at [CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner).
+- `http-ece` is `--no-build-isolation`-installed before the editable
+ package install because http-ece doesn't declare `setuptools` as a
+ build dep and uv creates bare venvs without it. Not promotion-worthy
+ (one project, one wheel).
+
+## Notes
+
+- Production runtime image (`Dockerfile`) also tracks Python 3.14 — the
+ CI image and runtime image stay aligned by design so test results are
+ representative.
+- Build wall time: dominated by `pytest` (full async test suite). Cold
+ ci-python pulls add ~30s; not a blocker.
+- Registry-backed BuildKit layer cache (`type=registry,ref=…:cache,mode=max`)
+ gives ~80% speedup on warm builds — see the build job comment.
+- `pyproject.toml` keeps `requires-python = ">=3.12"` permissive so the
+ package can still be installed against older interpreters by external
+ consumers, but the CI/runtime target is 3.14.
diff --git a/docs/development.md b/docs/development.md
index 592ae86..cbc4fa7 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -57,7 +57,9 @@ Migration conventions:
### Pipeline
-CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
+CI runs on Forgejo Actions, consuming the shared
+[`ci-python:3.14`](https://git.fabledsword.com/bvandeusen/CI-runner) image
+via `container.image` (Python 3.14 + Node 24 + ruff + uv + Docker CLI):
| Trigger | Jobs | Docker tags pushed |
|---------|------|--------------------|
@@ -77,13 +79,17 @@ CI runs on Forgejo Actions with a custom runner base image (`py3.12-node22`):
git checkout dev && git merge main && git push origin dev
```
-### Custom Runner
+### Runner
-Runner base image: `infra/Dockerfile.runner-base` (Ubuntu 24.04 + Python 3.12 + Node 22 LTS).
-Runner config: `infra/act-runner-config.yml` (label: `py3.12-node22`).
-Runner compose: `infra/runner-compose.yml`.
+CI jobs schedule against the `python-ci` runner label and run inside the
+shared `git.fabledsword.com/bvandeusen/ci-python:3.14` image (see
+`ci-requirements.md` for what this project relies on from the image).
+The runner deployment lives outside this repo; image bumps happen in
+[CI-Runner](https://git.fabledsword.com/bvandeusen/CI-runner) via Renovate.
-To activate a new runner registration, copy `infra/act-runner-config.yml` to the runner's config directory, delete the `.runner` registration file in the runner container, and restart the stack.
+`infra/runner-compose.yml` + `infra/act-runner-config.yml` document the
+runner-host deployment shape; the source of truth is the deployed
+config on the runner host.
### Docker Registry
diff --git a/infra/Dockerfile.runner-base b/infra/Dockerfile.runner-base
deleted file mode 100644
index b4fa7e9..0000000
--- a/infra/Dockerfile.runner-base
+++ /dev/null
@@ -1,48 +0,0 @@
-# Runner base image for Forgejo act_runner job containers.
-# Pre-installs Python 3.12, Node 22, uv, and ruff so workflows skip
-# lengthy runtime installs on every run.
-#
-# Build and push (re-run when this file changes):
-# docker build -f infra/Dockerfile.runner-base \
-# -t git.fabledsword.com/bvandeusen/runner-base:ci-runner .
-# docker push git.fabledsword.com/bvandeusen/runner-base:ci-runner
-#
-# Then redeploy the act_runner stack in Portainer to pick up the new image.
-
-FROM ubuntu:24.04
-
-ENV DEBIAN_FRONTEND=noninteractive
-ENV TZ=UTC
-
-# Split into separate RUN steps so each pushed layer is smaller — one
-# combined layer exceeds the nginx upload timeout on the self-hosted
-# Forgejo registry.
-
-# Python 3.12 (ships in Ubuntu 24.04 main repos) + core tools
-RUN apt-get update -qq && \
- apt-get install -y -qq \
- python3.12 python3.12-dev python3.12-venv \
- git curl ca-certificates gnupg jq tzdata && \
- apt-get clean && rm -rf /var/lib/apt/lists/*
-
-# Node 22 LTS via NodeSource
-RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
- apt-get install -y -qq nodejs && \
- apt-get clean && rm -rf /var/lib/apt/lists/*
-
-# Docker CLI — daemon comes from the host socket mount, only CLI needed.
-# docker.io from Ubuntu repos is sufficient for docker login + buildx.
-RUN apt-get update -qq && \
- apt-get install -y -qq docker.io && \
- apt-get clean && rm -rf /var/lib/apt/lists/*
-
-# uv — fast Python package installer/resolver (~10x faster than pip).
-# Used by test jobs instead of pip for venv creation + dep installs.
-RUN curl -LsSf https://astral.sh/uv/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
- mv /root/.local/bin/uv /usr/local/bin/ && \
- mv /root/.local/bin/uvx /usr/local/bin/
-
-# ruff — Python linter. Baked in so the lint job is just
-# `ruff check src/` with zero install overhead.
-RUN curl -LsSf https://astral.sh/ruff/install.sh | env INSTALLER_NO_MODIFY_PATH=1 sh && \
- mv /root/.local/bin/ruff /usr/local/bin/
From 6cf70e22db83a9da8da93be2c0ac1ab97591a9d7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 20 May 2026 20:18:19 -0400
Subject: [PATCH 003/118] compose(db): lenient healthcheck + stop_grace_period
to survive host stalls
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Mitigation for the nightly fabledscribe Postgres outage on the
vdnt-docker02 Swarm node (incidents 2026-05-15/16/17 around 03:50 UTC).
Confirmed kill chain (not the trigger): a brief host-level setns/exec
stall makes the Docker healthcheck exec fail with exit 1 → unhealthy →
SIGKILL → fast-shutdown can't finish on NFS in 10s → exit 137 → swarm
restart_policy.max_attempts: 5 burns out → DB stays dead.
Hardens the `db` service so a transient host blip can't escalate to
killing the database:
- stop_grace_period: 120s (gives PG room to fsync on shutdown)
- healthcheck: interval 30s / timeout 10s / retries 10 / start_period 180s
(only gates app startup order — not authoritative liveness)
- prod: restart_policy condition=on-failure, max_attempts=0, window=120s
- quickstart/dev: restart: unless-stopped
Host-side trigger (what stalls runc/exec at ~03:50 UTC) is still under
investigation — see project_pg_nightly_outage.md.
Note: the Portainer prod stack differs from docker-compose.prod.yml
here (NFS bind, traefik labels, no ollama). The same `db:` block needs
to be pasted into Portainer for the prod mitigation to apply.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
docker-compose.prod.yml | 15 +++++++++++----
docker-compose.quickstart.yml | 10 +++++++---
docker-compose.yml | 11 ++++++++---
3 files changed, 26 insertions(+), 10 deletions(-)
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 890d0df..7d0fac1 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -24,6 +24,7 @@ services:
db:
image: postgres:16-alpine
+ stop_grace_period: 120s
volumes:
- pgdata:/var/lib/postgresql/data
environment:
@@ -32,15 +33,21 @@ services:
POSTGRES_DB: fabledassistant
networks:
- fabledassistant_backend
+ # Lenient by design: a transient host exec/healthcheck stall (incident:
+ # runc setns failures -> "unhealthy" -> SIGKILL -> crash loop) must never
+ # escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"]
- interval: 10s
- timeout: 5s
- retries: 5
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 180s
deploy:
restart_policy:
condition: on-failure
- max_attempts: 5
+ delay: 10s
+ max_attempts: 0
+ window: 120s
ollama:
image: ollama/ollama
diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml
index e2a9ee5..882e80b 100644
--- a/docker-compose.quickstart.yml
+++ b/docker-compose.quickstart.yml
@@ -39,17 +39,21 @@ services:
db:
image: postgres:16-alpine
+ stop_grace_period: 120s
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: fabled
POSTGRES_PASSWORD: fabled
POSTGRES_DB: fabledassistant
+ # Lenient by design: a transient host exec/healthcheck stall must never
+ # escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fabled"]
- interval: 10s
- timeout: 5s
- retries: 5
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 180s
restart: unless-stopped
ollama:
diff --git a/docker-compose.yml b/docker-compose.yml
index 7be1606..2a06564 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -34,17 +34,22 @@ services:
db:
image: postgres:16-alpine
+ stop_grace_period: 120s
+ restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${POSTGRES_USER:-fabled}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-fabled}
POSTGRES_DB: ${POSTGRES_DB:-fabledassistant}
+ # Lenient by design: a transient host exec/healthcheck stall must never
+ # escalate to killing the DB. Health here only gates app startup order.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-fabled}"]
- interval: 5s
- timeout: 5s
- retries: 5
+ interval: 30s
+ timeout: 10s
+ retries: 10
+ start_period: 180s
ollama:
image: ollama/ollama
From 41d252e9d1e43c1430b70437fca94cf62ef18f41 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 20 May 2026 20:25:01 -0400
Subject: [PATCH 004/118] deps: pin requires-python = ">=3.14"; commit uv.lock
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Match CI + runtime target exactly — both run Python 3.14, so the
package metadata signals consumers that we don't test against 3.12/3.13.
uv.lock is tracked so the test job's `uv venv` resolution is
reproducible (currently the test job installs the editable package
without consulting the lockfile; future work could wire `uv sync` in).
Lockfile resolves 179 packages against Python 3.14.4.
ci-requirements.md updated to drop the prior "permissive lower bound"
caveat.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
ci-requirements.md | 6 +-
pyproject.toml | 2 +-
uv.lock | 2955 ++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 2959 insertions(+), 4 deletions(-)
create mode 100644 uv.lock
diff --git a/ci-requirements.md b/ci-requirements.md
index ca436a8..3f7aad2 100644
--- a/ci-requirements.md
+++ b/ci-requirements.md
@@ -44,6 +44,6 @@ candidates if more than one project needs them.
ci-python pulls add ~30s; not a blocker.
- Registry-backed BuildKit layer cache (`type=registry,ref=…:cache,mode=max`)
gives ~80% speedup on warm builds — see the build job comment.
-- `pyproject.toml` keeps `requires-python = ">=3.12"` permissive so the
- package can still be installed against older interpreters by external
- consumers, but the CI/runtime target is 3.14.
+- `pyproject.toml` pins `requires-python = ">=3.14"` to match the CI +
+ runtime target; lockfile (`uv.lock`) is committed and resolves against
+ Python 3.14.
diff --git a/pyproject.toml b/pyproject.toml
index c530669..a363e4b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta"
name = "fabledassistant"
version = "0.1.0"
description = "Self-hosted note-taking and task-tracking app with LLM integration"
-requires-python = ">=3.12"
+requires-python = ">=3.14"
dependencies = [
"quart>=0.19",
"sqlalchemy[asyncio]>=2.0",
diff --git a/uv.lock b/uv.lock
new file mode 100644
index 0000000..76e38e6
--- /dev/null
+++ b/uv.lock
@@ -0,0 +1,2955 @@
+version = 1
+revision = 3
+requires-python = ">=3.14"
+
+[[package]]
+name = "addict"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/ef/fd7649da8af11d93979831e8f1f8097e85e82d5bfeabc8c68b39175d8e75/addict-2.4.0.tar.gz", hash = "sha256:b3b2210e0e067a281f5646c8c5db92e99b7231ea8b0eb5f74dbdf9e259d4e494", size = 9186, upload-time = "2020-11-21T16:21:31.416Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6a/00/b08f23b7d7e1e14ce01419a467b583edbb93c6cdb8654e54a9cc579cd61f/addict-2.4.0-py3-none-any.whl", hash = "sha256:249bb56bbfd3cdc2a004ea0ff4c2b6ddc84d53bc2194761636eb314d5cfa5dfc", size = 3832, upload-time = "2020-11-21T16:21:29.588Z" },
+]
+
+[[package]]
+name = "aiofiles"
+version = "25.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
+]
+
+[[package]]
+name = "aiohappyeyeballs"
+version = "2.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" },
+]
+
+[[package]]
+name = "aiohttp"
+version = "3.13.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohappyeyeballs" },
+ { name = "aiosignal" },
+ { name = "attrs" },
+ { name = "frozenlist" },
+ { name = "multidict" },
+ { name = "propcache" },
+ { name = "yarl" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" },
+ { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" },
+ { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" },
+ { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" },
+ { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" },
+ { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" },
+ { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" },
+ { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" },
+ { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" },
+ { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" },
+ { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" },
+ { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" },
+ { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" },
+ { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" },
+ { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" },
+ { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
+]
+
+[[package]]
+name = "aiosignal"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "frozenlist" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
+]
+
+[[package]]
+name = "aiosmtplib"
+version = "5.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" },
+]
+
+[[package]]
+name = "alembic"
+version = "1.18.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mako" },
+ { name = "sqlalchemy" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
+]
+
+[[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" },
+]
+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 = "apscheduler"
+version = "3.11.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzlocal" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" },
+]
+
+[[package]]
+name = "asyncpg"
+version = "0.31.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
+ { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
+ { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
+ { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
+ { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
+ { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
+]
+
+[[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 = "av"
+version = "17.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b2/eb/abca886df3a091bc406feb5ff71b4c4f426beaae6b71b9697264ce8c7211/av-17.0.0.tar.gz", hash = "sha256:c53685df73775a8763c375c7b2d62a6cb149d992a26a4b098204da42ade8c3df", size = 4410769, upload-time = "2026-03-14T14:38:45.868Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b1/fb/55e3b5b5d1fc61466292f26fbcbabafa2642f378dc48875f8f554591e1a4/av-17.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ed4013fac77c309a4a68141dcf6148f1821bb1073a36d4289379762a6372f711", size = 23238424, upload-time = "2026-03-14T14:38:05.856Z" },
+ { url = "https://files.pythonhosted.org/packages/52/03/9ace1acc08bc9ae38c14bf3a4b1360e995e4d999d1d33c2cbd7c9e77582a/av-17.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:e44b6c83e9f3be9f79ee87d0b77a27cea9a9cd67bd630362c86b7e56a748dfbb", size = 18709043, upload-time = "2026-03-14T14:38:08.288Z" },
+ { url = "https://files.pythonhosted.org/packages/00/c0/637721f3cd5bb8bd16105a1a08efd781fc12f449931bdb3a4d0cfd63fa55/av-17.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b440da6ac47da0629d509316f24bcd858f33158dbdd0f1b7293d71e99beb26de", size = 34018780, upload-time = "2026-03-14T14:38:10.45Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/59/d19bc3257dd985d55337d7f0414c019414b97e16cd3690ebf9941a847543/av-17.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1060cba85f97f4a337311169d92c0b5e143452cfa5ca0e65fa499d7955e8592e", size = 36358757, upload-time = "2026-03-14T14:38:13.092Z" },
+ { url = "https://files.pythonhosted.org/packages/52/6c/a1f4f2677bae6f2ade7a8a18e90ebdcf70690c9b1c4e40e118aa30fa313f/av-17.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:deda202e6021cfc7ba3e816897760ec5431309d59a4da1f75df3c0e9413d71e7", size = 35195281, upload-time = "2026-03-14T14:38:15.789Z" },
+ { url = "https://files.pythonhosted.org/packages/90/ea/52b0fc6f69432c7bf3f5fbe6f707113650aa40a1a05b9096ffc2bba4f77d/av-17.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffaf266a1a9c2148072de0a4b5ae98061465178d2cfaa69ee089761149342974", size = 37444817, upload-time = "2026-03-14T14:38:18.563Z" },
+ { url = "https://files.pythonhosted.org/packages/34/ad/d2172966282cb8f146c13b6be7416efefde74186460c5e1708ddfc13dba6/av-17.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:45a35a40b2875bf2f98de7c952d74d960f92f319734e6d28e03b4c62a49e6f49", size = 28888553, upload-time = "2026-03-14T14:38:21.223Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/bb/c5a4c4172c514d631fb506e6366b503576b8c7f29809cf42aca73e28ff01/av-17.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:3d32e9b5c5bbcb872a0b6917b352a1db8a42142237826c9b49a36d5dbd9e9c26", size = 21916910, upload-time = "2026-03-14T14:38:23.706Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/8e/c40ac08e63f79387c59f6ecc38f47d4c942b549130eee579ec1a91f6a291/av-17.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:d13250fb4b4522e9a6bec32da082556d5f257110ea223758151375748d9bbe25", size = 23483029, upload-time = "2026-03-14T14:38:25.758Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/fb/b4419494bfc249163ec393c613966d66db7e95c76da3345711cd115a79df/av-17.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:dbb56aa3b7ae72451d1bf6e9d37c7d83d39b97af712f73583ff419fbf08fc237", size = 18920446, upload-time = "2026-03-14T14:38:27.905Z" },
+ { url = "https://files.pythonhosted.org/packages/30/62/c2306d91602ddad2c56106f21dcb334fd51d5ea2e952f7fa025bb8aa39fc/av-17.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a213ac9e83b7ab12c2e9f277a09cac8e9d85cf0883efdab7a87a60e2e4e48879", size = 37477266, upload-time = "2026-03-14T14:38:30.404Z" },
+ { url = "https://files.pythonhosted.org/packages/28/cd/c8510a9607886785c0b3ca019d503e888c3757529be42a7287fe2bfa92d5/av-17.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:e15c88bb0921f9435bcc5a27a0863dba571a80ad5e1389c4fcf2073833bb4a74", size = 39572988, upload-time = "2026-03-14T14:38:32.984Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/2d/207d9361e25b5abec9be335bbab4df6b6b838e2214be4b374f4cfb285427/av-17.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:096cfd1e9fc896506726c7c42aaf9b370e78c2f257cde4d6ddb6c889bfcc49ec", size = 38399591, upload-time = "2026-03-14T14:38:35.465Z" },
+ { url = "https://files.pythonhosted.org/packages/73/ca/307740c6aa2980966bf11383ffcb04bacc5b13f3d268ab4cfb274ad6f793/av-17.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3649ab3d2c7f58049ded1a36e100c0d8fd529cf258f41dd88678ba824034d8c9", size = 40590681, upload-time = "2026-03-14T14:38:38.269Z" },
+ { url = "https://files.pythonhosted.org/packages/35/f2/6fdb26d0651adf409864cb2a0d60da107e467d3d1aabc94b234ead54324a/av-17.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e5002271ab2135b551d980c2db8f3299d452e3b9d3633f24f6bb57fffe91cd10", size = 29216337, upload-time = "2026-03-14T14:38:40.83Z" },
+ { url = "https://files.pythonhosted.org/packages/41/0a/0896b829a39b5669a2d811e1a79598de661693685cd62b31f11d0c18e65b/av-17.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dba98603fc4665b4f750de86fbaf6c0cfaece970671a9b529e0e3d1711e8367e", size = 22071058, upload-time = "2026-03-14T14:38:43.663Z" },
+]
+
+[[package]]
+name = "babel"
+version = "2.18.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" },
+]
+
+[[package]]
+name = "bcrypt"
+version = "5.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef", size = 481862, upload-time = "2025-09-25T19:49:28.365Z" },
+ { url = "https://files.pythonhosted.org/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4", size = 268544, upload-time = "2025-09-25T19:49:30.39Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf", size = 271787, upload-time = "2025-09-25T19:49:32.144Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da", size = 269753, upload-time = "2025-09-25T19:49:33.885Z" },
+ { url = "https://files.pythonhosted.org/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9", size = 289587, upload-time = "2025-09-25T19:49:35.144Z" },
+ { url = "https://files.pythonhosted.org/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f", size = 272178, upload-time = "2025-09-25T19:49:36.793Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493", size = 269295, upload-time = "2025-09-25T19:49:38.164Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b", size = 271700, upload-time = "2025-09-25T19:49:39.917Z" },
+ { url = "https://files.pythonhosted.org/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c", size = 334034, upload-time = "2025-09-25T19:49:41.227Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4", size = 352766, upload-time = "2025-09-25T19:49:43.08Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e", size = 152449, upload-time = "2025-09-25T19:49:44.971Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d", size = 149310, upload-time = "2025-09-25T19:49:46.162Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993", size = 143761, upload-time = "2025-09-25T19:49:47.345Z" },
+ { url = "https://files.pythonhosted.org/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
+ { url = "https://files.pythonhosted.org/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
+ { url = "https://files.pythonhosted.org/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
+ { url = "https://files.pythonhosted.org/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
+ { url = "https://files.pythonhosted.org/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
+ { url = "https://files.pythonhosted.org/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
+ { url = "https://files.pythonhosted.org/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
+ { url = "https://files.pythonhosted.org/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
+ { url = "https://files.pythonhosted.org/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
+ { url = "https://files.pythonhosted.org/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
+ { url = "https://files.pythonhosted.org/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
+ { url = "https://files.pythonhosted.org/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
+ { url = "https://files.pythonhosted.org/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
+ { url = "https://files.pythonhosted.org/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
+ { url = "https://files.pythonhosted.org/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
+ { url = "https://files.pythonhosted.org/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
+]
+
+[[package]]
+name = "blinker"
+version = "1.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
+]
+
+[[package]]
+name = "blis"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d0/d0/d8cc8c9a4488a787e7fa430f6055e5bd1ddb22c340a751d9e901b82e2efe/blis-1.3.3.tar.gz", hash = "sha256:034d4560ff3cc43e8aa37e188451b0440e3261d989bb8a42ceee865607715ecd", size = 2644873, upload-time = "2025-11-17T12:28:30.511Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5f/8a/80f7c68fbc24a76fc9c18522c46d6d69329c320abb18e26a707a5d874083/blis-1.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3e33cfbf22a418373766816343fcfcd0556012aa3ffdf562c29cddec448a415", size = 6934081, upload-time = "2025-11-17T12:28:16.436Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/52/d1aa3a51a7fc299b0c89dcaa971922714f50b1202769eebbdaadd1b5cff7/blis-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6f165930e8d3a85c606d2003211497e28d528c7416fbfeafb6b15600963f7c9b", size = 1231486, upload-time = "2025-11-17T12:28:18.008Z" },
+ { url = "https://files.pythonhosted.org/packages/99/4f/badc7bd7f74861b26c10123bba7b9d16f99cd9535ad0128780360713820f/blis-1.3.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:878d4d96d8f2c7a2459024f013f2e4e5f46d708b23437dae970d998e7bff14a0", size = 2814944, upload-time = "2025-11-17T12:28:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/72/a6/f62a3bd814ca19ec7e29ac889fd354adea1217df3183e10217de51e2eb8b/blis-1.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f36c0ca84a05ee5d3dbaa38056c4423c1fc29948b17a7923dd2fed8967375d74", size = 11345825, upload-time = "2025-11-17T12:28:21.354Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/6c/671af79ee42bc4c968cae35c091ac89e8721c795bfa4639100670dc59139/blis-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e5a662c48cd4aad5dae1a950345df23957524f071315837a4c6feb7d3b288990", size = 3008771, upload-time = "2025-11-17T12:28:23.637Z" },
+ { url = "https://files.pythonhosted.org/packages/be/92/7cd7f8490da7c98ee01557f2105885cc597217b0e7fd2eeb9e22cdd4ef23/blis-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9de26fbd72bac900c273b76d46f0b45b77a28eace2e01f6ac6c2239531a413bb", size = 14219213, upload-time = "2025-11-17T12:28:26.143Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/de/acae8e9f9a1f4bb393d41c8265898b0f29772e38eac14e9f69d191e2c006/blis-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:9e5fdf4211b1972400f8ff6dafe87cb689c5d84f046b4a76b207c0bd2270faaf", size = 6324695, upload-time = "2025-11-17T12:28:28.401Z" },
+]
+
+[[package]]
+name = "caldav"
+version = "3.1.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "dnspython" },
+ { name = "icalendar" },
+ { name = "icalendar-searcher" },
+ { name = "lxml" },
+ { name = "niquests" },
+ { name = "python-dateutil" },
+ { name = "pyyaml" },
+ { name = "recurring-ical-events" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2f/13/d1a9f0b97ea6e674bdd003d0d1500ef5b9d5a28f5f5c38fac088a9808dc0/caldav-3.1.0.tar.gz", hash = "sha256:0b6dc76a462c0c32a6dbdc537eda832226bd4d9dacf3906247626b68db5b10b6", size = 10316756, upload-time = "2026-03-19T18:09:33.839Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cc/fd/e2e9f12d7b86296894dfc211d7120a4a740288761e5f5349b54ebb490461/caldav-3.1.0-py3-none-any.whl", hash = "sha256:9d6713f75273bc82f58e2fb7684ecb8196f09ee309609d36577c1c5eec22152b", size = 232470, upload-time = "2026-03-19T18:09:24.212Z" },
+]
+
+[[package]]
+name = "catalogue"
+version = "2.0.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/38/b4/244d58127e1cdf04cf2dc7d9566f0d24ef01d5ce21811bab088ecc62b5ea/catalogue-2.0.10.tar.gz", hash = "sha256:4f56daa940913d3f09d589c191c74e5a6d51762b3a9e37dd53b7437afd6cda15", size = 19561, upload-time = "2023-09-25T06:29:24.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/96/d32b941a501ab566a16358d68b6eb4e4acc373fab3c3c4d7d9e649f7b4bb/catalogue-2.0.10-py3-none-any.whl", hash = "sha256:58c2de0020aa90f4a2da7dfad161bf7b3b054c86a5f09fcedc0b2b740c109a9f", size = 17325, upload-time = "2023-09-25T06:29:23.337Z" },
+]
+
+[[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/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 = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[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 = "cloudpathlib"
+version = "0.23.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f4/18/2ac35d6b3015a0c74e923d94fc69baf8307f7c3233de015d69f99e17afa8/cloudpathlib-0.23.0.tar.gz", hash = "sha256:eb38a34c6b8a048ecfd2b2f60917f7cbad4a105b7c979196450c2f541f4d6b4b", size = 53126, upload-time = "2025-10-07T22:47:56.278Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ae/8a/c4bb04426d608be4a3171efa2e233d2c59a5c8937850c10d098e126df18e/cloudpathlib-0.23.0-py3-none-any.whl", hash = "sha256:8520b3b01468fee77de37ab5d50b1b524ea6b4a8731c35d1b7407ac0cd716002", size = 62755, upload-time = "2025-10-07T22:47:54.905Z" },
+]
+
+[[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 = "confection"
+version = "1.3.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ca/65/efd0fe8a936fc8ca2978cb7b82581fb20d901c6039e746a808f746b7647b/confection-1.3.3.tar.gz", hash = "sha256:f0f6810d567ff73993fe74d218ca5e1ffb6a44fb03f391257fc5d033546cbfaa", size = 54895, upload-time = "2026-03-24T18:45:24.331Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8d/e4/d66708bdf0d92fb4d49b22cdff4b10cec38aca5dcd7e81d909bb55c65cd7/confection-1.3.3-py3-none-any.whl", hash = "sha256:b9fef9ee84b237ef4611ec3eb5797b70e13063e6310ad9f15536373f5e313c82", size = 35902, upload-time = "2026-03-24T18:45:22.664Z" },
+]
+
+[[package]]
+name = "courlan"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "babel" },
+ { name = "tld" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/6f/54/6d6ceeff4bed42e7a10d6064d35ee43a810e7b3e8beb4abeae8cff4713ae/courlan-1.3.2.tar.gz", hash = "sha256:0b66f4db3a9c39a6e22dd247c72cfaa57d68ea660e94bb2c84ec7db8712af190", size = 206382, upload-time = "2024-10-29T16:40:20.994Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/ca/6a667ccbe649856dcd3458bab80b016681b274399d6211187c6ab969fc50/courlan-1.3.2-py3-none-any.whl", hash = "sha256:d0dab52cf5b5b1000ee2839fbc2837e93b2514d3cb5bb61ae158a55b7a04c6be", size = 33848, upload-time = "2024-10-29T16:40:18.325Z" },
+]
+
+[[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 = "csvw"
+version = "3.7.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "babel" },
+ { name = "isodate" },
+ { name = "jsonschema" },
+ { name = "language-tags" },
+ { name = "python-dateutil" },
+ { name = "rdflib" },
+ { name = "requests" },
+ { name = "rfc3986" },
+ { name = "termcolor" },
+ { name = "uritemplate" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d4/90/64efac40e52949079c2b4030167ee68ec52cf061f11e368ee1a82e410670/csvw-3.7.0.tar.gz", hash = "sha256:869b5c761481e52c01a99fb4749b278a4b8b0db4e0fa1965a33a3441c703465b", size = 74789, upload-time = "2025-10-07T10:46:28.729Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/cb/19e8e582fc164db200c18078bdbdcc60c012cb83c7f02ea8e876bc0b1adf/csvw-3.7.0-py2.py3-none-any.whl", hash = "sha256:21b88db50a35e940d4b5cdd8f3a8084493ad7f1bb1657ed7323aad977359940e", size = 60685, upload-time = "2025-10-07T10:46:26.708Z" },
+]
+
+[[package]]
+name = "ctranslate2"
+version = "4.7.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+ { name = "pyyaml" },
+ { name = "setuptools" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fd/73/8a6b7ba18cad0c8667ee221ddab8c361cb70926440e5b8dd0e81924c28ac/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d5dfb076566551f4959dfd0706f94c923c1931def9b7bb249a2caa6ab23353a0", size = 1257560, upload-time = "2026-02-04T06:12:00.926Z" },
+ { url = "https://files.pythonhosted.org/packages/70/c2/8817ca5d6c1b175b23a12f7c8b91484652f8718a76353317e5919b038733/ctranslate2-4.7.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:eecdb4ed934b384f16e8c01b185b082d6b5ffc7dcbb0b6a6eb48cd465282d957", size = 11918995, upload-time = "2026-02-04T06:12:02.875Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/33/b8eb3acc67bbca4d9872fc9ff94db78e6167a7ba5cd932f585d1560effc7/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aa6796edcc3c8d163c9e39c429d50076d266d68980fed9d1b2443f617c67e9e", size = 16844162, upload-time = "2026-02-04T06:12:05.099Z" },
+ { url = "https://files.pythonhosted.org/packages/80/11/6474893b07121057035069a0a483fe1cd8c47878213f282afb4c0c6fc275/ctranslate2-4.7.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24c0482c51726430fb83724451921c0e539d769c8618dcfd46b1645e7f75960d", size = 38966728, upload-time = "2026-02-04T06:12:07.923Z" },
+ { url = "https://files.pythonhosted.org/packages/94/88/8fc7ff435c5e783e5fad9586d839d463e023988dbbbad949d442092d01f1/ctranslate2-4.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:76db234c0446a23d20dd8eeaa7a789cc87d1d05283f48bf3152bae9fa0a69844", size = 19100788, upload-time = "2026-02-04T06:12:10.592Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/b3/f100013a76a98d64e67c721bd4559ea4eeb54be3e4ac45f4d801769899af/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:058c9db2277dc8b19ecc86c7937628f69022f341844b9081d2ab642965d88fc6", size = 1280179, upload-time = "2026-02-04T06:12:12.596Z" },
+ { url = "https://files.pythonhosted.org/packages/39/22/b77f748015667a5e2ca54a5ee080d7016fce34314f0e8cf904784549305a/ctranslate2-4.7.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5abcf885062c7f28a3f9a46be8d185795e8706ac6230ad086cae0bc82917df31", size = 11940166, upload-time = "2026-02-04T06:12:14.054Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/78/6d7fd52f646c6ba3343f71277a9bbef33734632949d1651231948b0f0359/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9950acb04a002d5c60ae90a1ddceead1a803af1f00cadd9b1a1dc76e1f017481", size = 16849483, upload-time = "2026-02-04T06:12:17.082Z" },
+ { url = "https://files.pythonhosted.org/packages/40/27/58769ff15ac31b44205bd7a8aeca80cf7357c657ea5df1b94ce0f5c83771/ctranslate2-4.7.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1dcc734e92e3f1ceeaa0c42bbfd009352857be179ecd4a7ed6cccc086a202f58", size = 38949393, upload-time = "2026-02-04T06:12:21.302Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/5c/9fa0ad6462b62efd0fb5ac1100eee47bc96ecc198ff4e237c731e5473616/ctranslate2-4.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dfb7657bdb7b8211c8f9ecb6f3b70bc0db0e0384d01a8b1808cb66fe7199df59", size = 19123451, upload-time = "2026-02-04T06:12:24.115Z" },
+]
+
+[[package]]
+name = "cuda-bindings"
+version = "13.2.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cuda-pathfinder" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" },
+]
+
+[[package]]
+name = "cuda-pathfinder"
+version = "1.5.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/f9/1b9b60a30fc463c14cdea7a77228131a0ccc89572e8df9cb86c9648271ab/cuda_pathfinder-1.5.2-py3-none-any.whl", hash = "sha256:0c5f160a7756c5b072723cbbd6d861e38917ef956c68150b02f0b6e9271c71fa", size = 49988, upload-time = "2026-04-06T23:01:05.17Z" },
+]
+
+[[package]]
+name = "cuda-toolkit"
+version = "13.0.2"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/57/b2/453099f5f3b698d7d0eab38916aac44c7f76229f451709e2eb9db6615dcd/cuda_toolkit-13.0.2-py2.py3-none-any.whl", hash = "sha256:b198824cf2f54003f50d64ada3a0f184b42ca0846c1c94192fa269ecd97a66eb", size = 2364, upload-time = "2025-12-19T23:24:07.328Z" },
+]
+
+[package.optional-dependencies]
+cublas = [
+ { name = "nvidia-cublas", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+cudart = [
+ { name = "nvidia-cuda-runtime", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+cufft = [
+ { name = "nvidia-cufft", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+cufile = [
+ { name = "nvidia-cufile", marker = "sys_platform == 'linux'" },
+]
+cupti = [
+ { name = "nvidia-cuda-cupti", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+curand = [
+ { name = "nvidia-curand", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+cusolver = [
+ { name = "nvidia-cusolver", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+cusparse = [
+ { name = "nvidia-cusparse", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+nvjitlink = [
+ { name = "nvidia-nvjitlink", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+nvrtc = [
+ { name = "nvidia-cuda-nvrtc", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+nvtx = [
+ { name = "nvidia-nvtx", marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
+]
+
+[[package]]
+name = "curated-tokenizers"
+version = "0.0.9"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "regex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/fc/fa/b2d55f0d53c7c7f5dc0b6dbb48cc4344ee84fb572f23de28040bf2cde89d/curated-tokenizers-0.0.9.tar.gz", hash = "sha256:c93d47e54ab3528a6db2796eeb4bdce5d44e8226c671e42c2f23522ab1d0ce25", size = 2237055, upload-time = "2024-01-18T13:45:52.36Z" }
+
+[[package]]
+name = "curated-transformers"
+version = "0.1.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/70/06/6c12c149a7f737dacc76b4c3949dbc7ff87d622567b86996896ae4d104aa/curated-transformers-0.1.1.tar.gz", hash = "sha256:4671f03314df30efda2ec2b59bc7692ea34fcea44cb65382342c16684e8a2119", size = 16313, upload-time = "2023-05-24T07:29:22.801Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/67/3b72b3fdfcadab61bc8f59c17e63770e526ffabd583ed32f174a7c01af85/curated_transformers-0.1.1-py2.py3-none-any.whl", hash = "sha256:d716063d73d803c6925d2dab56fde9b9ab8e89e663c2c0587804944ba488ff01", size = 25972, upload-time = "2023-05-24T07:29:21.119Z" },
+]
+
+[[package]]
+name = "cymem"
+version = "2.0.13"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/2f0fbb32535c3731b7c2974c569fb9325e0a38ed5565a08e1139a3b71e82/cymem-2.0.13.tar.gz", hash = "sha256:1c91a92ae8c7104275ac26bd4d29b08ccd3e7faff5893d3858cb6fadf1bc1588", size = 12320, upload-time = "2025-11-14T14:58:36.902Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a7/2e/f0e1596010a9a57fa9ebd124a678c07c5b2092283781ae51e79edcf5cb98/cymem-2.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2a4bf67db76c7b6afc33de44fb1c318207c3224a30da02c70901936b5aafdf1", size = 43812, upload-time = "2025-11-14T14:58:04.227Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/45/8ccc21df08fcbfa6aa3efeb7efc11a1c81c90e7476e255768bb9c29ba02a/cymem-2.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:92a2ce50afa5625fb5ce7c9302cee61e23a57ccac52cd0410b4858e572f8614b", size = 42951, upload-time = "2025-11-14T14:58:05.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/8c/fe16531631f051d3d1226fa42e2d76fd2c8d5cfa893ec93baee90c7a9d90/cymem-2.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bc116a70cc3a5dc3d1684db5268eff9399a0be8603980005e5b889564f1ea42f", size = 249878, upload-time = "2025-11-14T14:58:06.95Z" },
+ { url = "https://files.pythonhosted.org/packages/47/4b/39d67b80ffb260457c05fcc545de37d82e9e2dbafc93dd6b64f17e09b933/cymem-2.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68489bf0035c4c280614067ab6a82815b01dc9fcd486742a5306fe9f68deb7ef", size = 252571, upload-time = "2025-11-14T14:58:08.232Z" },
+ { url = "https://files.pythonhosted.org/packages/53/0e/76f6531f74dfdfe7107899cce93ab063bb7ee086ccd3910522b31f623c08/cymem-2.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:03cb7bdb55718d5eb6ef0340b1d2430ba1386db30d33e9134d01ba9d6d34d705", size = 248555, upload-time = "2025-11-14T14:58:09.429Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/7c/eee56757db81f0aefc2615267677ae145aff74228f529838425057003c0d/cymem-2.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1710390e7fb2510a8091a1991024d8ae838fd06b02cdfdcd35f006192e3c6b0e", size = 254177, upload-time = "2025-11-14T14:58:10.594Z" },
+ { url = "https://files.pythonhosted.org/packages/77/e0/a4b58ec9e53c836dce07ef39837a64a599f4a21a134fc7ca57a3a8f9a4b5/cymem-2.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:ac699c8ec72a3a9de8109bd78821ab22f60b14cf2abccd970b5ff310e14158ed", size = 40853, upload-time = "2025-11-14T14:58:12.116Z" },
+ { url = "https://files.pythonhosted.org/packages/61/81/9931d1f83e5aeba175440af0b28f0c2e6f71274a5a7b688bc3e907669388/cymem-2.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:90c2d0c04bcda12cd5cebe9be93ce3af6742ad8da96e1b1907e3f8e00291def1", size = 36970, upload-time = "2025-11-14T14:58:13.114Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/ef/af447c2184dec6dec973be14614df8ccb4d16d1c74e0784ab4f02538433c/cymem-2.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff036bbc1464993552fd1251b0a83fe102af334b301e3896d7aa05a4999ad042", size = 46804, upload-time = "2025-11-14T14:58:14.113Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/95/e10f33a8d4fc17f9b933d451038218437f9326c2abb15a3e7f58ce2a06ec/cymem-2.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fb8291691ba7ff4e6e000224cc97a744a8d9588418535c9454fd8436911df612", size = 46254, upload-time = "2025-11-14T14:58:15.156Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/7a/5efeb2d2ea6ebad2745301ad33a4fa9a8f9a33b66623ee4d9185683007a6/cymem-2.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d8d06ea59006b1251ad5794bcc00121e148434826090ead0073c7b7fedebe431", size = 296061, upload-time = "2025-11-14T14:58:16.254Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/28/2a3f65842cc8443c2c0650cf23d525be06c8761ab212e0a095a88627be1b/cymem-2.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c0046a619ecc845ccb4528b37b63426a0cbcb4f14d7940add3391f59f13701e6", size = 285784, upload-time = "2025-11-14T14:58:17.412Z" },
+ { url = "https://files.pythonhosted.org/packages/98/73/dd5f9729398f0108c2e71d942253d0d484d299d08b02e474d7cfc43ed0b0/cymem-2.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:18ad5b116a82fa3674bc8838bd3792891b428971e2123ae8c0fd3ca472157c5e", size = 288062, upload-time = "2025-11-14T14:58:20.225Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/01/ffe51729a8f961a437920560659073e47f575d4627445216c1177ecd4a41/cymem-2.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:666ce6146bc61b9318aa70d91ce33f126b6344a25cf0b925621baed0c161e9cc", size = 290465, upload-time = "2025-11-14T14:58:21.815Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/ac/c9e7d68607f71ef978c81e334ab2898b426944c71950212b1467186f69f9/cymem-2.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:84c1168c563d9d1e04546cb65e3e54fde2bf814f7c7faf11fc06436598e386d1", size = 46665, upload-time = "2025-11-14T14:58:23.512Z" },
+ { url = "https://files.pythonhosted.org/packages/66/66/150e406a2db5535533aa3c946de58f0371f2e412e23f050c704588023e6e/cymem-2.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:e9027764dc5f1999fb4b4cabee1d0322c59e330c0a6485b436a68275f614277f", size = 39715, upload-time = "2025-11-14T14:58:24.773Z" },
+]
+
+[[package]]
+name = "dateparser"
+version = "1.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "pytz" },
+ { name = "regex" },
+ { name = "tzlocal" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/46/2d/a0ccdb78788064fa0dc901b8524e50615c42be1d78b78d646d0b28d09180/dateparser-1.4.0.tar.gz", hash = "sha256:97a21840d5ecdf7630c584f673338a5afac5dfe84f647baf4d7e8df98f9354a4", size = 321512, upload-time = "2026-03-26T09:56:10.292Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b4/0b/3c3bb7cbe757279e693a0be6049048012f794d01f81099609ecd53b899f0/dateparser-1.4.0-py3-none-any.whl", hash = "sha256:7902b8e85d603494bf70a5a0b1decdddb2270b9c6e6b2bc8a57b93476c0df378", size = 300379, upload-time = "2026-03-26T09:56:08.409Z" },
+]
+
+[[package]]
+name = "dlinfo"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/85/8e/8f2f94cd40af1b51e8e371a83b385d622170d42f98776441a6118f4dd682/dlinfo-2.0.0.tar.gz", hash = "sha256:88a2bc04f51d01bc604cdc9eb1c3cc0bde89057532ca6a3e71a41f6235433e17", size = 12727, upload-time = "2025-01-16T15:43:10.756Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/da/90/022c79d6e5e6f843268c10b84d4a021ee3afba0621d3c176d3ff2024bfc8/dlinfo-2.0.0-py3-none-any.whl", hash = "sha256:b32cc18e3ea67c0ca9ca409e5b41eed863bd1363dbc9dd3de90fedf11b61e7bc", size = 3654, upload-time = "2025-01-16T15:43:09.474Z" },
+]
+
+[[package]]
+name = "dnspython"
+version = "2.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
+]
+
+[[package]]
+name = "docopt"
+version = "0.6.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf578136ef2cc5dfb50baa1761b68c9da1fb1e4eed343c9/docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491", size = 25901, upload-time = "2014-06-16T11:18:57.406Z" }
+
+[[package]]
+name = "espeakng-loader"
+version = "0.2.4"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/92/f44ed7f531143c3c6c97d56e2b0f9be8728dc05e18b96d46eb539230ed46/espeakng_loader-0.2.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b77477ae2ddf62a748e04e49714eabb2f3a24f344166200b00539083bd669904", size = 9938387, upload-time = "2025-01-17T01:22:42.064Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/26/258c0cd43b9bc1043301c5f61767d6a6c3b679df82790c9cb43a3277b865/espeakng_loader-0.2.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d27cdca31112226e7299d8562e889d3e38a1e48055c9ee381b45d669072ee59f", size = 9892565, upload-time = "2025-01-17T01:22:40.365Z" },
+ { url = "https://files.pythonhosted.org/packages/de/1e/25ec5ab07528c0fbb215a61800a38eca05c8a99445515a02d7fa5debcb32/espeakng_loader-0.2.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08721baf27d13d461f6be6eed9a65277e70d68234ff484fd8b9897b222cdcb6d", size = 10078484, upload-time = "2025-01-17T01:22:43.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/ad/1b768d8daffc2996e07bbcb6f534d8de3202cd75fce1f1c45eced1ce6465/espeakng_loader-0.2.4-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d1e798141b46a050cdb75fcf3c17db969bb2c40394f3f4a48910655d547508b9", size = 10037736, upload-time = "2025-01-17T01:22:42.576Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ed/a3d872fbad4f3a3f3db0e8c31768ab14e77cd77306de16b8b20b1e1df7ea/espeakng_loader-0.2.4-py3-none-win_amd64.whl", hash = "sha256:41f1e08ac9deda2efd1ea9de0b81dab9f5ae3c4b24284f76533d0a7b1dd7abd7", size = 9437292, upload-time = "2025-01-17T01:23:27.463Z" },
+ { url = "https://files.pythonhosted.org/packages/29/64/0b75bc50ec53b4e000bac913625511215aa96124adf5dba8c4baa17c02cd/espeakng_loader-0.2.4-py3-none-win_arm64.whl", hash = "sha256:d7a2928843eaeb2df82f99a370f44e8a630f59b02f9b0d1f168a03c4eeb76b89", size = 9426841, upload-time = "2025-01-17T01:23:21.766Z" },
+]
+
+[[package]]
+name = "fabledassistant"
+version = "0.1.0"
+source = { editable = "." }
+dependencies = [
+ { name = "aiosmtplib" },
+ { name = "alembic" },
+ { name = "apscheduler" },
+ { name = "asyncpg" },
+ { name = "bcrypt" },
+ { name = "caldav" },
+ { name = "feedparser" },
+ { name = "html2text" },
+ { name = "httpx" },
+ { name = "hypercorn" },
+ { name = "icalendar" },
+ { name = "pywebpush" },
+ { name = "quart" },
+ { name = "sqlalchemy", extra = ["asyncio"] },
+ { name = "trafilatura" },
+]
+
+[package.optional-dependencies]
+dev = [
+ { name = "pytest" },
+ { name = "pytest-asyncio" },
+ { name = "ruff" },
+]
+voice = [
+ { name = "faster-whisper" },
+ { name = "kokoro" },
+ { name = "soundfile" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "aiosmtplib", specifier = ">=3.0" },
+ { name = "alembic", specifier = ">=1.13" },
+ { name = "apscheduler", specifier = ">=3.10,<4.0" },
+ { name = "asyncpg", specifier = ">=0.29" },
+ { name = "bcrypt", specifier = ">=4.0" },
+ { name = "caldav", specifier = ">=1.3" },
+ { name = "faster-whisper", marker = "extra == 'voice'", specifier = ">=1.0" },
+ { name = "feedparser", specifier = ">=6.0" },
+ { name = "html2text", specifier = ">=2024.2" },
+ { name = "httpx", specifier = ">=0.27" },
+ { name = "hypercorn", specifier = ">=0.17" },
+ { name = "icalendar", specifier = ">=5.0" },
+ { name = "kokoro", marker = "extra == 'voice'", specifier = ">=0.9" },
+ { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" },
+ { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23" },
+ { name = "pywebpush", specifier = ">=2.0" },
+ { name = "quart", specifier = ">=0.19" },
+ { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" },
+ { name = "soundfile", marker = "extra == 'voice'", specifier = ">=0.12" },
+ { name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0" },
+ { name = "trafilatura", specifier = ">=1.12" },
+]
+provides-extras = ["dev", "voice"]
+
+[[package]]
+name = "faster-whisper"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "av" },
+ { name = "ctranslate2" },
+ { name = "huggingface-hub" },
+ { name = "onnxruntime" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" },
+]
+
+[[package]]
+name = "feedparser"
+version = "6.0.12"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "sgmllib3k" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dc/79/db7edb5e77d6dfbc54d7d9df72828be4318275b2e580549ff45a962f6461/feedparser-6.0.12.tar.gz", hash = "sha256:64f76ce90ae3e8ef5d1ede0f8d3b50ce26bcce71dd8ae5e82b1cd2d4a5f94228", size = 286579, upload-time = "2025-09-10T13:33:59.486Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/eb/c96d64137e29ae17d83ad2552470bafe3a7a915e85434d9942077d7fd011/feedparser-6.0.12-py3-none-any.whl", hash = "sha256:6bbff10f5a52662c00a2e3f86a38928c37c48f77b3c511aedcd51de933549324", size = 81480, upload-time = "2025-09-10T13:33:58.022Z" },
+]
+
+[[package]]
+name = "filelock"
+version = "3.25.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" },
+]
+
+[[package]]
+name = "flask"
+version = "3.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blinker" },
+ { name = "click" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
+]
+
+[[package]]
+name = "flatbuffers"
+version = "25.12.19"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" },
+]
+
+[[package]]
+name = "frozenlist"
+version = "1.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" },
+ { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" },
+ { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" },
+ { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" },
+ { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" },
+ { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" },
+ { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" },
+ { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" },
+ { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" },
+ { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" },
+ { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" },
+ { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" },
+ { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" },
+]
+
+[[package]]
+name = "fsspec"
+version = "2026.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e1/cf/b50ddf667c15276a9ab15a70ef5f257564de271957933ffea49d2cdbcdfb/fsspec-2026.3.0.tar.gz", hash = "sha256:1ee6a0e28677557f8c2f994e3eea77db6392b4de9cd1f5d7a9e87a0ae9d01b41", size = 313547, upload-time = "2026-03-27T19:11:14.892Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d5/1f/5f4a3cd9e4440e9d9bc78ad0a91a1c8d46b4d429d5239ebe6793c9fe5c41/fsspec-2026.3.0-py3-none-any.whl", hash = "sha256:d2ceafaad1b3457968ed14efa28798162f1638dbb5d2a6868a2db002a5ee39a4", size = 202595, upload-time = "2026-03-27T19:11:13.595Z" },
+]
+
+[[package]]
+name = "greenlet"
+version = "3.4.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/86/94/a5935717b307d7c71fe877b52b884c6af707d2d2090db118a03fbd799369/greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff", size = 195913, upload-time = "2026-04-08T17:08:00.863Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/78/02/bde66806e8f169cf90b14d02c500c44cdbe02c8e224c9c67bafd1b8cadd1/greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e", size = 286291, upload-time = "2026-04-08T17:09:34.307Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1f/39da1c336a87d47c58352fb8a78541ce63d63ae57c5b9dae1fe02801bbc2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d", size = 656749, upload-time = "2026-04-08T16:24:41.721Z" },
+ { url = "https://files.pythonhosted.org/packages/d3/6c/90ee29a4ee27af7aa2e2ec408799eeb69ee3fcc5abcecac6ddd07a5cd0f2/greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615", size = 669084, upload-time = "2026-04-08T16:31:01.372Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/4a/74078d3936712cff6d3c91a930016f476ce4198d84e224fe6d81d3e02880/greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19", size = 673405, upload-time = "2026-04-08T16:40:42.527Z" },
+ { url = "https://files.pythonhosted.org/packages/07/49/d4cad6e5381a50947bb973d2f6cf6592621451b09368b8c20d9b8af49c5b/greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf", size = 665621, upload-time = "2026-04-08T15:56:35.995Z" },
+ { url = "https://files.pythonhosted.org/packages/79/3e/df8a83ab894751bc31e1106fdfaa80ca9753222f106b04de93faaa55feb7/greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd", size = 471670, upload-time = "2026-04-08T16:43:08.512Z" },
+ { url = "https://files.pythonhosted.org/packages/37/31/d1edd54f424761b5d47718822f506b435b6aab2f3f93b465441143ea5119/greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf", size = 1622259, upload-time = "2026-04-08T16:26:23.201Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/c6/6d3f9cdcb21c4e12a79cb332579f1c6aa1af78eb68059c5a957c7812d95e/greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda", size = 1686916, upload-time = "2026-04-08T15:57:34.282Z" },
+ { url = "https://files.pythonhosted.org/packages/63/45/c1ca4a1ad975de4727e52d3ffe641ae23e1d7a8ffaa8ff7a0477e1827b92/greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d", size = 239821, upload-time = "2026-04-08T17:03:48.423Z" },
+ { url = "https://files.pythonhosted.org/packages/71/c4/6f621023364d7e85a4769c014c8982f98053246d142420e0328980933ceb/greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802", size = 236932, upload-time = "2026-04-08T17:04:33.551Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/8f/18d72b629783f5e8d045a76f5325c1e938e659a9e4da79c7dcd10169a48d/greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece", size = 294681, upload-time = "2026-04-08T15:52:35.778Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/ad/5fa86ec46769c4153820d58a04062285b3b9e10ba3d461ee257b68dcbf53/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8", size = 658899, upload-time = "2026-04-08T16:24:43.32Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f0/4e8174ca0e87ae748c409f055a1ba161038c43cc0a5a6f1433a26ac2e5bf/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2", size = 665284, upload-time = "2026-04-08T16:31:02.833Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/92/466b0d9afd44b8af623139a3599d651c7564fa4152f25f117e1ee5949ffb/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa", size = 665872, upload-time = "2026-04-08T16:40:43.912Z" },
+ { url = "https://files.pythonhosted.org/packages/19/da/991cf7cd33662e2df92a1274b7eb4d61769294d38a1bba8a45f31364845e/greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed", size = 661861, upload-time = "2026-04-08T15:56:37.269Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/14/3395a7ef3e260de0325152ddfe19dffb3e49fe10873b94654352b53ad48e/greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72", size = 489237, upload-time = "2026-04-08T16:43:09.993Z" },
+ { url = "https://files.pythonhosted.org/packages/36/c5/6c2c708e14db3d9caea4b459d8464f58c32047451142fe2cfd90e7458f41/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f", size = 1622182, upload-time = "2026-04-08T16:26:24.777Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/4c/50c5fed19378e11a29fabab1f6be39ea95358f4a0a07e115a51ca93385d8/greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a", size = 1685050, upload-time = "2026-04-08T15:57:36.453Z" },
+ { url = "https://files.pythonhosted.org/packages/db/72/85ae954d734703ab48e622c59d4ce35d77ce840c265814af9c078cacc7aa/greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705", size = 245554, upload-time = "2026-04-08T17:03:50.044Z" },
+]
+
+[[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 = "h2"
+version = "4.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "hpack" },
+ { name = "hyperframe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
+]
+
+[[package]]
+name = "hf-xet"
+version = "1.4.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/92/ec9ad04d0b5728dca387a45af7bc98fbb0d73b2118759f5f6038b61a57e8/hf_xet-1.4.3.tar.gz", hash = "sha256:8ddedb73c8c08928c793df2f3401ec26f95be7f7e516a7bee2fbb546f6676113", size = 670477, upload-time = "2026-03-31T22:40:07.874Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/36/3e8f85ca9fe09b8de2b2e10c63b3b3353d7dda88a0b3d426dffbe7b8313b/hf_xet-1.4.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5251d5ece3a81815bae9abab41cf7ddb7bcb8f56411bce0827f4a3071c92fdc6", size = 3801019, upload-time = "2026-03-31T22:39:56.651Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/9c/defb6cb1de28bccb7bd8d95f6e60f72a3d3fa4cb3d0329c26fb9a488bfe7/hf_xet-1.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1feb0f3abeacee143367c326a128a2e2b60868ec12a36c225afb1d6c5a05e6d2", size = 3558746, upload-time = "2026-03-31T22:39:54.766Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/bd/8d001191893178ff8e826e46ad5299446e62b93cd164e17b0ffea08832ec/hf_xet-1.4.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8b301fc150290ca90b4fccd079829b84bb4786747584ae08b94b4577d82fb791", size = 4207692, upload-time = "2026-03-31T22:39:46.246Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/6790b402803250e9936435613d3a78b9aaeee7973439f0918848dde58309/hf_xet-1.4.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d972fbe95ddc0d3c0fc49b31a8a69f47db35c1e3699bf316421705741aab6653", size = 3986281, upload-time = "2026-03-31T22:39:44.648Z" },
+ { url = "https://files.pythonhosted.org/packages/51/56/ea62552fe53db652a9099eda600b032d75554d0e86c12a73824bfedef88b/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c5b48db1ee344a805a1b9bd2cda9b6b65fe77ed3787bd6e87ad5521141d317cd", size = 4187414, upload-time = "2026-03-31T22:40:04.951Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/f5/bc1456d4638061bea997e6d2db60a1a613d7b200e0755965ec312dc1ef79/hf_xet-1.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:22bdc1f5fb8b15bf2831440b91d1c9bbceeb7e10c81a12e8d75889996a5c9da8", size = 4424368, upload-time = "2026-03-31T22:40:06.347Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/76/ab597bae87e1f06d18d3ecb8ed7f0d3c9a37037fc32ce76233d369273c64/hf_xet-1.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:0392c79b7cf48418cd61478c1a925246cf10639f4cd9d94368d8ca1e8df9ea07", size = 3672280, upload-time = "2026-03-31T22:40:16.401Z" },
+ { url = "https://files.pythonhosted.org/packages/62/05/2e462d34e23a09a74d73785dbed71cc5dbad82a72eee2ad60a72a554155d/hf_xet-1.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:681c92a07796325778a79d76c67011764ecc9042a8c3579332b61b63ae512075", size = 3528945, upload-time = "2026-03-31T22:40:14.995Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/9f/9c23e4a447b8f83120798f9279d0297a4d1360bdbf59ef49ebec78fe2545/hf_xet-1.4.3-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:d0da85329eaf196e03e90b84c2d0aca53bd4573d097a75f99609e80775f98025", size = 3805048, upload-time = "2026-03-31T22:39:53.105Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/f8/7aacb8e5f4a7899d39c787b5984e912e6c18b11be136ef13947d7a66d265/hf_xet-1.4.3-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:e23717ce4186b265f69afa66e6f0069fe7efbf331546f5c313d00e123dc84583", size = 3562178, upload-time = "2026-03-31T22:39:51.295Z" },
+ { url = "https://files.pythonhosted.org/packages/df/9a/a24b26dc8a65f0ecc0fe5be981a19e61e7ca963b85e062c083f3a9100529/hf_xet-1.4.3-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc360b70c815bf340ed56c7b8c63aacf11762a4b099b2fe2c9bd6d6068668c08", size = 4212320, upload-time = "2026-03-31T22:39:42.922Z" },
+ { url = "https://files.pythonhosted.org/packages/53/60/46d493db155d2ee2801b71fb1b0fd67696359047fdd8caee2c914cc50c79/hf_xet-1.4.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:39f2d2e9654cd9b4319885733993807aab6de9dfbd34c42f0b78338d6617421f", size = 3991546, upload-time = "2026-03-31T22:39:41.335Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/f5/067363e1c96c6b17256910830d1b54099d06287e10f4ec6ec4e7e08371fc/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:49ad8a8cead2b56051aa84d7fce3e1335efe68df3cf6c058f22a65513885baac", size = 4193200, upload-time = "2026-03-31T22:40:01.936Z" },
+ { url = "https://files.pythonhosted.org/packages/42/4b/53951592882d9c23080c7644542fda34a3813104e9e11fa1a7d82d419cb8/hf_xet-1.4.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7716d62015477a70ea272d2d68cd7cad140f61c52ee452e133e139abfe2c17ba", size = 4429392, upload-time = "2026-03-31T22:40:03.492Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/21/75a6c175b4e79662ad8e62f46a40ce341d8d6b206b06b4320d07d55b188c/hf_xet-1.4.3-cp37-abi3-win_amd64.whl", hash = "sha256:6b591fcad34e272a5b02607485e4f2a1334aebf1bc6d16ce8eb1eb8978ac2021", size = 3677359, upload-time = "2026-03-31T22:40:13.619Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/7c/44314ecd0e89f8b2b51c9d9e5e7a60a9c1c82024ac471d415860557d3cd8/hf_xet-1.4.3-cp37-abi3-win_arm64.whl", hash = "sha256:7c2c7e20bcfcc946dc67187c203463f5e932e395845d098cc2a93f5b67ca0b47", size = 3533664, upload-time = "2026-03-31T22:40:12.152Z" },
+]
+
+[[package]]
+name = "hpack"
+version = "4.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
+]
+
+[[package]]
+name = "html2text"
+version = "2025.4.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/27/e158d86ba1e82967cc2f790b0cb02030d4a8bef58e0c79a8590e9678107f/html2text-2025.4.15.tar.gz", hash = "sha256:948a645f8f0bc3abe7fd587019a2197a12436cd73d0d4908af95bfc8da337588", size = 64316, upload-time = "2025-04-15T04:02:30.045Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1d/84/1a0f9555fd5f2b1c924ff932d99b40a0f8a6b12f6dd625e2a47f415b00ea/html2text-2025.4.15-py3-none-any.whl", hash = "sha256:00569167ffdab3d7767a4cdf589b7f57e777a5ed28d12907d8c58769ec734acc", size = 34656, upload-time = "2025-04-15T04:02:28.44Z" },
+]
+
+[[package]]
+name = "htmldate"
+version = "1.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "charset-normalizer" },
+ { name = "dateparser" },
+ { name = "lxml" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9d/10/ead9dabc999f353c3aa5d0dc0835b1e355215a5ecb489a7f4ef2ddad5e33/htmldate-1.9.4.tar.gz", hash = "sha256:1129063e02dd0354b74264de71e950c0c3fcee191178321418ccad2074cc8ed0", size = 44690, upload-time = "2025-11-04T17:46:44.983Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/bd/adfcdaaad5805c0c5156aeefd64c1e868c05e9c1cd6fd21751f168cd88c7/htmldate-1.9.4-py3-none-any.whl", hash = "sha256:1b94bcc4e08232a5b692159903acf95548b6a7492dddca5bb123d89d6325921c", size = 31558, upload-time = "2025-11-04T17:46:43.258Z" },
+]
+
+[[package]]
+name = "http-ece"
+version = "1.2.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" }
+
+[[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 = "huggingface-hub"
+version = "1.10.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "tqdm" },
+ { name = "typer" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e4/28/baf5d745559503ce8d28cf5bc9551f5ac59158eafd7b6a6afff0bcdb0f50/huggingface_hub-1.10.1.tar.gz", hash = "sha256:696c53cf9c2ac9befbfb5dd41d05392a031c69fc6930d1ed9671debd405b6fff", size = 758094, upload-time = "2026-04-09T15:01:18.928Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/83/8c/c7a33f3efaa8d6a5bc40e012e5ecc2d72c2e6124550ca9085fe0ceed9993/huggingface_hub-1.10.1-py3-none-any.whl", hash = "sha256:6b981107a62fbe68c74374418983399c632e35786dcd14642a9f2972633c8b5a", size = 642630, upload-time = "2026-04-09T15:01:17.35Z" },
+]
+
+[[package]]
+name = "hypercorn"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "h2" },
+ { name = "priority" },
+ { name = "wsproto" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" },
+]
+
+[[package]]
+name = "hyperframe"
+version = "6.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" },
+]
+
+[[package]]
+name = "icalendar"
+version = "7.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/b8/60/6b0356a2ed1c9689ae14bd8e44f22eac67c420a0ecca4df8306b70906600/icalendar-7.0.3.tar.gz", hash = "sha256:95027ece087ab87184d765f03761f25875821f74cdd18d3b57e9c868216d8fde", size = 443788, upload-time = "2026-03-03T12:00:10.952Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/c6/431fbf9063a6a4306d4cedae7823d69baf0979ba6ca57ab24a9d898cd0aa/icalendar-7.0.3-py3-none-any.whl", hash = "sha256:8c9fea6d3a89671bba8b6938d8565b4d0ec465c6a2796ef0f92790dcb9e627cd", size = 442406, upload-time = "2026-03-03T12:00:09.228Z" },
+]
+
+[[package]]
+name = "icalendar-searcher"
+version = "1.0.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "icalendar" },
+ { name = "recurring-ical-events" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/cb/c3/56e751b7ae4f6ca61bf807e207c98e67756fc29134d219196806beb8957d/icalendar_searcher-1.0.5.tar.gz", hash = "sha256:abd99bf1ac9c9d675d84151101db4883a97e9958755708804c55abd30df58f6c", size = 68064, upload-time = "2026-02-25T09:10:17.962Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d1/b6/7ab1dfb1c5ba3ba394ff5ea285cbeb5db9f71ec5b3b906d01efd235f00d1/icalendar_searcher-1.0.5-py3-none-any.whl", hash = "sha256:66f6f5ece50041ceda5ea91995cd2ed80fa0b065a42b3b3f420f89343614b2a3", size = 37294, upload-time = "2026-02-25T09:10:16.61Z" },
+]
+
+[[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 = "isodate"
+version = "0.7.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/54/4d/e940025e2ce31a8ce1202635910747e5a87cc3a6a6bb2d00973375014749/isodate-0.7.2.tar.gz", hash = "sha256:4cd1aa0f43ca76f4a6c6c0292a85f40b35ec2e43e315b59f06e6d32171a953e6", size = 29705, upload-time = "2024-10-08T23:04:11.5Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
+]
+
+[[package]]
+name = "itsdangerous"
+version = "2.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
+]
+
+[[package]]
+name = "jh2"
+version = "5.0.11"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ea/ebd7cbba422317fdd4be5e04a5aa9a54192b8c483f20eb38c85cf9fb8adc/jh2-5.0.11.tar.gz", hash = "sha256:6c835b0b38d795dde7aaa4581626490ca5fcfbd4eefe9572ac18d9eb2427d215", size = 7320877, upload-time = "2026-04-05T07:33:51.119Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e9/51/f8dd078ba26c709cb89b48eef0259c714956e74bc5be1a1db48e40eaebbb/jh2-5.0.11-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:92ff21001d59d47f929418d0dae55a97be16221c13e1f7ed134bdc79189475fb", size = 606275, upload-time = "2026-04-05T07:31:17.228Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/09/f94b94f2a7683c6a5c1a1bcfbc34bb7fbcb09e16014dd7b8172bc7a365f1/jh2-5.0.11-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc22823c633e95c6b5298f9ffe2d77f0f1787f2d03c47ccb7dff006e6c30fac3", size = 384500, upload-time = "2026-04-05T07:31:19.083Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/e8/b0994158d6181be0c8e5674b6a988db0712f1afd1e9f8df2c2ae6faf90fb/jh2-5.0.11-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:08392b71819ef4dec683010b0366b15da8ed495250110c6009833f25855ab6a4", size = 392121, upload-time = "2026-04-05T07:31:20.966Z" },
+ { url = "https://files.pythonhosted.org/packages/61/8a/d2c2352bb21d69b6a8c678c21d8ca54d00474f80e3273151575eb9342a5a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3ebfcd80cfcaa17bbb5733871953d1df79e1cc8bdc0f22d7372d9f2ef3524008", size = 511896, upload-time = "2026-04-05T07:31:23.204Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/94/20522d75f8beaf036541b012947ddc834a649bd1be6b4fc6126fe9225aa4/jh2-5.0.11-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f26cdbf79bd0792bc65b7825b356040c56c365041a6ae7c44e5655f8fa173fe6", size = 505414, upload-time = "2026-04-05T07:31:25.104Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/e7/df5ddd84b1d0c2e70c244a9a51b31d93f77ebd7f7eddfc79a3a3d66f5e3e/jh2-5.0.11-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d7e43c6248e3a091e9f6c5aac23236bd7ba0e30d240f4017b644bd3da049688", size = 405467, upload-time = "2026-04-05T07:31:27Z" },
+ { url = "https://files.pythonhosted.org/packages/e6/8d/c3f00aaeb516926d35482d0b5f62dde8bfa66b8b1e89107df4e1c3699e1a/jh2-5.0.11-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34c0bbf4688917a3a1b1dba176bc49bb5b1ad4b75765431b989f7767061df432", size = 390021, upload-time = "2026-04-05T07:31:28.585Z" },
+ { url = "https://files.pythonhosted.org/packages/40/dc/8f3e7d0eeb6bbd7ef2c9fb186f6a38e1b883e9db7622a17050594be82b66/jh2-5.0.11-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:740e4e489759b749aaed695e8430d28a039c11765fc5e4d1b20bfad9c7e192f1", size = 408069, upload-time = "2026-04-05T07:31:30.186Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/9f/820f50745957e491e081b39b1333a6bf0893a9528002133951b9f6fe9fc9/jh2-5.0.11-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:57601fd1c5f6fce9e63ea1f2a61f83784478cf4d58e8491a7c18cc05abdb8e96", size = 560975, upload-time = "2026-04-05T07:31:31.64Z" },
+ { url = "https://files.pythonhosted.org/packages/00/c2/a658a73429bd96cf3c12dbfa9270e5ffb117d1d3207fbe0e72ebaff1fdb5/jh2-5.0.11-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:c7834d1000ac856234e7b574ed2ccf2136aab325d84051edb1db06c17e295df4", size = 667148, upload-time = "2026-04-05T07:31:33.523Z" },
+ { url = "https://files.pythonhosted.org/packages/be/a3/d5a789af902526265143631d14ad07f9dda88afa71e99b4f31495d7a0f53/jh2-5.0.11-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:985a9eb136e7897bcedba873cf30b51c19481d94ab31a391d05eeecf27c390ba", size = 625865, upload-time = "2026-04-05T07:31:34.912Z" },
+ { url = "https://files.pythonhosted.org/packages/18/97/129ec3c3ca5d46d4eff1b230623a98c0cc93d85663850533ba4b416eead8/jh2-5.0.11-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3c3b06db73cde4e350e8acd5960e6bd9880e512cc8ab9c28003c74414261382b", size = 593557, upload-time = "2026-04-05T07:31:36.363Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/5a/e8eb939e21b3d963206081d0bc9eb17b494b4747ea537e70337f5c5ae7d3/jh2-5.0.11-cp314-cp314t-win32.whl", hash = "sha256:ebe5ec3b51704119ca66717828631a777bc64132517f445d0b9ac2f30dd38264", size = 237324, upload-time = "2026-04-05T07:31:37.708Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/57/298d81b6477bfe573b4a02234c39c06ddcbb906114f339d119253bef4a3b/jh2-5.0.11-cp314-cp314t-win_amd64.whl", hash = "sha256:05102a4610dde1dc59c630e64ca34a74076d1afd275dbeac954b230a605788b9", size = 244785, upload-time = "2026-04-05T07:31:39.411Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/a5/3f6477c76630134ac3db8093b64b07fa3c15f03d7274836656dce53d7585/jh2-5.0.11-cp314-cp314t-win_arm64.whl", hash = "sha256:7a1388738fcce0ddc8e742d2d1c0619911299f339d54a19496bcbecfb4d7e775", size = 240967, upload-time = "2026-04-05T07:31:40.883Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/f0/0363ffb6ed11a76d47c6a543f16c3c3e6efedc27853fa1ef95df557c0724/jh2-5.0.11-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:4dc82aee3ab2c4103f3d9092f4463dd6cc4a248ab6a27a4acab79bef0d3ac8dd", size = 622317, upload-time = "2026-04-05T07:31:42.411Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/61/fc161568713450214d3c48db614d871f9393bd88db471e10ac868f5a7214/jh2-5.0.11-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85cf4f09f7159c29967212af685d2819f960d9136d931420fef107683d121f56", size = 393320, upload-time = "2026-04-05T07:31:43.814Z" },
+ { url = "https://files.pythonhosted.org/packages/68/0e/f300ee75f1bd3c8212d084d4e52a83e81fca2dbfe4c10c4b97bb4a8b9743/jh2-5.0.11-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:daadac34cefe67ea03a7d2324e03fc9b37ec8820604f1563e7d424471bee29b6", size = 399898, upload-time = "2026-04-05T07:31:45.376Z" },
+ { url = "https://files.pythonhosted.org/packages/94/74/cd7e97f87e85ddfb0449ff50d23c0ed3967348fd4c79917972ac0e277e84/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:558d4c15bc42419262ef15595d9c488ae53276b562397314e1cc934f4c7e4bdf", size = 520449, upload-time = "2026-04-05T07:31:47.158Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9b/b94e76661c2ade180654687cb7819106427a711e6b40c31cb5c41cac33fa/jh2-5.0.11-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c61f837b3e5c897bd2a90149afd615f59d24c72c893526485bca1b40f6ec49", size = 512366, upload-time = "2026-04-05T07:31:48.644Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/de/ddba9eb5ca665bcf557cb28b99e96a06df1b570bcfc086ab0bd21b1232f0/jh2-5.0.11-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a433f014c207ffc4b3eda0165fbd4d7d978b53cbdd6e71d441531221b2b1b879", size = 414743, upload-time = "2026-04-05T07:31:50.365Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/a4/b80fd188fa006a144cd4f280fdfd3808e3715aaa805fa830e828406080ed/jh2-5.0.11-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d15672b32f0891940691bac16a854af164e694f0b9d21bebfddd13e3c7d2f03", size = 399371, upload-time = "2026-04-05T07:31:51.855Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/48/d5ead4a7379bcc0386baed535183f8168b2bda0cf3e368586f6d4ab44024/jh2-5.0.11-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbf08eead0483ecaf275c2f447b704d1583278f7abd6f0e945fccd6a581c7df4", size = 418311, upload-time = "2026-04-05T07:31:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/8d/59dd21f1ed6f451f60581522e08e42f3e74275b1568ef9c7936a06445108/jh2-5.0.11-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:6cd51dd02943b703e10eb536722c5fd205b6084333dac5b9c114bdbbc2c46b3a", size = 569633, upload-time = "2026-04-05T07:31:55.228Z" },
+ { url = "https://files.pythonhosted.org/packages/28/0a/e53f7b8c13638c712d7ad1b8f0af29ec97a46ae67d912e501ce0a606f869/jh2-5.0.11-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:58f4c9da6555923f731e358d975e40d4ae6241c05b29e5a0f4dc8c91781cc229", size = 675463, upload-time = "2026-04-05T07:31:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/5a/e02ad465d53f144a3d5a7b52cf57e6f14800ec65ac6c90d081dbdf9c507d/jh2-5.0.11-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:6ba33ff1d1275586bb4d83687c59783dad60b66ef3d420c04982bae7e0d75f9b", size = 636580, upload-time = "2026-04-05T07:31:59.03Z" },
+ { url = "https://files.pythonhosted.org/packages/82/55/db34614186693ce66c69af384659c5f37fa79699c81c8867cec2fe4dc566/jh2-5.0.11-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b0ad821964a7701e2b80c6f8b424b6d4ca575fefb1aa04227967ef78fa15fcd5", size = 603360, upload-time = "2026-04-05T07:32:00.681Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/07/1490e419fe03ec9afb7c2571cfd3410d34bd5367dc36bbbc9d52d21f44ae/jh2-5.0.11-cp37-abi3-win32.whl", hash = "sha256:18f10dcf0aa9f19833ac0f4d58b195af2d0b056423d428f74bf03f7839db8055", size = 245065, upload-time = "2026-04-05T07:32:02.568Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/60/69a4fcc00a01fa65bbf67279e21886325087491250bc54af3e12e86c8532/jh2-5.0.11-cp37-abi3-win_amd64.whl", hash = "sha256:e28dabffcbd5525bf5f36d482764e3e56b513bce06a75b2fb4b540bedad80348", size = 251415, upload-time = "2026-04-05T07:32:04.021Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/9e/33ba56e964b58b056a4c17003c0be245be3dcfda17e9f1df95cd5209ece8/jh2-5.0.11-cp37-abi3-win_arm64.whl", hash = "sha256:dfbb07be66cb96a289c876aaab7ac46da4fb70f6526298f1fda60076b971d5f0", size = 247273, upload-time = "2026-04-05T07:32:05.771Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/17/512d0ac0484aca136e698498d6441b6072156fb2d618d8096a07578f67ad/jh2-5.0.11-py3-none-any.whl", hash = "sha256:aafd357af8d0de5267d3bc88e2384da30f05c38446a61425ae565925bc2ca9ba", size = 98207, upload-time = "2026-04-05T07:33:49.43Z" },
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
+]
+
+[[package]]
+name = "joblib"
+version = "1.5.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
+]
+
+[[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 = "justext"
+version = "3.0.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml", extra = ["html-clean"] },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/49/f3/45890c1b314f0d04e19c1c83d534e611513150939a7cf039664d9ab1e649/justext-3.0.2.tar.gz", hash = "sha256:13496a450c44c4cd5b5a75a5efcd9996066d2a189794ea99a49949685a0beb05", size = 828521, upload-time = "2025-02-25T20:21:49.934Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/ac/52f4e86d1924a7fc05af3aeb34488570eccc39b4af90530dd6acecdf16b5/justext-3.0.2-py2.py3-none-any.whl", hash = "sha256:62b1c562b15c3c6265e121cc070874243a443bfd53060e869393f09d6b6cc9a7", size = 837940, upload-time = "2025-02-25T20:21:44.179Z" },
+]
+
+[[package]]
+name = "kokoro"
+version = "0.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "loguru" },
+ { name = "misaki", extra = ["en"] },
+ { name = "numpy" },
+ { name = "torch" },
+ { name = "transformers" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/48/88b8cdf28b068d070195c2817175549dee48e7682e3ab8994bee5f69217e/kokoro-0.9.4.tar.gz", hash = "sha256:fbf633262797f8cf46fdac3315cf9cade67dc8b762c0feccf334892772fb9ac4", size = 26215928, upload-time = "2025-04-05T22:01:35.294Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ea/cc/75f41633c75224ba820a4533163bc8b070b6bf25416014074c63284c2d4e/kokoro-0.9.4-py3-none-any.whl", hash = "sha256:a129dc6364a286bd6a92c396e9862459d3d3e45f2c15596ed5a94dcee5789efd", size = 32592, upload-time = "2025-04-05T22:01:23.018Z" },
+]
+
+[[package]]
+name = "language-tags"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/7e/b6a0efe4fee11e9742c1baaedf7c574084238a70b03c1d8eb2761383848f/language_tags-1.2.0.tar.gz", hash = "sha256:e934acba3e3dc85f867703eca421847a9ab7b7679b11b5d5cfd096febbf8bde6", size = 207901, upload-time = "2023-01-11T18:38:07.893Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/42/327554649ed2dd5ce59d3f5da176c7be20f9352c7c6c51597293660b7b08/language_tags-1.2.0-py3-none-any.whl", hash = "sha256:d815604622242fdfbbfd747b40c31213617fd03734a267f2e39ee4bd73c88722", size = 213449, upload-time = "2023-01-11T18:38:05.692Z" },
+]
+
+[[package]]
+name = "loguru"
+version = "0.7.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+ { name = "win32-setctime", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
+]
+
+[[package]]
+name = "lxml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/42/149c7747977db9d68faee960c1a3391eb25e94d4bb677f8e2df8328e4098/lxml-6.0.3.tar.gz", hash = "sha256:a1664c5139755df44cab3834f4400b331b02205d62d3fdcb1554f63439bf3372", size = 4237567, upload-time = "2026-04-09T14:39:09.664Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d3/40/b637359bacf3813f1174d15b08516020ba5beb355e04377105d561e6e00a/lxml-6.0.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8c08926678852a233bf1ef645c4d683d56107f814482f8f41b21ef2c7659790e", size = 8575318, upload-time = "2026-04-09T14:36:20.608Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/91/d5286a45202ed91f1e428e68c6e1c11bcb2b42715c48424871fc73485b05/lxml-6.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2ce76d113a7c3bf42761ec1de7ca615b0cbf9d8ae478eb1d6c20111d9c9fc098", size = 4623084, upload-time = "2026-04-09T14:36:24.015Z" },
+ { url = "https://files.pythonhosted.org/packages/8a/5f/7ea1af571ee13ed1e5fba007fd83cd0794723ca76a51eed0ef9513363b1f/lxml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:83eca62141314d641ebe8089ffa532bbf572ea07dd6255b58c40130d06bb2509", size = 4948797, upload-time = "2026-04-09T14:36:26.662Z" },
+ { url = "https://files.pythonhosted.org/packages/82/be/3a9b8d787d9877cbe17e02ef5af2523bd14ecc177ce308397c485c56fe18/lxml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d8781d812bb8efd47c35651639da38980383ff0d0c1f3269ade23e3a90799079", size = 5085983, upload-time = "2026-04-09T14:36:29.486Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2b/645abaef837b11414c81513c31b308a001fb8cd370f665c3ebc854be5ba5/lxml-6.0.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19b079e81aa3a31b523a224b0dd46da4f56e1b1e248eef9a599e5c885c788813", size = 5031039, upload-time = "2026-04-09T14:36:31.735Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/4f/561f30b77e9edbb373e2b6b7203a7d6ab219c495abca219536c66f3a44b2/lxml-6.0.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6c055bafdcb53e7f9f75e22c009cd183dd410475e21c296d599531d7f03d1bf5", size = 5646718, upload-time = "2026-04-09T14:36:34.127Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ba/2a72e673d109b563c2ab77097f2f4ca64e2927d2f04836ba07aaabe1da0e/lxml-6.0.3-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f1594a183cee73f9a1dbfd35871c4e04b461f47eeb9bcf80f7d7856b1b136d", size = 5239360, upload-time = "2026-04-09T14:36:37.195Z" },
+ { url = "https://files.pythonhosted.org/packages/52/98/4e5a4ef87d846af90cc9c1ee2f8af2af34c221e620aad317b3a535361b93/lxml-6.0.3-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:a6380c5035598e4665272ad3fc86c96ddb2a220d4059cce5ba4b660f78346ad9", size = 5351233, upload-time = "2026-04-09T14:36:39.634Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/b8/cff0af5fe48ede6b1949dc2e14171470c0c68a15789037c1fed90602b89d/lxml-6.0.3-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:143ac903fb6c9be6da613390825c8e8bb8c8d71517d43882031f6b9bc89770ef", size = 4696677, upload-time = "2026-04-09T14:36:42.037Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/6e/0b2a918fb15c30b00ff112df16c548df011db37b58d764bd17f47db74905/lxml-6.0.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4fff7d77f440378cd841e340398edf5dbefee334816efbf521bb6e31651e54e", size = 5250503, upload-time = "2026-04-09T14:36:44.417Z" },
+ { url = "https://files.pythonhosted.org/packages/57/1b/4697918f9d4c2e643e2c59cedb37c2f3a9f76fb1217d767f6dff476813d8/lxml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:631567ffc3ddb989ccdcd28f6b9fa5aab1ec7fc0e99fe65572b006a6aad347e2", size = 5084563, upload-time = "2026-04-09T14:36:46.762Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/8c/d7ec96246f0632773912c6556288d3b6bb6580f3a967441ca4636ddc3f73/lxml-6.0.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:38acf7171535ffa7fff1fcec8b82ebd4e55cd02e581efe776928108421accaa1", size = 4737407, upload-time = "2026-04-09T14:36:49.826Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/0c/603e35bf77aeb28c972f39eece35e7c0f6579ff33a7bed095cc2f7f942d9/lxml-6.0.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:06b9f3ac459b4565bbaa97aa5512aa7f9a1188c662f0108364f288f6daf35773", size = 5670919, upload-time = "2026-04-09T14:36:52.231Z" },
+ { url = "https://files.pythonhosted.org/packages/92/08/6d3f188e6705cf0bfd8b5788055c7381bb3ffa786dfba9fa0b0ed5778506/lxml-6.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2773dbe2cedee81f2769bd5d24ceb4037706cf032e1703513dd0e9476cd9375f", size = 5237771, upload-time = "2026-04-09T14:36:55.286Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4c/01639533b90e9ff622909c113df2ab2dbdd1d78540eb153d13b66a9c96ba/lxml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:30c437d8bb9a9a9edff27e85b694342e47a26a6abc249abe00584a4824f9d80d", size = 5263862, upload-time = "2026-04-09T14:36:58.247Z" },
+ { url = "https://files.pythonhosted.org/packages/06/0e/bd1157d7b09d1f5e1d580c124203cee656130a3f8908365760a593b21daf/lxml-6.0.3-cp314-cp314-win32.whl", hash = "sha256:1b60a3a1205f869bd47874787c792087174453b1a869db4837bf5b3ff92be017", size = 3656378, upload-time = "2026-04-09T14:37:47.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/cc/d50cbce8cd5687670868bea33bbeefa0866c5e5d02c5e11c4a04c79fc45e/lxml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:5b6913a68d98c58c673667c864500ba31bc9b0f462effac98914e9a92ebacd2e", size = 4062518, upload-time = "2026-04-09T14:37:49.911Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c7/ece11a1e51390502894838aa384e9f98af7bef4d6806a927197153a16972/lxml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:1b36a3c73f2a6d9c2bfae78089ca7aedae5c2ee5fd5214a15f00b2f89e558ba7", size = 3741064, upload-time = "2026-04-09T14:37:52.185Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/ae/918d7f89635fb6456cd732c12246c0e504dd9c49e8006f3593c9ecdb90ff/lxml-6.0.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:239e9a6be3a79c03ec200d26f7bb17a4414704a208059e20050bf161e2d8848a", size = 8826590, upload-time = "2026-04-09T14:37:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/07/cf/bda0ae583758704719976b9ea69c8b089fa5f92e49683e517386539b21cf/lxml-6.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:16e5cbaa1a6351f2abefa4072e9aac1f09103b47fe7ab4496d54e5995b065162", size = 4735028, upload-time = "2026-04-09T14:37:03.602Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/0e/3bfb18778c6f73c7ead2d49a256501fa3052888b899826f5d1df1fbdf83b/lxml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89f8746c206d8cf2c167221831645d6cc2b24464afd9c428a5eb3fd34c584eb1", size = 4969184, upload-time = "2026-04-09T14:37:05.914Z" },
+ { url = "https://files.pythonhosted.org/packages/29/e6/796c77751a682d6d1bb9aa3fe43851b41a21b0377100e246a4a83a81d668/lxml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5d559a84b2fd583e5bcf8ec4af1ec895f98811684d5fbd6524ea31a04f92d4ad", size = 5103548, upload-time = "2026-04-09T14:37:08.605Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/5e/a02aee214f657f29d4690d88161de8ffb8f1b5139e792bae313b9479e317/lxml-6.0.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7966fbce2d18fde579d5593933d36ad98cc7c8dc7f2b1916d127057ce0415062", size = 5027775, upload-time = "2026-04-09T14:37:11.283Z" },
+ { url = "https://files.pythonhosted.org/packages/20/e5/65dd25f2c366879d696d1c720af9a96fa0969d2d135a27b6140222fc6f68/lxml-6.0.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a1f258e6aa0e6eda2c1199f5582c062c96c7d4a28d96d0c4daa79e39b3f2a764", size = 5595348, upload-time = "2026-04-09T14:37:13.618Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/1f/2f0e80d7fd2ad9755d771af4ad46ea14bf871bc5a1d2d365a3f948940ddf/lxml-6.0.3-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:738aef404c862d2c3cd951364ee7175c9d50e8290f5726611c4208c0fba8d186", size = 5224217, upload-time = "2026-04-09T14:37:16.519Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/28/e1aaeee7d6a4c9f24a3e4535a4e19ce64b99eefbe7437d325b61623b1817/lxml-6.0.3-cp314-cp314t-manylinux_2_28_i686.whl", hash = "sha256:5c35e5c3ed300990a46a144d3514465713f812b35dacfa83e928c60db7c90af7", size = 5312245, upload-time = "2026-04-09T14:37:19.387Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ac/9633cb919124473e03c62862b0494bf0e1705f902fbd9627be4f648bddfb/lxml-6.0.3-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:4ff774b43712b0cf40d9888a5494ca39aefe990c946511cc947b9fddcf74a29b", size = 4637952, upload-time = "2026-04-09T14:37:21.648Z" },
+ { url = "https://files.pythonhosted.org/packages/50/aa/135baeea457d41989bafa78e437fe3a370c793aab0d8fb3da73ccae10095/lxml-6.0.3-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d20af2784c763928d0d0879cbc5a3739e4d81eefa0d68962d3478bff4c13e644", size = 5232782, upload-time = "2026-04-09T14:37:24.6Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/77/d05183ac8440cbc4c6fa386edb7ba9718bee4f097e58485b1cd1f9479d56/lxml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fdb7786ebefaa0dad0d399dfeaf146b370a14591af2f3aea59e06f931a426678", size = 5083889, upload-time = "2026-04-09T14:37:27.432Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/58/e9fda8fb82775491ad0290c7b17252f944b6c3a6974cd820d65910690351/lxml-6.0.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c71a387ea133481e725079cff22de45593bf0b834824de22829365ab1d2386c9", size = 4758658, upload-time = "2026-04-09T14:37:29.81Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/32/4aae9f004f79f9d200efd8343809cfe46077f8e5bd58f08708c320a20fcd/lxml-6.0.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:841b89fc3d910d61c7c267db6bb7dc3a8b3dac240edb66220fcdf96fe70a0552", size = 5619494, upload-time = "2026-04-09T14:37:33.482Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/49/407fa9e3c91e7c6d0762eaeedd50d4695bcd26db817e933ca689eb1f3df4/lxml-6.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ac2d6cdafa29672d6a604c641bf67ace3fd0735ec6885501a94943379219ddbf", size = 5228386, upload-time = "2026-04-09T14:37:36.058Z" },
+ { url = "https://files.pythonhosted.org/packages/99/92/39982f818acbb1dd67dd5d20c2a06bcb9f1f3b9a8ff0021e367904f82417/lxml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:609bf136a7339aeca2bd4268c7cd190f33d13118975fe9964eda8e5138f42802", size = 5247973, upload-time = "2026-04-09T14:37:38.836Z" },
+ { url = "https://files.pythonhosted.org/packages/66/68/fcdbb78c8cda81a86e17b31abf103b7e474e474a09fb291a99e7a9b43eb8/lxml-6.0.3-cp314-cp314t-win32.whl", hash = "sha256:bf98f5f87f6484302e7cce4e2ca5af43562902852063d916c3e2f1c115fdce60", size = 3896249, upload-time = "2026-04-09T14:37:41.068Z" },
+ { url = "https://files.pythonhosted.org/packages/88/fb/6292681ac4a4223b700569ce98f71662cb07c5a3ade4f346f5f0d5c574cf/lxml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:d3d65e511e4e656ec67b472110f7a72cbf8547ca15f76fe74cffa4e97412a064", size = 4391091, upload-time = "2026-04-09T14:37:43.357Z" },
+ { url = "https://files.pythonhosted.org/packages/99/39/a0f486360a6f1b36fd2f5eb62d037652bef503d82b6f853aee6664cdfcac/lxml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:cbc7ce67f85b92db97c92219985432be84dc1ba9a028e68c6933e89551234df2", size = 3816374, upload-time = "2026-04-09T14:37:45.532Z" },
+]
+
+[package.optional-dependencies]
+html-clean = [
+ { name = "lxml-html-clean" },
+]
+
+[[package]]
+name = "lxml-html-clean"
+version = "0.4.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "lxml" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9a/a4/5c62acfacd69ff4f5db395100f5cfb9b54e7ac8c69a235e4e939fd13f021/lxml_html_clean-0.4.4.tar.gz", hash = "sha256:58f39a9d632711202ed1d6d0b9b47a904e306c85de5761543b90e3e3f736acfb", size = 23899, upload-time = "2026-02-27T09:35:52.911Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d9/76/7ffc1d3005cf7749123bc47cb3ea343cd97b0ac2211bab40f57283577d0e/lxml_html_clean-0.4.4-py3-none-any.whl", hash = "sha256:ce2ef506614ecb85ee1c5fe0a2aa45b06a19514ec7949e9c8f34f06925cfabcb", size = 14565, upload-time = "2026-02-27T09:35:51.86Z" },
+]
+
+[[package]]
+name = "mako"
+version = "1.3.10"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
+]
+
+[[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 = "markupsafe"
+version = "3.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
+ { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
+ { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
+ { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
+ { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
+ { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
+ { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
+ { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
+ { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
+ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
+]
+
+[[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 = "misaki"
+version = "0.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "addict" },
+ { name = "regex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c7/fb01370a76585b46595a01b52f18e65c8ba6d7a313a05e5d9fff0a8e1c69/misaki-0.9.4.tar.gz", hash = "sha256:3960fa3e6de179a90ee8e628446a4a4f6b8c730b6e3410999cf396189f4d9c40", size = 3756765, upload-time = "2025-04-05T21:57:14.186Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/82/ec/0ee4110ddb54278b8f21c40a140370ae8f687036c4edf578316602697c56/misaki-0.9.4-py3-none-any.whl", hash = "sha256:90e2eeb169786c014c429e5058d2ea6bcd02d651f2a24450ba6c9ffc0f8da15a", size = 3617774, upload-time = "2025-04-05T21:57:10.678Z" },
+]
+
+[package.optional-dependencies]
+en = [
+ { name = "espeakng-loader" },
+ { name = "num2words" },
+ { name = "phonemizer-fork" },
+ { name = "spacy" },
+ { name = "spacy-curated-transformers" },
+]
+
+[[package]]
+name = "mpmath"
+version = "1.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" },
+]
+
+[[package]]
+name = "multidict"
+version = "6.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
+ { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
+ { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
+ { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
+ { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
+ { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
+ { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
+ { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
+ { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
+ { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
+ { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
+ { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
+ { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
+ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
+]
+
+[[package]]
+name = "murmurhash"
+version = "1.0.15"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/23/2e/88c147931ea9725d634840d538622e94122bceaf346233349b7b5c62964b/murmurhash-1.0.15.tar.gz", hash = "sha256:58e2b27b7847f9e2a6edf10b47a8c8dd70a4705f45dccb7bf76aeadacf56ba01", size = 13291, upload-time = "2025-11-14T09:51:15.272Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1c/69/726df275edf07688146966e15eaaa23168100b933a2e1a29b37eb56c6db8/murmurhash-1.0.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c4280136b738e85ff76b4bdc4341d0b867ee753e73fd8b6994288080c040d0b", size = 28029, upload-time = "2025-11-14T09:50:44.124Z" },
+ { url = "https://files.pythonhosted.org/packages/59/8f/24ecf9061bc2b20933df8aba47c73e904274ea8811c8300cab92f6f82372/murmurhash-1.0.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d4d681f474830489e2ec1d912095cfff027fbaf2baa5414c7e9d25b89f0fab68", size = 27912, upload-time = "2025-11-14T09:50:45.266Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/26/fff3caba25aa3c0622114e03c69fb66c839b22335b04d7cce91a3a126d44/murmurhash-1.0.15-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d7e47c5746785db6a43b65fac47b9e63dd71dfbd89a8c92693425b9715e68c6e", size = 131847, upload-time = "2025-11-14T09:50:46.819Z" },
+ { url = "https://files.pythonhosted.org/packages/df/e4/0f2b9fc533467a27afb4e906c33f32d5f637477de87dd94690e0c44335a6/murmurhash-1.0.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e8e674f02a99828c8a671ba99cd03299381b2f0744e6f25c29cadfc6151dc724", size = 132267, upload-time = "2025-11-14T09:50:48.298Z" },
+ { url = "https://files.pythonhosted.org/packages/da/bf/9d1c107989728ec46e25773d503aa54070b32822a18cfa7f9d5f41bc17a5/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:26fd7c7855ac4850ad8737991d7b0e3e501df93ebaf0cf45aa5954303085fdba", size = 131894, upload-time = "2025-11-14T09:50:49.485Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/81/dcf27c71445c0e993b10e33169a098ca60ee702c5c58fcbde205fa6332a6/murmurhash-1.0.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb8ebafae60d5f892acff533cc599a359954d8c016a829514cb3f6e9ee10f322", size = 132054, upload-time = "2025-11-14T09:50:50.747Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/32/e874a14b2d2246bd2d16f80f49fad393a3865d4ee7d66d2cae939a67a29a/murmurhash-1.0.15-cp314-cp314-win_amd64.whl", hash = "sha256:898a629bf111f1aeba4437e533b5b836c0a9d2dd12d6880a9c75f6ca13e30e22", size = 26579, upload-time = "2025-11-14T09:50:52.278Z" },
+ { url = "https://files.pythonhosted.org/packages/af/8e/4fca051ed8ae4d23a15aaf0a82b18cb368e8cf84f1e3b474d5749ec46069/murmurhash-1.0.15-cp314-cp314-win_arm64.whl", hash = "sha256:88dc1dd53b7b37c0df1b8b6bce190c12763014492f0269ff7620dc6027f470f4", size = 24341, upload-time = "2025-11-14T09:50:53.295Z" },
+ { url = "https://files.pythonhosted.org/packages/38/9c/c72c2a4edd86aac829337ab9f83cf04cdb15e5d503e4c9a3a243f30a261c/murmurhash-1.0.15-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6cb4e962ec4f928b30c271b2d84e6707eff6d942552765b663743cfa618b294b", size = 30146, upload-time = "2025-11-14T09:50:54.705Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/d7/72b47ebc86436cd0aa1fd4c6e8779521ec389397ac11389990278d0f7a47/murmurhash-1.0.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5678a3ea4fbf0cbaaca2bed9b445f556f294d5f799c67185d05ffcb221a77faf", size = 30141, upload-time = "2025-11-14T09:50:55.829Z" },
+ { url = "https://files.pythonhosted.org/packages/64/bb/6d2f09135079c34dc2d26e961c52742d558b320c61503f273eab6ba743d9/murmurhash-1.0.15-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ef19f38c6b858eef83caf710773db98c8f7eb2193b4c324650c74f3d8ba299e0", size = 163898, upload-time = "2025-11-14T09:50:56.946Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/e2/9c1b462e33f9cb2d632056f07c90b502fc20bd7da50a15d0557343bd2fed/murmurhash-1.0.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22aa3ceaedd2e57078b491ed08852d512b84ff4ff9bb2ff3f9bf0eec7f214c9e", size = 168040, upload-time = "2025-11-14T09:50:58.234Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/73/8694db1408fcdfa73589f7df6c445437ea146986fa1e393ec60d26d6e30c/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bba0e0262c0d08682b028cb963ac477bd9839029486fa1333fc5c01fb6072749", size = 164239, upload-time = "2025-11-14T09:50:59.95Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/f9/8e360bdfc3c44e267e7e046f0e0b9922766da92da26959a6963f597e6bb5/murmurhash-1.0.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4fd8189ee293a09f30f4931408f40c28ccd42d9de4f66595f8814879339378bc", size = 161811, upload-time = "2025-11-14T09:51:01.289Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/31/97649680595b1096803d877ababb9a67c07f4378f177ec885eea28b9db6d/murmurhash-1.0.15-cp314-cp314t-win_amd64.whl", hash = "sha256:66395b1388f7daa5103db92debe06842ae3be4c0749ef6db68b444518666cdcc", size = 29817, upload-time = "2025-11-14T09:51:02.493Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/4fce8755f25d77324401886c00017c556be7ca3039575b94037aff905385/murmurhash-1.0.15-cp314-cp314t-win_arm64.whl", hash = "sha256:c22e56c6a0b70598a66e456de5272f76088bc623688da84ef403148a6d41851d", size = 26219, upload-time = "2025-11-14T09:51:03.563Z" },
+]
+
+[[package]]
+name = "networkx"
+version = "3.6.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
+]
+
+[[package]]
+name = "niquests"
+version = "3.18.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "charset-normalizer" },
+ { name = "urllib3-future" },
+ { name = "wassima", marker = "sys_platform != 'emscripten'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8e/ef/a69f0336532434db5c3d86fe21131aec7fea7ce3f090a86e4a79f5984944/niquests-3.18.5.tar.gz", hash = "sha256:5d97b6fc90219d0d8d8feea11b78267a5ac003290a90b5a5a5d8db7c4b7312f0", size = 1022762, upload-time = "2026-04-10T13:20:39.765Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bf/ca/e23538eff77d5921e53b6561002c86d60bcf04f62546ef1de7759c437bea/niquests-3.18.5-py3-none-any.whl", hash = "sha256:fe486ce71204f9f5ade1266ab1568cc09e4af772409070e26e65b0f8fda5c642", size = 208624, upload-time = "2026-04-10T13:20:38.032Z" },
+]
+
+[[package]]
+name = "num2words"
+version = "0.5.14"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "docopt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f6/58/ad645bd38b4b648eb2fc2ba1b909398e54eb0cbb6a7dbd2b4953e38c9621/num2words-0.5.14.tar.gz", hash = "sha256:b066ec18e56b6616a3b38086b5747daafbaa8868b226a36127e0451c0cf379c6", size = 218213, upload-time = "2024-12-17T20:17:10.191Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d6/5b/545e9267a1cc080c8a1be2746113a063e34bcdd0f5173fd665a5c13cb234/num2words-0.5.14-py3-none-any.whl", hash = "sha256:1c8e5b00142fc2966fd8d685001e36c4a9911e070d1b120e1beb721fa1edb33d", size = 163525, upload-time = "2024-12-17T20:17:06.074Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" },
+ { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" },
+ { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" },
+ { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" },
+ { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" },
+ { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" },
+ { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" },
+ { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" },
+ { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" },
+ { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" },
+ { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" },
+]
+
+[[package]]
+name = "nvidia-cublas"
+version = "13.1.0.3"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/a5/fce49e2ae977e0ccc084e5adafceb4f0ac0c8333cb6863501618a7277f67/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:c86fc7f7ae36d7528288c5d88098edcb7b02c633d262e7ddbb86b0ad91be5df2", size = 542851226, upload-time = "2025-10-09T08:59:04.818Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/44/423ac00af4dd95a5aeb27207e2c0d9b7118702149bf4704c3ddb55bb7429/nvidia_cublas-13.1.0.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:ee8722c1f0145ab246bccb9e452153b5e0515fd094c3678df50b2a0888b8b171", size = 423133236, upload-time = "2025-10-09T08:59:32.536Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-cupti"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" },
+ { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-nvrtc"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" },
+]
+
+[[package]]
+name = "nvidia-cuda-runtime"
+version = "13.0.96"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" },
+]
+
+[[package]]
+name = "nvidia-cudnn-cu13"
+version = "9.19.0.56"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f1/84/26025437c1e6b61a707442184fa0c03d083b661adf3a3eecfd6d21677740/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:6ed29ffaee1176c612daf442e4dd6cfeb6a0caa43ddcbeb59da94953030b1be4", size = 433781201, upload-time = "2026-02-03T20:40:53.805Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/22/0b4b932655d17a6da1b92fa92ab12844b053bb2ac2475e179ba6f043da1e/nvidia_cudnn_cu13-9.19.0.56-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:d20e1734305e9d68889a96e3f35094d733ff1f83932ebe462753973e53a572bf", size = 366066321, upload-time = "2026-02-03T20:44:52.837Z" },
+]
+
+[[package]]
+name = "nvidia-cufft"
+version = "12.0.0.61"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" },
+]
+
+[[package]]
+name = "nvidia-cufile"
+version = "1.15.1.6"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" },
+]
+
+[[package]]
+name = "nvidia-curand"
+version = "10.4.0.35"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" },
+]
+
+[[package]]
+name = "nvidia-cusolver"
+version = "12.0.4.66"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-cublas" },
+ { name = "nvidia-cusparse" },
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" },
+]
+
+[[package]]
+name = "nvidia-cusparse"
+version = "12.6.3.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "nvidia-nvjitlink" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" },
+]
+
+[[package]]
+name = "nvidia-cusparselt-cu13"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/46/10/8dcd1175260706a2fc92a16a52e306b71d4c1ea0b0cc4a9484183399818a/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:400c6ed1cf6780fc6efedd64ec9f1345871767e6a1a0a552a1ea0578117ea77c", size = 220791277, upload-time = "2025-08-13T19:22:40.982Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/53/43b0d71f4e702fa9733f8b4571fdca50a8813f1e450b656c239beff12315/nvidia_cusparselt_cu13-0.8.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:25e30a8a7323935d4ad0340b95a0b69926eee755767e8e0b1cf8dd85b197d3fd", size = 169884119, upload-time = "2025-08-13T19:23:41.967Z" },
+]
+
+[[package]]
+name = "nvidia-nccl-cu13"
+version = "2.28.9"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/55/1920646a2e43ffd4fc958536b276197ed740e9e0c54105b4bb3521591fc7/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:01c873ba1626b54caa12272ed228dc5b2781545e0ae8ba3f432a8ef1c6d78643", size = 196561677, upload-time = "2025-11-18T05:49:03.45Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/b4/878fefaad5b2bcc6fcf8d474a25e3e3774bc5133e4b58adff4d0bca238bc/nvidia_nccl_cu13-2.28.9-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:e4553a30f34195f3fa1da02a6da3d6337d28f2003943aa0a3d247bbc25fefc42", size = 196493177, upload-time = "2025-11-18T05:49:17.677Z" },
+]
+
+[[package]]
+name = "nvidia-nvjitlink"
+version = "13.0.88"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/56/7a/123e033aaff487c77107195fa5a2b8686795ca537935a24efae476c41f05/nvidia_nvjitlink-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:13a74f429e23b921c1109976abefacc69835f2f433ebd323d3946e11d804e47b", size = 40713933, upload-time = "2025-09-04T08:35:43.553Z" },
+ { url = "https://files.pythonhosted.org/packages/ab/2c/93c5250e64df4f894f1cbb397c6fd71f79813f9fd79d7cd61de3f97b3c2d/nvidia_nvjitlink-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e931536ccc7d467a98ba1d8b89ff7fa7f1fa3b13f2b0069118cd7f47bff07d0c", size = 38768748, upload-time = "2025-09-04T08:35:20.008Z" },
+]
+
+[[package]]
+name = "nvidia-nvshmem-cu13"
+version = "3.4.5"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" },
+]
+
+[[package]]
+name = "nvidia-nvtx"
+version = "13.0.85"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" },
+]
+
+[[package]]
+name = "onnxruntime"
+version = "1.24.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "flatbuffers" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "protobuf" },
+ { name = "sympy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/89/db/b30dbbd6037847b205ab75d962bc349bf1e46d02a65b30d7047a6893ffd6/onnxruntime-1.24.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:fbff2a248940e3398ae78374c5a839e49a2f39079b488bc64439fa0ec327a3e4", size = 17343300, upload-time = "2026-03-17T22:03:59.223Z" },
+ { url = "https://files.pythonhosted.org/packages/61/88/1746c0e7959961475b84c776d35601a21d445f463c93b1433a409ec3e188/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2b7969e72d8cb53ffc88ab6d49dd5e75c1c663bda7be7eb0ece192f127343d1", size = 15175936, upload-time = "2026-03-17T22:03:43.671Z" },
+ { url = "https://files.pythonhosted.org/packages/5f/ba/4699cde04a52cece66cbebc85bd8335a0d3b9ad485abc9a2e15946a1349d/onnxruntime-1.24.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14ed1f197fab812b695a5eaddb536c635e58a2fbbe50a517c78f082cc6ce9177", size = 17246432, upload-time = "2026-03-17T22:04:49.58Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/60/4590910841bb28bd3b4b388a9efbedf4e2d2cca99ddf0c863642b4e87814/onnxruntime-1.24.4-cp314-cp314-win_amd64.whl", hash = "sha256:311e309f573bf3c12aa5723e23823077f83d5e412a18499d4485c7eb41040858", size = 12903276, upload-time = "2026-03-17T22:05:46.349Z" },
+ { url = "https://files.pythonhosted.org/packages/7f/6f/60e2c0acea1e1ac09b3e794b5a19c166eebf91c0b860b3e6db8e74983fda/onnxruntime-1.24.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f0b910e86b759a4732663ec61fd57ac42ee1b0066f68299de164220b660546d", size = 12594365, upload-time = "2026-03-17T22:05:35.795Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/68/0c05d10f8f6c40fe0912ebec0d5a33884aaa2af2053507e864dab0883208/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa12ddc54c9c4594073abcaa265cd9681e95fb89dae982a6f508a794ca42e661", size = 15176889, upload-time = "2026-03-17T22:03:48.021Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/1d/1666dc64e78d8587d168fec4e3b7922b92eb286a2ddeebcf6acb55c7dc82/onnxruntime-1.24.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1cc6a518255f012134bc791975a6294806be9a3b20c4a54cca25194c90cf731", size = 17247021, upload-time = "2026-03-17T22:04:52.377Z" },
+]
+
+[[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 = "phonemizer-fork"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "dlinfo" },
+ { name = "joblib" },
+ { name = "segments" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/42/fa/9294d2f11890ca49d0bdac7a4da60cbe5686629bfd4987cae0ad75e051cc/phonemizer_fork-3.3.2.tar.gz", hash = "sha256:10e16e827d0443b087062e21b55e805c00989cf1343b2e81e734cae5f6c0cf69", size = 300989, upload-time = "2025-01-30T13:02:31.201Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/f1/0dcce21b0ae16a82df4b6583f8f3ad8e55b35f7e98b6bf536a4dd225fa08/phonemizer_fork-3.3.2-py3-none-any.whl", hash = "sha256:97305c76f4183b3825dae8f4c032265fe78c9946ce58c47d4b62161349264b74", size = 82700, upload-time = "2025-01-30T13:02:28.667Z" },
+]
+
+[[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 = "preshed"
+version = "3.0.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cymem" },
+ { name = "murmurhash" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/43/75/fe6b7bbd0dea530a001b0e24c331b21a0be2786e402abf3c57f5dce43d4b/preshed-3.0.13.tar.gz", hash = "sha256:d75f718bbfd97e992f7827e0fa7faf6a91bdd9c922d5baa4b50d62731396cb89", size = 18338, upload-time = "2026-03-23T08:57:31.378Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bb/b5/993886c98f5caaa6f07a648cac97a7c62a3093091cad65e1e43a1bd41cc4/preshed-3.0.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d2f1efae396cadab5f3890a2fd43d2ee65373ef9096ccbb805e51e8d8bcc563b", size = 137882, upload-time = "2026-03-23T08:56:56.878Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/86/b7fd137cbf140afd6c45e895946068a15f5b55642916de0075e6eb18581c/preshed-3.0.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8d6acc1f5031a535a55a6f7148e2f274554a8343a16309c700cebea0fe7aee8c", size = 138233, upload-time = "2026-03-23T08:56:58.318Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ca/21a7e79625614134273dfed32bca5bb4c2ec1313e33fbd12d41657536f1f/preshed-3.0.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7da9d931e7660dcdd757e5870269f0c159126d682ed73ed313971d199eb0f334", size = 834835, upload-time = "2026-03-23T08:56:59.48Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/3a/2dbd299516461831ae90e0d5b0637137bf28520c4e6dd0b01d6f1886659a/preshed-3.0.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d4ae5cfe075bb7a07982e382bca44f41ddf041f4d24cbd358e8cccfc049259b8", size = 834928, upload-time = "2026-03-23T08:57:01.075Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/d3/af654eba4f6587c4ee02c5043e62c194b0a1c4431ffef0c67b9518f6b61c/preshed-3.0.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7557963d0125a3a7bcdb2eb6948f3e45da31b5a7f066b55320de3dea22d7557f", size = 1820368, upload-time = "2026-03-23T08:57:02.351Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/9b/ebcb2b9e8cb881e40b55b0bf450f8a6b187e2ef3ae0c685cce81d2d85026/preshed-3.0.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c4bc60dc994864095d784b7e4d77dba3e64188d169ac88722b699d175561fddb", size = 1888251, upload-time = "2026-03-23T08:57:04.158Z" },
+ { url = "https://files.pythonhosted.org/packages/97/f7/c6c012779edcaa6e2cd092c554e98dc53e77f41205b07208655ba77e2327/preshed-3.0.13-cp314-cp314-win_amd64.whl", hash = "sha256:208dcebbe294bf1881ce33fb015d56ab2a7587aece85a09147727174207892e4", size = 125211, upload-time = "2026-03-23T08:57:05.83Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/82/390ef87d732ef64e673ef6bf9e5d898453986e979efa50fb3a400e2c0766/preshed-3.0.13-cp314-cp314-win_arm64.whl", hash = "sha256:cf8e1a7a1823b2a7765121446c630140ac6e8650c07a6efbf375e168d1fef4f7", size = 111942, upload-time = "2026-03-23T08:57:06.996Z" },
+ { url = "https://files.pythonhosted.org/packages/80/3a/a9dde3167bcecb27ae82ce4567b5ab1aa3989113ae6814c092ce223cc4ef/preshed-3.0.13-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9ca43ecbc3783eda4d6ab3416ae2ecd9ef23dca5f53995843f69f7457bcd0677", size = 144997, upload-time = "2026-03-23T08:57:08.064Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d4/22d9355b50b6a13b407dcad0a81df83fb1d5602092d1f05834674dde8fda/preshed-3.0.13-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c8596e41a258ff213553a441e0bb3eb388fd8158e84a7bf3aae6d8ede2c166d3", size = 147294, upload-time = "2026-03-23T08:57:09.411Z" },
+ { url = "https://files.pythonhosted.org/packages/70/42/a225ee83fdb306d2a503f21a627953b820f4e079c90c8a84338957cb8ff5/preshed-3.0.13-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4f8856ca3d88e9b250630d70abb4f260d8933151ddfb413024784b25b009868e", size = 952110, upload-time = "2026-03-23T08:57:10.592Z" },
+ { url = "https://files.pythonhosted.org/packages/40/ba/09a9dfe3d22d7e745483fd5d7f2a82cd4d39c161f7d2daa0faa4bd6402be/preshed-3.0.13-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e5b2865aecbd2e1e10e5d19bb8bfad765863c1307c6c3e51f2a08bd64122409", size = 932217, upload-time = "2026-03-23T08:57:12.124Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/5c/e10e2e05133e7fcbd7c40536af1148c82dd24357b8f5726e2c7bc51cfd53/preshed-3.0.13-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:09f96b477c987755b3c945df214ea1c1c80bfb350e9f34e78da89585535b77e8", size = 1896542, upload-time = "2026-03-23T08:57:13.525Z" },
+ { url = "https://files.pythonhosted.org/packages/37/aa/51e5b4109a4cdfae28c3613eeeb10764a3794ebef8de93ffbb109465bea3/preshed-3.0.13-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:670db59a52e1823b5f088c764df474e65b686592d4093adbeef14581c95ee2cb", size = 1959473, upload-time = "2026-03-23T08:57:15.706Z" },
+ { url = "https://files.pythonhosted.org/packages/0e/6a/1d966f367a14c703dde629d150d996c1b727d442f620300b21c9ec1a24d1/preshed-3.0.13-cp314-cp314t-win_amd64.whl", hash = "sha256:b03e21b0bf95eb56e23973f32cabb930e94f352228652f81c0955dbd6967d904", size = 146229, upload-time = "2026-03-23T08:57:17.457Z" },
+ { url = "https://files.pythonhosted.org/packages/22/80/368139067603e590a000122355f9c8576c8ebed4fb0b8849feaa2698489d/preshed-3.0.13-cp314-cp314t-win_arm64.whl", hash = "sha256:b980f3ea9bb74b7f94464bc3d6eb3c9162b6b79b531febd14c6465c24344d2cc", size = 119339, upload-time = "2026-03-23T08:57:18.882Z" },
+]
+
+[[package]]
+name = "priority"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" },
+]
+
+[[package]]
+name = "propcache"
+version = "0.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
+ { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
+ { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
+ { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
+ { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
+ { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
+ { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
+ { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
+ { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
+ { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
+ { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
+ { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
+ { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
+ { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
+]
+
+[[package]]
+name = "protobuf"
+version = "7.34.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/6b/6b/a0e95cad1ad7cc3f2c6821fcab91671bd5b78bd42afb357bb4765f29bc41/protobuf-7.34.1.tar.gz", hash = "sha256:9ce42245e704cc5027be797c1db1eb93184d44d1cdd71811fb2d9b25ad541280", size = 454708, upload-time = "2026-03-20T17:34:47.036Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/11/3325d41e6ee15bf1125654301211247b042563bcc898784351252549a8ad/protobuf-7.34.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:d8b2cc79c4d8f62b293ad9b11ec3aebce9af481fa73e64556969f7345ebf9fc7", size = 429247, upload-time = "2026-03-20T17:34:37.024Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/9d/aa69df2724ff63efa6f72307b483ce0827f4347cc6d6df24b59e26659fef/protobuf-7.34.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:5185e0e948d07abe94bb76ec9b8416b604cfe5da6f871d67aad30cbf24c3110b", size = 325753, upload-time = "2026-03-20T17:34:38.751Z" },
+ { url = "https://files.pythonhosted.org/packages/92/e8/d174c91fd48e50101943f042b09af9029064810b734e4160bbe282fa1caa/protobuf-7.34.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:403b093a6e28a960372b44e5eb081775c9b056e816a8029c61231743d63f881a", size = 340198, upload-time = "2026-03-20T17:34:39.871Z" },
+ { url = "https://files.pythonhosted.org/packages/53/1b/3b431694a4dc6d37b9f653f0c64b0a0d9ec074ee810710c0c3da21d67ba7/protobuf-7.34.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ff40ce8cd688f7265326b38d5a1bed9bfdf5e6723d49961432f83e21d5713e4", size = 324267, upload-time = "2026-03-20T17:34:41.1Z" },
+ { url = "https://files.pythonhosted.org/packages/85/29/64de04a0ac142fb685fd09999bc3d337943fb386f3a0ec57f92fd8203f97/protobuf-7.34.1-cp310-abi3-win32.whl", hash = "sha256:34b84ce27680df7cca9f231043ada0daa55d0c44a2ddfaa58ec1d0d89d8bf60a", size = 426628, upload-time = "2026-03-20T17:34:42.536Z" },
+ { url = "https://files.pythonhosted.org/packages/4d/87/cb5e585192a22b8bd457df5a2c16a75ea0db9674c3a0a39fc9347d84e075/protobuf-7.34.1-cp310-abi3-win_amd64.whl", hash = "sha256:e97b55646e6ce5cbb0954a8c28cd39a5869b59090dfaa7df4598a7fba869468c", size = 437901, upload-time = "2026-03-20T17:34:44.112Z" },
+ { url = "https://files.pythonhosted.org/packages/88/95/608f665226bca68b736b79e457fded9a2a38c4f4379a4a7614303d9db3bc/protobuf-7.34.1-py3-none-any.whl", hash = "sha256:bb3812cd53aefea2b028ef42bd780f5b96407247f20c6ef7c679807e9d188f11", size = 170715, upload-time = "2026-03-20T17:34:45.384Z" },
+]
+
+[[package]]
+name = "py-vapid"
+version = "1.9.4"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cryptography" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/a3/ed/c648c8018fab319951764f4babe68ddcbbff7f2bbcd7ff7e531eac1788c8/py_vapid-1.9.4.tar.gz", hash = "sha256:a004023560cbc54e34fc06380a0580f04ffcc788e84fb6d19e9339eeb6551a28", size = 74750, upload-time = "2026-01-05T22:13:25.201Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/15/f9d0171e1ad863ca49e826d5afb6b50566f20dc9b4f76965096d3555ce9e/py_vapid-1.9.4-py2.py3-none-any.whl", hash = "sha256:f165a5bf90dcf966b226114f01f178f137579a09784c7f0628fa2f0a299741b6", size = 23912, upload-time = "2026-01-05T20:42:05.455Z" },
+]
+
+[[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/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" },
+]
+
+[[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 = "pyparsing"
+version = "3.3.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" },
+]
+
+[[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" },
+]
+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-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2026.1.post1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/56/db/b8721d71d945e6a8ac63c0fc900b2067181dbb50805958d4d4661cf7d277/pytz-2026.1.post1.tar.gz", hash = "sha256:3378dde6a0c3d26719182142c56e60c7f9af7e968076f31aae569d72a0358ee1", size = 321088, upload-time = "2026-03-03T07:47:50.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/99/781fe0c827be2742bcc775efefccb3b048a3a9c6ce9aec0cbf4a101677e5/pytz-2026.1.post1-py2.py3-none-any.whl", hash = "sha256:f2fd16142fda348286a75e1a524be810bb05d444e5a081f37f7affc635035f7a", size = 510489, upload-time = "2026-03-03T07:47:49.167Z" },
+]
+
+[[package]]
+name = "pywebpush"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiohttp" },
+ { name = "cryptography" },
+ { name = "http-ece" },
+ { name = "py-vapid" },
+ { name = "requests" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/d9/e497a24bc9f659bfc0e570382a41e6b2d6726fbcfa4d85aaa23fe9c81ba2/pywebpush-2.3.0.tar.gz", hash = "sha256:d1e27db8de9e6757c1875f67292554bd54c41874c36f4b5c4ebb5442dce204f2", size = 28489, upload-time = "2026-02-09T23:30:18.574Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/d8/ac21241cf8007cb93255eabf318da4f425ec0f75d28c366992253aa8c1b2/pywebpush-2.3.0-py3-none-any.whl", hash = "sha256:3d97469fb14d4323c362319d438183737249a4115b50e146ce233e7f01e3cf98", size = 22851, upload-time = "2026-02-09T23:30:16.093Z" },
+]
+
+[[package]]
+name = "pyyaml"
+version = "6.0.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+]
+
+[[package]]
+name = "qh3"
+version = "1.7.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/2d/fe3fb2cb618191dcaa0f9fbeb98498641a8148cfa0a5086b7298d9b7b7ac/qh3-1.7.1.tar.gz", hash = "sha256:4ce90c54ab94521840248522de2b6620f302cde8ff317333f40f405907e6b6ad", size = 285895, upload-time = "2026-03-30T07:16:06.378Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/79/49/73e7b33f12d9ba318933dd83e7f2e596f1ac7e175f62daa91e2ca5f54b07/qh3-1.7.1-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d3f305f3d55a7bd1d235537829d3e4704bf15478657434fc29809e1130aa00d3", size = 4163579, upload-time = "2026-03-30T07:13:18.14Z" },
+ { url = "https://files.pythonhosted.org/packages/68/38/4912c46758c13f2dd7f93a58be9b22ac87ef0e47f18645046edf2a7d6f7f/qh3-1.7.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad639b734f8e91fc1c97801fb16e504c08e3ad1bad3f18b1e71e5736cf079872", size = 2025078, upload-time = "2026-03-30T07:13:19.792Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/41/68bc9c9470f51a261e0528864c70e2019b150fc3ba076ea5dfe766823788/qh3-1.7.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9777367bee6bb7500faf4cfacf2741464ea4f30221e8d6298899a398452de2dc", size = 1741439, upload-time = "2026-03-30T07:13:21.181Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/fa/d682a279da99ca3978922a247a70e8471850d5095fd3a1f9497d77262f91/qh3-1.7.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29ae0d8c96b9bd3e6c1bf107aba402d0a140117ca38d05e65ce97cd66a8766b", size = 1906206, upload-time = "2026-03-30T07:13:22.492Z" },
+ { url = "https://files.pythonhosted.org/packages/95/17/45101128d70dca398fe7668bbde603aba7808f0bc216d799918752a35421/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:cfe638402e76d7f54183a5e3b365f9fcfc6a5e367556f827ac124c6f1ce26b7e", size = 1890431, upload-time = "2026-03-30T07:13:23.896Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/b7/9aa1beff114488278a155af6b1b8258ac0a8ef0a98e1107c93c2b4ab75cf/qh3-1.7.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4cc90002b5581580526d7d7f11b3359693cc09f43dc28a4433daab2194149ad8", size = 1892506, upload-time = "2026-03-30T07:13:25.549Z" },
+ { url = "https://files.pythonhosted.org/packages/17/e3/bceaa72e9a95d18df5874f85d5e7889ae3362250327b24bc16c571a57fba/qh3-1.7.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0bb89e1afe81ddf2226baa2b837cec0773fe0b099a3145ebbfbc520e46f1da30", size = 1963256, upload-time = "2026-03-30T07:13:27.117Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/bf/6006185e904523d006b9e11be1b9a0ad5b1adcea9f1872f77271462c1388/qh3-1.7.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f3a32657aea7e48baf590b81f030325be6f69d7cb771893302abee915cfe3be", size = 2249446, upload-time = "2026-03-30T07:13:28.616Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/42/67405ee7031e86ad99379f6c7421fa2ae1893c330800ba24b7bdf4b798f0/qh3-1.7.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:c0da3ddf00b978dc8b9d8fc550498839d4e4afbf59538837dc6b4209b90ec281", size = 1898225, upload-time = "2026-03-30T07:13:30.365Z" },
+ { url = "https://files.pythonhosted.org/packages/14/ce/7713db24b6624e0c60f5c3895750f4c57b288a889ea7adcd3a80cea2908f/qh3-1.7.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:ec10cd0caf4938e63bf90fffcc6485eedccb77df449d7631f6bbcbaf8688ff84", size = 2204645, upload-time = "2026-03-30T07:13:31.876Z" },
+ { url = "https://files.pythonhosted.org/packages/00/72/6056ab22b239d3edeb6e6acd25e43f9479d2acc50531eeaaa4d1769124a1/qh3-1.7.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:a8f1cb3e1ac71dd221feb4f5feeeb5171a2d1f16e531ef53b8a9a0b2dd2c275c", size = 1994040, upload-time = "2026-03-30T07:13:33.362Z" },
+ { url = "https://files.pythonhosted.org/packages/24/81/d9fdf1a1cbe86d49202375f69ce2e923cd7274a7dc39b71422ce72072c43/qh3-1.7.1-cp314-cp314t-musllinux_1_1_i686.whl", hash = "sha256:59e0ba0db9e68cfeba5ea8a0c119777f92c6fb5850307ab7e98eb8c388bedf7f", size = 2095538, upload-time = "2026-03-30T07:13:34.923Z" },
+ { url = "https://files.pythonhosted.org/packages/74/d9/99f75449f021c3c5bc0f215503158591a1f8794bd435f5a92f86b0941fff/qh3-1.7.1-cp314-cp314t-musllinux_1_1_riscv64.whl", hash = "sha256:eaff3423e2145c41db0e914f0e624c4f01e443533f4ca849c289cb0018f0b800", size = 2008870, upload-time = "2026-03-30T07:13:36.537Z" },
+ { url = "https://files.pythonhosted.org/packages/35/66/4b963671fde5b4b1721e3285ec65dae9b79897d3284bc71d0844edf28f5d/qh3-1.7.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0c6b008df6aed81f71daf1e44f56e96e73b6b9c5df8b018af0cc98e196f3bbd0", size = 2459870, upload-time = "2026-03-30T07:13:37.958Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/d6/327ad6aac7b5c8b74cfd70e875791c868cd41d175cd871a7ef206491b1f2/qh3-1.7.1-cp314-cp314t-win32.whl", hash = "sha256:be271db1d7df7bf56dd7c2b1ddb37a679de913c96ab3f2e9be85de96d973bfd7", size = 1755903, upload-time = "2026-03-30T07:13:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/57/e4/2a5551af4f44aa5b150f4b0d8a86b974d8f42fe567645b2ad1b1ab056714/qh3-1.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb1c03009fefe3b3d89de18d81edea934d276d8d0817fedd9ab34226e9d92cda", size = 2003442, upload-time = "2026-03-30T07:13:40.748Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/c1/62238dc67a0c99b330d48f8642b23ce9bc7a8c5705fbb1406d609cc68348/qh3-1.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:60a9044c3f758d4b8abc9f414bae29cce986fdae12d2ace6b733c2306423cddc", size = 1841401, upload-time = "2026-03-30T07:13:42.207Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/9d/37c127a2fde22787c8a265cb3f3541cfaa3599725e9667a4c8a4672cfa92/qh3-1.7.1-cp37-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:bb58ce71850c302f35cf961a5f218e562a934fdead0ae32883b3d2bf632d5d42", size = 4174574, upload-time = "2026-03-30T07:13:43.939Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/f6/cfe4a6d8cd45aeb4d472154e4f2776a9574df78d612bb275cbac19dc47a6/qh3-1.7.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca2b3ed1407d2e0898369b7ebf2075b4e856d89c9fdabd0b93943b6350a3378b", size = 2028163, upload-time = "2026-03-30T07:13:45.371Z" },
+ { url = "https://files.pythonhosted.org/packages/89/11/1871830752405356b25e283d8eb09579e663a3869330d7b940510cee110e/qh3-1.7.1-cp37-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4276453d9887326d55ecc2fed07d1569c7ecc8d31b40ff0d2488b7587b340f94", size = 1743072, upload-time = "2026-03-30T07:13:47.213Z" },
+ { url = "https://files.pythonhosted.org/packages/34/0a/187a271b4d5f8bfeee2b7149efb504897e583e87c37e8f11bb5bc2c1d0ef/qh3-1.7.1-cp37-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee7375faf82ae7acb32cf238b551886bf58b4456152b7c0c478c0d44b34165b3", size = 1911137, upload-time = "2026-03-30T07:13:48.719Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/46/164affc8b9c785fffb10a4dffed0c543a95422fdf80c6a8823a8603092dc/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:df1d99471f5074faadea2cee7ebbd4ccd44e9b234c8350fb56003227a7890b9b", size = 1893576, upload-time = "2026-03-30T07:13:50.489Z" },
+ { url = "https://files.pythonhosted.org/packages/69/54/1661d594e1fb3fdfef5c062e49c4e79e24336585a3668a88fefb10615284/qh3-1.7.1-cp37-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e8c37aff831446f8dc7e131c52cd89f6f5b6ac03b0e7adf9d35818c014c51884", size = 1898396, upload-time = "2026-03-30T07:13:52.285Z" },
+ { url = "https://files.pythonhosted.org/packages/29/07/adba3ec305e2240dc7c3795cceac8fd1cbb2ca802bf3e6e7d16cfed644fb/qh3-1.7.1-cp37-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:47a457645ab86d41d06ef7e3486bdf419b06f1392ba20858ec9d492b2862ce0f", size = 1965969, upload-time = "2026-03-30T07:13:53.798Z" },
+ { url = "https://files.pythonhosted.org/packages/23/a1/67b3b59025aada229faec97de60df31ce765bf42dd3adcce9a638150f6e7/qh3-1.7.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dab8eb5b4bd3777f3f7a823f57e759e65f558c239c32030918dcd078c01f8734", size = 2252690, upload-time = "2026-03-30T07:13:55.325Z" },
+ { url = "https://files.pythonhosted.org/packages/02/be/a190715ceb0890de6a7693a4c34ae1a559cb74031db30a2c50d6c8d2abee/qh3-1.7.1-cp37-abi3-manylinux_2_39_riscv64.whl", hash = "sha256:cc9beb80baddcc1755869e0da07c25dbb0515d41d8e53b6a6984d0ea769f066e", size = 1899680, upload-time = "2026-03-30T07:13:57.153Z" },
+ { url = "https://files.pythonhosted.org/packages/57/b9/95cdcb0e2b76808813a86d520ceb30171f343555e922a627d58b015ce0c1/qh3-1.7.1-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3bc0f9714f5a5a08a3bd80d9597e59358cbbe1333661feda29e249689ad1fd19", size = 2207162, upload-time = "2026-03-30T07:13:58.868Z" },
+ { url = "https://files.pythonhosted.org/packages/47/eb/9186a4c96e3a319ae37fef801663667b0e9ce5c1881b8c4f1d6cb8c610b5/qh3-1.7.1-cp37-abi3-musllinux_1_1_armv7l.whl", hash = "sha256:e2b5f2d607f8a4982f540a2f3de9477b8e5c2c8cd5557dad2ed80cd33cb58b4c", size = 1995378, upload-time = "2026-03-30T07:14:00.264Z" },
+ { url = "https://files.pythonhosted.org/packages/32/27/ac4d939baa3d9c11aaa37856ccd4e776c1329bb9fc58214741e1344c4f8f/qh3-1.7.1-cp37-abi3-musllinux_1_1_i686.whl", hash = "sha256:5a478b4733dbb7f37694e2045ae706542f734d9033438e55bd38bf93eda0f093", size = 2099056, upload-time = "2026-03-30T07:14:02.039Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/ab/81c4e026c1cf81f8988f5a178d05ce56c7819b24e52cc913a3db6ecf3cac/qh3-1.7.1-cp37-abi3-musllinux_1_1_riscv64.whl", hash = "sha256:5cd1f93e905ceec99217ceb1c2a971b430d69101e74423d271616173ab738dab", size = 2010716, upload-time = "2026-03-30T07:14:03.461Z" },
+ { url = "https://files.pythonhosted.org/packages/35/eb/951451ae4a7a899a7cf3ae2d796178f814ce27f423a97ca29314a33d1ce7/qh3-1.7.1-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:23ed67a177ec6b8453a23bcff7fdad47307f54edaf32f968f95adb665cc04ae3", size = 2463359, upload-time = "2026-03-30T07:14:05.371Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/dd/1ed9e4fe7d277981b5d169b506b63007c22d73151497875b7414611f568e/qh3-1.7.1-cp37-abi3-win32.whl", hash = "sha256:591e3a2801973b88705cff3624b24436ec16d61f55bc37ed6ce417e998a77f30", size = 1759471, upload-time = "2026-03-30T07:14:06.902Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/4d/75efaefbf24cdb13fcee77f77ac6ba6ac51a6428995386962b9d21768307/qh3-1.7.1-cp37-abi3-win_amd64.whl", hash = "sha256:2e1a36665f93f50ff70dfdc6c4d4873bc7fb99210caf8806a52452fa813fea8f", size = 2009871, upload-time = "2026-03-30T07:14:08.44Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/ac/2e962684bc88a3fb1ffc40e9e058e7d5d9c1e88390f260e49e58f72cea06/qh3-1.7.1-cp37-abi3-win_arm64.whl", hash = "sha256:f07701c40d6b012b91756cc1c59805f72dd4706159fde66e0759cff411705d41", size = 1846336, upload-time = "2026-03-30T07:14:09.931Z" },
+]
+
+[[package]]
+name = "quart"
+version = "0.20.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "aiofiles" },
+ { name = "blinker" },
+ { name = "click" },
+ { name = "flask" },
+ { name = "hypercorn" },
+ { name = "itsdangerous" },
+ { name = "jinja2" },
+ { name = "markupsafe" },
+ { name = "werkzeug" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" },
+]
+
+[[package]]
+name = "rdflib"
+version = "7.6.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyparsing" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/98/f5/18bb77b7af9526add0c727a3b2048959847dc5fb030913e2918bf384fec3/rdflib-7.6.0.tar.gz", hash = "sha256:6c831288d5e4a5a7ece85d0ccde9877d512a3d0f02d7c06455d00d6d0ea379df", size = 4943826, upload-time = "2026-02-13T07:15:55.938Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/10/c2/6604a71269e0c1bd75656d5a001432d16f2cc5b8c057140ec797155c295e/rdflib-7.6.0-py3-none-any.whl", hash = "sha256:30c0a3ebf4c0e09215f066be7246794b6492e054e782d7ac2a34c9f70a15e0dd", size = 615416, upload-time = "2026-02-13T07:15:46.487Z" },
+]
+
+[[package]]
+name = "recurring-ical-events"
+version = "3.8.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "icalendar" },
+ { name = "python-dateutil" },
+ { name = "tzdata" },
+ { name = "x-wr-timezone" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/f1/d4/51c9361bb0efb2290dfd850c036b49acb502794e0fe9cc3520dbf60fd7db/recurring_ical_events-3.8.1.tar.gz", hash = "sha256:c3eb2490a00559fb963d2bdee39acf2f287c91c07dcea4ce80ade1c60a8c3acf", size = 603730, upload-time = "2026-02-18T11:45:53.272Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/67/4d4aead359164de68d30ee67efcdbe3784063cb21535c85b9a9a03dd2ebb/recurring_ical_events-3.8.1-py3-none-any.whl", hash = "sha256:3bb3aaa0c87a4d3ab5951360480686bd69f1512945f478be6a2c0f141da0bf78", size = 238286, upload-time = "2026-02-18T11:45:51.631Z" },
+]
+
+[[package]]
+name = "referencing"
+version = "0.37.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "attrs" },
+ { name = "rpds-py" },
+]
+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 = "regex"
+version = "2026.4.4"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/3a246dbf05666918bd3664d9d787f84a9108f6f43cc953a077e4a7dfdb7e/regex-2026.4.4.tar.gz", hash = "sha256:e08270659717f6973523ce3afbafa53515c4dc5dcad637dc215b6fd50f689423", size = 416000, upload-time = "2026-04-03T20:56:28.155Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f0/f5/ed97c2dc47b5fbd4b73c0d7d75f9ebc8eca139f2bbef476bba35f28c0a77/regex-2026.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:2da82d643fa698e5e5210e54af90181603d5853cf469f5eedf9bfc8f59b4b8c7", size = 490343, upload-time = "2026-04-03T20:55:15.241Z" },
+ { url = "https://files.pythonhosted.org/packages/80/e9/de4828a7385ec166d673a5790ad06ac48cdaa98bc0960108dd4b9cc1aef7/regex-2026.4.4-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:54a1189ad9d9357760557c91103d5e421f0a2dabe68a5cdf9103d0dcf4e00752", size = 291909, upload-time = "2026-04-03T20:55:17.558Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/d6/5cfbfc97f3201a4d24b596a77957e092030dcc4205894bc035cedcfce62f/regex-2026.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:76d67d5afb1fe402d10a6403bae668d000441e2ab115191a804287d53b772951", size = 289692, upload-time = "2026-04-03T20:55:20.561Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/ac/f2212d9fd56fe897e36d0110ba30ba2d247bd6410c5bd98499c7e5a1e1f2/regex-2026.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e7cd3e4ee8d80447a83bbc9ab0c8459781fa77087f856c3e740d7763be0df27f", size = 796979, upload-time = "2026-04-03T20:55:22.56Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/e3/a016c12675fbac988a60c7e1c16e67823ff0bc016beb27bd7a001dbdabc6/regex-2026.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e19e18c568d2866d8b6a6dfad823db86193503f90823a8f66689315ba28fbe8", size = 866744, upload-time = "2026-04-03T20:55:24.646Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a4/0b90ca4cf17adc3cb43de80ec71018c37c88ad64987e8d0d481a95ca60b5/regex-2026.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7698a6f38730fd1385d390d1ed07bb13dce39aa616aca6a6d89bea178464b9a4", size = 911613, upload-time = "2026-04-03T20:55:27.033Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/3b/2b3dac0b82d41ab43aa87c6ecde63d71189d03fe8854b8ca455a315edac3/regex-2026.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:173a66f3651cdb761018078e2d9487f4cf971232c990035ec0eb1cdc6bf929a9", size = 800551, upload-time = "2026-04-03T20:55:29.532Z" },
+ { url = "https://files.pythonhosted.org/packages/25/fe/5365eb7aa0e753c4b5957815c321519ecab033c279c60e1b1ae2367fa810/regex-2026.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa7922bbb2cc84fa062d37723f199d4c0cd200245ce269c05db82d904db66b83", size = 776911, upload-time = "2026-04-03T20:55:31.526Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b3/7fb0072156bba065e3b778a7bc7b0a6328212be5dd6a86fd207e0c4f2dab/regex-2026.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:59f67cd0a0acaf0e564c20bbd7f767286f23e91e2572c5703bf3e56ea7557edb", size = 785751, upload-time = "2026-04-03T20:55:33.797Z" },
+ { url = "https://files.pythonhosted.org/packages/02/1a/9f83677eb699273e56e858f7bd95acdbee376d42f59e8bfca2fd80d79df3/regex-2026.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:475e50f3f73f73614f7cba5524d6de49dee269df00272a1b85e3d19f6d498465", size = 860484, upload-time = "2026-04-03T20:55:35.745Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/7a/93937507b61cfcff8b4c5857f1b452852b09f741daa9acae15c971d8554e/regex-2026.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a1c0c7d67b64d85ac2e1879923bad2f08a08f3004055f2f406ef73c850114bd4", size = 765939, upload-time = "2026-04-03T20:55:37.972Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ea/81a7f968a351c6552b1670ead861e2a385be730ee28402233020c67f9e0f/regex-2026.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:1371c2ccbb744d66ee63631cc9ca12aa233d5749972626b68fe1a649dd98e566", size = 851417, upload-time = "2026-04-03T20:55:39.92Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/7e/323c18ce4b5b8f44517a36342961a0306e931e499febbd876bb149d900f0/regex-2026.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59968142787042db793348a3f5b918cf24ced1f23247328530e063f89c128a95", size = 789056, upload-time = "2026-04-03T20:55:42.303Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/af/e7510f9b11b1913b0cd44eddb784b2d650b2af6515bfce4cffcc5bfd1d38/regex-2026.4.4-cp314-cp314-win32.whl", hash = "sha256:59efe72d37fd5a91e373e5146f187f921f365f4abc1249a5ab446a60f30dd5f8", size = 272130, upload-time = "2026-04-03T20:55:44.995Z" },
+ { url = "https://files.pythonhosted.org/packages/9a/51/57dae534c915e2d3a21490e88836fa2ae79dde3b66255ecc0c0a155d2c10/regex-2026.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:e0aab3ff447845049d676827d2ff714aab4f73f340e155b7de7458cf53baa5a4", size = 280992, upload-time = "2026-04-03T20:55:47.316Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/5e/abaf9f4c3792e34edb1434f06717fae2b07888d85cb5cec29f9204931bf8/regex-2026.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:a7a5bb6aa0cf62208bb4fa079b0c756734f8ad0e333b425732e8609bd51ee22f", size = 273563, upload-time = "2026-04-03T20:55:49.273Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/06/35da85f9f217b9538b99cbb170738993bcc3b23784322decb77619f11502/regex-2026.4.4-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:97850d0638391bdc7d35dc1c1039974dcb921eaafa8cc935ae4d7f272b1d60b3", size = 494191, upload-time = "2026-04-03T20:55:51.258Z" },
+ { url = "https://files.pythonhosted.org/packages/54/5b/1bc35f479eef8285c4baf88d8c002023efdeebb7b44a8735b36195486ae7/regex-2026.4.4-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ee7337f88f2a580679f7bbfe69dc86c043954f9f9c541012f49abc554a962f2e", size = 293877, upload-time = "2026-04-03T20:55:53.214Z" },
+ { url = "https://files.pythonhosted.org/packages/39/5b/f53b9ad17480b3ddd14c90da04bfb55ac6894b129e5dea87bcaf7d00e336/regex-2026.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7429f4e6192c11d659900c0648ba8776243bf396ab95558b8c51a345afeddde6", size = 292410, upload-time = "2026-04-03T20:55:55.736Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/56/52377f59f60a7c51aa4161eecf0b6032c20b461805aca051250da435ffc9/regex-2026.4.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4f10fbd5dd13dcf4265b4cc07d69ca70280742870c97ae10093e3d66000359", size = 811831, upload-time = "2026-04-03T20:55:57.802Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/63/8026310bf066f702a9c361f83a8c9658f3fe4edb349f9c1e5d5273b7c40c/regex-2026.4.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a152560af4f9742b96f3827090f866eeec5becd4765c8e0d3473d9d280e76a5a", size = 871199, upload-time = "2026-04-03T20:56:00.333Z" },
+ { url = "https://files.pythonhosted.org/packages/20/9f/a514bbb00a466dbb506d43f187a04047f7be1505f10a9a15615ead5080ee/regex-2026.4.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54170b3e95339f415d54651f97df3bff7434a663912f9358237941bbf9143f55", size = 917649, upload-time = "2026-04-03T20:56:02.445Z" },
+ { url = "https://files.pythonhosted.org/packages/cb/6b/8399f68dd41a2030218839b9b18360d79b86d22b9fab5ef477c7f23ca67c/regex-2026.4.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:07f190d65f5a72dcb9cf7106bfc3d21e7a49dd2879eda2207b683f32165e4d99", size = 816388, upload-time = "2026-04-03T20:56:04.595Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/9c/103963f47c24339a483b05edd568594c2be486188f688c0170fd504b2948/regex-2026.4.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9a2741ce5a29d3c84b0b94261ba630ab459a1b847a0d6beca7d62d188175c790", size = 785746, upload-time = "2026-04-03T20:56:07.13Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/ee/7f6054c0dec0cee3463c304405e4ff42e27cff05bf36fcb34be549ab17bd/regex-2026.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b26c30df3a28fd9793113dac7385a4deb7294a06c0f760dd2b008bd49a9139bc", size = 801483, upload-time = "2026-04-03T20:56:09.365Z" },
+ { url = "https://files.pythonhosted.org/packages/30/c2/51d3d941cf6070dc00c3338ecf138615fc3cce0421c3df6abe97a08af61a/regex-2026.4.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:421439d1bee44b19f4583ccf42670ca464ffb90e9fdc38d37f39d1ddd1e44f1f", size = 866331, upload-time = "2026-04-03T20:56:12.039Z" },
+ { url = "https://files.pythonhosted.org/packages/16/e8/76d50dcc122ac33927d939f350eebcfe3dbcbda96913e03433fc36de5e63/regex-2026.4.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b40379b53ecbc747fd9bdf4a0ea14eb8188ca1bd0f54f78893a39024b28f4863", size = 772673, upload-time = "2026-04-03T20:56:14.558Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/6e/5f6bf75e20ea6873d05ba4ec78378c375cbe08cdec571c83fbb01606e563/regex-2026.4.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:08c55c13d2eef54f73eeadc33146fb0baaa49e7335eb1aff6ae1324bf0ddbe4a", size = 857146, upload-time = "2026-04-03T20:56:16.663Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/33/3c76d9962949e487ebba353a18e89399f292287204ac8f2f4cfc3a51c233/regex-2026.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9776b85f510062f5a75ef112afe5f494ef1635607bf1cc220c1391e9ac2f5e81", size = 803463, upload-time = "2026-04-03T20:56:18.923Z" },
+ { url = "https://files.pythonhosted.org/packages/19/eb/ef32dcd2cb69b69bc0c3e55205bce94a7def48d495358946bc42186dcccc/regex-2026.4.4-cp314-cp314t-win32.whl", hash = "sha256:385edaebde5db5be103577afc8699fea73a0e36a734ba24870be7ffa61119d74", size = 275709, upload-time = "2026-04-03T20:56:20.996Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/86/c291bf740945acbf35ed7dbebf8e2eea2f3f78041f6bd7cdab80cb274dc0/regex-2026.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:5d354b18839328927832e2fa5f7c95b7a3ccc39e7a681529e1685898e6436d45", size = 285622, upload-time = "2026-04-03T20:56:23.641Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/e7/ec846d560ae6a597115153c02ca6138a7877a1748b2072d9521c10a93e58/regex-2026.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:af0384cb01a33600c49505c27c6c57ab0b27bf84a74e28524c92ca897ebdac9d", size = 275773, upload-time = "2026-04-03T20:56:26.07Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
+]
+
+[[package]]
+name = "rfc3986"
+version = "1.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/79/30/5b1b6c28c105629cc12b33bdcbb0b11b5bb1880c6cfbd955f9e792921aa8/rfc3986-1.5.0.tar.gz", hash = "sha256:270aaf10d87d0d4e095063c65bf3ddbc6ee3d0b226328ce21e036f946e421835", size = 49378, upload-time = "2021-05-07T23:29:27.183Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/e5/63ca2c4edf4e00657584608bee1001302bbf8c5f569340b78304f2f446cb/rfc3986-1.5.0-py2.py3-none-any.whl", hash = "sha256:a86d6e1f5b1dc238b218b012df0aa79409667bb209e58da56d0b94704e712a97", size = 31976, upload-time = "2021-05-07T23:29:25.611Z" },
+]
+
+[[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/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 = "ruff"
+version = "0.15.10"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
+ { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
+ { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
+ { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
+ { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
+ { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
+ { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
+]
+
+[[package]]
+name = "safetensors"
+version = "0.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
+ { url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
+ { url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
+ { url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
+ { url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
+ { url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
+]
+
+[[package]]
+name = "segments"
+version = "2.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "csvw" },
+ { name = "regex" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/57/85cac3a8e32370e88fa5fa92812edb6025db7fcbed51452bd56ee1524957/segments-2.4.0.tar.gz", hash = "sha256:bba71f5520ddd54c8aa2f4d765a60618c6862162d6e7356a4a097f2223166f5b", size = 18662, upload-time = "2026-03-07T10:01:28.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/be/60/eef9acce946177f92c9aabf432224d87ab908bafafac516a36ab924199f3/segments-2.4.0-py2.py3-none-any.whl", hash = "sha256:4021dc67f201cc03c864c74c618bdb163b1af629da3040babbaa37d8813f3db0", size = 16321, upload-time = "2026-03-07T10:01:27.885Z" },
+]
+
+[[package]]
+name = "setuptools"
+version = "81.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
+]
+
+[[package]]
+name = "sgmllib3k"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/9e/bd/3704a8c3e0942d711c1299ebf7b9091930adae6675d7c8f476a7ce48653c/sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9", size = 5750, upload-time = "2010-08-24T14:33:52.445Z" }
+
+[[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 = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "smart-open"
+version = "7.5.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "wrapt" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e8/be/a66598b305763861a9ab15ff0f2fbc44e47b1ce7a776797337a4eef37c66/smart_open-7.5.1.tar.gz", hash = "sha256:3f08e16827c4733699e6b2cc40328a3568f900cb12ad9a3ad233ba6c872d9fe7", size = 54034, upload-time = "2026-02-23T11:01:28.979Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5e/ea/dcdecd68acebb49d3fd560473a43499b1635076f7f1ae8641c060fe7ce74/smart_open-7.5.1-py3-none-any.whl", hash = "sha256:3e07cbbd9c8a908bcb8e25d48becf1a5cbb4886fa975e9f34c672ed171df2318", size = 64108, upload-time = "2026-02-23T11:01:27.429Z" },
+]
+
+[[package]]
+name = "soundfile"
+version = "0.13.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cffi" },
+ { name = "numpy" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e1/41/9b873a8c055582859b239be17902a85339bec6a30ad162f98c9b0288a2cc/soundfile-0.13.1.tar.gz", hash = "sha256:b2c68dab1e30297317080a5b43df57e302584c49e2942defdde0acccc53f0e5b", size = 46156, upload-time = "2025-01-25T09:17:04.831Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/64/28/e2a36573ccbcf3d57c00626a21fe51989380636e821b341d36ccca0c1c3a/soundfile-0.13.1-py2.py3-none-any.whl", hash = "sha256:a23c717560da2cf4c7b5ae1142514e0fd82d6bbd9dfc93a50423447142f2c445", size = 25751, upload-time = "2025-01-25T09:16:44.235Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/ab/73e97a5b3cc46bba7ff8650a1504348fa1863a6f9d57d7001c6b67c5f20e/soundfile-0.13.1-py2.py3-none-macosx_10_9_x86_64.whl", hash = "sha256:82dc664d19831933fe59adad199bf3945ad06d84bc111a5b4c0d3089a5b9ec33", size = 1142250, upload-time = "2025-01-25T09:16:47.583Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e5/58fd1a8d7b26fc113af244f966ee3aecf03cb9293cb935daaddc1e455e18/soundfile-0.13.1-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:743f12c12c4054921e15736c6be09ac26b3b3d603aef6fd69f9dde68748f2593", size = 1101406, upload-time = "2025-01-25T09:16:49.662Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ae/c0e4a53d77cf6e9a04179535766b3321b0b9ced5f70522e4caf9329f0046/soundfile-0.13.1-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:9c9e855f5a4d06ce4213f31918653ab7de0c5a8d8107cd2427e44b42df547deb", size = 1235729, upload-time = "2025-01-25T09:16:53.018Z" },
+ { url = "https://files.pythonhosted.org/packages/57/5e/70bdd9579b35003a489fc850b5047beeda26328053ebadc1fb60f320f7db/soundfile-0.13.1-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:03267c4e493315294834a0870f31dbb3b28a95561b80b134f0bd3cf2d5f0e618", size = 1313646, upload-time = "2025-01-25T09:16:54.872Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/df/8c11dc4dfceda14e3003bb81a0d0edcaaf0796dd7b4f826ea3e532146bba/soundfile-0.13.1-py2.py3-none-win32.whl", hash = "sha256:c734564fab7c5ddf8e9be5bf70bab68042cd17e9c214c06e365e20d64f9a69d5", size = 899881, upload-time = "2025-01-25T09:16:56.663Z" },
+ { url = "https://files.pythonhosted.org/packages/14/e9/6b761de83277f2f02ded7e7ea6f07828ec78e4b229b80e4ca55dd205b9dc/soundfile-0.13.1-py2.py3-none-win_amd64.whl", hash = "sha256:1e70a05a0626524a69e9f0f4dd2ec174b4e9567f4d8b6c11d38b5c289be36ee9", size = 1019162, upload-time = "2025-01-25T09:16:59.573Z" },
+]
+
+[[package]]
+name = "spacy"
+version = "3.8.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "catalogue" },
+ { name = "confection" },
+ { name = "cymem" },
+ { name = "jinja2" },
+ { name = "murmurhash" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "preshed" },
+ { name = "pydantic" },
+ { name = "requests" },
+ { name = "setuptools" },
+ { name = "spacy-legacy" },
+ { name = "spacy-loggers" },
+ { name = "srsly" },
+ { name = "thinc" },
+ { name = "tqdm" },
+ { name = "typer" },
+ { name = "wasabi" },
+ { name = "weasel" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/63/d7/1924f32272f50d2f13275f16d6f869fcec47b2b676b64f91fa206bd30ef4/spacy-3.8.13.tar.gz", hash = "sha256:eb7c03c2bb16593c34d4c91974118f0931c6e6969dbfe895b1a026c6714176cf", size = 1328016, upload-time = "2026-03-23T17:44:32.042Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f2/02/bf2943f61a8bd21cca90e4fe19c4da8752c386f02a76e326b33199e09953/spacy-3.8.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:edcdd4f99e2711fc90c6ba3e8e07f7dc9753d111377ba7b7b5eb0d7259b0d211", size = 6209920, upload-time = "2026-03-23T17:43:58.441Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/87/fa5e4eb2ccb660d5042eb9144134dc6238c132ec812131bb0aca296bcdd9/spacy-3.8.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b8fd0188f78e032ac58f70a00748d591e244f3d4718e0ead2d9418b4148c744d", size = 6040696, upload-time = "2026-03-23T17:44:00.704Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/f3/9132878e0c18d627adffc1d23129a3706385d4f0fbe2ea5839523519452c/spacy-3.8.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0dc2d058cb919102bf0634b1b41aff64b867d9bab36bd35979ab658b5b2e4c27", size = 32431416, upload-time = "2026-03-23T17:44:05.435Z" },
+ { url = "https://files.pythonhosted.org/packages/80/73/396c5c3aaef5004d04abbda4ec736ce4d33944aff6722e974cebdd4023fd/spacy-3.8.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ef884df658d3b60d91cbc1e9b62a6d932589d2fa3f322c3f739b6ffdf3303e0c", size = 32483676, upload-time = "2026-03-23T17:44:10.913Z" },
+ { url = "https://files.pythonhosted.org/packages/71/01/15ceb2097a817c2912297eac29f801bd71d895e93d7557110937c88f7c77/spacy-3.8.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:86f58d686d1ba6f0dc818005ee6ad87ade8dde7224012f1870a0d31abccd349e", size = 31732468, upload-time = "2026-03-23T17:44:15.967Z" },
+ { url = "https://files.pythonhosted.org/packages/03/c4/b4c39083df6209414faec7d7471994316dc6fca68c4d2f1f8f099f25c2e3/spacy-3.8.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbcb411b9749a1cd5e4325953212b3984d8c0f60b4976943e77fb0c7d5027383", size = 32473870, upload-time = "2026-03-23T17:44:21.36Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/5c/9b0fa420b01809c0170b3cf0bf509ec06b4da96214995360815615b8e8fa/spacy-3.8.13-cp314-cp314-win_amd64.whl", hash = "sha256:bf9b6879d70201acb73fc5f07d550ff488d3b5f15a959e9896717b2635c8e137", size = 14405303, upload-time = "2026-03-23T17:44:25.83Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/58/0001fd8124b62a2a9984278d793e3abfa1b70e969fc26a8668755178db84/spacy-3.8.13-cp314-cp314-win_arm64.whl", hash = "sha256:b2a402f229fcb5dba5454c346468757bd3a5215809e784b85d739ca84916f05b", size = 13834663, upload-time = "2026-03-23T17:44:29.43Z" },
+]
+
+[[package]]
+name = "spacy-curated-transformers"
+version = "0.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "curated-tokenizers" },
+ { name = "curated-transformers" },
+ { name = "torch" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d8/b3/a4fd3cf28008cbe1d95463b5c76a0d9c8da7b9ad4f06289c2be4aae62052/spacy_curated_transformers-0.3.1.tar.gz", hash = "sha256:7e53fccf64260e641b0a3f2b65b6d98381b86cef6eeb21ce279e8db849e8525d", size = 218990, upload-time = "2025-05-28T10:29:32.69Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/42/d8/f053d43125ae4ad14f3e2a12a475a656128233f1f40a272c6e09a05c73e8/spacy_curated_transformers-0.3.1-py2.py3-none-any.whl", hash = "sha256:503559b6a1d6e44ec2c978e18ed871ce5c3d56871dc9216c0e1523428204e610", size = 237943, upload-time = "2025-05-28T10:29:31.058Z" },
+]
+
+[[package]]
+name = "spacy-legacy"
+version = "3.0.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d9/79/91f9d7cc8db5642acad830dcc4b49ba65a7790152832c4eceb305e46d681/spacy-legacy-3.0.12.tar.gz", hash = "sha256:b37d6e0c9b6e1d7ca1cf5bc7152ab64a4c4671f59c85adaf7a3fcb870357a774", size = 23806, upload-time = "2023-01-23T09:04:15.104Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c3/55/12e842c70ff8828e34e543a2c7176dac4da006ca6901c9e8b43efab8bc6b/spacy_legacy-3.0.12-py2.py3-none-any.whl", hash = "sha256:476e3bd0d05f8c339ed60f40986c07387c0a71479245d6d0f4298dbd52cda55f", size = 29971, upload-time = "2023-01-23T09:04:13.45Z" },
+]
+
+[[package]]
+name = "spacy-loggers"
+version = "1.0.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/67/3d/926db774c9c98acf66cb4ed7faf6c377746f3e00b84b700d0868b95d0712/spacy-loggers-1.0.5.tar.gz", hash = "sha256:d60b0bdbf915a60e516cc2e653baeff946f0cfc461b452d11a4d5458c6fe5f24", size = 20811, upload-time = "2023-09-11T12:26:52.323Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" },
+]
+
+[[package]]
+name = "sqlalchemy"
+version = "2.0.49"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
+ { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/45/461788f35e0364a8da7bda51a1fe1b09762d0c32f12f63727998d85a873b/sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f", size = 9898221, upload-time = "2026-04-03T16:38:11.704Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/55/33/bf28f618c0a9597d14e0b9ee7d1e0622faff738d44fe986ee287cdf1b8d0/sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e", size = 2156356, upload-time = "2026-04-03T16:53:09.914Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/a7/5f476227576cb8644650eff68cc35fa837d3802b997465c96b8340ced1e2/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a", size = 3276486, upload-time = "2026-04-03T17:07:46.9Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/84/efc7c0bf3a1c5eef81d397f6fddac855becdbb11cb38ff957888603014a7/sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066", size = 3281479, upload-time = "2026-04-03T17:12:32.226Z" },
+ { url = "https://files.pythonhosted.org/packages/91/68/bb406fa4257099c67bd75f3f2261b129c63204b9155de0d450b37f004698/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187", size = 3226269, upload-time = "2026-04-03T17:07:48.678Z" },
+ { url = "https://files.pythonhosted.org/packages/67/84/acb56c00cca9f251f437cb49e718e14f7687505749ea9255d7bd8158a6df/sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401", size = 3248260, upload-time = "2026-04-03T17:12:34.381Z" },
+ { url = "https://files.pythonhosted.org/packages/56/19/6a20ea25606d1efd7bd1862149bb2a22d1451c3f851d23d887969201633f/sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5", size = 2118463, upload-time = "2026-04-03T17:05:47.093Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/4f/8297e4ed88e80baa1f5aa3c484a0ee29ef3c69c7582f206c916973b75057/sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5", size = 2144204, upload-time = "2026-04-03T17:05:48.694Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/33/95e7216df810c706e0cd3655a778604bbd319ed4f43333127d465a46862d/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977", size = 3565474, upload-time = "2026-04-03T16:58:35.128Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/a4/ed7b18d8ccf7f954a83af6bb73866f5bc6f5636f44c7731fbb741f72cc4f/sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01", size = 3530567, upload-time = "2026-04-03T17:06:04.587Z" },
+ { url = "https://files.pythonhosted.org/packages/73/a3/20faa869c7e21a827c4a2a42b41353a54b0f9f5e96df5087629c306df71e/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61", size = 3474282, upload-time = "2026-04-03T16:58:37.131Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/50/276b9a007aa0764304ad467eceb70b04822dc32092492ee5f322d559a4dc/sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a", size = 3480406, upload-time = "2026-04-03T17:06:07.176Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/c3/c80fcdb41905a2df650c2a3e0337198b6848876e63d66fe9188ef9003d24/sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158", size = 2149151, upload-time = "2026-04-03T17:02:07.281Z" },
+ { url = "https://files.pythonhosted.org/packages/05/52/9f1a62feab6ed368aff068524ff414f26a6daebc7361861035ae00b05530/sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7", size = 2184178, upload-time = "2026-04-03T17:02:08.623Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/30/8519fdde58a7bdf155b714359791ad1dc018b47d60269d5d160d311fdc36/sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0", size = 1942158, upload-time = "2026-04-03T16:53:44.135Z" },
+]
+
+[package.optional-dependencies]
+asyncio = [
+ { name = "greenlet" },
+]
+
+[[package]]
+name = "srsly"
+version = "2.5.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "catalogue" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/2b/db/f794f219a6c788b881252d2536a8c4a97d2bdaadc690391e1cb53d123d71/srsly-2.5.3.tar.gz", hash = "sha256:08f98dbecbff3a31466c4ae7c833131f59d3655a0ad8ac749e6e2c149e2b0680", size = 490881, upload-time = "2026-03-23T11:56:59.865Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/5b/e4ef43c2a381711230af98d4c94a5323df48d6a7899ee652e05bf889290e/srsly-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:39c13d552a9f9674a12cdcdc66b0c2f02f3430d0cd04c5f9cf598824c2bd3d65", size = 661294, upload-time = "2026-03-23T11:56:23.29Z" },
+ { url = "https://files.pythonhosted.org/packages/92/2d/ebce7f3717e52cd0a01f4ec570f388f3b7098526794fcf1ad734e0b8f852/srsly-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:14c930767cc169611a2dc14e23bc7638cfb616d6f79029700ade033607343540", size = 660952, upload-time = "2026-03-23T11:56:24.908Z" },
+ { url = "https://files.pythonhosted.org/packages/22/47/a8f3e9b214be2624c8e8a78d38ca7b1d4e26b92d57018412e4bfc4abe89a/srsly-2.5.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2f2d464f0d0237e32fb53f0ec6f05418652c550e772b50e9918e83a1577cba4d", size = 1154554, upload-time = "2026-03-23T11:56:26.608Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/71/2a89dc3180a51e633a87a079ca064225f4aaf46c7b2a5fc720e28f261d98/srsly-2.5.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d18933248a5bb0ad56a1bae6003a9a7f37daac2ecb0c5bcbfaaf081b317e1c84", size = 1155746, upload-time = "2026-03-23T11:56:28.102Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/36/72e5ce3153927ca404b6f5bf5280e6ff3399c11557df472b153945468e0a/srsly-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7ea5412ea229e571ac9738cbe14f845cc06c8e4e956afb5f42061ccd087ef31f", size = 1112374, upload-time = "2026-03-23T11:56:29.591Z" },
+ { url = "https://files.pythonhosted.org/packages/04/b2/0895de109c28eca0d41a811ab7c076d4e4a505e8466f06bae22f5180a1dd/srsly-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8d3988970b4cf7d03bdd5b5169302ff84562dd2e1e0f84aeb34df3e5b5dc19bf", size = 1127732, upload-time = "2026-03-23T11:56:31.458Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/79/a37fa7759797fbdfe0a2e029ab13e78b1e81e191220d2bb8ff57d869aefb/srsly-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:6a02d7dcc16126c8fae1c1c09b2072798a1dc482ab5f9c52b12c7114dac47325", size = 656467, upload-time = "2026-03-23T11:56:33.14Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/25/0dae019b3b90ad9037f91de4c390555cdaac9460a93ad62b02b03babdff5/srsly-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:1c9129c4abe31903ff7996904a51afdd5428060de6c3d12af49a4da5e8df2821", size = 643040, upload-time = "2026-03-23T11:56:34.448Z" },
+ { url = "https://files.pythonhosted.org/packages/3a/44/72dd5285b2e05435d98b0797f101d91d9b345d491ddc1fdb9bd09e27ccb8/srsly-2.5.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:29d5d01ba4c2e9c01f936e5e6d5babc4a47b38c9cbd6e1ec23f6d5a49df32605", size = 666200, upload-time = "2026-03-23T11:56:35.753Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/ad/002c71b87fc3f648c9bf0ec47de0c3822bf2c95c8896a589dd03e7fd3977/srsly-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5c8df4039426d99f0148b5743542842ab96b82daded0b342555e15a639927757", size = 667409, upload-time = "2026-03-23T11:56:37.172Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/35/2cea3d5e80aeecfc4ece9e7e1783e7792cc3bad7ab85ab585882e1db4e38/srsly-2.5.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:06a43d63bde2e8cccadb953d7fff70b18196ca286b65dd2ad16006d65f3f8166", size = 1265941, upload-time = "2026-03-23T11:56:38.825Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/38/8a4d7e86dd0370a2e5af251b646000197bb5b7e0f9aa360c71bbfb253d0d/srsly-2.5.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:808cfafc047f0dec507a34c8fa8e4cda5722737fd33577df73452f52f7aca644", size = 1250693, upload-time = "2026-03-23T11:56:40.449Z" },
+ { url = "https://files.pythonhosted.org/packages/99/05/340129de5ea7b237271b12f8a6962cfa7eb0c5a3056794626d348c5ae7c7/srsly-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:71d4cbe2b2a1335c76ed0acae2dc862163787d8b01a705e1949796907ed94ccd", size = 1242408, upload-time = "2026-03-23T11:56:41.8Z" },
+ { url = "https://files.pythonhosted.org/packages/01/cb/d7fee7ab27c6aa2e3f865fb7b50ba18c81a4c763bba12bdf53df246441bc/srsly-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:565f69083d33cb329cfc74317da937fb3270c0f40fabc1b4488702d8074b4a3e", size = 1242749, upload-time = "2026-03-23T11:56:43.246Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/d1/9bad3a0f2fa7b72f4e0cf1d267b00513092d20ef538c47f72823ae4f7656/srsly-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:8ac016ffaeac35bc010992b71bf8afdd39d458f201c8138d84cf78778a936e6c", size = 673783, upload-time = "2026-03-23T11:56:44.875Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/ae/57d1d7af907e20c077e113e0e4976f87b82c0a415403d99284a262229dd0/srsly-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d822083fe26ec6728bd8c273ac121fc4ab3864a0fdf0cf0ff3efb188fcd209ed", size = 650229, upload-time = "2026-03-23T11:56:46.148Z" },
+]
+
+[[package]]
+name = "sympy"
+version = "1.14.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "mpmath" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" },
+]
+
+[[package]]
+name = "termcolor"
+version = "3.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" },
+]
+
+[[package]]
+name = "thinc"
+version = "8.3.13"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "blis" },
+ { name = "catalogue" },
+ { name = "confection" },
+ { name = "cymem" },
+ { name = "murmurhash" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "preshed" },
+ { name = "pydantic" },
+ { name = "setuptools" },
+ { name = "srsly" },
+ { name = "wasabi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/13/46/76df95f2c327f9a9cef30c1523bf285627897097163584dcf5f77b2ebce2/thinc-8.3.13.tar.gz", hash = "sha256:68e658549fc1eb3ff92aed5147fcbb9c15d6e9cc0e623b4d0998d16522ffb4f9", size = 194640, upload-time = "2026-03-23T07:22:36.41Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/80/40/f4937d113912c6d669ffe982356ab29dcb6c7fe3be926a15981dbbb6a91c/thinc-8.3.13-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7badb0be4825535e6362c19e8a41872b65409e9da46d3453a391b843a0720865", size = 817024, upload-time = "2026-03-23T07:22:23.005Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/00/4d4ed1a11ba2920b85a03a0683b16d97dc5beb2e78078dbf0e13e43bcea7/thinc-8.3.13-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:565300b7e13de799e5abff00d445f537e9256cf7da4dcb0d0f005fc16748a29e", size = 792096, upload-time = "2026-03-23T07:22:24.349Z" },
+ { url = "https://files.pythonhosted.org/packages/44/5d/dc33d6932be8721af2ef76b4a3a6e8020648630eabae61fb916d2a861d1d/thinc-8.3.13-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c17cef1900a1aba7e1487493d16b8aa0a8633116f1b2a51c6649a4000697f17b", size = 3842215, upload-time = "2026-03-23T07:22:25.836Z" },
+ { url = "https://files.pythonhosted.org/packages/af/bc/a6d37d8dadc2c5b524f51192413481160c42c9dd6105e8d5551531623225/thinc-8.3.13-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f4f26d1eec9b2a6a8f2e0298a5515d13eb06d70730d0d9e1040bb329e12bf3fb", size = 3849253, upload-time = "2026-03-23T07:22:27.845Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/59/ce9c7067f1dfe5985875927de9cf7a79f9dae3e69487fd650dfba558029d/thinc-8.3.13-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a61a31fd0ce3c2771cf4901ba6df70e774ffe32febf1024c5b43d63575cd58fe", size = 4831163, upload-time = "2026-03-23T07:22:29.395Z" },
+ { url = "https://files.pythonhosted.org/packages/4f/a8/f57819347fc4d8bef2204d15fcbb9d7dff2d6cdd5f83d5ed91456ddacc55/thinc-8.3.13-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba8119daf84a12259ae4d251d36426417bafa0b34108890b4b7e2b50966bd990", size = 4986051, upload-time = "2026-03-23T07:22:30.933Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ef/a82214bb7c7c1e2d92b69e1a7654be90cfab180082c6108e45a98af2422c/thinc-8.3.13-cp314-cp314-win_amd64.whl", hash = "sha256:433e3826e018da489f1a8068e6de677f6eff3cc93991a599d90f12cd1bc26cdc", size = 1740382, upload-time = "2026-03-23T07:22:32.869Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/ef/1648fda54e9689058335ff54f650a7a314db2a42e21af1b83949b2dc748e/thinc-8.3.13-cp314-cp314-win_arm64.whl", hash = "sha256:11754fada9ad5ba2e02d5f3f234f940e24015b82333db58372f4a6aedad9b43f", size = 1667687, upload-time = "2026-03-23T07:22:34.967Z" },
+]
+
+[[package]]
+name = "tld"
+version = "0.13.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5c/5d/76b4383ac4e5b5e254e50c09807b3e13820bed6d6c11cd540264988d6802/tld-0.13.2.tar.gz", hash = "sha256:d983fa92b9d717400742fca844e29d5e18271079c7bcfabf66d01b39b4a14345", size = 467175, upload-time = "2026-03-06T23:50:34.498Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9e/90/39a85a4b63c84213e78b3c17d22e1bf45328acf8ebb33ef93be30d0a3911/tld-0.13.2-py2.py3-none-any.whl", hash = "sha256:9b8fdbdb880e7ba65b216a4937f2c94c49a7226723783d5838fc958ac76f4e0c", size = 296743, upload-time = "2026-03-06T23:50:32.465Z" },
+]
+
+[[package]]
+name = "tokenizers"
+version = "0.22.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
+ { url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
+ { url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
+ { url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
+ { url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
+ { url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
+ { url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
+]
+
+[[package]]
+name = "torch"
+version = "2.11.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cuda-bindings", marker = "sys_platform == 'linux'" },
+ { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" },
+ { name = "filelock" },
+ { name = "fsspec" },
+ { name = "jinja2" },
+ { name = "networkx" },
+ { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" },
+ { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" },
+ { name = "setuptools" },
+ { name = "sympy" },
+ { name = "triton", marker = "sys_platform == 'linux'" },
+ { name = "typing-extensions" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/26/0d/8603382f61abd0db35841148ddc1ffd607bf3100b11c6e1dab6d2fc44e72/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:01018087326984a33b64e04c8cb5c2795f9120e0d775ada1f6638840227b04d7", size = 80573442, upload-time = "2026-03-23T18:09:10.117Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/86/7cd7c66cb9cec6be330fff36db5bd0eef386d80c031b581ec81be1d4b26c/torch-2.11.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:2bb3cc54bd0dea126b0060bb1ec9de0f9c7f7342d93d436646516b0330cd5be7", size = 419749385, upload-time = "2026-03-23T18:07:33.77Z" },
+ { url = "https://files.pythonhosted.org/packages/47/e8/b98ca2d39b2e0e4730c0ee52537e488e7008025bc77ca89552ff91021f7c/torch-2.11.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4dc8b3809469b6c30b411bb8c4cad3828efd26236153d9beb6a3ec500f211a60", size = 530716756, upload-time = "2026-03-23T18:07:50.02Z" },
+ { url = "https://files.pythonhosted.org/packages/78/88/d4a4cda8362f8a30d1ed428564878c3cafb0d87971fbd3947d4c84552095/torch-2.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:2b4e811728bd0cc58fb2b0948fe939a1ee2bf1422f6025be2fca4c7bd9d79718", size = 114552300, upload-time = "2026-03-23T18:09:05.617Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/46/4419098ed6d801750f26567b478fc185c3432e11e2cad712bc6b4c2ab0d0/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8245477871c3700d4370352ffec94b103cfcb737229445cf9946cddb7b2ca7cd", size = 80959460, upload-time = "2026-03-23T18:09:00.818Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/66/54a56a4a6ceaffb567231994a9745821d3af922a854ed33b0b3a278e0a99/torch-2.11.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:ab9a8482f475f9ba20e12db84b0e55e2f58784bdca43a854a6ccd3fd4b9f75e6", size = 419735835, upload-time = "2026-03-23T18:07:18.974Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/e7/0b6665f533aa9e337662dc190425abc0af1fe3234088f4454c52393ded61/torch-2.11.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:563ed3d25542d7e7bbc5b235ccfacfeb97fb470c7fee257eae599adb8005c8a2", size = 530613405, upload-time = "2026-03-23T18:08:07.014Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/bf/c8d12a2c86dbfd7f40fb2f56fbf5a505ccf2d9ce131eb559dfc7c51e1a04/torch-2.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b2a43985ff5ef6ddd923bbcf99943e5f58059805787c5c9a2622bf05ca2965b0", size = 114792991, upload-time = "2026-03-23T18:08:19.216Z" },
+]
+
+[[package]]
+name = "tqdm"
+version = "4.67.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
+]
+
+[[package]]
+name = "trafilatura"
+version = "2.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "courlan" },
+ { name = "htmldate" },
+ { name = "justext" },
+ { name = "lxml" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/06/25/e3ebeefdebfdfae8c4a4396f5a6ea51fc6fa0831d63ce338e5090a8003dc/trafilatura-2.0.0.tar.gz", hash = "sha256:ceb7094a6ecc97e72fea73c7dba36714c5c5b577b6470e4520dca893706d6247", size = 253404, upload-time = "2024-12-03T15:23:24.16Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/b6/097367f180b6383a3581ca1b86fcae284e52075fa941d1232df35293363c/trafilatura-2.0.0-py3-none-any.whl", hash = "sha256:77eb5d1e993747f6f20938e1de2d840020719735690c840b9a1024803a4cd51d", size = 132557, upload-time = "2024-12-03T15:23:21.41Z" },
+]
+
+[[package]]
+name = "transformers"
+version = "5.5.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "huggingface-hub" },
+ { name = "numpy" },
+ { name = "packaging" },
+ { name = "pyyaml" },
+ { name = "regex" },
+ { name = "safetensors" },
+ { name = "tokenizers" },
+ { name = "tqdm" },
+ { name = "typer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/af/35/cd5b0d1288e65d2c12db4ce84c1ec1074f7ee9bced040de6c9d69e70d620/transformers-5.5.3.tar.gz", hash = "sha256:3f60128e840b40d352655903552e1eed4f94ed49369a4d43e1bc067bd32d3f50", size = 8226047, upload-time = "2026-04-09T15:52:56.231Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a1/0b/f8524551ab2d896dfaca74ddb70a4453d515bbf4ab5451c100c7788ae155/transformers-5.5.3-py3-none-any.whl", hash = "sha256:e48f3ec31dd96505e96e66b63a1e43e1ad7a65749e108d9227caaf51051cdb02", size = 10236257, upload-time = "2026-04-09T15:52:52.866Z" },
+]
+
+[[package]]
+name = "triton"
+version = "3.6.0"
+source = { registry = "https://pypi.org/simple" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/49/55/5ecf0dcaa0f2fbbd4420f7ef227ee3cb172e91e5fede9d0ecaddc43363b4/triton-3.6.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5523241e7d1abca00f1d240949eebdd7c673b005edbbce0aca95b8191f1d43", size = 176138577, upload-time = "2026-01-20T16:16:25.426Z" },
+ { url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
+ { url = "https://files.pythonhosted.org/packages/48/db/56ee649cab5eaff4757541325aca81f52d02d4a7cd3506776cad2451e060/triton-3.6.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b3a97e8ed304dfa9bd23bb41ca04cdf6b2e617d5e782a8653d616037a5d537d", size = 176274804, upload-time = "2026-01-20T16:16:31.528Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
+]
+
+[[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 = "tzdata"
+version = "2026.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" },
+]
+
+[[package]]
+name = "tzlocal"
+version = "5.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "tzdata", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
+]
+
+[[package]]
+name = "uritemplate"
+version = "4.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
+]
+
+[[package]]
+name = "urllib3-future"
+version = "2.19.905"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+ { name = "jh2" },
+ { name = "qh3", marker = "(platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'darwin') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'linux') or (platform_machine == 'AMD64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ARM64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'aarch64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'arm64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'armv7l' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'i686' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'ppc64le' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'riscv64gc' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 's390x' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86' and platform_python_implementation == 'CPython' and sys_platform == 'win32') or (platform_machine == 'x86_64' and platform_python_implementation == 'CPython' and sys_platform == 'win32')" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/5b/d1/6c75acaa5848e2327937cb193df60e49e0e59285227be080c1ddebe7149c/urllib3_future-2.19.905.tar.gz", hash = "sha256:eff59e3c53b7762e288d342b0d53fd34a8a94e6d6d897ecb667c6719537f964e", size = 1155093, upload-time = "2026-04-10T12:05:30.243Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/31/ad/8be18a6ab03bd557e20d51e5db83b38a44a5b08e8e6a2fabbac27313826a/urllib3_future-2.19.905-py3-none-any.whl", hash = "sha256:1ea385c64b8197fa81668b2bf507d578d49f0411f5e2fa00a7603f1ca6123e2c", size = 721408, upload-time = "2026-04-10T12:05:28.55Z" },
+]
+
+[[package]]
+name = "wasabi"
+version = "1.1.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/f9/054e6e2f1071e963b5e746b48d1e3727470b2a490834d18ad92364929db3/wasabi-1.1.3.tar.gz", hash = "sha256:4bb3008f003809db0c3e28b4daf20906ea871a2bb43f9914197d540f4f2e0878", size = 30391, upload-time = "2024-05-31T16:56:18.99Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/06/7c/34330a89da55610daa5f245ddce5aab81244321101614751e7537f125133/wasabi-1.1.3-py3-none-any.whl", hash = "sha256:f76e16e8f7e79f8c4c8be49b4024ac725713ab10cd7f19350ad18a8e3f71728c", size = 27880, upload-time = "2024-05-31T16:56:16.699Z" },
+]
+
+[[package]]
+name = "wassima"
+version = "2.0.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/96/1d/e27f3b2730e1964e819d47ad1c7ffde135a3658b120a441ecc40be0c627d/wassima-2.0.6.tar.gz", hash = "sha256:7c7fa67161ebe0c0ffbbc4c648186de80124f62474682b57c3ac60520d5c471b", size = 145426, upload-time = "2026-04-07T01:52:02.559Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/10/bd9b185b33ecd2b2523eb83bb1088e6dfdc1dad19136d91312dce9996c37/wassima-2.0.6-py3-none-any.whl", hash = "sha256:24c327cfce58e36b1e554feb809a12cd6677e39158dee419deb0d16b8f648f0d", size = 140613, upload-time = "2026-04-07T01:52:00.835Z" },
+]
+
+[[package]]
+name = "weasel"
+version = "1.0.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "cloudpathlib" },
+ { name = "confection" },
+ { name = "httpx" },
+ { name = "packaging" },
+ { name = "pydantic" },
+ { name = "smart-open" },
+ { name = "srsly" },
+ { name = "typer" },
+ { name = "wasabi" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ce/e5/e272bb9a045105a1fdf4b798d8086f5932a178f4d738f17a74f5c9e0ae9a/weasel-1.0.0.tar.gz", hash = "sha256:7b129b44c90cc543b760532974ca1e4eb30dad2aa2026f57bdce66354ae610fc", size = 38682, upload-time = "2026-03-20T08:10:25.266Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0a/07/57ebf7a6798b016c064bd0ca81b4c6a99daa4dc377b898bc7b41eb6b5af0/weasel-1.0.0-py3-none-any.whl", hash = "sha256:89518acee027f49d743126c3502d35e6dd14f5768be5c37c9af47c171b6005cc", size = 50713, upload-time = "2026-03-20T08:10:23.637Z" },
+]
+
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "markupsafe" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" },
+]
+
+[[package]]
+name = "win32-setctime"
+version = "1.2.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
+]
+
+[[package]]
+name = "wrapt"
+version = "2.1.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/64/925f213fdcbb9baeb1530449ac71a4d57fc361c053d06bf78d0c5c7cd80c/wrapt-2.1.2.tar.gz", hash = "sha256:3996a67eecc2c68fd47b4e3c564405a5777367adfd9b8abb58387b63ee83b21e", size = 81678, upload-time = "2026-03-06T02:53:25.134Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/39/25/e7ea0b417db02bb796182a5316398a75792cd9a22528783d868755e1f669/wrapt-2.1.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1370e516598854e5b4366e09ce81e08bfe94d42b0fd569b88ec46cc56d9164a9", size = 61418, upload-time = "2026-03-06T02:53:55.706Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/0f/fa539e2f6a770249907757eaeb9a5ff4deb41c026f8466c1c6d799088a9b/wrapt-2.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6de1a3851c27e0bd6a04ca993ea6f80fc53e6c742ee1601f486c08e9f9b900a9", size = 61914, upload-time = "2026-03-06T02:52:53.37Z" },
+ { url = "https://files.pythonhosted.org/packages/53/37/02af1867f5b1441aaeda9c82deed061b7cd1372572ddcd717f6df90b5e93/wrapt-2.1.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:de9f1a2bbc5ac7f6012ec24525bdd444765a2ff64b5985ac6e0692144838542e", size = 120417, upload-time = "2026-03-06T02:54:30.74Z" },
+ { url = "https://files.pythonhosted.org/packages/c3/b7/0138a6238c8ba7476c77cf786a807f871672b37f37a422970342308276e7/wrapt-2.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:970d57ed83fa040d8b20c52fe74a6ae7e3775ae8cff5efd6a81e06b19078484c", size = 122797, upload-time = "2026-03-06T02:54:51.539Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/ad/819ae558036d6a15b7ed290d5b14e209ca795dd4da9c58e50c067d5927b0/wrapt-2.1.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3969c56e4563c375861c8df14fa55146e81ac11c8db49ea6fb7f2ba58bc1ff9a", size = 117350, upload-time = "2026-03-06T02:54:37.651Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/2d/afc18dc57a4600a6e594f77a9ae09db54f55ba455440a54886694a84c71b/wrapt-2.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:57d7c0c980abdc5f1d98b11a2aa3bb159790add80258c717fa49a99921456d90", size = 121223, upload-time = "2026-03-06T02:54:35.221Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/5b/5ec189b22205697bc56eb3b62aed87a1e0423e9c8285d0781c7a83170d15/wrapt-2.1.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:776867878e83130c7a04237010463372e877c1c994d449ca6aaafeab6aab2586", size = 116287, upload-time = "2026-03-06T02:54:19.654Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/2d/f84939a7c9b5e6cdd8a8d0f6a26cabf36a0f7e468b967720e8b0cd2bdf69/wrapt-2.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fab036efe5464ec3291411fabb80a7a39e2dd80bae9bcbeeca5087fdfa891e19", size = 119593, upload-time = "2026-03-06T02:54:16.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/fe/ccd22a1263159c4ac811ab9374c061bcb4a702773f6e06e38de5f81a1bdc/wrapt-2.1.2-cp314-cp314-win32.whl", hash = "sha256:e6ed62c82ddf58d001096ae84ce7f833db97ae2263bff31c9b336ba8cfe3f508", size = 58631, upload-time = "2026-03-06T02:53:06.498Z" },
+ { url = "https://files.pythonhosted.org/packages/65/0a/6bd83be7bff2e7efaac7b4ac9748da9d75a34634bbbbc8ad077d527146df/wrapt-2.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:467e7c76315390331c67073073d00662015bb730c566820c9ca9b54e4d67fd04", size = 60875, upload-time = "2026-03-06T02:53:50.252Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/c0/0b3056397fe02ff80e5a5d72d627c11eb885d1ca78e71b1a5c1e8c7d45de/wrapt-2.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:da1f00a557c66225d53b095a97eace0fc5349e3bfda28fa34ffae238978ee575", size = 59164, upload-time = "2026-03-06T02:53:59.128Z" },
+ { url = "https://files.pythonhosted.org/packages/71/ed/5d89c798741993b2371396eb9d4634f009ff1ad8a6c78d366fe2883ea7a6/wrapt-2.1.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:62503ffbc2d3a69891cf29beeaccdb4d5e0a126e2b6a851688d4777e01428dbb", size = 63163, upload-time = "2026-03-06T02:52:54.873Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/8c/05d277d182bf36b0a13d6bd393ed1dec3468a25b59d01fba2dd70fe4d6ae/wrapt-2.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c7e6cd120ef837d5b6f860a6ea3745f8763805c418bb2f12eeb1fa6e25f22d22", size = 63723, upload-time = "2026-03-06T02:52:56.374Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/27/6c51ec1eff4413c57e72d6106bb8dec6f0c7cdba6503d78f0fa98767bcc9/wrapt-2.1.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3769a77df8e756d65fbc050333f423c01ae012b4f6731aaf70cf2bef61b34596", size = 152652, upload-time = "2026-03-06T02:53:23.79Z" },
+ { url = "https://files.pythonhosted.org/packages/db/4c/d7dd662d6963fc7335bfe29d512b02b71cdfa23eeca7ab3ac74a67505deb/wrapt-2.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a76d61a2e851996150ba0f80582dd92a870643fa481f3b3846f229de88caf044", size = 158807, upload-time = "2026-03-06T02:53:35.742Z" },
+ { url = "https://files.pythonhosted.org/packages/b4/4d/1e5eea1a78d539d346765727422976676615814029522c76b87a95f6bcdd/wrapt-2.1.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6f97edc9842cf215312b75fe737ee7c8adda75a89979f8e11558dfff6343cc4b", size = 146061, upload-time = "2026-03-06T02:52:57.574Z" },
+ { url = "https://files.pythonhosted.org/packages/89/bc/62cabea7695cd12a288023251eeefdcb8465056ddaab6227cb78a2de005b/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4006c351de6d5007aa33a551f600404ba44228a89e833d2fadc5caa5de8edfbf", size = 155667, upload-time = "2026-03-06T02:53:39.422Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/99/6f2888cd68588f24df3a76572c69c2de28287acb9e1972bf0c83ce97dbc1/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a9372fc3639a878c8e7d87e1556fa209091b0a66e912c611e3f833e2c4202be2", size = 144392, upload-time = "2026-03-06T02:54:22.41Z" },
+ { url = "https://files.pythonhosted.org/packages/40/51/1dfc783a6c57971614c48e361a82ca3b6da9055879952587bc99fe1a7171/wrapt-2.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3144b027ff30cbd2fca07c0a87e67011adb717eb5f5bd8496325c17e454257a3", size = 150296, upload-time = "2026-03-06T02:54:07.848Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/38/cbb8b933a0201076c1f64fc42883b0023002bdc14a4964219154e6ff3350/wrapt-2.1.2-cp314-cp314t-win32.whl", hash = "sha256:3b8d15e52e195813efe5db8cec156eebe339aaf84222f4f4f051a6c01f237ed7", size = 60539, upload-time = "2026-03-06T02:54:00.594Z" },
+ { url = "https://files.pythonhosted.org/packages/82/dd/e5176e4b241c9f528402cebb238a36785a628179d7d8b71091154b3e4c9e/wrapt-2.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:08ffa54146a7559f5b8df4b289b46d963a8e74ed16ba3687f99896101a3990c5", size = 63969, upload-time = "2026-03-06T02:54:39Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/99/79f17046cf67e4a95b9987ea129632ba8bcec0bc81f3fb3d19bdb0bd60cd/wrapt-2.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:72aaa9d0d8e4ed0e2e98019cea47a21f823c9dd4b43c7b77bba6679ffcca6a00", size = 60554, upload-time = "2026-03-06T02:53:14.132Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/c7/8528ac2dfa2c1e6708f647df7ae144ead13f0a31146f43c7264b4942bf12/wrapt-2.1.2-py3-none-any.whl", hash = "sha256:b8fd6fa2b2c4e7621808f8c62e8317f4aae56e59721ad933bac5239d913cf0e8", size = 43993, upload-time = "2026-03-06T02:53:12.905Z" },
+]
+
+[[package]]
+name = "wsproto"
+version = "1.3.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "h11" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" },
+]
+
+[[package]]
+name = "x-wr-timezone"
+version = "2.0.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "click" },
+ { name = "icalendar" },
+ { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/79/2b/8ae5f59ab852c8fe32dd37c1aa058eb98aca118fec2d3af5c3cd56fffb7b/x_wr_timezone-2.0.1.tar.gz", hash = "sha256:9166c40e6ffd4c0edebabc354e1a1e2cffc1bb473f88007694793757685cc8c3", size = 18212, upload-time = "2025-02-06T17:10:40.913Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0f/b7/4bac35b4079b76c07d8faddf89467e9891b1610cfe8d03b0ebb5610e4423/x_wr_timezone-2.0.1-py3-none-any.whl", hash = "sha256:e74a53b9f4f7def8138455c240e65e47c224778bce3c024fcd6da2cbe91ca038", size = 11102, upload-time = "2025-02-06T17:10:39.192Z" },
+]
+
+[[package]]
+name = "yarl"
+version = "1.23.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "idna" },
+ { name = "multidict" },
+ { name = "propcache" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
+ { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
+ { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
+ { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
+ { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
+ { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
+ { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
+ { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
+ { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
+ { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
+ { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
+ { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
+ { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
+ { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
+ { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
+ { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
+ { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
+ { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
+ { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
+ { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
+ { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
+ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
+ { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
+]
From d3d4294c3021b4bc793426639fcea5e4049bcde7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 08:53:10 -0400
Subject: [PATCH 005/118] scripts: add bench_ollama.py for CPU/GPU model
benchmarking
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Standalone tool to measure Ollama model performance under the two
workload shapes the chat+curator architecture would impose:
- chat scenario: short user message, short reply, no thinking. Mirrors
the no-tools chat companion's expected load.
- curator scenario: ~700-token journal transcript with an extraction
prompt, thinking enabled. Mirrors the curator's expected load.
Defaults to CPU-only inference (num_gpu=0). Streams responses; reports
TTFT, total wall time, tokens/sec (from Ollama's eval_count/eval_duration
so it excludes client-side stream overhead), and prompt token count.
First request per (model, num_gpu) is a warm-up to load the model into
memory; not counted in the measured runs.
Designed for cross-server comparison: --server points at any Ollama
instance, --out writes a markdown table. Comparing the two CPU servers
becomes a matter of running the same command on each and diffing the
output.
Lives outside the chat/curator architecture commitment — measurement
tool only. Tells us "is qwen2.5:32b on CPU fast enough for a 10-20 min
curator cadence?" without writing any of the architecture code yet.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
scripts/bench_ollama.py | 369 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 369 insertions(+)
create mode 100755 scripts/bench_ollama.py
diff --git a/scripts/bench_ollama.py b/scripts/bench_ollama.py
new file mode 100755
index 0000000..b045864
--- /dev/null
+++ b/scripts/bench_ollama.py
@@ -0,0 +1,369 @@
+#!/usr/bin/env python3
+"""Benchmark Ollama models for FabledScribe chat / curator workloads.
+
+Measures time-to-first-token, total wall time, and tokens/sec for each
+(model, scenario) pair. Designed to be runnable against both local and
+remote Ollama servers so results from your two CPU servers are directly
+comparable.
+
+Default forces CPU-only inference (num_gpu=0). Pass --num-gpu 99 for full
+GPU offload, or any integer for partial.
+
+Scenarios:
+ chat — small input, small output, no thinking. Mirrors the chat-only
+ journal companion's expected load (short user message →
+ curious follow-up).
+ curator — longer transcript input, structured-output extraction, with
+ thinking enabled. Mirrors the curator's expected load
+ (read recent conversation → emit captures).
+
+Prerequisites:
+ - Ollama running and reachable at --server (default http://localhost:11434).
+ - The models named in --models must already be pulled
+ (`ollama pull qwen2.5:32b` etc).
+
+Usage examples:
+ # Curator candidate on CPU, 3 runs each (default), one model:
+ python scripts/bench_ollama.py --models qwen2.5:32b --scenario curator
+
+ # Chat candidate on GPU, against a remote server:
+ python scripts/bench_ollama.py \\
+ --server http://dock02:11434 \\
+ --models llama3.2:3b \\
+ --scenario chat --num-gpu 99
+
+ # Compare three curator candidates on CPU, 5 runs each, write markdown:
+ python scripts/bench_ollama.py \\
+ --models qwen2.5:32b,qwen3:14b,gemma2:27b \\
+ --scenario curator --runs 5 --out bench-cpu.md
+
+The first request to a (model, num_gpu) pair triggers a model load and is
+excluded from the timing as a warm-up. Subsequent runs reflect warm-cache
+performance — closest to how the running app will see the model in practice.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import statistics
+import sys
+import time
+from dataclasses import dataclass, field
+
+import httpx
+
+
+SYSTEM_PROMPT_CHAT = (
+ "You are the user's journal companion. Be warm and curious. Ask one "
+ "short follow-up question. Don't presume to help; let the user lead. "
+ "Match the user's length."
+)
+
+USER_MESSAGE_CHAT = (
+ "Got a lot done at Famous Supply today. Finished the network restage "
+ "on the Bedford swarm and there was one weird issue with a Windows "
+ "node. Tired but it feels good to be making progress."
+)
+
+SYSTEM_PROMPT_CURATOR = (
+ "You are reading a journal conversation. Extract the user's notable "
+ "moments — events, decisions, encounters, observations, plans. "
+ "Output a JSON array. Each entry has fields: content (1-2 sentence "
+ "summary in the user's voice), tags (list of strings), people (list "
+ "of names mentioned), projects (list of project names mentioned). "
+ "Skip meta-conversational chatter."
+)
+
+USER_TRANSCRIPT_CURATOR = """\
+[12:04] User: morning. starting on the Famous Supply network restage today.
+[12:04] Assistant: how big a job is that?
+[12:05] User: bedford location swarm has 3 nodes. one of them is a Windows
+ host that's been flaky for a while. probably going to take all
+ afternoon.
+[12:05] Assistant: anything you want to flag before you start in case
+ something goes sideways?
+[12:06] User: yeah — the Windows node sometimes loses its bridge network
+ on reboot. if that happens i'll have to manually rejoin it. last
+ time it took an hour to figure out.
+[12:06] Assistant: noted. how are you feeling going in?
+[12:07] User: decent. didn't sleep great but i had coffee with Sarah this
+ morning which was nice. she mentioned she's hiring on her team if
+ i ever want to make a jump.
+[12:07] Assistant: good to know. anything to capture about the work itself
+ before you dive in, or you good?
+[12:08] User: nope, just wanted to externalize before starting.
+[14:42] User: ok the Windows node DID lose its bridge. fixing it now.
+[14:42] Assistant: any guess what triggers it?
+[14:43] User: i think it's a stale dhcp lease that gets re-issued before
+ the bridge comes up. might write that down as a thing to fix
+ properly later.
+[15:55] User: done. all three nodes restaged, the windows one too. wrote
+ a note about the dhcp issue for future me.
+[15:55] Assistant: how do you feel about how the day went?
+[15:56] User: tired but good. ate lunch standing up which was dumb.
+ watching a show with Victoria tonight to decompress.
+"""
+
+
+@dataclass
+class RunResult:
+ ttft_ms: float
+ total_ms: float
+ prompt_tokens: int
+ output_tokens: int
+ tokens_per_sec: float
+
+
+@dataclass
+class ScenarioResult:
+ model: str
+ scenario: str
+ runs: list[RunResult] = field(default_factory=list)
+ error: str | None = None
+
+ def summary(self) -> dict:
+ if self.error or not self.runs:
+ return {
+ "model": self.model,
+ "scenario": self.scenario,
+ "error": self.error or "no successful runs",
+ }
+ return {
+ "model": self.model,
+ "scenario": self.scenario,
+ "runs": len(self.runs),
+ "ttft_ms_p50": statistics.median(r.ttft_ms for r in self.runs),
+ "total_ms_p50": statistics.median(r.total_ms for r in self.runs),
+ "tokens_per_sec_p50": statistics.median(
+ r.tokens_per_sec for r in self.runs
+ ),
+ "output_tokens_mean": statistics.mean(
+ r.output_tokens for r in self.runs
+ ),
+ "prompt_tokens": self.runs[0].prompt_tokens,
+ }
+
+
+def build_request(
+ scenario: str, model: str, num_gpu: int, keep_alive: str
+) -> dict:
+ if scenario == "chat":
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT_CHAT},
+ {"role": "user", "content": USER_MESSAGE_CHAT},
+ ]
+ think = False
+ elif scenario == "curator":
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT_CURATOR},
+ {"role": "user", "content": USER_TRANSCRIPT_CURATOR},
+ ]
+ think = True
+ else:
+ raise ValueError(f"unknown scenario: {scenario}")
+ return {
+ "model": model,
+ "messages": messages,
+ "stream": True,
+ "think": think,
+ "keep_alive": keep_alive,
+ "options": {
+ "num_gpu": num_gpu,
+ "temperature": 0.3,
+ "num_ctx": 8192,
+ },
+ }
+
+
+def run_once(server: str, payload: dict) -> RunResult:
+ """Stream one chat request and time it.
+
+ Uses Ollama-reported `eval_count` and `eval_duration` for tokens/sec
+ (authoritative; doesn't include client-side stream overhead). TTFT is
+ wall-clock from request send to first content chunk.
+ """
+ url = f"{server.rstrip('/')}/api/chat"
+ t_start = time.monotonic()
+ ttft = None
+ prompt_tokens = 0
+ output_tokens = 0
+ eval_duration_ns = 0
+
+ with httpx.stream("POST", url, json=payload, timeout=600.0) as resp:
+ resp.raise_for_status()
+ for line in resp.iter_lines():
+ if not line:
+ continue
+ chunk = json.loads(line)
+ if ttft is None and chunk.get("message", {}).get("content"):
+ ttft = time.monotonic() - t_start
+ if chunk.get("done"):
+ prompt_tokens = chunk.get("prompt_eval_count", 0)
+ output_tokens = chunk.get("eval_count", 0)
+ eval_duration_ns = chunk.get("eval_duration", 0)
+
+ total = time.monotonic() - t_start
+ tps = (
+ output_tokens / (eval_duration_ns / 1e9)
+ if eval_duration_ns
+ else 0.0
+ )
+ return RunResult(
+ ttft_ms=(ttft if ttft is not None else total) * 1000,
+ total_ms=total * 1000,
+ prompt_tokens=prompt_tokens,
+ output_tokens=output_tokens,
+ tokens_per_sec=tps,
+ )
+
+
+def benchmark(
+ *,
+ server: str,
+ models: list[str],
+ scenarios: list[str],
+ runs: int,
+ num_gpu: int,
+ keep_alive: str,
+) -> list[ScenarioResult]:
+ results: list[ScenarioResult] = []
+ for model in models:
+ for scenario in scenarios:
+ sr = ScenarioResult(model=model, scenario=scenario)
+ payload = build_request(scenario, model, num_gpu, keep_alive)
+ # Warm-up run loads the model into RAM/VRAM with the requested
+ # num_gpu setting. Excluded from the measured runs because it
+ # otherwise dominates TTFT with model-load time.
+ print(
+ f"[{model} :: {scenario}] warm-up (loading model)...",
+ flush=True,
+ )
+ try:
+ run_once(server, payload)
+ except httpx.HTTPError as e:
+ sr.error = f"warm-up failed: {e}"
+ print(f" {sr.error}", file=sys.stderr)
+ results.append(sr)
+ continue
+ except Exception as e:
+ sr.error = f"warm-up exception: {e}"
+ print(f" {sr.error}", file=sys.stderr)
+ results.append(sr)
+ continue
+ for i in range(runs):
+ try:
+ r = run_once(server, payload)
+ except Exception as e:
+ print(f" run {i+1} failed: {e}", file=sys.stderr)
+ continue
+ sr.runs.append(r)
+ print(
+ f" run {i+1}/{runs}: ttft={r.ttft_ms:.0f}ms "
+ f"total={r.total_ms:.0f}ms tps={r.tokens_per_sec:.1f} "
+ f"out_tokens={r.output_tokens}",
+ flush=True,
+ )
+ results.append(sr)
+ return results
+
+
+def format_markdown(results: list[ScenarioResult], *, server: str, num_gpu: int) -> str:
+ mode = "CPU only" if num_gpu == 0 else (
+ f"GPU offload ({num_gpu} layers)" if num_gpu > 0 else "Ollama default"
+ )
+ lines = [
+ "# Ollama benchmark",
+ "",
+ f"- Server: `{server}`",
+ f"- Mode: {mode} (`num_gpu={num_gpu}`)",
+ "",
+ "| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) "
+ "| Total p50 (ms) | tok/s p50 | Output tok (mean) |",
+ "|---|---|---|---|---|---|---|---|",
+ ]
+ for sr in results:
+ s = sr.summary()
+ if "error" in s:
+ lines.append(
+ f"| {s['model']} | {s['scenario']} | — | — | — | — | — "
+ f"| error: {s['error']} |"
+ )
+ continue
+ lines.append(
+ f"| {s['model']} | {s['scenario']} | {s['runs']} "
+ f"| {s['prompt_tokens']} "
+ f"| {s['ttft_ms_p50']:.0f} | {s['total_ms_p50']:.0f} "
+ f"| {s['tokens_per_sec_p50']:.1f} "
+ f"| {s['output_tokens_mean']:.0f} |"
+ )
+ return "\n".join(lines) + "\n"
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument(
+ "--server",
+ default="http://localhost:11434",
+ help="Ollama server URL (default %(default)s)",
+ )
+ parser.add_argument(
+ "--models",
+ required=True,
+ help="Comma-separated model tags (e.g. qwen2.5:32b,qwen3:14b)",
+ )
+ parser.add_argument(
+ "--scenario",
+ choices=["chat", "curator", "both"],
+ default="both",
+ )
+ parser.add_argument(
+ "--runs",
+ type=int,
+ default=3,
+ help="Runs per (model,scenario), excluding warm-up (default %(default)s)",
+ )
+ parser.add_argument(
+ "--num-gpu",
+ type=int,
+ default=0,
+ help="0 = CPU only (default), 99 = full offload, -1 = Ollama default",
+ )
+ parser.add_argument(
+ "--keep-alive",
+ default="10m",
+ help="Ollama keep_alive (default %(default)s)",
+ )
+ parser.add_argument(
+ "--out",
+ help="Write markdown table to this file (also prints to stdout)",
+ )
+ args = parser.parse_args()
+
+ models = [m.strip() for m in args.models.split(",") if m.strip()]
+ scenarios = (
+ ["chat", "curator"] if args.scenario == "both" else [args.scenario]
+ )
+
+ results = benchmark(
+ server=args.server,
+ models=models,
+ scenarios=scenarios,
+ runs=args.runs,
+ num_gpu=args.num_gpu,
+ keep_alive=args.keep_alive,
+ )
+
+ md = format_markdown(results, server=args.server, num_gpu=args.num_gpu)
+ print("\n" + md)
+ if args.out:
+ with open(args.out, "w") as f:
+ f.write(md)
+ print(f"Wrote results to {args.out}", file=sys.stderr)
+
+
+if __name__ == "__main__":
+ main()
From cf986b5097c49ec17c7736958f0f6ea5acb1bd5e Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 13:34:20 -0400
Subject: [PATCH 006/118] bench_ollama: add --think on|off|auto for
cross-family comparison
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The curator scenario hardcoded think=true, which is qwen3-family-specific.
Non-qwen3 models silently ignore the field, so cross-family curator
comparisons were apples-to-oranges (qwen thinks, others don't).
New --think flag:
- auto (default): scenario-driven — chat=off, curator=on. Matches the
prior behaviour and the most common case.
- off: force disabled across all runs. Use for fair cross-family
comparison; aligns behaviour explicitly even though non-qwen models
would ignore think anyway.
- on: force enabled across all runs. Use to measure what think
contributes on the same model (paired runs: --think off then on).
Output markdown table now records the think mode used, so saved results
are self-documenting when you diff cross-server or cross-config.
Docstring + usage examples updated to reflect the qwen3 candidate set
the bench was originally tuned for.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
scripts/bench_ollama.py | 89 ++++++++++++++++++++++++++++++++++-------
1 file changed, 74 insertions(+), 15 deletions(-)
diff --git a/scripts/bench_ollama.py b/scripts/bench_ollama.py
index b045864..a698cdf 100755
--- a/scripts/bench_ollama.py
+++ b/scripts/bench_ollama.py
@@ -13,18 +13,26 @@ Scenarios:
chat — small input, small output, no thinking. Mirrors the chat-only
journal companion's expected load (short user message →
curious follow-up).
- curator — longer transcript input, structured-output extraction, with
- thinking enabled. Mirrors the curator's expected load
- (read recent conversation → emit captures).
+ curator — longer transcript input, structured-output extraction.
+ Thinking defaults ON (qwen3 family) but is overridable via
+ --think for cross-family comparisons.
+
+Think modes:
+ auto — chat=off, curator=on (default; matches scenario intent).
+ off — force think disabled for all runs (fair cross-family
+ comparison; non-qwen3 models silently ignore the field
+ anyway, so this aligns behaviour explicitly).
+ on — force think enabled for all runs (measures what think
+ contributes vs `off` on the same model).
Prerequisites:
- Ollama running and reachable at --server (default http://localhost:11434).
- The models named in --models must already be pulled
- (`ollama pull qwen2.5:32b` etc).
+ (`ollama pull qwen3:30b-a3b` etc).
Usage examples:
# Curator candidate on CPU, 3 runs each (default), one model:
- python scripts/bench_ollama.py --models qwen2.5:32b --scenario curator
+ python scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator
# Chat candidate on GPU, against a remote server:
python scripts/bench_ollama.py \\
@@ -32,10 +40,16 @@ Usage examples:
--models llama3.2:3b \\
--scenario chat --num-gpu 99
- # Compare three curator candidates on CPU, 5 runs each, write markdown:
+ # Compare two qwen curator candidates on CPU, 5 runs each:
python scripts/bench_ollama.py \\
- --models qwen2.5:32b,qwen3:14b,gemma2:27b \\
- --scenario curator --runs 5 --out bench-cpu.md
+ --models qwen3:30b-a3b,qwen3:32b \\
+ --scenario curator --runs 5 --out bench-qwen-curator.md
+
+ # Cross-family curator comparison with think forced OFF (apples-to-apples):
+ python scripts/bench_ollama.py \\
+ --models qwen3:30b-a3b,gemma2:27b,mistral-small:22b \\
+ --scenario curator --think off --runs 3 \\
+ --out bench-curator-no-think.md
The first request to a (model, num_gpu) pair triggers a model load and is
excluded from the timing as a warm-up. Subsequent runs reflect warm-cache
@@ -145,23 +159,36 @@ class ScenarioResult:
}
+def _resolve_think(scenario: str, think_mode: str) -> bool:
+ """Map (scenario, --think mode) → boolean think flag."""
+ if think_mode == "on":
+ return True
+ if think_mode == "off":
+ return False
+ # auto: scenario-driven
+ return scenario == "curator"
+
+
def build_request(
- scenario: str, model: str, num_gpu: int, keep_alive: str
+ scenario: str,
+ model: str,
+ num_gpu: int,
+ keep_alive: str,
+ think_mode: str = "auto",
) -> dict:
if scenario == "chat":
messages = [
{"role": "system", "content": SYSTEM_PROMPT_CHAT},
{"role": "user", "content": USER_MESSAGE_CHAT},
]
- think = False
elif scenario == "curator":
messages = [
{"role": "system", "content": SYSTEM_PROMPT_CURATOR},
{"role": "user", "content": USER_TRANSCRIPT_CURATOR},
]
- think = True
else:
raise ValueError(f"unknown scenario: {scenario}")
+ think = _resolve_think(scenario, think_mode)
return {
"model": model,
"messages": messages,
@@ -226,12 +253,15 @@ def benchmark(
runs: int,
num_gpu: int,
keep_alive: str,
+ think_mode: str,
) -> list[ScenarioResult]:
results: list[ScenarioResult] = []
for model in models:
for scenario in scenarios:
sr = ScenarioResult(model=model, scenario=scenario)
- payload = build_request(scenario, model, num_gpu, keep_alive)
+ payload = build_request(
+ scenario, model, num_gpu, keep_alive, think_mode
+ )
# Warm-up run loads the model into RAM/VRAM with the requested
# num_gpu setting. Excluded from the measured runs because it
# otherwise dominates TTFT with model-load time.
@@ -268,15 +298,27 @@ def benchmark(
return results
-def format_markdown(results: list[ScenarioResult], *, server: str, num_gpu: int) -> str:
+def format_markdown(
+ results: list[ScenarioResult],
+ *,
+ server: str,
+ num_gpu: int,
+ think_mode: str,
+) -> str:
mode = "CPU only" if num_gpu == 0 else (
f"GPU offload ({num_gpu} layers)" if num_gpu > 0 else "Ollama default"
)
+ think_label = {
+ "auto": "auto (chat=off, curator=on)",
+ "on": "forced ON",
+ "off": "forced OFF",
+ }.get(think_mode, think_mode)
lines = [
"# Ollama benchmark",
"",
f"- Server: `{server}`",
- f"- Mode: {mode} (`num_gpu={num_gpu}`)",
+ f"- Hardware mode: {mode} (`num_gpu={num_gpu}`)",
+ f"- Think: {think_label}",
"",
"| Model | Scenario | Runs | Prompt tok | TTFT p50 (ms) "
"| Total p50 (ms) | tok/s p50 | Output tok (mean) |",
@@ -337,6 +379,17 @@ def main():
default="10m",
help="Ollama keep_alive (default %(default)s)",
)
+ parser.add_argument(
+ "--think",
+ choices=["auto", "on", "off"],
+ default="auto",
+ help=(
+ "Think-mode control. auto = chat off / curator on (default). "
+ "off = force disabled for fair cross-family comparison "
+ "(non-qwen3 models silently ignore think anyway). "
+ "on = force enabled to measure what think contributes."
+ ),
+ )
parser.add_argument(
"--out",
help="Write markdown table to this file (also prints to stdout)",
@@ -355,9 +408,15 @@ def main():
runs=args.runs,
num_gpu=args.num_gpu,
keep_alive=args.keep_alive,
+ think_mode=args.think,
)
- md = format_markdown(results, server=args.server, num_gpu=args.num_gpu)
+ md = format_markdown(
+ results,
+ server=args.server,
+ num_gpu=args.num_gpu,
+ think_mode=args.think,
+ )
print("\n" + md)
if args.out:
with open(args.out, "w") as f:
From 2d5d3ffdff48b9368bc8e75a877c37cceb380809 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 14:53:18 -0400
Subject: [PATCH 007/118] bench_ollama: PEP 723 inline script metadata for
uv-run
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Was failing with ModuleNotFoundError for httpx when run via system
python — httpx is a project dep but isn't on the system interpreter's
path. Adding PEP 723 script metadata + uv-run shebang means the script
auto-resolves its deps in an ephemeral venv on every invocation, no
project-venv setup required.
Run with `uv run scripts/bench_ollama.py …` or directly via the shebang
`./scripts/bench_ollama.py …`. `python scripts/bench_ollama.py …` still
works only when httpx happens to be on the active interpreter.
Docstring updated to reflect the running options.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
scripts/bench_ollama.py | 25 ++++++++++++++++++++-----
1 file changed, 20 insertions(+), 5 deletions(-)
diff --git a/scripts/bench_ollama.py b/scripts/bench_ollama.py
index a698cdf..5c5a4e1 100755
--- a/scripts/bench_ollama.py
+++ b/scripts/bench_ollama.py
@@ -1,4 +1,8 @@
-#!/usr/bin/env python3
+#!/usr/bin/env -S uv run --script
+# /// script
+# requires-python = ">=3.10"
+# dependencies = ["httpx>=0.27"]
+# ///
"""Benchmark Ollama models for FabledScribe chat / curator workloads.
Measures time-to-first-token, total wall time, and tokens/sec for each
@@ -26,27 +30,38 @@ Think modes:
contributes vs `off` on the same model).
Prerequisites:
+ - `uv` installed (https://docs.astral.sh/uv/). The script's shebang
+ uses `uv run --script`, which auto-creates an ephemeral venv with
+ httpx — no project install required.
- Ollama running and reachable at --server (default http://localhost:11434).
- The models named in --models must already be pulled
(`ollama pull qwen3:30b-a3b` etc).
+Running:
+ # Preferred — uv handles the venv:
+ uv run scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator
+ # Or via the shebang (the file is executable):
+ ./scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator
+ # `python scripts/bench_ollama.py …` works ONLY if httpx is on the
+ # interpreter's path — usually it isn't outside the project venv.
+
Usage examples:
# Curator candidate on CPU, 3 runs each (default), one model:
- python scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator
+ uv run scripts/bench_ollama.py --models qwen3:30b-a3b --scenario curator
# Chat candidate on GPU, against a remote server:
- python scripts/bench_ollama.py \\
+ uv run scripts/bench_ollama.py \\
--server http://dock02:11434 \\
--models llama3.2:3b \\
--scenario chat --num-gpu 99
# Compare two qwen curator candidates on CPU, 5 runs each:
- python scripts/bench_ollama.py \\
+ uv run scripts/bench_ollama.py \\
--models qwen3:30b-a3b,qwen3:32b \\
--scenario curator --runs 5 --out bench-qwen-curator.md
# Cross-family curator comparison with think forced OFF (apples-to-apples):
- python scripts/bench_ollama.py \\
+ uv run scripts/bench_ollama.py \\
--models qwen3:30b-a3b,gemma2:27b,mistral-small:22b \\
--scenario curator --think off --runs 3 \\
--out bench-curator-no-think.md
From d345b32856e9621731359eb4ae6bc62dce2ceeee Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 22:00:44 -0400
Subject: [PATCH 008/118] feat(llm): user-controlled think mode (default off);
remove qwen3 hardcode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The chat generation pipeline previously forced think=True unconditionally
to match qwen3's combined think+tools template, locking the system into
that model family. Bench data (2026-05-21, qwen3:30b-a3b/qwen3:32b on
CPU) showed thinking adds 1-2 minutes per turn for unclear quality
benefit — qwen3:30b-a3b even produced more rambling with think on.
This decouples think from the model family by reading a per-user
`think_enabled` setting (default `false`). Non-qwen3 models can now run
through the same pipeline without the silent-generation failure mode
that content-gated thinking would have caused — they just don't think.
qwen3 users who still want thinking can opt in via the Settings UI.
Settings UI:
- New "Enable model thinking" checkbox in General → Assistant section.
- Help text explains the default-off rationale and when to opt in.
- Persists via the existing settings API; no schema migration needed
(Setting is key/value text).
Telemetry to confirm whether this regresses tool-call reliability on
qwen3 (the current model) is in a follow-up commit (generation_tool_log).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
frontend/src/views/SettingsView.vue | 34 +++++++++++++++++++
.../services/generation_task.py | 13 ++++---
2 files changed, 42 insertions(+), 5 deletions(-)
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index fbf138f..21a2f8a 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -21,6 +21,8 @@ const savingTimezone = ref(false);
const timezoneSaved = ref(false);
const autoConsolidateTasks = ref(true);
const savingAutoConsolidate = ref(false);
+const thinkEnabled = ref(false);
+const savingThinkEnabled = ref(false);
async function saveAutoConsolidate() {
savingAutoConsolidate.value = true;
@@ -35,6 +37,19 @@ async function saveAutoConsolidate() {
}
}
+async function saveThinkEnabled() {
+ savingThinkEnabled.value = true;
+ try {
+ await apiPut('/api/settings', {
+ think_enabled: thinkEnabled.value ? "true" : "false",
+ });
+ } catch {
+ toastStore.show('Failed to save setting', 'error');
+ } finally {
+ savingThinkEnabled.value = false;
+ }
+}
+
function detectTimezone() {
userTimezone.value = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
@@ -771,6 +786,9 @@ onMounted(async () => {
userTimezone.value = allSettings.user_timezone ?? "";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
+ // Default false: think mode is opt-in. Bench showed it costs 1-2 min/turn
+ // for unclear benefit; qwen3 users can re-enable if tool calls regress.
+ thinkEnabled.value = (allSettings.think_enabled ?? "false") === "true";
chatRetentionDays.value = allSettings.chat_retention_days !== undefined
? Number(allSettings.chat_retention_days)
: 90;
@@ -1518,6 +1536,22 @@ function formatUserDate(iso: string): string {
+
+
+
+ Enable model thinking
+
+
+ When on, the chat model runs in reasoning mode before responding.
+ Default off — bench data showed thinking adds 1–2 minutes per turn for unclear quality benefit.
+ Turn on for qwen3-family models if tool-call reliability drops without it.
+
+
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index 057fe43..a92af60 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -231,12 +231,15 @@ async def run_generation(
# Emit context event
buf.append_event("context", {"context": context_meta})
- # Always think on qwen3-class models: reasoning mode is the only reliable
- # path for the tool-call template. Content-based gating was tried in 87fcaa6
- # but exposed silent-generation failures on short tool-intent prompts, since
- # the classifier had no way to tell that "create a task" needs a tool call.
+ # Think mode is per-user-setting (default off). Historically forced on for
+ # qwen3 because content-gated thinking (87fcaa6) exposed silent-generation
+ # failures on short tool-intent prompts. After moving to a model-family
+ # decoupled architecture, the bench data (May 2026) showed think costs
+ # 1-2 min/turn for unclear quality benefit; default off, opt in via
+ # the Settings UI. The generation_tool_log captures per-turn outcomes
+ # so reliability regressions surface empirically.
think_requested = think
- think = True
+ think = (await get_setting(user_id, "think_enabled", "false")).lower() == "true"
t_start = time.monotonic()
timing: dict = {
From bf7a29e8a0d109a6d25eae7672aa15c315c8ccc4 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 22:04:09 -0400
Subject: [PATCH 009/118] feat(llm): per-turn tool-call telemetry
(generation_tool_log)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds an empirical surface for evaluating model swaps. One row per
assistant turn captures: model, think_enabled, tools_available,
tools_attempted, tools_succeeded, tools_failed (with error details
as JSONB). Without this, judging whether a new model "actually fires
record_moment when it should" relies on anecdote across user-reported
sessions. With it, the data is queryable directly.
Pieces:
- Migration 0046: generation_tool_log table with user_created and
per-conversation indexes.
- Model: SQLAlchemy GenerationToolLog with to_dict() for plain-dict
consumption outside session scope.
- Service: log_tool_outcomes() normalizes the in-app tool-call shape
(function/result/status) into the split buckets and persists. It
catches its own exceptions — telemetry failure must NEVER affect
the user-facing generation flow. recent_logs() helper for read.
- Integration in run_generation: called once per turn right after
log_generation, fire-and-forget.
- Tests: pure-normalization unit tests using a stub session — no DB
needed in CI. Cover the success/error split, the empty-tool-calls
case, the exception-swallowing contract, and the success=False
edge case where status incorrectly says "success".
No UI for the telemetry yet — internal infrastructure (the operator
is the consumer, not the journal user), which the FabledRulebook
"no UI no ship" explicitly excepts. Query via psql or extend the
Fable MCP later if direct shell access gets tiresome.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.../versions/0046_add_generation_tool_log.py | 103 ++++++++++
src/fabledassistant/models/__init__.py | 1 +
.../models/generation_tool_log.py | 75 ++++++++
.../services/generation_log.py | 111 +++++++++++
.../services/generation_task.py | 17 ++
tests/test_generation_log.py | 180 ++++++++++++++++++
6 files changed, 487 insertions(+)
create mode 100644 alembic/versions/0046_add_generation_tool_log.py
create mode 100644 src/fabledassistant/models/generation_tool_log.py
create mode 100644 src/fabledassistant/services/generation_log.py
create mode 100644 tests/test_generation_log.py
diff --git a/alembic/versions/0046_add_generation_tool_log.py b/alembic/versions/0046_add_generation_tool_log.py
new file mode 100644
index 0000000..32ff4cf
--- /dev/null
+++ b/alembic/versions/0046_add_generation_tool_log.py
@@ -0,0 +1,103 @@
+"""generation_tool_log: per-turn tool-call telemetry
+
+Revision ID: 0046
+Revises: 0045
+Create Date: 2026-05-21
+
+Captures one row per assistant turn, recording which tools the model
+could have used, which it attempted, which fired successfully, and
+which failed (with error details). The empirical surface for
+evaluating model swaps and answering "does model X actually fire
+record_moment when it should?" without relying on anecdote.
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects.postgresql import ARRAY, JSONB
+
+
+revision = "0046"
+down_revision = "0045"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "generation_tool_log",
+ sa.Column("id", sa.Integer, primary_key=True),
+ sa.Column(
+ "user_id",
+ sa.Integer,
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "conv_id",
+ sa.Integer,
+ sa.ForeignKey("conversations.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ # SET NULL (not CASCADE) so the telemetry row survives if the
+ # underlying assistant message is later deleted — we want to keep
+ # the per-turn outcome record for retrospective analysis.
+ sa.Column(
+ "assistant_message_id",
+ sa.Integer,
+ sa.ForeignKey("messages.id", ondelete="SET NULL"),
+ nullable=True,
+ ),
+ sa.Column("model", sa.Text, nullable=False),
+ sa.Column("think_enabled", sa.Boolean, nullable=False),
+ sa.Column(
+ "tools_available",
+ ARRAY(sa.Text),
+ nullable=False,
+ server_default=sa.text("'{}'::text[]"),
+ ),
+ sa.Column(
+ "tools_attempted",
+ ARRAY(sa.Text),
+ nullable=False,
+ server_default=sa.text("'{}'::text[]"),
+ ),
+ sa.Column(
+ "tools_succeeded",
+ ARRAY(sa.Text),
+ nullable=False,
+ server_default=sa.text("'{}'::text[]"),
+ ),
+ # JSONB array of {name, error} objects so failed-tool details are
+ # queryable without a separate failure table.
+ sa.Column(
+ "tools_failed",
+ JSONB,
+ nullable=False,
+ server_default=sa.text("'[]'::jsonb"),
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ )
+ # Common query: "recent tool outcomes for this user, filterable by model."
+ op.create_index(
+ "ix_generation_tool_log_user_created",
+ "generation_tool_log",
+ ["user_id", sa.text("created_at DESC")],
+ )
+ # Per-conversation lookup for the journal page's own retrospection.
+ op.create_index(
+ "ix_generation_tool_log_conv",
+ "generation_tool_log",
+ ["conv_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index("ix_generation_tool_log_conv", table_name="generation_tool_log")
+ op.drop_index(
+ "ix_generation_tool_log_user_created", table_name="generation_tool_log"
+ )
+ op.drop_table("generation_tool_log")
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index a8b9c71..37c4af2 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -49,3 +49,4 @@ from fabledassistant.models.moment import ( # noqa: E402, F401
moment_tasks,
moment_notes,
)
+from fabledassistant.models.generation_tool_log import GenerationToolLog # noqa: E402, F401
diff --git a/src/fabledassistant/models/generation_tool_log.py b/src/fabledassistant/models/generation_tool_log.py
new file mode 100644
index 0000000..1322143
--- /dev/null
+++ b/src/fabledassistant/models/generation_tool_log.py
@@ -0,0 +1,75 @@
+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,
+ }
diff --git a/src/fabledassistant/services/generation_log.py b/src/fabledassistant/services/generation_log.py
new file mode 100644
index 0000000..4264766
--- /dev/null
+++ b/src/fabledassistant/services/generation_log.py
@@ -0,0 +1,111 @@
+"""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": , "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()]
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index a92af60..401ccb5 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -480,6 +480,23 @@ async def run_generation(
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}
diff --git a/tests/test_generation_log.py b/tests/test_generation_log.py
new file mode 100644
index 0000000..8dada22
--- /dev/null
+++ b/tests/test_generation_log.py
@@ -0,0 +1,180 @@
+"""Unit tests for the tool-call telemetry normalization logic.
+
+The DB-touching path of `log_tool_outcomes` requires a running PostgreSQL,
+so we test the pure normalization by stubbing the session. The shape
+contract that `generation_task.py` actually emits is:
+
+ {"function": , "arguments": ..., "result": , "status": ...}
+
+with `result["success"]` being False for failures and `result["error"]`
+the message. We verify the helper splits these correctly into the
+attempted / succeeded / failed buckets.
+"""
+from __future__ import annotations
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_log_tool_outcomes_splits_success_and_failure():
+ """A mix of success and error entries should split into the right buckets."""
+ captured: dict = {}
+
+ class _StubSession:
+ def __init__(self):
+ self.added = []
+
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ def add(self, row):
+ self.added.append(row)
+ captured["row"] = row
+
+ async def commit(self):
+ return None
+
+ stub = _StubSession()
+
+ from fabledassistant.services import generation_log
+
+ with patch.object(generation_log, "async_session", lambda: stub):
+ await generation_log.log_tool_outcomes(
+ user_id=1,
+ conv_id=42,
+ assistant_message_id=99,
+ model="qwen3:8b",
+ think_enabled=False,
+ tools_available=["record_moment", "search_notes", "create_task"],
+ tool_calls=[
+ {
+ "function": "record_moment",
+ "arguments": {},
+ "result": {"success": True, "moment_id": 17},
+ "status": "success",
+ },
+ {
+ "function": "create_task",
+ "arguments": {},
+ "result": {"success": False, "error": "missing required field 'title'"},
+ "status": "error",
+ },
+ ],
+ )
+
+ row = captured["row"]
+ assert row.user_id == 1
+ assert row.conv_id == 42
+ assert row.assistant_message_id == 99
+ assert row.model == "qwen3:8b"
+ assert row.think_enabled is False
+ assert row.tools_available == ["create_task", "record_moment", "search_notes"]
+ assert row.tools_attempted == ["record_moment", "create_task"]
+ assert row.tools_succeeded == ["record_moment"]
+ assert row.tools_failed == [{"name": "create_task", "error": "missing required field 'title'"}]
+
+
+@pytest.mark.asyncio
+async def test_log_tool_outcomes_handles_empty_tool_calls():
+ """A turn with no tool calls produces an empty-attempted row, not an exception."""
+ captured: dict = {}
+
+ class _StubSession:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ def add(self, row):
+ captured["row"] = row
+
+ async def commit(self):
+ return None
+
+ from fabledassistant.services import generation_log
+
+ with patch.object(generation_log, "async_session", lambda: _StubSession()):
+ await generation_log.log_tool_outcomes(
+ user_id=1,
+ conv_id=42,
+ assistant_message_id=99,
+ model="mistral-small:22b",
+ think_enabled=False,
+ tools_available=["record_moment"],
+ tool_calls=[],
+ )
+
+ row = captured["row"]
+ assert row.tools_attempted == []
+ assert row.tools_succeeded == []
+ assert row.tools_failed == []
+
+
+@pytest.mark.asyncio
+async def test_log_tool_outcomes_swallows_exceptions():
+ """Telemetry failure must NOT propagate — it would break user-facing flow."""
+ from fabledassistant.services import generation_log
+
+ bad_session = MagicMock()
+ bad_session.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down"))
+
+ with patch.object(generation_log, "async_session", bad_session):
+ # Should NOT raise.
+ await generation_log.log_tool_outcomes(
+ user_id=1,
+ conv_id=42,
+ assistant_message_id=99,
+ model="qwen3:8b",
+ think_enabled=False,
+ tools_available=[],
+ tool_calls=[],
+ )
+
+
+@pytest.mark.asyncio
+async def test_log_tool_outcomes_detects_success_false_without_error_field():
+ """A tool result with success=False but no error string should still go to failed."""
+ captured: dict = {}
+
+ class _StubSession:
+ async def __aenter__(self):
+ return self
+
+ async def __aexit__(self, *exc):
+ return False
+
+ def add(self, row):
+ captured["row"] = row
+
+ async def commit(self):
+ return None
+
+ from fabledassistant.services import generation_log
+
+ with patch.object(generation_log, "async_session", lambda: _StubSession()):
+ await generation_log.log_tool_outcomes(
+ user_id=1,
+ conv_id=42,
+ assistant_message_id=99,
+ model="qwen3:8b",
+ think_enabled=False,
+ tools_available=["search_notes"],
+ tool_calls=[
+ {
+ "function": "search_notes",
+ "arguments": {},
+ "result": {"success": False}, # no explicit error field
+ "status": "success", # but status incorrectly says success
+ },
+ ],
+ )
+
+ row = captured["row"]
+ assert row.tools_succeeded == []
+ assert row.tools_failed == [{"name": "search_notes", "error": "unspecified"}]
From 9137bf698a1e824928e10805c6d1f5f34638d656 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 22:18:56 -0400
Subject: [PATCH 010/118] fix(docker): drop voice deps from runtime image to
unblock 3.14 build
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
CI broke on the build job: kokoro's resolver walks back to a version
that pins numpy<2, which has no cp314 wheel; pip falls back to compiling
numpy from source; python:3.14-slim has no compiler; build fails.
Removing the voice deps install (torch + faster-whisper + kokoro +
soundfile + spacy) from the runtime image:
- unblocks the 3.14 build immediately
- shrinks the image by ~2 GB (torch alone)
- aligns with the explicit operator preference (voice/TTS doesn't pay
off in their workflow; conversational chat will get smaller/faster
with the new no-tools chat model on GPU, so transcription matters
even less)
Voice paths in code are already lazily guarded — TYPE_CHECKING-only
imports plus try/except inside load_stt_model. With VOICE_ENABLED=false
(default), the app starts cleanly with no voice deps installed. With
voice enabled, the import error is caught and logged; the feature
degrades gracefully rather than crashing.
To re-enable voice in a future build, `pyproject.toml` already has the
`voice` extra ready: install it with `pip install .[voice]` plus the
torch index pin, and download spacy en_core_web_sm. Dockerfile comment
documents the path.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
Dockerfile | 14 ++++++++------
1 file changed, 8 insertions(+), 6 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index d3fc9e5..62fe8f4 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -16,12 +16,14 @@ COPY pyproject.toml .
COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install .
-# Voice dependencies (faster-whisper, Kokoro TTS, soundfile) — activated at runtime via VOICE_ENABLED
-# Install CPU-only torch first so pip doesn't pull full CUDA wheels (~2 GB) for kokoro/transformers.
-RUN --mount=type=cache,target=/root/.cache/pip \
- pip install torch --index-url https://download.pytorch.org/whl/cpu \
- && pip install faster-whisper kokoro soundfile \
- && python -m spacy download en_core_web_sm
+
+# Voice deps (faster-whisper, kokoro, torch, soundfile, spacy) are NOT
+# installed in the production image. They added ~2 GB and pulled numpy<2,
+# which has no cp314 wheel and broke the slim-image build.
+# Voice paths in code are lazily guarded; with VOICE_ENABLED=false (default),
+# the app starts cleanly. With voice enabled, missing imports log and
+# the feature gracefully degrades. To re-enable voice in a build, install
+# the `voice` extra: `pip install .[voice]` — and add an extras step here.
# Build the fable-mcp wheel so it can be served for download
COPY fable-mcp/ fable-mcp/
From c91b9c46ff1c72676aa91000cd5ae88a42fb0742 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 21 May 2026 22:58:12 -0400
Subject: [PATCH 011/118] fix(docker): restore STT (faster-whisper) on Python
3.14
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Previously removed all voice deps from the runtime image because of the
numpy<2 / cp314 wheel chain. Actual upstream check (PyPI 2026-05-21)
shows the chain has resolved for the STT half:
- ctranslate2 v4.7.2 (2026-05-19) ships cp314 wheels
- faster-whisper v1.2.1 is pure Python and works on any supported runtime
- onnxruntime v1.26.0 has cp314 wheels (not used here but shared with
the upcoming piper-tts install)
The blocker was kokoro, not the whole stack. Kokoro has been stale
upstream since April 2025 with a `requires_python='<3.13'` pin; that's
being replaced separately with piper-tts.
This commit restores ONLY STT — faster-whisper + soundfile. No torch
(ctranslate2 does its own CPU inference), no kokoro, no spacy. Image
add: ~150 MB.
Voice code is lazily guarded; STT now works when VOICE_ENABLED=true.
TTS still fails gracefully (kokoro import error logged, voice degrades)
until the piper-tts swap lands.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
Dockerfile | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/Dockerfile b/Dockerfile
index 62fe8f4..84b5971 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -17,13 +17,14 @@ COPY src/ src/
RUN --mount=type=cache,target=/root/.cache/pip \
pip install .
-# Voice deps (faster-whisper, kokoro, torch, soundfile, spacy) are NOT
-# installed in the production image. They added ~2 GB and pulled numpy<2,
-# which has no cp314 wheel and broke the slim-image build.
-# Voice paths in code are lazily guarded; with VOICE_ENABLED=false (default),
-# the app starts cleanly. With voice enabled, missing imports log and
-# the feature gracefully degrades. To re-enable voice in a build, install
-# the `voice` extra: `pip install .[voice]` — and add an extras step here.
+# 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.
+# TTS (piper-tts) is installed in a separate later step so STT can
+# stand alone if TTS becomes problematic.
+RUN --mount=type=cache,target=/root/.cache/pip \
+ pip install faster-whisper soundfile
# Build the fable-mcp wheel so it can be served for download
COPY fable-mcp/ fable-mcp/
From a28f75994a97066fe6d6928dd1550d08fbf78af9 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Fri, 22 May 2026 07:59:09 -0400
Subject: [PATCH 012/118] =?UTF-8?q?feat(voice):=20swap=20kokoro=20TTS=20?=
=?UTF-8?q?=E2=86=92=20piper-tts?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Kokoro has been stale upstream since April 2025 (`requires_python<3.13`),
which broke the Python 3.14 build. Piper is the active replacement:
maintained by OHF/Home Assistant, depends only on onnxruntime +
pathvalidate (no torch, no spacy, no transformers), and has cp314
support today.
Dockerfile:
- Add `pip install piper-tts` after the STT install.
- Bundle two default voices (en_US-amy-medium, en_US-ryan-medium) into
/opt/piper-voices at build. Additional voices can be downloaded into
/data/voices via the admin UI (separate commit).
- Image add over the STT-only baseline: ~150 MB.
services/tts.py — full rewrite:
- New voice-discovery layer scans /opt/piper-voices + /data/voices for
.onnx + .onnx.json pairs. /data wins over /opt for the same id so
admin-downloaded voices can override bundled defaults.
- Single PiperVoice kept warm; switches via _switch_voice() when the
user changes their voice_tts_voice setting.
- list_voices() returns metadata read from .onnx.json sidecars (label
derived from filename, language, quality, sample_rate).
- synthesise() uses piper's SynthesisConfig; converts kokoro-shaped
`speed` multiplier to piper's `length_scale` (1.0 / speed).
- `voice_blend` parameter accepted but ignored — piper has no blend
equivalent; first entry's voice is used if anything is passed.
- Dropped: HuggingFace commit-hash tracking (~80 lines), the daily
check_for_kokoro_updates task, voice-tensor blending math.
routes/voice.py:
- tts_backend reports "piper" in /api/voice/status.
- /api/voice/voices no longer requires tts_available() — even with
the active voice failed to load, the catalog still lets the user
pick a different one.
- Synthesise request body dropped the voice_blend field; speed and
voice still supported.
alembic 0047_reset_voice_tts_settings:
- Deletes any stored voice_tts_voice (kokoro IDs that don't map to
piper) and voice_tts_blend (no piper equivalent) rows. Both
re-default cleanly on next read.
frontend:
- VoiceBlendEntry type removed from api/client.ts.
- synthesiseSpeech() signature dropped the voiceBlend parameter.
- SettingsView.vue Voice Blend section removed entirely (slider,
preview, slot management). voice_tts_blend save path removed.
- Default voice id changed from "af_heart" to "en_US-amy-medium".
- VoiceEntry gains optional language/quality/sample_rate fields
from the richer piper sidecar metadata.
Voice paths remain lazily guarded — `VOICE_ENABLED=false` (default)
starts the app cleanly regardless of which TTS deps are present.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
Dockerfile | 22 +-
.../versions/0047_reset_voice_tts_settings.py | 38 ++
frontend/src/api/client.ts | 23 +-
frontend/src/views/SettingsView.vue | 130 +-----
src/fabledassistant/routes/voice.py | 44 +-
src/fabledassistant/services/tts.py | 423 +++++++++---------
6 files changed, 317 insertions(+), 363 deletions(-)
create mode 100644 alembic/versions/0047_reset_voice_tts_settings.py
diff --git a/Dockerfile b/Dockerfile
index 84b5971..2341478 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -21,11 +21,29 @@ RUN --mount=type=cache,target=/root/.cache/pip \
# 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.
-# TTS (piper-tts) is installed in a separate later step so STT can
-# stand alone if TTS becomes problematic.
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
+RUN mkdir -p /opt/piper-voices && cd /opt/piper-voices && \
+ for VOICE in en_US-amy-medium en_US-ryan-medium; do \
+ DATASET="${VOICE#en_US-}"; DATASET="${DATASET%-medium}"; \
+ BASE="https://huggingface.co/rhasspy/piper-voices/resolve/main/en/en_US/${DATASET}/medium"; \
+ curl -fsSL -o "${VOICE}.onnx" "${BASE}/${VOICE}.onnx"; \
+ curl -fsSL -o "${VOICE}.onnx.json" "${BASE}/${VOICE}.onnx.json"; \
+ done
+
# 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 \
diff --git a/alembic/versions/0047_reset_voice_tts_settings.py b/alembic/versions/0047_reset_voice_tts_settings.py
new file mode 100644
index 0000000..98bff53
--- /dev/null
+++ b/alembic/versions/0047_reset_voice_tts_settings.py
@@ -0,0 +1,38 @@
+"""reset voice_tts_voice and voice_tts_blend settings for piper migration
+
+Revision ID: 0047
+Revises: 0046
+Create Date: 2026-05-22
+
+The TTS backend swapped from kokoro to piper. Kokoro voice IDs
+(`af_heart`, `am_adam`, etc.) don't map to piper voice files
+(`en_US-amy-medium`, etc.) and there's no sensible auto-translation.
+Clear the stored voice selection so every user falls back to the piper
+default the next time they synthesize.
+
+`voice_tts_blend` goes away entirely — piper has no voice-blending
+equivalent. The TTS service accepts the field for backward compat but
+ignores it; clearing the rows makes the DB match reality.
+
+Both settings re-default cleanly on read, so this migration just deletes
+the rows without backfilling anything.
+"""
+from alembic import op
+
+
+revision = "0047"
+down_revision = "0046"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute(
+ "DELETE FROM settings WHERE key IN ('voice_tts_voice', 'voice_tts_blend')"
+ )
+
+
+def downgrade() -> None:
+ # No-op. The deleted settings just re-default on read; restoring the
+ # kokoro IDs wouldn't help anyway since kokoro is gone.
+ pass
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 19607c2..52305a0 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -623,11 +623,9 @@ export interface VoiceStatusResult {
export interface VoiceEntry {
id: string
label: string
-}
-
-export interface VoiceBlendEntry {
- voice: string
- weight: number
+ language?: string
+ quality?: string
+ sample_rate?: number
}
export const getVoiceStatus = () => apiGet('/api/voice/status')
@@ -651,16 +649,15 @@ export async function synthesiseSpeech(
text: string,
voice?: string,
speed?: number,
- voiceBlend?: VoiceBlendEntry[]
): Promise {
- // Only send voice/speed/blend when explicitly provided — omitting them lets
- // the server auto-load the user's saved voice settings (voice, speed, blend).
+ // Only send voice/speed when explicitly provided — omitting them lets the
+ // server auto-load the user's saved voice settings.
+ // Voice blending was removed with the kokoro → piper migration (piper has
+ // no blend equivalent); callers that previously passed `voiceBlend` should
+ // pass the first voice's id as `voice` instead.
const body: Record = { text }
- if (voiceBlend && voiceBlend.length >= 2) {
- body.voice_blend = voiceBlend
- if (speed !== undefined) body.speed = speed
- } else if (voice !== undefined || speed !== undefined) {
- body.voice = voice ?? 'af_heart'
+ if (voice !== undefined || speed !== undefined) {
+ body.voice = voice ?? 'en_US-amy-medium'
body.speed = speed ?? 1.0
}
const res = await fetch('/api/voice/synthesise', {
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 21a2f8a..772b8fa 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -1,10 +1,9 @@
@@ -214,12 +188,6 @@ onUnmounted(() => {
p
Projects
-
- g
- +
- c
- Chat
-
g
+
@@ -267,23 +235,6 @@ onUnmounted(() => {
Edit current item (viewer)
-
-
Chat
-
- c
- Focus chat (home) / go to chat
-
-
- Enter
- Send message
-
-
- Shift
- +
- Enter
- New line
-
-
@@ -328,8 +279,6 @@ onUnmounted(() => {
min-height: 0;
overflow-y: auto;
}
-.app-content:has(.workspace-root),
-.app-content:has(.chat-page),
.app-content:has(.knowledge-root) {
overflow: hidden;
}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index f11789e..553b0db 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -4,7 +4,6 @@ import { useRouter, useRoute } from "vue-router";
import { useTheme } from "@/composables/useTheme";
import { useShortcuts } from "@/composables/useShortcuts";
import { useAuthStore } from "@/stores/auth";
-import { useChatStore } from "@/stores/chat";
import AppLogo from "@/components/AppLogo.vue";
import NotificationBell from "@/components/NotificationBell.vue";
import { Sun, Moon, Settings } from "lucide-vue-next";
@@ -12,42 +11,13 @@ import { Sun, Moon, Settings } from "lucide-vue-next";
const { theme, toggleTheme } = useTheme();
const { toggleShortcuts } = useShortcuts();
const authStore = useAuthStore();
-const chatStore = useChatStore();
const router = useRouter();
const route = useRoute();
-const isChatActive = computed(() => route.path.startsWith("/chat"))
const isKnowledgeActive = computed(() => route.path === "/knowledge");
const mobileMenuOpen = ref(false);
-const statusShortLabel = computed(() => {
- if (chatStore.ollamaStatus === "unavailable") return "Offline";
- if (chatStore.ollamaStatus === "checking") return "Checking";
- if (chatStore.modelStatus === "not_found") return "No model";
- if (chatStore.modelStatus === "cold") return "Cold";
- if (chatStore.modelStatus === "loaded") return "Ready";
- return "Checking";
-});
-
-const statusLabel = computed(() => {
- if (chatStore.ollamaStatus === "unavailable") return "Ollama unavailable";
- if (chatStore.ollamaStatus === "checking") return "Checking...";
- if (chatStore.modelStatus === "not_found") return "Model not installed";
- if (chatStore.modelStatus === "cold") return "Model cold — first response may be slow";
- if (chatStore.modelStatus === "loaded") return "Model loaded · ready";
- return "Checking...";
-});
-
-const statusClass = computed(() => {
- if (chatStore.ollamaStatus === "unavailable") return "status-red";
- if (chatStore.ollamaStatus === "checking") return "status-gray";
- if (chatStore.modelStatus === "not_found") return "status-orange";
- if (chatStore.modelStatus === "cold") return "status-yellow";
- if (chatStore.modelStatus === "loaded") return "status-green";
- return "status-gray";
-});
-
function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value;
}
@@ -76,20 +46,13 @@ router.afterEach(() => {
Knowledge
- Chat
- Journal
Calendar
Projects
-
+
-
-
- {{ statusShortLabel }}
-
-
?
@@ -122,8 +85,6 @@ router.afterEach(() => {
@@ -656,41 +599,6 @@ onUnmounted(() => {
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1294,71 +1202,4 @@ onUnmounted(() => {
height: 100%;
}
-/* ── Mini-chat ───────────────────────────────────────────── */
-.minichat {
- position: absolute;
- bottom: 0;
- left: var(--sidebar-width); /* align with content area past filter panel */
- right: 0;
- max-width: var(--page-max-width);
- margin-inline: auto;
- z-index: 20;
- display: flex;
- flex-direction: column;
- background: var(--color-bg-secondary);
- border-radius: 16px 16px 0 0;
- box-shadow: 0 -8px 32px rgba(0,0,0,0.35), 0 -1px 0 rgba(255,255,255,0.05);
- transition: height 0.2s ease;
-}
-.minichat--open {
- height: 500px;
-}
-.knowledge-root.graph-open .minichat {
- right: 500px;
- transition: right 0.2s ease;
-}
-.knowledge-root.graph-open.graph-expanded .minichat {
- right: min(960px, 60vw);
-}
-
-/* Header row (collapse / close buttons) */
-.minichat-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 8px 14px;
- border-bottom: 1px solid rgba(255,255,255,0.06);
- flex-shrink: 0;
-}
-.minichat-title {
- font-size: 0.82rem;
- font-weight: 500;
- color: var(--color-muted);
- text-transform: uppercase;
- letter-spacing: 0.06em;
-}
-.minichat-header-actions {
- display: flex;
- gap: 4px;
- align-items: center;
-}
-
-/* ChatPanel body — fills remaining space */
-.minichat-chat-panel {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
-}
-.minichat-chat-panel :deep(.chat-body) {
- flex: 1;
- min-height: 0;
-}
-
-/* Standalone input row (closed / collapsed state) */
-.minichat-input-row {
- padding: 10px 14px;
- flex-shrink: 0;
-}
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 95678bf..9f1a65c 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -82,7 +82,7 @@ const appVersion = ref('dev');
const restoreFileInput = ref(null);
// Migrate stored "admin" → "config"; unknown tabs fall back to "general"
-const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "voice", "apikeys", "config", "users", "logs", "groups"]);
+const VALID_TABS = new Set(["general", "account", "profile", "notifications", "integrations", "data", "apikeys", "config", "users", "logs", "groups"]);
const _stored = localStorage.getItem("settings_tab") ?? "general";
const activeTab = ref(VALID_TABS.has(_stored) ? (_stored === "admin" ? "config" : _stored) : "general");
@@ -92,7 +92,6 @@ function _loadTabContent(tab: string) {
else if (tab === "logs") loadLogsPanel();
else if (tab === "groups") loadGroupsPanel();
}
- if (tab === "voice") loadVoiceTab();
if (tab === "apikeys") { fetchApiKeys(); }
}
@@ -1482,7 +1481,7 @@ function formatUserDate(iso: string): string {
@@ -1946,109 +1830,6 @@ function formatUserDate(iso: string): string {
-
- Journal
- Controls when the daily journal prep generates and how the day is framed.
-
-
-
- Generate daily prep automatically
-
-
When enabled, the journal generates a morning briefing at the time below. When off, the journal still works — just without the auto-generated daily prep.
-
-
-
-
Prep generates at
-
-
- :
-
-
-
24-hour. Generates each morning at this local time.
-
-
-
Day rolls over at
-
-
- :00
-
-
Hour when the journal switches to a new day. Default 4am — late-night entries (1–3am) still count as the previous day.
-
-
-
- {{ journalConfigSaving ? 'Saving…' : 'Save' }}
- Saved!
-
-
-
-
- What the Assistant Has Learned
-
- The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you.
- {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.
-
-
-
-
-
- Nightly closeout
- Extracts patterns from yesterday's journal at your day-rollover hour.
-
-
-
- {{ profile.learned_summary }}
- No learned summary yet. Observations accumulate from journal and chat conversations.
-
-
-
- {{ observationsExpanded ? '▾' : '▸' }} Recent observations ({{ profile.observations_count }})
-
-
-
Loading…
-
No observations yet.
-
-
-
{{ entry.date }}
-
{{ entry.bullets }}
-
-
-
-
-
-
-
- {{ consolidating ? 'Consolidating…' : 'Consolidate Now' }}
-
-
- {{ clearingObs ? 'Clearing…' : 'Reset Learned Data' }}
-
-
-
@@ -2080,103 +1861,6 @@ function formatUserDate(iso: string): string {
-
- Push Notifications
-
- Receive browser push notifications when a response is ready. Requires HTTPS.
-
-
- Push notifications are not supported in this browser or connection (requires HTTPS).
-
-
-
- Permission:
-
- {{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
-
-
-
- Status:
-
- {{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
-
-
- {{ pushStore.error }}
-
-
- {{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
-
-
- {{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
-
-
-
- Notifications are blocked. Allow them in your browser site settings to re-enable.
-
-
-
-
-
- If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
- the key pair here. All existing subscriptions will be cleared — you will need to
- re-enable notifications in this browser afterwards.
-
-
-
- {{ vapidResetting ? 'Regenerating…' : 'Regenerate VAPID Keys' }}
-
-
-
- {{ vapidResetMsg }}
-
-
-
-
-
- Chat History
-
- Conversations older than this many days are automatically deleted when you load the chat.
- Set to 0 to keep conversations forever.
-
-
-
Retention period (days)
-
-
-
- {{ savingRetention ? 'Saving...' : 'Save' }}
-
-
-
-
-
-
- About
- Fabled Scribe {{ appVersion }}
-
-
-
@@ -2307,198 +1991,6 @@ function formatUserDate(iso: string): string {
-
-
-
- Voice
-
- Configure text-to-speech and speech-to-text for voice conversations.
- Requires VOICE_ENABLED=true on the server.
-
-
-
- Loading voice status…
- Voice status unavailable.
-
-
- {{ voiceStatus.enabled ? 'Enabled' : 'Disabled' }}
-
-
- STT {{ voiceStatus.stt ? 'ready' : 'loading…' }}
-
-
- TTS {{ voiceStatus.tts ? 'ready' : 'loading…' }}
-
-
- Model: {{ voiceStatus.stt_model }}
-
-
-
-
- Voice is disabled. An administrator can enable it under
- Admin → Config → Voice .
-
-
-
-
- Speech Style
- Controls how the assistant phrases spoken responses.
-
-
-
Style
-
-
-
-
- {{ opt.label }}
- {{ opt.hint }}
-
-
-
-
-
-
-
- Voice & Speed
- Choose the TTS voice and playback speed.
-
-
-
Voice
-
- af_heart (default)
- {{ v.label }}
-
-
Voice character and accent. Applies to all voice conversations.
-
-
-
-
- Speed: {{ voiceTtsSpeed.toFixed(1) }}×
-
-
-
- 0.7× (slow)
- 1.0× (normal)
- 1.3× (fast)
-
-
-
-
-
- {{ voicePreviewing ? 'Generating…' : '▶ Preview' }}
-
-
- {{ voiceSaved ? 'Saved ✓' : savingVoice ? 'Saving…' : 'Save Voice Settings' }}
-
-
-
-
-
-
-
-
-
-
- Download additional piper voices from the
- piper-voices catalog .
- Installed voices appear in every user's voice picker after a page reload.
-
-
-
-
-
-
- {{ voiceLibraryLoading ? 'Loading…' : 'Refresh' }}
-
-
-
-
- {{ voiceLibraryError }}
-
-
-
- Loading catalog…
-
-
-
-
-
- No voices match the filter.
-
-
-
-
-
diff --git a/frontend/src/views/WorkspaceView.vue b/frontend/src/views/WorkspaceView.vue
deleted file mode 100644
index ab33b27..0000000
--- a/frontend/src/views/WorkspaceView.vue
+++ /dev/null
@@ -1,240 +0,0 @@
-
-
-
-
-
-
-
From ba6f2c7614b8b4e8cd89048012b70f3c5b9d3e9d Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 15:26:51 -0400
Subject: [PATCH 065/118] refactor(ui): purge SettingsView dead JS left over
from Phase 7
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The Phase 7 template strip left script-level state, functions, and
imports that no template ever referenced. TypeScript strict mode
(noUnusedLocals + noUnusedParameters) caught all of them on CI.
Removed from the script:
- chat retention: chatRetentionDays, savingRetention, saveRetention
- VAPID/push: vapidResetting/Msg/Error, resetVapidKeys, usePushStore,
pushStore.checkSubscription() call in onMounted
- admin voice block: adminVoiceEnabled, adminVoiceSttModel,
savingAdminVoice, adminVoiceSaved, voiceLoadingModels,
saveAdminVoice, reloadVoiceModels (plus the matching admin
template block — that was still present and referenced these)
- user voice block: voiceStatus, voiceStatusLoading, availableVoices,
voiceTtsVoice, voiceTtsSpeed, voiceSpeechStyle, savingVoice,
voiceSaved, voiceTabLoaded, the whole voice library
(voiceLibrary, voiceLibraryLoading/Error/Filter/Expanded,
installingVoiceIds, uninstallingVoiceIds, filteredVoiceLibrary,
formatVoiceSize, loadVoiceLibrary, refreshInstalledVoices,
installLibraryVoice, uninstallLibraryVoice, loadVoiceTab,
voicePreviewing, previewVoice, saveVoiceSettings)
- observations / consolidation: consolidating, clearingObs,
observations, observationsExpanded/Loading/Loaded,
toggleObservations, onToggleCloseout, runConsolidate,
clearObservations
- assistant / model management: assistantName, defaultModel,
backgroundModel, installedModels, defaultChatModel, OllamaModel
interface, ollamaModels, pullModelName, pullProgress, pulling,
deletingModel, formatBytes, loadOllamaModels, pullModel,
deleteModel, saveAssistant, saving, saved
- api/client imports: getVoiceStatus, getVoiceList, getVoiceLibrary,
installVoice, uninstallVoice, synthesiseSpeech, consolidateProfile,
clearProfileObservations, listProfileObservations,
VoiceStatusResult, VoiceEntry, VoiceLibraryEntry,
ProfileObservationEntry
Surviving: journalConfig + locations editor + temp_unit selector
(Profile→Locations section still uses these to manage home/work
addresses for any future feature; the journal-specific prep/closeout
fields on the same object are dead but harmless as object members).
Co-Authored-By: Claude Opus 4.7 (1M context)
---
frontend/src/views/SettingsView.vue | 492 +---------------------------
1 file changed, 3 insertions(+), 489 deletions(-)
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 9f1a65c..b700b0b 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -3,8 +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, getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice, uninstallVoice, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceLibraryEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client";
-import { usePushStore } from "@/stores/push";
+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 type { User } from "@/types/auth";
import PaginationBar from "@/components/PaginationBar.vue";
import TagInput from "@/components/TagInput.vue";
@@ -12,9 +11,6 @@ import TagInput from "@/components/TagInput.vue";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
-const pushStore = usePushStore();
-const assistantName = ref("");
-const defaultModel = ref("");
const userTimezone = ref("");
const savingTimezone = ref(false);
const timezoneSaved = ref(false);
@@ -56,16 +52,6 @@ async function saveTimezone() {
savingTimezone.value = false;
}
}
-const backgroundModel = ref("");
-const installedModels = ref([]);
-const defaultChatModel = ref("");
-
-interface OllamaModel { name: string; size: number; loaded: boolean; modified_at: string; }
-const ollamaModels = ref([]);
-const pullModelName = ref("");
-const pullProgress = ref<{ status: string; pct: number | null } | null>(null);
-const pulling = ref(false);
-const deletingModel = ref(null);
const newEmail = ref("");
const emailPassword = ref("");
const changingEmail = ref(false);
@@ -74,8 +60,6 @@ const newPassword = ref("");
const confirmNewPassword = ref("");
const changingPassword = ref(false);
const invalidatingSessions = ref(false);
-const saving = ref(false);
-const saved = ref(false);
const exporting = ref(false);
const restoring = ref(false);
const appVersion = ref('dev');
@@ -296,47 +280,12 @@ async function removeMemberFromGroup(groupId: number, userId: number) {
}
-// Chat retention
-const chatRetentionDays = ref(90);
-const savingRetention = ref(false);
-
-async function saveRetention() {
- savingRetention.value = true;
- try {
- await apiPut("/api/settings", { chat_retention_days: String(chatRetentionDays.value) });
- } finally {
- savingRetention.value = false;
- }
-}
-
-// Notification preferences
+// Notification preferences (email notifications only — push surface removed)
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
-// VAPID key reset (admin)
-const vapidResetting = ref(false);
-const vapidResetMsg = ref("");
-const vapidResetError = ref(false);
-
-async function resetVapidKeys() {
- if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
- vapidResetting.value = true;
- vapidResetMsg.value = "";
- vapidResetError.value = false;
- try {
- await apiPost("/api/push/reset-vapid", {});
- vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
- pushStore.checkSubscription();
- } catch {
- vapidResetError.value = true;
- vapidResetMsg.value = "Reset failed — check server logs.";
- } finally {
- vapidResetting.value = false;
- }
-}
-
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
@@ -369,59 +318,6 @@ const baseUrl = ref("");
const savingBaseUrl = ref(false);
const baseUrlSaved = ref(false);
-// Voice config (admin only)
-const adminVoiceEnabled = ref(false);
-const adminVoiceSttModel = ref("base.en");
-const savingAdminVoice = ref(false);
-const adminVoiceSaved = ref(false);
-const voiceLoadingModels = ref(false);
-
-async function saveAdminVoice() {
- savingAdminVoice.value = true;
- adminVoiceSaved.value = false;
- try {
- await apiPut("/api/admin/voice", {
- voice_enabled: adminVoiceEnabled.value,
- voice_stt_model: adminVoiceSttModel.value,
- });
- adminVoiceSaved.value = true;
- setTimeout(() => { adminVoiceSaved.value = false; }, 2000);
- voiceTabLoaded.value = false;
- // Always update global voice state so mic buttons appear/disappear immediately
- await store.checkVoiceStatus();
- if (adminVoiceEnabled.value) {
- await reloadVoiceModels();
- }
- } finally {
- savingAdminVoice.value = false;
- }
-}
-
-async function reloadVoiceModels() {
- voiceLoadingModels.value = true;
- try {
- await apiPost("/api/admin/voice/reload", {});
- // Poll /api/voice/status until both STT and TTS are ready (max 3 min)
- const deadline = Date.now() + 3 * 60 * 1000;
- while (Date.now() < deadline) {
- await new Promise((r) => setTimeout(r, 2500));
- try {
- const status = await apiGet<{ enabled: boolean; stt: boolean; tts: boolean }>("/api/voice/status");
- if (status.stt && status.tts) {
- voiceStatus.value = status;
- voiceTabLoaded.value = true;
- // Propagate to global store so mic buttons appear everywhere
- store.checkVoiceStatus();
- break;
- }
- } catch { /* keep polling */ }
- }
- } catch {
- toastStore.show("Failed to trigger model reload", "error");
- } finally {
- voiceLoadingModels.value = false;
- }
-}
// Search test (SearXNG)
const searxngConfigured = ref(false);
@@ -431,156 +327,6 @@ const searchResults = ref<{ url: string; title: string; snippet: string }[]>([])
const searchLoading = ref(false);
const searchError = ref("");
-// Voice settings. Default voice id matches the bundled piper voice
-// (en_US-amy-medium); legacy kokoro IDs in user settings were cleared
-// by alembic migration 0047, so the first read after upgrade returns "".
-const voiceStatus = ref(null);
-const voiceStatusLoading = ref(false);
-const availableVoices = ref([]);
-const voiceTtsVoice = ref("en_US-amy-medium");
-const voiceTtsSpeed = ref(1.0);
-const voiceSpeechStyle = ref("conversational");
-const savingVoice = ref(false);
-const voiceSaved = ref(false);
-const voiceTabLoaded = ref(false);
-
-// Voice library (admin-only): browse + install piper voices from HF
-const voiceLibrary = ref([]);
-const voiceLibraryLoading = ref(false);
-const voiceLibraryError = ref(null);
-const voiceLibraryFilter = ref(""); // free-text language/name filter
-const voiceLibraryExpanded = ref(false);
-const installingVoiceIds = ref>(new Set());
-const uninstallingVoiceIds = ref>(new Set());
-
-const filteredVoiceLibrary = computed(() => {
- const q = voiceLibraryFilter.value.trim().toLowerCase();
- if (!q) return voiceLibrary.value;
- return voiceLibrary.value.filter(v =>
- v.id.toLowerCase().includes(q)
- || v.language_code.toLowerCase().includes(q)
- || v.language_name.toLowerCase().includes(q)
- || v.country.toLowerCase().includes(q)
- || v.name.toLowerCase().includes(q)
- );
-});
-
-function formatVoiceSize(bytes: number): string {
- if (!bytes) return "—";
- const mb = bytes / (1024 * 1024);
- return mb >= 10 ? `${Math.round(mb)} MB` : `${mb.toFixed(1)} MB`;
-}
-
-async function loadVoiceLibrary(refresh = false) {
- if (!authStore.isAdmin) return;
- voiceLibraryLoading.value = true;
- voiceLibraryError.value = null;
- try {
- const res = await getVoiceLibrary(refresh);
- voiceLibrary.value = res.voices;
- } catch (e) {
- voiceLibraryError.value = e instanceof Error ? e.message : "Failed to load voice library";
- } finally {
- voiceLibraryLoading.value = false;
- }
-}
-
-async function refreshInstalledVoices() {
- // Reload the active voice list (used after install/uninstall) so the
- // user's voice picker sees the change without a full page reload.
- try {
- availableVoices.value = await getVoiceList();
- } catch {
- /* voice feature may be disabled — ignore */
- }
-}
-
-async function installLibraryVoice(voiceId: string) {
- if (installingVoiceIds.value.has(voiceId)) return;
- installingVoiceIds.value.add(voiceId);
- try {
- await installVoice(voiceId);
- toastStore.show(`Installed ${voiceId}`, "success");
- await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Install failed";
- toastStore.show(`Failed to install ${voiceId}: ${msg}`, "error");
- } finally {
- installingVoiceIds.value.delete(voiceId);
- }
-}
-
-async function uninstallLibraryVoice(voiceId: string) {
- if (uninstallingVoiceIds.value.has(voiceId)) return;
- uninstallingVoiceIds.value.add(voiceId);
- try {
- await uninstallVoice(voiceId);
- toastStore.show(`Removed ${voiceId}`, "success");
- await Promise.all([loadVoiceLibrary(false), refreshInstalledVoices()]);
- } catch (e) {
- const msg = e instanceof Error ? e.message : "Uninstall failed";
- toastStore.show(`Failed to remove ${voiceId}: ${msg}`, "error");
- } finally {
- uninstallingVoiceIds.value.delete(voiceId);
- }
-}
-
-async function loadVoiceTab() {
- if (voiceTabLoaded.value) return;
- voiceTabLoaded.value = true;
- voiceStatusLoading.value = true;
- try {
- voiceStatus.value = await getVoiceStatus();
- if (voiceStatus.value.tts) {
- availableVoices.value = await getVoiceList();
- }
- } catch {
- // Voice feature may be disabled
- } finally {
- voiceStatusLoading.value = false;
- }
-}
-
-const voicePreviewing = ref(false);
-
-async function previewVoice() {
- if (voicePreviewing.value) return;
- voicePreviewing.value = true;
- // Create AudioContext synchronously in user-gesture context to bypass autoplay policy
- const ctx = new AudioContext();
- try {
- const blob = await synthesiseSpeech(
- "Hello! I'm your assistant. How can I help you today?",
- voiceTtsVoice.value,
- voiceTtsSpeed.value,
- );
- const buf = await ctx.decodeAudioData(await blob.arrayBuffer());
- const src = ctx.createBufferSource();
- src.buffer = buf;
- src.connect(ctx.destination);
- src.start(0);
- } catch {
- toastStore.show("Preview failed — TTS may not be ready", "error");
- } finally {
- voicePreviewing.value = false;
- }
-}
-
-async function saveVoiceSettings() {
- savingVoice.value = true;
- voiceSaved.value = false;
- try {
- await apiPut("/api/settings", {
- voice_tts_voice: voiceTtsVoice.value,
- voice_tts_speed: String(voiceTtsSpeed.value),
- voice_speech_style: voiceSpeechStyle.value,
- });
- voiceSaved.value = true;
- setTimeout(() => { voiceSaved.value = false; }, 2000);
- } finally {
- savingVoice.value = false;
- }
-}
// ── Profile ──────────────────────────────────────────────────────────────────
const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -592,28 +338,6 @@ const profile = ref({
})
const profileSaving = ref(false)
const profileSaved = ref(false)
-const consolidating = ref(false)
-const clearingObs = ref(false)
-const observations = ref([])
-const observationsExpanded = ref(false)
-const observationsLoading = ref(false)
-const observationsLoaded = ref(false)
-
-async function toggleObservations() {
- observationsExpanded.value = !observationsExpanded.value
- if (observationsExpanded.value && !observationsLoaded.value) {
- observationsLoading.value = true
- try {
- const res = await listProfileObservations()
- observations.value = res.observations
- observationsLoaded.value = true
- } catch {
- toastStore.show('Failed to load observations', 'error')
- } finally {
- observationsLoading.value = false
- }
- }
-}
async function loadProfile() {
try { profile.value = await getProfile() } catch { /* non-critical */ }
@@ -647,27 +371,6 @@ function toggleProfileWorkDay(day: string) {
profile.value.work_schedule = { ...profile.value.work_schedule, days }
}
-async function onToggleCloseout(enabled: boolean) {
- journalConfig.value.closeout_enabled = enabled
- try {
- await saveJournalConfig(journalConfig.value)
- toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled')
- } catch {
- journalConfig.value.closeout_enabled = !enabled // revert UI on failure
- toastStore.show('Failed to update closeout setting', 'error')
- }
-}
-
-async function runConsolidate() {
- consolidating.value = true
- try {
- const result = await consolidateProfile()
- profile.value.learned_summary = result.learned_summary
- toastStore.show('Profile updated from observations')
- } catch { toastStore.show('Consolidation failed', 'error') }
- finally { consolidating.value = false }
-}
-
function emptyTagsFetch(): Promise { return Promise.resolve([]) }
// ── Journal config (locations, temp unit, prep schedule) ────────────────────
@@ -759,51 +462,19 @@ async function saveJournalCfg() {
finally { journalConfigSaving.value = false }
}
-async function clearObservations() {
- if (!confirm('Clear all learned observations and the generated summary? This cannot be undone.')) return
- clearingObs.value = true
- try {
- await clearProfileObservations()
- profile.value.learned_summary = ''
- profile.value.observations_count = 0
- profile.value.observations_updated_at = null
- observations.value = []
- observationsLoaded.value = false
- toastStore.show('Learned data cleared')
- } catch { toastStore.show('Failed to clear observations', 'error') }
- finally { clearingObs.value = false }
-}
-
onMounted(async () => {
try {
const v = await apiGet<{ version: string }>('/api/version')
appVersion.value = v.version
} catch { /* non-critical */ }
await store.fetchSettings();
- pushStore.checkSubscription();
- assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
- // Load installed models and configured defaults
- try {
- const modelsData = await apiGet<{ models: string[]; default_chat_model: string }>("/api/settings/models");
- installedModels.value = modelsData.models;
- defaultChatModel.value = modelsData.default_chat_model;
- } catch {
- // Ollama unreachable — dropdowns will be empty
- }
- await loadOllamaModels();
-
// Load notification preferences from user settings
const allSettings = await apiGet>("/api/settings");
- defaultModel.value = allSettings.default_model ?? "";
- backgroundModel.value = allSettings.background_model ?? "";
userTimezone.value = allSettings.user_timezone ?? "";
// Default true if unset; explicit "false" disables auto-consolidation.
autoConsolidateTasks.value = (allSettings.auto_consolidate_tasks ?? "true") !== "false";
- chatRetentionDays.value = allSettings.chat_retention_days !== undefined
- ? Number(allSettings.chat_retention_days)
- : 90;
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
@@ -814,16 +485,9 @@ onMounted(async () => {
// Load user profile
await loadProfile();
- // Load journal config (locations, temp unit, prep schedule)
+ // Load journal config (locations, temp unit; prep/closeout UI removed in Phase 7)
await loadJournalConfig();
- // Load voice settings. Voice blending was removed with the kokoro→piper
- // migration (piper has no blend equivalent); legacy voice_tts_blend rows
- // were dropped in migration 0047.
- if (allSettings.voice_tts_voice) voiceTtsVoice.value = allSettings.voice_tts_voice;
- if (allSettings.voice_tts_speed) voiceTtsSpeed.value = parseFloat(allSettings.voice_tts_speed);
- if (allSettings.voice_speech_style) voiceSpeechStyle.value = allSettings.voice_speech_style;
-
// Load CalDAV settings
try {
const caldavConfig = await apiGet>("/api/settings/caldav");
@@ -855,13 +519,6 @@ onMounted(async () => {
} catch {
// base URL not configured yet
}
- try {
- const vc = await apiGet>("/api/admin/voice");
- adminVoiceEnabled.value = vc.voice_enabled === "true";
- adminVoiceSttModel.value = vc.voice_stt_model ?? "base.en";
- } catch {
- // voice not configured yet
- }
}
_loadTabContent(activeTab.value);
});
@@ -928,105 +585,6 @@ async function changePassword() {
}
}
-function formatBytes(bytes: number): string {
- if (bytes === 0) return "—";
- const gb = bytes / 1e9;
- if (gb >= 1) return `${gb.toFixed(1)} GB`;
- return `${(bytes / 1e6).toFixed(0)} MB`;
-}
-
-async function loadOllamaModels() {
- try {
- const data = await apiGet<{ models: OllamaModel[] }>("/api/chat/models");
- ollamaModels.value = data.models ?? [];
- installedModels.value = ollamaModels.value.map((m) => m.name);
- } catch {
- // Ollama unreachable
- }
-}
-
-async function pullModel() {
- const name = pullModelName.value.trim();
- if (!name || pulling.value) return;
- pulling.value = true;
- pullProgress.value = { status: "Connecting…", pct: null };
- try {
- const resp = await fetch("/api/chat/models/pull", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ model: name }),
- credentials: "include",
- });
- if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
- const reader = resp.body.getReader();
- const decoder = new TextDecoder();
- let buf = "";
- while (true) {
- const { done, value } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- const lines = buf.split("\n");
- buf = lines.pop() ?? "";
- for (const line of lines) {
- const trimmed = line.replace(/^data: /, "").trim();
- if (!trimmed) continue;
- try {
- const msg = JSON.parse(trimmed);
- if (msg.error) throw new Error(msg.error);
- if (msg.status === "success") {
- pullProgress.value = { status: "Complete", pct: 100 };
- pullModelName.value = "";
- } else if (msg.total && msg.completed) {
- const pct = Math.round((msg.completed / msg.total) * 100);
- pullProgress.value = { status: msg.status || "Downloading…", pct };
- } else {
- pullProgress.value = { status: msg.status || "Working…", pct: null };
- }
- } catch (parseErr: any) {
- if (parseErr.message !== "JSON parse error") throw parseErr;
- }
- }
- }
- await loadOllamaModels();
- toastStore.show(`Model ${name} ready`);
- } catch (err: any) {
- toastStore.show(`Pull failed: ${err.message}`, "error");
- } finally {
- pulling.value = false;
- setTimeout(() => { pullProgress.value = null; }, 3000);
- }
-}
-
-async function deleteModel(name: string) {
- deletingModel.value = name;
- try {
- await apiPost("/api/chat/models/delete", { model: name });
- await loadOllamaModels();
- if (defaultModel.value === name) defaultModel.value = "";
- toastStore.show(`Deleted ${name}`);
- } catch {
- toastStore.show(`Failed to delete ${name}`, "error");
- } finally {
- deletingModel.value = null;
- }
-}
-
-async function saveAssistant() {
- saving.value = true;
- saved.value = false;
- try {
- await store.updateSettings({
- assistant_name: assistantName.value.trim() || "Fable",
- default_model: defaultModel.value,
- background_model: backgroundModel.value,
- });
- saved.value = true;
- setTimeout(() => (saved.value = false), 2000);
- } finally {
- saving.value = false;
- }
-}
-
async function exportData(scope: "user" | "full") {
exporting.value = true;
try {
@@ -2250,50 +1808,6 @@ function formatUserDate(iso: string): string {
-
- Voice (Speech-to-Speech)
-
- Enable self-hosted voice using faster-whisper (STT) and Kokoro (TTS).
- Save settings then click "Reload models" to apply without restarting the server.
- Install dependencies first: pip install faster-whisper kokoro soundfile
-
-
-
-
- Enable Voice
-
-
Shows the PTT button and voice settings to all users.
-
-
-
STT Model
-
- tiny.en — fastest, lowest accuracy (~75 MB)
- base.en — balanced (recommended, ~145 MB)
- small.en — better accuracy (~465 MB)
- medium.en — best accuracy (~1.5 GB)
-
-
Larger models are more accurate but use more RAM and take longer to load.
-
-
-
- {{ savingAdminVoice ? 'Saving…' : adminVoiceSaved ? 'Saved ✓' : 'Save' }}
-
- Reload models
-
- Loading models… this may take a minute.
-
-
-
-
-
-
-
From 8bec68abc07ce6024507aeee7301db15ab1cb09e Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 15:38:06 -0400
Subject: [PATCH 066/118] fix(ui): restore missing closing the
Notifications tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The earlier sed-delete of the Push/ChatHistory/About sections from
the Notifications tab also clipped the tab's outer . Vue's
type-checker happily accepted the unbalanced structure (templates
type-check on script bindings, not tag pairing) but Vite's Vue
compiler failed at build time:
Element is missing end tag.
file: src/views/SettingsView.vue:1062:5
(The reported line 1062 was the outermost .settings-content div —
Vue's parser blames the outermost open tag when an inner sibling
goes unclosed.)
Confirmed by counting: 115 open before — and now
116/116 after restoring the closing tag between the Email Notifications
section and the Integrations tab.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
frontend/src/views/SettingsView.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index b700b0b..c659d83 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -1419,6 +1419,7 @@ function formatUserDate(iso: string): string {
+
From 91bafb641fe594b27f22c29f502f14b3295ae3b8 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 17:47:18 -0400
Subject: [PATCH 067/118] =?UTF-8?q?refactor:=20Phase=208=20=E2=80=94=20bac?=
=?UTF-8?q?kend=20deletion=20(chat=20/=20voice=20/=20push=20/=20journal=20?=
=?UTF-8?q?/=20curator)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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 //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)
---
.remember/tmp/save-session.pid | 2 +-
Dockerfile | 50 -
bench-chat.md | 12 +
bench-curator-no-think.md | 12 +
bench-curator-think-on.md | 10 +
bench-gpuhost-chat-cpu.md | 12 +
bench-gpuhost-chat-gpu.md | 12 +
bench-gpuhost-curator-70b.md | 9 +
bench-gpuhost-curator-no-think.md | 12 +
bench-gpuhost-curator-think-on.md | 10 +
fable-mcp/.env.example | 2 -
fable-mcp/fable_mcp/__init__.py | 0
fable-mcp/fable_mcp/client.py | 126 ---
fable-mcp/fable_mcp/server.py | 757 ---------------
fable-mcp/fable_mcp/tools/__init__.py | 0
fable-mcp/fable_mcp/tools/admin.py | 20 -
fable-mcp/fable_mcp/tools/chat.py | 95 --
fable-mcp/fable_mcp/tools/journal.py | 96 --
fable-mcp/fable_mcp/tools/milestones.py | 53 --
fable-mcp/fable_mcp/tools/notes.py | 72 --
fable-mcp/fable_mcp/tools/projects.py | 59 --
fable-mcp/fable_mcp/tools/search.py | 30 -
fable-mcp/fable_mcp/tools/tasks.py | 93 --
fable-mcp/pyproject.toml | 24 -
fable-mcp/tests/__init__.py | 0
fable-mcp/tests/conftest.py | 12 -
fable-mcp/tests/test_client.py | 142 ---
fable-mcp/tests/test_tools_chat.py | 58 --
fable-mcp/tests/test_tools_milestones.py | 35 -
fable-mcp/tests/test_tools_notes.py | 56 --
fable-mcp/tests/test_tools_projects.py | 42 -
fable-mcp/tests/test_tools_tasks.py | 54 --
fable-mcp/uv.lock | 771 ---------------
frontend/src/stores/push.ts | 109 ---
frontend/src/stores/settings.ts | 36 +-
frontend/src/views/SettingsView.vue | 150 +--
pyproject.toml | 11 +-
src/fabledassistant/app.py | 194 +---
src/fabledassistant/models/__init__.py | 13 -
src/fabledassistant/models/conversation.py | 106 ---
.../models/generation_tool_log.py | 75 --
src/fabledassistant/models/moment.py | 130 ---
.../models/pending_curator_action.py | 81 --
.../models/push_subscription.py | 20 -
src/fabledassistant/models/weather_cache.py | 38 -
src/fabledassistant/routes/admin.py | 39 -
src/fabledassistant/routes/chat.py | 548 -----------
src/fabledassistant/routes/fable_mcp_dist.py | 42 -
src/fabledassistant/routes/journal.py | 519 -----------
src/fabledassistant/routes/notes.py | 142 +--
src/fabledassistant/routes/profile.py | 28 -
src/fabledassistant/routes/push.py | 54 --
src/fabledassistant/routes/quick_capture.py | 111 ---
src/fabledassistant/routes/settings.py | 110 +--
src/fabledassistant/routes/tasks.py | 20 -
src/fabledassistant/routes/voice.py | 258 -----
.../services/article_fetcher.py | 53 --
src/fabledassistant/services/assist.py | 92 --
src/fabledassistant/services/auth.py | 9 -
src/fabledassistant/services/backup.py | 141 +--
src/fabledassistant/services/chat.py | 302 ------
src/fabledassistant/services/consolidation.py | 206 ----
src/fabledassistant/services/curator.py | 465 ---------
.../services/curator_scheduler.py | 197 ----
src/fabledassistant/services/diagnostics.py | 7 +-
.../services/event_scheduler.py | 62 +-
.../services/generation_buffer.py | 138 ---
.../services/generation_log.py | 111 ---
.../services/generation_task.py | 653 -------------
.../services/journal_closeout.py | 133 ---
.../services/journal_pipeline.py | 193 ----
src/fabledassistant/services/journal_prep.py | 615 ------------
.../services/journal_scheduler.py | 210 -----
.../services/journal_search.py | 204 ----
src/fabledassistant/services/llm.py | 881 ------------------
src/fabledassistant/services/moments.py | 185 ----
src/fabledassistant/services/notes.py | 34 -
src/fabledassistant/services/notifications.py | 8 +-
.../services/pending_actions.py | 167 ----
src/fabledassistant/services/projects.py | 85 --
src/fabledassistant/services/push.py | 238 -----
src/fabledassistant/services/research.py | 583 ------------
src/fabledassistant/services/stt.py | 90 --
.../services/tag_suggestions.py | 96 --
.../services/tools/__init__.py | 32 -
.../services/tools/_helpers.py | 164 ----
.../services/tools/_registry.py | 339 -------
src/fabledassistant/services/tools/article.py | 35 -
.../services/tools/calendar.py | 503 ----------
.../services/tools/entities.py | 231 -----
src/fabledassistant/services/tools/journal.py | 385 --------
src/fabledassistant/services/tools/notes.py | 455 ---------
src/fabledassistant/services/tools/profile.py | 77 --
.../services/tools/projects.py | 250 -----
src/fabledassistant/services/tools/rag.py | 34 -
src/fabledassistant/services/tools/tasks.py | 127 ---
src/fabledassistant/services/tools/utility.py | 34 -
src/fabledassistant/services/tools/weather.py | 122 ---
src/fabledassistant/services/tools/web.py | 195 ----
src/fabledassistant/services/tts.py | 288 ------
src/fabledassistant/services/user_profile.py | 179 +---
src/fabledassistant/services/voice_config.py | 40 -
src/fabledassistant/services/voice_library.py | 251 -----
src/fabledassistant/services/weather.py | 408 --------
src/fabledassistant/services/wikipedia.py | 113 ---
tests/test_calendar_tool_tz.py | 795 ----------------
tests/test_consolidation.py | 317 -------
tests/test_generation_log.py | 180 ----
tests/test_journal_closeout.py | 249 -----
tests/test_journal_message_count.py | 60 --
tests/test_journal_prep_filtering.py | 195 ----
tests/test_journal_prep_hardening.py | 123 ---
tests/test_journal_search.py | 23 -
tests/test_lookup_tool.py | 111 ---
tests/test_notes_consolidation_trigger.py | 118 ---
tests/test_record_moment_guards.py | 181 ----
tests/test_research_pipeline.py | 231 -----
tests/test_tool_use_fixes.py | 145 ---
tests/test_tools_notes.py | 200 ----
tests/test_tools_tasks.py | 58 --
tests/test_voice_library.py | 156 ----
tests/test_weather_service.py | 166 ----
tests/test_wikipedia.py | 173 ----
123 files changed, 161 insertions(+), 19312 deletions(-)
create mode 100644 bench-chat.md
create mode 100644 bench-curator-no-think.md
create mode 100644 bench-curator-think-on.md
create mode 100644 bench-gpuhost-chat-cpu.md
create mode 100644 bench-gpuhost-chat-gpu.md
create mode 100644 bench-gpuhost-curator-70b.md
create mode 100644 bench-gpuhost-curator-no-think.md
create mode 100644 bench-gpuhost-curator-think-on.md
delete mode 100644 fable-mcp/.env.example
delete mode 100644 fable-mcp/fable_mcp/__init__.py
delete mode 100644 fable-mcp/fable_mcp/client.py
delete mode 100644 fable-mcp/fable_mcp/server.py
delete mode 100644 fable-mcp/fable_mcp/tools/__init__.py
delete mode 100644 fable-mcp/fable_mcp/tools/admin.py
delete mode 100644 fable-mcp/fable_mcp/tools/chat.py
delete mode 100644 fable-mcp/fable_mcp/tools/journal.py
delete mode 100644 fable-mcp/fable_mcp/tools/milestones.py
delete mode 100644 fable-mcp/fable_mcp/tools/notes.py
delete mode 100644 fable-mcp/fable_mcp/tools/projects.py
delete mode 100644 fable-mcp/fable_mcp/tools/search.py
delete mode 100644 fable-mcp/fable_mcp/tools/tasks.py
delete mode 100644 fable-mcp/pyproject.toml
delete mode 100644 fable-mcp/tests/__init__.py
delete mode 100644 fable-mcp/tests/conftest.py
delete mode 100644 fable-mcp/tests/test_client.py
delete mode 100644 fable-mcp/tests/test_tools_chat.py
delete mode 100644 fable-mcp/tests/test_tools_milestones.py
delete mode 100644 fable-mcp/tests/test_tools_notes.py
delete mode 100644 fable-mcp/tests/test_tools_projects.py
delete mode 100644 fable-mcp/tests/test_tools_tasks.py
delete mode 100644 fable-mcp/uv.lock
delete mode 100644 frontend/src/stores/push.ts
delete mode 100644 src/fabledassistant/models/conversation.py
delete mode 100644 src/fabledassistant/models/generation_tool_log.py
delete mode 100644 src/fabledassistant/models/moment.py
delete mode 100644 src/fabledassistant/models/pending_curator_action.py
delete mode 100644 src/fabledassistant/models/push_subscription.py
delete mode 100644 src/fabledassistant/models/weather_cache.py
delete mode 100644 src/fabledassistant/routes/chat.py
delete mode 100644 src/fabledassistant/routes/fable_mcp_dist.py
delete mode 100644 src/fabledassistant/routes/journal.py
delete mode 100644 src/fabledassistant/routes/push.py
delete mode 100644 src/fabledassistant/routes/quick_capture.py
delete mode 100644 src/fabledassistant/routes/voice.py
delete mode 100644 src/fabledassistant/services/article_fetcher.py
delete mode 100644 src/fabledassistant/services/assist.py
delete mode 100644 src/fabledassistant/services/chat.py
delete mode 100644 src/fabledassistant/services/consolidation.py
delete mode 100644 src/fabledassistant/services/curator.py
delete mode 100644 src/fabledassistant/services/curator_scheduler.py
delete mode 100644 src/fabledassistant/services/generation_buffer.py
delete mode 100644 src/fabledassistant/services/generation_log.py
delete mode 100644 src/fabledassistant/services/generation_task.py
delete mode 100644 src/fabledassistant/services/journal_closeout.py
delete mode 100644 src/fabledassistant/services/journal_pipeline.py
delete mode 100644 src/fabledassistant/services/journal_prep.py
delete mode 100644 src/fabledassistant/services/journal_scheduler.py
delete mode 100644 src/fabledassistant/services/journal_search.py
delete mode 100644 src/fabledassistant/services/llm.py
delete mode 100644 src/fabledassistant/services/moments.py
delete mode 100644 src/fabledassistant/services/pending_actions.py
delete mode 100644 src/fabledassistant/services/push.py
delete mode 100644 src/fabledassistant/services/research.py
delete mode 100644 src/fabledassistant/services/stt.py
delete mode 100644 src/fabledassistant/services/tag_suggestions.py
delete mode 100644 src/fabledassistant/services/tools/__init__.py
delete mode 100644 src/fabledassistant/services/tools/_helpers.py
delete mode 100644 src/fabledassistant/services/tools/_registry.py
delete mode 100644 src/fabledassistant/services/tools/article.py
delete mode 100644 src/fabledassistant/services/tools/calendar.py
delete mode 100644 src/fabledassistant/services/tools/entities.py
delete mode 100644 src/fabledassistant/services/tools/journal.py
delete mode 100644 src/fabledassistant/services/tools/notes.py
delete mode 100644 src/fabledassistant/services/tools/profile.py
delete mode 100644 src/fabledassistant/services/tools/projects.py
delete mode 100644 src/fabledassistant/services/tools/rag.py
delete mode 100644 src/fabledassistant/services/tools/tasks.py
delete mode 100644 src/fabledassistant/services/tools/utility.py
delete mode 100644 src/fabledassistant/services/tools/weather.py
delete mode 100644 src/fabledassistant/services/tools/web.py
delete mode 100644 src/fabledassistant/services/tts.py
delete mode 100644 src/fabledassistant/services/voice_config.py
delete mode 100644 src/fabledassistant/services/voice_library.py
delete mode 100644 src/fabledassistant/services/weather.py
delete mode 100644 src/fabledassistant/services/wikipedia.py
delete mode 100644 tests/test_calendar_tool_tz.py
delete mode 100644 tests/test_consolidation.py
delete mode 100644 tests/test_generation_log.py
delete mode 100644 tests/test_journal_closeout.py
delete mode 100644 tests/test_journal_message_count.py
delete mode 100644 tests/test_journal_prep_filtering.py
delete mode 100644 tests/test_journal_prep_hardening.py
delete mode 100644 tests/test_journal_search.py
delete mode 100644 tests/test_lookup_tool.py
delete mode 100644 tests/test_notes_consolidation_trigger.py
delete mode 100644 tests/test_record_moment_guards.py
delete mode 100644 tests/test_research_pipeline.py
delete mode 100644 tests/test_tool_use_fixes.py
delete mode 100644 tests/test_tools_notes.py
delete mode 100644 tests/test_tools_tasks.py
delete mode 100644 tests/test_voice_library.py
delete mode 100644 tests/test_weather_service.py
delete mode 100644 tests/test_wikipedia.py
diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid
index 9bc105c..423d3c2 100644
--- a/.remember/tmp/save-session.pid
+++ b/.remember/tmp/save-session.pid
@@ -1 +1 @@
-3158517
+603480
diff --git a/Dockerfile b/Dockerfile
index ea6eef5..37fcb07 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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/
diff --git a/bench-chat.md b/bench-chat.md
new file mode 100644
index 0000000..d8ba48b
--- /dev/null
+++ b/bench-chat.md
@@ -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 |
diff --git a/bench-curator-no-think.md b/bench-curator-no-think.md
new file mode 100644
index 0000000..c024b9f
--- /dev/null
+++ b/bench-curator-no-think.md
@@ -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 |
diff --git a/bench-curator-think-on.md b/bench-curator-think-on.md
new file mode 100644
index 0000000..b56e201
--- /dev/null
+++ b/bench-curator-think-on.md
@@ -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 |
diff --git a/bench-gpuhost-chat-cpu.md b/bench-gpuhost-chat-cpu.md
new file mode 100644
index 0000000..d9198fb
--- /dev/null
+++ b/bench-gpuhost-chat-cpu.md
@@ -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 |
diff --git a/bench-gpuhost-chat-gpu.md b/bench-gpuhost-chat-gpu.md
new file mode 100644
index 0000000..c2442f4
--- /dev/null
+++ b/bench-gpuhost-chat-gpu.md
@@ -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 |
diff --git a/bench-gpuhost-curator-70b.md b/bench-gpuhost-curator-70b.md
new file mode 100644
index 0000000..e5b569d
--- /dev/null
+++ b/bench-gpuhost-curator-70b.md
@@ -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 |
diff --git a/bench-gpuhost-curator-no-think.md b/bench-gpuhost-curator-no-think.md
new file mode 100644
index 0000000..81362b5
--- /dev/null
+++ b/bench-gpuhost-curator-no-think.md
@@ -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 |
diff --git a/bench-gpuhost-curator-think-on.md b/bench-gpuhost-curator-think-on.md
new file mode 100644
index 0000000..af3d5bf
--- /dev/null
+++ b/bench-gpuhost-curator-think-on.md
@@ -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 |
diff --git a/fable-mcp/.env.example b/fable-mcp/.env.example
deleted file mode 100644
index 767f8d0..0000000
--- a/fable-mcp/.env.example
+++ /dev/null
@@ -1,2 +0,0 @@
-FABLE_URL=http://localhost:5000
-FABLE_API_KEY=fmcp_your_key_here
diff --git a/fable-mcp/fable_mcp/__init__.py b/fable-mcp/fable_mcp/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/fable-mcp/fable_mcp/client.py b/fable-mcp/fable_mcp/client.py
deleted file mode 100644
index 80a562d..0000000
--- a/fable-mcp/fable_mcp/client.py
+++ /dev/null
@@ -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
diff --git a/fable-mcp/fable_mcp/server.py b/fable-mcp/fable_mcp/server.py
deleted file mode 100644
index 2b4d3a1..0000000
--- a/fable-mcp/fable_mcp/server.py
+++ /dev/null
@@ -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()
diff --git a/fable-mcp/fable_mcp/tools/__init__.py b/fable-mcp/fable_mcp/tools/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/fable-mcp/fable_mcp/tools/admin.py b/fable-mcp/fable_mcp/tools/admin.py
deleted file mode 100644
index 60111c3..0000000
--- a/fable-mcp/fable_mcp/tools/admin.py
+++ /dev/null
@@ -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)
diff --git a/fable-mcp/fable_mcp/tools/chat.py b/fable-mcp/fable_mcp/tools/chat.py
deleted file mode 100644
index 119803b..0000000
--- a/fable-mcp/fable_mcp/tools/chat.py
+++ /dev/null
@@ -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:
- event: # chunk | done | tool_call | status | ...
- data:
-
- 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,
- }
diff --git a/fable-mcp/fable_mcp/tools/journal.py b/fable-mcp/fable_mcp/tools/journal.py
deleted file mode 100644
index 32b3544..0000000
--- a/fable-mcp/fable_mcp/tools/journal.py
+++ /dev/null
@@ -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}")
diff --git a/fable-mcp/fable_mcp/tools/milestones.py b/fable-mcp/fable_mcp/tools/milestones.py
deleted file mode 100644
index 3dbdc6a..0000000
--- a/fable-mcp/fable_mcp/tools/milestones.py
+++ /dev/null
@@ -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
- )
diff --git a/fable-mcp/fable_mcp/tools/notes.py b/fable-mcp/fable_mcp/tools/notes.py
deleted file mode 100644
index 25cadcd..0000000
--- a/fable-mcp/fable_mcp/tools/notes.py
+++ /dev/null
@@ -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}")
diff --git a/fable-mcp/fable_mcp/tools/projects.py b/fable-mcp/fable_mcp/tools/projects.py
deleted file mode 100644
index bcbbef2..0000000
--- a/fable-mcp/fable_mcp/tools/projects.py
+++ /dev/null
@@ -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)
diff --git a/fable-mcp/fable_mcp/tools/search.py b/fable-mcp/fable_mcp/tools/search.py
deleted file mode 100644
index dc8f083..0000000
--- a/fable-mcp/fable_mcp/tools/search.py
+++ /dev/null
@@ -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)
diff --git a/fable-mcp/fable_mcp/tools/tasks.py b/fable-mcp/fable_mcp/tools/tasks.py
deleted file mode 100644
index d2f6167..0000000
--- a/fable-mcp/fable_mcp/tools/tasks.py
+++ /dev/null
@@ -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})
diff --git a/fable-mcp/pyproject.toml b/fable-mcp/pyproject.toml
deleted file mode 100644
index ff90611..0000000
--- a/fable-mcp/pyproject.toml
+++ /dev/null
@@ -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"]
diff --git a/fable-mcp/tests/__init__.py b/fable-mcp/tests/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/fable-mcp/tests/conftest.py b/fable-mcp/tests/conftest.py
deleted file mode 100644
index 716448c..0000000
--- a/fable-mcp/tests/conftest.py
+++ /dev/null
@@ -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
diff --git a/fable-mcp/tests/test_client.py b/fable-mcp/tests/test_client.py
deleted file mode 100644
index 9e5723b..0000000
--- a/fable-mcp/tests/test_client.py
+++ /dev/null
@@ -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
diff --git a/fable-mcp/tests/test_tools_chat.py b/fable-mcp/tests/test_tools_chat.py
deleted file mode 100644
index d8b527d..0000000
--- a/fable-mcp/tests/test_tools_chat.py
+++ /dev/null
@@ -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]
diff --git a/fable-mcp/tests/test_tools_milestones.py b/fable-mcp/tests/test_tools_milestones.py
deleted file mode 100644
index 13f94f3..0000000
--- a/fable-mcp/tests/test_tools_milestones.py
+++ /dev/null
@@ -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
diff --git a/fable-mcp/tests/test_tools_notes.py b/fable-mcp/tests/test_tools_notes.py
deleted file mode 100644
index a7ba00d..0000000
--- a/fable-mcp/tests/test_tools_notes.py
+++ /dev/null
@@ -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")
diff --git a/fable-mcp/tests/test_tools_projects.py b/fable-mcp/tests/test_tools_projects.py
deleted file mode 100644
index 8cd12e9..0000000
--- a/fable-mcp/tests/test_tools_projects.py
+++ /dev/null
@@ -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]
diff --git a/fable-mcp/tests/test_tools_tasks.py b/fable-mcp/tests/test_tools_tasks.py
deleted file mode 100644
index 17976b6..0000000
--- a/fable-mcp/tests/test_tools_tasks.py
+++ /dev/null
@@ -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"
diff --git a/fable-mcp/uv.lock b/fable-mcp/uv.lock
deleted file mode 100644
index 046e9b5..0000000
--- a/fable-mcp/uv.lock
+++ /dev/null
@@ -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" },
-]
diff --git a/frontend/src/stores/push.ts b/frontend/src/stores/push.ts
deleted file mode 100644
index d6e90fa..0000000
--- a/frontend/src/stores/push.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { defineStore } from 'pinia'
-import { ref } from 'vue'
-
-function urlBase64ToUint8Array(base64String: string): Uint8Array {
- 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(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(
- 'Notification' in window ? Notification.permission : 'denied'
- )
- const isSubscribed = ref(false)
- const loading = ref(false)
- const error = ref(null)
-
- async function checkSubscription(): Promise {
- 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 {
- 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 {
- 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 }
-})
diff --git a/frontend/src/stores/settings.ts b/frontend/src/stores/settings.ts
index 4d07108..3f0c92e 100644
--- a/frontend/src/stores/settings.ts
+++ b/frontend/src/stores/settings.ts
@@ -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({});
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,
};
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index c659d83..0e95a1e 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -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 { return Promise.resolve([]) }
-// ── Journal config (locations, temp unit, prep schedule) ────────────────────
-const journalConfig = ref({
- 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 {
-
- Locations
- Home and work locations are used by the journal's daily prep to fetch local weather. Place names are geocoded once on save.
-
-
Home
-
-
-
-
{{ homeGeoMsg }}
-
{{ homeGeoMsg }}
-
{{ homeGeoMsg }}
-
-
-
Work
-
-
-
-
{{ workGeoMsg }}
-
{{ workGeoMsg }}
-
{{ workGeoMsg }}
-
-
-
Temperature unit
-
- Celsius
- Fahrenheit
-
-
-
- {{ journalConfigSaving ? 'Saving…' : 'Save' }}
- Saved!
-
-
-
diff --git a/pyproject.toml b/pyproject.toml
index c240a79..a5c927e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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"]
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 2ecde12..2cea950 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -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()
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index 45248e9..1a29265 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -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
diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py
deleted file mode 100644
index 3227811..0000000
--- a/src/fabledassistant/models/conversation.py
+++ /dev/null
@@ -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(),
- }
diff --git a/src/fabledassistant/models/generation_tool_log.py b/src/fabledassistant/models/generation_tool_log.py
deleted file mode 100644
index 1322143..0000000
--- a/src/fabledassistant/models/generation_tool_log.py
+++ /dev/null
@@ -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,
- }
diff --git a/src/fabledassistant/models/moment.py b/src/fabledassistant/models/moment.py
deleted file mode 100644
index e2d8cb9..0000000
--- a/src/fabledassistant/models/moment.py
+++ /dev/null
@@ -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),
- )
diff --git a/src/fabledassistant/models/pending_curator_action.py b/src/fabledassistant/models/pending_curator_action.py
deleted file mode 100644
index 5784877..0000000
--- a/src/fabledassistant/models/pending_curator_action.py
+++ /dev/null
@@ -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
- ),
- }
diff --git a/src/fabledassistant/models/push_subscription.py b/src/fabledassistant/models/push_subscription.py
deleted file mode 100644
index 072afaf..0000000
--- a/src/fabledassistant/models/push_subscription.py
+++ /dev/null
@@ -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"),
- )
diff --git a/src/fabledassistant/models/weather_cache.py b/src/fabledassistant/models/weather_cache.py
deleted file mode 100644
index e6c076e..0000000
--- a/src/fabledassistant/models/weather_cache.py
+++ /dev/null
@@ -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:
"
- 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(),
- }
diff --git a/src/fabledassistant/routes/admin.py b/src/fabledassistant/routes/admin.py
index 0b2df86..115f35d 100644
--- a/src/fabledassistant/routes/admin.py
+++ b/src/fabledassistant/routes/admin.py
@@ -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():
diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py
deleted file mode 100644
index e0cdeb0..0000000
--- a/src/fabledassistant/routes/chat.py
+++ /dev/null
@@ -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/", 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/", 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/", 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//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//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//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//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//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//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
-
-
diff --git a/src/fabledassistant/routes/fable_mcp_dist.py b/src/fabledassistant/routes/fable_mcp_dist.py
deleted file mode 100644
index 0136c89..0000000
--- a/src/fabledassistant/routes/fable_mcp_dist.py
+++ /dev/null
@@ -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)
diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py
deleted file mode 100644
index 119b03e..0000000
--- a/src/fabledassistant/routes/journal.py
+++ /dev/null
@@ -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/")
-@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/")
-@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//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//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/")
-@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/")
-@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],
- })
diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py
index d59e218..775f146 100644
--- a/src/fabledassistant/routes/notes.py
+++ b/src/fabledassistant/routes/notes.py
@@ -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("//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'\[\[[^\]]+\]\]')
diff --git a/src/fabledassistant/routes/profile.py b/src/fabledassistant/routes/profile.py
index 56ea9d2..88f1328 100644
--- a/src/fabledassistant/routes/profile.py
+++ b/src/fabledassistant/routes/profile.py
@@ -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:]))})
diff --git a/src/fabledassistant/routes/push.py b/src/fabledassistant/routes/push.py
deleted file mode 100644
index 6179518..0000000
--- a/src/fabledassistant/routes/push.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/routes/quick_capture.py b/src/fabledassistant/routes/quick_capture.py
deleted file mode 100644
index de029e1..0000000
--- a/src/fabledassistant/routes/quick_capture.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/routes/settings.py b/src/fabledassistant/routes/settings.py
index 7f6b179..19d2be1 100644
--- a/src/fabledassistant/routes/settings.py
+++ b/src/fabledassistant/routes/settings.py
@@ -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})
diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py
index c716914..91d7b2b 100644
--- a/src/fabledassistant/routes/tasks.py
+++ b/src/fabledassistant/routes/tasks.py
@@ -284,23 +284,3 @@ async def delete_task_route(task_id: int):
return "", 204
-@tasks_bp.route("//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())
diff --git a/src/fabledassistant/routes/voice.py b/src/fabledassistant/routes/voice.py
deleted file mode 100644
index 39094cb..0000000
--- a/src/fabledassistant/routes/voice.py
+++ /dev/null
@@ -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/", 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)
diff --git a/src/fabledassistant/services/article_fetcher.py b/src/fabledassistant/services/article_fetcher.py
deleted file mode 100644
index 5f3f717..0000000
--- a/src/fabledassistant/services/article_fetcher.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/services/assist.py b/src/fabledassistant/services/assist.py
deleted file mode 100644
index c3aa2b0..0000000
--- a/src/fabledassistant/services/assist.py
+++ /dev/null
@@ -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},
- ]
diff --git a/src/fabledassistant/services/auth.py b/src/fabledassistant/services/auth.py
index c203e83..0605186 100644
--- a/src/fabledassistant/services/auth.py
+++ b/src/fabledassistant/services/auth.py
@@ -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))
diff --git a/src/fabledassistant/services/backup.py b/src/fabledassistant/services/backup.py
index 94ce7c8..47f72d6 100644
--- a/src/fabledassistant/services/backup.py
+++ b/src/fabledassistant/services/backup.py
@@ -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:
diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py
deleted file mode 100644
index e6df2bc..0000000
--- a/src/fabledassistant/services/chat.py
+++ /dev/null
@@ -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()
diff --git a/src/fabledassistant/services/consolidation.py b/src/fabledassistant/services/consolidation.py
deleted file mode 100644
index ee77b5e..0000000
--- a/src/fabledassistant/services/consolidation.py
+++ /dev/null
@@ -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,
- )
diff --git a/src/fabledassistant/services/curator.py b/src/fabledassistant/services/curator.py
deleted file mode 100644
index 2fcc6ac..0000000
--- a/src/fabledassistant/services/curator.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/services/curator_scheduler.py b/src/fabledassistant/services/curator_scheduler.py
deleted file mode 100644
index 7797d85..0000000
--- a/src/fabledassistant/services/curator_scheduler.py
+++ /dev/null
@@ -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")
diff --git a/src/fabledassistant/services/diagnostics.py b/src/fabledassistant/services/diagnostics.py
index ea1fa22..ff2f684 100644
--- a/src/fabledassistant/services/diagnostics.py
+++ b/src/fabledassistant/services/diagnostics.py
@@ -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:
diff --git a/src/fabledassistant/services/event_scheduler.py b/src/fabledassistant/services/event_scheduler.py
index 2a9a449..b64bea3 100644
--- a/src/fabledassistant/services/event_scheduler.py
+++ b/src/fabledassistant/services/event_scheduler.py
@@ -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:
diff --git a/src/fabledassistant/services/generation_buffer.py b/src/fabledassistant/services/generation_buffer.py
deleted file mode 100644
index dc90e07..0000000
--- a/src/fabledassistant/services/generation_buffer.py
+++ /dev/null
@@ -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())
diff --git a/src/fabledassistant/services/generation_log.py b/src/fabledassistant/services/generation_log.py
deleted file mode 100644
index 4264766..0000000
--- a/src/fabledassistant/services/generation_log.py
+++ /dev/null
@@ -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": , "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()]
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
deleted file mode 100644
index 51b3d97..0000000
--- a/src/fabledassistant/services/generation_task.py
+++ /dev/null
@@ -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)})
diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py
deleted file mode 100644
index 467af9f..0000000
--- a/src/fabledassistant/services/journal_closeout.py
+++ /dev/null
@@ -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)
diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py
deleted file mode 100644
index 86b84e9..0000000
--- a/src/fabledassistant/services/journal_pipeline.py
+++ /dev/null
@@ -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)
diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py
deleted file mode 100644
index 1a5852f..0000000
--- a/src/fabledassistant/services/journal_prep.py
+++ /dev/null
@@ -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:
- 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 ",
-# "(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 8–14B 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
diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py
deleted file mode 100644
index f5bb45b..0000000
--- a/src/fabledassistant/services/journal_scheduler.py
+++ /dev/null
@@ -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")
diff --git a/src/fabledassistant/services/journal_search.py b/src/fabledassistant/services/journal_search.py
deleted file mode 100644
index ca12740..0000000
--- a/src/fabledassistant/services/journal_search.py
+++ /dev/null
@@ -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
- ]
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
deleted file mode 100644
index cd31175..0000000
--- a/src/fabledassistant/services/llm.py
+++ /dev/null
@@ -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"", "", text, flags=re.DOTALL)
- text = re.sub(r"", "", 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. ), 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
diff --git a/src/fabledassistant/services/moments.py b/src/fabledassistant/services/moments.py
deleted file mode 100644
index 0079b6a..0000000
--- a/src/fabledassistant/services/moments.py
+++ /dev/null
@@ -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],
- )
diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py
index 4448b47..493a5c8 100644
--- a/src/fabledassistant/services/notes.py
+++ b/src/fabledassistant/services/notes.py
@@ -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
diff --git a/src/fabledassistant/services/notifications.py b/src/fabledassistant/services/notifications.py
index 2ec1cb6..c8dec98 100644
--- a/src/fabledassistant/services/notifications.py
+++ b/src/fabledassistant/services/notifications.py
@@ -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:
diff --git a/src/fabledassistant/services/pending_actions.py b/src/fabledassistant/services/pending_actions.py
deleted file mode 100644
index 98f1349..0000000
--- a/src/fabledassistant/services/pending_actions.py
+++ /dev/null
@@ -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"}
diff --git a/src/fabledassistant/services/projects.py b/src/fabledassistant/services/projects.py
index 5d7725b..2abcebc 100644
--- a/src/fabledassistant/services/projects.py
+++ b/src/fabledassistant/services/projects.py
@@ -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(
diff --git a/src/fabledassistant/services/push.py b/src/fabledassistant/services/push.py
deleted file mode 100644
index b6c7708..0000000
--- a/src/fabledassistant/services/push.py
+++ /dev/null
@@ -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)
diff --git a/src/fabledassistant/services/research.py b/src/fabledassistant/services/research.py
deleted file mode 100644
index c882e64..0000000
--- a/src/fabledassistant/services/research.py
+++ /dev/null
@@ -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 (2–8 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 3–7 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"
- "- 300–600 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"
- "- 2–3 paragraphs of substantive prose (150–300 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
diff --git a/src/fabledassistant/services/stt.py b/src/fabledassistant/services/stt.py
deleted file mode 100644
index adc8c5a..0000000
--- a/src/fabledassistant/services/stt.py
+++ /dev/null
@@ -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)
diff --git a/src/fabledassistant/services/tag_suggestions.py b/src/fabledassistant/services/tag_suggestions.py
deleted file mode 100644
index 90fce85..0000000
--- a/src/fabledassistant/services/tag_suggestions.py
+++ /dev/null
@@ -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 []
diff --git a/src/fabledassistant/services/tools/__init__.py b/src/fabledassistant/services/tools/__init__.py
deleted file mode 100644
index 6d6b4e4..0000000
--- a/src/fabledassistant/services/tools/__init__.py
+++ /dev/null
@@ -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",
-]
diff --git a/src/fabledassistant/services/tools/_helpers.py b/src/fabledassistant/services/tools/_helpers.py
deleted file mode 100644
index 218237b..0000000
--- a/src/fabledassistant/services/tools/_helpers.py
+++ /dev/null
@@ -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
diff --git a/src/fabledassistant/services/tools/_registry.py b/src/fabledassistant/services/tools/_registry.py
deleted file mode 100644
index c17e63d..0000000
--- a/src/fabledassistant/services/tools/_registry.py
+++ /dev/null
@@ -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,
-}
diff --git a/src/fabledassistant/services/tools/article.py b/src/fabledassistant/services/tools/article.py
deleted file mode 100644
index 9a97f2d..0000000
--- a/src/fabledassistant/services/tools/article.py
+++ /dev/null
@@ -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]}}
diff --git a/src/fabledassistant/services/tools/calendar.py b/src/fabledassistant/services/tools/calendar.py
deleted file mode 100644
index 6149854..0000000
--- a/src/fabledassistant/services/tools/calendar.py
+++ /dev/null
@@ -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},
- }
diff --git a/src/fabledassistant/services/tools/entities.py b/src/fabledassistant/services/tools/entities.py
deleted file mode 100644
index 1a30972..0000000
--- a/src/fabledassistant/services/tools/entities.py
+++ /dev/null
@@ -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}}
diff --git a/src/fabledassistant/services/tools/journal.py b/src/fabledassistant/services/tools/journal.py
deleted file mode 100644
index 500fbdf..0000000
--- a/src/fabledassistant/services/tools/journal.py
+++ /dev/null
@@ -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}
diff --git a/src/fabledassistant/services/tools/notes.py b/src/fabledassistant/services/tools/notes.py
deleted file mode 100644
index 131ee7a..0000000
--- a/src/fabledassistant/services/tools/notes.py
+++ /dev/null
@@ -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}}
diff --git a/src/fabledassistant/services/tools/profile.py b/src/fabledassistant/services/tools/profile.py
deleted file mode 100644
index d7796ab..0000000
--- a/src/fabledassistant/services/tools/profile.py
+++ /dev/null
@@ -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())}}
diff --git a/src/fabledassistant/services/tools/projects.py b/src/fabledassistant/services/tools/projects.py
deleted file mode 100644
index a685062..0000000
--- a/src/fabledassistant/services/tools/projects.py
+++ /dev/null
@@ -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},
- }
diff --git a/src/fabledassistant/services/tools/rag.py b/src/fabledassistant/services/tools/rag.py
deleted file mode 100644
index e595fcd..0000000
--- a/src/fabledassistant/services/tools/rag.py
+++ /dev/null
@@ -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= 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}
diff --git a/src/fabledassistant/services/tools/tasks.py b/src/fabledassistant/services/tools/tasks.py
deleted file mode 100644
index 48677ff..0000000
--- a/src/fabledassistant/services/tools/tasks.py
+++ /dev/null
@@ -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}
diff --git a/src/fabledassistant/services/tools/utility.py b/src/fabledassistant/services/tools/utility.py
deleted file mode 100644
index 74f9ec1..0000000
--- a/src/fabledassistant/services/tools/utility.py
+++ /dev/null
@@ -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}}
diff --git a/src/fabledassistant/services/tools/weather.py b/src/fabledassistant/services/tools/weather.py
deleted file mode 100644
index 2f2f872..0000000
--- a/src/fabledassistant/services/tools/weather.py
+++ /dev/null
@@ -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 (1–8). 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}}
diff --git a/src/fabledassistant/services/tools/web.py b/src/fabledassistant/services/tools/web.py
deleted file mode 100644
index 8a2652e..0000000
--- a/src/fabledassistant/services/tools/web.py
+++ /dev/null
@@ -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 30–120 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"",
- "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."
- ),
- },
- }
diff --git a/src/fabledassistant/services/tts.py b/src/fabledassistant/services/tts.py
deleted file mode 100644
index 55922e2..0000000
--- a/src/fabledassistant/services/tts.py
+++ /dev/null
@@ -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: -- 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)
diff --git a/src/fabledassistant/services/user_profile.py b/src/fabledassistant/services/user_profile.py
index 8b776b8..0813012 100644
--- a/src/fabledassistant/services/user_profile.py
+++ b/src/fabledassistant/services/user_profile.py
@@ -1,7 +1,11 @@
-"""User profile service — structured per-user preferences for LLM context."""
-import asyncio
+"""User profile service — structured per-user preferences.
+
+Post-pivot the LLM-driven observation/consolidation surface is gone (curator
+deleted in Phase 8). The profile model is preserved as user-managed metadata
+(name, job, expertise, style, tone, interests, work schedule) — Claude can
+read it via the upcoming MCP profile tools.
+"""
import logging
-from datetime import date, datetime, timedelta, timezone
from sqlalchemy import select
@@ -14,9 +18,6 @@ VALID_EXPERTISE = {"novice", "intermediate", "expert"}
VALID_STYLES = {"concise", "balanced", "detailed"}
VALID_TONES = {"casual", "professional", "technical"}
-# Trigger consolidation when raw observations reach this count
-_CONSOLIDATION_THRESHOLD = 14
-
async def get_profile(user_id: int) -> UserProfile:
"""Get or create the profile row for a user."""
@@ -53,169 +54,3 @@ async def update_profile(user_id: int, data: dict) -> UserProfile:
await session.commit()
await session.refresh(profile)
return profile
-
-
-async def append_observations(user_id: int, bullets: str) -> None:
- """
- Append a new dated observation entry from the day's briefing closeout.
- Automatically triggers consolidation when the raw list grows large.
- """
- if not bullets.strip():
- return
-
- async with async_session() as session:
- result = await session.execute(
- select(UserProfile).where(UserProfile.user_id == user_id)
- )
- profile = result.scalar_one_or_none()
- if profile is None:
- profile = UserProfile(user_id=user_id)
- session.add(profile)
-
- existing: list = list(profile.observations_raw or [])
- existing.append({
- "date": date.today().isoformat(),
- "bullets": bullets.strip(),
- })
- # Keep at most 60 raw entries as a rolling window
- profile.observations_raw = existing[-60:]
- profile.observations_updated_at = datetime.now(timezone.utc)
- await session.commit()
- raw_count = len(profile.observations_raw or [])
-
- logger.info("Appended observations for user %d (%d raw entries)", user_id, raw_count)
-
- if raw_count >= _CONSOLIDATION_THRESHOLD:
- asyncio.create_task(_consolidate_observations(user_id))
-
-
-async def consolidate_observations(user_id: int) -> str:
- """Public entry point to manually trigger observation consolidation."""
- return await _consolidate_observations(user_id)
-
-
-async def clear_learned_data(user_id: int) -> None:
- """Reset all learned observations and summary for a user."""
- async with async_session() as session:
- result = await session.execute(
- select(UserProfile).where(UserProfile.user_id == user_id)
- )
- profile = result.scalar_one_or_none()
- if profile:
- profile.learned_summary = None
- profile.observations_raw = []
- profile.observations_updated_at = None
- await session.commit()
-
-
-async def _consolidate_observations(user_id: int) -> str:
- """
- LLM pass: synthesise all raw observation bullets into an updated
- learned_summary paragraph. Prunes raw entries older than 30 days afterwards.
- """
- from fabledassistant.config import Config
- from fabledassistant.services.llm import generate_completion
- from fabledassistant.services.settings import get_setting
-
- async with async_session() as session:
- result = await session.execute(
- select(UserProfile).where(UserProfile.user_id == user_id)
- )
- profile = result.scalar_one_or_none()
- if not profile or not profile.observations_raw:
- return ""
- observations = list(profile.observations_raw)
- existing_summary = profile.learned_summary or ""
-
- obs_text = "\n\n".join(
- f"[{entry['date']}]\n{entry['bullets']}"
- for entry in observations
- )
-
- system = (
- "You are synthesising preference observations into a concise user profile summary. "
- "Consolidate the observations into 3-6 factual sentences describing the user's patterns, "
- "preferences, and habits. Be specific and useful for a personal assistant. "
- "Merge with any existing summary, removing duplicates and outdated information. "
- "Output only the consolidated summary paragraph — no preamble, no bullet points."
- )
- user_prompt = ""
- if existing_summary:
- user_prompt += f"Existing summary:\n{existing_summary}\n\n"
- user_prompt += f"New observations:\n{obs_text}"
-
- # Profile observation consolidation reasons over multiple documents to
- # produce a coherent summary — closer to curator-shaped work than chat.
- # Route to worker (background_model). Falls back to OLLAMA_MODEL only if
- # neither setting nor BACKGROUND default is available.
- model = (
- await get_setting(user_id, "background_model", "")
- or Config.OLLAMA_BACKGROUND_MODEL
- or Config.OLLAMA_MODEL
- )
- try:
- new_summary = (await generate_completion(
- [
- {"role": "system", "content": system},
- {"role": "user", "content": user_prompt},
- ],
- model,
- )).strip()
- except Exception:
- logger.warning("Observation consolidation failed for user %d", user_id, exc_info=True)
- new_summary = ""
-
- if new_summary:
- cutoff = (date.today() - timedelta(days=30)).isoformat()
- async with async_session() as session:
- result = await session.execute(
- select(UserProfile).where(UserProfile.user_id == user_id)
- )
- profile = result.scalar_one_or_none()
- if profile:
- profile.learned_summary = new_summary
- profile.observations_raw = [
- o for o in (profile.observations_raw or [])
- if o.get("date", "") >= cutoff
- ]
- await session.commit()
- logger.info("Consolidated observations for user %d", user_id)
-
- return new_summary
-
-
-async def build_profile_context(user_id: int) -> str:
- """
- Build a formatted context string from the user's structured profile
- for injection into LLM system prompts (briefing and chat).
- Returns an empty string if no meaningful data is set.
- """
- profile = await get_profile(user_id)
-
- parts: list[str] = []
-
- if profile.display_name:
- parts.append(f"User's name: {profile.display_name}")
- if profile.job_title or profile.industry:
- job = " in ".join(filter(None, [profile.job_title, profile.industry]))
- parts.append(f"Occupation: {job}")
- if profile.expertise_level and profile.expertise_level != "intermediate":
- parts.append(
- f"Expertise level: {profile.expertise_level} — calibrate explanation depth accordingly"
- )
- if profile.response_style or profile.tone:
- style = profile.response_style or "balanced"
- tone = profile.tone or "casual"
- parts.append(f"Preferred response style: {style}, tone: {tone}")
- if profile.interests:
- parts.append(f"Interests: {', '.join(profile.interests)}")
- if profile.work_schedule:
- sched = profile.work_schedule
- days = ", ".join(sched.get("days") or []) or "weekdays"
- start = sched.get("start", "9:00")
- end = sched.get("end", "17:00")
- parts.append(f"Work schedule: {days}, {start}–{end}")
- if profile.learned_summary:
- parts.append(f"What the assistant has learned about this user: {profile.learned_summary}")
-
- return "\n".join(parts)
diff --git a/src/fabledassistant/services/voice_config.py b/src/fabledassistant/services/voice_config.py
deleted file mode 100644
index 546a792..0000000
--- a/src/fabledassistant/services/voice_config.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""System-wide voice configuration stored in the admin user's settings row.
-
-Falls back to Config env vars so operators can still seed defaults via the
-environment without touching the UI.
-"""
-from sqlalchemy import select
-
-from fabledassistant.config import Config
-from fabledassistant.models import async_session
-from fabledassistant.models.setting import Setting
-from fabledassistant.models.user import User
-
-VOICE_SETTING_KEYS = ("voice_enabled", "voice_stt_model")
-
-
-async def get_voice_config() -> dict[str, str]:
- """Return voice config, preferring DB values over env-var defaults."""
- config: dict[str, str] = {
- "voice_enabled": "true" if Config.VOICE_ENABLED else "false",
- "voice_stt_model": Config.STT_MODEL,
- }
- async with async_session() as session:
- result = await session.execute(
- select(Setting)
- .join(User, Setting.user_id == User.id)
- .where(User.role == "admin", Setting.key.in_(VOICE_SETTING_KEYS))
- )
- for setting in result.scalars().all():
- config[setting.key] = setting.value
- return config
-
-
-async def is_voice_enabled() -> bool:
- config = await get_voice_config()
- return config.get("voice_enabled", "false").lower() in ("1", "true", "yes")
-
-
-async def get_stt_model() -> str:
- config = await get_voice_config()
- return config.get("voice_stt_model", Config.STT_MODEL)
diff --git a/src/fabledassistant/services/voice_library.py b/src/fabledassistant/services/voice_library.py
deleted file mode 100644
index 3bc7810..0000000
--- a/src/fabledassistant/services/voice_library.py
+++ /dev/null
@@ -1,251 +0,0 @@
-"""Piper voice library — browse + install voices from the HuggingFace catalog.
-
-Piper voices live in https://huggingface.co/rhasspy/piper-voices, with a
-machine-readable catalog at:
-
- https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json
-
-That catalog enumerates every available voice (~250+ across many languages)
-with file sizes and download paths. We cache it in memory with a TTL so
-the Settings UI's "browse voices" panel doesn't hit HuggingFace every time
-the page opens, but a manual "refresh" button can force a re-fetch.
-
-Downloads land in /data/voices so they persist across container restarts.
-Bundled voices in /opt/piper-voices are read-only and cannot be deleted
-via the admin UI.
-"""
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-import re
-import shutil
-import time
-import urllib.request
-from pathlib import Path
-
-logger = logging.getLogger(__name__)
-
-CATALOG_URL = (
- "https://huggingface.co/rhasspy/piper-voices/raw/main/voices.json"
-)
-VOICE_BASE_URL = (
- "https://huggingface.co/rhasspy/piper-voices/resolve/main"
-)
-_USER_VOICES_DIR = Path("/data/voices")
-_BUNDLED_VOICES_DIR = Path("/opt/piper-voices")
-
-# Voice IDs are like `en_US-amy-medium`, `fr_FR-siwis-low` — strict
-# allowlist to prevent path traversal in download/delete handlers.
-_VOICE_ID_RE = re.compile(r"^[a-zA-Z]{2,3}(?:_[a-zA-Z]{2,4})?-[a-zA-Z0-9_+]+-(?:x_low|low|medium|high)$")
-
-# In-memory cache. Catalog rarely changes (kokoro-style "model update"
-# checks would be overkill — admin can force-refresh from the UI).
-_CATALOG_CACHE: dict | None = None
-_CATALOG_FETCHED_AT: float = 0.0
-_CATALOG_TTL_SECS = 24 * 3600 # 24h — long, but the manual refresh button overrides
-_CATALOG_LOCK = asyncio.Lock()
-
-
-def _is_valid_voice_id(voice_id: str) -> bool:
- return bool(_VOICE_ID_RE.match(voice_id))
-
-
-async def fetch_catalog(force_refresh: bool = False) -> dict:
- """Return the piper-voices catalog dict, fetching from HF if stale.
-
- Cached in memory across requests. force_refresh=True bypasses the TTL.
- Raises on network error so the UI can surface the failure.
- """
- global _CATALOG_CACHE, _CATALOG_FETCHED_AT
- async with _CATALOG_LOCK:
- now = time.monotonic()
- fresh_enough = (
- _CATALOG_CACHE is not None
- and (now - _CATALOG_FETCHED_AT) < _CATALOG_TTL_SECS
- )
- if fresh_enough and not force_refresh:
- return _CATALOG_CACHE # type: ignore[return-value]
-
- loop = asyncio.get_running_loop()
-
- def _do_fetch() -> dict:
- with urllib.request.urlopen(CATALOG_URL, timeout=30) as resp:
- raw = resp.read().decode("utf-8")
- return json.loads(raw)
-
- try:
- catalog = await loop.run_in_executor(None, _do_fetch)
- except Exception:
- logger.exception("Failed to fetch piper voices catalog from %s", CATALOG_URL)
- raise
-
- _CATALOG_CACHE = catalog
- _CATALOG_FETCHED_AT = now
- logger.info("Piper voices catalog refreshed: %d entries", len(catalog))
- return catalog
-
-
-def _is_installed(voice_id: str) -> tuple[bool, str | None]:
- """Return (installed, source) where source is 'bundled' / 'user' / None."""
- if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file():
- return True, "bundled"
- if (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file():
- return True, "user"
- return False, None
-
-
-def shape_catalog_for_ui(catalog: dict) -> list[dict]:
- """Project the raw HF catalog into a list of UI-friendly voice cards.
-
- The HF catalog uses voice IDs as keys with nested file-size metadata.
- The UI just needs id, language, dataset, quality, size, install state.
- Sorted by language then dataset for stable display.
- """
- out: list[dict] = []
- for voice_id, info in catalog.items():
- if not isinstance(info, dict):
- continue
- installed, source = _is_installed(voice_id)
- lang_info = info.get("language") or {}
- files = info.get("files") or {}
- # Sum the file sizes for the .onnx + .onnx.json pair so the UI
- # can show "≈ 62 MB" before the user clicks Download.
- total_bytes = 0
- for f_info in files.values():
- if isinstance(f_info, dict):
- total_bytes += int(f_info.get("size_bytes", 0) or 0)
- out.append({
- "id": voice_id,
- "name": info.get("name") or voice_id.split("-")[1],
- "language_code": lang_info.get("code") or voice_id.split("-")[0],
- "language_name": lang_info.get("name_native")
- or lang_info.get("name_english")
- or lang_info.get("code", ""),
- "country": lang_info.get("country_english") or lang_info.get("region", ""),
- "quality": info.get("quality") or voice_id.split("-")[-1],
- "num_speakers": int(info.get("num_speakers") or 1),
- "size_bytes": total_bytes,
- "installed": installed,
- "installed_source": source,
- })
- out.sort(key=lambda v: (v["language_code"], v["name"], v["quality"]))
- return out
-
-
-def _resolve_file_urls(catalog: dict, voice_id: str) -> dict[str, str]:
- """Return absolute URLs for the .onnx + .onnx.json files of `voice_id`.
-
- Walks the catalog entry's `files` dict; that's the authoritative path
- list (the layout in the repo can drift, e.g. some voices live under
- different sub-paths). Returns {extension: url}.
- """
- info = catalog.get(voice_id)
- if not isinstance(info, dict):
- raise KeyError(f"Voice {voice_id!r} not in catalog")
- files = info.get("files") or {}
- urls: dict[str, str] = {}
- for rel_path in files.keys():
- if rel_path.endswith(".onnx"):
- urls["onnx"] = f"{VOICE_BASE_URL}/{rel_path}"
- elif rel_path.endswith(".onnx.json"):
- urls["onnx.json"] = f"{VOICE_BASE_URL}/{rel_path}"
- if "onnx" not in urls or "onnx.json" not in urls:
- raise ValueError(
- f"Catalog entry for {voice_id!r} is missing .onnx or .onnx.json file"
- )
- return urls
-
-
-async def install_voice(voice_id: str) -> dict:
- """Download a voice's .onnx + .onnx.json into /data/voices.
-
- Idempotent — if already installed under /data, returns success without
- re-downloading. Bundled voices in /opt are not touched; the same voice
- can also live under /data if the admin wants to override it.
- Returns {"id", "size_bytes", "skipped"}.
- """
- if not _is_valid_voice_id(voice_id):
- raise ValueError(f"Invalid voice id: {voice_id!r}")
-
- _USER_VOICES_DIR.mkdir(parents=True, exist_ok=True)
- onnx_path = _USER_VOICES_DIR / f"{voice_id}.onnx"
- sidecar_path = _USER_VOICES_DIR / f"{voice_id}.onnx.json"
-
- if onnx_path.is_file() and sidecar_path.is_file():
- return {
- "id": voice_id,
- "size_bytes": onnx_path.stat().st_size + sidecar_path.stat().st_size,
- "skipped": True,
- }
-
- catalog = await fetch_catalog()
- urls = _resolve_file_urls(catalog, voice_id)
-
- loop = asyncio.get_running_loop()
-
- def _download() -> int:
- total = 0
- # Use atomic write — download to .tmp then rename. Means a failed
- # partial download doesn't leave a corrupt model in place that
- # would later cause PiperVoice.load() to throw.
- for ext, url in urls.items():
- target = _USER_VOICES_DIR / f"{voice_id}.{ext}"
- tmp = target.with_suffix(target.suffix + ".tmp")
- logger.info("Downloading piper voice file %s → %s", url, target)
- with urllib.request.urlopen(url, timeout=120) as resp:
- with open(tmp, "wb") as fh:
- shutil.copyfileobj(resp, fh, length=1024 * 1024)
- tmp.replace(target)
- total += target.stat().st_size
- return total
-
- try:
- size = await loop.run_in_executor(None, _download)
- except Exception:
- # Clean up partials.
- for ext in ("onnx", "onnx.json"):
- for p in (
- _USER_VOICES_DIR / f"{voice_id}.{ext}",
- _USER_VOICES_DIR / f"{voice_id}.{ext}.tmp",
- ):
- try:
- if p.exists():
- p.unlink()
- except Exception:
- pass
- logger.exception("Voice install failed for %s — cleaned up partials", voice_id)
- raise
-
- logger.info("Installed piper voice %s (%d bytes)", voice_id, size)
- return {"id": voice_id, "size_bytes": size, "skipped": False}
-
-
-async def uninstall_voice(voice_id: str) -> dict:
- """Remove a /data voice. Bundled voices in /opt cannot be removed via API.
-
- Returns {"id", "removed": bool}.
- """
- if not _is_valid_voice_id(voice_id):
- raise ValueError(f"Invalid voice id: {voice_id!r}")
-
- # Refuse to delete a bundled voice. If a /data override exists alongside
- # a bundled one with the same id, only the /data copy is removed.
- if not (_USER_VOICES_DIR / f"{voice_id}.onnx").is_file() and not (
- _USER_VOICES_DIR / f"{voice_id}.onnx.json"
- ).is_file():
- if (_BUNDLED_VOICES_DIR / f"{voice_id}.onnx").is_file():
- raise PermissionError(
- f"Voice {voice_id!r} is bundled with the image — cannot be removed"
- )
- return {"id": voice_id, "removed": False}
-
- removed_any = False
- for ext in ("onnx", "onnx.json"):
- p = _USER_VOICES_DIR / f"{voice_id}.{ext}"
- if p.is_file():
- p.unlink()
- removed_any = True
- logger.info("Uninstalled piper voice %s (files removed: %s)", voice_id, removed_any)
- return {"id": voice_id, "removed": removed_any}
diff --git a/src/fabledassistant/services/weather.py b/src/fabledassistant/services/weather.py
deleted file mode 100644
index 46051a1..0000000
--- a/src/fabledassistant/services/weather.py
+++ /dev/null
@@ -1,408 +0,0 @@
-"""Weather service: geocoding, Open-Meteo 7-day forecast, caching, change detection."""
-
-import logging
-from datetime import datetime, timezone
-
-import httpx
-from sqlalchemy import select
-
-from fabledassistant.models import async_session
-from fabledassistant.models.weather_cache import WeatherCache
-
-logger = logging.getLogger(__name__)
-
-NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
-OPEN_METEO_URL = "https://api.open-meteo.com/v1/forecast"
-OPEN_METEO_DAILY = (
- "temperature_2m_max,temperature_2m_min,precipitation_sum,"
- "precipitation_probability_max,weathercode,windspeed_10m_max"
-)
-OPEN_METEO_HOURLY = "precipitation_probability"
-
-# WMO weather code → description (subset; covers the most common codes)
-_WMO_CODES: dict[int, str] = {
- 0: "Clear sky",
- 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
- 45: "Fog", 48: "Depositing rime fog",
- 51: "Drizzle: light", 53: "Drizzle: moderate", 55: "Drizzle: dense",
- 61: "Rain: slight", 63: "Rain: moderate", 65: "Rain: heavy",
- 71: "Snow: slight", 73: "Snow: moderate", 75: "Snow: heavy",
- 77: "Snow grains",
- 80: "Rain showers: slight", 81: "Rain showers: moderate", 82: "Rain showers: violent",
- 85: "Snow showers: slight", 86: "Snow showers: heavy",
- 95: "Thunderstorm",
- 96: "Thunderstorm with slight hail", 99: "Thunderstorm with heavy hail",
-}
-
-
-def describe_weathercode(code: int) -> str:
- return _WMO_CODES.get(code, f"Unknown (code {code})")
-
-
-async def get_temp_unit(user_id: int) -> str:
- """Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
- from fabledassistant.services.settings import get_setting
- raw = await get_setting(user_id, "temp_unit", "C")
- return raw if raw in ("C", "F") else "C"
-
-
-def parse_forecast(raw: dict) -> list[dict]:
- """Convert Open-Meteo daily response into a clean list of day dicts."""
- daily = raw.get("daily", {})
- dates = daily.get("time", [])
- precip_prob = daily.get("precipitation_probability_max", [])
- return [
- {
- "date": dates[i],
- "temp_max": daily.get("temperature_2m_max", [])[i],
- "temp_min": daily.get("temperature_2m_min", [])[i],
- "precip_mm": daily.get("precipitation_sum", [])[i],
- "precip_probability": precip_prob[i] if i < len(precip_prob) else None,
- "weathercode": daily.get("weathercode", [])[i],
- "description": describe_weathercode(daily.get("weathercode", [])[i]),
- "windspeed_max": daily.get("windspeed_10m_max", [])[i],
- }
- for i in range(len(dates))
- ]
-
-
-def detect_changes(old_days: list[dict], new_days: list[dict]) -> list[str]:
- """Return human-readable change strings where the forecast has meaningfully shifted."""
- old_by_date = {d["date"]: d for d in old_days}
- changes = []
- for day in new_days:
- date = day["date"]
- old = old_by_date.get(date)
- if not old:
- continue
- notes = []
- # Weather condition changed
- if day["weathercode"] != old["weathercode"]:
- notes.append(
- f"conditions changed from '{describe_weathercode(old['weathercode'])}' "
- f"to '{describe_weathercode(day['weathercode'])}'"
- )
- # Precipitation appeared or disappeared (threshold: 1mm)
- was_dry = old["precip_mm"] < 1.0
- now_wet = day["precip_mm"] >= 1.0
- was_wet = old["precip_mm"] >= 1.0
- now_dry = day["precip_mm"] < 1.0
- if was_dry and now_wet:
- notes.append(f"rain now expected ({day['precip_mm']:.1f}mm)")
- elif was_wet and now_dry:
- notes.append("rain no longer expected")
- # Temperature shifted by 4°C or more
- temp_shift = abs(day["temp_max"] - old["temp_max"])
- if temp_shift >= 4:
- direction = "warmer" if day["temp_max"] > old["temp_max"] else "colder"
- notes.append(f"high temperature {direction} by {temp_shift:.0f}°C")
- if notes:
- changes.append(f"{date}: " + "; ".join(notes))
- return changes
-
-
-def _summarize_precip(hourly_probs: list[tuple[int, int]], threshold: int = 30) -> str | None:
- """Build a human-readable precipitation summary from (hour, probability) pairs.
-
- Returns None when no significant precipitation is expected.
- """
- wet_hours = [(h, p) for h, p in hourly_probs if p >= threshold]
- if not wet_hours:
- return None
-
- peak_hour, peak_prob = max(wet_hours, key=lambda x: x[1])
- daytime_hours = [h for h, _ in hourly_probs if 6 <= h <= 22]
- if not daytime_hours:
- return None
-
- wet_daytime = [h for h, p in hourly_probs if 6 <= h <= 22 and p >= threshold]
- if len(wet_daytime) >= 10:
- return f"Rain likely all day (up to {peak_prob}%)"
-
- if not wet_daytime:
- return None
-
- def _fmt_hour(h: int) -> str:
- if h == 0 or h == 24:
- return "12 AM"
- if h == 12:
- return "12 PM"
- return f"{h} AM" if h < 12 else f"{h - 12} PM"
-
- start = wet_daytime[0]
- end = wet_daytime[-1]
- if start == end:
- return f"{peak_prob}% chance around {_fmt_hour(start)}"
- return f"Rain likely {_fmt_hour(start)}–{_fmt_hour(end + 1)} (up to {peak_prob}%)"
-
-
-def _extract_hourly_precip_for_date(raw: dict, date_str: str) -> list[tuple[int, int]]:
- """Extract (hour, probability) pairs for a specific date from cached forecast JSON."""
- hourly = raw.get("hourly", {})
- times = hourly.get("precipitation_probability", [])
- time_labels = hourly.get("time", [])
- pairs: list[tuple[int, int]] = []
- prefix = date_str + "T"
- for i, t in enumerate(time_labels):
- if t.startswith(prefix) and i < len(times) and times[i] is not None:
- hour = int(t[11:13])
- pairs.append((hour, times[i]))
- return pairs
-
-
-def parse_weather_card_data(
- cache_row,
- temp_unit: str = "C",
-) -> dict | None:
- """
- Parse a WeatherCache row into the metadata.weather card schema.
- Returns None if the cache row is missing or unparseable. Stale data is
- returned as-is — the frontend uses `fetched_at` to convey freshness, and
- WeatherCard handles missing today/forecast fields gracefully.
- """
- from datetime import date, timedelta
-
- if cache_row is None or cache_row.fetched_at is None or not cache_row.forecast_json:
- return None
-
- raw = cache_row.forecast_json or {}
- current_weather = raw.get("current_weather", {})
- days = parse_forecast(raw)
-
- today_str = date.today().isoformat()
- yesterday_str = (date.today() - timedelta(days=1)).isoformat()
-
- today_day = next((d for d in days if d["date"] == today_str), None)
- yesterday_day = next((d for d in days if d["date"] == yesterday_str), None)
- future_days = [d for d in days if d["date"] > today_str][:5]
-
- imperial = temp_unit == "F"
-
- def to_temp(c: float) -> int:
- return round(c * 9 / 5 + 32) if imperial else round(c)
-
- def to_wind(kmh: float) -> int:
- return round(kmh * 0.621371) if imperial else round(kmh)
-
- def day_label(date_str: str) -> str:
- from datetime import date as _date
- try:
- return _date.fromisoformat(date_str).strftime("%a")
- except ValueError:
- return date_str
-
- wind_unit = "mph" if imperial else "km/h"
-
- today_hourly = _extract_hourly_precip_for_date(raw, today_str)
- today_precip_summary = _summarize_precip(today_hourly)
-
- def _forecast_day(d: dict) -> dict:
- entry: dict = {
- "day": day_label(d["date"]),
- "condition": d["description"],
- "high": to_temp(d["temp_max"]),
- "low": to_temp(d["temp_min"]),
- "precip_probability": d["precip_probability"],
- "precip_mm": d["precip_mm"],
- "windspeed_max": to_wind(d["windspeed_max"]),
- }
- hourly = _extract_hourly_precip_for_date(raw, d["date"])
- summary = _summarize_precip(hourly)
- if summary:
- entry["precip_summary"] = summary
- if hourly:
- peak = max(hourly, key=lambda x: x[1])
- if peak[1] >= 30:
- h = peak[0]
- entry["precip_peak_hour"] = f"{h} AM" if h < 12 else ("12 PM" if h == 12 else f"{h - 12} PM")
- return entry
-
- return {
- "location": getattr(cache_row, "location_label", ""),
- "fetched_at": cache_row.fetched_at.isoformat(),
- "current_temp": to_temp(current_weather.get("temperature", 0)),
- "condition": describe_weathercode(current_weather.get("weathercode", 0)),
- "today_high": to_temp(today_day["temp_max"]) if today_day else None,
- "today_low": to_temp(today_day["temp_min"]) if today_day else None,
- "yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
- "yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
- "wind_unit": wind_unit,
- "precip_summary": today_precip_summary,
- "forecast": [_forecast_day(d) for d in future_days],
- }
-
-
-async def get_cached_weather_rows(
- user_id: int,
- valid_keys: set[str] | None = None,
-) -> list:
- """Return raw WeatherCache ORM rows for a user (for card parsing).
-
- If ``valid_keys`` is provided, only rows whose ``location_key`` is in the
- set are returned. This is how callers drop orphaned cache rows whose
- location is no longer in the user's ``journal_config.locations`` (e.g.
- leftovers from the briefing era, or a location that's been removed).
- Passing an empty set returns no rows.
- """
- async with async_session() as session:
- stmt = select(WeatherCache).where(WeatherCache.user_id == user_id)
- if valid_keys is not None:
- stmt = stmt.where(WeatherCache.location_key.in_(list(valid_keys)))
- result = await session.execute(stmt)
- return list(result.scalars().all())
-
-
-async def geocode(query: str) -> tuple[float, float, str]:
- """Return (lat, lon, display_name) for a place name using Nominatim."""
- async with httpx.AsyncClient(timeout=10.0, headers={"User-Agent": "FabledAssistant/1.0"}) as client:
- resp = await client.get(NOMINATIM_URL, params={"q": query, "format": "json", "limit": 1})
- resp.raise_for_status()
- results = resp.json()
- if not results:
- raise ValueError(f"No geocoding result for: {query!r}")
- r = results[0]
- return float(r["lat"]), float(r["lon"]), r.get("display_name", query)
-
-
-async def fetch_current_conditions(lat: float, lon: float) -> dict | None:
- """Fetch current temperature + conditions + next 3 hours precipitation.
-
- Lightweight call — no daily forecast, no caching needed.
- Returns dict with: temperature, windspeed, weathercode, description,
- and precip_next_3h (list of hourly precip probabilities).
- """
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- resp = await client.get(OPEN_METEO_URL, params={
- "latitude": lat,
- "longitude": lon,
- "current_weather": "true",
- "hourly": "precipitation_probability",
- "forecast_days": 1,
- "timezone": "auto",
- })
- resp.raise_for_status()
- data = resp.json()
-
- current = data.get("current_weather", {})
- hourly = data.get("hourly", {})
- hourly_times = hourly.get("time", [])
- hourly_precip = hourly.get("precipitation_probability", [])
-
- # Find the current hour index and get next 3 hours of precip
- current_time = current.get("time", "")
- precip_next_3h = []
- try:
- idx = next(i for i, t in enumerate(hourly_times) if t >= current_time)
- precip_next_3h = hourly_precip[idx:idx + 3]
- except (StopIteration, IndexError):
- pass
-
- code = current.get("weathercode", 0)
- return {
- "temperature": current.get("temperature"),
- "windspeed": current.get("windspeed"),
- "weathercode": code,
- "description": describe_weathercode(code),
- "time": current_time,
- "precip_next_3h": precip_next_3h,
- }
- except Exception:
- logger.debug("Failed to fetch current conditions for %s, %s", lat, lon, exc_info=True)
- return None
-
-
-async def fetch_hourly_precip(lat: float, lon: float) -> dict[str, int]:
- """Fetch today's hourly precipitation probabilities.
-
- Returns a dict of ISO hour string → probability percentage, e.g.:
- {"2026-04-08T14:00": 65, "2026-04-08T15:00": 80, ...}
- """
- try:
- async with httpx.AsyncClient(timeout=10.0) as client:
- resp = await client.get(OPEN_METEO_URL, params={
- "latitude": lat,
- "longitude": lon,
- "hourly": "precipitation_probability",
- "forecast_days": 1,
- "timezone": "auto",
- })
- resp.raise_for_status()
- data = resp.json()
- hourly = data.get("hourly", {})
- times = hourly.get("time", [])
- probs = hourly.get("precipitation_probability", [])
- return {t: p for t, p in zip(times, probs) if p is not None}
- except Exception:
- logger.debug("Failed to fetch hourly precip for %s, %s", lat, lon, exc_info=True)
- return {}
-
-
-async def _fetch_open_meteo(lat: float, lon: float) -> dict:
- """Fetch 7-day forecast from Open-Meteo with current conditions, hourly precip, and yesterday's data."""
- async with httpx.AsyncClient(timeout=15.0) as client:
- resp = await client.get(OPEN_METEO_URL, params={
- "latitude": lat,
- "longitude": lon,
- "daily": OPEN_METEO_DAILY,
- "hourly": OPEN_METEO_HOURLY,
- "current_weather": "true",
- "past_days": 1,
- "timezone": "auto",
- "forecast_days": 7,
- })
- resp.raise_for_status()
- return resp.json()
-
-
-async def refresh_location_cache(
- user_id: int, location_key: str, location_label: str, lat: float, lon: float
-) -> WeatherCache:
- """Fetch fresh forecast and update the cache row for one location."""
- raw = await _fetch_open_meteo(lat, lon)
- async with async_session() as session:
- result = await session.execute(
- select(WeatherCache).where(
- WeatherCache.user_id == user_id,
- WeatherCache.location_key == location_key,
- )
- )
- cache = result.scalars().first()
- if cache is None:
- cache = WeatherCache(
- user_id=user_id,
- location_key=location_key,
- location_label=location_label,
- )
- session.add(cache)
- # Rotate: current becomes previous before overwriting
- cache.previous_json = cache.forecast_json
- cache.forecast_json = raw
- cache.location_label = location_label
- cache.fetched_at = datetime.now(timezone.utc)
- await session.commit()
- await session.refresh(cache)
- return cache
-
-
-async def get_cached_weather(user_id: int) -> list[dict]:
- """Return all cached weather entries for the user, with parsed days and change notes."""
- async with async_session() as session:
- result = await session.execute(
- select(WeatherCache).where(WeatherCache.user_id == user_id)
- )
- rows = list(result.scalars().all())
-
- out = []
- for row in rows:
- current_days = parse_forecast(row.forecast_json) if row.forecast_json else []
- previous_days = parse_forecast(row.previous_json) if row.previous_json else []
- changes = detect_changes(previous_days, current_days) if previous_days else []
- out.append({
- "location_key": row.location_key,
- "location_label": row.location_label,
- "fetched_at": row.fetched_at.isoformat(),
- "days": current_days,
- "changes_since_last_fetch": changes,
- })
- return out
diff --git a/src/fabledassistant/services/wikipedia.py b/src/fabledassistant/services/wikipedia.py
deleted file mode 100644
index 17185cf..0000000
--- a/src/fabledassistant/services/wikipedia.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""Wikipedia API: lightweight topic lookups and article search."""
-import logging
-from urllib.parse import quote as url_quote
-
-import httpx
-
-logger = logging.getLogger(__name__)
-
-_SUMMARY_URL = "https://en.wikipedia.org/api/rest_v1/page/summary"
-_SEARCH_URL = "https://en.wikipedia.org/w/api.php"
-_TIMEOUT = 5.0
-_USER_AGENT = "FabledAssistant/1.0 (https://fabledsword.com)"
-
-
-def _extract_thumbnail(data: dict) -> str:
- """Prefer the 320px thumbnail (kinder to cache/bandwidth) over originalimage."""
- thumb = data.get("thumbnail", {}).get("source", "")
- if thumb:
- return thumb
- return data.get("originalimage", {}).get("source", "")
-
-
-async def wiki_summary(query: str) -> dict | None:
- """Fetch a Wikipedia summary for a given title/query.
-
- Does a direct title lookup via the REST v1 summary endpoint.
- Returns a dict with title, extract, url, and thumbnail_url on success;
- thumbnail_url is an empty string when the article has no image.
- Returns None on any failure.
- """
- title = url_quote(query.replace(" ", "_"))
- url = f"{_SUMMARY_URL}/{title}"
- headers = {"User-Agent": _USER_AGENT}
- try:
- async with httpx.AsyncClient() as client:
- resp = await client.get(url, headers=headers, timeout=_TIMEOUT, follow_redirects=True)
- resp.raise_for_status()
- data = resp.json()
- if data.get("type") == "disambiguation":
- logger.debug("wiki_summary: disambiguation page for %r", query)
- return None
- extract = data.get("extract", "").strip()
- if not extract:
- logger.debug("wiki_summary: empty extract for %r", query)
- return None
- return {
- "title": data.get("title", query),
- "extract": extract,
- "url": data.get("content_urls", {}).get("desktop", {}).get("page", ""),
- "thumbnail_url": _extract_thumbnail(data),
- }
- except Exception as exc:
- logger.debug("wiki_summary failed for %r: %s", query, exc)
- return None
-
-
-async def wiki_search(query: str, limit: int = 3) -> list[dict]:
- """Search Wikipedia and return summaries for the top results.
-
- Uses the MediaWiki search API then fetches summaries for each hit.
- Skips disambiguation pages and empty extracts.
- Returns a list of dicts with title, extract, and url.
- """
- headers = {"User-Agent": _USER_AGENT}
- params = {
- "action": "query",
- "list": "search",
- "srsearch": query,
- "srlimit": str(limit),
- "format": "json",
- }
- results: list[dict] = []
- try:
- async with httpx.AsyncClient() as client:
- resp = await client.get(
- _SEARCH_URL, params=params, headers=headers, timeout=_TIMEOUT
- )
- resp.raise_for_status()
- data = resp.json()
- hits = data.get("query", {}).get("search", [])
- for hit in hits:
- title = hit.get("title", "")
- encoded = url_quote(title.replace(" ", "_"))
- summary_url = f"{_SUMMARY_URL}/{encoded}"
- try:
- sr = await client.get(
- summary_url, headers=headers, timeout=_TIMEOUT, follow_redirects=True
- )
- sr.raise_for_status()
- sdata = sr.json()
- if sdata.get("type") == "disambiguation":
- logger.debug("wiki_search: skipping disambiguation %r", title)
- continue
- extract = sdata.get("extract", "").strip()
- if not extract:
- logger.debug("wiki_search: empty extract for %r", title)
- continue
- results.append(
- {
- "title": sdata.get("title", title),
- "extract": extract,
- "url": sdata.get("content_urls", {})
- .get("desktop", {})
- .get("page", ""),
- "thumbnail_url": _extract_thumbnail(sdata),
- }
- )
- except Exception as exc:
- logger.debug("wiki_search: summary fetch failed for %r: %s", title, exc)
- except Exception as exc:
- logger.debug("wiki_search failed for %r: %s", query, exc)
- return []
- return results
diff --git a/tests/test_calendar_tool_tz.py b/tests/test_calendar_tool_tz.py
deleted file mode 100644
index fa47fcb..0000000
--- a/tests/test_calendar_tool_tz.py
+++ /dev/null
@@ -1,795 +0,0 @@
-"""Regression tests: calendar tool respects the user's configured timezone."""
-
-from datetime import timezone
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-
-@pytest.mark.asyncio
-async def test_create_event_all_day_bare_date_interprets_local_midnight():
- """Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
- stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
- be 2026-09-29 19:00 NY and land on the wrong day)."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={"title": "Birthday", "start": "2026-09-30"},
- )
-
- assert result["success"] is True
- start_dt = captured["start_dt"]
- assert start_dt.tzinfo is not None
- # Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
- utc = start_dt.astimezone(timezone.utc)
- assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
- assert utc.hour == 4 # EDT offset; would be 5 in EST
- assert captured["all_day"] is True
-
-
-@pytest.mark.asyncio
-async def test_create_event_naive_datetime_is_user_local():
- """'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- await create_event_tool(
- user_id=1,
- arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
- )
-
- utc = captured["start_dt"].astimezone(timezone.utc)
- # 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
- assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
-
-
-@pytest.mark.asyncio
-async def test_create_event_explicit_offset_is_respected():
- """Model-supplied timezone offsets must not be overridden by user TZ."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- await create_event_tool(
- user_id=1,
- arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
- )
-
- utc = captured["start_dt"].astimezone(timezone.utc)
- assert (utc.hour, utc.minute) == (14, 30)
-
-
-@pytest.mark.asyncio
-async def test_list_events_bare_date_range_covers_local_day():
- """date_from/date_to as bare dates should cover the full LOCAL day."""
- from fabledassistant.services.tools.calendar import list_events_tool
-
- captured = {}
-
- async def fake_list_events(*, user_id, date_from, date_to):
- captured["date_from"] = date_from
- captured["date_to"] = date_to
- return []
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_list_events",
- side_effect=fake_list_events,
- ):
- await list_events_tool(
- user_id=1,
- arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
- )
-
- df = captured["date_from"].astimezone(timezone.utc)
- dt = captured["date_to"].astimezone(timezone.utc)
- # 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
- assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
- # 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
- assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
-
-
-# ── Split date/time field tests (durable shape) ──────────────────────────────
-#
-# These exercise the start_date + start_time path that the model is now
-# steered toward. The split-field shape is structurally immune to the
-# class of bugs where a model emits a TZ-tagged combined datetime that
-# the parser correctly honors but lands on the wrong calendar day for
-# negative-offset users.
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_fields_friday_8am_no_drift_eastern():
- """The reported bug: 'next Friday at 8am' for a NY user must land on
- 2026-05-01 08:00 NY, never 04-30 19:00. With split fields the model
- can't TZ-tag the date string, so the calendar day is fixed."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "Meeting",
- "start_date": "2026-05-01",
- "start_time": "08:00",
- },
- )
-
- assert result["success"] is True
- utc = captured["start_dt"].astimezone(timezone.utc)
- # 08:00 NY (EDT, UTC-4) on 2026-05-01 = 12:00 UTC same day
- assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 5, 1, 12, 0)
- assert captured["all_day"] is False
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_fields_no_drift_pacific():
- """Same scenario for a UTC-8 user — the calendar day must be 5/1
- regardless of how big the offset gets."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/Los_Angeles")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- await create_event_tool(
- user_id=1,
- arguments={
- "title": "Standup",
- "start_date": "2026-05-01",
- "start_time": "08:00",
- },
- )
-
- utc = captured["start_dt"].astimezone(timezone.utc)
- # 08:00 LA (PDT, UTC-7) = 15:00 UTC same day
- assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 15)
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_fields_no_drift_positive_offset():
- """A positive-offset user (Tokyo, UTC+9) — 08:00 Tokyo on 2026-05-01
- is 23:00 UTC on 2026-04-30, but the local calendar day must still be
- stored as 2026-05-01 from the user's perspective."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- await create_event_tool(
- user_id=1,
- arguments={
- "title": "Sync",
- "start_date": "2026-05-01",
- "start_time": "08:00",
- },
- )
-
- utc = captured["start_dt"].astimezone(timezone.utc)
- # 08:00 Tokyo (UTC+9) = 23:00 UTC previous day; the round-trip back
- # to Tokyo TZ recovers 2026-05-01 08:00.
- assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 4, 30, 23)
- tokyo = captured["start_dt"].astimezone(__import__("zoneinfo").ZoneInfo("Asia/Tokyo"))
- assert (tokyo.year, tokyo.month, tokyo.day, tokyo.hour) == (2026, 5, 1, 8)
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_date_only_is_all_day():
- """Omitting start_time means the event is all-day; cache row uses
- local midnight."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- await create_event_tool(
- user_id=1,
- arguments={"title": "Birthday", "start_date": "2026-09-30"},
- )
-
- assert captured["all_day"] is True
- utc = captured["start_dt"].astimezone(timezone.utc)
- assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 9, 30, 4)
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_rejects_tz_suffix_in_date():
- """The whole point of split fields: a TZ suffix on the date string
- must be rejected, not silently honored."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={"title": "x", "start_date": "2026-05-01Z", "start_time": "08:00"},
- )
-
- assert result["success"] is False
- assert "YYYY-MM-DD" in result["error"]
-
-
-@pytest.mark.asyncio
-async def test_create_event_split_rejects_tz_suffix_in_time():
- """A TZ suffix on the time string must also be rejected."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={"title": "x", "start_date": "2026-05-01", "start_time": "08:00 UTC"},
- )
-
- assert result["success"] is False
- assert "HH:MM" in result["error"]
-
-
-@pytest.mark.asyncio
-async def test_create_event_legacy_combined_start_still_works():
- """Backcompat: saved tool-call payloads using the old `start` field
- must still produce the same UTC datetime as before."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={"title": "Legacy", "start": "2026-05-01T08:00"},
- )
-
- assert result["success"] is True
- utc = captured["start_dt"].astimezone(timezone.utc)
- assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
-
-
-@pytest.mark.asyncio
-async def test_update_event_split_fields_reschedule_no_drift():
- """update_event with split fields must drift no calendar day either."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- captured = {}
-
- async def fake_find(*, user_id, query):
- ev = AsyncMock()
- ev.id = 99
- ev.title = "Coffee"
- return [ev]
-
- async def fake_update(*, user_id, event_id, **fields):
- captured.update(fields)
- ev = AsyncMock()
- ev.to_dict.return_value = {"id": event_id, **fields}
- return ev
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.find_events_by_query",
- side_effect=fake_find,
- ), patch(
- "fabledassistant.services.tools.calendar.events_update_event",
- side_effect=fake_update,
- ):
- result = await update_event_tool(
- user_id=1,
- arguments={
- "query": "Coffee",
- "start_date": "2026-05-01",
- "start_time": "08:00",
- },
- )
-
- assert result["success"] is True
- utc = captured["start_dt"].astimezone(timezone.utc)
- assert (utc.year, utc.month, utc.day, utc.hour) == (2026, 5, 1, 12)
-
-
-# ── expected_weekday verification (catches "this Friday" → Thursday bugs) ────
-
-
-@pytest.mark.asyncio
-async def test_create_event_expected_weekday_match_succeeds():
- """When expected_weekday agrees with the resolved local date's
- weekday, the event is created normally."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "Meeting",
- "start_date": "2026-05-01", # is a Friday
- "start_time": "08:00",
- "expected_weekday": "friday",
- },
- )
-
- assert result["success"] is True
- assert captured["start_dt"] is not None
-
-
-@pytest.mark.asyncio
-async def test_create_event_expected_weekday_mismatch_rejects_and_names_actual():
- """The reported failure mode: model picks Thursday and calls it
- Friday. With expected_weekday set, the create is rejected and the
- error names the actual weekday so the model can self-correct."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- create_called = False
-
- async def fake_create_event(**kwargs):
- nonlocal create_called
- create_called = True
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "Dentist",
- "start_date": "2026-04-30", # Thursday
- "start_time": "08:00",
- "expected_weekday": "friday",
- },
- )
-
- assert result["success"] is False
- assert "Thursday" in result["error"]
- assert "Friday" in result["error"]
- # Critical: the event must NOT have been created when the check failed.
- assert create_called is False
-
-
-@pytest.mark.asyncio
-async def test_create_event_expected_weekday_omitted_skips_check():
- """Backcompat: when expected_weekday isn't passed, no validation
- runs — the existing create flow is unchanged."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "x",
- "start_date": "2026-04-30",
- "start_time": "08:00",
- },
- )
-
- assert result["success"] is True
-
-
-@pytest.mark.asyncio
-async def test_create_event_expected_weekday_invalid_value_rejects():
- """Garbage in expected_weekday produces a clear validation error,
- not a silent pass."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "x",
- "start_date": "2026-05-01",
- "start_time": "08:00",
- "expected_weekday": "fri", # abbreviation not accepted
- },
- )
-
- assert result["success"] is False
- assert "weekday" in result["error"].lower()
-
-
-@pytest.mark.asyncio
-async def test_update_event_expected_weekday_mismatch_rejects():
- """update_event must enforce the same weekday check on reschedules."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- update_called = False
-
- async def fake_find(*, user_id, query):
- ev = AsyncMock()
- ev.id = 99
- ev.title = "Coffee"
- return [ev]
-
- async def fake_update(*, user_id, event_id, **fields):
- nonlocal update_called
- update_called = True
- ev = AsyncMock()
- ev.to_dict.return_value = {"id": event_id}
- return ev
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
- ), patch(
- "fabledassistant.services.tools.calendar.find_events_by_query",
- side_effect=fake_find,
- ), patch(
- "fabledassistant.services.tools.calendar.events_update_event",
- side_effect=fake_update,
- ):
- result = await update_event_tool(
- user_id=1,
- arguments={
- "query": "Coffee",
- "start_date": "2026-04-30", # Thursday
- "start_time": "08:00",
- "expected_weekday": "friday",
- },
- )
-
- assert result["success"] is False
- assert "Thursday" in result["error"]
- assert update_called is False
-
-
-# ── Multi-match disambiguation (Fable #161) ──────────────────────────────────
-
-
-@pytest.mark.asyncio
-async def test_update_event_refuses_ambiguous_query_with_candidates():
- """The reported failure: model called update_event(query='Appointment')
- when two events matched. Pre-fix: silently mutated matches[0] (the
- wrong event). Post-fix: returns success=False with a candidates list
- so the model can pick the right one."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- # Two events both matching "Appointment"
- ev_a = AsyncMock()
- ev_a.id = 2
- ev_a.title = "Appointment"
- ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
- ev_a.location = ""
- ev_b = AsyncMock()
- ev_b.id = 15
- ev_b.title = "Appointment"
- ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
- ev_b.location = "Dentist"
-
- update_called = False
-
- async def fake_find(*, user_id, query):
- return [ev_a, ev_b]
-
- async def fake_update(*, user_id, event_id, **fields):
- nonlocal update_called
- update_called = True
- return AsyncMock()
-
- with patch(
- "fabledassistant.services.tools.calendar.find_events_by_query",
- side_effect=fake_find,
- ), patch(
- "fabledassistant.services.tools.calendar.events_update_event",
- side_effect=fake_update,
- ):
- result = await update_event_tool(
- user_id=1,
- arguments={"query": "Appointment", "title": "Dentist"},
- )
-
- assert result["success"] is False
- assert "Found 2 events" in result["error"]
- assert "event_id" in result["error"].lower()
- assert "candidates" in result
- candidate_ids = [c["id"] for c in result["candidates"]]
- assert candidate_ids == [2, 15]
- # Critical: nothing was mutated.
- assert update_called is False
-
-
-@pytest.mark.asyncio
-async def test_update_event_with_event_id_skips_query_lookup():
- """Once the model picks a candidate via event_id, the call proceeds
- against that exact event — no query, no ambiguity."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- # find_events_by_query should NOT be called when event_id is supplied
- find_called = False
-
- async def fake_find(*, user_id, query):
- nonlocal find_called
- find_called = True
- return []
-
- target = AsyncMock()
- target.id = 15
- target.title = "Appointment"
- target.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
- target.location = ""
-
- async def fake_get_event(*, user_id, event_id):
- return target if event_id == 15 else None
-
- captured = {}
-
- async def fake_update(*, user_id, event_id, **fields):
- captured["event_id"] = event_id
- captured.update(fields)
- ev = AsyncMock()
- ev.to_dict.return_value = {"id": event_id, **fields}
- return ev
-
- with patch(
- "fabledassistant.services.tools.calendar.find_events_by_query",
- side_effect=fake_find,
- ), patch(
- "fabledassistant.services.events.get_event",
- side_effect=fake_get_event,
- ), patch(
- "fabledassistant.services.tools.calendar.events_update_event",
- side_effect=fake_update,
- ):
- result = await update_event_tool(
- user_id=1,
- arguments={"event_id": 15, "title": "Dentist: Permanent Crown Fitting"},
- )
-
- assert result["success"] is True
- assert captured["event_id"] == 15
- assert captured["title"] == "Dentist: Permanent Crown Fitting"
- assert find_called is False
-
-
-@pytest.mark.asyncio
-async def test_update_event_with_event_id_not_found_returns_error():
- """A bad event_id (non-existent) must surface clearly, not silently
- fall back to query-based lookup."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- async def fake_get_event(*, user_id, event_id):
- return None
-
- with patch(
- "fabledassistant.services.events.get_event",
- side_effect=fake_get_event,
- ):
- result = await update_event_tool(
- user_id=1,
- arguments={"event_id": 999, "title": "anything"},
- )
-
- assert result["success"] is False
- assert "999" in result["error"]
- assert "No event found" in result["error"]
-
-
-@pytest.mark.asyncio
-async def test_update_event_neither_query_nor_event_id_returns_error():
- """Calling update_event with neither identifier is a usage error."""
- from fabledassistant.services.tools.calendar import update_event_tool
-
- result = await update_event_tool(user_id=1, arguments={"title": "x"})
- assert result["success"] is False
- assert "query or event_id" in result["error"]
-
-
-@pytest.mark.asyncio
-async def test_delete_event_refuses_ambiguous_query_with_candidates():
- """delete_event must enforce the same disambiguation. Costly to get
- wrong — silent matches[0] would delete the wrong event entirely."""
- from fabledassistant.services.tools.calendar import delete_event_tool
-
- ev_a = AsyncMock()
- ev_a.id = 2; ev_a.title = "Appointment"
- ev_a.start_dt = __import__("datetime").datetime(2026, 4, 30, 12, 0, tzinfo=timezone.utc)
- ev_a.location = ""
- ev_b = AsyncMock()
- ev_b.id = 15; ev_b.title = "Appointment"
- ev_b.start_dt = __import__("datetime").datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc)
- ev_b.location = "Dentist"
-
- delete_called = False
-
- async def fake_find(*, user_id, query):
- return [ev_a, ev_b]
-
- async def fake_delete(*, user_id, event_id):
- nonlocal delete_called
- delete_called = True
-
- with patch(
- "fabledassistant.services.tools.calendar.find_events_by_query",
- side_effect=fake_find,
- ), patch(
- "fabledassistant.services.tools.calendar.events_delete_event",
- side_effect=fake_delete,
- ):
- result = await delete_event_tool(
- user_id=1, arguments={"query": "Appointment"},
- )
-
- assert result["success"] is False
- assert len(result["candidates"]) == 2
- # Most important assertion: nothing was actually deleted.
- assert delete_called is False
-
-
-@pytest.mark.asyncio
-async def test_create_event_weekday_check_uses_local_not_utc():
- """The weekday check must use the LOCAL date, not the UTC date.
- A late-evening Friday event in Tokyo (UTC+9) crosses midnight UTC,
- so a UTC-day check would call it Saturday — but the user's calendar
- says Friday. The check must respect the user's local view."""
- from fabledassistant.services.tools.calendar import create_event_tool
-
- captured = {}
-
- async def fake_create_event(**kwargs):
- captured.update(kwargs)
- event = AsyncMock()
- event.to_dict.return_value = {"id": 1}
- return event
-
- with patch(
- "fabledassistant.services.tools.calendar.get_user_tz",
- AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("Asia/Tokyo")),
- ), patch(
- "fabledassistant.services.tools.calendar.events_create_event",
- side_effect=fake_create_event,
- ):
- result = await create_event_tool(
- user_id=1,
- arguments={
- "title": "Friday night",
- "start_date": "2026-05-01", # Friday in Tokyo
- "start_time": "23:00", # 14:00 UTC same day; safe
- "expected_weekday": "friday",
- },
- )
-
- assert result["success"] is True
diff --git a/tests/test_consolidation.py b/tests/test_consolidation.py
deleted file mode 100644
index fb86782..0000000
--- a/tests/test_consolidation.py
+++ /dev/null
@@ -1,317 +0,0 @@
-"""Tests for the task-body consolidation pipeline (gate + full pass).
-
-Design: docs/superpowers/specs/2026-05-13-task-as-durable-record-design.md
-"""
-from datetime import datetime, timezone
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock, patch
-
-
-# ── Gate logic ───────────────────────────────────────────────────────────────
-
-
-async def test_maybe_consolidate_below_threshold_does_not_fire():
- """log_added with only 2 logs since last pass → no consolidation."""
- from fabledassistant.services import consolidation
-
- with patch.object(
- consolidation, "_logs_since_last_consolidation",
- new=AsyncMock(return_value=2),
- ), patch.object(
- consolidation, "consolidate_task", new=AsyncMock(),
- ) as mock_consolidate, patch.object(
- consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
- ):
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="log_added",
- )
-
- mock_consolidate.assert_not_awaited()
-
-
-async def test_maybe_consolidate_at_threshold_fires():
- """log_added with N logs since last pass → consolidation scheduled."""
- from fabledassistant.services import consolidation
-
- # asyncio.create_task is the actual scheduler; replace it so the test
- # doesn't need an event-loop background task to settle.
- with patch.object(
- consolidation, "_logs_since_last_consolidation",
- new=AsyncMock(return_value=3),
- ), patch.object(
- consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
- ), patch(
- "fabledassistant.services.consolidation.asyncio.create_task",
- ) as mock_create_task:
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="log_added",
- )
-
- mock_create_task.assert_called_once()
-
-
-async def test_maybe_consolidate_task_closed_always_fires():
- """task_closed bypasses the log-count gate."""
- from fabledassistant.services import consolidation
-
- with patch.object(
- consolidation, "_logs_since_last_consolidation",
- new=AsyncMock(return_value=0),
- ), patch.object(
- consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
- ), patch(
- "fabledassistant.services.consolidation.asyncio.create_task",
- ) as mock_create_task:
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="task_closed",
- )
-
- mock_create_task.assert_called_once()
-
-
-async def test_maybe_consolidate_setting_off_blocks_both_reasons():
- """auto_consolidate_tasks=false → neither trigger fires."""
- from fabledassistant.services import consolidation
-
- with patch.object(
- consolidation, "_logs_since_last_consolidation",
- new=AsyncMock(return_value=5),
- ), patch.object(
- consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=False),
- ), patch(
- "fabledassistant.services.consolidation.asyncio.create_task",
- ) as mock_create_task:
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="log_added",
- )
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="task_closed",
- )
-
- mock_create_task.assert_not_called()
-
-
-async def test_maybe_consolidate_unknown_reason_is_noop():
- """Unknown reasons get logged and skipped — not raised."""
- from fabledassistant.services import consolidation
-
- with patch.object(
- consolidation, "_logs_since_last_consolidation",
- new=AsyncMock(return_value=99),
- ), patch.object(
- consolidation, "_auto_consolidate_enabled", new=AsyncMock(return_value=True),
- ), patch(
- "fabledassistant.services.consolidation.asyncio.create_task",
- ) as mock_create_task:
- await consolidation.maybe_consolidate(
- user_id=1, task_id=42, reason="some_other_reason",
- )
-
- mock_create_task.assert_not_called()
-
-
-# ── Prompt builder ───────────────────────────────────────────────────────────
-
-
-def test_build_consolidation_prompt_includes_title_goal_and_logs():
- from fabledassistant.services.consolidation import _build_consolidation_prompt
-
- logs = [
- SimpleNamespace(
- content="tried REJECT, logs noisy",
- created_at=datetime(2026, 5, 13, 10, 0, tzinfo=timezone.utc),
- ),
- SimpleNamespace(
- content="switched to DROP",
- created_at=datetime(2026, 5, 13, 11, 0, tzinfo=timezone.utc),
- ),
- ]
- prompt = _build_consolidation_prompt(
- title="firewall tuning", description="quiet the logs", logs=logs,
- )
- assert "firewall tuning" in prompt
- assert "quiet the logs" in prompt
- assert "tried REJECT" in prompt
- assert "switched to DROP" in prompt
-
-
-def test_build_consolidation_prompt_handles_missing_description():
- from fabledassistant.services.consolidation import _build_consolidation_prompt
-
- logs = [
- SimpleNamespace(
- content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
- ),
- ]
- prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
- assert "no goal recorded" in prompt
-
-
-def test_build_consolidation_prompt_truncates_to_char_budget():
- from fabledassistant.services.consolidation import (
- _build_consolidation_prompt, MAX_PROMPT_INPUT_CHARS,
- )
-
- big = "x" * (MAX_PROMPT_INPUT_CHARS + 1000)
- logs = [
- SimpleNamespace(
- content=big, created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
- ),
- SimpleNamespace(
- content="should be dropped",
- created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
- ),
- ]
- prompt = _build_consolidation_prompt(title="t", description=None, logs=logs)
- # First log consumes the budget; the second is dropped.
- assert "should be dropped" not in prompt
-
-
-# ── consolidate_task orchestration (mocked DB + LLM + embedding) ─────────────
-
-
-def _make_mock_session(task, logs):
- """Return a mock async_session that hands out task and logs to successive
- .execute() calls. consolidate_task calls execute three times: select task,
- select logs (same context), then re-select task in the write-back block."""
- mock_session = AsyncMock()
-
- task_result = MagicMock()
- task_result.scalars.return_value.first.return_value = task
- logs_result = MagicMock()
- logs_result.scalars.return_value.all.return_value = logs
-
- mock_session.execute = AsyncMock(
- side_effect=[task_result, logs_result, task_result],
- )
- mock_session.commit = AsyncMock()
- mock_session.refresh = AsyncMock()
- mock_session.__aenter__ = AsyncMock(return_value=mock_session)
- mock_session.__aexit__ = AsyncMock(return_value=False)
- return mock_session
-
-
-async def test_consolidate_task_writes_body_and_timestamp_and_reembeds():
- task = MagicMock()
- task.id = 42
- task.title = "Cert renewal"
- task.description = "renew LE before Nov 30"
- task.status = "in_progress"
- task.body = "old body"
- task.consolidated_at = None
- logs = [
- SimpleNamespace(
- content="tried certbot renew",
- created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
- ),
- SimpleNamespace(
- content="switched to manual",
- created_at=datetime(2026, 5, 13, 1, tzinfo=timezone.utc),
- ),
- ]
- fake_summary = (
- "Started with certbot renew; DNS split-horizon failed. "
- "Switched to manual flow."
- )
-
- # Each consolidate_task call needs its own per-task lock state.
- # _locks is module-level defaultdict — patch it on the module to avoid
- # cross-test leakage of the held-lock state.
- from collections import defaultdict
- import asyncio as _asyncio
-
- with patch(
- "fabledassistant.services.consolidation._locks",
- new=defaultdict(_asyncio.Lock),
- ), patch(
- "fabledassistant.services.consolidation.async_session",
- return_value=_make_mock_session(task, logs),
- ), patch(
- "fabledassistant.services.llm.generate_completion",
- new=AsyncMock(return_value=fake_summary),
- ) as mock_llm, patch(
- "fabledassistant.services.embeddings.upsert_note_embedding",
- new=AsyncMock(),
- ) as mock_embed, patch(
- "fabledassistant.services.settings.get_setting",
- new=AsyncMock(return_value="gemma3:4b"),
- ):
- from fabledassistant.services.consolidation import consolidate_task
- await consolidate_task(1, 42)
-
- mock_llm.assert_awaited_once()
- mock_embed.assert_awaited_once()
- assert task.body == fake_summary
- assert task.consolidated_at is not None
-
-
-async def test_consolidate_task_llm_failure_leaves_body_untouched():
- task = MagicMock()
- task.id = 42
- task.title = "X"
- task.description = "y"
- task.status = "in_progress"
- task.body = "old body content"
- task.consolidated_at = None
- logs = [
- SimpleNamespace(
- content="a", created_at=datetime(2026, 5, 13, tzinfo=timezone.utc),
- ),
- ]
-
- from collections import defaultdict
- import asyncio as _asyncio
-
- with patch(
- "fabledassistant.services.consolidation._locks",
- new=defaultdict(_asyncio.Lock),
- ), patch(
- "fabledassistant.services.consolidation.async_session",
- return_value=_make_mock_session(task, logs),
- ), patch(
- "fabledassistant.services.llm.generate_completion",
- new=AsyncMock(side_effect=RuntimeError("ollama down")),
- ), patch(
- "fabledassistant.services.embeddings.upsert_note_embedding",
- new=AsyncMock(),
- ) as mock_embed, patch(
- "fabledassistant.services.settings.get_setting",
- new=AsyncMock(return_value="gemma3:4b"),
- ):
- from fabledassistant.services.consolidation import consolidate_task
- await consolidate_task(1, 42) # must not raise
-
- assert task.body == "old body content"
- assert task.consolidated_at is None
- mock_embed.assert_not_awaited()
-
-
-async def test_consolidate_task_skips_when_no_logs():
- task = MagicMock()
- task.id = 42
- task.title = "X"
- task.description = "y"
- task.status = "in_progress"
- task.body = "old"
- task.consolidated_at = None
-
- from collections import defaultdict
- import asyncio as _asyncio
-
- with patch(
- "fabledassistant.services.consolidation._locks",
- new=defaultdict(_asyncio.Lock),
- ), patch(
- "fabledassistant.services.consolidation.async_session",
- return_value=_make_mock_session(task, []),
- ), patch(
- "fabledassistant.services.llm.generate_completion", new=AsyncMock(),
- ) as mock_llm, patch(
- "fabledassistant.services.settings.get_setting",
- new=AsyncMock(return_value="gemma3:4b"),
- ):
- from fabledassistant.services.consolidation import consolidate_task
- await consolidate_task(1, 42)
-
- mock_llm.assert_not_awaited()
- assert task.body == "old"
diff --git a/tests/test_generation_log.py b/tests/test_generation_log.py
deleted file mode 100644
index 8dada22..0000000
--- a/tests/test_generation_log.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""Unit tests for the tool-call telemetry normalization logic.
-
-The DB-touching path of `log_tool_outcomes` requires a running PostgreSQL,
-so we test the pure normalization by stubbing the session. The shape
-contract that `generation_task.py` actually emits is:
-
- {"function": , "arguments": ..., "result": , "status": ...}
-
-with `result["success"]` being False for failures and `result["error"]`
-the message. We verify the helper splits these correctly into the
-attempted / succeeded / failed buckets.
-"""
-from __future__ import annotations
-
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-@pytest.mark.asyncio
-async def test_log_tool_outcomes_splits_success_and_failure():
- """A mix of success and error entries should split into the right buckets."""
- captured: dict = {}
-
- class _StubSession:
- def __init__(self):
- self.added = []
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *exc):
- return False
-
- def add(self, row):
- self.added.append(row)
- captured["row"] = row
-
- async def commit(self):
- return None
-
- stub = _StubSession()
-
- from fabledassistant.services import generation_log
-
- with patch.object(generation_log, "async_session", lambda: stub):
- await generation_log.log_tool_outcomes(
- user_id=1,
- conv_id=42,
- assistant_message_id=99,
- model="qwen3:8b",
- think_enabled=False,
- tools_available=["record_moment", "search_notes", "create_task"],
- tool_calls=[
- {
- "function": "record_moment",
- "arguments": {},
- "result": {"success": True, "moment_id": 17},
- "status": "success",
- },
- {
- "function": "create_task",
- "arguments": {},
- "result": {"success": False, "error": "missing required field 'title'"},
- "status": "error",
- },
- ],
- )
-
- row = captured["row"]
- assert row.user_id == 1
- assert row.conv_id == 42
- assert row.assistant_message_id == 99
- assert row.model == "qwen3:8b"
- assert row.think_enabled is False
- assert row.tools_available == ["create_task", "record_moment", "search_notes"]
- assert row.tools_attempted == ["record_moment", "create_task"]
- assert row.tools_succeeded == ["record_moment"]
- assert row.tools_failed == [{"name": "create_task", "error": "missing required field 'title'"}]
-
-
-@pytest.mark.asyncio
-async def test_log_tool_outcomes_handles_empty_tool_calls():
- """A turn with no tool calls produces an empty-attempted row, not an exception."""
- captured: dict = {}
-
- class _StubSession:
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *exc):
- return False
-
- def add(self, row):
- captured["row"] = row
-
- async def commit(self):
- return None
-
- from fabledassistant.services import generation_log
-
- with patch.object(generation_log, "async_session", lambda: _StubSession()):
- await generation_log.log_tool_outcomes(
- user_id=1,
- conv_id=42,
- assistant_message_id=99,
- model="mistral-small:22b",
- think_enabled=False,
- tools_available=["record_moment"],
- tool_calls=[],
- )
-
- row = captured["row"]
- assert row.tools_attempted == []
- assert row.tools_succeeded == []
- assert row.tools_failed == []
-
-
-@pytest.mark.asyncio
-async def test_log_tool_outcomes_swallows_exceptions():
- """Telemetry failure must NOT propagate — it would break user-facing flow."""
- from fabledassistant.services import generation_log
-
- bad_session = MagicMock()
- bad_session.return_value.__aenter__ = AsyncMock(side_effect=RuntimeError("db down"))
-
- with patch.object(generation_log, "async_session", bad_session):
- # Should NOT raise.
- await generation_log.log_tool_outcomes(
- user_id=1,
- conv_id=42,
- assistant_message_id=99,
- model="qwen3:8b",
- think_enabled=False,
- tools_available=[],
- tool_calls=[],
- )
-
-
-@pytest.mark.asyncio
-async def test_log_tool_outcomes_detects_success_false_without_error_field():
- """A tool result with success=False but no error string should still go to failed."""
- captured: dict = {}
-
- class _StubSession:
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, *exc):
- return False
-
- def add(self, row):
- captured["row"] = row
-
- async def commit(self):
- return None
-
- from fabledassistant.services import generation_log
-
- with patch.object(generation_log, "async_session", lambda: _StubSession()):
- await generation_log.log_tool_outcomes(
- user_id=1,
- conv_id=42,
- assistant_message_id=99,
- model="qwen3:8b",
- think_enabled=False,
- tools_available=["search_notes"],
- tool_calls=[
- {
- "function": "search_notes",
- "arguments": {},
- "result": {"success": False}, # no explicit error field
- "status": "success", # but status incorrectly says success
- },
- ],
- )
-
- row = captured["row"]
- assert row.tools_succeeded == []
- assert row.tools_failed == [{"name": "search_notes", "error": "unspecified"}]
diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py
deleted file mode 100644
index 8b556f8..0000000
--- a/tests/test_journal_closeout.py
+++ /dev/null
@@ -1,249 +0,0 @@
-"""Tests for journal closeout extraction helpers."""
-
-import datetime
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock, patch
-
-import pytest
-
-
-def _msg(role: str, content: str, kind: str | None = None):
- """Build a Message-like stand-in for the filter helpers."""
- metadata = {"kind": kind} if kind else None
- return SimpleNamespace(role=role, content=content, msg_metadata=metadata)
-
-
-def _fake_conv(conv_id: int = 1):
- return SimpleNamespace(id=conv_id)
-
-
-def _patch_db(messages, conv=None):
- """Build a context manager that fakes async_session() and the two
- select() calls (conversation lookup + messages query)."""
- session = AsyncMock()
- session.execute = AsyncMock()
- conv_result = MagicMock()
- conv_result.scalar_one_or_none = MagicMock(return_value=conv)
- msg_result = MagicMock()
- scalars = MagicMock()
- scalars.all = MagicMock(return_value=list(messages))
- msg_result.scalars = MagicMock(return_value=scalars)
- session.execute.side_effect = [conv_result, msg_result]
-
- session_ctx = MagicMock()
- session_ctx.__aenter__ = AsyncMock(return_value=session)
- session_ctx.__aexit__ = AsyncMock(return_value=None)
- return session_ctx
-
-
-def test_filter_excludes_daily_prep_messages():
- from fabledassistant.services.journal_closeout import _filter_messages
-
- msgs = [
- _msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
- _msg("user", "I want to focus on the auth refactor today."),
- _msg("assistant", "Got it. I'll keep tool calls quiet."),
- ]
- kept = _filter_messages(msgs)
- contents = [m.content for m in kept]
- assert "Good morning — here is today's plan…" not in contents
- assert "I want to focus on the auth refactor today." in contents
- assert "Got it. I'll keep tool calls quiet." in contents
-
-
-def test_build_transcript_labels_roles_and_caps_content():
- from fabledassistant.services.journal_closeout import _build_transcript
-
- msgs = [
- _msg("user", "hello"),
- _msg("assistant", "hi"),
- _msg("user", "x" * 600),
- ]
- out = _build_transcript(msgs)
- lines = out.splitlines()
- assert lines[0] == "USER: hello"
- assert lines[1] == "ASSISTANT: hi"
- # Third line content truncated to 500 chars
- assert lines[2].startswith("USER: ")
- assert len(lines[2]) == len("USER: ") + 500
-
-
-def test_build_transcript_keeps_only_last_20_messages():
- from fabledassistant.services.journal_closeout import _build_transcript
-
- msgs = [_msg("user", f"msg-{i}") for i in range(30)]
- out = _build_transcript(msgs)
- lines = out.splitlines()
- assert len(lines) == 20
- # Newest 20 means msg-10 through msg-29
- assert lines[0] == "USER: msg-10"
- assert lines[-1] == "USER: msg-29"
-
-
-def test_system_prompt_lists_structured_fields_to_exclude():
- """The prompt must explicitly tell the LLM not to restate structured
- fields, so the freeform learned_summary stays a narrow lane."""
- from fabledassistant.services.journal_closeout import SYSTEM_PROMPT
-
- text = SYSTEM_PROMPT.lower()
- for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
- assert field in text, f"system prompt should mention '{field}'"
- assert "(nothing to note)" in SYSTEM_PROMPT
-
-
-@pytest.mark.asyncio
-async def test_run_for_user_happy_path_appends_bullets():
- from fabledassistant.services import journal_closeout
-
- yesterday = datetime.date(2026, 5, 11)
- msgs = [
- _msg("assistant", "Good morning — here's today.", kind="daily_prep"),
- _msg("user", "Skip the news section — I never read it."),
- _msg("assistant", "Noted. Will drop it."),
- ]
-
- with (
- patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
- patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
- patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")),
- patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
- ):
- await journal_closeout.run_for_user(user_id=42, yesterday=yesterday)
-
- mock_append.assert_awaited_once_with(42, "- User skips news section")
-
-
-@pytest.mark.asyncio
-async def test_run_for_user_skips_when_no_conversation():
- from fabledassistant.services import journal_closeout
-
- with (
- patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
- patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
- patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
- ):
- await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
-
- mock_append.assert_not_awaited()
- mock_llm.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_run_for_user_skips_when_only_prep_message_after_filter():
- """If only the daily_prep message exists, the in-Python filter pass
- leaves zero messages and we short-circuit before the LLM."""
- from fabledassistant.services import journal_closeout
-
- msgs_post_sql = [
- _msg("assistant", "Daily prep block.", kind="daily_prep"),
- ]
- with (
- patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
- patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
- patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
- ):
- await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
-
- mock_append.assert_not_awaited()
- mock_llm.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_run_for_user_respects_nothing_to_note_sentinel():
- from fabledassistant.services import journal_closeout
-
- msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
- with (
- patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())),
- patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")),
- patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="(nothing to note)")),
- patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
- ):
- await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
-
- mock_append.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
- from fabledassistant.services import journal_scheduler as sched
-
- fake_scheduler = MagicMock()
- fake_scheduler.get_job = MagicMock(return_value=None)
- monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
- monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
- "prep_enabled": False, # isolate the closeout job
- "closeout_enabled": True,
- "day_rollover_hour": 4,
- }))
- monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
-
- await sched.update_user_schedule(user_id=7)
-
- added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
- assert "journal_closeout_7" in added
-
-
-@pytest.mark.asyncio
-async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
- from fabledassistant.services import journal_scheduler as sched
-
- fake_scheduler = MagicMock()
- fake_scheduler.get_job = MagicMock(return_value=None)
- monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
- monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
- "prep_enabled": False,
- "closeout_enabled": False,
- "day_rollover_hour": 4,
- }))
- monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
-
- await sched.update_user_schedule(user_id=7)
-
- added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
- assert "journal_closeout_7" not in added
-
-
-@pytest.mark.asyncio
-async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
- from fabledassistant.services import journal_scheduler as sched
-
- monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
- monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
- "closeout_enabled": True,
- "day_rollover_hour": 0, # slot has always passed
- }))
- yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
- fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])
-
- from fabledassistant.services import user_profile as up
- monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
-
- run_mock = AsyncMock()
- from fabledassistant.services import journal_closeout as jc
- monkeypatch.setattr(jc, "run_for_user", run_mock)
-
- await sched._closeout_catchup(user_id=7)
- run_mock.assert_not_awaited()
-
-
-@pytest.mark.asyncio
-async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
- from fabledassistant.services import journal_scheduler as sched
-
- monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
- monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
- "closeout_enabled": True,
- "day_rollover_hour": 0,
- }))
- fake_profile = SimpleNamespace(observations_raw=[])
-
- from fabledassistant.services import user_profile as up
- monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
-
- run_mock = AsyncMock()
- from fabledassistant.services import journal_closeout as jc
- monkeypatch.setattr(jc, "run_for_user", run_mock)
-
- await sched._closeout_catchup(user_id=7)
- run_mock.assert_awaited_once()
diff --git a/tests/test_journal_message_count.py b/tests/test_journal_message_count.py
deleted file mode 100644
index 4975791..0000000
--- a/tests/test_journal_message_count.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""Regression coverage for the journal `message_count: 0` bug.
-
-`_day_payload` (routes/journal.py) loads messages in a separate query and
-serializes the Conversation with `conv.to_dict()`. `Conversation.to_dict()`
-derives `message_count` from the `messages` relationship, which is NOT
-eager-loaded on that instance — so it silently fell back to 0 for every
-journal day (observed via the fable MCP: conversation #291 reported
-message_count 0 with 9 messages present).
-
-Full HTTP coverage of `_day_payload` needs a live DB (not available in the
-unit-test env — see test_events_routes.py). These tests pin the model-level
-contract that necessitates the route-side override.
-"""
-from datetime import date, datetime, timezone
-
-from fabledassistant.models.conversation import Conversation, Message
-
-
-def _conv() -> Conversation:
- now = datetime(2026, 5, 19, 9, 0, tzinfo=timezone.utc)
- return Conversation(
- user_id=1,
- conversation_type="journal",
- day_date=date(2026, 5, 19),
- title="2026-05-19",
- created_at=now,
- updated_at=now,
- )
-
-
-def test_to_dict_reports_zero_when_messages_relationship_not_loaded():
- """The trap: an untouched `messages` relationship is absent from the
- instance state, so to_dict() reports 0 regardless of DB rows. This is
- exactly the journal path's situation and why the route must override."""
- conv = _conv()
- assert conv.to_dict()["message_count"] == 0
-
-
-def test_to_dict_counts_when_messages_loaded():
- """When the relationship IS populated the count is correct — confirming
- the model isn't broken, the journal path just never loaded it."""
- conv = _conv()
- conv.messages = [
- Message(conversation_id=1, role="assistant", content="prep"),
- Message(conversation_id=1, role="user", content="hi"),
- ]
- assert conv.to_dict()["message_count"] == 2
-
-
-def test_day_payload_override_yields_true_count():
- """Pins the fix contract: _day_payload already holds the real message
- list and overrides message_count with len(messages), the same way the
- chat-list path (services/chat.py) supplies its own count."""
- conv = _conv() # messages relationship deliberately not loaded
- messages = [object(), object(), object()] # stand-ins for the loaded rows
-
- conv_dict = conv.to_dict()
- conv_dict["message_count"] = len(messages)
-
- assert conv_dict["message_count"] == 3
diff --git a/tests/test_journal_prep_filtering.py b/tests/test_journal_prep_filtering.py
deleted file mode 100644
index 362dca3..0000000
--- a/tests/test_journal_prep_filtering.py
+++ /dev/null
@@ -1,195 +0,0 @@
-"""Tests for the journal prep filtering helpers added in #159."""
-
-import datetime
-from types import SimpleNamespace
-from zoneinfo import ZoneInfo
-
-import pytest
-
-
-# ── _task_to_prep_dict ────────────────────────────────────────────────────────
-
-
-def test_task_to_prep_dict_marks_overdue_with_days_count():
- from fabledassistant.services.journal_prep import _task_to_prep_dict
-
- today = datetime.date(2026, 4, 29)
- task = SimpleNamespace(
- id=2, title="Research Weston's ADHD Evaluation",
- status="todo", priority="high",
- due_date=datetime.date(2026, 2, 20),
- )
- d = _task_to_prep_dict(task, today)
- assert d["days_overdue"] == 68
- assert d["due_date"] == "2026-02-20"
-
-
-def test_task_to_prep_dict_due_today_no_overdue_marker():
- from fabledassistant.services.journal_prep import _task_to_prep_dict
-
- today = datetime.date(2026, 4, 29)
- task = SimpleNamespace(
- id=10, title="Pick up dry cleaning",
- status="todo", priority="none",
- due_date=today,
- )
- d = _task_to_prep_dict(task, today)
- assert "days_overdue" not in d
-
-
-def test_task_to_prep_dict_handles_no_due_date():
- from fabledassistant.services.journal_prep import _task_to_prep_dict
-
- task = SimpleNamespace(
- id=11, title="Long-running side project",
- status="in_progress", priority="medium",
- due_date=None,
- )
- d = _task_to_prep_dict(task, datetime.date(2026, 4, 29))
- assert d["due_date"] is None
- assert "days_overdue" not in d
-
-
-# ── _filter_proximate_events ──────────────────────────────────────────────────
-
-
-def test_filter_proximate_drops_far_future_recurring_event():
- """The reported failure: a recurring Birthday event with start_dt
- 2026-09-29 surfaced in every daily prep. The proximity filter drops it."""
- from fabledassistant.services.journal_prep import _filter_proximate_events
-
- events = [
- {"id": 13, "title": "Birthday", "start_dt": "2026-09-29T00:00:00+00:00"},
- ]
- kept = _filter_proximate_events(
- events,
- day_date=datetime.date(2026, 4, 29),
- user_tz=ZoneInfo("America/New_York"),
- )
- assert kept == []
-
-
-def test_filter_proximate_keeps_events_within_window():
- """Events within ±7 days of the prep date stay."""
- from fabledassistant.services.journal_prep import _filter_proximate_events
-
- events = [
- {"id": 1, "title": "Today", "start_dt": "2026-04-29T13:00:00+00:00"},
- {"id": 2, "title": "Tomorrow", "start_dt": "2026-04-30T12:00:00+00:00"},
- {"id": 3, "title": "Next week", "start_dt": "2026-05-05T15:00:00+00:00"},
- {"id": 4, "title": "Two weeks out", "start_dt": "2026-05-15T15:00:00+00:00"},
- ]
- kept = _filter_proximate_events(
- events,
- day_date=datetime.date(2026, 4, 29),
- user_tz=ZoneInfo("America/New_York"),
- )
- kept_ids = [e["id"] for e in kept]
- assert 1 in kept_ids
- assert 2 in kept_ids
- assert 3 in kept_ids
- assert 4 not in kept_ids
-
-
-def test_filter_proximate_uses_local_date_not_utc():
- """An event at 04:30 UTC on 2026-04-30 = 00:30 local on 2026-04-30 in
- NY (EDT, UTC-4). Local date is 2026-04-30, delta from 2026-04-29 = 1
- day. Must NOT cross-classify based on UTC date alone."""
- from fabledassistant.services.journal_prep import _filter_proximate_events
-
- events = [
- {"id": 99, "title": "Late-night dentist", "start_dt": "2026-04-30T04:30:00+00:00"},
- ]
- kept = _filter_proximate_events(
- events,
- day_date=datetime.date(2026, 4, 29),
- user_tz=ZoneInfo("America/New_York"),
- )
- assert len(kept) == 1
-
-
-def test_filter_proximate_keeps_unparseable_dates():
- """Bad date strings are kept rather than silently suppressing real
- events on a parser bug."""
- from fabledassistant.services.journal_prep import _filter_proximate_events
-
- events = [
- {"id": 50, "title": "Garbage", "start_dt": "not-a-date"},
- {"id": 51, "title": "Empty", "start_dt": ""},
- {"id": 52, "title": "Missing"},
- ]
- kept = _filter_proximate_events(
- events,
- day_date=datetime.date(2026, 4, 29),
- user_tz=ZoneInfo("America/New_York"),
- )
- assert len(kept) == 3
-
-
-# ── _render_sections_for_prompt — overdue framing ────────────────────────────
-
-
-def test_render_overdue_includes_staleness_duration():
- """The rendered prompt block must surface days_overdue so the LLM
- can frame stale tasks correctly."""
- from fabledassistant.services.journal_prep import _render_sections_for_prompt
-
- sections = {
- "tasks_due_today": [],
- "tasks_upcoming": [],
- "tasks_overdue": [{
- "id": 2, "title": "Research Weston's ADHD Evaluation",
- "status": "todo", "priority": "high",
- "due_date": "2026-02-20", "days_overdue": 68,
- }],
- }
- rendered = _render_sections_for_prompt(sections)
- assert "OVERDUE TASKS" in rendered
- # Unambiguous overdue phrasing (replaced the old "68 days ago", which the
- # model parroted alongside the header into a self-contradiction).
- assert "68 days overdue" in rendered
- assert "2026-02-20" in rendered
- # Crucially, NOT framed as due today.
- assert "DUE TODAY" not in rendered
-
-
-def test_render_due_today_no_due_date_repetition():
- """Tasks in the DUE TODAY bucket don't need the (due 2026-04-29)
- parenthetical — the section header already says 'today'."""
- from fabledassistant.services.journal_prep import _render_sections_for_prompt
-
- sections = {
- "tasks_due_today": [{
- "id": 1, "title": "Pick up dry cleaning",
- "status": "todo", "priority": "none",
- "due_date": "2026-04-29",
- }],
- "tasks_upcoming": [],
- "tasks_overdue": [],
- }
- rendered = _render_sections_for_prompt(sections)
- assert "TASKS DUE TODAY" in rendered
- assert "Pick up dry cleaning" in rendered
- # The line itself shouldn't repeat the due date.
- line = next(
- (l for l in rendered.splitlines() if "Pick up dry cleaning" in l),
- "",
- )
- assert "2026-04-29" not in line
-
-
-def test_render_three_buckets_in_correct_order():
- """When all three buckets have content, the prompt sees them in
- DUE TODAY → UPCOMING → OVERDUE order so the LLM leads with today."""
- from fabledassistant.services.journal_prep import _render_sections_for_prompt
-
- sections = {
- "tasks_due_today": [{"id": 1, "title": "Today task", "status": "todo", "priority": "none", "due_date": "2026-04-29"}],
- "tasks_upcoming": [{"id": 2, "title": "Upcoming task", "status": "todo", "priority": "none", "due_date": "2026-05-02"}],
- "tasks_overdue": [{"id": 3, "title": "Stale task", "status": "todo", "priority": "none", "due_date": "2026-02-20", "days_overdue": 68}],
- }
- rendered = _render_sections_for_prompt(sections)
- today_idx = rendered.index("TASKS DUE TODAY")
- upcoming_idx = rendered.index("UPCOMING TASKS")
- overdue_idx = rendered.index("OVERDUE TASKS")
- assert today_idx < upcoming_idx < overdue_idx
diff --git a/tests/test_journal_prep_hardening.py b/tests/test_journal_prep_hardening.py
deleted file mode 100644
index 5b50e21..0000000
--- a/tests/test_journal_prep_hardening.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Coverage for the daily-prep hardening pass.
-
-Targets the two prep-quality defects observed via the fable MCP on
-2026-05-19 (conversation #291):
-
- 1. Fabricated weather ("partly cloudy with a high of 68°F and a 15%
- chance of rain") emitted with an empty weather section.
- 2. Self-contradictory overdue phrasing ("still on the list, not
- currently due, 88 days ago"; "1 days ago").
-
-All targets are pure sync functions — no DB / LLM needed.
-"""
-import datetime
-
-from fabledassistant.services.journal_prep import (
- _prose_fabricated_weather,
- _render_sections_for_prompt,
- _render_task_line,
-)
-
-TODAY = datetime.date(2026, 5, 19)
-
-
-# ── Overdue phrasing ────────────────────────────────────────────────────────
-
-
-def test_overdue_line_singular_day():
- line = _render_task_line(
- {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
- include_due=False, include_overdue=True,
- )
- assert "1 day overdue" in line
- assert "1 days" not in line # the old ungrammatical form is gone
-
-
-def test_overdue_line_plural_days_no_contradiction():
- line = _render_task_line(
- {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
- include_due=False, include_overdue=True,
- )
- assert "88 days overdue" in line
- assert "was due 2026-02-20" in line
- assert "days ago" not in line # replaced by unambiguous "overdue"
-
-
-def test_overdue_section_header_is_unambiguous():
- out = _render_sections_for_prompt({
- "tasks_overdue": [
- {"title": "Research X", "due_date": "2026-02-20", "days_overdue": 88},
- ],
- })
- assert "past their due date, still open" in out
- # The phrase that the model parroted into the self-contradiction is gone.
- assert "not currently due" not in out
-
-
-# ── Weather absent-marker ───────────────────────────────────────────────────
-
-
-def test_empty_weather_emits_explicit_none_marker():
- out = _render_sections_for_prompt({
- "tasks_overdue": [
- {"title": "Change my oil", "due_date": "2026-05-18", "days_overdue": 1},
- ],
- "weather": [],
- })
- assert "WEATHER: none available" in out
- assert "Do NOT mention weather" in out
-
-
-def test_present_weather_renders_data_not_marker():
- out = _render_sections_for_prompt({
- "tasks_due_today": [{"title": "Ship it"}],
- "weather": [{
- "location_label": "Home",
- "forecast_json": {"daily": {
- "temperature_2m_max": [70],
- "temperature_2m_min": [52],
- "precipitation_probability_max": [10],
- }},
- }],
- })
- assert "WEATHER:" in out
- assert "none available" not in out
- assert "high 70°" in out
-
-
-def test_quiet_day_returns_fabrication_forbidding_text():
- out = _render_sections_for_prompt({})
- assert "quiet day" in out
- assert "Do not invent weather" in out
-
-
-# ── Fabricated-weather guard ────────────────────────────────────────────────
-
-
-def test_guard_flags_the_real_observed_hallucination():
- prose = (
- "You have two overdue tasks. The weather today is partly cloudy "
- "with a high of 68°F and a 15% chance of rain. What's on your mind?"
- )
- assert _prose_fabricated_weather(prose, {"weather": []}) is True
-
-
-def test_guard_flags_bare_temperature_glyph():
- assert _prose_fabricated_weather("Expect around 72° later.", {"weather": []}) is True
-
-
-def test_guard_passes_clean_prose():
- prose = "Two tasks are overdue and nothing is due today. What's on your mind?"
- assert _prose_fabricated_weather(prose, {"weather": []}) is False
-
-
-def test_guard_no_false_positive_on_rain_in_task_title():
- # Bare "rain" must NOT trip the guard — only "chance of rain" etc. does.
- prose = "Don't forget to buy rain boots and finish the training module."
- assert _prose_fabricated_weather(prose, {"weather": []}) is False
-
-
-def test_guard_allows_weather_when_data_present():
- prose = "Home is 70°F with a 10% chance of rain."
- sections = {"weather": [{"location_label": "Home"}]}
- assert _prose_fabricated_weather(prose, sections) is False
diff --git a/tests/test_journal_search.py b/tests/test_journal_search.py
deleted file mode 100644
index 44c2e4f..0000000
--- a/tests/test_journal_search.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""Unit tests for the cosine helper inside journal_search."""
-import math
-
-from fabledassistant.services.journal_search import _cosine
-
-
-def test_cosine_identical_vectors():
- v = [1.0, 2.0, 3.0]
- assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9)
-
-
-def test_cosine_orthogonal_vectors():
- a = [1.0, 0.0]
- b = [0.0, 1.0]
- assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9)
-
-
-def test_cosine_zero_vector_returns_zero():
- assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0
-
-
-def test_cosine_empty_returns_zero():
- assert _cosine([], [1.0]) == 0.0
diff --git a/tests/test_lookup_tool.py b/tests/test_lookup_tool.py
deleted file mode 100644
index 1d9833d..0000000
--- a/tests/test_lookup_tool.py
+++ /dev/null
@@ -1,111 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, MagicMock, patch
-
-from fabledassistant.services.tools.web import lookup_tool
-
-
-@pytest.mark.asyncio
-async def test_lookup_wikipedia_hit():
- wiki_data = {
- "title": "QUIC",
- "extract": "QUIC is a transport layer protocol.",
- "url": "https://en.wikipedia.org/wiki/QUIC",
- "thumbnail_url": "",
- }
-
- with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
- patch("fabledassistant.config.Config") as mock_config:
- mock_config.searxng_enabled.return_value = False
- result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
-
- assert result["success"] is True
- assert result["type"] == "lookup"
- assert result["data"]["wikipedia"]["title"] == "QUIC"
- assert result["data"]["web"] == []
- assert "image" not in result["data"]["wikipedia"]
-
-
-@pytest.mark.asyncio
-async def test_lookup_wikipedia_miss_searxng_fallback():
- searxng_results = [
- {"url": "https://example.com/quic", "title": "QUIC Explained", "snippet": "An overview..."},
- ]
-
- with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
- patch("fabledassistant.config.Config") as mock_config, \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
- patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Full article about QUIC..."):
- mock_config.searxng_enabled.return_value = True
- result = await lookup_tool(user_id=1, arguments={"query": "QUIC"})
-
- assert result["success"] is True
- assert result["data"]["wikipedia"] is None
- assert len(result["data"]["web"]) == 1
- assert result["data"]["web"][0]["url"] == "https://example.com/quic"
-
-
-@pytest.mark.asyncio
-async def test_lookup_parallel_both_sources():
- wiki_data = {
- "title": "President of the United States",
- "extract": "The president is the head of state.",
- "url": "https://en.wikipedia.org/wiki/President_of_the_United_States",
- "thumbnail_url": "",
- }
- searxng_results = [
- {"url": "https://whitehouse.gov", "title": "The White House", "snippet": "Current admin..."},
- ]
-
- with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
- patch("fabledassistant.config.Config") as mock_config, \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=searxng_results), \
- patch("fabledassistant.services.article_fetcher.fetch_article_text", new_callable=AsyncMock, return_value="Current president is..."):
- mock_config.searxng_enabled.return_value = True
- result = await lookup_tool(user_id=1, arguments={"query": "president of the united states"})
-
- assert result["success"] is True
- assert result["data"]["wikipedia"]["title"] == "President of the United States"
- assert len(result["data"]["web"]) == 1
-
-
-@pytest.mark.asyncio
-async def test_lookup_wikipedia_thumbnail_cached():
- wiki_data = {
- "title": "Hedgehog",
- "extract": "Hedgehogs are spiny mammals.",
- "url": "https://en.wikipedia.org/wiki/Hedgehog",
- "thumbnail_url": "https://upload.wikimedia.org/.../Hedgehog.jpg",
- }
- mock_record = MagicMock()
- mock_record.id = 42
-
- with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=wiki_data), \
- patch("fabledassistant.config.Config") as mock_config, \
- patch("fabledassistant.services.images.fetch_and_store_image", new_callable=AsyncMock, return_value=mock_record):
- mock_config.searxng_enabled.return_value = False
- result = await lookup_tool(user_id=1, arguments={"query": "hedgehog"})
-
- assert result["data"]["wikipedia"]["image"]["embed"] == ""
- assert "Wikipedia" in result["data"]["wikipedia"]["image"]["citation"]
- assert "image_instructions" in result["data"]
-
-
-@pytest.mark.asyncio
-async def test_lookup_wikipedia_miss_no_searxng():
- with patch("fabledassistant.services.wikipedia.wiki_summary", new_callable=AsyncMock, return_value=None), \
- patch("fabledassistant.config.Config") as mock_config:
- mock_config.searxng_enabled.return_value = False
- result = await lookup_tool(user_id=1, arguments={"query": "xyznonexistent"})
-
- assert result["success"] is True
- assert "message" in result["data"]
-
-
-@pytest.mark.asyncio
-async def test_lookup_always_available():
- with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock, return_value=False), \
- patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock, return_value="false"):
- from fabledassistant.services.tools import get_tools_for_user
- tools = await get_tools_for_user(user_id=1)
- tool_names = {t["function"]["name"] for t in tools}
- assert "lookup" in tool_names
diff --git a/tests/test_notes_consolidation_trigger.py b/tests/test_notes_consolidation_trigger.py
deleted file mode 100644
index cb8f109..0000000
--- a/tests/test_notes_consolidation_trigger.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""Tests for the status-terminal consolidation trigger in update_note."""
-from unittest.mock import AsyncMock, MagicMock, patch
-
-
-def _mock_session_for_update(mock_note):
- mock_session = AsyncMock()
- mock_result = MagicMock()
- mock_result.scalars.return_value.first.return_value = mock_note
- mock_session.execute = AsyncMock(return_value=mock_result)
- mock_session.commit = AsyncMock()
- mock_session.refresh = AsyncMock()
- mock_session.__aenter__ = AsyncMock(return_value=mock_session)
- mock_session.__aexit__ = AsyncMock(return_value=False)
- return mock_session
-
-
-async def test_update_note_to_done_triggers_consolidation():
- """status: in_progress → done should fire maybe_consolidate(task_closed)."""
- mock_note = MagicMock()
- mock_note.id = 42
- mock_note.status = "in_progress" # pre-update state
- mock_note.body = ""
- mock_note.title = ""
- mock_note.tags = []
- mock_note.started_at = None
- mock_note.completed_at = None
- mock_note.recurrence_rule = None
- mock_note.project_id = None
-
- with patch(
- "fabledassistant.services.notes.async_session",
- return_value=_mock_session_for_update(mock_note),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- from fabledassistant.services.notes import update_note
- await update_note(1, 42, status="done")
-
- mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
-
-
-async def test_update_note_to_cancelled_triggers_consolidation():
- mock_note = MagicMock()
- mock_note.id = 42
- mock_note.status = "in_progress"
- mock_note.body = ""
- mock_note.title = ""
- mock_note.tags = []
- mock_note.started_at = None
- mock_note.completed_at = None
- mock_note.recurrence_rule = None
- mock_note.project_id = None
-
- with patch(
- "fabledassistant.services.notes.async_session",
- return_value=_mock_session_for_update(mock_note),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- from fabledassistant.services.notes import update_note
- await update_note(1, 42, status="cancelled")
-
- mock_mc.assert_awaited_once_with(1, 42, reason="task_closed")
-
-
-async def test_update_note_to_in_progress_does_not_trigger_consolidation():
- """Non-terminal status changes don't fire the trigger."""
- mock_note = MagicMock()
- mock_note.id = 42
- mock_note.status = "todo"
- mock_note.body = ""
- mock_note.title = ""
- mock_note.tags = []
- mock_note.started_at = None
- mock_note.completed_at = None
- mock_note.recurrence_rule = None
- mock_note.project_id = None
-
- with patch(
- "fabledassistant.services.notes.async_session",
- return_value=_mock_session_for_update(mock_note),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- from fabledassistant.services.notes import update_note
- await update_note(1, 42, status="in_progress")
-
- mock_mc.assert_not_awaited()
-
-
-async def test_update_note_already_done_does_not_retrigger():
- """If the status was already 'done' and update_note(status='done') runs
- again, no fresh trigger fires — only transitions count."""
- mock_note = MagicMock()
- mock_note.id = 42
- mock_note.status = "done"
- mock_note.body = ""
- mock_note.title = ""
- mock_note.tags = []
- mock_note.started_at = None
- mock_note.completed_at = None
- mock_note.recurrence_rule = None
- mock_note.project_id = None
-
- with patch(
- "fabledassistant.services.notes.async_session",
- return_value=_mock_session_for_update(mock_note),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- from fabledassistant.services.notes import update_note
- await update_note(1, 42, status="done")
-
- mock_mc.assert_not_awaited()
diff --git a/tests/test_record_moment_guards.py b/tests/test_record_moment_guards.py
deleted file mode 100644
index 2772dcd..0000000
--- a/tests/test_record_moment_guards.py
+++ /dev/null
@@ -1,181 +0,0 @@
-"""Tests for the record_moment server-side data-hygiene guards.
-
-These guard against two failure modes observed in real journal usage:
-
-1. The LLM emits ``task_titles`` referencing a task that's only in the
- prep context — not actually mentioned by the user. Without a check,
- every moment ends up linked to whatever's open in the user's queue.
-
-2. The LLM emits generic placeholder ``place_names`` like ``"work"`` /
- ``"home"`` instead of real place notes. These role-labels aren't
- places.
-
-Both are exercised through the pure helpers; full-stack handler tests
-would require a session-bound DB fixture this suite doesn't have yet.
-"""
-
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-
-def test_content_keywords_drops_stopwords_and_short_tokens():
- from fabledassistant.services.tools.journal import _content_keywords
-
- kw = _content_keywords(
- "I went to the store to pick up milk and bread for the kids."
- )
- # Stopwords (the, to, for, and, i) gone; short tokens (kids? 4 chars - kept)
- # filtered. Real content words kept.
- assert "store" in kw
- assert "milk" in kw
- assert "bread" in kw
- assert "kids" in kw
- assert "the" not in kw
- assert "to" not in kw
- assert "for" not in kw
- assert "i" not in kw
-
-
-def test_content_keywords_extracts_named_entities():
- """Place names and project nouns survive tokenization. Short numeric
- fragments (e.g. '14') get filtered alongside other <3-char tokens —
- the surrounding alpha keywords carry the overlap weight in practice."""
- from fabledassistant.services.tools.journal import _content_keywords
-
- kw = _content_keywords("Migration prep at Branch 14 Bedford.")
- assert "branch" in kw
- assert "bedford" in kw
- assert "migration" in kw
-
-
-def test_filter_placeholder_places_drops_generic_role_labels():
- from fabledassistant.services.tools.journal import _filter_placeholder_places
-
- kept, dropped = _filter_placeholder_places(
- ["work", "Famous Supply", "home", "Branch 14 Bedford", "the office"]
- )
- assert "Famous Supply" in kept
- assert "Branch 14 Bedford" in kept
- assert "work" in dropped
- assert "home" in dropped
- assert "the office" in dropped
-
-
-def test_filter_placeholder_places_is_case_insensitive():
- from fabledassistant.services.tools.journal import _filter_placeholder_places
-
- kept, dropped = _filter_placeholder_places(["WORK", "Home", "OFFICE"])
- assert kept == []
- assert set(dropped) == {"WORK", "Home", "OFFICE"}
-
-
-def test_filter_placeholder_places_preserves_real_places_named_similarly():
- """A user-defined place that happens to be ONE word but isn't a generic
- role-label (e.g. 'Akron') stays."""
- from fabledassistant.services.tools.journal import _filter_placeholder_places
-
- kept, dropped = _filter_placeholder_places(["Akron", "Cleveland"])
- assert kept == ["Akron", "Cleveland"]
- assert dropped == []
-
-
-@pytest.mark.asyncio
-async def test_keyword_overlap_drops_unrelated_task_link():
- """The reported failure mode: model attached Weston's ADHD task to a
- Docker-swarm moment. With the keyword guard, the link gets dropped."""
- from fabledassistant.services.tools.journal import (
- _filter_task_ids_by_keyword_overlap,
- )
-
- # Mock the DB lookup to return the offending task title for id=2.
- fake_session = AsyncMock()
- fake_session.execute = AsyncMock()
- fake_session.execute.return_value.all = lambda: [
- (2, "Research Weston's ADHD Evaluation"),
- ]
- fake_session.__aenter__ = AsyncMock(return_value=fake_session)
- fake_session.__aexit__ = AsyncMock(return_value=None)
-
- with patch(
- "fabledassistant.services.tools.journal.async_session",
- return_value=fake_session,
- ):
- kept = await _filter_task_ids_by_keyword_overlap(
- user_id=1,
- content="Working on restaging Docker in the swarm; encountered network breakage on a Windows node.",
- task_ids=[2],
- )
-
- assert kept == []
-
-
-@pytest.mark.asyncio
-async def test_keyword_overlap_keeps_genuinely_referenced_task():
- """When the moment content shares a meaningful keyword with the task
- title, the link is preserved."""
- from fabledassistant.services.tools.journal import (
- _filter_task_ids_by_keyword_overlap,
- )
-
- fake_session = AsyncMock()
- fake_session.execute = AsyncMock()
- fake_session.execute.return_value.all = lambda: [
- (5, "Restage Docker on Fam-dockerwin04"),
- ]
- fake_session.__aenter__ = AsyncMock(return_value=fake_session)
- fake_session.__aexit__ = AsyncMock(return_value=None)
-
- with patch(
- "fabledassistant.services.tools.journal.async_session",
- return_value=fake_session,
- ):
- kept = await _filter_task_ids_by_keyword_overlap(
- user_id=1,
- content="Reinstalled Microsoft container support; Docker restage now works.",
- task_ids=[5],
- )
-
- # 'docker' appears in both → keep
- assert kept == [5]
-
-
-@pytest.mark.asyncio
-async def test_keyword_overlap_handles_partial_relevance():
- """Mixed ids: keep the related one, drop the unrelated one."""
- from fabledassistant.services.tools.journal import (
- _filter_task_ids_by_keyword_overlap,
- )
-
- fake_session = AsyncMock()
- fake_session.execute = AsyncMock()
- fake_session.execute.return_value.all = lambda: [
- (2, "Research Weston's ADHD Evaluation"),
- (5, "Restage Docker on Fam-dockerwin04"),
- ]
- fake_session.__aenter__ = AsyncMock(return_value=fake_session)
- fake_session.__aexit__ = AsyncMock(return_value=None)
-
- with patch(
- "fabledassistant.services.tools.journal.async_session",
- return_value=fake_session,
- ):
- kept = await _filter_task_ids_by_keyword_overlap(
- user_id=1,
- content="Working on restaging Docker in the swarm.",
- task_ids=[2, 5],
- )
-
- assert kept == [5]
-
-
-@pytest.mark.asyncio
-async def test_keyword_overlap_empty_task_ids_returns_empty():
- from fabledassistant.services.tools.journal import (
- _filter_task_ids_by_keyword_overlap,
- )
-
- kept = await _filter_task_ids_by_keyword_overlap(
- user_id=1, content="anything", task_ids=[],
- )
- assert kept == []
diff --git a/tests/test_research_pipeline.py b/tests/test_research_pipeline.py
deleted file mode 100644
index 8974d5a..0000000
--- a/tests/test_research_pipeline.py
+++ /dev/null
@@ -1,231 +0,0 @@
-"""Tests for the multi-note research pipeline."""
-import json
-import pytest
-from unittest.mock import AsyncMock, patch
-
-
-@pytest.mark.asyncio
-async def test_generate_outline_parses_valid_json():
- """_generate_outline returns parsed sections when model returns valid JSON."""
- from fabledassistant.services.research import _generate_outline
-
- outline_json = json.dumps([
- {"title": "Section One", "focus": "Focus one"},
- {"title": "Section Two", "focus": "Focus two"},
- {"title": "Section Three", "focus": "Focus three"},
- ])
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
- result = await _generate_outline("test topic", [], "test-model")
-
- assert len(result) == 3
- assert result[0]["title"] == "Section One"
- assert result[0]["focus"] == "Focus one"
-
-
-@pytest.mark.asyncio
-async def test_generate_outline_returns_empty_on_parse_failure():
- """_generate_outline returns [] when model output is not parseable JSON."""
- from fabledassistant.services.research import _generate_outline
-
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="not json at all"):
- result = await _generate_outline("test topic", [], "test-model")
-
- assert result == []
-
-
-@pytest.mark.asyncio
-async def test_generate_outline_returns_empty_when_fewer_than_2_sections():
- """_generate_outline returns [] when model returns fewer than 2 valid sections."""
- from fabledassistant.services.research import _generate_outline
-
- outline_json = json.dumps([{"title": "Only One", "focus": "Focus"}])
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
- result = await _generate_outline("test topic", [], "test-model")
-
- assert result == []
-
-
-@pytest.mark.asyncio
-async def test_generate_outline_truncates_to_8():
- """_generate_outline truncates results to at most 8 sections."""
- from fabledassistant.services.research import _generate_outline
-
- sections = [{"title": f"Section {i}", "focus": f"Focus {i}"} for i in range(10)]
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=json.dumps(sections)):
- result = await _generate_outline("test topic", [], "test-model")
-
- assert len(result) == 8
-
-
-@pytest.mark.asyncio
-async def test_generate_outline_skips_malformed_entries():
- """_generate_outline filters out entries missing title or focus."""
- from fabledassistant.services.research import _generate_outline
-
- outline_json = json.dumps([
- {"title": "Good One", "focus": "Good focus"},
- {"title": "Missing focus"},
- {"focus": "Missing title"},
- {"title": "Good Two", "focus": "Good focus two"},
- {"title": "Good Three", "focus": "Good focus three"},
- ])
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value=outline_json):
- result = await _generate_outline("test topic", [], "test-model")
-
- assert len(result) == 3
- assert all(s["title"] and s["focus"] for s in result)
-
-
-@pytest.mark.asyncio
-async def test_synthesize_section_returns_title_and_body():
- """_synthesize_section returns (section_title, body) from model output."""
- from fabledassistant.services.research import _synthesize_section
-
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="The body of this section."):
- title, body = await _synthesize_section(
- section_title="Quantum Entanglement: Mechanisms",
- section_focus="How entanglement works at the particle level",
- sources=[],
- model="test-model",
- )
-
- assert title == "Quantum Entanglement: Mechanisms"
- assert body == "The body of this section."
-
-
-@pytest.mark.asyncio
-async def test_synthesize_section_strips_whitespace():
- """_synthesize_section strips leading/trailing whitespace from body."""
- from fabledassistant.services.research import _synthesize_section
-
- with patch("fabledassistant.services.research.generate_completion", new_callable=AsyncMock, return_value="\n\n body text \n\n"):
- title, body = await _synthesize_section("Title", "Focus", [], "model")
-
- assert body == "body text"
-
-
-@pytest.mark.asyncio
-async def test_pipeline_creates_section_notes_and_index():
- """run_research_pipeline creates N section notes + 1 index note with links, returns index."""
- from unittest.mock import MagicMock
-
- outline = [
- {"title": "Section A", "focus": "Focus A"},
- {"title": "Section B", "focus": "Focus B"},
- {"title": "Section C", "focus": "Focus C"},
- ]
-
- note_id_counter = iter(range(10, 20))
-
- def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
- n = MagicMock()
- n.id = next(note_id_counter)
- n.title = title
- return n
-
- with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
- patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
- patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
- patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
- patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Executive summary text."), \
- patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
- patch("fabledassistant.services.research.update_note", new_callable=AsyncMock) as mock_update:
-
- from fabledassistant.services.research import run_research_pipeline
- result = await run_research_pipeline("test topic", user_id=1, model="test-model")
-
- assert result.title == "Research: test topic"
- # Index note body should be updated with section links
- mock_update.assert_called_once()
- updated_body = mock_update.call_args.kwargs.get("body", "")
- assert "/notes/" in updated_body
-
-
-@pytest.mark.asyncio
-async def test_pipeline_falls_back_to_single_note_when_outline_empty():
- """run_research_pipeline falls back to single-note synthesis when _generate_outline returns []."""
- from unittest.mock import MagicMock
-
- single_note = MagicMock()
- single_note.id = 99
- single_note.title = "Research: test topic"
-
- with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
- patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
- patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=[]), \
- patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
- patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=single_note):
-
- from fabledassistant.services.research import run_research_pipeline
- result = await run_research_pipeline("test topic", user_id=1, model="test-model")
-
- assert result.id == 99
-
-
-@pytest.mark.asyncio
-async def test_pipeline_falls_back_when_all_sections_fail():
- """run_research_pipeline falls back to single note when all section syntheses raise."""
- from unittest.mock import MagicMock
-
- outline = [
- {"title": "Section A", "focus": "Focus A"},
- {"title": "Section B", "focus": "Focus B"},
- {"title": "Section C", "focus": "Focus C"},
- ]
- fallback_note = MagicMock()
- fallback_note.id = 77
- fallback_note.title = "Research: test topic"
-
- with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
- patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
- patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline), \
- patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=RuntimeError("synthesis failed")), \
- patch("fabledassistant.services.research._synthesize_note", new_callable=AsyncMock, return_value=("Research: test topic", "body")), \
- patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, return_value=fallback_note):
-
- from fabledassistant.services.research import run_research_pipeline
- result = await run_research_pipeline("test topic", user_id=1, model="test-model")
-
- assert result.id == 77
-
-
-@pytest.mark.asyncio
-async def test_pipeline_includes_wikipedia_sources():
- """run_research_pipeline should merge Wikipedia results into the source pool."""
- from unittest.mock import MagicMock
-
- wiki_results = [{"title": "Wiki Article", "extract": "Wikipedia content about the topic.", "url": "https://en.wikipedia.org/wiki/Topic"}]
-
- outline = [
- {"title": "Section A", "focus": "Focus A"},
- {"title": "Section B", "focus": "Focus B"},
- ]
-
- note_id_counter = iter(range(30, 40))
-
- def _make_note(user_id, title, body, tags, project_id=None, parent_id=None):
- n = MagicMock()
- n.id = next(note_id_counter)
- n.title = title
- return n
-
- with patch("fabledassistant.services.research._generate_sub_queries", new_callable=AsyncMock, return_value=["q1"]), \
- patch("fabledassistant.services.research._search_searxng", new_callable=AsyncMock, return_value=[{"url": "http://x.com", "title": "X", "snippet": "s"}]), \
- patch("fabledassistant.services.research.wiki_search", new_callable=AsyncMock, return_value=wiki_results), \
- patch("fabledassistant.services.research.fetch_url_content", new_callable=AsyncMock, return_value="content"), \
- patch("fabledassistant.services.research._generate_outline", new_callable=AsyncMock, return_value=outline) as mock_outline, \
- patch("fabledassistant.services.research._synthesize_section", new_callable=AsyncMock, side_effect=lambda t, f, s, m: (t, f"Body for {t}")), \
- patch("fabledassistant.services.research._generate_executive_summary", new_callable=AsyncMock, return_value="Summary."), \
- patch("fabledassistant.services.research.create_note", new_callable=AsyncMock, side_effect=_make_note), \
- patch("fabledassistant.services.research.update_note", new_callable=AsyncMock):
-
- from fabledassistant.services.research import run_research_pipeline
- await run_research_pipeline("test topic", user_id=1, model="test-model")
-
- # The sources passed to _generate_outline should include the Wikipedia article
- sources_arg = mock_outline.call_args[0][1] # second positional arg
- source_urls = [s["url"] for s in sources_arg]
- assert "https://en.wikipedia.org/wiki/Topic" in source_urls
diff --git a/tests/test_tool_use_fixes.py b/tests/test_tool_use_fixes.py
deleted file mode 100644
index c63e2da..0000000
--- a/tests/test_tool_use_fixes.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""Tests for the tool-use fixes from the 2026-05-08 journal session."""
-
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-import pytest
-
-
-def _project(pid: int, title: str, description: str = "", auto_summary: str = ""):
- return SimpleNamespace(
- id=pid, title=title, description=description, auto_summary=auto_summary,
- )
-
-
-@pytest.mark.asyncio
-async def test_search_projects_tool_returns_success_true():
- """The dispatcher sets status from result['success']; without this key
- the call gets labeled 'error' even when data is returned."""
- from fabledassistant.services.tools import projects as projects_tool
-
- fake_projects = [_project(5, "Famous-Supply Work topics", "AT&T fiber circuit")]
-
- # `list_projects` is imported locally inside search_projects_tool, so we
- # patch the source module rather than the consumer.
- with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
- result = await projects_tool.search_projects_tool(
- user_id=1, arguments={"query": "famous supply"},
- )
-
- assert result["success"] is True
- assert result["type"] == "projects_list"
- assert len(result["data"]["projects"]) == 1
-
-
-def test_strip_type_nouns_removes_task_word():
- from fabledassistant.services.notes import _strip_type_nouns
- assert _strip_type_nouns("sebring secondary task") == ["sebring", "secondary"]
-
-
-def test_strip_type_nouns_removes_all_variants():
- from fabledassistant.services.notes import _strip_type_nouns
- assert _strip_type_nouns("project notes task") == []
-
-
-def test_strip_type_nouns_case_insensitive():
- from fabledassistant.services.notes import _strip_type_nouns
- assert _strip_type_nouns("Sebring Task NOTES") == ["Sebring"]
-
-
-def test_strip_type_nouns_preserves_real_content_words():
- from fabledassistant.services.notes import _strip_type_nouns
- assert _strip_type_nouns("circuit configuration") == ["circuit", "configuration"]
-
-
-def test_strip_type_nouns_handles_empty_string():
- from fabledassistant.services.notes import _strip_type_nouns
- assert _strip_type_nouns("") == []
- assert _strip_type_nouns(" ") == []
-
-
-def test_score_project_match_exact_title_returns_1():
- from fabledassistant.services.tools._helpers import score_project_match
- p = _project(5, "Famous-Supply Work topics")
- assert score_project_match("Famous-Supply Work topics", p) == 1.0
-
-
-def test_score_project_match_colloquial_substring_at_least_85():
- """'famous supply' is a substring of normalized 'famous supply work topics'
- after stripping the hyphen. Substring match returns 0.85."""
- from fabledassistant.services.tools._helpers import score_project_match
- p = _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit")
- score = score_project_match("famous supply project", p)
- assert score >= 0.85, f"expected substring tier (>=0.85), got {score}"
-
-
-def test_score_project_match_query_in_summary_returns_70():
- from fabledassistant.services.tools._helpers import score_project_match
- p = _project(12, "Minstrel", auto_summary="self-hosted music server")
- assert score_project_match("music server", p) == 0.70
-
-
-def test_score_project_match_unrelated_returns_low():
- from fabledassistant.services.tools._helpers import score_project_match
- p = _project(12, "Minstrel", auto_summary="self-hosted music server")
- assert score_project_match("garden renovation", p) < 0.5
-
-
-def test_score_project_match_empty_query_returns_zero():
- from fabledassistant.services.tools._helpers import score_project_match
- p = _project(5, "Famous-Supply Work topics")
- assert score_project_match("", p) == 0.0
-
-
-@pytest.mark.asyncio
-async def test_search_projects_tool_ranks_substring_match_above_others():
- """Substring hits (score 0.85) must rank above SequenceMatcher misses
- for unrelated projects."""
- from fabledassistant.services.tools import projects as projects_tool
-
- fake_projects = [
- _project(12, "Minstrel", auto_summary="self-hosted music server"),
- _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
- _project(13, "ImageRepo", auto_summary="self-hosted Flask app"),
- ]
-
- with patch("fabledassistant.services.projects.list_projects", new=AsyncMock(return_value=fake_projects)):
- result = await projects_tool.search_projects_tool(
- user_id=1, arguments={"query": "famous supply project"},
- )
-
- top = result["data"]["projects"][0]
- assert top["id"] == 5, f"expected Famous-Supply to rank first, got {top}"
- assert top["score"] >= 0.85
-
-
-@pytest.mark.asyncio
-async def test_resolve_project_finds_colloquial_match(monkeypatch):
- """resolve_project must surface 'Famous-Supply Work topics' when the
- user passes 'famous supply project' — substring match via the shared
- score helper, score 0.85 ≥ 0.55 threshold."""
- from fabledassistant.services.tools import _helpers
-
- fake_projects = [
- _project(12, "Minstrel", auto_summary="self-hosted music server"),
- _project(5, "Famous-Supply Work topics", auto_summary="AT&T fiber circuit"),
- ]
-
- async def fake_get_project_by_title(uid, name):
- return None # force the scored path
-
- async def fake_list_projects(uid):
- return fake_projects
-
- monkeypatch.setattr(
- "fabledassistant.services.projects.get_project_by_title",
- fake_get_project_by_title,
- )
- monkeypatch.setattr(
- "fabledassistant.services.projects.list_projects",
- fake_list_projects,
- )
-
- result = await _helpers.resolve_project(user_id=1, project_name="famous supply project")
- assert result is not None
- assert result.id == 5
diff --git a/tests/test_tools_notes.py b/tests/test_tools_notes.py
deleted file mode 100644
index 5298b09..0000000
--- a/tests/test_tools_notes.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""Tests for the create_note / update_note LLM tools.
-
-Verifies the new task-as-durable-record contract:
-- create_note drops `body` when a task is being created (status set).
-- create_note forwards `description` to the service.
-- update_note rejects `body` writes when the target is a task.
-- update_note accepts `description` updates on tasks.
-"""
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-
-async def test_create_note_tool_ignores_body_when_creating_task():
- """Status present → is_task=True → body must be dropped before the
- create_note service call; description must be forwarded."""
- from fabledassistant.services.tools import notes as notes_tool
-
- captured: dict = {}
-
- async def fake_create_note(**kwargs):
- captured.update(kwargs)
- return SimpleNamespace(
- id=1,
- title=kwargs["title"],
- status=kwargs.get("status"),
- priority=kwargs.get("priority"),
- due_date=None,
- project_id=None,
- milestone_id=None,
- parent_id=None,
- )
-
- with patch(
- "fabledassistant.services.tools.notes.create_note", new=fake_create_note,
- ), patch(
- "fabledassistant.services.tools.notes.check_duplicate",
- new=AsyncMock(return_value=None),
- ), patch(
- "fabledassistant.services.tools.notes.suggest_tags",
- new=AsyncMock(return_value=[]),
- ), patch(
- "fabledassistant.services.tools.notes.schedule_embedding",
- ):
- await notes_tool.create_note_tool(
- user_id=1,
- arguments={
- "title": "renew cert",
- "status": "todo",
- "description": "the goal text",
- "body": "this should be dropped on tasks",
- },
- )
-
- assert captured.get("description") == "the goal text"
- # Body dropped because is_task=True. consolidation owns the body field.
- assert not captured.get("body")
-
-
-async def test_create_note_tool_preserves_body_for_knowledge_notes():
- """No status → is_task=False → body is preserved as today."""
- from fabledassistant.services.tools import notes as notes_tool
-
- captured: dict = {}
-
- async def fake_create_note(**kwargs):
- captured.update(kwargs)
- # Knowledge-note return path reads note.project_id; SimpleNamespace
- # needs the attribute even when it's None.
- return SimpleNamespace(
- id=1, title=kwargs["title"], status=None, project_id=None,
- )
-
- with patch(
- "fabledassistant.services.tools.notes.create_note", new=fake_create_note,
- ), patch(
- "fabledassistant.services.tools.notes.check_duplicate",
- new=AsyncMock(return_value=None),
- ), patch(
- "fabledassistant.services.tools.notes.suggest_tags",
- new=AsyncMock(return_value=[]),
- ), patch(
- "fabledassistant.services.tools.notes.schedule_embedding",
- ):
- await notes_tool.create_note_tool(
- user_id=1,
- arguments={
- "title": "runbook",
- "body": "preserved markdown content",
- },
- )
-
- assert captured.get("body") == "preserved markdown content"
-
-
-async def test_update_note_tool_rejects_body_on_tasks():
- """When the resolved note has is_task=True, providing body must error."""
- from fabledassistant.services.tools import notes as notes_tool
-
- # SimpleNamespace can't fake a @property; build is_task as a real attr.
- fake_task = SimpleNamespace(
- id=42, title="t", status="in_progress",
- body="", tags=[], project_id=None, milestone_id=None,
- )
- fake_task.is_task = True
-
- with patch(
- "fabledassistant.services.tools.notes.get_note_by_title",
- new=AsyncMock(return_value=fake_task),
- ), patch(
- "fabledassistant.services.tools.notes.update_note", new=AsyncMock(),
- ) as mock_update:
- result = await notes_tool.update_note_tool(
- user_id=1,
- arguments={"query": "t", "body": "trying to overwrite"},
- )
-
- assert result["success"] is False
- err = result.get("error", "").lower()
- assert "body" in err or "log_work" in err
- mock_update.assert_not_awaited()
-
-
-async def test_update_note_tool_accepts_description_on_tasks():
- """description updates flow through to the service even on tasks."""
- from fabledassistant.services.tools import notes as notes_tool
-
- fake_task = SimpleNamespace(
- id=42, title="t", status="in_progress",
- body="", tags=[], project_id=None, milestone_id=None,
- description=None,
- )
- fake_task.is_task = True
-
- captured: dict = {}
-
- async def fake_update_note(uid, nid, **kwargs):
- captured.update(kwargs)
- # Apply the description so the post-update inspection makes sense.
- for k, v in kwargs.items():
- setattr(fake_task, k, v)
- return fake_task
-
- with patch(
- "fabledassistant.services.tools.notes.get_note_by_title",
- new=AsyncMock(return_value=fake_task),
- ), patch(
- "fabledassistant.services.tools.notes.update_note",
- new=fake_update_note,
- ), patch(
- "fabledassistant.services.tools.notes.suggest_tags",
- new=AsyncMock(return_value=[]),
- ), patch(
- "fabledassistant.services.tools.notes.schedule_embedding",
- ):
- result = await notes_tool.update_note_tool(
- user_id=1,
- arguments={"query": "t", "description": "updated goal"},
- )
-
- assert result["success"] is True
- assert captured.get("description") == "updated goal"
-
-
-async def test_update_note_tool_accepts_body_on_knowledge_notes():
- """Body writes are still allowed on non-task notes."""
- from fabledassistant.services.tools import notes as notes_tool
-
- fake_note = SimpleNamespace(
- id=10, title="n", status=None,
- body="old", tags=[], project_id=None, milestone_id=None,
- )
- fake_note.is_task = False
-
- captured: dict = {}
-
- async def fake_update_note(uid, nid, **kwargs):
- captured.update(kwargs)
- for k, v in kwargs.items():
- setattr(fake_note, k, v)
- return fake_note
-
- with patch(
- "fabledassistant.services.tools.notes.get_note_by_title",
- new=AsyncMock(return_value=fake_note),
- ), patch(
- "fabledassistant.services.tools.notes.update_note",
- new=fake_update_note,
- ), patch(
- "fabledassistant.services.tools.notes.suggest_tags",
- new=AsyncMock(return_value=[]),
- ), patch(
- "fabledassistant.services.tools.notes.schedule_embedding",
- ):
- result = await notes_tool.update_note_tool(
- user_id=1,
- arguments={"query": "n", "body": "new body content"},
- )
-
- assert result["success"] is True
- assert captured.get("body") == "new body content"
diff --git a/tests/test_tools_tasks.py b/tests/test_tools_tasks.py
deleted file mode 100644
index 8d7d4a0..0000000
--- a/tests/test_tools_tasks.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""Tests for the task tools — log_work in particular wires into the
-consolidation pipeline after every successful log."""
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, patch
-
-
-async def test_log_work_tool_invokes_maybe_consolidate():
- """log_work must call maybe_consolidate(user_id, task_id, reason='log_added')
- after a successful task_logs.create_log."""
- from fabledassistant.services.tools import tasks as tasks_tool
-
- fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
- fake_log = SimpleNamespace(
- to_dict=lambda: {"id": 1, "content": "did stuff"},
- )
-
- # get_note_by_title is imported at the top of tools/tasks.py — patch the
- # consumer module's bound symbol, not the source.
- with patch(
- "fabledassistant.services.tools.tasks.get_note_by_title",
- new=AsyncMock(return_value=fake_task),
- ), patch(
- "fabledassistant.services.task_logs.create_log",
- new=AsyncMock(return_value=fake_log),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- result = await tasks_tool.log_work_tool(
- user_id=1, arguments={"task": "X", "content": "did stuff"},
- )
-
- assert result["success"] is True
- mock_mc.assert_awaited_once_with(1, 42, reason="log_added")
-
-
-async def test_log_work_tool_does_not_trigger_on_create_log_failure():
- """If create_log raises ValueError, maybe_consolidate must not be called."""
- from fabledassistant.services.tools import tasks as tasks_tool
-
- fake_task = SimpleNamespace(id=42, title="X", status="in_progress")
-
- with patch(
- "fabledassistant.services.tools.tasks.get_note_by_title",
- new=AsyncMock(return_value=fake_task),
- ), patch(
- "fabledassistant.services.task_logs.create_log",
- new=AsyncMock(side_effect=ValueError("bad input")),
- ), patch(
- "fabledassistant.services.consolidation.maybe_consolidate",
- new=AsyncMock(),
- ) as mock_mc:
- result = await tasks_tool.log_work_tool(
- user_id=1, arguments={"task": "X", "content": "did stuff"},
- )
-
- assert result["success"] is False
- mock_mc.assert_not_awaited()
diff --git a/tests/test_voice_library.py b/tests/test_voice_library.py
deleted file mode 100644
index 59f1d84..0000000
--- a/tests/test_voice_library.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""Unit tests for the voice library catalog shaper + ID validation.
-
-DB- and network-touching paths (fetch_catalog, install_voice) need a
-real environment, so we test only the pure transformations here:
-
-- Voice ID regex accepts valid piper IDs and rejects path-traversal attempts.
-- shape_catalog_for_ui projects an HF-shaped catalog dict into the UI list.
-- _resolve_file_urls picks the right .onnx and .onnx.json paths.
-"""
-from __future__ import annotations
-
-import pytest
-
-from fabledassistant.services import voice_library as lib
-
-
-def test_voice_id_accepts_real_piper_ids():
- for vid in [
- "en_US-amy-medium",
- "en_US-ryan-medium",
- "en_GB-alan-low",
- "fr_FR-siwis-medium",
- "de_DE-thorsten-high",
- "es_AR-daniela-high",
- "zh_CN-huayan-medium",
- "ar_JO-kareem-low",
- "ca_ES-upc_ona-x_low",
- ]:
- assert lib._is_valid_voice_id(vid), f"should accept {vid!r}"
-
-
-def test_voice_id_rejects_path_traversal_and_garbage():
- # Regex purpose is path-traversal + structurally-malformed prevention,
- # NOT enforcement of the HF catalog's lowercase-language convention.
- # Uppercase variants like `EN_US-amy-medium` pass the regex but would
- # 404 at install time against HF — harmless, no need to over-tighten.
- for vid in [
- "../../etc/passwd",
- "en_US-amy-medium/../something",
- "../en_US-amy-medium",
- "",
- "en_US-amy", # missing quality
- "amy-medium", # missing language
- "en_US--medium", # empty dataset
- "en_US-amy-large", # invalid quality
- ".onnx",
- "en_US-amy-medium.onnx", # has extension; we want the bare id
- ]:
- assert not lib._is_valid_voice_id(vid), f"should reject {vid!r}"
-
-
-def test_shape_catalog_projects_expected_fields(monkeypatch, tmp_path):
- """Catalog dict → list of UI cards, with install state from disk scan."""
- # Redirect the install-scan paths to a tmp dir; mark en_US-amy-medium
- # as installed under /data so we can verify the field flips.
- user_dir = tmp_path / "data_voices"
- bundled_dir = tmp_path / "opt_voices"
- user_dir.mkdir()
- bundled_dir.mkdir()
- (user_dir / "en_US-amy-medium.onnx").write_text("model")
- (user_dir / "en_US-amy-medium.onnx.json").write_text("{}")
-
- monkeypatch.setattr(lib, "_USER_VOICES_DIR", user_dir)
- monkeypatch.setattr(lib, "_BUNDLED_VOICES_DIR", bundled_dir)
-
- catalog = {
- "en_US-amy-medium": {
- "name": "amy",
- "language": {
- "code": "en_US",
- "name_native": "English",
- "country_english": "United States",
- },
- "quality": "medium",
- "num_speakers": 1,
- "files": {
- "en/en_US/amy/medium/en_US-amy-medium.onnx": {
- "size_bytes": 63_500_000,
- },
- "en/en_US/amy/medium/en_US-amy-medium.onnx.json": {
- "size_bytes": 4_300,
- },
- },
- },
- "fr_FR-siwis-medium": {
- "name": "siwis",
- "language": {
- "code": "fr_FR",
- "name_native": "Français",
- "country_english": "France",
- },
- "quality": "medium",
- "num_speakers": 1,
- "files": {
- "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx": {
- "size_bytes": 60_000_000,
- },
- "fr/fr_FR/siwis/medium/fr_FR-siwis-medium.onnx.json": {
- "size_bytes": 3_900,
- },
- },
- },
- }
- out = lib.shape_catalog_for_ui(catalog)
- assert len(out) == 2
- by_id = {v["id"]: v for v in out}
-
- amy = by_id["en_US-amy-medium"]
- assert amy["name"] == "amy"
- assert amy["language_code"] == "en_US"
- assert amy["quality"] == "medium"
- assert amy["size_bytes"] == 63_500_000 + 4_300
- assert amy["installed"] is True
- assert amy["installed_source"] == "user"
-
- siwis = by_id["fr_FR-siwis-medium"]
- assert siwis["language_code"] == "fr_FR"
- assert siwis["installed"] is False
- assert siwis["installed_source"] is None
-
- # Sorted by language_code, then name — en_US before fr_FR.
- assert out[0]["id"] == "en_US-amy-medium"
- assert out[1]["id"] == "fr_FR-siwis-medium"
-
-
-def test_resolve_file_urls_picks_onnx_and_sidecar():
- catalog = {
- "en_US-amy-medium": {
- "files": {
- "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
- "en/en_US/amy/medium/en_US-amy-medium.onnx.json": {"size_bytes": 2},
- },
- }
- }
- urls = lib._resolve_file_urls(catalog, "en_US-amy-medium")
- assert urls["onnx"].endswith("en_US-amy-medium.onnx")
- assert urls["onnx.json"].endswith("en_US-amy-medium.onnx.json")
- assert urls["onnx"].startswith("https://huggingface.co/rhasspy/piper-voices/resolve/main/")
-
-
-def test_resolve_file_urls_raises_when_voice_missing():
- with pytest.raises(KeyError):
- lib._resolve_file_urls({}, "en_US-amy-medium")
-
-
-def test_resolve_file_urls_raises_when_files_incomplete():
- catalog = {
- "en_US-amy-medium": {
- "files": {
- # Only the .onnx, missing the sidecar — that's malformed.
- "en/en_US/amy/medium/en_US-amy-medium.onnx": {"size_bytes": 1},
- }
- }
- }
- with pytest.raises(ValueError, match="missing"):
- lib._resolve_file_urls(catalog, "en_US-amy-medium")
diff --git a/tests/test_weather_service.py b/tests/test_weather_service.py
deleted file mode 100644
index 882cfa9..0000000
--- a/tests/test_weather_service.py
+++ /dev/null
@@ -1,166 +0,0 @@
-import pytest
-from unittest.mock import AsyncMock, patch, MagicMock
-
-
-def test_parse_open_meteo_response():
- """parse_forecast() should extract daily data into a clean list."""
- from fabledassistant.services.weather import parse_forecast
- raw = {
- "daily": {
- "time": ["2026-03-11", "2026-03-12"],
- "temperature_2m_max": [15.0, 12.0],
- "temperature_2m_min": [8.0, 6.0],
- "precipitation_sum": [0.0, 3.2],
- "weathercode": [0, 61],
- "windspeed_10m_max": [10.0, 25.0],
- }
- }
- days = parse_forecast(raw)
- assert len(days) == 2
- assert days[0]["date"] == "2026-03-11"
- assert days[0]["temp_max"] == 15.0
- assert days[0]["temp_min"] == 8.0
- assert days[0]["precip_mm"] == 0.0
- assert days[0]["weathercode"] == 0
- assert days[1]["date"] == "2026-03-12"
- assert days[1]["precip_mm"] == 3.2
-
-
-def test_describe_weathercode():
- """describe_weathercode() should return human-readable strings."""
- from fabledassistant.services.weather import describe_weathercode
- assert describe_weathercode(0) == "Clear sky"
- assert describe_weathercode(61) == "Rain: slight"
- assert describe_weathercode(95) == "Thunderstorm"
- assert "Unknown" in describe_weathercode(999)
-
-
-def test_detect_forecast_changes_no_change():
- """detect_changes() should return empty list when forecasts are identical."""
- from fabledassistant.services.weather import detect_changes
- day = {"date": "2026-03-12", "temp_max": 12.0, "temp_min": 6.0,
- "precip_mm": 3.2, "weathercode": 61}
- assert detect_changes([day], [day]) == []
-
-
-def test_detect_forecast_changes_rain_added():
- """detect_changes() should flag when precipitation appears."""
- from fabledassistant.services.weather import detect_changes
- old = [{"date": "2026-03-12", "temp_max": 14.0, "temp_min": 8.0,
- "precip_mm": 0.0, "weathercode": 2}]
- new = [{"date": "2026-03-12", "temp_max": 12.0, "temp_min": 7.0,
- "precip_mm": 4.5, "weathercode": 61}]
- changes = detect_changes(old, new)
- assert len(changes) == 1
- assert "2026-03-12" in changes[0]
- assert "rain" in changes[0].lower() or "precip" in changes[0].lower()
-
-
-def test_parse_weather_card_data_returns_none_when_stale():
- """Should return None when cache is older than 24 hours."""
- from datetime import datetime, timezone, timedelta
- from fabledassistant.services.weather import parse_weather_card_data
-
- cache = MagicMock()
- cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25)
- cache.forecast_json = {}
- assert parse_weather_card_data(cache) is None
-
-
-def test_parse_weather_card_data_returns_card_schema():
- """Should return the correct metadata.weather schema for fresh cache."""
- from datetime import datetime, timezone, timedelta, date
- from fabledassistant.services.weather import parse_weather_card_data
-
- today = date.today().isoformat()
- yesterday = (date.today() - timedelta(days=1)).isoformat()
- tomorrow = (date.today() + timedelta(days=1)).isoformat()
-
- cache = MagicMock()
- cache.fetched_at = datetime.now(timezone.utc)
- cache.location_label = "Berlin, DE"
- cache.forecast_json = {
- "current_weather": {"temperature": 12.0, "weathercode": 2},
- "daily": {
- "time": [yesterday, today, tomorrow],
- "temperature_2m_max": [14.0, 16.0, 18.0],
- "temperature_2m_min": [9.0, 8.0, 10.0],
- "precipitation_sum": [0.0, 0.0, 1.5],
- "weathercode": [1, 2, 61],
- "windspeed_10m_max": [10.0, 12.0, 8.0],
- }
- }
-
- card = parse_weather_card_data(cache)
- assert card is not None
- assert card["current_temp"] == 12
- assert card["condition"] == "Partly cloudy"
- assert card["today_high"] == 16
- assert card["today_low"] == 8
- assert card["yesterday_high"] == 14
- assert card["yesterday_low"] == 9
- assert len(card["forecast"]) >= 1
- assert card["location"] == "Berlin, DE"
- assert card["precip_summary"] is None
-
-
-def test_summarize_precip_dry():
- from fabledassistant.services.weather import _summarize_precip
- assert _summarize_precip([(h, 10) for h in range(24)]) is None
-
-
-def test_summarize_precip_all_day():
- from fabledassistant.services.weather import _summarize_precip
- result = _summarize_precip([(h, 60) for h in range(24)])
- assert result is not None
- assert "all day" in result.lower()
-
-
-def test_summarize_precip_window():
- from fabledassistant.services.weather import _summarize_precip
- hourly = [(h, 5) for h in range(24)]
- for h in range(14, 18):
- hourly[h] = (h, 65)
- result = _summarize_precip(hourly)
- assert result is not None
- assert "2 PM" in result
- assert "65%" in result
-
-
-def test_parse_weather_card_includes_hourly_precip_summary():
- from datetime import datetime, timezone, timedelta, date
- from fabledassistant.services.weather import parse_weather_card_data
-
- today = date.today().isoformat()
- yesterday = (date.today() - timedelta(days=1)).isoformat()
- tomorrow = (date.today() + timedelta(days=1)).isoformat()
-
- hourly_times = [f"{today}T{h:02d}:00" for h in range(24)]
- hourly_probs = [5] * 24
- for h in range(14, 18):
- hourly_probs[h] = 70
-
- cache = MagicMock()
- cache.fetched_at = datetime.now(timezone.utc)
- cache.location_label = "Berlin, DE"
- cache.forecast_json = {
- "current_weather": {"temperature": 12.0, "weathercode": 61},
- "daily": {
- "time": [yesterday, today, tomorrow],
- "temperature_2m_max": [14.0, 16.0, 18.0],
- "temperature_2m_min": [9.0, 8.0, 10.0],
- "precipitation_sum": [0.0, 5.0, 1.5],
- "precipitation_probability_max": [0, 70, 30],
- "weathercode": [1, 61, 61],
- "windspeed_10m_max": [10.0, 12.0, 8.0],
- },
- "hourly": {
- "time": hourly_times,
- "precipitation_probability": hourly_probs,
- },
- }
-
- card = parse_weather_card_data(cache)
- assert card is not None
- assert card["precip_summary"] is not None
- assert "2 PM" in card["precip_summary"]
diff --git a/tests/test_wikipedia.py b/tests/test_wikipedia.py
deleted file mode 100644
index 6b8f2ac..0000000
--- a/tests/test_wikipedia.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""Tests for the Wikipedia service module."""
-import pytest
-from unittest.mock import AsyncMock, MagicMock, patch
-
-from fabledassistant.services.wikipedia import wiki_summary, wiki_search
-
-
-def _make_mock_response(json_data: dict, status_code: int = 200) -> MagicMock:
- mock_resp = MagicMock()
- mock_resp.status_code = status_code
- mock_resp.json.return_value = json_data
- mock_resp.raise_for_status = MagicMock()
- return mock_resp
-
-
-@pytest.mark.asyncio
-async def test_wiki_summary_returns_extract():
- """Successful lookup returns a dict with title, extract, and url."""
- payload = {
- "type": "standard",
- "title": "Python (programming language)",
- "extract": "Python is a high-level programming language.",
- "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Python_(programming_language)"}},
- }
- mock_resp = _make_mock_response(payload)
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(return_value=mock_resp)
- mock_client_cls.return_value = mock_client
-
- result = await wiki_summary("Python programming language")
-
- assert result is not None
- assert result["title"] == "Python (programming language)"
- assert "Python" in result["extract"]
- assert result["url"].startswith("https://")
- assert result["thumbnail_url"] == ""
-
-
-@pytest.mark.asyncio
-async def test_wiki_summary_includes_thumbnail():
- """Thumbnail URL is extracted when the article has an image."""
- payload = {
- "type": "standard",
- "title": "Hedgehog",
- "extract": "Hedgehogs are spiny mammals.",
- "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Hedgehog"}},
- "thumbnail": {"source": "https://upload.wikimedia.org/foo-320px.jpg"},
- "originalimage": {"source": "https://upload.wikimedia.org/foo.jpg"},
- }
- mock_resp = _make_mock_response(payload)
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(return_value=mock_resp)
- mock_client_cls.return_value = mock_client
-
- result = await wiki_summary("Hedgehog")
-
- assert result is not None
- assert result["thumbnail_url"] == "https://upload.wikimedia.org/foo-320px.jpg"
-
-
-@pytest.mark.asyncio
-async def test_wiki_summary_returns_none_on_404():
- """HTTP error (e.g. 404) causes wiki_summary to return None."""
- import httpx
-
- mock_resp = MagicMock()
- mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError(
- "404", request=MagicMock(), response=MagicMock()
- )
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(return_value=mock_resp)
- mock_client_cls.return_value = mock_client
-
- result = await wiki_summary("NonExistentPageXYZ123")
-
- assert result is None
-
-
-@pytest.mark.asyncio
-async def test_wiki_summary_returns_none_on_disambiguation():
- """Disambiguation pages cause wiki_summary to return None."""
- payload = {
- "type": "disambiguation",
- "title": "Mercury",
- "extract": "Mercury may refer to:",
- "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Mercury"}},
- }
- mock_resp = _make_mock_response(payload)
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(return_value=mock_resp)
- mock_client_cls.return_value = mock_client
-
- result = await wiki_summary("Mercury")
-
- assert result is None
-
-
-@pytest.mark.asyncio
-async def test_wiki_search_returns_results():
- """wiki_search returns a list of result dicts for a successful query."""
- search_payload = {
- "query": {
- "search": [
- {"title": "Quantum mechanics"},
- {"title": "Quantum field theory"},
- ]
- }
- }
- summary_payloads = [
- {
- "type": "standard",
- "title": "Quantum mechanics",
- "extract": "Quantum mechanics is a fundamental theory in physics.",
- "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_mechanics"}},
- },
- {
- "type": "standard",
- "title": "Quantum field theory",
- "extract": "Quantum field theory is a framework in theoretical physics.",
- "content_urls": {"desktop": {"page": "https://en.wikipedia.org/wiki/Quantum_field_theory"}},
- },
- ]
-
- search_resp = _make_mock_response(search_payload)
- summary_resp_1 = _make_mock_response(summary_payloads[0])
- summary_resp_2 = _make_mock_response(summary_payloads[1])
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(side_effect=[search_resp, summary_resp_1, summary_resp_2])
- mock_client_cls.return_value = mock_client
-
- results = await wiki_search("quantum physics", limit=2)
-
- assert len(results) == 2
- assert results[0]["title"] == "Quantum mechanics"
- assert "quantum" in results[0]["extract"].lower()
- assert results[1]["title"] == "Quantum field theory"
-
-
-@pytest.mark.asyncio
-async def test_wiki_search_returns_empty_on_failure():
- """wiki_search returns an empty list when the network request fails."""
- import httpx
-
- with patch("fabledassistant.services.wikipedia.httpx.AsyncClient") as mock_client_cls:
- mock_client = AsyncMock()
- mock_client.__aenter__ = AsyncMock(return_value=mock_client)
- mock_client.__aexit__ = AsyncMock(return_value=False)
- mock_client.get = AsyncMock(side_effect=httpx.ConnectError("connection refused"))
- mock_client_cls.return_value = mock_client
-
- results = await wiki_search("anything")
-
- assert results == []
From 42861142dba7968603e2fcedccb9c2373ba60a22 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 18:09:32 -0400
Subject: [PATCH 068/118] chore: remove benchmark notes that were accidentally
committed
The bench-*.md files got swept into the Phase 8 mega-commit by
`git add -A`. They were local working-tree notes the user never
intended to track.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.remember/tmp/save-session.pid | 2 +-
bench-chat.md | 12 ------------
bench-curator-no-think.md | 12 ------------
bench-curator-think-on.md | 10 ----------
bench-gpuhost-chat-cpu.md | 12 ------------
bench-gpuhost-chat-gpu.md | 12 ------------
bench-gpuhost-curator-70b.md | 9 ---------
bench-gpuhost-curator-no-think.md | 12 ------------
bench-gpuhost-curator-think-on.md | 10 ----------
9 files changed, 1 insertion(+), 90 deletions(-)
delete mode 100644 bench-chat.md
delete mode 100644 bench-curator-no-think.md
delete mode 100644 bench-curator-think-on.md
delete mode 100644 bench-gpuhost-chat-cpu.md
delete mode 100644 bench-gpuhost-chat-gpu.md
delete mode 100644 bench-gpuhost-curator-70b.md
delete mode 100644 bench-gpuhost-curator-no-think.md
delete mode 100644 bench-gpuhost-curator-think-on.md
diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid
index 423d3c2..e7d4661 100644
--- a/.remember/tmp/save-session.pid
+++ b/.remember/tmp/save-session.pid
@@ -1 +1 @@
-603480
+604006
diff --git a/bench-chat.md b/bench-chat.md
deleted file mode 100644
index d8ba48b..0000000
--- a/bench-chat.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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 |
diff --git a/bench-curator-no-think.md b/bench-curator-no-think.md
deleted file mode 100644
index c024b9f..0000000
--- a/bench-curator-no-think.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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 |
diff --git a/bench-curator-think-on.md b/bench-curator-think-on.md
deleted file mode 100644
index b56e201..0000000
--- a/bench-curator-think-on.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# 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 |
diff --git a/bench-gpuhost-chat-cpu.md b/bench-gpuhost-chat-cpu.md
deleted file mode 100644
index d9198fb..0000000
--- a/bench-gpuhost-chat-cpu.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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 |
diff --git a/bench-gpuhost-chat-gpu.md b/bench-gpuhost-chat-gpu.md
deleted file mode 100644
index c2442f4..0000000
--- a/bench-gpuhost-chat-gpu.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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 |
diff --git a/bench-gpuhost-curator-70b.md b/bench-gpuhost-curator-70b.md
deleted file mode 100644
index e5b569d..0000000
--- a/bench-gpuhost-curator-70b.md
+++ /dev/null
@@ -1,9 +0,0 @@
-# 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 |
diff --git a/bench-gpuhost-curator-no-think.md b/bench-gpuhost-curator-no-think.md
deleted file mode 100644
index 81362b5..0000000
--- a/bench-gpuhost-curator-no-think.md
+++ /dev/null
@@ -1,12 +0,0 @@
-# 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 |
diff --git a/bench-gpuhost-curator-think-on.md b/bench-gpuhost-curator-think-on.md
deleted file mode 100644
index af3d5bf..0000000
--- a/bench-gpuhost-curator-think-on.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# 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 |
From 8d73f553cad029a82494c5cf806d827b9bafce37 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 18:10:06 -0400
Subject: [PATCH 069/118] test: drop test_tools_calendar_always_available
Tested that services/tools/_registry exposed event tools to the LLM
tool layer. That layer was removed in Phase 8 commit 91bafb6; event
CRUD is now covered via the MCP tools in test_mcp_tool_events.py.
---
tests/test_events_service.py | 18 +++---------------
1 file changed, 3 insertions(+), 15 deletions(-)
diff --git a/tests/test_events_service.py b/tests/test_events_service.py
index 0089bd5..8649d91 100644
--- a/tests/test_events_service.py
+++ b/tests/test_events_service.py
@@ -321,18 +321,6 @@ async def test_list_events_excludes_timed_event_that_already_ended():
assert results == []
-@pytest.mark.asyncio
-async def test_tools_calendar_always_available():
- """Calendar tools must appear in get_tools_for_user even without CalDAV."""
- with patch("fabledassistant.services.tools._registry.is_caldav_configured", new_callable=AsyncMock) as mock_configured, \
- patch("fabledassistant.services.settings.get_setting", new_callable=AsyncMock) as mock_setting:
- mock_configured.return_value = False
- mock_setting.return_value = "false"
- from fabledassistant.services.tools import get_tools_for_user
- tools = await get_tools_for_user(user_id=1)
- tool_names = {t["function"]["name"] for t in tools}
- assert "create_event" in tool_names
- assert "list_events" in tool_names
- assert "search_events" in tool_names
- assert "update_event" in tool_names
- assert "delete_event" in tool_names
+# test_tools_calendar_always_available removed in Phase 8 along with the
+# services/tools/ LLM-tool layer. Event CRUD is now exposed via MCP tools
+# (see tests/test_mcp_tool_events.py for coverage).
From b3fca3ced4db16b6f22ba5852dd567cfea5005d7 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 18:16:18 -0400
Subject: [PATCH 070/118] migration: drop chat/journal/push/curator/weather
tables (Phase 9)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 8 deleted the Python models for these tables; this migration
drops the orphan SQL.
Dropped tables (CASCADE-safe):
conversations, messages, generation_tool_log,
moments + moment_embeddings + moment_people/places/tasks/notes,
pending_curator_actions, push_subscriptions, weather_cache,
rss_item_embeddings (legacy pre-pivot experiment)
Dropped per-user settings: every voice_*, journal_*, briefing_*,
curator_* key, plus default_model, background_model, assistant_name,
auto_consolidate_tasks, chat_retention_days, think_enabled,
rag_default_scope.
Hard cutover — no downgrade. Existing data in these tables is lost;
the spec explicitly accepted this in exchange for a clean schema.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
...3_drop_chat_journal_push_curator_tables.py | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 alembic/versions/0053_drop_chat_journal_push_curator_tables.py
diff --git a/alembic/versions/0053_drop_chat_journal_push_curator_tables.py b/alembic/versions/0053_drop_chat_journal_push_curator_tables.py
new file mode 100644
index 0000000..e21823a
--- /dev/null
+++ b/alembic/versions/0053_drop_chat_journal_push_curator_tables.py
@@ -0,0 +1,82 @@
+"""drop chat / journal / push / curator / weather tables
+
+Revision ID: 0053
+Revises: 0052
+Create Date: 2026-05-27
+
+Phase 9 of the MCP-first pivot. The Python models for these tables were
+deleted in Phase 8 (commit 91bafb6); this migration drops the orphan
+SQL tables and cleans up dead per-user setting rows.
+
+Hard-cutover: existing rows in these tables are discarded. The
+accompanying spec at docs/superpowers/specs/2026-05-26-mcp-first-pivot-design.md
+explicitly accepted this loss.
+"""
+from alembic import op
+
+
+revision = "0053"
+down_revision = "0052"
+branch_labels = None
+depends_on = None
+
+
+# Order: junction tables first so the parent tables don't trip FK drops
+# even with CASCADE — keeps the migration readable.
+_DEAD_TABLES = [
+ # Moments + junction tables (journal entity links)
+ "moment_notes",
+ "moment_tasks",
+ "moment_places",
+ "moment_people",
+ "moment_embeddings",
+ "moments",
+ # Chat / generation telemetry
+ "generation_tool_log",
+ "messages",
+ "conversations",
+ # Curator approval queue
+ "pending_curator_actions",
+ # Push notifications
+ "push_subscriptions",
+ # Weather cache (journal-prep background fetcher)
+ "weather_cache",
+ # Legacy RSS-item embeddings (pre-MCP pivot, briefly experimented with)
+ "rss_item_embeddings",
+]
+
+
+# Specific setting keys to drop. Anything matching the LIKE patterns in
+# upgrade() is also nuked; the explicit list catches one-off keys that
+# don't fit the namespaced patterns.
+_DEAD_SETTING_KEYS = [
+ "default_model",
+ "background_model",
+ "assistant_name",
+ "auto_consolidate_tasks",
+ "chat_retention_days",
+ "think_enabled",
+ "rag_default_scope",
+]
+
+
+def upgrade() -> None:
+ for table in _DEAD_TABLES:
+ op.execute(f"DROP TABLE IF EXISTS {table} CASCADE")
+
+ keys_csv = ",".join(f"'{k}'" for k in _DEAD_SETTING_KEYS)
+ op.execute(
+ "DELETE FROM settings WHERE "
+ "key LIKE 'voice_%' OR "
+ "key LIKE 'journal_%' OR "
+ "key LIKE 'briefing_%' OR "
+ "key LIKE 'curator_%' OR "
+ f"key IN ({keys_csv})"
+ )
+
+
+def downgrade() -> None:
+ raise NotImplementedError(
+ "No downgrade — hard cutover per the MCP-first pivot spec. "
+ "Rolling back is not supported at the database layer."
+ )
From 4806c34a3c8b3491ec26f1bf4d380dae35b83a6d Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 19:10:25 -0400
Subject: [PATCH 071/118] =?UTF-8?q?refactor:=20Phase=2010=20=E2=80=94=20Ol?=
=?UTF-8?q?lama=20service,=20image=20cache,=20config,=20frontend=20orphans?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Final cleanup phase of the MCP-first pivot.
docker-compose:
- docker-compose.yml: drop ollama service + OLLAMA_URL/MODEL env vars +
IMAGE_CACHE / VAPID env comments
- docker-compose.prod.yml: drop ollama service + Ollama env + GPU
reservation
- docker-compose.quickstart.yml: drop ollama service + Ollama env +
GPU-reservation comment; quickstart instructions now point at the
MCP Access tab instead of model-pull
Config:
- Drop OLLAMA_URL, OLLAMA_MODEL, OLLAMA_BACKGROUND_MODEL,
OLLAMA_KEEP_ALIVE_*, OLLAMA_NUM_CTX, EMBEDDING_MODEL (fastembed
is hard-coded inside services/embeddings.py)
- Drop IMAGE_CACHE_DIR, IMAGE_MAX_BYTES (image cache subsystem
deleted)
- Drop VAPID_PRIVATE_KEY, VAPID_PUBLIC_KEY, VAPID_CLAIMS_SUB (push
deleted in phase 8)
- Drop VOICE_ENABLED, STT_BACKEND, STT_MODEL, TTS_BACKEND (voice
deleted in phase 8)
- Drop Config.validate() rules for those keys
Image cache deletion:
- services/images.py, routes/images.py, models/image_cache.py
- models/__init__.py: drop ImageCache import
- app.py: drop images_bp registration
- alembic/versions/0054_drop_image_cache.py: DROP TABLE image_cache
Frontend client.ts orphan exports stripped:
- getVoiceStatus, getVoiceList, getVoiceLibrary, installVoice,
uninstallVoice, transcribeAudio, synthesiseSpeech,
VoiceStatusResult / VoiceEntry / VoiceLibraryEntry types
- getJournalConfig, saveJournalConfig, getJournalToday/Day/Days,
triggerJournalPrep, runJournalCurator, listPendingActions,
approvePendingAction, rejectPendingAction, listJournalMoments,
updateJournalMoment, deleteJournalMoment, geocodeAddress
- JournalConfig / JournalLocation / JournalConversation /
JournalMessage / JournalDayPayload / JournalMoment /
CuratorRunResult / PendingCuratorAction types
- consolidateProfile, clearProfileObservations, listProfileObservations
- ProfileObservationEntry, learned_summary/observations_* fields on
UserProfile
- consolidateTask (cascading update to TaskEditorView)
- getFableMcpInfo, getNewsItems, GetNewsItemsParams, NewsItem import
TaskEditorView:
- Drop the auto-summary banner + Re-consolidate button
- Drop isBodyAutoMaintained gate (editor is always user-controlled now)
- Drop reconsolidate function + reconsolidating ref
SettingsView:
- profile ref no longer initialises learned_summary /
observations_count / observations_updated_at (those fields are
gone from UserProfile type)
Surviving frontend composables/components flagged for likely future
cleanup but not deleted in this commit (no compile errors, just
unreferenced after Phase 7-8):
- useAssist, useFloatingAssist, useTagSuggestions, useVad,
useListenMode, useOnnxPreloader (composables)
- WorkspaceNoteEditor, WorkspaceTaskPanel, WeatherCard, InlineAssistPanel
(components)
- api/client.ts still references /api/notes/assist/* and
/api/notes/suggest-tags via useAssist + useTagSuggestions — those
endpoints 404 now but no caller hits them; dead at runtime, harmless.
Compose stack collapses to two services: `app` + `db`. No Ollama, no
voice models, no fable-mcp wheel build. First-boot install reduces to:
docker compose up -d
→ visit web UI → register → Settings → MCP Access → copy snippet
→ claude mcp add … → done.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.remember/tmp/save-session.pid | 2 +-
alembic/versions/0054_drop_image_cache.py | 26 ++
docker-compose.prod.yml | 33 ---
docker-compose.quickstart.yml | 32 +--
docker-compose.yml | 32 +--
frontend/src/api/client.ts | 318 ----------------------
frontend/src/views/SettingsView.vue | 3 +-
frontend/src/views/TaskEditorView.vue | 53 +---
src/fabledassistant/app.py | 2 -
src/fabledassistant/config.py | 49 +---
src/fabledassistant/models/__init__.py | 1 -
src/fabledassistant/models/image_cache.py | 25 --
src/fabledassistant/routes/images.py | 32 ---
src/fabledassistant/services/images.py | 198 --------------
14 files changed, 42 insertions(+), 764 deletions(-)
create mode 100644 alembic/versions/0054_drop_image_cache.py
delete mode 100644 src/fabledassistant/models/image_cache.py
delete mode 100644 src/fabledassistant/routes/images.py
delete mode 100644 src/fabledassistant/services/images.py
diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid
index e7d4661..4724b9a 100644
--- a/.remember/tmp/save-session.pid
+++ b/.remember/tmp/save-session.pid
@@ -1 +1 @@
-604006
+632037
diff --git a/alembic/versions/0054_drop_image_cache.py b/alembic/versions/0054_drop_image_cache.py
new file mode 100644
index 0000000..187b402
--- /dev/null
+++ b/alembic/versions/0054_drop_image_cache.py
@@ -0,0 +1,26 @@
+"""drop image_cache table
+
+Revision ID: 0054
+Revises: 0053
+Create Date: 2026-05-27
+
+The image cache was wired into the LLM image-search tool (removed in Phase 8).
+With no producer or consumer left, the table is dropped here.
+"""
+from alembic import op
+
+
+revision = "0054"
+down_revision = "0053"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("DROP TABLE IF EXISTS image_cache CASCADE")
+
+
+def downgrade() -> None:
+ raise NotImplementedError(
+ "No downgrade — hard cutover per the MCP-first pivot spec."
+ )
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 7d0fac1..6150a66 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -4,8 +4,6 @@ services:
environment:
DATABASE_URL: "postgresql+asyncpg://fabled:${DB_PASSWORD}@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY}"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
TRUST_PROXY_HEADERS: "true"
SECURE_COOKIES: "true"
@@ -49,39 +47,8 @@ services:
max_attempts: 0
window: 120s
- ollama:
- image: ollama/ollama
- volumes:
- - ollama_models:/root/.ollama
- networks:
- - fabledassistant_backend
- environment:
- OLLAMA_MAX_LOADED_MODELS: "2"
- OLLAMA_KEEP_ALIVE: "30m"
- OLLAMA_FLASH_ATTENTION: "1"
- healthcheck:
- test: ["CMD-SHELL", "ollama list || exit 1"]
- interval: 30s
- timeout: 10s
- retries: 5
- start_period: 30s
- deploy:
- placement:
- constraints:
- - node.role == worker
- resources:
- reservations:
- devices:
- - driver: nvidia
- count: all
- capabilities: [gpu]
- restart_policy:
- condition: on-failure
- max_attempts: 5
-
volumes:
pgdata:
- ollama_models:
networks:
fabledassistant_backend:
diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml
index 882e80b..0e7e00b 100644
--- a/docker-compose.quickstart.yml
+++ b/docker-compose.quickstart.yml
@@ -6,7 +6,8 @@
# 1. Download this file
# 2. docker compose -f docker-compose.quickstart.yml up -d
# 3. Open http://localhost:5000 — the first account registered becomes admin
-# 4. Go to Settings → General to pull an LLM model (qwen3:8b or llama3.1:8b are good starting points)
+# 4. Go to Settings → MCP Access and connect Claude (Code or Desktop) via the
+# bearer-token URL shown there.
#
# Set SECRET_KEY via environment variable or a .env file alongside this file:
# SECRET_KEY=your-random-secret-here
@@ -19,16 +20,12 @@ services:
environment:
DATABASE_URL: "postgresql+asyncpg://fabled:fabled@db:5432/fabledassistant"
SECRET_KEY: "${SECRET_KEY:-change-me-in-production}"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1:8b}"
LOG_LEVEL: "${LOG_LEVEL:-INFO}"
volumes:
- app_data:/data
depends_on:
db:
condition: service_healthy
- ollama:
- condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
@@ -56,31 +53,6 @@ services:
start_period: 180s
restart: unless-stopped
- ollama:
- image: ollama/ollama
- volumes:
- - ollama_models:/root/.ollama
- environment:
- OLLAMA_MAX_LOADED_MODELS: "2"
- OLLAMA_KEEP_ALIVE: "30m"
- OLLAMA_FLASH_ATTENTION: "1"
- healthcheck:
- test: ["CMD-SHELL", "ollama list > /dev/null 2>&1"]
- interval: 30s
- timeout: 10s
- retries: 5
- start_period: 15s
- restart: unless-stopped
- # Uncomment to enable NVIDIA GPU passthrough (requires nvidia-container-toolkit):
- # deploy:
- # resources:
- # reservations:
- # devices:
- # - driver: nvidia
- # count: all
- # capabilities: [gpu]
-
volumes:
app_data:
pgdata:
- ollama_models:
diff --git a/docker-compose.yml b/docker-compose.yml
index 2a06564..22602be 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,25 +6,16 @@ services:
depends_on:
db:
condition: service_healthy
- ollama:
- condition: service_started
volumes:
- app_data:/data
# To use a bind mount instead (gives direct host access to all app data):
# - ./data:/data
environment:
DATABASE_URL: "postgresql+asyncpg://${POSTGRES_USER:-fabled}:${POSTGRES_PASSWORD:-fabled}@db:5432/${POSTGRES_DB:-fabledassistant}"
- OLLAMA_URL: "http://ollama:11434"
- OLLAMA_MODEL: "${OLLAMA_MODEL:-qwen3:8B}"
SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}"
- # Uncomment and set to enable web research and image search via SearXNG:
+ # Uncomment if you have a SearXNG instance you want to surface in the
+ # Integrations tab as a configured web-search backend:
# SEARXNG_URL: "http://searxng:8080"
- # IMAGE_CACHE_DIR: /data/images # default, change if using a different mount path
- # IMAGE_MAX_BYTES: "5242880" # 5 MB per image, adjust if needed
- # Push notifications (VAPID keys - generate with: python -c "from py_vapid import Vapid01; v=Vapid01(); v.generate_keys(); print(v.private_key, v.public_key)")
- VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}"
- VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}"
- VAPID_CLAIMS_SUB: "${VAPID_CLAIMS_SUB:-mailto:admin@fabledassistant.local}"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"]
interval: 10s
@@ -51,25 +42,6 @@ services:
retries: 10
start_period: 180s
- ollama:
- image: ollama/ollama
- volumes:
- - ollama_models:/root/.ollama
- environment:
- OLLAMA_MAX_LOADED_MODELS: "2"
- OLLAMA_NUM_PARALLEL: "2"
- OLLAMA_KEEP_ALIVE: "30m"
- OLLAMA_FLASH_ATTENTION: "1"
- # GPU reservation commented out — no nvidia-container-toolkit on this host
- # deploy:
- # resources:
- # reservations:
- # devices:
- # - driver: nvidia
- # count: all
- # capabilities: [gpu]
-
volumes:
pgdata:
- ollama_models:
app_data:
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 0d5df9f..c0aa114 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -299,188 +299,6 @@ export function apiSSEStream(
return { close: () => controller.abort(), done };
}
-// ---------------------------------------------------------------------------
-// Journal
-// ---------------------------------------------------------------------------
-
-export interface JournalLocation {
- label: string;
- address: string;
- lat?: number;
- lon?: number;
-}
-
-export interface JournalConfig {
- prep_enabled: boolean;
- prep_hour: number;
- prep_minute: number;
- day_rollover_hour: number;
- morning_end_hour?: number;
- midday_end_hour?: number;
- closeout_enabled?: boolean;
- // Ambient-context fields (carried forward from the briefing config schema)
- locations?: { home?: JournalLocation; work?: JournalLocation };
- temp_unit?: 'C' | 'F';
- use_caldav_event_locations?: boolean;
- enabled?: boolean;
- [key: string]: unknown;
-}
-
-
-export interface JournalConversation {
- id: number;
- title: string;
- model: string;
- conversation_type: string;
- day_date: string | null;
- rag_project_id: number | null;
- message_count: number;
- created_at: string;
- updated_at: string;
-}
-
-export interface JournalMessage {
- id: number;
- conversation_id: number;
- role: 'user' | 'assistant' | 'system';
- content: string;
- status: string;
- context_note_id: number | null;
- tool_calls: unknown[] | null;
- metadata: Record | null;
- created_at: string;
-}
-
-export interface JournalDayPayload {
- day_date: string;
- conversation: JournalConversation | null;
- messages: JournalMessage[];
-}
-
-export interface JournalMoment {
- id: number;
- user_id: number;
- conversation_id: number | null;
- source_message_id: number | null;
- day_date: string;
- occurred_at: string;
- recorded_at: string;
- content: string;
- raw_excerpt: string | null;
- tags: string[];
- pinned: boolean;
- people: { id: number; title: string }[];
- places: { id: number; title: string }[];
- task_ids: number[];
- note_ids: number[];
- score?: number;
-}
-
-export async function getJournalConfig(): Promise {
- return apiGet('/api/journal/config');
-}
-
-export async function saveJournalConfig(config: JournalConfig): Promise {
- await apiPut('/api/journal/config', config);
-}
-
-export async function getJournalToday(): Promise {
- return apiGet('/api/journal/today');
-}
-
-export async function getJournalDay(isoDate: string): Promise {
- return apiGet(`/api/journal/day/${isoDate}`);
-}
-
-export async function getJournalDays(): Promise {
- const data = await apiGet<{ days: string[] }>('/api/journal/days');
- return data.days;
-}
-
-export async function triggerJournalPrep(date?: string): Promise<{ ok: boolean; message_id: number }> {
- return apiPost('/api/journal/trigger-prep', date ? { date } : {});
-}
-
-export interface CuratorRunResult {
- conv_id: number;
- user_id: number;
- model: string;
- messages_examined: number;
- tool_calls: Array<{
- name: string;
- arguments: Record;
- status: 'success' | 'error' | 'pending';
- error: string | null;
- }>;
- tools_attempted: number;
- tools_succeeded: number;
- summary: string;
- duration_ms: number;
- error: string | null;
-}
-
-export async function runJournalCurator(convId: number): Promise {
- return apiPost(`/api/journal/curator/run/${convId}`, {});
-}
-
-// Curator-proposed mutations awaiting user review (Needs Review panel).
-// See routes/journal.py pending_actions endpoints.
-
-export interface PendingCuratorAction {
- id: number;
- user_id: number;
- conv_id: number | null;
- action_type: string; // e.g. "update_note", "delete_note", "update_milestone"
- target_type: string | null; // "task" | "note" | "milestone" | "project" | "profile"
- target_id: number | null;
- target_label: string | null; // human-readable title for the card header
- payload: Record; // the curator's proposed args
- current_snapshot: Record; // target state at proposal time
- status: 'pending' | 'approved' | 'rejected';
- created_at: string;
- reviewed_at: string | null;
-}
-
-export async function listPendingActions(): Promise<{ pending: PendingCuratorAction[]; count: number }> {
- return apiGet<{ pending: PendingCuratorAction[]; count: number }>('/api/journal/pending');
-}
-
-export async function approvePendingAction(actionId: number): Promise> {
- return apiPost>(`/api/journal/pending/${actionId}/approve`, {});
-}
-
-export async function rejectPendingAction(actionId: number): Promise> {
- return apiPost>(`/api/journal/pending/${actionId}/reject`, {});
-}
-
-export async function listJournalMoments(params: Record = {}): Promise {
- const qs = new URLSearchParams();
- for (const [k, v] of Object.entries(params)) qs.set(k, String(v));
- const data = await apiGet<{ moments: JournalMoment[] }>(`/api/journal/moments?${qs}`);
- return data.moments;
-}
-
-export async function updateJournalMoment(id: number, patch: Partial): Promise {
- return apiPatch(`/api/journal/moments/${id}`, patch);
-}
-
-export async function deleteJournalMoment(id: number): Promise {
- await apiDelete(`/api/journal/moments/${id}`);
-}
-
-export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number; display_name: string } | null> {
- try {
- const r = await apiPost<{ lat: number; lon: number; label: string }>('/api/journal/weather/geocode', { query: address });
- return { lat: r.lat, lon: r.lon, display_name: r.label };
- } catch {
- return null;
- }
-}
-
-export async function getFableMcpInfo(): Promise<{ available: boolean; filename: string | null }> {
- return apiGet('/api/fable-mcp/info');
-}
-
export async function apiStreamPost(
path: string,
body: unknown,
@@ -640,117 +458,6 @@ export const createApiKey = (name: string, scope: 'read' | 'write') =>
export const revokeApiKey = (id: number) => apiDelete(`/api/api-keys/${id}`)
-// ─── News ─────────────────────────────────────────────────────────────────────
-
-import type { NewsItem } from '@/types/news'
-
-export interface GetNewsItemsParams {
- days?: number
- limit?: number
- offset?: number
- feed_id?: number | null
-}
-
-export function getNewsItems(params: GetNewsItemsParams = {}) {
- const p = new URLSearchParams()
- if (params.days != null) p.set('days', String(params.days))
- if (params.limit != null) p.set('limit', String(params.limit))
- if (params.offset != null) p.set('offset', String(params.offset))
- if (params.feed_id != null) p.set('feed_id', String(params.feed_id))
- return apiGet<{ items: NewsItem[]; offset: number; limit: number }>(
- `/api/briefing/news?${p}`
- )
-}
-
-// ─── Voice ────────────────────────────────────────────────────────────────────
-
-export interface VoiceStatusResult {
- enabled: boolean
- stt: boolean
- tts: boolean
- stt_model?: string
- tts_backend?: string
-}
-
-export interface VoiceEntry {
- id: string
- label: string
- language?: string
- quality?: string
- sample_rate?: number
-}
-
-export interface VoiceLibraryEntry {
- id: string
- name: string
- language_code: string
- language_name: string
- country: string
- quality: string
- num_speakers: number
- size_bytes: number
- installed: boolean
- installed_source: 'user' | 'bundled' | null
-}
-
-export const getVoiceStatus = () => apiGet('/api/voice/status')
-
-export const getVoiceList = () =>
- apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices)
-
-export const getVoiceLibrary = (refresh = false) =>
- apiGet<{ voices: VoiceLibraryEntry[]; count: number }>(
- refresh ? '/api/voice/voices/library?refresh=1' : '/api/voice/voices/library'
- )
-
-export const installVoice = (voiceId: string) =>
- apiPost<{ id: string; size_bytes: number; skipped: boolean }>(
- '/api/voice/voices/install',
- { voice_id: voiceId }
- )
-
-export const uninstallVoice = (voiceId: string) =>
- apiDelete(`/api/voice/voices/${encodeURIComponent(voiceId)}`)
-
-export async function transcribeAudio(blob: Blob, context?: string): Promise<{ transcript: string; duration_ms: number }> {
- const form = new FormData()
- form.append('audio', blob, 'audio.webm')
- if (context) form.append('context', context)
- const res = await fetch('/api/voice/transcribe', { method: 'POST', body: form })
- if (!res.ok) {
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
- throw new ApiError(res.status, err)
- }
- return res.json()
-}
-
-export async function synthesiseSpeech(
- text: string,
- voice?: string,
- speed?: number,
-): Promise {
- // Only send voice/speed when explicitly provided — omitting them lets the
- // server auto-load the user's saved voice settings.
- // Voice blending was removed with the kokoro → piper migration (piper has
- // no blend equivalent); callers that previously passed `voiceBlend` should
- // pass the first voice's id as `voice` instead.
- const body: Record = { text }
- if (voice !== undefined || speed !== undefined) {
- body.voice = voice ?? 'en_US-amy-medium'
- body.speed = speed ?? 1.0
- }
- const res = await fetch('/api/voice/synthesise', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(body),
- })
- if (!res.ok) {
- const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }))
- throw new ApiError(res.status, err)
- }
- return res.blob()
-}
-
// ── User Profile ─────────────────────────────────────────────────────────────
export interface UserProfile {
@@ -762,36 +469,11 @@ export interface UserProfile {
tone: 'casual' | 'professional' | 'technical'
interests: string[]
work_schedule: { days?: string[]; start?: string; end?: string }
- learned_summary: string
- observations_count: number
- observations_updated_at: string | null
}
export const getProfile = () => apiGet('/api/profile')
export const updateProfile = (data: Partial) =>
apiPut('/api/profile', data)
-export const consolidateProfile = () =>
- apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {})
-export const clearProfileObservations = () => apiDelete('/api/profile/observations')
-
-export interface ProfileObservationEntry {
- date: string
- bullets: string
-}
-
-export const listProfileObservations = () =>
- apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations')
-
-
-// ── Tasks ────────────────────────────────────────────────────────────────────
-
-import type { Note as Task } from '../types/note'
-
-/** Manually trigger a consolidation pass for a task. Returns the freshly-
- * updated task with new body + consolidated_at. Bypasses the user's
- * auto_consolidate_tasks setting. */
-export const consolidateTask = (id: number) =>
- apiPost(`/api/tasks/${id}/consolidate`, {})
// ── Note Versions (pinning) ──────────────────────────────────────────────────
diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 0e95a1e..81d9b57 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -333,8 +333,7 @@ const WORK_DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const profile = ref({
display_name: '', job_title: '', industry: '',
expertise_level: 'intermediate', response_style: 'balanced', tone: 'casual',
- interests: [], work_schedule: {}, learned_summary: '',
- observations_count: 0, observations_updated_at: null,
+ interests: [], work_schedule: {},
})
const profileSaving = ref(false)
const profileSaved = ref(false)
diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue
index db70b29..179c431 100644
--- a/frontend/src/views/TaskEditorView.vue
+++ b/frontend/src/views/TaskEditorView.vue
@@ -109,29 +109,10 @@ async function toggleSubTask(sub: SubTask) {
}
const showPreview = ref(false);
const sidebarOpen = ref(true);
-const reconsolidating = ref(false);
-// Body is machine-maintained once a consolidation pass has run. The editor
-// is gated to read-only in that state; the user can re-consolidate or rely
-// on log_work entries flowing into the next auto pass.
-const isBodyAutoMaintained = computed(() => consolidatedAt.value !== null);
-
-async function reconsolidate() {
- if (!taskId.value || reconsolidating.value) return;
- reconsolidating.value = true;
- try {
- const { consolidateTask } = await import("@/api/client");
- const updated = await consolidateTask(taskId.value);
- body.value = updated.body;
- consolidatedAt.value = updated.consolidated_at ?? null;
- savedBody = body.value;
- toast.show("Task summary refreshed");
- } catch {
- toast.show("Failed to re-consolidate", "error");
- } finally {
- reconsolidating.value = false;
- }
-}
+// reconsolidate / isBodyAutoMaintained removed in Phase 8 — the curator
+// that auto-maintained task bodies is gone, so the body editor is now
+// always user-controlled.
const editorRef = ref | null>(null);
const titleRef = ref(null);
const tiptapEditor = computed(() => {
@@ -485,33 +466,16 @@ useEditorGuards(dirty, save);
-
-
- ✦
- Auto-summarized from work logs.
-
- {{ reconsolidating ? "Re-consolidating…" : "Re-consolidate" }}
-
-
-
-
+
Write
- Preview
+ Preview
-
+
@@ -525,11 +489,8 @@ useEditorGuards(dirty, save);
-
@@ -543,7 +504,7 @@ useEditorGuards(dirty, save);
/>
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index 2cea950..fbefb8a 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -11,7 +11,6 @@ from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_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
@@ -69,7 +68,6 @@ def create_app() -> Quart:
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(export_bp)
- app.register_blueprint(images_bp)
app.register_blueprint(milestones_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(projects_bp)
diff --git a/src/fabledassistant/config.py b/src/fabledassistant/config.py
index 87a00ba..0088482 100644
--- a/src/fabledassistant/config.py
+++ b/src/fabledassistant/config.py
@@ -22,30 +22,11 @@ class Config:
"DATABASE_URL_FILE",
"postgresql+asyncpg://fabled:fabled@localhost:5432/fabledassistant",
)
- OLLAMA_URL: str = os.environ.get("OLLAMA_URL", "http://localhost:11434")
- OLLAMA_MODEL: str = os.environ.get("OLLAMA_MODEL", "qwen3:latest")
- # Lightweight model for background tasks (title generation, tag suggestions,
- # project summaries). Using a separate model keeps the
- # main model's KV cache intact between user messages, enabling prefix cache hits.
- OLLAMA_BACKGROUND_MODEL: str = os.environ.get("OLLAMA_BACKGROUND_MODEL", "gemma3:4b")
- # Ollama keep_alive — how long a model stays resident in VRAM after its last
- # request. Main model gets a longer window since it's used interactively;
- # the background model is called sporadically and doesn't need to camp VRAM.
- # Format matches Ollama's duration strings: "30m", "10m", "1h", "0s", "-1" (forever).
- OLLAMA_KEEP_ALIVE_MAIN: str = os.environ.get("OLLAMA_KEEP_ALIVE_MAIN", "30m")
- OLLAMA_KEEP_ALIVE_BACKGROUND: str = os.environ.get("OLLAMA_KEEP_ALIVE_BACKGROUND", "10m")
- # KV cache context window for generation. Keep this as small as practical —
- # a larger context forces more KV cache into CPU RAM, drastically slowing prefill.
- # 16384 covers ~30+ message conversations with our system prompt comfortably.
- OLLAMA_NUM_CTX: int = int(os.environ.get("OLLAMA_NUM_CTX", "16384"))
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
- # Embedding model for semantic note search (served by Ollama)
- EMBEDDING_MODEL: str = os.environ.get("EMBEDDING_MODEL", "nomic-embed-text")
-
# SMTP defaults (overridden by DB settings when configured via admin UI)
SMTP_HOST: str = os.environ.get("SMTP_HOST", "")
SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587"))
@@ -64,25 +45,11 @@ class Config:
OIDC_SCOPES: str = os.environ.get("OIDC_SCOPES", "openid profile email")
LOCAL_AUTH_ENABLED: bool = os.environ.get("LOCAL_AUTH_ENABLED", "true").lower() not in ("0", "false", "no")
- # SearXNG web search (external instance)
+ # SearXNG web search (external instance). Currently only surfaced via
+ # /api/settings/search for the Integrations tab's status indicator —
+ # the MCP layer doesn't proxy web search (Claude has its own).
SEARXNG_URL: str = os.environ.get("SEARXNG_URL", "")
- # Image cache — images fetched from the web are stored here and served locally
- IMAGE_CACHE_DIR: str = os.environ.get("IMAGE_CACHE_DIR", "/data/images")
- # Maximum size of a single image to cache (default 5 MB)
- IMAGE_MAX_BYTES: int = int(os.environ.get("IMAGE_MAX_BYTES", str(5 * 1024 * 1024)))
-
- # VAPID keys for browser push notifications
- VAPID_PRIVATE_KEY: str = os.environ.get("VAPID_PRIVATE_KEY", "")
- VAPID_PUBLIC_KEY: str = os.environ.get("VAPID_PUBLIC_KEY", "")
- VAPID_CLAIMS_SUB: str = os.environ.get("VAPID_CLAIMS_SUB", "mailto:admin@fabledassistant.local")
-
- # Voice (Speech-to-Speech) feature
- VOICE_ENABLED: bool = os.environ.get("VOICE_ENABLED", "").lower() in ("1", "true", "yes")
- STT_BACKEND: str = os.environ.get("STT_BACKEND", "faster-whisper")
- STT_MODEL: str = os.environ.get("STT_MODEL", "base.en")
- TTS_BACKEND: str = os.environ.get("TTS_BACKEND", "kokoro")
-
@classmethod
def oidc_enabled(cls) -> bool:
return bool(cls.OIDC_ISSUER and cls.OIDC_CLIENT_ID and cls.OIDC_CLIENT_SECRET)
@@ -95,12 +62,8 @@ class Config:
def validate(cls) -> None:
"""Validate critical config values at startup. Raises ValueError on misconfiguration."""
errors: list[str] = []
- if cls.OLLAMA_NUM_CTX < 512:
- errors.append(f"OLLAMA_NUM_CTX={cls.OLLAMA_NUM_CTX} is too small (minimum 512)")
if cls.LOG_RETENTION_DAYS < 1:
errors.append(f"LOG_RETENTION_DAYS={cls.LOG_RETENTION_DAYS} must be >= 1")
- if cls.IMAGE_MAX_BYTES < 1024:
- errors.append(f"IMAGE_MAX_BYTES={cls.IMAGE_MAX_BYTES} must be >= 1024")
if not (1 <= cls.SMTP_PORT <= 65535):
errors.append(f"SMTP_PORT={cls.SMTP_PORT} must be between 1 and 65535")
if cls.oidc_enabled() and not cls.BASE_URL.startswith(("http://", "https://")):
@@ -110,11 +73,5 @@ class Config:
"SECRET_KEY is set to the insecure default but SECURE_COOKIES=true indicates "
"a production deployment. Set SECRET_KEY or SECRET_KEY_FILE before starting."
)
- _valid_stt_models = {"tiny.en", "base.en", "small.en", "medium.en"}
- if cls.VOICE_ENABLED and cls.STT_MODEL not in _valid_stt_models:
- errors.append(
- f"STT_MODEL='{cls.STT_MODEL}' is not supported. "
- f"Valid values: {', '.join(sorted(_valid_stt_models))}"
- )
if errors:
raise ValueError("Configuration errors:\n" + "\n".join(f" - {e}" for e in errors))
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index 1a29265..9ea3232 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -26,7 +26,6 @@ from fabledassistant.models.app_log import AppLog # noqa: E402, F401
from fabledassistant.models.password_reset import PasswordResetToken # noqa: E402, F401
from fabledassistant.models.invitation import InvitationToken # noqa: E402, F401
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.event import Event # noqa: E402, F401
from fabledassistant.models.milestone import Milestone # noqa: E402, F401
diff --git a/src/fabledassistant/models/image_cache.py b/src/fabledassistant/models/image_cache.py
deleted file mode 100644
index 37af733..0000000
--- a/src/fabledassistant/models/image_cache.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from datetime import datetime, timezone
-
-from sqlalchemy import DateTime, Integer, Text
-from sqlalchemy.orm import Mapped, mapped_column
-
-from fabledassistant.models import Base
-
-
-class ImageCache(Base):
- """Metadata for an image fetched from the web and stored locally on disk."""
-
- __tablename__ = "image_cache"
-
- id: Mapped[int] = mapped_column(Integer, primary_key=True)
- url_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False, index=True)
- original_url: Mapped[str] = mapped_column(Text, nullable=False)
- source_domain: Mapped[str | None] = mapped_column(Text, nullable=True)
- title: Mapped[str | None] = mapped_column(Text, nullable=True)
- content_type: Mapped[str] = mapped_column(Text, nullable=False, default="image/jpeg")
- file_size: Mapped[int | None] = mapped_column(Integer, nullable=True)
- file_ext: Mapped[str] = mapped_column(Text, nullable=False, default="jpg")
- fetched_at: Mapped[datetime] = mapped_column(
- DateTime(timezone=True),
- default=lambda: datetime.now(timezone.utc),
- )
diff --git a/src/fabledassistant/routes/images.py b/src/fabledassistant/routes/images.py
deleted file mode 100644
index 3519d28..0000000
--- a/src/fabledassistant/routes/images.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Serve locally-cached images."""
-
-import logging
-
-from quart import Blueprint, jsonify, send_file
-
-from fabledassistant.auth import login_required
-from fabledassistant.services.images import get_image_path, get_image_record
-
-logger = logging.getLogger(__name__)
-
-images_bp = Blueprint("images", __name__, url_prefix="/api/images")
-
-
-@images_bp.route("/", methods=["GET"])
-@login_required
-async def serve_image(image_id: int):
- """Serve a locally-cached image by its DB ID."""
- record = await get_image_record(image_id)
- if record is None:
- return jsonify({"error": "Image not found"}), 404
-
- file_path = get_image_path(record)
- if not file_path.exists():
- logger.warning("Image file missing on disk for id=%d (%s)", image_id, file_path)
- return jsonify({"error": "Image file not found"}), 404
-
- return await send_file(
- file_path,
- mimetype=record.content_type,
- cache_timeout=86400,
- )
diff --git a/src/fabledassistant/services/images.py b/src/fabledassistant/services/images.py
deleted file mode 100644
index 6e1c8b6..0000000
--- a/src/fabledassistant/services/images.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""Image cache service: fetch from web, store on disk, serve locally.
-
-Images are stored in IMAGE_CACHE_DIR (default /data/images) and tracked in
-the image_cache DB table. The original URL is preserved for citation.
-Deduplication is by SHA-256 of the original URL — the same image requested
-twice shares one file and one DB row.
-"""
-
-import hashlib
-import ipaddress
-import logging
-import socket
-from pathlib import Path
-from urllib.parse import urlparse
-
-import httpx
-from sqlalchemy import select
-from sqlalchemy.exc import IntegrityError
-
-from fabledassistant.config import Config
-from fabledassistant.models import async_session
-from fabledassistant.models.image_cache import ImageCache
-
-logger = logging.getLogger(__name__)
-
-
-def _is_safe_image_url(url: str) -> bool:
- """Return True only if the URL is a public http/https address (SSRF guard)."""
- try:
- parsed = urlparse(url)
- if parsed.scheme not in ("http", "https"):
- return False
- host = parsed.hostname
- if not host:
- return False
- if host.lower() in ("localhost", "::1"):
- return False
- 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 False
- return True
- except Exception:
- return False # Block on resolution failure
-
-_ALLOWED_TYPES = {
- "image/jpeg",
- "image/png",
- "image/gif",
- "image/webp",
- "image/avif",
-}
-
-_EXT_MAP = {
- "image/jpeg": "jpg",
- "image/png": "png",
- "image/gif": "gif",
- "image/webp": "webp",
- "image/avif": "avif",
-}
-
-
-def _cache_dir() -> Path:
- d = Path(Config.IMAGE_CACHE_DIR)
- d.mkdir(parents=True, exist_ok=True)
- return d
-
-
-def _url_hash(url: str) -> str:
- return hashlib.sha256(url.encode()).hexdigest()
-
-
-async def fetch_and_store_image(
- url: str,
- title: str | None = None,
- source_domain: str | None = None,
-) -> ImageCache | None:
- """Fetch an image URL, write it to disk, persist metadata to DB.
-
- Returns the ImageCache record (new or existing).
- Returns None if the URL is unreachable, not a valid image type, or
- exceeds IMAGE_MAX_BYTES.
- """
- if not _is_safe_image_url(url):
- logger.warning("Blocked image fetch of private/internal URL: %s", url[:80])
- return None
-
- url_hash = _url_hash(url)
-
- # Return existing record if already cached
- async with async_session() as session:
- existing = (
- await session.execute(
- select(ImageCache).where(ImageCache.url_hash == url_hash)
- )
- ).scalar_one_or_none()
- if existing:
- logger.debug("Image cache hit id=%d for %s", existing.id, url[:80])
- return existing
-
- # Infer source domain from URL if not supplied
- if not source_domain:
- try:
- source_domain = urlparse(url).netloc or None
- except Exception:
- source_domain = None
-
- # Fetch from origin
- try:
- async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
- headers = {
- "User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0)",
- }
- # Provide a same-origin Referer to bypass basic hotlink protection
- if source_domain:
- headers["Referer"] = f"https://{source_domain}/"
- resp = await client.get(url, headers=headers)
- resp.raise_for_status()
-
- ct = resp.headers.get("content-type", "").split(";")[0].strip().lower()
- if ct not in _ALLOWED_TYPES:
- logger.warning("Skipping non-image content-type '%s' for %s", ct, url[:80])
- return None
-
- # Reject oversized images before buffering all bytes
- cl = resp.headers.get("content-length")
- if cl and int(cl) > Config.IMAGE_MAX_BYTES:
- logger.warning("Image too large (%s bytes, limit %d) at %s", cl, Config.IMAGE_MAX_BYTES, url[:80])
- return None
-
- data = resp.content
- if len(data) > Config.IMAGE_MAX_BYTES:
- logger.warning("Image too large (%d bytes) at %s", len(data), url[:80])
- return None
- if not data:
- return None
-
- except Exception:
- logger.warning("Failed to fetch image from %s", url[:80], exc_info=True)
- return None
-
- # Write to disk
- file_ext = _EXT_MAP.get(ct, "jpg")
- file_path = _cache_dir() / f"{url_hash}.{file_ext}"
- try:
- file_path.write_bytes(data)
- except Exception:
- logger.warning("Failed to write image to disk: %s", file_path, exc_info=True)
- return None
-
- # Persist metadata — handle the rare race-condition duplicate gracefully
- record = ImageCache(
- url_hash=url_hash,
- original_url=url,
- source_domain=source_domain,
- title=title,
- content_type=ct,
- file_size=len(data),
- file_ext=file_ext,
- )
- async with async_session() as session:
- try:
- session.add(record)
- await session.commit()
- await session.refresh(record)
- except IntegrityError:
- await session.rollback()
- existing = (
- await session.execute(
- select(ImageCache).where(ImageCache.url_hash == url_hash)
- )
- ).scalar_one_or_none()
- if existing:
- return existing
- logger.warning("Failed to persist image cache record for %s", url[:80], exc_info=True)
- return None
-
- logger.info(
- "Cached image id=%d (%s, %d bytes) from %s",
- record.id, ct, len(data), url[:80],
- )
- return record
-
-
-async def get_image_record(image_id: int) -> ImageCache | None:
- """Return the ImageCache row for a given ID, or None."""
- async with async_session() as session:
- return (
- await session.execute(
- select(ImageCache).where(ImageCache.id == image_id)
- )
- ).scalar_one_or_none()
-
-
-def get_image_path(record: ImageCache) -> Path:
- """Filesystem path for a cached image record."""
- return _cache_dir() / f"{record.url_hash}.{record.file_ext}"
From 05e379263a80e9762dc0e1f9b758adc2a07966a2 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:16:24 -0400
Subject: [PATCH 072/118] =?UTF-8?q?feat(rulebook):=20migration=200055=20?=
=?UTF-8?q?=E2=80=94=20rulebooks,=20topics,=20rules,=20subscriptions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
alembic/versions/0055_rulebook_tables.py | 153 +++++++++++++++++++++++
1 file changed, 153 insertions(+)
create mode 100644 alembic/versions/0055_rulebook_tables.py
diff --git a/alembic/versions/0055_rulebook_tables.py b/alembic/versions/0055_rulebook_tables.py
new file mode 100644
index 0000000..54f4846
--- /dev/null
+++ b/alembic/versions/0055_rulebook_tables.py
@@ -0,0 +1,153 @@
+"""rulebook tables: rulebooks, rulebook_topics, rules, project_rulebook_subscriptions
+
+Revision ID: 0055
+Revises: 0054
+Create Date: 2026-05-27
+
+Adds the Scribe Rulebook hierarchy as a fourth top-level entity, sibling
+to Project. Rules carry a structural (statement, why, how_to_apply)
+triple. Projects subscribe to Rulebooks (many-to-many). See the design
+doc at docs/superpowers/specs/2026-05-27-scribe-rulebook-design.md.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0055"
+down_revision = "0054"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.create_table(
+ "rulebooks",
+ sa.Column("id", sa.BigInteger, primary_key=True),
+ sa.Column(
+ "owner_user_id",
+ sa.BigInteger,
+ sa.ForeignKey("users.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("title", sa.Text, nullable=False),
+ sa.Column("description", sa.Text),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ )
+ op.create_index("ix_rulebooks_owner", "rulebooks", ["owner_user_id"])
+
+ op.create_table(
+ "rulebook_topics",
+ sa.Column("id", sa.BigInteger, primary_key=True),
+ sa.Column(
+ "rulebook_id",
+ sa.BigInteger,
+ sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("title", sa.Text, nullable=False),
+ sa.Column("description", sa.Text),
+ sa.Column(
+ "order_index", sa.Integer, nullable=False, server_default="0",
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
+ )
+ op.create_index(
+ "ix_rulebook_topics_rulebook", "rulebook_topics", ["rulebook_id"],
+ )
+
+ op.create_table(
+ "rules",
+ sa.Column("id", sa.BigInteger, primary_key=True),
+ sa.Column(
+ "topic_id",
+ sa.BigInteger,
+ sa.ForeignKey("rulebook_topics.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("title", sa.Text, nullable=False),
+ sa.Column("statement", sa.Text, nullable=False),
+ sa.Column("why", sa.Text),
+ sa.Column("how_to_apply", sa.Text),
+ sa.Column(
+ "order_index", sa.Integer, nullable=False, server_default="0",
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
+ )
+ op.create_index("ix_rules_topic", "rules", ["topic_id"])
+
+ op.create_table(
+ "project_rulebook_subscriptions",
+ sa.Column(
+ "project_id",
+ sa.BigInteger,
+ sa.ForeignKey("projects.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "rulebook_id",
+ sa.BigInteger,
+ sa.ForeignKey("rulebooks.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ nullable=False,
+ server_default=sa.text("now()"),
+ ),
+ sa.PrimaryKeyConstraint("project_id", "rulebook_id"),
+ )
+ op.create_index(
+ "ix_project_rulebook_subs_rulebook",
+ "project_rulebook_subscriptions",
+ ["rulebook_id"],
+ )
+
+
+def downgrade() -> None:
+ op.drop_index(
+ "ix_project_rulebook_subs_rulebook",
+ table_name="project_rulebook_subscriptions",
+ )
+ op.drop_table("project_rulebook_subscriptions")
+ op.drop_index("ix_rules_topic", table_name="rules")
+ op.drop_table("rules")
+ op.drop_index("ix_rulebook_topics_rulebook", table_name="rulebook_topics")
+ op.drop_table("rulebook_topics")
+ op.drop_index("ix_rulebooks_owner", table_name="rulebooks")
+ op.drop_table("rulebooks")
From 45c2197cdf82710c29f9ec990ce9c6096d3d70db Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:16:49 -0400
Subject: [PATCH 073/118] feat(rulebook): SQLAlchemy models for rulebooks,
topics, rules
---
src/fabledassistant/models/__init__.py | 3 +
src/fabledassistant/models/rulebook.py | 117 +++++++++++++++++++++++++
2 files changed, 120 insertions(+)
create mode 100644 src/fabledassistant/models/rulebook.py
diff --git a/src/fabledassistant/models/__init__.py b/src/fabledassistant/models/__init__.py
index 9ea3232..4288178 100644
--- a/src/fabledassistant/models/__init__.py
+++ b/src/fabledassistant/models/__init__.py
@@ -37,3 +37,6 @@ from fabledassistant.models.share import NoteShare, ProjectShare # noqa: E402,
from fabledassistant.models.notification import Notification # 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.rulebook import ( # noqa: E402, F401
+ Rulebook, RulebookTopic, Rule, project_rulebook_subscriptions,
+)
diff --git a/src/fabledassistant/models/rulebook.py b/src/fabledassistant/models/rulebook.py
new file mode 100644
index 0000000..3de66ff
--- /dev/null
+++ b/src/fabledassistant/models/rulebook.py
@@ -0,0 +1,117 @@
+from datetime import datetime, timezone
+
+from sqlalchemy import BigInteger, Column, DateTime, ForeignKey, Integer, Table, Text, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column
+
+from fabledassistant.models import Base
+
+
+class Rulebook(Base):
+ __tablename__ = "rulebooks"
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+ owner_user_id: Mapped[int] = mapped_column(
+ BigInteger, ForeignKey("users.id", ondelete="CASCADE")
+ )
+ title: Mapped[str] = mapped_column(Text)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "owner_user_id": self.owner_user_id,
+ "title": self.title,
+ "description": self.description or "",
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+ }
+
+
+class RulebookTopic(Base):
+ __tablename__ = "rulebook_topics"
+ __table_args__ = (
+ UniqueConstraint("rulebook_id", "title", name="uq_topic_per_rulebook"),
+ )
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+ rulebook_id: Mapped[int] = mapped_column(
+ BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE")
+ )
+ title: Mapped[str] = mapped_column(Text)
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
+ order_index: Mapped[int] = mapped_column(Integer, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "rulebook_id": self.rulebook_id,
+ "title": self.title,
+ "description": self.description or "",
+ "order_index": self.order_index,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+ }
+
+
+class Rule(Base):
+ __tablename__ = "rules"
+ __table_args__ = (
+ UniqueConstraint("topic_id", "title", name="uq_rule_per_topic"),
+ )
+
+ id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
+ topic_id: Mapped[int] = mapped_column(
+ BigInteger, ForeignKey("rulebook_topics.id", ondelete="CASCADE")
+ )
+ title: Mapped[str] = mapped_column(Text)
+ statement: Mapped[str] = mapped_column(Text)
+ why: Mapped[str | None] = mapped_column(Text, nullable=True)
+ how_to_apply: Mapped[str | None] = mapped_column(Text, nullable=True)
+ order_index: Mapped[int] = mapped_column(Integer, default=0)
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
+ )
+ updated_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True),
+ default=lambda: datetime.now(timezone.utc),
+ onupdate=lambda: datetime.now(timezone.utc),
+ )
+
+ def to_dict(self) -> dict:
+ return {
+ "id": self.id,
+ "topic_id": self.topic_id,
+ "title": self.title,
+ "statement": self.statement,
+ "why": self.why or "",
+ "how_to_apply": self.how_to_apply or "",
+ "order_index": self.order_index,
+ "created_at": self.created_at.isoformat() if self.created_at else None,
+ "updated_at": self.updated_at.isoformat() if self.updated_at else None,
+ }
+
+
+# Pure many-to-many — no model class, just the join table.
+project_rulebook_subscriptions = Table(
+ "project_rulebook_subscriptions",
+ Base.metadata,
+ Column("project_id", BigInteger, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
+ Column("rulebook_id", BigInteger, ForeignKey("rulebooks.id", ondelete="CASCADE"), primary_key=True),
+ Column("created_at", DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)),
+)
From cfd801d1818537187d6d8f2f7b7f5ff99b8eda8a Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:17:21 -0400
Subject: [PATCH 074/118] =?UTF-8?q?feat(rulebook):=20service=20layer=20?=
=?UTF-8?q?=E2=80=94=20Rulebook=20CRUD=20+=20find=5Frulebook=5Fby=5Ftitle?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/rulebooks.py | 110 ++++++++++++++++++++++
tests/test_services_rulebooks.py | 103 ++++++++++++++++++++
2 files changed, 213 insertions(+)
create mode 100644 src/fabledassistant/services/rulebooks.py
create mode 100644 tests/test_services_rulebooks.py
diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py
new file mode 100644
index 0000000..885e2be
--- /dev/null
+++ b/src/fabledassistant/services/rulebooks.py
@@ -0,0 +1,110 @@
+"""Rulebook / topic / rule service layer — single source of truth used by
+both routes/rulebooks.py and mcp/tools/rulebooks.py.
+
+Ownership enforcement: every function takes user_id and scopes through
+rulebooks.owner_user_id. Functions return models or model.to_dict() output
+depending on the caller's needs (mirroring services/events.py pattern).
+"""
+from __future__ import annotations
+
+import logging
+from typing import Optional
+
+from sqlalchemy import select
+
+from fabledassistant.models import async_session
+from fabledassistant.models.rulebook import Rulebook
+
+logger = logging.getLogger(__name__)
+
+
+# ── Rulebook CRUD ────────────────────────────────────────────────────────
+
+async def create_rulebook(
+ user_id: int, title: str, description: str = "",
+) -> Rulebook:
+ """Create a new rulebook owned by user_id. Returns the persisted model."""
+ async with async_session() as session:
+ rb = Rulebook(
+ owner_user_id=user_id, title=title, description=description or None,
+ )
+ session.add(rb)
+ await session.commit()
+ await session.refresh(rb)
+ return rb
+
+
+async def list_rulebooks(user_id: int) -> list[Rulebook]:
+ """List rulebooks owned by user_id, ordered by title."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rulebook)
+ .where(Rulebook.owner_user_id == user_id)
+ .order_by(Rulebook.title)
+ )
+ return list(result.scalars().all())
+
+
+async def get_rulebook(rulebook_id: int, user_id: int) -> Optional[Rulebook]:
+ """Get a rulebook by id, scoped to user_id. None if not owned or not found."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rulebook).where(
+ Rulebook.id == rulebook_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def update_rulebook(
+ rulebook_id: int, user_id: int, **fields,
+) -> Optional[Rulebook]:
+ """Partial update. Returns updated rulebook or None if not found."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rulebook).where(
+ Rulebook.id == rulebook_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ rb = result.scalar_one_or_none()
+ if rb is None:
+ return None
+ allowed = {"title", "description"}
+ for key, value in fields.items():
+ if key in allowed and value is not None:
+ setattr(rb, key, value)
+ await session.commit()
+ await session.refresh(rb)
+ return rb
+
+
+async def delete_rulebook(rulebook_id: int, user_id: int) -> None:
+ """Delete a rulebook. Cascade-deletes topics, rules, subscriptions."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rulebook).where(
+ Rulebook.id == rulebook_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ rb = result.scalar_one_or_none()
+ if rb is None:
+ return
+ await session.delete(rb)
+ await session.commit()
+
+
+async def find_rulebook_by_title(
+ user_id: int, title: str,
+) -> Optional[Rulebook]:
+ """Used by the port script for the dupe-guard. None if not found."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rulebook).where(
+ Rulebook.owner_user_id == user_id,
+ Rulebook.title == title,
+ )
+ )
+ return result.scalar_one_or_none()
diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py
new file mode 100644
index 0000000..17ba7dd
--- /dev/null
+++ b/tests/test_services_rulebooks.py
@@ -0,0 +1,103 @@
+"""Tests for services/rulebooks.py — mocks async_session, no real DB.
+
+Mirrors the pattern in tests/test_events_service.py.
+"""
+from unittest.mock import AsyncMock, MagicMock, patch
+from datetime import datetime, timezone
+
+import pytest
+
+
+def _make_mock_session():
+ s = AsyncMock()
+ s.__aenter__ = AsyncMock(return_value=s)
+ s.__aexit__ = AsyncMock(return_value=False)
+ s.add = MagicMock()
+ s.commit = AsyncMock()
+ s.refresh = AsyncMock()
+ return s
+
+
+def _fake_rulebook(id=1, owner_user_id=7, title="FabledSword family", description=""):
+ rb = MagicMock()
+ rb.id = id
+ rb.owner_user_id = owner_user_id
+ rb.title = title
+ rb.description = description
+ rb.created_at = datetime.now(timezone.utc)
+ rb.updated_at = datetime.now(timezone.utc)
+ rb.to_dict.return_value = {
+ "id": id, "owner_user_id": owner_user_id,
+ "title": title, "description": description or "",
+ }
+ return rb
+
+
+@pytest.mark.asyncio
+async def test_create_rulebook_stores_to_db():
+ mock_session = _make_mock_session()
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import create_rulebook
+ await create_rulebook(
+ user_id=7, title="FabledSword family", description="rules for the family",
+ )
+ assert mock_session.add.called
+ assert mock_session.commit.called
+
+
+@pytest.mark.asyncio
+async def test_list_rulebooks_returns_owned_only():
+ rb = _fake_rulebook(id=1)
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalars.return_value.all.return_value = [rb]
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import list_rulebooks
+ results = await list_rulebooks(user_id=7)
+ assert len(results) == 1
+
+
+@pytest.mark.asyncio
+async def test_get_rulebook_returns_none_when_not_owner():
+ """get_rulebook scopes by owner_user_id — wrong user gets None."""
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = None
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import get_rulebook
+ result = await get_rulebook(rulebook_id=1, user_id=99)
+ assert result is None
+
+
+@pytest.mark.asyncio
+async def test_update_rulebook_only_sets_provided_fields():
+ rb = _fake_rulebook(id=1, title="old")
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = rb
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import update_rulebook
+ await update_rulebook(rulebook_id=1, user_id=7, title="new")
+ assert rb.title == "new"
+
+
+@pytest.mark.asyncio
+async def test_delete_rulebook_calls_delete():
+ rb = _fake_rulebook(id=1)
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = rb
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ mock_session.delete = AsyncMock()
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import delete_rulebook
+ await delete_rulebook(rulebook_id=1, user_id=7)
+ assert mock_session.delete.called
From 38e4220015c69e9810ad18fcb089dd4607923e78 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:17:54 -0400
Subject: [PATCH 075/118] =?UTF-8?q?feat(rulebook):=20service=20layer=20?=
=?UTF-8?q?=E2=80=94=20Topic=20CRUD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/rulebooks.py | 103 ++++++++++++++++++++++
tests/test_services_rulebooks.py | 55 ++++++++++++
2 files changed, 158 insertions(+)
diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py
index 885e2be..76f3293 100644
--- a/src/fabledassistant/services/rulebooks.py
+++ b/src/fabledassistant/services/rulebooks.py
@@ -108,3 +108,106 @@ async def find_rulebook_by_title(
)
)
return result.scalar_one_or_none()
+
+
+# ── Topic CRUD ──────────────────────────────────────────────────────────
+
+from fabledassistant.models.rulebook import RulebookTopic
+
+
+async def _assert_rulebook_owned(session, rulebook_id: int, user_id: int) -> None:
+ """Raise ValueError if rulebook doesn't exist or isn't owned by user.
+ Centralizes ownership check used by all topic/rule operations.
+ """
+ result = await session.execute(
+ select(Rulebook).where(
+ Rulebook.id == rulebook_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ if result.scalar_one_or_none() is None:
+ raise ValueError(f"rulebook {rulebook_id} not found")
+
+
+async def create_topic(
+ rulebook_id: int, user_id: int, title: str,
+ description: str = "", order_index: int = 0,
+) -> RulebookTopic:
+ async with async_session() as session:
+ await _assert_rulebook_owned(session, rulebook_id, user_id)
+ topic = RulebookTopic(
+ rulebook_id=rulebook_id,
+ title=title,
+ description=description or None,
+ order_index=order_index,
+ )
+ session.add(topic)
+ await session.commit()
+ await session.refresh(topic)
+ return topic
+
+
+async def list_topics(rulebook_id: int, user_id: int) -> list[RulebookTopic]:
+ async with async_session() as session:
+ await _assert_rulebook_owned(session, rulebook_id, user_id)
+ result = await session.execute(
+ select(RulebookTopic)
+ .where(RulebookTopic.rulebook_id == rulebook_id)
+ .order_by(RulebookTopic.order_index, RulebookTopic.title)
+ )
+ return list(result.scalars().all())
+
+
+async def get_topic(topic_id: int, user_id: int) -> Optional[RulebookTopic]:
+ """Get a topic, scoped via the rulebook owner."""
+ async with async_session() as session:
+ result = await session.execute(
+ select(RulebookTopic)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ RulebookTopic.id == topic_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def update_topic(
+ topic_id: int, user_id: int, **fields,
+) -> Optional[RulebookTopic]:
+ async with async_session() as session:
+ result = await session.execute(
+ select(RulebookTopic)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ RulebookTopic.id == topic_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ topic = result.scalar_one_or_none()
+ if topic is None:
+ return None
+ allowed = {"title", "description", "order_index"}
+ for key, value in fields.items():
+ if key in allowed and value is not None:
+ setattr(topic, key, value)
+ await session.commit()
+ await session.refresh(topic)
+ return topic
+
+
+async def delete_topic(topic_id: int, user_id: int) -> None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(RulebookTopic)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ RulebookTopic.id == topic_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ topic = result.scalar_one_or_none()
+ if topic is None:
+ return
+ await session.delete(topic)
+ await session.commit()
diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py
index 17ba7dd..cdcb9e3 100644
--- a/tests/test_services_rulebooks.py
+++ b/tests/test_services_rulebooks.py
@@ -101,3 +101,58 @@ async def test_delete_rulebook_calls_delete():
from fabledassistant.services.rulebooks import delete_rulebook
await delete_rulebook(rulebook_id=1, user_id=7)
assert mock_session.delete.called
+
+
+# ── Topic CRUD ───────────────────────────────────────────────────────────
+
+def _fake_topic(id=1, rulebook_id=1, title="git-workflow", description="", order_index=0):
+ t = MagicMock()
+ t.id = id
+ t.rulebook_id = rulebook_id
+ t.title = title
+ t.description = description
+ t.order_index = order_index
+ t.created_at = datetime.now(timezone.utc)
+ t.updated_at = datetime.now(timezone.utc)
+ t.to_dict.return_value = {
+ "id": id, "rulebook_id": rulebook_id, "title": title,
+ "description": description or "", "order_index": order_index,
+ }
+ return t
+
+
+@pytest.mark.asyncio
+async def test_create_topic_requires_owned_rulebook():
+ """create_topic raises ValueError if the rulebook isn't owned by user."""
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = None
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import create_topic
+ with pytest.raises(ValueError, match="not found"):
+ await create_topic(
+ rulebook_id=999, user_id=7, title="git-workflow",
+ )
+
+
+@pytest.mark.asyncio
+async def test_list_topics_returns_topics_for_owned_rulebook():
+ rb = _fake_rulebook(id=1)
+ topic = _fake_topic(id=10, rulebook_id=1, title="git-workflow")
+
+ # Two execute calls: ownership check, then topic select.
+ mock_session = _make_mock_session()
+ rb_result = MagicMock()
+ rb_result.scalar_one_or_none.return_value = rb
+ topic_result = MagicMock()
+ topic_result.scalars.return_value.all.return_value = [topic]
+ mock_session.execute = AsyncMock(side_effect=[rb_result, topic_result])
+
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import list_topics
+ results = await list_topics(rulebook_id=1, user_id=7)
+ assert len(results) == 1
+ assert results[0].title == "git-workflow"
From d3833ba5a48e9cf2e54b8744fa213b868213e600 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:18:35 -0400
Subject: [PATCH 076/118] =?UTF-8?q?feat(rulebook):=20service=20layer=20?=
=?UTF-8?q?=E2=80=94=20Rule=20CRUD=20with=20multi-filter=20list=5Frules?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/rulebooks.py | 132 ++++++++++++++++++++++
tests/test_services_rulebooks.py | 65 +++++++++++
2 files changed, 197 insertions(+)
diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py
index 76f3293..72ae6bc 100644
--- a/src/fabledassistant/services/rulebooks.py
+++ b/src/fabledassistant/services/rulebooks.py
@@ -211,3 +211,135 @@ async def delete_topic(topic_id: int, user_id: int) -> None:
return
await session.delete(topic)
await session.commit()
+
+
+# ── Rule CRUD ──────────────────────────────────────────────────────────
+
+from fabledassistant.models.rulebook import Rule
+
+
+async def _assert_topic_owned(session, topic_id: int, user_id: int) -> None:
+ """Raise ValueError if topic doesn't exist or isn't in user's rulebook."""
+ result = await session.execute(
+ select(RulebookTopic)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ RulebookTopic.id == topic_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ if result.scalar_one_or_none() is None:
+ raise ValueError(f"topic {topic_id} not found")
+
+
+async def create_rule(
+ topic_id: int, user_id: int, title: str, statement: str,
+ why: str = "", how_to_apply: str = "", order_index: int = 0,
+) -> Rule:
+ async with async_session() as session:
+ await _assert_topic_owned(session, topic_id, user_id)
+ rule = Rule(
+ topic_id=topic_id,
+ title=title,
+ statement=statement,
+ why=why or None,
+ how_to_apply=how_to_apply or None,
+ order_index=order_index,
+ )
+ session.add(rule)
+ await session.commit()
+ await session.refresh(rule)
+ return rule
+
+
+async def list_rules(
+ user_id: int,
+ rulebook_id: int | None = None,
+ topic_id: int | None = None,
+ project_id: int | None = None,
+) -> list[Rule]:
+ """List rules filtered by any of the three IDs. All filters are ownership-scoped.
+
+ project_id resolves rules through project_rulebook_subscriptions.
+ """
+ from fabledassistant.models.rulebook import project_rulebook_subscriptions
+
+ async with async_session() as session:
+ stmt = (
+ select(Rule)
+ .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(Rulebook.owner_user_id == user_id)
+ )
+ if topic_id:
+ stmt = stmt.where(Rule.topic_id == topic_id)
+ if rulebook_id:
+ stmt = stmt.where(RulebookTopic.rulebook_id == rulebook_id)
+ if project_id:
+ stmt = (
+ stmt.join(
+ project_rulebook_subscriptions,
+ project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
+ )
+ .where(project_rulebook_subscriptions.c.project_id == project_id)
+ )
+ stmt = stmt.order_by(
+ Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
+ )
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
+
+async def get_rule(rule_id: int, user_id: int) -> Optional[Rule]:
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rule)
+ .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ Rule.id == rule_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ return result.scalar_one_or_none()
+
+
+async def update_rule(rule_id: int, user_id: int, **fields) -> Optional[Rule]:
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rule)
+ .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ Rule.id == rule_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ rule = result.scalar_one_or_none()
+ if rule is None:
+ return None
+ allowed = {"title", "statement", "why", "how_to_apply", "order_index"}
+ for key, value in fields.items():
+ if key in allowed and value is not None:
+ setattr(rule, key, value)
+ await session.commit()
+ await session.refresh(rule)
+ return rule
+
+
+async def delete_rule(rule_id: int, user_id: int) -> None:
+ async with async_session() as session:
+ result = await session.execute(
+ select(Rule)
+ .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .where(
+ Rule.id == rule_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ )
+ rule = result.scalar_one_or_none()
+ if rule is None:
+ return
+ await session.delete(rule)
+ await session.commit()
diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py
index cdcb9e3..21125e3 100644
--- a/tests/test_services_rulebooks.py
+++ b/tests/test_services_rulebooks.py
@@ -156,3 +156,68 @@ async def test_list_topics_returns_topics_for_owned_rulebook():
results = await list_topics(rulebook_id=1, user_id=7)
assert len(results) == 1
assert results[0].title == "git-workflow"
+
+
+# ── Rule CRUD ───────────────────────────────────────────────────────────
+
+def _fake_rule(id=1, topic_id=10, title="dev is home",
+ statement="Work directly on dev", why="", how_to_apply=""):
+ r = MagicMock()
+ r.id = id
+ r.topic_id = topic_id
+ r.title = title
+ r.statement = statement
+ r.why = why
+ r.how_to_apply = how_to_apply
+ r.order_index = 0
+ r.created_at = datetime.now(timezone.utc)
+ r.updated_at = datetime.now(timezone.utc)
+ r.to_dict.return_value = {
+ "id": id, "topic_id": topic_id, "title": title,
+ "statement": statement, "why": why or "",
+ "how_to_apply": how_to_apply or "", "order_index": 0,
+ }
+ return r
+
+
+@pytest.mark.asyncio
+async def test_create_rule_requires_owned_topic():
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = None # topic not found
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import create_rule
+ with pytest.raises(ValueError, match="topic .* not found"):
+ await create_rule(
+ topic_id=999, user_id=7, title="x", statement="y",
+ )
+
+
+@pytest.mark.asyncio
+async def test_list_rules_filters_by_topic_id():
+ """list_rules(topic_id=X) returns rules in that topic, ownership-scoped."""
+ rule = _fake_rule(id=1, topic_id=10)
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalars.return_value.all.return_value = [rule]
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import list_rules
+ results = await list_rules(user_id=7, topic_id=10)
+ assert len(results) == 1
+
+
+@pytest.mark.asyncio
+async def test_get_rule_returns_none_when_not_owner():
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = None
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import get_rule
+ result = await get_rule(rule_id=1, user_id=99)
+ assert result is None
From 45fe198d54d3287697df5e1d9ba4f364c992676f Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:19:14 -0400
Subject: [PATCH 077/118] =?UTF-8?q?feat(rulebook):=20service=20layer=20?=
=?UTF-8?q?=E2=80=94=20subscriptions=20+=20get=5Fapplicable=5Frules?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/rulebooks.py | 113 ++++++++++++++++++++++
tests/test_services_rulebooks.py | 66 +++++++++++++
2 files changed, 179 insertions(+)
diff --git a/src/fabledassistant/services/rulebooks.py b/src/fabledassistant/services/rulebooks.py
index 72ae6bc..db01acb 100644
--- a/src/fabledassistant/services/rulebooks.py
+++ b/src/fabledassistant/services/rulebooks.py
@@ -343,3 +343,116 @@ async def delete_rule(rule_id: int, user_id: int) -> None:
return
await session.delete(rule)
await session.commit()
+
+
+# ── Subscriptions + get_applicable_rules ───────────────────────────────
+
+from sqlalchemy import insert, delete as sql_delete
+
+
+async def subscribe_project(
+ project_id: int, rulebook_id: int, user_id: int,
+) -> None:
+ """Add a subscription. Idempotent — duplicates raise; we swallow."""
+ from fabledassistant.models.rulebook import project_rulebook_subscriptions
+
+ async with async_session() as session:
+ await _assert_rulebook_owned(session, rulebook_id, user_id)
+ # ON CONFLICT DO NOTHING via try/except to keep dialect-agnostic.
+ try:
+ await session.execute(
+ insert(project_rulebook_subscriptions).values(
+ project_id=project_id, rulebook_id=rulebook_id,
+ )
+ )
+ await session.commit()
+ except Exception:
+ await session.rollback() # PK collision = already subscribed; fine.
+
+
+async def unsubscribe_project(
+ project_id: int, rulebook_id: int, user_id: int,
+) -> None:
+ from fabledassistant.models.rulebook import project_rulebook_subscriptions
+
+ async with async_session() as session:
+ await _assert_rulebook_owned(session, rulebook_id, user_id)
+ await session.execute(
+ sql_delete(project_rulebook_subscriptions).where(
+ project_rulebook_subscriptions.c.project_id == project_id,
+ project_rulebook_subscriptions.c.rulebook_id == rulebook_id,
+ )
+ )
+ await session.commit()
+
+
+async def get_applicable_rules(
+ project_id: int, user_id: int, limit: int = 50,
+) -> dict:
+ """Return rules applicable to a project via its subscriptions.
+
+ Shape:
+ {
+ "rules": [{id, title, statement, topic_title, rulebook_title}, ...],
+ "truncated": bool,
+ "subscribed_rulebooks": [{id, title}, ...]
+ }
+ """
+ from fabledassistant.models.rulebook import project_rulebook_subscriptions
+
+ async with async_session() as session:
+ # Subscribed rulebooks for the project (ownership-scoped).
+ sub_q = (
+ select(Rulebook.id, Rulebook.title)
+ .join(
+ project_rulebook_subscriptions,
+ project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
+ )
+ .where(
+ project_rulebook_subscriptions.c.project_id == project_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ .order_by(Rulebook.title)
+ )
+ sub_rows = (await session.execute(sub_q)).all()
+ subscribed_rulebooks = [
+ {"id": rb_id, "title": rb_title} for rb_id, rb_title in sub_rows
+ ]
+
+ # Applicable rules (limit + 1 so we can detect truncation).
+ rules_q = (
+ select(
+ Rule.id, Rule.title, Rule.statement,
+ RulebookTopic.title.label("topic_title"),
+ Rulebook.title.label("rulebook_title"),
+ )
+ .join(RulebookTopic, Rule.topic_id == RulebookTopic.id)
+ .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id)
+ .join(
+ project_rulebook_subscriptions,
+ project_rulebook_subscriptions.c.rulebook_id == Rulebook.id,
+ )
+ .where(
+ project_rulebook_subscriptions.c.project_id == project_id,
+ Rulebook.owner_user_id == user_id,
+ )
+ .order_by(
+ Rulebook.id, RulebookTopic.order_index, Rule.order_index, Rule.title,
+ )
+ .limit(limit + 1)
+ )
+ rule_rows = (await session.execute(rules_q)).all()
+ truncated = len(rule_rows) > limit
+ rules = [
+ {
+ "id": rid, "title": rtitle, "statement": stmt,
+ "topic_title": tt, "rulebook_title": rbt,
+ }
+ for rid, rtitle, stmt, tt, rbt in rule_rows[:limit]
+ ]
+
+ return {
+ "rules": rules,
+ "truncated": truncated,
+ "subscribed_rulebooks": subscribed_rulebooks,
+ }
diff --git a/tests/test_services_rulebooks.py b/tests/test_services_rulebooks.py
index 21125e3..05cf2ee 100644
--- a/tests/test_services_rulebooks.py
+++ b/tests/test_services_rulebooks.py
@@ -221,3 +221,69 @@ async def test_get_rule_returns_none_when_not_owner():
from fabledassistant.services.rulebooks import get_rule
result = await get_rule(rule_id=1, user_id=99)
assert result is None
+
+
+# ── Subscriptions + applicable_rules ────────────────────────────────────
+
+@pytest.mark.asyncio
+async def test_subscribe_project_requires_owned_rulebook():
+ """subscribe_project raises if user doesn't own the rulebook."""
+ mock_session = _make_mock_session()
+ mock_result = MagicMock()
+ mock_result.scalar_one_or_none.return_value = None
+ mock_session.execute = AsyncMock(return_value=mock_result)
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import subscribe_project
+ with pytest.raises(ValueError, match="not found"):
+ await subscribe_project(
+ project_id=1, rulebook_id=999, user_id=7,
+ )
+
+
+@pytest.mark.asyncio
+async def test_get_applicable_rules_returns_shape():
+ """get_applicable_rules returns {rules, truncated, subscribed_rulebooks}."""
+ mock_session = _make_mock_session()
+ sub_result = MagicMock()
+ sub_result.all.return_value = [(1, "FabledSword family")]
+ rules_result = MagicMock()
+ rules_result.all.return_value = [
+ (i, f"Rule {i}", f"Statement {i}", "git-workflow", "FabledSword family")
+ for i in range(50)
+ ]
+ mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
+
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import get_applicable_rules
+ result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
+
+ assert "rules" in result
+ assert "truncated" in result
+ assert "subscribed_rulebooks" in result
+ assert result["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
+ assert len(result["rules"]) == 50
+ assert result["truncated"] is False # exactly 50, not over
+
+
+@pytest.mark.asyncio
+async def test_get_applicable_rules_truncates_when_over_limit():
+ """When limit+1 rows are returned, truncated=True and only `limit` returned."""
+ mock_session = _make_mock_session()
+ sub_result = MagicMock()
+ sub_result.all.return_value = []
+ rules_result = MagicMock()
+ # 51 rows means truncation detected
+ rules_result.all.return_value = [
+ (i, f"r{i}", "stmt", "topic", "rb") for i in range(51)
+ ]
+ mock_session.execute = AsyncMock(side_effect=[sub_result, rules_result])
+
+ with patch("fabledassistant.services.rulebooks.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.rulebooks import get_applicable_rules
+ result = await get_applicable_rules(project_id=3, user_id=7, limit=50)
+
+ assert result["truncated"] is True
+ assert len(result["rules"]) == 50
From 0219c673c12dfb7e4a689d720d82503092d00386 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:34:25 -0400
Subject: [PATCH 078/118] =?UTF-8?q?feat(rulebook):=20REST=20routes=20?=
=?UTF-8?q?=E2=80=94=20rulebook=20+=20topic=20endpoints?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/app.py | 2 +
src/fabledassistant/routes/rulebooks.py | 121 ++++++++++++++++++++++++
tests/test_routes_rulebooks.py | 49 ++++++++++
3 files changed, 172 insertions(+)
create mode 100644 src/fabledassistant/routes/rulebooks.py
create mode 100644 tests/test_routes_rulebooks.py
diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py
index fbefb8a..f22c3fd 100644
--- a/src/fabledassistant/app.py
+++ b/src/fabledassistant/app.py
@@ -25,6 +25,7 @@ from fabledassistant.routes.events import events_bp
from fabledassistant.routes.search import search_bp
from fabledassistant.routes.profile import profile_bp
from fabledassistant.routes.knowledge import knowledge_bp
+from fabledassistant.routes.rulebooks import rulebooks_bp
from fabledassistant.mcp import mount_mcp
STATIC_DIR = Path(__file__).parent / "static"
@@ -83,6 +84,7 @@ def create_app() -> Quart:
app.register_blueprint(search_bp)
app.register_blueprint(profile_bp)
app.register_blueprint(knowledge_bp)
+ app.register_blueprint(rulebooks_bp)
@app.before_request
async def before_request():
diff --git a/src/fabledassistant/routes/rulebooks.py b/src/fabledassistant/routes/rulebooks.py
new file mode 100644
index 0000000..d2f2f9a
--- /dev/null
+++ b/src/fabledassistant/routes/rulebooks.py
@@ -0,0 +1,121 @@
+"""Rulebook / topic REST endpoints.
+
+Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
+authenticated owner; the service enforces ownership scoping.
+"""
+from __future__ import annotations
+
+from quart import Blueprint, g, jsonify, request
+
+from fabledassistant.auth import login_required
+import fabledassistant.services.rulebooks as rulebooks_svc
+
+rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
+
+
+def _uid() -> int:
+ return g.user.id
+
+
+# ── Rulebooks ───────────────────────────────────────────────────────────
+
+@rulebooks_bp.get("/rulebooks")
+@login_required
+async def list_rulebooks():
+ rows = await rulebooks_svc.list_rulebooks(_uid())
+ return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
+
+
+@rulebooks_bp.post("/rulebooks")
+@login_required
+async def create_rulebook():
+ data = await request.get_json() or {}
+ title = (data.get("title") or "").strip()
+ if not title:
+ return jsonify({"error": "title is required"}), 400
+ rb = await rulebooks_svc.create_rulebook(
+ user_id=_uid(),
+ title=title,
+ description=data.get("description", ""),
+ )
+ return jsonify(rb.to_dict()), 201
+
+
+@rulebooks_bp.get("/rulebooks/")
+@login_required
+async def get_rulebook(rulebook_id: int):
+ rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
+ if rb is None:
+ return jsonify({"error": "rulebook not found"}), 404
+ return jsonify(rb.to_dict())
+
+
+@rulebooks_bp.patch("/rulebooks/")
+@login_required
+async def update_rulebook(rulebook_id: int):
+ data = await request.get_json() or {}
+ fields = {k: v for k, v in data.items() if k in ("title", "description")}
+ rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
+ if rb is None:
+ return jsonify({"error": "rulebook not found"}), 404
+ return jsonify(rb.to_dict())
+
+
+@rulebooks_bp.delete("/rulebooks/")
+@login_required
+async def delete_rulebook(rulebook_id: int):
+ await rulebooks_svc.delete_rulebook(rulebook_id, _uid())
+ return "", 204
+
+
+# ── Topics ──────────────────────────────────────────────────────────────
+
+@rulebooks_bp.get("/rulebooks//topics")
+@login_required
+async def list_topics(rulebook_id: int):
+ try:
+ rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return jsonify({"topics": [t.to_dict() for t in rows]})
+
+
+@rulebooks_bp.post("/rulebooks//topics")
+@login_required
+async def create_topic(rulebook_id: int):
+ data = await request.get_json() or {}
+ title = (data.get("title") or "").strip()
+ if not title:
+ return jsonify({"error": "title is required"}), 400
+ try:
+ topic = await rulebooks_svc.create_topic(
+ rulebook_id=rulebook_id,
+ user_id=_uid(),
+ title=title,
+ description=data.get("description", ""),
+ order_index=data.get("order_index", 0),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return jsonify(topic.to_dict()), 201
+
+
+@rulebooks_bp.patch("/rulebook-topics/")
+@login_required
+async def update_topic(topic_id: int):
+ data = await request.get_json() or {}
+ fields = {
+ k: v for k, v in data.items()
+ if k in ("title", "description", "order_index")
+ }
+ topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
+ if topic is None:
+ return jsonify({"error": "topic not found"}), 404
+ return jsonify(topic.to_dict())
+
+
+@rulebooks_bp.delete("/rulebook-topics/")
+@login_required
+async def delete_topic(topic_id: int):
+ await rulebooks_svc.delete_topic(topic_id, _uid())
+ return "", 204
diff --git a/tests/test_routes_rulebooks.py b/tests/test_routes_rulebooks.py
new file mode 100644
index 0000000..1aa733c
--- /dev/null
+++ b/tests/test_routes_rulebooks.py
@@ -0,0 +1,49 @@
+"""Route-level tests for the rulebooks blueprint.
+
+Mirrors the structural-tests pattern in tests/test_events_routes.py:
+covers blueprint registration, callable handlers, and service-signature
+contracts. Full HTTP integration requires a live DB and auth machinery
+that the unit-test environment doesn't provide.
+"""
+import inspect
+
+
+def test_rulebooks_blueprint_registered():
+ from fabledassistant.routes.rulebooks import rulebooks_bp
+ assert rulebooks_bp.name == "rulebooks"
+ assert rulebooks_bp.url_prefix == "/api"
+
+
+def test_rulebooks_blueprint_registered_in_app():
+ from fabledassistant.app import create_app
+ app = create_app()
+ assert "rulebooks" in app.blueprints
+
+
+def test_rulebook_handlers_callable():
+ from fabledassistant.routes import rulebooks as rb_routes
+ for name in (
+ "list_rulebooks", "create_rulebook", "get_rulebook",
+ "update_rulebook", "delete_rulebook",
+ ):
+ assert callable(getattr(rb_routes, name))
+
+
+def test_topic_handlers_callable():
+ from fabledassistant.routes import rulebooks as rb_routes
+ for name in (
+ "list_topics", "create_topic", "update_topic", "delete_topic",
+ ):
+ assert callable(getattr(rb_routes, name))
+
+
+def test_service_signatures_require_user_id():
+ """Routes must call services with user_id — verify the contract."""
+ from fabledassistant.services import rulebooks as svc
+ for fn_name in (
+ "create_rulebook", "list_rulebooks", "get_rulebook",
+ "update_rulebook", "delete_rulebook", "find_rulebook_by_title",
+ "create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
+ ):
+ sig = inspect.signature(getattr(svc, fn_name))
+ assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
From bbbd6b2f28d8caa2848d067b6a54a3ef67714d42 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:34:53 -0400
Subject: [PATCH 079/118] =?UTF-8?q?feat(rulebook):=20REST=20routes=20?=
=?UTF-8?q?=E2=80=94=20rules,=20subscriptions,=20applicable=20rules?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/routes/rulebooks.py | 119 ++++++++++++++++++++++++
tests/test_routes_rulebooks.py | 11 +++
2 files changed, 130 insertions(+)
diff --git a/src/fabledassistant/routes/rulebooks.py b/src/fabledassistant/routes/rulebooks.py
index d2f2f9a..2d8d99d 100644
--- a/src/fabledassistant/routes/rulebooks.py
+++ b/src/fabledassistant/routes/rulebooks.py
@@ -119,3 +119,122 @@ async def update_topic(topic_id: int):
async def delete_topic(topic_id: int):
await rulebooks_svc.delete_topic(topic_id, _uid())
return "", 204
+
+
+# ── Rules ───────────────────────────────────────────────────────────────
+
+@rulebooks_bp.get("/rules")
+@login_required
+async def list_rules():
+ def _opt_int(name):
+ raw = request.args.get(name)
+ return int(raw) if raw else None
+
+ try:
+ rulebook_id = _opt_int("rulebook_id")
+ topic_id = _opt_int("topic_id")
+ project_id = _opt_int("project_id")
+ except ValueError:
+ return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
+
+ rows = await rulebooks_svc.list_rules(
+ user_id=_uid(),
+ rulebook_id=rulebook_id,
+ topic_id=topic_id,
+ project_id=project_id,
+ )
+ return jsonify({"rules": [r.to_dict() for r in rows]})
+
+
+@rulebooks_bp.post("/rulebook-topics//rules")
+@login_required
+async def create_rule(topic_id: int):
+ data = await request.get_json() or {}
+ title = (data.get("title") or "").strip()
+ statement = (data.get("statement") or "").strip()
+ if not title or not statement:
+ return jsonify({"error": "title and statement are required"}), 400
+ try:
+ rule = await rulebooks_svc.create_rule(
+ topic_id=topic_id,
+ user_id=_uid(),
+ title=title,
+ statement=statement,
+ why=data.get("why", ""),
+ how_to_apply=data.get("how_to_apply", ""),
+ order_index=data.get("order_index", 0),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return jsonify(rule.to_dict()), 201
+
+
+@rulebooks_bp.get("/rules/")
+@login_required
+async def get_rule(rule_id: int):
+ rule = await rulebooks_svc.get_rule(rule_id, _uid())
+ if rule is None:
+ return jsonify({"error": "rule not found"}), 404
+ return jsonify(rule.to_dict())
+
+
+@rulebooks_bp.patch("/rules/")
+@login_required
+async def update_rule(rule_id: int):
+ data = await request.get_json() or {}
+ fields = {
+ k: v for k, v in data.items()
+ if k in ("title", "statement", "why", "how_to_apply", "order_index")
+ }
+ rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields)
+ if rule is None:
+ return jsonify({"error": "rule not found"}), 404
+ return jsonify(rule.to_dict())
+
+
+@rulebooks_bp.delete("/rules/")
+@login_required
+async def delete_rule(rule_id: int):
+ await rulebooks_svc.delete_rule(rule_id, _uid())
+ return "", 204
+
+
+# ── Subscriptions ──────────────────────────────────────────────────────
+
+@rulebooks_bp.post("/projects//rulebook-subscriptions")
+@login_required
+async def subscribe_project(project_id: int):
+ data = await request.get_json() or {}
+ rulebook_id = data.get("rulebook_id")
+ if not rulebook_id:
+ return jsonify({"error": "rulebook_id is required"}), 400
+ try:
+ await rulebooks_svc.subscribe_project(
+ project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return "", 204
+
+
+@rulebooks_bp.delete(
+ "/projects//rulebook-subscriptions/"
+)
+@login_required
+async def unsubscribe_project(project_id: int, rulebook_id: int):
+ try:
+ await rulebooks_svc.unsubscribe_project(
+ project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(),
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return "", 204
+
+
+@rulebooks_bp.get("/projects//rules")
+@login_required
+async def get_project_rules(project_id: int):
+ result = await rulebooks_svc.get_applicable_rules(
+ project_id=project_id, user_id=_uid(),
+ )
+ return jsonify(result)
diff --git a/tests/test_routes_rulebooks.py b/tests/test_routes_rulebooks.py
index 1aa733c..6f6fa0b 100644
--- a/tests/test_routes_rulebooks.py
+++ b/tests/test_routes_rulebooks.py
@@ -44,6 +44,17 @@ def test_service_signatures_require_user_id():
"create_rulebook", "list_rulebooks", "get_rulebook",
"update_rulebook", "delete_rulebook", "find_rulebook_by_title",
"create_topic", "list_topics", "get_topic", "update_topic", "delete_topic",
+ "create_rule", "list_rules", "get_rule", "update_rule", "delete_rule",
+ "subscribe_project", "unsubscribe_project", "get_applicable_rules",
):
sig = inspect.signature(getattr(svc, fn_name))
assert "user_id" in sig.parameters, f"{fn_name} missing user_id param"
+
+
+def test_rule_and_subscription_handlers_callable():
+ from fabledassistant.routes import rulebooks as rb_routes
+ for name in (
+ "list_rules", "create_rule", "get_rule", "update_rule", "delete_rule",
+ "subscribe_project", "unsubscribe_project", "get_project_rules",
+ ):
+ assert callable(getattr(rb_routes, name))
From a1a6c5e47eae1f62c91f742e694904e6139c5463 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:51:39 -0400
Subject: [PATCH 080/118] =?UTF-8?q?feat(rulebook):=20MCP=20tools=20?=
=?UTF-8?q?=E2=80=94=2016=20tools=20for=20rulebook/topic/rule/subscription?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/mcp/tools/__init__.py | 3 +-
src/fabledassistant/mcp/tools/rulebooks.py | 304 +++++++++++++++++++++
tests/test_mcp_tool_rulebooks.py | 185 +++++++++++++
3 files changed, 491 insertions(+), 1 deletion(-)
create mode 100644 src/fabledassistant/mcp/tools/rulebooks.py
create mode 100644 tests/test_mcp_tool_rulebooks.py
diff --git a/src/fabledassistant/mcp/tools/__init__.py b/src/fabledassistant/mcp/tools/__init__.py
index d162f6d..f3ec021 100644
--- a/src/fabledassistant/mcp/tools/__init__.py
+++ b/src/fabledassistant/mcp/tools/__init__.py
@@ -5,7 +5,7 @@ to a FastMCP instance. `register_all(mcp)` is the single entry point called
from `mcp.server.build_mcp_server`.
"""
from fabledassistant.mcp.tools import (
- entities, events, milestones, notes, projects, recent, search, tags, tasks,
+ entities, events, milestones, notes, projects, recent, rulebooks, search, tags, tasks,
)
@@ -20,3 +20,4 @@ def register_all(mcp) -> None:
tags.register(mcp)
recent.register(mcp)
entities.register(mcp)
+ rulebooks.register(mcp)
diff --git a/src/fabledassistant/mcp/tools/rulebooks.py b/src/fabledassistant/mcp/tools/rulebooks.py
new file mode 100644
index 0000000..adb9ba3
--- /dev/null
+++ b/src/fabledassistant/mcp/tools/rulebooks.py
@@ -0,0 +1,304 @@
+"""MCP tools for the Scribe Rulebook system.
+
+Sixteen tools: rulebook/topic/rule CRUD + subscription management. Thin
+wrappers over services/rulebooks.py — ownership is enforced in the service.
+
+Destructive ops (delete_*) require confirmed=True; otherwise return a
+preview-style warning. Mirrors the pattern in delete_event and the design
+spec.
+"""
+from __future__ import annotations
+
+from fabledassistant.mcp._context import current_user_id
+from fabledassistant.services import rulebooks as rulebooks_svc
+
+
+# ── Rulebook CRUD ───────────────────────────────────────────────────────
+
+async def list_rulebooks() -> dict:
+ """List all rulebooks owned by the current user.
+
+ Returns id, title, description for each.
+ """
+ uid = current_user_id()
+ rows = await rulebooks_svc.list_rulebooks(uid)
+ return {"rulebooks": [rb.to_dict() for rb in rows]}
+
+
+async def get_rulebook(rulebook_id: int) -> dict:
+ """Fetch a rulebook by id with its full topic list."""
+ uid = current_user_id()
+ rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
+ if rb is None:
+ raise ValueError(f"rulebook {rulebook_id} not found")
+ topics = await rulebooks_svc.list_topics(rulebook_id, uid)
+ data = rb.to_dict()
+ data["topics"] = [t.to_dict() for t in topics]
+ return data
+
+
+async def create_rulebook(title: str, description: str = "") -> dict:
+ """Create a new rulebook.
+
+ Args:
+ title: Rulebook name (e.g. "FabledSword family").
+ description: Optional short description of what this rulebook covers.
+ """
+ uid = current_user_id()
+ rb = await rulebooks_svc.create_rulebook(
+ user_id=uid, title=title, description=description,
+ )
+ return rb.to_dict()
+
+
+async def update_rulebook(
+ rulebook_id: int, title: str = "", description: str = "",
+) -> dict:
+ """Update an existing rulebook. Only non-empty fields are changed."""
+ uid = current_user_id()
+ fields: dict = {}
+ if title:
+ fields["title"] = title
+ if description:
+ fields["description"] = description
+ rb = await rulebooks_svc.update_rulebook(rulebook_id, uid, **fields)
+ if rb is None:
+ raise ValueError(f"rulebook {rulebook_id} not found")
+ return rb.to_dict()
+
+
+async def delete_rulebook(rulebook_id: int, confirmed: bool = False) -> dict:
+ """Permanently delete a rulebook (cascades to all its topics and rules).
+
+ Pass confirmed=True to actually delete. Without confirmation, returns a
+ preview describing what will be cascaded.
+ """
+ uid = current_user_id()
+ rb = await rulebooks_svc.get_rulebook(rulebook_id, uid)
+ if rb is None:
+ raise ValueError(f"rulebook {rulebook_id} not found")
+ if not confirmed:
+ topics = await rulebooks_svc.list_topics(rulebook_id, uid)
+ rule_count = 0
+ for t in topics:
+ rule_count += len(await rulebooks_svc.list_rules(uid, topic_id=t.id))
+ return {
+ "warning": (
+ f"Rulebook {rulebook_id} ('{rb.title}') contains "
+ f"{len(topics)} topics and {rule_count} rules; all will be "
+ f"deleted. Pass confirmed=True to proceed."
+ ),
+ "confirmed_required": True,
+ }
+ await rulebooks_svc.delete_rulebook(rulebook_id, uid)
+ return {"deleted": rulebook_id}
+
+
+# ── Topic CRUD ─────────────────────────────────────────────────────────
+
+async def list_topics(rulebook_id: int) -> dict:
+ """List topics inside a rulebook."""
+ uid = current_user_id()
+ rows = await rulebooks_svc.list_topics(rulebook_id, uid)
+ return {"topics": [t.to_dict() for t in rows]}
+
+
+async def create_topic(
+ rulebook_id: int, title: str,
+ description: str = "", order_index: int = 0,
+) -> dict:
+ """Create a topic within a rulebook.
+
+ Args:
+ rulebook_id: Rulebook to add the topic to.
+ title: Topic name (e.g. "git-workflow").
+ description: Optional description.
+ order_index: Display order (0-based; default 0).
+ """
+ uid = current_user_id()
+ topic = await rulebooks_svc.create_topic(
+ rulebook_id=rulebook_id, user_id=uid,
+ title=title, description=description, order_index=order_index,
+ )
+ return topic.to_dict()
+
+
+async def update_topic(
+ topic_id: int, title: str = "",
+ description: str = "", order_index: int = -1,
+) -> dict:
+ """Update a topic. Sentinels: title="" / description="" leave unchanged;
+ order_index=-1 leaves unchanged.
+ """
+ uid = current_user_id()
+ fields: dict = {}
+ if title:
+ fields["title"] = title
+ if description:
+ fields["description"] = description
+ if order_index >= 0:
+ fields["order_index"] = order_index
+ topic = await rulebooks_svc.update_topic(topic_id, uid, **fields)
+ if topic is None:
+ raise ValueError(f"topic {topic_id} not found")
+ return topic.to_dict()
+
+
+async def delete_topic(topic_id: int, confirmed: bool = False) -> dict:
+ """Delete a topic and all its rules. Requires confirmed=True."""
+ uid = current_user_id()
+ topic = await rulebooks_svc.get_topic(topic_id, uid)
+ if topic is None:
+ raise ValueError(f"topic {topic_id} not found")
+ if not confirmed:
+ rules = await rulebooks_svc.list_rules(uid, topic_id=topic_id)
+ return {
+ "warning": (
+ f"Topic {topic_id} ('{topic.title}') contains {len(rules)} "
+ f"rules; all will be deleted. Pass confirmed=True to proceed."
+ ),
+ "confirmed_required": True,
+ }
+ await rulebooks_svc.delete_topic(topic_id, uid)
+ return {"deleted": topic_id}
+
+
+# ── Rule CRUD ──────────────────────────────────────────────────────────
+
+async def list_rules(
+ rulebook_id: int = 0, topic_id: int = 0, project_id: int = 0,
+) -> dict:
+ """List rules — filter by rulebook, topic, and/or project.
+
+ Args:
+ rulebook_id: 0 = no filter; positive = restrict to that rulebook.
+ topic_id: 0 = no filter; positive = restrict to that topic.
+ project_id: 0 = no filter; positive = restrict to rules applicable
+ to that project (via its rulebook subscriptions).
+
+ All filters are AND-combined; ownership-scoped.
+ """
+ uid = current_user_id()
+ rows = await rulebooks_svc.list_rules(
+ user_id=uid,
+ rulebook_id=rulebook_id or None,
+ topic_id=topic_id or None,
+ project_id=project_id or None,
+ )
+ return {
+ "rules": [
+ {
+ "id": r.id, "title": r.title, "statement": r.statement,
+ "topic_id": r.topic_id,
+ }
+ for r in rows
+ ],
+ "total": len(rows),
+ }
+
+
+async def get_rule(rule_id: int) -> dict:
+ """Fetch a rule by id — full statement + why + how_to_apply."""
+ uid = current_user_id()
+ rule = await rulebooks_svc.get_rule(rule_id, uid)
+ if rule is None:
+ raise ValueError(f"rule {rule_id} not found")
+ return rule.to_dict()
+
+
+async def create_rule(
+ topic_id: int, title: str, statement: str,
+ why: str = "", how_to_apply: str = "", order_index: int = 0,
+) -> dict:
+ """Create a new rule under a topic.
+
+ Args:
+ topic_id: The topic to attach the rule to.
+ title: A short imperative title (e.g. "dev is home").
+ statement: The actionable instruction (required). 1-2 sentences.
+ why: Optional rationale — the reason the rule exists.
+ how_to_apply: Optional operationalization — when / where it kicks in.
+ order_index: Display order within the topic (default 0).
+ """
+ uid = current_user_id()
+ rule = await rulebooks_svc.create_rule(
+ topic_id=topic_id, user_id=uid,
+ title=title, statement=statement,
+ why=why, how_to_apply=how_to_apply, order_index=order_index,
+ )
+ return rule.to_dict()
+
+
+async def update_rule(
+ rule_id: int, title: str = "", statement: str = "",
+ why: str = "", how_to_apply: str = "", order_index: int = -1,
+) -> dict:
+ """Update a rule. Empty strings / order_index=-1 leave fields unchanged."""
+ uid = current_user_id()
+ fields: dict = {}
+ if title:
+ fields["title"] = title
+ if statement:
+ fields["statement"] = statement
+ if why:
+ fields["why"] = why
+ if how_to_apply:
+ fields["how_to_apply"] = how_to_apply
+ if order_index >= 0:
+ fields["order_index"] = order_index
+ rule = await rulebooks_svc.update_rule(rule_id, uid, **fields)
+ if rule is None:
+ raise ValueError(f"rule {rule_id} not found")
+ return rule.to_dict()
+
+
+async def delete_rule(rule_id: int, confirmed: bool = False) -> dict:
+ """Permanently delete a rule. Requires confirmed=True."""
+ uid = current_user_id()
+ rule = await rulebooks_svc.get_rule(rule_id, uid)
+ if rule is None:
+ raise ValueError(f"rule {rule_id} not found")
+ if not confirmed:
+ return {
+ "warning": (
+ f"Rule {rule_id} ('{rule.title}') will be permanently deleted. "
+ f"Pass confirmed=True to proceed."
+ ),
+ "confirmed_required": True,
+ }
+ await rulebooks_svc.delete_rule(rule_id, uid)
+ return {"deleted": rule_id}
+
+
+# ── Subscriptions ──────────────────────────────────────────────────────
+
+async def subscribe_project_to_rulebook(
+ project_id: int, rulebook_id: int,
+) -> dict:
+ """Subscribe a project to a rulebook. Its rules will apply to that project."""
+ uid = current_user_id()
+ await rulebooks_svc.subscribe_project(
+ project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
+ )
+ return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": True}
+
+
+async def unsubscribe_project_from_rulebook(
+ project_id: int, rulebook_id: int,
+) -> dict:
+ """Remove a project's subscription to a rulebook."""
+ uid = current_user_id()
+ await rulebooks_svc.unsubscribe_project(
+ project_id=project_id, rulebook_id=rulebook_id, user_id=uid,
+ )
+ return {"project_id": project_id, "rulebook_id": rulebook_id, "subscribed": False}
+
+
+def register(mcp) -> None:
+ for fn in (
+ list_rulebooks, get_rulebook, create_rulebook, update_rulebook, delete_rulebook,
+ list_topics, create_topic, update_topic, delete_topic,
+ list_rules, get_rule, create_rule, update_rule, delete_rule,
+ subscribe_project_to_rulebook, unsubscribe_project_from_rulebook,
+ ):
+ mcp.tool(name=fn.__name__)(fn)
diff --git a/tests/test_mcp_tool_rulebooks.py b/tests/test_mcp_tool_rulebooks.py
new file mode 100644
index 0000000..84e5d55
--- /dev/null
+++ b/tests/test_mcp_tool_rulebooks.py
@@ -0,0 +1,185 @@
+"""Tests for MCP rulebook tools — patches the service layer."""
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from fabledassistant.mcp._context import _user_id_ctx
+
+
+@pytest.fixture(autouse=True)
+def _bind_user():
+ token = _user_id_ctx.set(7)
+ yield
+ _user_id_ctx.reset(token)
+
+
+def _fake_rulebook(id=1, title="t"):
+ rb = MagicMock()
+ rb.id = id
+ rb.title = title
+ rb.to_dict.return_value = {"id": id, "title": title}
+ return rb
+
+
+def _fake_topic(id=10, title="git"):
+ t = MagicMock()
+ t.id = id
+ t.title = title
+ t.to_dict.return_value = {"id": id, "title": title}
+ return t
+
+
+def _fake_rule(id=100, title="r", statement="s"):
+ r = MagicMock()
+ r.id = id
+ r.title = title
+ r.topic_id = 10
+ r.statement = statement
+ r.to_dict.return_value = {"id": id, "title": title, "statement": statement}
+ return r
+
+
+@pytest.mark.asyncio
+async def test_list_rulebooks_wraps_in_dict():
+ rows = [_fake_rulebook(id=1), _fake_rulebook(id=2)]
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_rulebooks",
+ AsyncMock(return_value=rows),
+ ):
+ from fabledassistant.mcp.tools.rulebooks import list_rulebooks
+ out = await list_rulebooks()
+ assert len(out["rulebooks"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_get_rulebook_includes_topics():
+ rb = _fake_rulebook(id=1)
+ topics = [_fake_topic(id=10), _fake_topic(id=11)]
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
+ AsyncMock(return_value=rb),
+ ), patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.list_topics",
+ AsyncMock(return_value=topics),
+ ):
+ from fabledassistant.mcp.tools.rulebooks import get_rulebook
+ out = await get_rulebook(rulebook_id=1)
+ assert out["id"] == 1
+ assert len(out["topics"]) == 2
+
+
+@pytest.mark.asyncio
+async def test_get_rulebook_raises_when_not_found():
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.get_rulebook",
+ AsyncMock(return_value=None),
+ ):
+ from fabledassistant.mcp.tools.rulebooks import get_rulebook
+ with pytest.raises(ValueError, match="rulebook 999 not found"):
+ await get_rulebook(rulebook_id=999)
+
+
+@pytest.mark.asyncio
+async def test_create_rule_passes_required_fields():
+ rule = _fake_rule()
+ mock = AsyncMock(return_value=rule)
+ with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.create_rule", mock):
+ from fabledassistant.mcp.tools.rulebooks import create_rule
+ await create_rule(
+ topic_id=10, title="dev is home", statement="Work directly on dev",
+ )
+ kwargs = mock.call_args.kwargs
+ assert kwargs["user_id"] == 7
+ assert kwargs["topic_id"] == 10
+ assert kwargs["statement"] == "Work directly on dev"
+
+
+@pytest.mark.asyncio
+async def test_update_rule_only_sends_non_default_fields():
+ rule = _fake_rule()
+ mock = AsyncMock(return_value=rule)
+ with patch("fabledassistant.mcp.tools.rulebooks.rulebooks_svc.update_rule", mock):
+ from fabledassistant.mcp.tools.rulebooks import update_rule
+ await update_rule(rule_id=1, statement="new statement")
+ args, kwargs = mock.call_args
+ assert args == (1, 7)
+ assert kwargs == {"statement": "new statement"}
+
+
+@pytest.mark.asyncio
+async def test_delete_rule_without_confirmed_returns_warning():
+ """delete_rule with confirmed=False returns a preview, not an action."""
+ rule = _fake_rule()
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.get_rule",
+ AsyncMock(return_value=rule),
+ ), patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
+ AsyncMock(),
+ ) as mock_delete:
+ from fabledassistant.mcp.tools.rulebooks import delete_rule
+ out = await delete_rule(rule_id=1, confirmed=False)
+ assert out.get("confirmed_required") is True
+ assert "confirmed=True" in out.get("warning", "")
+ assert not mock_delete.called # service NOT called
+
+
+@pytest.mark.asyncio
+async def test_delete_rule_with_confirmed_calls_service():
+ rule = _fake_rule()
+ mock_delete = AsyncMock()
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.get_rule",
+ AsyncMock(return_value=rule),
+ ), patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.delete_rule",
+ mock_delete,
+ ):
+ from fabledassistant.mcp.tools.rulebooks import delete_rule
+ out = await delete_rule(rule_id=1, confirmed=True)
+ assert out == {"deleted": 1}
+ assert mock_delete.called
+
+
+@pytest.mark.asyncio
+async def test_subscribe_project_to_rulebook_calls_service():
+ mock = AsyncMock()
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.subscribe_project", mock,
+ ):
+ from fabledassistant.mcp.tools.rulebooks import subscribe_project_to_rulebook
+ out = await subscribe_project_to_rulebook(project_id=3, rulebook_id=1)
+ assert out["subscribed"] is True
+ assert mock.called
+
+
+@pytest.mark.asyncio
+async def test_unsubscribe_project_from_rulebook_calls_service():
+ mock = AsyncMock()
+ with patch(
+ "fabledassistant.mcp.tools.rulebooks.rulebooks_svc.unsubscribe_project", mock,
+ ):
+ from fabledassistant.mcp.tools.rulebooks import unsubscribe_project_from_rulebook
+ out = await unsubscribe_project_from_rulebook(project_id=3, rulebook_id=1)
+ assert out["subscribed"] is False
+ assert mock.called
+
+
+def test_register_attaches_all_sixteen_tools():
+ """register(mcp) should call mcp.tool(name=...) for all 16 tools."""
+ from fabledassistant.mcp.tools.rulebooks import register
+ registered: list[str] = []
+
+ class FakeMCP:
+ def tool(self, name=None):
+ def decorator(fn):
+ registered.append(name)
+ return fn
+ return decorator
+
+ register(FakeMCP())
+ assert len(registered) == 16
+ # spot-check a few names
+ assert "list_rulebooks" in registered
+ assert "create_rule" in registered
+ assert "subscribe_project_to_rulebook" in registered
From eab5c5a026b147ad04ee2ac068708b6ecd0c9aec Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:52:18 -0400
Subject: [PATCH 081/118] feat(rulebook): augment get_project with
applicable_rules + MCP instructions
---
src/fabledassistant/mcp/server.py | 10 ++++++
src/fabledassistant/mcp/tools/projects.py | 14 ++++++--
tests/test_mcp_tool_projects.py | 39 +++++++++++++++++++++++
3 files changed, 60 insertions(+), 3 deletions(-)
diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py
index b686b92..563ad36 100644
--- a/src/fabledassistant/mcp/server.py
+++ b/src/fabledassistant/mcp/server.py
@@ -20,6 +20,16 @@ Hierarchy: Project -> Milestone -> Task/Note.
unchanged on updates.
- For optional integer FKs (project_id, milestone_id, parent_id), use 0 to mean
"not set".
+
+Scribe maintains a Rulebook system (Rulebook -> Topic -> Rule). Rules carry
+an actionable statement plus optional Why and How-to-apply context. When you
+start work on a project, get_project(id) returns applicable_rules (the rules
+from rulebooks the project subscribes to) and subscribed_rulebooks. Consult
+these before making decisions about workflow, conventions, or scope. Full
+text (Why / How-to-apply) is available via get_rule(id). You may create new
+rules via create_rule when you notice a pattern worth codifying — coordinate
+with the operator on whether it belongs in an existing rulebook+topic or a
+new one.
"""
diff --git a/src/fabledassistant/mcp/tools/projects.py b/src/fabledassistant/mcp/tools/projects.py
index d99a677..a6c4928 100644
--- a/src/fabledassistant/mcp/tools/projects.py
+++ b/src/fabledassistant/mcp/tools/projects.py
@@ -19,6 +19,7 @@ from __future__ import annotations
from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import milestones as milestones_svc
from fabledassistant.services import projects as projects_svc
+from fabledassistant.services import rulebooks as rulebooks_svc
async def list_projects() -> dict:
@@ -33,10 +34,11 @@ async def list_projects() -> dict:
async def get_project(project_id: int) -> dict:
- """Fetch a Scribe project by ID, including its milestone summary.
+ """Fetch a Scribe project by ID.
- Returns full project fields plus a milestone_summary list with each
- milestone's id, title, status, and task counts.
+ Returns full project fields, a milestone_summary list, and the
+ rulebook-applicable_rules / subscribed_rulebooks pair the assistant
+ should consult when working on this project.
"""
uid = current_user_id()
project = await projects_svc.get_project(uid, project_id)
@@ -46,6 +48,12 @@ async def get_project(project_id: int) -> dict:
data["milestone_summary"] = await milestones_svc.get_project_milestone_summary(
uid, project_id,
)
+ applicable = await rulebooks_svc.get_applicable_rules(
+ project_id=project_id, user_id=uid,
+ )
+ data["applicable_rules"] = applicable["rules"]
+ data["applicable_rules_truncated"] = applicable["truncated"]
+ data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
return data
diff --git a/tests/test_mcp_tool_projects.py b/tests/test_mcp_tool_projects.py
index 936f928..696eddb 100644
--- a/tests/test_mcp_tool_projects.py
+++ b/tests/test_mcp_tool_projects.py
@@ -41,18 +41,57 @@ async def test_list_projects_wraps_in_dict():
async def test_get_project_enriches_with_milestone_summary():
p = _fake_project(id=5, title="found")
milestone_summary = [{"id": 10, "title": "MS", "task_count": 3}]
+ applicable_payload = {
+ "rules": [], "truncated": False, "subscribed_rulebooks": [],
+ }
with patch(
"fabledassistant.mcp.tools.projects.projects_svc.get_project",
AsyncMock(return_value=p),
), patch(
"fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
AsyncMock(return_value=milestone_summary),
+ ), patch(
+ "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
+ AsyncMock(return_value=applicable_payload),
):
out = await get_project(project_id=5)
assert out["id"] == 5
assert out["milestone_summary"] == milestone_summary
+@pytest.mark.asyncio
+async def test_get_project_includes_applicable_rules_and_subscribed_rulebooks():
+ """The augmented get_project response includes applicable_rules and
+ subscribed_rulebooks pulled from services/rulebooks.get_applicable_rules.
+ """
+ p = _fake_project(id=3, title="Fabled Assistant")
+ milestone_summary = []
+ applicable_payload = {
+ "rules": [
+ {"id": 1, "title": "dev is home",
+ "statement": "Work directly on dev",
+ "topic_title": "git-workflow",
+ "rulebook_title": "FabledSword family"},
+ ],
+ "truncated": False,
+ "subscribed_rulebooks": [{"id": 1, "title": "FabledSword family"}],
+ }
+ with patch(
+ "fabledassistant.mcp.tools.projects.projects_svc.get_project",
+ AsyncMock(return_value=p),
+ ), patch(
+ "fabledassistant.mcp.tools.projects.milestones_svc.get_project_milestone_summary",
+ AsyncMock(return_value=milestone_summary),
+ ), patch(
+ "fabledassistant.mcp.tools.projects.rulebooks_svc.get_applicable_rules",
+ AsyncMock(return_value=applicable_payload),
+ ):
+ out = await get_project(project_id=3)
+ assert out["applicable_rules"][0]["title"] == "dev is home"
+ assert out["subscribed_rulebooks"] == [{"id": 1, "title": "FabledSword family"}]
+ assert out["applicable_rules_truncated"] is False
+
+
@pytest.mark.asyncio
async def test_get_project_raises_when_not_found():
with patch(
From 605dd0a13ab99403fd47629404827b630cff4404 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 21:59:23 -0400
Subject: [PATCH 082/118] feat(rulebook): frontend API client + Pinia store
---
frontend/src/api/rulebooks.ts | 135 +++++++++++++++++++++++++++++++
frontend/src/stores/rulebooks.ts | 128 +++++++++++++++++++++++++++++
2 files changed, 263 insertions(+)
create mode 100644 frontend/src/api/rulebooks.ts
create mode 100644 frontend/src/stores/rulebooks.ts
diff --git a/frontend/src/api/rulebooks.ts b/frontend/src/api/rulebooks.ts
new file mode 100644
index 0000000..77685a6
--- /dev/null
+++ b/frontend/src/api/rulebooks.ts
@@ -0,0 +1,135 @@
+import { apiGet, apiPost, apiPatch, apiDelete } from "@/api/client";
+
+export interface Rulebook {
+ id: number;
+ owner_user_id: number;
+ title: string;
+ description: string;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export interface RulebookTopic {
+ id: number;
+ rulebook_id: number;
+ title: string;
+ description: string;
+ order_index: number;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export interface Rule {
+ id: number;
+ topic_id: number;
+ title: string;
+ statement: string;
+ why: string;
+ how_to_apply: string;
+ order_index: number;
+ created_at: string | null;
+ updated_at: string | null;
+}
+
+export interface RuleHeader {
+ id: number;
+ title: string;
+ statement: string;
+ topic_id: number;
+}
+
+export interface ApplicableRules {
+ rules: {
+ id: number;
+ title: string;
+ statement: string;
+ topic_title: string;
+ rulebook_title: string;
+ }[];
+ truncated: boolean;
+ subscribed_rulebooks: { id: number; title: string }[];
+}
+
+// ── Rulebooks ───────────────────────────────────────────────────────
+
+export async function listRulebooks(): Promise {
+ const data = await apiGet<{ rulebooks: Rulebook[] }>("/api/rulebooks");
+ return data.rulebooks;
+}
+
+export async function getRulebook(id: number): Promise {
+ return apiGet(`/api/rulebooks/${id}`);
+}
+
+export async function createRulebook(data: { title: string; description?: string }): Promise {
+ return apiPost("/api/rulebooks", data);
+}
+
+export async function updateRulebook(id: number, data: Partial<{ title: string; description: string }>): Promise {
+ return apiPatch(`/api/rulebooks/${id}`, data);
+}
+
+export async function deleteRulebook(id: number): Promise {
+ return apiDelete(`/api/rulebooks/${id}`);
+}
+
+// ── Topics ─────────────────────────────────────────────────────────
+
+export async function listTopics(rulebookId: number): Promise {
+ const data = await apiGet<{ topics: RulebookTopic[] }>(`/api/rulebooks/${rulebookId}/topics`);
+ return data.topics;
+}
+
+export async function createTopic(rulebookId: number, data: { title: string; description?: string; order_index?: number }): Promise {
+ return apiPost(`/api/rulebooks/${rulebookId}/topics`, data);
+}
+
+export async function updateTopic(id: number, data: Partial<{ title: string; description: string; order_index: number }>): Promise {
+ return apiPatch(`/api/rulebook-topics/${id}`, data);
+}
+
+export async function deleteTopic(id: number): Promise {
+ return apiDelete(`/api/rulebook-topics/${id}`);
+}
+
+// ── Rules ──────────────────────────────────────────────────────────
+
+export async function listRules(filters: { rulebook_id?: number; topic_id?: number; project_id?: number } = {}): Promise {
+ const params = new URLSearchParams();
+ if (filters.rulebook_id) params.set("rulebook_id", String(filters.rulebook_id));
+ if (filters.topic_id) params.set("topic_id", String(filters.topic_id));
+ if (filters.project_id) params.set("project_id", String(filters.project_id));
+ const qs = params.toString();
+ const data = await apiGet<{ rules: Rule[] }>(`/api/rules${qs ? `?${qs}` : ""}`);
+ return data.rules;
+}
+
+export async function getRule(id: number): Promise {
+ return apiGet(`/api/rules/${id}`);
+}
+
+export async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string; order_index?: number }): Promise {
+ return apiPost(`/api/rulebook-topics/${topicId}/rules`, data);
+}
+
+export async function updateRule(id: number, data: Partial<{ title: string; statement: string; why: string; how_to_apply: string; order_index: number }>): Promise {
+ return apiPatch(`/api/rules/${id}`, data);
+}
+
+export async function deleteRule(id: number): Promise {
+ return apiDelete(`/api/rules/${id}`);
+}
+
+// ── Subscriptions ──────────────────────────────────────────────────
+
+export async function subscribeProject(projectId: number, rulebookId: number): Promise {
+ await apiPost(`/api/projects/${projectId}/rulebook-subscriptions`, { rulebook_id: rulebookId });
+}
+
+export async function unsubscribeProject(projectId: number, rulebookId: number): Promise {
+ return apiDelete(`/api/projects/${projectId}/rulebook-subscriptions/${rulebookId}`);
+}
+
+export async function getProjectApplicableRules(projectId: number): Promise {
+ return apiGet(`/api/projects/${projectId}/rules`);
+}
diff --git a/frontend/src/stores/rulebooks.ts b/frontend/src/stores/rulebooks.ts
new file mode 100644
index 0000000..61d2b88
--- /dev/null
+++ b/frontend/src/stores/rulebooks.ts
@@ -0,0 +1,128 @@
+import { ref } from "vue";
+import { defineStore } from "pinia";
+import * as api from "@/api/rulebooks";
+import type { Rulebook, RulebookTopic, Rule, RuleHeader } from "@/api/rulebooks";
+import { useToastStore } from "@/stores/toast";
+
+export const useRulebooksStore = defineStore("rulebooks", () => {
+ const rulebooks = ref([]);
+ const topicsByRulebook = ref>({});
+ const rulesByTopic = ref>({});
+ const currentRule = ref(null);
+ const loading = ref(false);
+
+ async function fetchRulebooks() {
+ loading.value = true;
+ try {
+ rulebooks.value = await api.listRulebooks();
+ } catch (e) {
+ useToastStore().show("Failed to load rulebooks", "error");
+ throw e;
+ } finally {
+ loading.value = false;
+ }
+ }
+
+ async function fetchTopics(rulebookId: number) {
+ try {
+ topicsByRulebook.value[rulebookId] = await api.listTopics(rulebookId);
+ } catch (e) {
+ useToastStore().show("Failed to load topics", "error");
+ throw e;
+ }
+ }
+
+ async function fetchRules(topicId: number) {
+ try {
+ const rules = await api.listRules({ topic_id: topicId });
+ rulesByTopic.value[topicId] = rules.map((r) => ({
+ id: r.id, title: r.title, statement: r.statement, topic_id: r.topic_id,
+ }));
+ } catch (e) {
+ useToastStore().show("Failed to load rules", "error");
+ throw e;
+ }
+ }
+
+ async function fetchRule(id: number) {
+ currentRule.value = await api.getRule(id);
+ }
+
+ async function createRulebook(data: { title: string; description?: string }) {
+ const rb = await api.createRulebook(data);
+ rulebooks.value.push(rb);
+ return rb;
+ }
+
+ async function updateRulebook(id: number, data: Partial>) {
+ const rb = await api.updateRulebook(id, data);
+ const idx = rulebooks.value.findIndex((r) => r.id === id);
+ if (idx >= 0) rulebooks.value[idx] = rb;
+ return rb;
+ }
+
+ async function deleteRulebook(id: number) {
+ await api.deleteRulebook(id);
+ rulebooks.value = rulebooks.value.filter((r) => r.id !== id);
+ delete topicsByRulebook.value[id];
+ }
+
+ async function createTopic(rulebookId: number, data: { title: string; description?: string }) {
+ const topic = await api.createTopic(rulebookId, data);
+ if (!topicsByRulebook.value[rulebookId]) topicsByRulebook.value[rulebookId] = [];
+ topicsByRulebook.value[rulebookId].push(topic);
+ return topic;
+ }
+
+ async function updateTopic(id: number, data: Partial>) {
+ const topic = await api.updateTopic(id, data);
+ for (const rbId of Object.keys(topicsByRulebook.value)) {
+ const list = topicsByRulebook.value[Number(rbId)];
+ const idx = list.findIndex((t) => t.id === id);
+ if (idx >= 0) list[idx] = topic;
+ }
+ return topic;
+ }
+
+ async function deleteTopic(id: number) {
+ await api.deleteTopic(id);
+ for (const rbId of Object.keys(topicsByRulebook.value)) {
+ topicsByRulebook.value[Number(rbId)] = topicsByRulebook.value[Number(rbId)].filter((t) => t.id !== id);
+ }
+ delete rulesByTopic.value[id];
+ }
+
+ async function createRule(topicId: number, data: { title: string; statement: string; why?: string; how_to_apply?: string }) {
+ const rule = await api.createRule(topicId, data);
+ if (!rulesByTopic.value[topicId]) rulesByTopic.value[topicId] = [];
+ rulesByTopic.value[topicId].push({ id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id });
+ return rule;
+ }
+
+ async function updateRule(id: number, data: Partial>) {
+ const rule = await api.updateRule(id, data);
+ if (currentRule.value?.id === id) currentRule.value = rule;
+ for (const tid of Object.keys(rulesByTopic.value)) {
+ const list = rulesByTopic.value[Number(tid)];
+ const idx = list.findIndex((r) => r.id === id);
+ if (idx >= 0) list[idx] = { id: rule.id, title: rule.title, statement: rule.statement, topic_id: rule.topic_id };
+ }
+ return rule;
+ }
+
+ async function deleteRule(id: number) {
+ await api.deleteRule(id);
+ if (currentRule.value?.id === id) currentRule.value = null;
+ for (const tid of Object.keys(rulesByTopic.value)) {
+ rulesByTopic.value[Number(tid)] = rulesByTopic.value[Number(tid)].filter((r) => r.id !== id);
+ }
+ }
+
+ return {
+ rulebooks, topicsByRulebook, rulesByTopic, currentRule, loading,
+ fetchRulebooks, fetchTopics, fetchRules, fetchRule,
+ createRulebook, updateRulebook, deleteRulebook,
+ createTopic, updateTopic, deleteTopic,
+ createRule, updateRule, deleteRule,
+ };
+});
From 75d8e7ab4908165afebaaea6d004ef6c2879e7ba Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 22:00:32 -0400
Subject: [PATCH 083/118] feat(rulebook): RulesView three-pane shell + child
panes + rule editor
---
.../components/rules/RuleEditorSlideOver.vue | 123 ++++++++++++++++++
.../src/components/rules/RuleListPane.vue | 40 ++++++
.../components/rules/RulebookDetailPane.vue | 80 ++++++++++++
.../src/components/rules/RulebookListPane.vue | 65 +++++++++
frontend/src/views/RulesView.vue | 118 +++++++++++++++++
5 files changed, 426 insertions(+)
create mode 100644 frontend/src/components/rules/RuleEditorSlideOver.vue
create mode 100644 frontend/src/components/rules/RuleListPane.vue
create mode 100644 frontend/src/components/rules/RulebookDetailPane.vue
create mode 100644 frontend/src/components/rules/RulebookListPane.vue
create mode 100644 frontend/src/views/RulesView.vue
diff --git a/frontend/src/components/rules/RuleEditorSlideOver.vue b/frontend/src/components/rules/RuleEditorSlideOver.vue
new file mode 100644
index 0000000..72e1c6c
--- /dev/null
+++ b/frontend/src/components/rules/RuleEditorSlideOver.vue
@@ -0,0 +1,123 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/components/rules/RuleListPane.vue b/frontend/src/components/rules/RuleListPane.vue
new file mode 100644
index 0000000..65ecac4
--- /dev/null
+++ b/frontend/src/components/rules/RuleListPane.vue
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ {{ r.title }}
+ {{ r.statement }}
+
+
+ + New rule
+
+
+
+
diff --git a/frontend/src/components/rules/RulebookDetailPane.vue b/frontend/src/components/rules/RulebookDetailPane.vue
new file mode 100644
index 0000000..6bb4636
--- /dev/null
+++ b/frontend/src/components/rules/RulebookDetailPane.vue
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
Subscribers
+
(subscription management — populated in next task)
+
+
+
+
+
diff --git a/frontend/src/components/rules/RulebookListPane.vue b/frontend/src/components/rules/RulebookListPane.vue
new file mode 100644
index 0000000..33f8657
--- /dev/null
+++ b/frontend/src/components/rules/RulebookListPane.vue
@@ -0,0 +1,65 @@
+
+
+
+
+
+
+
diff --git a/frontend/src/views/RulesView.vue b/frontend/src/views/RulesView.vue
new file mode 100644
index 0000000..f491c33
--- /dev/null
+++ b/frontend/src/views/RulesView.vue
@@ -0,0 +1,118 @@
+
+
+
+
+
+
+
+
Select a rulebook to view its topics.
+
+
+
+
Select a topic to view its rules.
+
+
+
+
+
+
From 447adf816caa9b5dc7fa1fb856c635afb742677e Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 22:01:03 -0400
Subject: [PATCH 084/118] =?UTF-8?q?feat(rulebook):=20subscription=20panel?=
=?UTF-8?q?=20=E2=80=94=20toggle=20projects=20per=20rulebook?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../components/rules/RulebookDetailPane.vue | 61 +++++++++++++++++--
1 file changed, 57 insertions(+), 4 deletions(-)
diff --git a/frontend/src/components/rules/RulebookDetailPane.vue b/frontend/src/components/rules/RulebookDetailPane.vue
index 6bb4636..56be664 100644
--- a/frontend/src/components/rules/RulebookDetailPane.vue
+++ b/frontend/src/components/rules/RulebookDetailPane.vue
@@ -1,6 +1,10 @@
@@ -47,10 +89,20 @@ async function submitNew() {
-
Subscribers
-
(subscription management — populated in next task)
+
@@ -75,6 +127,7 @@ li:hover { background: var(--color-hover, rgba(255,255,255,0.05)); }
padding-top: 1rem;
}
.subscriptions h3 { font-size: 0.9em; opacity: 0.7; text-transform: uppercase; letter-spacing: 0.05em; }
-.hint { font-size: 0.85em; opacity: 0.6; }
+.sub-list li { cursor: default; }
+.sub-list label { display: flex; gap: 0.5rem; align-items: center; cursor: pointer; }
button { cursor: pointer; }
From f2afb2a8bf5ffbb1d3088b88cb31cda76a334778 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 22:01:54 -0400
Subject: [PATCH 085/118] feat(rulebook): /rules route, nav entry, g+r shortcut
---
frontend/src/App.vue | 1 +
frontend/src/components/AppHeader.vue | 2 ++
frontend/src/router/index.ts | 5 +++++
3 files changed, 8 insertions(+)
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 4b0e8c5..5a908a5 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -79,6 +79,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "n": router.push("/notes"); break;
case "t": router.push("/"); break;
case "p": router.push("/projects"); break;
+ case "r": router.push("/rules"); break;
case "g": router.push("/graph"); break;
case "l": router.push("/calendar"); break;
}
diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue
index 553b0db..95dc35b 100644
--- a/frontend/src/components/AppHeader.vue
+++ b/frontend/src/components/AppHeader.vue
@@ -48,6 +48,7 @@ router.afterEach(() => {
Knowledge
Calendar
Projects
+ Rulebooks
@@ -87,6 +88,7 @@ router.afterEach(() => {
Knowledge
Calendar
Projects
+ Rulebooks
Shared
Settings
diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts
index 7f7918a..a4eb030 100644
--- a/frontend/src/router/index.ts
+++ b/frontend/src/router/index.ts
@@ -79,6 +79,11 @@ const router = createRouter({
name: "project-view",
component: () => import("@/views/ProjectView.vue"),
},
+ {
+ path: "/rules",
+ name: "rules",
+ component: () => import("@/views/RulesView.vue"),
+ },
{
path: "/tasks",
redirect: "/",
From 9658e9a35c876cd63d56cfbdd2574c839fd95d71 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 22:20:19 -0400
Subject: [PATCH 086/118] =?UTF-8?q?feat(rulebook):=20Project=20Rules=20tab?=
=?UTF-8?q?=20=E2=80=94=20applicable=20rules=20+=20subscription=20chips?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../src/components/rules/ProjectRulesTab.vue | 211 ++++++++++++++++++
frontend/src/views/ProjectView.vue | 9 +-
2 files changed, 219 insertions(+), 1 deletion(-)
create mode 100644 frontend/src/components/rules/ProjectRulesTab.vue
diff --git a/frontend/src/components/rules/ProjectRulesTab.vue b/frontend/src/components/rules/ProjectRulesTab.vue
new file mode 100644
index 0000000..955b58c
--- /dev/null
+++ b/frontend/src/components/rules/ProjectRulesTab.vue
@@ -0,0 +1,211 @@
+
+
+
+
+
+ Subscribed rulebooks
+
+
+ {{ rb.title }}
+ ×
+
+
+ Subscribe
+
+ Choose a rulebook…
+
+ {{ rb.title }}
+
+
+
+
+
+
+ Applicable rules
+
+ No rules yet — subscribe to a rulebook above, or create one at
+ Rulebooks .
+
+
+
+ Truncated at 50 rules — there are more applicable rules.
+
+
+
+
+
+
diff --git a/frontend/src/views/ProjectView.vue b/frontend/src/views/ProjectView.vue
index 9b83782..f252429 100644
--- a/frontend/src/views/ProjectView.vue
+++ b/frontend/src/views/ProjectView.vue
@@ -5,6 +5,7 @@ import { apiGet, apiPatch, apiDelete, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import { relativeTime } from "@/composables/useRelativeTime";
import ShareDialog from "@/components/ShareDialog.vue";
+import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
import {
LayoutGrid,
Clock,
@@ -67,7 +68,7 @@ const loading = ref(false);
const saving = ref(false);
const error = ref(null);
-const activeTab = ref<"tasks" | "notes">("tasks");
+const activeTab = ref<"tasks" | "notes" | "rules">("tasks");
const tasks = ref([]);
const notes = ref([]);
@@ -436,6 +437,9 @@ async function confirmDelete() {
Notes
{{ project.summary.note_count }}
+
+ Rules
+
@@ -616,6 +620,9 @@ async function confirmDelete() {
No notes in this project.
+
+
+
From 5f3da7c0047bf6496c8384331c97605395352d43 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Wed, 27 May 2026 22:39:50 -0400
Subject: [PATCH 087/118] =?UTF-8?q?feat(rulebook):=20port=20script=20?=
=?UTF-8?q?=E2=80=94=20parse=20FabledRulebook=20.md=20=E2=86=92=20Scribe?=
=?UTF-8?q?=20DB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/__init__.py | 1 +
scripts/import_fabledrulebook.py | 185 ++++++++++++++++++
.../fixtures/fabledrulebook_sample/README.md | 3 +
.../fabledrulebook_sample/git-workflow.md | 19 ++
tests/test_import_fabledrulebook.py | 46 +++++
5 files changed, 254 insertions(+)
create mode 100644 scripts/__init__.py
create mode 100644 scripts/import_fabledrulebook.py
create mode 100644 tests/fixtures/fabledrulebook_sample/README.md
create mode 100644 tests/fixtures/fabledrulebook_sample/git-workflow.md
create mode 100644 tests/test_import_fabledrulebook.py
diff --git a/scripts/__init__.py b/scripts/__init__.py
new file mode 100644
index 0000000..5017c42
--- /dev/null
+++ b/scripts/__init__.py
@@ -0,0 +1 @@
+"""One-shot operational scripts (not part of the running app)."""
diff --git a/scripts/import_fabledrulebook.py b/scripts/import_fabledrulebook.py
new file mode 100644
index 0000000..6ee3505
--- /dev/null
+++ b/scripts/import_fabledrulebook.py
@@ -0,0 +1,185 @@
+"""One-shot import of FabledRulebook .md files into the Scribe DB.
+
+Usage:
+ python -m scripts.import_fabledrulebook \\
+ --source-dir /path/to/FabledRulebook \\
+ --user-id 1 \\
+ [--rulebook-title "FabledSword family"] \\
+ [--subscribe-projects 3,4,5] \\
+ [--dry-run] \\
+ [--force]
+
+Calls the services layer (services/rulebooks.py) — does not write raw SQL.
+This means any structural mismatch surfaces as a clean service-level
+ValueError. The script is not part of Alembic; the operator runs it once
+after migration 0055 lands.
+"""
+from __future__ import annotations
+
+import argparse
+import asyncio
+import logging
+import re
+from pathlib import Path
+
+
+RULE_HEADING_RE = re.compile(r"^##\s+(.+?)\s*$", re.MULTILINE)
+WHY_RE = re.compile(r"\*\*\s*Why:?\s*\*\*", re.IGNORECASE)
+HOW_RE = re.compile(r"\*\*\s*How to apply:?\s*\*\*", re.IGNORECASE)
+H1_RE = re.compile(r"^#\s+.+$", re.MULTILINE)
+
+
+logger = logging.getLogger("import_fabledrulebook")
+
+
+def parse_rule_body(title: str, body: str) -> dict:
+ """Extract statement, why, how_to_apply from a rule's H2-section body."""
+ body = body.strip()
+ statement = body
+ why = ""
+ how_to_apply = ""
+
+ why_match = WHY_RE.search(body)
+ how_match = HOW_RE.search(body)
+
+ if why_match:
+ statement = body[: why_match.start()].strip()
+ if how_match and how_match.start() > why_match.end():
+ why = body[why_match.end() : how_match.start()].strip()
+ how_to_apply = body[how_match.end() :].strip()
+ else:
+ why = body[why_match.end() :].strip()
+ elif how_match:
+ statement = body[: how_match.start()].strip()
+ how_to_apply = body[how_match.end() :].strip()
+
+ return {
+ "title": title,
+ "statement": statement,
+ "why": why,
+ "how_to_apply": how_to_apply,
+ }
+
+
+def parse_topic_file(path: Path) -> tuple[str, str, list[dict]]:
+ """Returns (topic_title, topic_description, [rules])."""
+ text = path.read_text(encoding="utf-8")
+ topic_title = path.stem # e.g. "git-workflow"
+
+ parts = RULE_HEADING_RE.split(text)
+ preamble = parts[0] if parts else ""
+ # Strip H1 from preamble to get just the description.
+ description = H1_RE.sub("", preamble, count=1).strip()
+
+ rules: list[dict] = []
+ for i in range(1, len(parts), 2):
+ rule_title = parts[i].strip()
+ body = parts[i + 1] if i + 1 < len(parts) else ""
+ rules.append(parse_rule_body(rule_title, body))
+
+ return topic_title, description, rules
+
+
+async def run(args) -> None:
+ source = Path(args.source_dir)
+ if not source.exists():
+ raise SystemExit(f"Source dir not found: {source}")
+
+ md_files = sorted(p for p in source.glob("*.md") if p.name.lower() != "readme.md")
+ if not md_files:
+ raise SystemExit(f"No topic .md files found in {source}")
+
+ readme = source / "README.md"
+ description = readme.read_text(encoding="utf-8") if readme.exists() else ""
+
+ if args.dry_run:
+ print(f"DRY RUN — would import {len(md_files)} topic file(s):")
+ for f in md_files:
+ topic_title, _, rules = parse_topic_file(f)
+ print(f" TOPIC: {topic_title} ({len(rules)} rules)")
+ for r in rules:
+ preview = r["statement"][:80] + ("…" if len(r["statement"]) > 80 else "")
+ empty_flag = " [EMPTY STATEMENT — will skip]" if not r["statement"].strip() else ""
+ print(f" - {r['title']}: {preview}{empty_flag}")
+ return
+
+ from fabledassistant.services import rulebooks as rb_service
+
+ existing = await rb_service.find_rulebook_by_title(args.user_id, args.rulebook_title)
+ if existing is not None and not args.force:
+ raise SystemExit(
+ f"Rulebook '{args.rulebook_title}' already exists for user "
+ f"{args.user_id}; pass --force to create another."
+ )
+
+ rb = await rb_service.create_rulebook(
+ user_id=args.user_id,
+ title=args.rulebook_title,
+ description=description,
+ )
+ logger.info("Created rulebook %d ('%s')", rb.id, rb.title)
+
+ rules_created = 0
+ rules_skipped = 0
+ for i, f in enumerate(md_files):
+ topic_title, topic_desc, rules = parse_topic_file(f)
+ topic = await rb_service.create_topic(
+ rulebook_id=rb.id,
+ user_id=args.user_id,
+ title=topic_title,
+ description=topic_desc,
+ order_index=i,
+ )
+ logger.info(" Created topic %d ('%s', %d rules)", topic.id, topic.title, len(rules))
+ for j, rule in enumerate(rules):
+ if not rule["statement"].strip():
+ logger.warning(" SKIP empty-statement rule: %s / %s", topic_title, rule["title"])
+ rules_skipped += 1
+ continue
+ await rb_service.create_rule(
+ topic_id=topic.id,
+ user_id=args.user_id,
+ title=rule["title"],
+ statement=rule["statement"],
+ why=rule["why"],
+ how_to_apply=rule["how_to_apply"],
+ order_index=j,
+ )
+ rules_created += 1
+
+ for pid in (args.subscribe_projects or []):
+ await rb_service.subscribe_project(
+ project_id=pid, rulebook_id=rb.id, user_id=args.user_id,
+ )
+ logger.info("Subscribed project %d to rulebook %d", pid, rb.id)
+
+ print(
+ f"\nImported rulebook '{rb.title}' (id={rb.id}): "
+ f"{len(md_files)} topics, {rules_created} rules created"
+ + (f", {rules_skipped} skipped" if rules_skipped else "")
+ )
+
+
+def _comma_ints(s: str) -> list[int]:
+ return [int(x) for x in s.split(",") if x.strip()]
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--source-dir", required=True,
+ help="Path to the FabledRulebook directory")
+ parser.add_argument("--user-id", type=int, required=True,
+ help="Scribe user id who will own the rulebook")
+ parser.add_argument("--rulebook-title", default="FabledSword family")
+ parser.add_argument("--subscribe-projects", type=_comma_ints,
+ help="Comma-separated project ids to auto-subscribe")
+ parser.add_argument("--dry-run", action="store_true")
+ parser.add_argument("--force", action="store_true",
+ help="Allow creating a rulebook even if title exists")
+ args = parser.parse_args()
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+ asyncio.run(run(args))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tests/fixtures/fabledrulebook_sample/README.md b/tests/fixtures/fabledrulebook_sample/README.md
new file mode 100644
index 0000000..e96b6d3
--- /dev/null
+++ b/tests/fixtures/fabledrulebook_sample/README.md
@@ -0,0 +1,3 @@
+# Sample Rulebook
+
+This is a fixture for testing the port script.
diff --git a/tests/fixtures/fabledrulebook_sample/git-workflow.md b/tests/fixtures/fabledrulebook_sample/git-workflow.md
new file mode 100644
index 0000000..1fdf4b6
--- /dev/null
+++ b/tests/fixtures/fabledrulebook_sample/git-workflow.md
@@ -0,0 +1,19 @@
+# git-workflow
+
+Lead paragraph describing the topic.
+
+## dev is home
+
+Work directly on dev; no feature branches.
+
+**Why:** It minimises ceremony for a solo developer.
+
+**How to apply:** Default for all repo work. Switch to a feature branch only when explicitly asked.
+
+## no-force-push
+
+Never force-push to main.
+
+**Why:** Force-pushing rewrites shared history.
+
+**How to apply:** Anytime you're about to run `git push --force`, stop and check the target branch.
diff --git a/tests/test_import_fabledrulebook.py b/tests/test_import_fabledrulebook.py
new file mode 100644
index 0000000..ed6e73a
--- /dev/null
+++ b/tests/test_import_fabledrulebook.py
@@ -0,0 +1,46 @@
+"""Tests for scripts/import_fabledrulebook.py — parser fixtures only.
+
+The CLI / DB-writing portion is exercised manually on remote dev — it's a
+one-shot script.
+"""
+from pathlib import Path
+
+
+FIXTURES = Path(__file__).parent / "fixtures" / "fabledrulebook_sample"
+
+
+def test_parse_topic_file_extracts_topic_title_and_rules():
+ from scripts.import_fabledrulebook import parse_topic_file
+ topic_title, description, rules = parse_topic_file(FIXTURES / "git-workflow.md")
+
+ assert topic_title == "git-workflow"
+ assert "Lead paragraph" in description
+ assert len(rules) == 2
+ assert rules[0]["title"] == "dev is home"
+ assert "Work directly on dev" in rules[0]["statement"]
+ assert "minimises ceremony" in rules[0]["why"]
+ assert "Default for all repo work" in rules[0]["how_to_apply"]
+
+
+def test_parse_rule_body_without_why_treats_whole_body_as_statement():
+ from scripts.import_fabledrulebook import parse_rule_body
+ out = parse_rule_body("test", "Just a body with no markers.")
+ assert out["statement"] == "Just a body with no markers."
+ assert out["why"] == ""
+ assert out["how_to_apply"] == ""
+
+
+def test_parse_rule_body_handles_only_why():
+ from scripts.import_fabledrulebook import parse_rule_body
+ out = parse_rule_body("test", "Statement.\n\n**Why:** Because.")
+ assert out["statement"] == "Statement."
+ assert out["why"] == "Because."
+ assert out["how_to_apply"] == ""
+
+
+def test_parse_rule_body_handles_only_how_to_apply():
+ from scripts.import_fabledrulebook import parse_rule_body
+ out = parse_rule_body("test", "Statement.\n\n**How to apply:** Do it now.")
+ assert out["statement"] == "Statement."
+ assert out["why"] == ""
+ assert out["how_to_apply"] == "Do it now."
From ac462d1203740ced67cb13e4696acf8ef217f580 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 08:15:13 -0400
Subject: [PATCH 088/118] =?UTF-8?q?feat(plan):=20migration=200056=20?=
=?UTF-8?q?=E2=80=94=20task=5Fkind=20column=20on=20notes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
alembic/versions/0056_task_kind.py | 35 ++++++++++++++++++++++++++++++
1 file changed, 35 insertions(+)
create mode 100644 alembic/versions/0056_task_kind.py
diff --git a/alembic/versions/0056_task_kind.py b/alembic/versions/0056_task_kind.py
new file mode 100644
index 0000000..fa07d18
--- /dev/null
+++ b/alembic/versions/0056_task_kind.py
@@ -0,0 +1,35 @@
+"""task_kind column on notes: distinguishes plan-tasks from work-tasks
+
+Revision ID: 0056
+Revises: 0055
+Create Date: 2026-05-28
+
+A plan is a Task (a note with non-null status) marked task_kind='plan'.
+The CHECK constraint lands in the same migration as the value set, per the
+'new CHECK-enum values need a same-change migration' rule.
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "0056"
+down_revision = "0055"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "notes",
+ sa.Column(
+ "task_kind", sa.Text(), nullable=False, server_default="work",
+ ),
+ )
+ op.create_check_constraint(
+ "notes_task_kind_check", "notes", "task_kind IN ('work','plan')",
+ )
+
+
+def downgrade() -> None:
+ op.drop_constraint("notes_task_kind_check", "notes", type_="check")
+ op.drop_column("notes", "task_kind")
From 8754b1c94d46b602497bd395bc32b7b5f61e5abd Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 08:15:29 -0400
Subject: [PATCH 089/118] feat(plan): task_kind on Note model + to_dict
---
src/fabledassistant/models/note.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/fabledassistant/models/note.py b/src/fabledassistant/models/note.py
index dfce1b3..048a8b0 100644
--- a/src/fabledassistant/models/note.py
+++ b/src/fabledassistant/models/note.py
@@ -60,6 +60,10 @@ class Note(Base, TimestampMixin):
# Structured metadata for entity types (person/place/list)
# Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute
entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True)
+ # Task sub-kind — 'work' (default) or 'plan'. Only meaningful when the note
+ # is a task (status is not None); ordinary notes keep the 'work' default and
+ # ignore it. Orthogonal to note_type (which is the note/entity axis).
+ task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work")
__table_args__ = (
Index("ix_notes_tags", "tags", postgresql_using="gin"),
@@ -106,6 +110,7 @@ class Note(Base, TimestampMixin):
),
"is_task": self.is_task,
"note_type": self.entity_type,
+ "task_kind": self.task_kind,
"metadata": self.entity_meta or {},
"created_at": self.created_at.isoformat(),
"updated_at": self.updated_at.isoformat(),
From 50d2a0e9c0706ce5ee75f428a574f4abd2a29fc4 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 08:21:59 -0400
Subject: [PATCH 090/118] =?UTF-8?q?feat(plan):=20services/notes=20?=
=?UTF-8?q?=E2=80=94=20task=5Fkind=20create=20param=20+=20list=20filter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/notes.py | 7 ++++
tests/test_services_notes_task_kind.py | 47 ++++++++++++++++++++++++++
2 files changed, 54 insertions(+)
create mode 100644 tests/test_services_notes_task_kind.py
diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py
index 493a5c8..4ef525f 100644
--- a/src/fabledassistant/services/notes.py
+++ b/src/fabledassistant/services/notes.py
@@ -64,6 +64,7 @@ async def create_note(
recurrence_rule: dict | None = None,
note_type: str = "note",
entity_meta: dict | None = None,
+ task_kind: str = "work",
) -> Note:
# Auto-populate project_id from milestone when not explicitly provided
if milestone_id is not None and project_id is None:
@@ -92,6 +93,7 @@ async def create_note(
recurrence_rule=recurrence_rule,
note_type=note_type,
entity_meta=entity_meta,
+ task_kind=task_kind,
)
session.add(note)
await session.commit()
@@ -124,6 +126,7 @@ async def list_notes(
milestone_id: int | None = None,
milestone_ids: list[int] | None = None,
parent_id: int | None = None,
+ task_kind: str | None = None,
no_project: bool = False,
exclude_paused_projects: bool = False,
sort: str = "updated_at",
@@ -197,6 +200,10 @@ async def list_notes(
query = query.where(Note.parent_id == parent_id)
count_query = count_query.where(Note.parent_id == parent_id)
+ if task_kind is not None:
+ query = query.where(Note.task_kind == task_kind)
+ count_query = count_query.where(Note.task_kind == task_kind)
+
if no_project:
query = query.where(Note.project_id.is_(None))
count_query = count_query.where(Note.project_id.is_(None))
diff --git a/tests/test_services_notes_task_kind.py b/tests/test_services_notes_task_kind.py
new file mode 100644
index 0000000..d1eeb12
--- /dev/null
+++ b/tests/test_services_notes_task_kind.py
@@ -0,0 +1,47 @@
+"""task_kind create + list-filter behavior in services/notes.py."""
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+def _make_mock_session():
+ s = AsyncMock()
+ s.__aenter__ = AsyncMock(return_value=s)
+ s.__aexit__ = AsyncMock(return_value=False)
+ s.add = MagicMock()
+ s.commit = AsyncMock()
+ s.refresh = AsyncMock()
+ return s
+
+
+@pytest.mark.asyncio
+async def test_create_note_passes_task_kind_to_model():
+ mock_session = _make_mock_session()
+ captured = {}
+
+ def _capture_add(obj):
+ captured["task_kind"] = getattr(obj, "task_kind", "MISSING")
+
+ mock_session.add = MagicMock(side_effect=_capture_add)
+ with patch("fabledassistant.services.notes.async_session") as mock_cls, \
+ patch("fabledassistant.services.notes._maybe_reactivate_project", AsyncMock()):
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.notes import create_note
+ await create_note(user_id=1, title="P", status="todo", task_kind="plan")
+ assert captured["task_kind"] == "plan"
+
+
+@pytest.mark.asyncio
+async def test_list_notes_filters_by_task_kind():
+ mock_session = _make_mock_session()
+ mock_session.scalar = AsyncMock(return_value=0)
+ exec_result = MagicMock()
+ exec_result.scalars.return_value.all.return_value = []
+ mock_session.execute = AsyncMock(return_value=exec_result)
+ with patch("fabledassistant.services.notes.async_session") as mock_cls:
+ mock_cls.return_value = mock_session
+ from fabledassistant.services.notes import list_notes
+ # Should not raise; task_kind is a recognized kwarg.
+ rows, total = await list_notes(user_id=1, is_task=True, task_kind="plan")
+ assert rows == []
+ assert total == 0
From fc4a1627b556f1a650dfc5322b698633140b4701 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 08:22:31 -0400
Subject: [PATCH 091/118] =?UTF-8?q?feat(plan):=20MCP=20task=20tools=20?=
=?UTF-8?q?=E2=80=94=20kind=20on=20create=5Ftask=20+=20list=5Ftasks?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/mcp/tools/tasks.py | 6 ++++
tests/test_mcp_tool_tasks_kind.py | 45 ++++++++++++++++++++++++++
2 files changed, 51 insertions(+)
create mode 100644 tests/test_mcp_tool_tasks_kind.py
diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py
index 1077a00..8c7bbfe 100644
--- a/src/fabledassistant/mcp/tools/tasks.py
+++ b/src/fabledassistant/mcp/tools/tasks.py
@@ -28,12 +28,14 @@ async def list_tasks(
offset: int = 0,
status: str = "",
project_id: int = 0,
+ kind: str = "",
) -> dict:
"""List tasks in Scribe.
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.
+ kind: Filter by task kind — 'work' or 'plan'. Omit (empty) for all kinds.
Results are ordered by last-updated descending.
"""
@@ -43,6 +45,7 @@ async def list_tasks(
is_task=True,
status=status or None,
project_id=project_id or None,
+ task_kind=kind or None,
limit=max(1, min(limit, 100)),
offset=max(0, offset),
)
@@ -78,6 +81,7 @@ async def create_task(
milestone_id: int = 0,
parent_id: int = 0,
tags: list[str] | None = None,
+ kind: str = "work",
) -> dict:
"""Create a new task in Scribe.
@@ -90,6 +94,7 @@ async def create_task(
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.
+ kind: 'work' (default) or 'plan'. Prefer the start_planning tool to create plans.
"""
uid = current_user_id()
note = await notes_svc.create_note(
@@ -102,6 +107,7 @@ async def create_task(
milestone_id=milestone_id or None,
parent_id=parent_id or None,
tags=tags,
+ task_kind=kind,
)
return note.to_dict()
diff --git a/tests/test_mcp_tool_tasks_kind.py b/tests/test_mcp_tool_tasks_kind.py
new file mode 100644
index 0000000..9d8bda0
--- /dev/null
+++ b/tests/test_mcp_tool_tasks_kind.py
@@ -0,0 +1,45 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from fabledassistant.mcp._context import _user_id_ctx
+
+
+@pytest.fixture(autouse=True)
+def _bind_user():
+ token = _user_id_ctx.set(7)
+ yield
+ _user_id_ctx.reset(token)
+
+
+def _fake_note(task_kind="work"):
+ n = MagicMock()
+ n.to_dict.return_value = {"id": 1, "title": "T", "task_kind": task_kind}
+ return n
+
+
+@pytest.mark.asyncio
+async def test_create_task_passes_kind():
+ mock = AsyncMock(return_value=_fake_note(task_kind="plan"))
+ with patch("fabledassistant.mcp.tools.tasks.notes_svc.create_note", mock):
+ from fabledassistant.mcp.tools.tasks import create_task
+ await create_task(title="P", kind="plan")
+ assert mock.call_args.kwargs["task_kind"] == "plan"
+
+
+@pytest.mark.asyncio
+async def test_list_tasks_passes_kind_filter():
+ mock = AsyncMock(return_value=([], 0))
+ with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
+ from fabledassistant.mcp.tools.tasks import list_tasks
+ await list_tasks(kind="plan")
+ assert mock.call_args.kwargs["task_kind"] == "plan"
+
+
+@pytest.mark.asyncio
+async def test_list_tasks_kind_empty_means_no_filter():
+ mock = AsyncMock(return_value=([], 0))
+ with patch("fabledassistant.mcp.tools.tasks.notes_svc.list_notes", mock):
+ from fabledassistant.mcp.tools.tasks import list_tasks
+ await list_tasks()
+ assert mock.call_args.kwargs["task_kind"] is None
From 737467f9966f25a8e7cc9775da49177ceb574433 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 08:22:54 -0400
Subject: [PATCH 092/118] =?UTF-8?q?feat(plan):=20REST=20task=20routes=20?=
=?UTF-8?q?=E2=80=94=20kind=20on=20create=20+=20list?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/routes/tasks.py | 3 +++
tests/test_routes_tasks_kind.py | 17 +++++++++++++++++
2 files changed, 20 insertions(+)
create mode 100644 tests/test_routes_tasks_kind.py
diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py
index 91d7b2b..99883a9 100644
--- a/src/fabledassistant/routes/tasks.py
+++ b/src/fabledassistant/routes/tasks.py
@@ -57,6 +57,7 @@ async def list_tasks_route():
project_id = request.args.get("project_id", type=int)
no_project = request.args.get("no_project", "").lower() == "true"
+ kind = request.args.get("kind") or None
due_before = parse_iso_date(request.args.get("due_before"), "due_before")
if isinstance(due_before, tuple):
@@ -75,6 +76,7 @@ async def list_tasks_route():
due_before=due_before,
due_after=due_after,
project_id=project_id,
+ task_kind=kind,
no_project=no_project,
sort=sort,
order=order,
@@ -133,6 +135,7 @@ async def create_task_route():
milestone_id=data.get("milestone_id"),
parent_id=data.get("parent_id"),
recurrence_rule=recurrence_rule if recurrence_rule is not _UNSET else None,
+ task_kind=data.get("kind", "work"),
)
text = f"{task.title}\n{task.body}".strip() if task.body else (task.title or "")
if text:
diff --git a/tests/test_routes_tasks_kind.py b/tests/test_routes_tasks_kind.py
new file mode 100644
index 0000000..bd06220
--- /dev/null
+++ b/tests/test_routes_tasks_kind.py
@@ -0,0 +1,17 @@
+import inspect
+
+
+def test_create_note_accepts_task_kind():
+ from fabledassistant.services.notes import create_note
+ assert "task_kind" in inspect.signature(create_note).parameters
+
+
+def test_list_notes_accepts_task_kind():
+ from fabledassistant.services.notes import list_notes
+ assert "task_kind" in inspect.signature(list_notes).parameters
+
+
+def test_tasks_blueprint_registered_in_app():
+ from fabledassistant.app import create_app
+ app = create_app()
+ assert "tasks" in app.blueprints
From e269ac9d5cb7ebb42bdf0f0478a0b67ae49caa4a Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 10:16:36 -0400
Subject: [PATCH 093/118] =?UTF-8?q?feat(plan):=20services/planning=20?=
=?UTF-8?q?=E2=80=94=20start=5Fplanning=20aggregator?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/fabledassistant/services/planning.py | 63 ++++++++++++++++++++++++
tests/test_services_planning.py | 46 +++++++++++++++++
2 files changed, 109 insertions(+)
create mode 100644 src/fabledassistant/services/planning.py
create mode 100644 tests/test_services_planning.py
diff --git a/src/fabledassistant/services/planning.py b/src/fabledassistant/services/planning.py
new file mode 100644
index 0000000..0bb9597
--- /dev/null
+++ b/src/fabledassistant/services/planning.py
@@ -0,0 +1,63 @@
+"""Planning service — start_planning aggregates plan-task creation with the
+project's applicable Rulebook rules and a little context, so planning happens
+in Scribe and rules surface at the planning moment.
+"""
+from __future__ import annotations
+
+from fabledassistant.services import notes as notes_svc
+from fabledassistant.services import projects as projects_svc
+from fabledassistant.services import rulebooks as rulebooks_svc
+
+PLAN_TEMPLATE = """## Goal
+
+## Approach
+
+## Steps
+- [ ]
+
+## Verification
+"""
+
+
+async def start_planning(user_id: int, project_id: int, title: str) -> dict:
+ """Create a plan-task seeded with the plan template and return it with the
+ project's applicable rules + brief context.
+
+ Returns:
+ {
+ "task": ,
+ "applicable_rules": [...],
+ "subscribed_rulebooks": [...],
+ "applicable_rules_truncated": bool,
+ "project_goal": str,
+ "open_task_count": int,
+ }
+ """
+ project = await projects_svc.get_project(user_id, project_id)
+ if project is None:
+ raise ValueError(f"project {project_id} not found")
+
+ note = await notes_svc.create_note(
+ user_id,
+ title=title,
+ body=PLAN_TEMPLATE,
+ status="todo",
+ task_kind="plan",
+ project_id=project_id,
+ )
+
+ applicable = await rulebooks_svc.get_applicable_rules(
+ project_id=project_id, user_id=user_id,
+ )
+ _, open_count = await notes_svc.list_notes(
+ user_id, is_task=True, status="todo", project_id=project_id, limit=1,
+ )
+
+ return {
+ "task": note.to_dict(),
+ "applicable_rules": applicable["rules"],
+ "subscribed_rulebooks": applicable["subscribed_rulebooks"],
+ "applicable_rules_truncated": applicable["truncated"],
+ "project_goal": getattr(project, "goal", "") or "",
+ "open_task_count": open_count,
+ }
diff --git a/tests/test_services_planning.py b/tests/test_services_planning.py
new file mode 100644
index 0000000..b831f7a
--- /dev/null
+++ b/tests/test_services_planning.py
@@ -0,0 +1,46 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+
+@pytest.mark.asyncio
+async def test_start_planning_creates_plan_task_and_returns_rules():
+ fake_note = MagicMock()
+ fake_note.to_dict.return_value = {"id": 5, "title": "Plan it", "task_kind": "plan"}
+ applicable = {
+ "rules": [{"id": 1, "title": "dev is home", "statement": "...",
+ "topic_title": "git-workflow", "rulebook_title": "FabledSword family"}],
+ "truncated": False,
+ "subscribed_rulebooks": [{"id": 2, "title": "FabledSword family"}],
+ }
+ with patch("fabledassistant.services.planning.notes_svc.create_note",
+ AsyncMock(return_value=fake_note)) as mock_create, \
+ patch("fabledassistant.services.planning.rulebooks_svc.get_applicable_rules",
+ AsyncMock(return_value=applicable)), \
+ patch("fabledassistant.services.planning.notes_svc.list_notes",
+ AsyncMock(return_value=([], 3))), \
+ patch("fabledassistant.services.planning.projects_svc.get_project",
+ AsyncMock(return_value=MagicMock(goal="ship it"))):
+ from fabledassistant.services.planning import start_planning
+ out = await start_planning(user_id=7, project_id=3, title="Plan it")
+
+ # Created a plan-task (status set => task, kind=plan)
+ kwargs = mock_create.call_args.kwargs
+ assert kwargs["task_kind"] == "plan"
+ assert kwargs["status"] == "todo"
+ assert kwargs["project_id"] == 3
+ assert "## Goal" in kwargs["body"] # seeded template
+ # Returned shape
+ assert out["task"]["id"] == 5
+ assert out["applicable_rules"][0]["title"] == "dev is home"
+ assert out["subscribed_rulebooks"] == [{"id": 2, "title": "FabledSword family"}]
+ assert out["open_task_count"] == 3
+
+
+@pytest.mark.asyncio
+async def test_start_planning_raises_when_project_not_found():
+ with patch("fabledassistant.services.planning.projects_svc.get_project",
+ AsyncMock(return_value=None)):
+ from fabledassistant.services.planning import start_planning
+ with pytest.raises(ValueError, match="project 999 not found"):
+ await start_planning(user_id=7, project_id=999, title="x")
From 4609abacd85ffb885f01fd35ecee190f4b933af5 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 10:17:20 -0400
Subject: [PATCH 094/118] feat(plan): start_planning MCP tool + get_task rules
augmentation + instructions
---
src/fabledassistant/mcp/server.py | 6 +++
src/fabledassistant/mcp/tools/tasks.py | 33 +++++++++++++-
tests/test_mcp_tool_planning.py | 59 ++++++++++++++++++++++++++
3 files changed, 97 insertions(+), 1 deletion(-)
create mode 100644 tests/test_mcp_tool_planning.py
diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py
index 563ad36..561b93d 100644
--- a/src/fabledassistant/mcp/server.py
+++ b/src/fabledassistant/mcp/server.py
@@ -30,6 +30,12 @@ text (Why / How-to-apply) is available via get_rule(id). You may create new
rules via create_rule when you notice a pattern worth codifying — coordinate
with the operator on whether it belongs in an existing rulebook+topic or a
new one.
+
+Plans are tasks with kind=plan. Begin a plan with start_planning(project_id,
+title) — that seeds a plan template and returns the project's applicable_rules.
+Maintain the plan with the normal task tools (update_task for the body,
+add_task_log for progress); its work-logs are the build record. Build plans in
+Scribe via start_planning, not in local .md files.
"""
diff --git a/src/fabledassistant/mcp/tools/tasks.py b/src/fabledassistant/mcp/tools/tasks.py
index 8c7bbfe..a1ce839 100644
--- a/src/fabledassistant/mcp/tools/tasks.py
+++ b/src/fabledassistant/mcp/tools/tasks.py
@@ -20,6 +20,8 @@ from __future__ import annotations
from fabledassistant.mcp._context import current_user_id
from fabledassistant.services import notes as notes_svc
+from fabledassistant.services import planning as planning_svc
+from fabledassistant.services import rulebooks as rulebooks_svc
from fabledassistant.services import task_logs as task_logs_svc
@@ -56,7 +58,9 @@ async def get_task(task_id: int) -> dict:
"""Fetch a single Scribe task by ID.
Returns id, title, body, status, priority, tags, project_id, milestone_id,
- parent_id, parent_title, due_date, created_at, updated_at.
+ parent_id, parent_title, due_date, created_at, updated_at. For kind=plan
+ tasks, the response also includes applicable_rules + subscribed_rulebooks
+ from the task's project's rulebook subscriptions.
"""
uid = current_user_id()
note = await notes_svc.get_note(uid, task_id)
@@ -69,6 +73,14 @@ async def get_task(task_id: int) -> dict:
if parent is not None:
parent_title = parent.title
data["parent_title"] = parent_title
+
+ if data.get("task_kind") == "plan" and note.project_id:
+ applicable = await rulebooks_svc.get_applicable_rules(
+ project_id=note.project_id, user_id=uid,
+ )
+ data["applicable_rules"] = applicable["rules"]
+ data["subscribed_rulebooks"] = applicable["subscribed_rulebooks"]
+ data["applicable_rules_truncated"] = applicable["truncated"]
return data
@@ -167,6 +179,24 @@ async def add_task_log(task_id: int, content: str) -> dict:
}
+async def start_planning(project_id: int, title: str) -> dict:
+ """Begin a plan in Scribe (the preferred home for plans — not a local .md file).
+
+ Creates a plan-task (a task with kind=plan) seeded with a plan template under
+ the given project, and returns it together with the project's applicable
+ Rulebook rules and brief context. Maintain the plan afterwards with the normal
+ task tools (update_task to edit the body, add_task_log to record progress).
+
+ Args:
+ project_id: The project this plan is for.
+ title: A short title for the plan.
+ """
+ uid = current_user_id()
+ return await planning_svc.start_planning(
+ user_id=uid, project_id=project_id, title=title,
+ )
+
+
def register(mcp) -> None:
for fn in (
list_tasks,
@@ -174,5 +204,6 @@ def register(mcp) -> None:
create_task,
update_task,
add_task_log,
+ start_planning,
):
mcp.tool(name=fn.__name__)(fn)
diff --git a/tests/test_mcp_tool_planning.py b/tests/test_mcp_tool_planning.py
new file mode 100644
index 0000000..ef003df
--- /dev/null
+++ b/tests/test_mcp_tool_planning.py
@@ -0,0 +1,59 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from fabledassistant.mcp._context import _user_id_ctx
+
+
+@pytest.fixture(autouse=True)
+def _bind_user():
+ token = _user_id_ctx.set(7)
+ yield
+ _user_id_ctx.reset(token)
+
+
+@pytest.mark.asyncio
+async def test_start_planning_tool_delegates_to_service():
+ payload = {"task": {"id": 5}, "applicable_rules": [], "subscribed_rulebooks": [],
+ "applicable_rules_truncated": False, "project_goal": "", "open_task_count": 0}
+ with patch("fabledassistant.mcp.tools.tasks.planning_svc.start_planning",
+ AsyncMock(return_value=payload)) as mock:
+ from fabledassistant.mcp.tools.tasks import start_planning
+ out = await start_planning(project_id=3, title="Plan it")
+ assert out["task"]["id"] == 5
+ assert mock.call_args.kwargs == {"user_id": 7, "project_id": 3, "title": "Plan it"}
+
+
+@pytest.mark.asyncio
+async def test_get_task_augments_plan_with_rules():
+ note = MagicMock()
+ note.parent_id = None
+ note.project_id = 3
+ note.to_dict.return_value = {"id": 9, "task_kind": "plan", "project_id": 3}
+ applicable = {"rules": [{"id": 1, "title": "r"}], "truncated": False,
+ "subscribed_rulebooks": [{"id": 2, "title": "rb"}]}
+ with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note",
+ AsyncMock(return_value=note)), \
+ patch("fabledassistant.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
+ AsyncMock(return_value=applicable)):
+ from fabledassistant.mcp.tools.tasks import get_task
+ out = await get_task(task_id=9)
+ assert out["applicable_rules"] == [{"id": 1, "title": "r"}]
+ assert out["subscribed_rulebooks"] == [{"id": 2, "title": "rb"}]
+ assert out["applicable_rules_truncated"] is False
+
+
+@pytest.mark.asyncio
+async def test_get_task_work_kind_has_no_rules():
+ note = MagicMock()
+ note.parent_id = None
+ note.project_id = 3
+ note.to_dict.return_value = {"id": 9, "task_kind": "work", "project_id": 3}
+ with patch("fabledassistant.mcp.tools.tasks.notes_svc.get_note",
+ AsyncMock(return_value=note)), \
+ patch("fabledassistant.mcp.tools.tasks.rulebooks_svc.get_applicable_rules",
+ AsyncMock()) as mock_rules:
+ from fabledassistant.mcp.tools.tasks import get_task
+ out = await get_task(task_id=9)
+ assert "applicable_rules" not in out
+ assert not mock_rules.called
From b250141e15c9c2c7ab7ea9c2f879a81b8bbdd810 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 10:17:38 -0400
Subject: [PATCH 095/118] feat(plan): REST /api/tasks/planning endpoint
---
src/fabledassistant/routes/tasks.py | 19 +++++++++++++++++++
tests/test_routes_planning.py | 12 ++++++++++++
2 files changed, 31 insertions(+)
create mode 100644 tests/test_routes_planning.py
diff --git a/src/fabledassistant/routes/tasks.py b/src/fabledassistant/routes/tasks.py
index 99883a9..85dc591 100644
--- a/src/fabledassistant/routes/tasks.py
+++ b/src/fabledassistant/routes/tasks.py
@@ -16,6 +16,7 @@ from fabledassistant.services.notes import (
list_notes,
update_note,
)
+from fabledassistant.services.planning import start_planning as svc_start_planning
from fabledassistant.services.recurrence import calculate_next_due, validate_recurrence_rule
tasks_bp = Blueprint("tasks", __name__, url_prefix="/api/tasks")
@@ -143,6 +144,24 @@ async def create_task_route():
return jsonify(task.to_dict()), 201
+@tasks_bp.route("/planning", methods=["POST"])
+@login_required
+async def start_planning_route():
+ uid = get_current_user_id()
+ data = await request.get_json() or {}
+ project_id = data.get("project_id")
+ title = (data.get("title") or "").strip()
+ if not project_id or not title:
+ return jsonify({"error": "project_id and title are required"}), 400
+ try:
+ result = await svc_start_planning(
+ user_id=uid, project_id=int(project_id), title=title,
+ )
+ except ValueError as exc:
+ return jsonify({"error": str(exc)}), 404
+ return jsonify(result), 201
+
+
@tasks_bp.route("/", methods=["GET"])
@login_required
async def get_task_route(task_id: int):
diff --git a/tests/test_routes_planning.py b/tests/test_routes_planning.py
new file mode 100644
index 0000000..7c4e63b
--- /dev/null
+++ b/tests/test_routes_planning.py
@@ -0,0 +1,12 @@
+import inspect
+
+
+def test_planning_handler_callable():
+ from fabledassistant.routes import tasks as tasks_routes
+ assert callable(tasks_routes.start_planning_route)
+
+
+def test_planning_service_signature():
+ from fabledassistant.services.planning import start_planning
+ params = inspect.signature(start_planning).parameters
+ assert "user_id" in params and "project_id" in params and "title" in params
From 1d5f49fe3b93113951dbadabc0f2bf6ab2856833 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 10:41:24 -0400
Subject: [PATCH 096/118] feat(plan): frontend startPlanning API + store action
+ task_kind type
---
frontend/src/stores/tasks.ts | 17 ++++++++++++++++-
frontend/src/types/note.ts | 1 +
frontend/src/types/task.ts | 15 +++++++++++++++
3 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts
index 231ee52..a0d73cc 100644
--- a/frontend/src/stores/tasks.ts
+++ b/frontend/src/stores/tasks.ts
@@ -2,7 +2,7 @@ import { ref } from "vue";
import { defineStore } from "pinia";
import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from "@/api/client";
import { useToastStore } from "@/stores/toast";
-import type { Task, TaskListResponse, TaskStatus, TaskPriority } from "@/types/task";
+import type { Task, TaskListResponse, TaskStatus, TaskPriority, StartPlanningResult } from "@/types/task";
export const useTasksStore = defineStore("tasks", () => {
const tasks = ref([]);
@@ -129,6 +129,21 @@ export const useTasksStore = defineStore("tasks", () => {
}
}
+ async function startPlanning(
+ projectId: number,
+ title: string
+ ): Promise {
+ try {
+ return await apiPost("/api/tasks/planning", {
+ project_id: projectId,
+ title,
+ });
+ } catch (e) {
+ useToastStore().show("Failed to start planning", "error");
+ throw e;
+ }
+ }
+
function setStatusFilter(statuses: TaskStatus[]) {
statusFilter.value = statuses;
offset.value = 0;
diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts
index bceb8e2..d68d2be 100644
--- a/frontend/src/types/note.ts
+++ b/frontend/src/types/note.ts
@@ -22,6 +22,7 @@ export interface Note {
recurrence_next_spawn_at: string | null;
is_task: boolean;
note_type: NoteType;
+ task_kind?: "work" | "plan";
metadata: Record;
created_at: string;
updated_at: string;
diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts
index b7ba893..8b55262 100644
--- a/frontend/src/types/task.ts
+++ b/frontend/src/types/task.ts
@@ -5,6 +5,21 @@ export interface TaskListResponse {
total: number;
}
+export interface StartPlanningResult {
+ task: import("./note").Note;
+ applicable_rules: {
+ id: number;
+ title: string;
+ statement: string;
+ topic_title: string;
+ rulebook_title: string;
+ }[];
+ subscribed_rulebooks: { id: number; title: string }[];
+ applicable_rules_truncated: boolean;
+ project_goal: string;
+ open_task_count: number;
+}
+
export interface TaskLog {
id: number;
task_id: number;
From 75d3d40038836bf239b387a8705dc2959c50cf99 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Thu, 28 May 2026 10:42:03 -0400
Subject: [PATCH 097/118] feat(plan): plan-task viewer shows Applicable Rules
panel
---
.../src/components/rules/PlanRulesPanel.vue | 67 +++++++++++++++++++
frontend/src/views/TaskViewerView.vue | 7 ++
2 files changed, 74 insertions(+)
create mode 100644 frontend/src/components/rules/PlanRulesPanel.vue
diff --git a/frontend/src/components/rules/PlanRulesPanel.vue b/frontend/src/components/rules/PlanRulesPanel.vue
new file mode 100644
index 0000000..4cab350
--- /dev/null
+++ b/frontend/src/components/rules/PlanRulesPanel.vue
@@ -0,0 +1,67 @@
+
+
+
+
+ Applicable rules
+
+
{{ rb }}
+
+
{{ topic }}
+
+
+ {{ r.title }} — {{ r.statement }}
+
+
+
+
+
+ Truncated at 50 rules — more apply.
+
+
+
+
+
diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue
index f9065ef..fa77f60 100644
--- a/frontend/src/views/TaskViewerView.vue
+++ b/frontend/src/views/TaskViewerView.vue
@@ -10,6 +10,7 @@ import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
+import PlanRulesPanel from "@/components/rules/PlanRulesPanel.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
@@ -379,6 +380,12 @@ const subTaskProgress = computed(() => {
@click="onBodyClick"
>
+
+
+
diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue
index fa77f60..f9065ef 100644
--- a/frontend/src/views/TaskViewerView.vue
+++ b/frontend/src/views/TaskViewerView.vue
@@ -10,7 +10,6 @@ import type { Note } from "@/types/note";
import type { TaskStatus } from "@/types/task";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
-import PlanRulesPanel from "@/components/rules/PlanRulesPanel.vue";
import TagPill from "@/components/TagPill.vue";
import TableOfContents from "@/components/TableOfContents.vue";
import ShareDialog from "@/components/ShareDialog.vue";
@@ -380,12 +379,6 @@ const subTaskProgress = computed(() => {
@click="onBodyClick"
>
-
-
-
+
+
+ Trash retention
+ Deleted items move to Trash and can be restored. They're permanently purged after this many days.
+
+
Retention period (days)
+
+
Set to 0 to keep deleted items forever (never auto-purge).
+
+
+
+ {{ savingRetention ? 'Saving…' : 'Save' }}
+
+ Saved!
+
+
+
From 2f577fee58f257b010dd58f53ae76ac8caff01a4 Mon Sep 17 00:00:00 2001
From: Bryan Van Deusen
Date: Fri, 29 May 2026 13:16:38 -0400
Subject: [PATCH 118/118] fix(mcp): stateless HTTP transport so client
reconnects after redeploy
Stateful session manager strands Claude Code after a container redeploy:
it reconnects with a now-unknown Mcp-Session-Id, the server 404s, and the
client won't re-initialize on a 404 (claude-code #60949). Stateless makes
each request self-contained (bearer-auth only) so post-deploy reconnect
works without a manual /mcp retry.
---
src/fabledassistant/mcp/server.py | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/src/fabledassistant/mcp/server.py b/src/fabledassistant/mcp/server.py
index 69871e8..f321c78 100644
--- a/src/fabledassistant/mcp/server.py
+++ b/src/fabledassistant/mcp/server.py
@@ -74,9 +74,18 @@ def build_mcp_server() -> FastMCP:
behind a reverse proxy with bearer-token auth as the real security
boundary.
"""
+ # stateless_http=True: don't hand the client a persistent Mcp-Session-Id.
+ # The stateful default strands Claude Code after a container redeploy —
+ # it reconnects with the now-unknown session id, the server returns 404,
+ # and the client won't re-initialize on a 404 (Claude Code issue #60949),
+ # so the connection stays dead until a manual /mcp retry. Stateless makes
+ # every request self-contained (bearer-auth only), so a post-deploy
+ # reconnect just works. Trade-off: no server-pushed list_changed stream,
+ # which we don't use — tools are re-fetched on reconnect anyway.
mcp = FastMCP(
"scribe",
instructions=_INSTRUCTIONS.strip(),
+ stateless_http=True,
transport_security=TransportSecuritySettings(
enable_dns_rebinding_protection=False,
),