diff options
| author | Guillermo Serrahima <dev@vylion.com> | 2026-06-26 14:39:23 +0200 |
|---|---|---|
| committer | Guillermo Serrahima <dev@vylion.com> | 2026-06-26 14:39:23 +0200 |
| commit | 963f59569437fe43a49ce36a003a143fa2f767c8 (patch) | |
| tree | 968b0dad13193536770ae1e6b580b27e22f9366b | |
| parent | 741a04c2abcbd77451c0933a19d91fddb9982882 (diff) | |
| download | senia-963f59569437fe43a49ce36a003a143fa2f767c8.tar.xz senia-963f59569437fe43a49ce36a003a143fa2f767c8.zip | |
created custom agent loop
| -rw-r--r-- | .dockerignore | 26 | ||||
| -rw-r--r-- | README.md | 171 | ||||
| -rw-r--r-- | agent/README.md | 0 | ||||
| -rw-r--r-- | agent/orchestrator/__init__.py | 6 | ||||
| -rw-r--r-- | agent/orchestrator/api.py | 56 | ||||
| -rw-r--r-- | agent/orchestrator/context.py | 42 | ||||
| -rw-r--r-- | agent/orchestrator/loop.py | 18 | ||||
| -rw-r--r-- | agent/orchestrator/models/__init__.py | 24 | ||||
| -rw-r--r-- | agent/orchestrator/models/base.py | 44 | ||||
| -rw-r--r-- | agent/orchestrator/models/claude_code.py | 68 | ||||
| -rw-r--r-- | agent/pyproject.toml | 24 | ||||
| -rw-r--r-- | agent/uv.lock | 690 | ||||
| -rw-r--r-- | docker/Dockerfile | 62 | ||||
| -rw-r--r-- | docker/compose.yml | 16 | ||||
| -rw-r--r-- | mcp/senia/server.py | 8 |
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:384c17174464c8e873398b7af24f0b1f4 |
