aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.dockerignore26
-rw-r--r--README.md171
-rw-r--r--agent/README.md0
-rw-r--r--agent/orchestrator/__init__.py6
-rw-r--r--agent/orchestrator/api.py56
-rw-r--r--agent/orchestrator/context.py42
-rw-r--r--agent/orchestrator/loop.py18
-rw-r--r--agent/orchestrator/models/__init__.py24
-rw-r--r--agent/orchestrator/models/base.py44
-rw-r--r--agent/orchestrator/models/claude_code.py68
-rw-r--r--agent/pyproject.toml24
-rw-r--r--agent/uv.lock690
-rw-r--r--docker/Dockerfile62
-rw-r--r--docker/compose.yml16
-rw-r--r--mcp/senia/server.py8
15 files changed, 1228 insertions, 27 deletions
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..883f232
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,26 @@
+# .dockerignore at senia/ (the build context root)
+
+# everything runtime-mounted; not needed in the image
+.claude.json
+claude/
+data/
+workspace/
+mcp/
+scripts/
+
+# the docker dir itself; no need to send it back into the build
+docker/
+
+# python dev artifacts
+**/.venv/
+**/__pycache__/
+**/*.pyc
+**/.pytest_cache/
+
+# editor / OS noise
+.git/
+.gitignore
+.vscode/
+**/.DS_Store
+**/*.swp
+**/*.swo
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f2e8b8b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,171 @@
+# Sénia
+
+a personal butler/coder AI assistant.
+
+runs in a docker container with persistent state, an HTTP API for chat, autonomous heartbeat behavior, and a memory system designed to mimic human-style recall (granular recent, fuzzy distant).
+
+## current state
+
+### running
+
+- **docker container, debian trixie**, `restart: unless-stopped`
+- **senia user** (uid 750) with narrow `sudo` (apt/apt-get only)
+- **claude code** as the current LLM backend (subscription-auth via mounted `~/.claude/`), installed system-wide so updates happen via container rebuild rather than self-update
+- **read-only `:ro` mounts** enforce sandbox boundaries even against in-container root
+- **HTTP API** on port 8000, OpenAI-compatible (`/v1/chat/completions`, `/v1/models`)
+
+### the agent loop (`senia/agent/`)
+
+- **fastapi service** running inside the container as the main process (uvicorn)
+- **backend-agnostic design** via a `ModelBackend` strategy pattern: swapping models is a one-line change
+- **current backend:** shells out to `claude -p` for non-interactive turns
+- **dynamic per-turn context** assembled fresh: current time + today's diary entries
+- **persona** handled by the backend natively (claude code autodetects `CLAUDE.md` from the workspace)
+- **dependency management with `uv`**, a single static rust binary, dramatically faster than pip/poetry, uses standard PEP 621 metadata in pyproject.toml
+
+### memory
+
+- **diary log MCP tool:** short timestamped entries in sqlite, agent-authored throughout the day
+- **task tracking MCP tool:** categorized by owner `agent` (to be done autonomously during heartbeats) or `user` (to periodically remind the user of)
+- **single shared db** at `/etc/senia/data/senia.db`, migration-tracked
+- **persona files** (SOUL.md / USER.md / AGENTS.md) for stable identity across model swaps
+
+### autonomous behavior
+
+- **variable heartbeat script** runs every 30 min via host cron + `docker exec`
+- wakes Sénia when there are pending tasks OR more than 2 hours since the last wake
+- captures her response and delivers it via **ntfy** push notification
+- supports the `(quiet)` sentinel for no-op heartbeats
+- runs orthogonal to the HTTP API; the heartbeat directly invokes claude code, doesn't pass through the loop
+
+### tooling for the agent herself
+
+- `uv` (preferred) and `pip --user` for python packages
+- `npm install -g` for node tools (configured to her user-local npm prefix)
+- `sudo apt install` for system packages, narrowly allowed
+
+### public / private repo split
+
+the persona content (`CLAUDE.md`) and saved conversation contexts contain real personal data that shouldn't live in a public portfolio. they're tracked in a separate private companion repository (`senia-private`), referenced as a git submodule at `private/`.
+
+the setup script handles both cases:
+
+- **with access to the private repo:** the submodule pulls down, real persona and contexts are mounted into the container
+- **without access:** placeholder files are generated locally, the container runs in a demonstrable state with a generic persona
+
+both cases produce a fully working system; only the content differs. this preserves portfolio-readability while protecting personal data.
+
+## planned
+
+### chat interface
+
+- **Open WebUI** behind authentik for the primary chat surface (browser + mobile)
+- **Conduit** as the native mobile client (FOSS, flutter, authentik-aware)
+- the loop's HTTP API registered with Open WebUI as a custom OpenAI provider
+- **ntfy** kept for asynchronous Sénia-initiated push (heartbeats, alerts)
+
+### memory expansion
+
+- **daily markdown summaries:** end-of-day compaction job consolidates raw logs into structured daily entries with frontmatter
+- **monthly markdown summaries:** same pattern, dailies into a monthly digest
+- **embeddings layer:** semantic retrieval via `sqlite-vec`, multilingual embedding model running locally (e.g. multilingual-e5-base via ollama)
+- **session-start context injection:** already partially done via the loop; will expand as compaction lands
+
+### model strategy
+
+- **swap the backend to a local-first hybrid:** Qwen3.6 quantized via ollama as the default for english butler tasks
+- **delegation pattern:** catalan inputs + coding tasks routed to a strong API model (Claude Sonnet)
+- **OpenCode** as a tool Sénia can shell out to for LSP-aware code work
+- expected to be a single new `OllamaBackend` class in `agent/orchestrator/models/` + routing logic in the loop
+
+### knowledge
+
+- **DokuWiki** as the private wiki (deferred for later)
+- **Sénia reads via HTTP API**, not direct filesystem; DokuWiki handles link resolution and rendering
+- **drafts to a staging area** for documentation help; user commits to wiki manually, preserving curation quality
+
+## project structure
+
+```
+~/senia/
+├── .gitmodules # references the private submodule
+├── .gitignore
+├── .dockerignore # at project root (the docker build context)
+├── README.md
+├── scripts/
+│ └── setup.sh # bridges public + private repos with fallback
+├── docker/
+│ ├── compose.yml
+│ └── Dockerfile
+├── claude/
+│ └── CLAUDE.example.md # placeholder persona, tracked publicly
+├── workspace/ # mounted as /workspace, Sénia's per-turn cwd
+├── data/ # runtime state, owned by senia user
+│ ├── senia.db # single sqlite db (diary, tasks, future tables)
+│ ├── memory/ # future: daily and monthly summaries
+│ ├── heartbeat/ # heartbeat state + logs
+│ └── scripts/
+│ └── heartbeat.py # variable cron-driven wake-up
+├── mcp/ # MCP server source, mounted :ro
+│ └── senia/
+│ ├── server.py
+│ ├── db.py
+│ ├── memory/
+│ │ └── diary.py
+│ ├── tasks.py
+│ └── migrations/
+├── private/ # submodule: real CLAUDE.md + contexts/
+│ # (gitignored except for .gitmodules entry)
+└── agent/ # the orchestration loop, baked into the image
+ ├── pyproject.toml
+ ├── uv.lock
+ ├── README.md
+ └── orchestrator/
+ ├── api.py # fastapi entrypoint
+ ├── loop.py # per-turn orchestration
+ ├── context.py # dynamic per-turn context builder
+ └── models/
+ ├── base.py # ModelBackend ABC, ModelRequest/Response types
+ └── claude_code.py # current backend
+```
+
+## working with the project
+
+### first-time setup
+
+```bash
+git clone git@github.com:vylion/senia.git
+cd senia
+./scripts/setup.sh # initializes private submodule or generates placeholders
+cd docker
+docker compose build
+docker compose up -d
+docker compose logs -f senia
+```
+
+### updating agent code
+
+the agent code (everything under `agent/`) is **baked into the image at build time**, not mounted at runtime. so changes to `agent/` require rebuilding:
+
+```bash
+cd ~/senia/docker
+docker compose build senia
+docker compose up -d
+```
+
+thanks to docker's layer caching (dependencies are copied/installed in a separate step from application code), rebuilds where only python code changed complete in seconds. only changes to `pyproject.toml` or `uv.lock` trigger a full dependency reinstall.
+
+other things that don't require rebuild:
+
+- the persona files, mcp server code, data, and workspace: all mounted at runtime
+- the heartbeat script (lives on the host, runs via `docker exec`)
+
+## design principles
+
+- **own the loop where dynamic context injection matters; use mature tools where they earn their keep** (e.g. OpenCode for LSP-aware coding when added, Open WebUI for chat UI polish)
+- **memory hierarchy mirrors human recall:** granular recent, compressed distant, semantic retrieval on demand
+- **model is swappable; system stays the same:** persona, tools, and memory all model-agnostic
+- **container is the security boundary:** in-container non-root user + `:ro` mounts give Sénia freedom in her sandbox without compromising host
+- **diary as audit trail:** installations, decisions, and observations all logged, so the user can review what's been changed
+- **orthogonal channels:** chat goes through the loop's HTTP API; heartbeats invoke claude code directly. each channel uses the simplest path that works
+- **separation of public code and private data:** architecture is open, content is private, both versioned
diff --git a/agent/README.md b/agent/README.md
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/agent/README.md
diff --git a/agent/orchestrator/__init__.py b/agent/orchestrator/__init__.py
new file mode 100644
index 0000000..f2260e3
--- /dev/null
+++ b/agent/orchestrator/__init__.py
@@ -0,0 +1,6 @@
+"""Sénia's orchestration kernel.
+
+The thin layer that assembles per-turn context, dispatches to a model backend,
+and returns the response. Backend-agnostic: switching models is a one-line
+change in `models/__init__.py`.
+"""
diff --git a/agent/orchestrator/api.py b/agent/orchestrator/api.py
new file mode 100644
index 0000000..c3582d4
--- /dev/null
+++ b/agent/orchestrator/api.py
@@ -0,0 +1,56 @@
+"""OpenAI-compatible HTTP API for Sénia.
+
+Open WebUI (and other OpenAI-API clients) talks to this endpoint set. We accept
+the standard `/v1/chat/completions` shape, translate to internal types, hand
+off to the orchestrator, and translate the response back to OpenAI shape.
+"""
+
+from fastapi import FastAPI, HTTPException
+from pydantic import BaseModel, ConfigDict
+
+from .loop import handle_turn
+from .models import Message
+
+
+app = FastAPI(title="Sénia")
+
+
+class ChatMessage(BaseModel):
+ role: str
+ content: str
+
+
+class ChatCompletionRequest(BaseModel):
+ model_config = ConfigDict(extra="allow")
+ model: str
+ messages: list[ChatMessage]
+
+
+@app.get("/v1/models")
+def list_models():
+ return {
+ "object": "list",
+ "data": [{"id": "senia", "object": "model", "owned_by": "local"}],
+ }
+
+
+@app.post("/v1/chat/completions")
+def chat_completions(req: ChatCompletionRequest):
+ try:
+ response_text = handle_turn(
+ messages=[Message(role=m.role, content=m.content) for m in req.messages],
+ )
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=str(e))
+ return {
+ "id": "chat-1",
+ "object": "chat.completion",
+ "model": req.model,
+ "choices": [
+ {
+ "index": 0,
+ "message": {"role": "assistant", "content": response_text},
+ "finish_reason": "stop",
+ }
+ ],
+ }
diff --git a/agent/orchestrator/context.py b/agent/orchestrator/context.py
new file mode 100644
index 0000000..f8679ff
--- /dev/null
+++ b/agent/orchestrator/context.py
@@ -0,0 +1,42 @@
+"""Builds the dynamic per-turn context.
+
+This handles things that change per turn: the current time, today's diary
+entries, open tasks. Persona is NOT here; that's a backend configuration
+concern (see models/claude_code.py docstring).
+"""
+
+import sqlite3
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+SENIA_DB = Path("/etc/senia/data/senia.db")
+
+
+def _today_diary_entries() -> list[str]:
+ if not SENIA_DB.exists():
+ return []
+ now = datetime.now(timezone.utc).astimezone()
+ start = now.replace(hour=0, minute=0, second=0, microsecond=0).isoformat()
+ conn = sqlite3.connect(SENIA_DB)
+ try:
+ return [
+ f"- {row[0]} - {row[1]}"
+ for row in conn.execute(
+ "SELECT ts, content FROM diary_logs WHERE ts >= ? ORDER BY ts",
+ (start,),
+ ).fetchall()
+ ]
+ finally:
+ conn.close()
+
+
+def build_turn_context() -> str:
+ diary = _today_diary_entries()
+ diary_section = (
+ "## Today's diary so far\n\n" + "\n".join(diary)
+ if diary
+ else "## Today's diary so far\n\n(no entries yet today)"
+ )
+ now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
+ return f"## Current time\n\n{now}\n\n{diary_section}"
diff --git a/agent/orchestrator/loop.py b/agent/orchestrator/loop.py
new file mode 100644
index 0000000..ed2c0a8
--- /dev/null
+++ b/agent/orchestrator/loop.py
@@ -0,0 +1,18 @@
+"""Per-turn orchestration.
+
+A turn is: gather dynamic context, hand the conversation to the backend, return
+the response. As capabilities grow (memory recall, routing per intent, channel
+adapters) they hang off this function.
+"""
+
+from .context import build_turn_context
+from .models import Message, ModelRequest, call_model
+
+
+def handle_turn(messages: list[Message]) -> str:
+ request = ModelRequest(
+ messages=messages,
+ turn_context=build_turn_context(),
+ )
+ response = call_model(request)
+ return response.content
diff --git a/agent/orchestrator/models/__init__.py b/agent/orchestrator/models/__init__.py
new file mode 100644
index 0000000..10b2b5a
--- /dev/null
+++ b/agent/orchestrator/models/__init__.py
@@ -0,0 +1,24 @@
+"""The public surface for model invocation.
+
+Other modules import `call_model` and use it without knowing which backend is
+configured. Switching backends means changing one assignment here.
+"""
+
+from .base import Message, ModelBackend, ModelRequest, ModelResponse
+from .claude_code import ClaudeCodeBackend
+
+_backend: ModelBackend = ClaudeCodeBackend()
+
+
+def call_model(request: ModelRequest) -> ModelResponse:
+ """Invoke the configured model backend."""
+ return _backend.call(request)
+
+
+__all__ = [
+ "Message",
+ "ModelBackend",
+ "ModelRequest",
+ "ModelResponse",
+ "call_model",
+]
diff --git a/agent/orchestrator/models/base.py b/agent/orchestrator/models/base.py
new file mode 100644
index 0000000..4ca1988
--- /dev/null
+++ b/agent/orchestrator/models/base.py
@@ -0,0 +1,44 @@
+"""Backend-agnostic model interface.
+
+Concrete backends (Claude Code, Ollama, Anthropic API direct, OpenCode, etc.)
+implement `ModelBackend.call`. The orchestration code only ever talks to this
+interface, never to a specific backend.
+
+Persona is NOT carried in ModelRequest; that's a backend-level configuration
+concern (some backends autodiscover persona from a workspace, others need to
+be told). What flows through here is per-turn DYNAMIC context only.
+"""
+
+from abc import ABC, abstractmethod
+from dataclasses import dataclass, field
+from typing import Literal
+
+
+Role = Literal["system", "user", "assistant", "tool"]
+
+
+@dataclass
+class Message:
+ role: Role
+ content: str
+
+
+@dataclass
+class ModelRequest:
+ """Everything a backend needs to produce one response."""
+ messages: list[Message]
+ turn_context: str | None = None
+ extras: dict = field(default_factory=dict)
+
+
+@dataclass
+class ModelResponse:
+ """What a backend returns. Kept narrow now, extended later."""
+ content: str
+ extras: dict = field(default_factory=dict)
+
+
+class ModelBackend(ABC):
+ @abstractmethod
+ def call(self, request: ModelRequest) -> ModelResponse:
+ ...
diff --git a/agent/orchestrator/models/claude_code.py b/agent/orchestrator/models/claude_code.py
new file mode 100644
index 0000000..c73df98
--- /dev/null
+++ b/agent/orchestrator/models/claude_code.py
@@ -0,0 +1,68 @@
+"""Claude Code backend.
+
+Shells out to `claude -p` for non-interactive turns. Claude Code handles its
+own persona discovery (CLAUDE.md / SOUL.md from the workspace) and its own MCP
+tool dispatch internally; this backend just formats the prompt and parses
+the response.
+"""
+
+import os
+import subprocess
+from pathlib import Path
+
+from .base import ModelBackend, ModelRequest, ModelResponse
+
+
+_ROLE_LABELS = {
+ "user": "User",
+ "assistant": "Assistant",
+ "tool": "Tool",
+ "system": "System",
+}
+
+
+class ClaudeCodeBackend(ModelBackend):
+ def __init__(
+ self,
+ workspace: Path = Path(os.environ.get("SENIA_WORKSPACE", "/workspace")),
+ timeout: int = 300,
+ allowed_tools: str = "mcp__senia__*",
+ ):
+ self.workspace = workspace
+ self.timeout = timeout
+ self.allowed_tools = allowed_tools
+
+ def call(self, request: ModelRequest) -> ModelResponse:
+ prompt = self._format_prompt(request)
+ result = subprocess.run(
+ [
+ "claude", "-p", prompt,
+ "--output-format", "text",
+ "--allowedTools", self.allowed_tools,
+ ],
+ cwd=self.workspace,
+ capture_output=True,
+ text=True,
+ timeout=self.timeout,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(
+ f"claude exited {result.returncode}: {result.stderr.strip()}"
+ )
+ return ModelResponse(content=result.stdout.strip())
+
+ def _format_prompt(self, request: ModelRequest) -> str:
+ """Flatten messages + dynamic context into a single prompt string.
+
+ Claude Code's -p mode takes one big prompt, not a structured
+ conversation. We render the conversation as a readable transcript with
+ the per-turn context prepended. Persona is NOT here; Claude Code
+ finds it in the workspace itself.
+ """
+ parts = []
+ if request.turn_context:
+ parts.append(f"## Context for this turn\n\n{request.turn_context}\n")
+ for msg in request.messages:
+ parts.append(f"## {_ROLE_LABELS[msg.role]}\n\n{msg.content}\n")
+ parts.append("## Assistant\n\n")
+ return "\n".join(parts)
diff --git a/agent/pyproject.toml b/agent/pyproject.toml
new file mode 100644
index 0000000..7262c04
--- /dev/null
+++ b/agent/pyproject.toml
@@ -0,0 +1,24 @@
+[project]
+name = "senia-agent"
+version = "0.1.0"
+description = "Custom orchestration loop for the Sénia personal AI butler"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+ "fastapi>=0.110",
+ "uvicorn[standard]>=0.27",
+ "pydantic>=2.0",
+]
+
+[dependency-groups]
+dev = [
+ "pytest>=7.0",
+ "httpx>=0.25",
+]
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[tool.hatch.build.targets.wheel]
+packages = ["orchestrator"]
diff --git a/agent/uv.lock b/agent/uv.lock
new file mode 100644
index 0000000..768adbe
--- /dev/null
+++ b/agent/uv.lock
@@ -0,0 +1,690 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+
+[[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.14.1"
+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/3b/72/5562aabb8dd7181e8e860622a38bea08d17842b99ecd4c91f84ac95251b0/anyio-4.14.1.tar.gz", hash = "sha256:8d648a3544c1a700e3ff78615cd679e4c5c3f149904287e73687b2596963629e", size = 254831, upload-time = "2026-06-24T20:56:06.017Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b0/7b/90df4a0a816d98d6ea26f559d87836d494a2cf1fcf063be67df50a7bcc30/anyio-4.14.1-py3-none-any.whl", hash = "sha256:4e5533c5b8ff0a24f5d7a176cbe6877129cd183893f66b537f8f227d10527d72", size = 124875, upload-time = "2026-06-24T20:56:04.413Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.6.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
+]
+
+[[package]]
+name = "click"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "colorama", marker = "sys_platform == 'win32'" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
+]
+
+[[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 = "fastapi"
+version = "0.138.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "annotated-doc" },
+ { name = "pydantic" },
+ { name = "starlette" },
+ { name = "typing-extensions" },
+ { name = "typing-inspection" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/8a/c9/5e8defe249899c0dc900643695fc07829a67fc88b4ff2cdb03fcbdbf5a4b/fastapi-0.138.1.tar.gz", hash = "sha256:96e3702dce09ee0dce48856135620d3d865ca684a79fe7513fd7b13a12f82862", size = 419646, upload-time = "2026-06-25T15:40:42.115Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" },
+]
+
+[[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 = "httptools"
+version = "0.8.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/f8/d2/c3eedaef57de65c3cc5f8dc244cf12d09c84ad258a479055aad6db23206c/httptools-0.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168", size = 208428, upload-time = "2026-05-25T22:16:59.717Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/94/dfe435d90d0ef61ec0f2cc3d480eef78c59727c6c2ce039f433882f6131a/httptools-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d", size = 113366, upload-time = "2026-05-25T22:17:00.795Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/d4/13025f1a56e615dcb331e0bbe2d9a1143212b58c263385fc5d2e558f5bac/httptools-0.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376", size = 464676, upload-time = "2026-05-25T22:17:02.014Z" },
+ { url = "https://files.pythonhosted.org/packages/bf/95/4c1c26c0b985f8a3331682d802598f14e32dc41bf7509266eb2c04ad4801/httptools-0.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d", size = 464235, upload-time = "2026-05-25T22:17:03.109Z" },
+ { url = "https://files.pythonhosted.org/packages/a2/82/6735be2b0ca527718c431cdb8e5f70c3862c0844a687df0f572c51e11497/httptools-0.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085", size = 449809, upload-time = "2026-05-25T22:17:04.443Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/f9/5811c74f37a758c8a4aa3dc430375119d335947e883efc4664d8f3559a41/httptools-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124", size = 452174, upload-time = "2026-05-25T22:17:05.476Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/94/97b75870dea07b71e3ec535cebe525b08d723152e4c7d13fa887e51f4de2/httptools-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07", size = 90991, upload-time = "2026-05-25T22:17:06.75Z" },
+ { url = "https://files.pythonhosted.org/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
+ { url = "https://files.pythonhosted.org/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
+ { url = "https://files.pythonhosted.org/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
+ { url = "https://files.pythonhosted.org/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/e5/8cfcabc5546e8022f168be28bcdaa128a240a0befdd03b59d558b4f18bd6/httptools-0.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8", size = 205148, upload-time = "2026-05-25T22:17:16.333Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/0e/0fb14848c19a686c8062ff9067c1a48793e3224b47bc5b201535b6036fce/httptools-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c", size = 111368, upload-time = "2026-05-25T22:17:17.586Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/1b/46f1cecf06b9bbde8e4b8c88034ac7908989e5ff7a3a388ef38392949c1f/httptools-0.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7", size = 486447, upload-time = "2026-05-25T22:17:18.564Z" },
+ { url = "https://files.pythonhosted.org/packages/77/00/258bfc0837221f81d9725c45f9b948a6a6b2994a147a4fb66e85100c668f/httptools-0.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d", size = 482448, upload-time = "2026-05-25T22:17:19.912Z" },
+ { url = "https://files.pythonhosted.org/packages/04/ab/d1cef3b5523f4d272a70f42a776c3169a2dddfe3a54de4b2ce4a36341528/httptools-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681", size = 464460, upload-time = "2026-05-25T22:17:20.882Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/48/5d1d072442277bb2b3434e0e60690b8e8c23840ef7de8b6ea54040a536d3/httptools-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683", size = 471312, upload-time = "2026-05-25T22:17:22.085Z" },
+ { url = "https://files.pythonhosted.org/packages/0d/66/b96623b27e51a68199ef4efdda0613cced9233fe3062ac74e50749c5ad37/httptools-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1", size = 90117, upload-time = "2026-05-25T22:17:23.074Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/12/fa3fbf5f9517b273edea2dc982aa82a8c634091e67c590792b729017bc6f/httptools-0.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6", size = 206183, upload-time = "2026-05-25T22:17:24.004Z" },
+ { url = "https://files.pythonhosted.org/packages/30/fc/5e7c4cb443370f2090a3aba0453a07384d29ff66b7435bb90e77e1037599/httptools-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b", size = 112079, upload-time = "2026-05-25T22:17:25.216Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/53/771bd891eb0f236f32145d6a1775777ec85745f3cc983a1f23d1a3b8ddfe/httptools-0.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0", size = 481596, upload-time = "2026-05-25T22:17:26.186Z" },
+ { url = "https://files.pythonhosted.org/packages/62/42/94e15bc68ce3d423243c45d7f1b0c7561f13844f97dc52ae23182fb65628/httptools-0.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e", size = 480865, upload-time = "2026-05-25T22:17:27.542Z" },
+ { url = "https://files.pythonhosted.org/packages/1c/7c/fe2980fc03723272e30f135b62360b075f513dfe7cc73aef36c7f04012bd/httptools-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b", size = 463189, upload-time = "2026-05-25T22:17:28.546Z" },
+ { url = "https://files.pythonhosted.org/packages/15/1b/47fc5fff68acd1bfa20b4734059c9a06cadb88119dcd5258b5b0d21d91c8/httptools-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0", size = 466610, upload-time = "2026-05-25T22:17:29.816Z" },
+ { url = "https://files.pythonhosted.org/packages/60/bd/07b13c93ffd9bec9546e0d43f8e19378dd696dbd278511406bc07371ef1f/httptools-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527", size = 92705, upload-time = "2026-05-25T22:17:31.133Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/c4/121648f68ce066d7bd762d6b6d97e620847642d38d54f3d90ff11d947629/httptools-0.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568", size = 215023, upload-time = "2026-05-25T22:17:32.401Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/b0/312a062ae741ae3e8baa8c8bf20be81b2e67337b259ab4349bebc7b6142e/httptools-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b", size = 117405, upload-time = "2026-05-25T22:17:33.742Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/37/fccd705f795386bb05bf413012fecff2a33e5aa8c2f069096de3e9fd8702/httptools-0.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca", size = 558497, upload-time = "2026-05-25T22:17:34.732Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/39/f172e8003576de35f5ba77ff417cf0e34429d35dc014deef15afa337a72c/httptools-0.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f", size = 571585, upload-time = "2026-05-25T22:17:35.813Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/b9/f5564760af99f3dbbf3f9104dc00e5da27e96cf433c6bdcf77617f70bf3f/httptools-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d", size = 543297, upload-time = "2026-05-25T22:17:37.08Z" },
+ { url = "https://files.pythonhosted.org/packages/99/67/8d9f2c313618e161b82f3873188e7196126da1d6e29688df40eb3997c77a/httptools-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081", size = 539535, upload-time = "2026-05-25T22:17:38.032Z" },
+ { url = "https://files.pythonhosted.org/packages/48/63/b906c01e53f50d432c0defe43ce52764a111dc1bdd028bafbeb54dcfd008/httptools-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77", size = 108209, upload-time = "2026-05-25T22:17:39.473Z" },
+]
+
+[[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 = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
<