diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4ff4ecb..8e3b446 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -42,6 +42,11 @@ on: - "assets/**" - "fable-mcp/**" - ".forgejo/workflows/ci.yml" + # Manual trigger from the Forgejo Actions UI. Useful when an image has + # been built but the deployment didn't pick it up, or when re-running + # against the same source produces different upstream behaviour + # (e.g. a transient HF download flake during the voice-bundle step). + workflow_dispatch: {} # Cancel older runs on the same branch when a newer push lands. Tag runs # get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and @@ -64,7 +69,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 +93,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 +107,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 +143,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 @@ -141,15 +154,25 @@ jobs: - name: Generate image tags and version id: tags + # POSIX `case` instead of bash `[[ ]]` because act_runner invokes + # `sh -e` (dash on the ci-python:3.14 image, which has no bash on + # the default PATH for /bin/sh). Previous `[[ ]]` form failed + # silently — only the SHA tag got appended, so :dev / :latest + # never updated in the registry and the deployed stack kept + # pulling stale images. Verified via `[[: not found` lines in + # the runner log on commit 2a374d9. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" BUILD_VERSION="dev" - if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then - TAGS="$TAGS,${{ env.IMAGE }}:dev" - elif [[ "${{ github.ref }}" == refs/tags/* ]]; then - TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" - BUILD_VERSION="${{ github.ref_name }}" - fi + case "${{ github.ref }}" in + refs/heads/dev) + TAGS="$TAGS,${{ env.IMAGE }}:dev" + ;; + refs/tags/*) + TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" + BUILD_VERSION="${{ github.ref_name }}" + ;; + esac echo "value=$TAGS" >> $GITHUB_OUTPUT echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid index 9bc105c..4724b9a 100644 --- a/.remember/tmp/save-session.pid +++ b/.remember/tmp/save-session.pid @@ -1 +1 @@ -3158517 +632037 diff --git a/Dockerfile b/Dockerfile index 931d8cd..37fcb07 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,27 +8,14 @@ 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 . 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 - -# 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 . 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/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/alembic/versions/0048_conversation_last_curator_run.py b/alembic/versions/0048_conversation_last_curator_run.py new file mode 100644 index 0000000..302f57d --- /dev/null +++ b/alembic/versions/0048_conversation_last_curator_run.py @@ -0,0 +1,47 @@ +"""conversations.last_curator_run_at — tracks scheduler progress per-conversation + +Revision ID: 0048 +Revises: 0047 +Create Date: 2026-05-22 + +Phase 2 of the conversation+curator architecture (Fable #172). The +scheduler runs every 15 minutes and processes any journal conversation +with messages newer than its `last_curator_run_at` timestamp. NULL +means "never run; process all of today on first sweep." +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0048" +down_revision = "0047" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "conversations", + sa.Column( + "last_curator_run_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + # Indexed because the scheduler's selection query is + # WHERE conversation_type='journal' AND (last_curator_run_at IS NULL OR ...) + # which benefits from a partial index narrowed to journal rows. + op.create_index( + "ix_conversations_journal_last_curator", + "conversations", + ["last_curator_run_at"], + postgresql_where=sa.text("conversation_type = 'journal'"), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_conversations_journal_last_curator", + table_name="conversations", + ) + op.drop_column("conversations", "last_curator_run_at") diff --git a/alembic/versions/0049_conversation_curator_summary.py b/alembic/versions/0049_conversation_curator_summary.py new file mode 100644 index 0000000..6e1cea0 --- /dev/null +++ b/alembic/versions/0049_conversation_curator_summary.py @@ -0,0 +1,36 @@ +"""conversations.curator_summary — feeds curator output back to the chat model + +Revision ID: 0049 +Revises: 0048 +Create Date: 2026-05-22 + +Phase 3 of the conversation+curator architecture (Fable #172). The +curator's final summary line (≤ 240 chars) is persisted here so the +NEXT chat turn can include it in the system prompt. This is the +feedback loop that closes the architecture — without it, the chat +model has no awareness of what the curator extracted, and conversations +risk circling back to topics already captured. + +NULL means "no curator pass has produced a summary yet." The chat +pipeline reads this column when building the journal system prompt; +missing column = no injection, no failure. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0049" +down_revision = "0048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "conversations", + sa.Column("curator_summary", sa.Text, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("conversations", "curator_summary") diff --git a/alembic/versions/0050_drop_think_enabled_setting.py b/alembic/versions/0050_drop_think_enabled_setting.py new file mode 100644 index 0000000..9b58d0b --- /dev/null +++ b/alembic/versions/0050_drop_think_enabled_setting.py @@ -0,0 +1,27 @@ +"""drop stored think_enabled rows — setting was removed + +Revision ID: 0050 +Revises: 0049 +Create Date: 2026-05-23 + +The `think_enabled` user setting was retired with the chat+curator +architecture: chat has tools=[] and curator hardcodes think=False, so +the toggle was dead weight. Any rows already in `settings` for that +key are now unread by the app; this migration clears them. +""" +from alembic import op + + +revision = "0050" +down_revision = "0049" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DELETE FROM settings WHERE key = 'think_enabled'") + + +def downgrade() -> None: + # No-op. The setting is dead; restoring NULL rows wouldn't help. + pass diff --git a/alembic/versions/0051_pending_curator_actions.py b/alembic/versions/0051_pending_curator_actions.py new file mode 100644 index 0000000..eedd1d8 --- /dev/null +++ b/alembic/versions/0051_pending_curator_actions.py @@ -0,0 +1,98 @@ +"""pending_curator_actions — curator-proposed mutations awaiting user approval + +Revision ID: 0051 +Revises: 0050 +Create Date: 2026-05-23 + +Architecture: the curator runs unattended and can be confidently wrong. +Additive operations (create_*, record_moment, log_work, save_person) +land directly because adds are easily undone. Mutating operations +(update_*, delete_*) are proposed to this table instead — the user +sees them in a "Needs Review" panel and approves or rejects each one. + +A proposed action stores: + - The action type (`update_task`, `delete_note`, etc.) — drives which + handler gets re-executed on approval. + - The target id + type for display ("update task 42", "delete note 17"). + - The proposed arguments (`payload`) — what the curator wanted to do. + - A snapshot of the target's state at proposal time (`current_snapshot`) + — so the review UI can render a real before/after diff even if other + work modified the entity since. + - The status (`pending` / `approved` / `rejected`) and review timestamp. + +Approval replays the original tool call via execute_tool with +authority="user" so the request bypasses the curator interceptor and +just runs. +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB + + +revision = "0051" +down_revision = "0050" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "pending_curator_actions", + 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="SET NULL"), + nullable=True, + ), + sa.Column("action_type", sa.Text, nullable=False), + sa.Column("target_type", sa.Text, nullable=True), + sa.Column("target_id", sa.Integer, nullable=True), + sa.Column("target_label", sa.Text, nullable=True), + sa.Column("payload", JSONB, nullable=False, server_default=sa.text("'{}'::jsonb")), + sa.Column( + "current_snapshot", + JSONB, + nullable=False, + server_default=sa.text("'{}'::jsonb"), + ), + sa.Column( + "status", + sa.Text, + nullable=False, + server_default=sa.text("'pending'"), + ), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("reviewed_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint( + "status IN ('pending', 'approved', 'rejected')", + name="pending_curator_actions_status_check", + ), + ) + # Pending-only index — the Needs Review panel fetches "user's pending" + # constantly, history rows just accumulate. + op.create_index( + "ix_pending_curator_actions_user_pending", + "pending_curator_actions", + ["user_id", sa.text("created_at DESC")], + postgresql_where=sa.text("status = 'pending'"), + ) + + +def downgrade() -> None: + op.drop_index( + "ix_pending_curator_actions_user_pending", + table_name="pending_curator_actions", + ) + op.drop_table("pending_curator_actions") diff --git a/alembic/versions/0052_clear_note_embeddings_for_fastembed.py b/alembic/versions/0052_clear_note_embeddings_for_fastembed.py new file mode 100644 index 0000000..e60eac0 --- /dev/null +++ b/alembic/versions/0052_clear_note_embeddings_for_fastembed.py @@ -0,0 +1,33 @@ +"""clear note_embeddings for fastembed swap + +Revision ID: 0052 +Revises: 0051 +Create Date: 2026-05-26 + +Embeddings stored in `note_embeddings.embedding` (JSONB) were generated by +Ollama's nomic-embed-text model (768-dim). The fastembed swap uses +BAAI/bge-small-en-v1.5 (384-dim). Mixed-dim vectors would break the +Python-side cosine similarity (length mismatch), so we wipe the table. + +The startup hook `services.embeddings.backfill_note_embeddings` regenerates +everything at the new dimension on next boot. There's no column-type change +because the column is JSONB — dimensionless on the storage side. + +Downgrade is the same operation: a fresh deployment of the prior code would +backfill with the old model. No data is lost that the next boot won't restore. +""" +from alembic import op + + +revision = "0052" +down_revision = "0051" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute("DELETE FROM note_embeddings") + + +def downgrade() -> None: + op.execute("DELETE FROM note_embeddings") 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." + ) 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/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") 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") diff --git a/alembic/versions/0057_soft_delete.py b/alembic/versions/0057_soft_delete.py new file mode 100644 index 0000000..25767ac --- /dev/null +++ b/alembic/versions/0057_soft_delete.py @@ -0,0 +1,38 @@ +"""soft-delete: deleted_at + deleted_batch_id on 7 tables + +Revision ID: 0057 +Revises: 0056 +Create Date: 2026-05-28 + +Recoverable deletes: each soft-deletable table gains a nullable deleted_at +timestamp (NULL = live) and a deleted_batch_id (text UUID) stamped per delete +operation so a cascaded delete restores as a unit. A daily cron purges rows +past the retention window. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0057" +down_revision = "0056" +branch_labels = None +depends_on = None + +_TABLES = ( + "notes", "events", "projects", "milestones", + "rulebooks", "rulebook_topics", "rules", +) + + +def upgrade() -> None: + for t in _TABLES: + op.add_column(t, sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True)) + op.add_column(t, sa.Column("deleted_batch_id", sa.Text(), nullable=True)) + op.create_index(f"ix_{t}_deleted_at", t, ["deleted_at"]) + + +def downgrade() -> None: + for t in reversed(_TABLES): + op.drop_index(f"ix_{t}_deleted_at", table_name=t) + op.drop_column(t, "deleted_batch_id") + op.drop_column(t, "deleted_at") diff --git a/ci-requirements.md b/ci-requirements.md new file mode 100644 index 0000000..3f7aad2 --- /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` 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/docker-compose.prod.yml b/docker-compose.prod.yml index 890d0df..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" @@ -24,6 +22,7 @@ services: db: image: postgres:16-alpine + stop_grace_period: 120s volumes: - pgdata:/var/lib/postgresql/data environment: @@ -32,49 +31,24 @@ 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 - deploy: - restart_policy: - condition: on-failure - max_attempts: 5 - - 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 + retries: 10 + start_period: 180s deploy: - placement: - constraints: - - node.role == worker - resources: - reservations: - devices: - - driver: nvidia - count: all - capabilities: [gpu] restart_policy: condition: on-failure - max_attempts: 5 + delay: 10s + max_attempts: 0 + window: 120s volumes: pgdata: - ollama_models: networks: fabledassistant_backend: diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index e2a9ee5..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')"] @@ -39,44 +36,23 @@ 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 - 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 + retries: 10 + start_period: 180s 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 7be1606..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 @@ -34,37 +25,23 @@ 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 - - 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] + interval: 30s + timeout: 10s + retries: 10 + start_period: 180s volumes: pgdata: - ollama_models: app_data: 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/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/public/sw.js b/frontend/public/sw.js index 5ce54d6..fd5182d 100644 --- a/frontend/public/sw.js +++ b/frontend/public/sw.js @@ -1,45 +1,11 @@ -// Service Worker for push notifications and PWA support -self.addEventListener('push', event => { - if (!event.data) return; - const data = event.data.json(); - const notifUrl = data.url || '/'; +// Service Worker — PWA installability shell only. +// +// The push and notificationclick listeners were removed alongside the chat +// generation pipeline (they only fired when the internal LLM finished a +// response). The empty fetch handler is preserved as the installability +// surface required for "Add to Home Screen" in some browsers; offline +// caching is intentionally not implemented yet. - event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { - // Suppress notification if the user already has the relevant page focused - for (const client of clientList) { - if (client.visibilityState === 'visible' && client.url.includes(notifUrl)) { - return; - } - } - return self.registration.showNotification(data.title || 'Scribe', { - body: data.body || '', - icon: '/favicon.ico', - badge: '/favicon.ico', - data: { url: notifUrl }, - }); - }) - ); -}); - -self.addEventListener('notificationclick', event => { - event.notification.close(); - event.waitUntil( - clients.matchAll({ type: 'window', includeUncontrolled: true }).then(clientList => { - const url = event.notification.data.url; - for (const client of clientList) { - if (client.url.includes(url) && 'focus' in client) { - return client.focus(); - } - } - if (clients.openWindow) { - return clients.openWindow(url); - } - }) - ); -}); - -// PWA: serve cached assets for offline support (minimal) -self.addEventListener('fetch', event => { +self.addEventListener('fetch', () => { // Pass-through — no offline caching in v1 }); diff --git a/frontend/src/App.vue b/frontend/src/App.vue index fd39b8e..1aac6f0 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -5,43 +5,26 @@ import AppHeader from "@/components/AppHeader.vue"; import ToastNotification from "@/components/ToastNotification.vue"; import { useTheme } from "@/composables/useTheme"; import { useShortcuts } from "@/composables/useShortcuts"; -import { useOnnxPreloader } from "@/composables/useOnnxPreloader"; import { useAuthStore } from "@/stores/auth"; -import { useChatStore } from "@/stores/chat"; import { useSettingsStore } from "@/stores/settings"; import { apiGet, apiPut } from "@/api/client"; useTheme(); -const { schedulePreload: scheduleVadPreload } = useOnnxPreloader(); -scheduleVadPreload(); - const router = useRouter(); const appVersion = ref("dev"); const authStore = useAuthStore(); -const chatStore = useChatStore(); const settingsStore = useSettingsStore(); const { showShortcuts, toggleShortcuts, closeShortcuts } = useShortcuts(); function startAppServices() { - chatStore.startStatusPolling(); settingsStore.fetchSettings(); - settingsStore.checkVoiceStatus(); // Sync browser timezone to the server on every login/page load. apiPut("/api/settings", { user_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone }).catch(() => {}); - // Re-check voice status when the tab becomes visible again (model may - // have finished loading while the user was away). - document.addEventListener("visibilitychange", onVisibilityChange); -} - -function onVisibilityChange() { - if (document.visibilityState === "visible" && authStore.isAuthenticated) { - settingsStore.checkVoiceStatus(); - } } function stopAppServices() { - chatStore.stopStatusPolling(); + // no-op for now; kept as a hook for future per-session teardown } function isInputActive(): boolean { @@ -96,9 +79,10 @@ function onGlobalKeydown(e: KeyboardEvent) { case "n": router.push("/notes"); break; case "t": router.push("/"); break; case "p": router.push("/projects"); break; - case "c": router.push("/chat"); break; + case "r": router.push("/rules"); break; case "g": router.push("/graph"); break; case "l": router.push("/calendar"); break; + case "x": router.push("/trash"); break; } return; } @@ -126,13 +110,6 @@ function onGlobalKeydown(e: KeyboardEvent) { e.preventDefault(); document.dispatchEvent(new CustomEvent("shortcut:focus-search")); break; - case "c": - if (router.currentRoute.value.name === "home") { - document.dispatchEvent(new CustomEvent("shortcut:focus-chat")); - } else { - router.push("/chat"); - } - break; } } @@ -163,7 +140,6 @@ watch( onUnmounted(() => { document.removeEventListener("keydown", onGlobalKeydown); - document.removeEventListener("visibilitychange", onVisibilityChange); stopAppServices(); }); @@ -217,14 +193,14 @@ onUnmounted(() => {
g + - c - Chat + l + Calendar
g + - l - Calendar + x + Trash
Esc @@ -267,23 +243,6 @@ onUnmounted(() => { Edit current item (viewer)
-
-
Chat
-
- c - Focus chat (home) / go to chat -
-
- Enter - Send message -
-
- Shift - + - Enter - New line -
-
@@ -328,8 +287,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/api/client.ts b/frontend/src/api/client.ts index 19607c2..c0aa114 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -299,136 +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 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, @@ -588,93 +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 -} - -export interface VoiceBlendEntry { - voice: string - weight: number -} - -export const getVoiceStatus = () => apiGet('/api/voice/status') - -export const getVoiceList = () => - apiGet<{ voices: VoiceEntry[] }>('/api/voice/voices').then(r => r.voices) - -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, - 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). - 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' - 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 { @@ -686,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/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/api/trash.ts b/frontend/src/api/trash.ts new file mode 100644 index 0000000..59ea008 --- /dev/null +++ b/frontend/src/api/trash.ts @@ -0,0 +1,28 @@ +import { apiGet, apiPost, apiDelete } from "@/api/client"; + +export interface TrashItem { + type: string; + id: number; + title: string; +} + +export interface TrashBatch { + batch_id: string; + deleted_at: string | null; + summary: string; + count: number; + items: TrashItem[]; +} + +export async function listTrash(): Promise { + const data = await apiGet<{ batches: TrashBatch[] }>("/api/trash"); + return data.batches; +} + +export async function restoreBatch(batchId: string): Promise { + await apiPost(`/api/trash/${batchId}/restore`, {}); +} + +export async function purgeBatch(batchId: string): Promise { + return apiDelete(`/api/trash/${batchId}`); +} diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index f11789e..5de4c6a 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -4,50 +4,20 @@ 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"; +import { Sun, Moon, Settings, Trash2 } 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,14 @@ router.afterEach(() => { - +